Vibe Themer, reviewed
Vibe Themer is a VS Code extension that turns a sentence into a color theme. You type a vibe. "Cozy autumn evening." "Cyberpunk neon city." The extension streams a full theme back from the OpenAI API and applies each color the instant it lands. The editor repaints in front of you, line by line.
It ships on the VS Code Marketplace at v1.1.0, publisher mseeks. The README wears its origin as a badge: every line AI-authored, "not a single manual edit." This document reads the result back. The goal is to understand what it does, then find where the seams show.
The verdict: a C+. The happy path delivers — type a vibe, watch a coherent theme stream in, live. The build is clean, the packaging is correct, and the documentation is unusually thorough for the size. Underneath are the marks of code written without a human in the review loop: an OpenAI key that gets logged in plaintext the first time generation fails, a 1,248-line prompt that pays rent on ~19% duplicated text, a configuration-scope reader that silently does nothing, and zero automated tests hiding behind a green npm test. None of it stops the extension from working. All of it is what a second pair of eyes catches.
The scope is small: ~2,600 lines of TypeScript across 16 files, plus a 1,248-line prompt that is, in a real sense, the actual product. Every file is rendered in full below, so fold them open as you read. The build is green. tsc --noEmit, ESLint, and esbuild all pass, so nothing here is a compile error. The findings are about behavior and structure, and about the distance between what the code says it is and what it does.
The shape of the whole thing
The codebase aims for a three-layer architecture, spelled out in docs/ARCHITECTURE.md and the Copilot instructions. An Extension layer registers commands. A Service layer orchestrates workflows. A pure Core layer holds the domain logic. Files even advertise their layer in the name: *Service.ts, *Core.ts.
One path is load-bearing. The Change Theme command runs the streaming workflow: pick a prompt, ensure a client, then stream lines and write each one straight to VS Code settings. Everything else is a small satellite command, whether that means clearing the key, picking a model, or resetting the theme. Follow the spine and you have the extension.
| File | Lines | Role |
|---|---|---|
extension.ts | 125 | Activation; registers all commands |
themeGenerationService.ts | 433 | The streaming loop — orchestrates generation |
themeCore.ts | 688 | Line parser + settings writer + current-state reader |
openaiCore.ts | 424 | API-key state machine + client lifecycle |
openaiService.ts | 110 | Thin pass-through wrappers over openaiCore |
types/theme.ts | 146 | Domain types (discriminated unions) |
promptPicker.ts | 108 | QuickPick UI for entering a vibe |
suggestionCore.ts | 108 | Curated example prompts + validation |
colorUtils.ts | 108 | Hex/RGB/HSL normalization (unreferenced) |
commands/*.ts | 135 | Clear key · select model · reset theme |
utils/*Test.ts | 250 | Manual dev commands (not automated tests) |
prompts/streamingThemePrompt.txt | 1,248 | The system prompt — every theme selector enumerated |
Following the one path that matters
Act I walks the Change Theme command end to end, in execution order: activation, prompt entry, client setup, the prompt itself, the streaming loop, and the parser/writer that turns a line of text into a repainted editor. This is 90% of what the extension does.
Activation: register first, ask questions later
src/extension.tsactivate() runs once when VS Code loads the extension. Its first job is ordering. Register every command synchronously, then fire the async OpenAI setup without awaiting it. The comment block explains why, and the CHANGELOG confirms it was learned the hard way: v1.0.3 and v1.0.7 were both "command not found" fixes for getting this order wrong.
src/extension.ts · 125 lines
⋯ 38 lines hidden (lines 1–38)
⋯ 72 lines hidden (lines 54–125)
The Change Theme handler is a thin shim. It calls runThemeGenerationWorkflow and passes a mutable { current: ... } reference meant to hold the last generated theme. That ref-cell pattern recurs across the commands, and Act IV shows where it quietly fails. The dev-only test commands register behind an extensionMode === Development check, so end users never see them.
The dev-only command registrations (one carries a latent bug — see Act IV).
src/extension.ts · 125 lines
⋯ 72 lines hidden (lines 1–72)
⋯ 31 lines hidden (lines 95–125)
Getting the vibe: QuickPick + curated seeds
src/utils/promptPicker.tssrc/core/suggestionCore.tsThe workflow opens with showThemePromptPicker(), a VS Code QuickPick seeded with six random curated examples. It also takes free text. Type, and your input becomes the first item, so you can submit anything at all. Pick an item, or accept what you typed, and the promise resolves. Dismiss it and you get undefined, which the caller reads as a cancel.
src/utils/promptPicker.ts · 108 lines
⋯ 43 lines hidden (lines 1–43)
⋯ 30 lines hidden (lines 79–108)
The seeds come from suggestionCore.ts: a frozen array of curated prompts, plus pure helpers that validate and shuffle them. It is the tidiest module in the repo. Small, pure, well-typed. It is also the foundation for a feature that never arrived, which the docs act picks up.
The curated suggestion list and its as const typing.
src/core/suggestionCore.ts · 108 lines
⋯ 13 lines hidden (lines 1–13)
⋯ 67 lines hidden (lines 42–108)
The API key, as a state machine
src/services/openaiCore.tsBefore any generation, ensureOpenAIClient guarantees a usable client. The design is the strongest example of the codebase's type-driven aspirations actually paying off. An API key is not a string — it is an APIKeyState: missing, present, or invalid, each carrying exactly the data that state implies.
The key and client states — invalid combinations are unrepresentable.
src/types/theme.ts · 146 lines
⋯ 109 lines hidden (lines 1–109)
⋯ 12 lines hidden (lines 135–146)
initializeOpenAIClient reads that state and drives the flow: try stored key, prompt the user if missing, validate format (sk- + length), then prove the key works with a live models.list() call before trusting it. Only a user-entered key is written to context.secrets — the encrypted store — and only after it validates.
Init: stored key → prompt → format check → live validation → store.
src/services/openaiCore.ts · 424 lines
⋯ 238 lines hidden (lines 1–238)
⋯ 51 lines hidden (lines 248–298)
⋯ 96 lines hidden (lines 329–424)
The 1,248-line prompt that is the product
prompts/streamingThemePrompt.txtThe model is given one job and a strict wire format. Emit lines, each tagged COUNT:, SELECTOR:, TOKEN:, or MESSAGE:. The prompt opens by teaching intent detection (generate a new theme vs. iterate on an existing one), then a "painter's workflow" ordering — backgrounds first, fine details last — then an exhaustive catalogue of every VS Code theme selector the model may set.
The wire format and the rules. The whole streaming protocol is defined here, in prose.
prompts/streamingThemePrompt.txt · 1247 lines
⋯ 18 lines hidden (lines 1–18)
⋯ 1190 lines hidden (lines 58–1247)
That catalogue is the bulk of the file, and it is where the cracks show. Whole sections are duplicated verbatim. SIDE BAR, EDITOR COLORS, MINIMAP, PROFILES, and EDITOR GROUPS & TABS each appear twice. Every generation pays for that dead weight. Every request, every user, forever.
The second copy of SIDE BAR / MINIMAP — the first is up at lines 268–300.
prompts/streamingThemePrompt.txt · 1247 lines
⋯ 502 lines hidden (lines 1–502)
⋯ 723 lines hidden (lines 525–1247)
The streaming loop
src/services/themeGenerationService.tsrunThemeGenerationWorkflow is the heart of the extension and its longest function. First it ensures the client and gets the prompt. If a theme is already applied, it injects that as context so the model can iterate. Then it opens a streamed chat completion and consumes it inside a cancellable progress notification.
Open the stream, buffer by line, parse-and-apply each setting live.
src/services/themeGenerationService.ts · 434 lines
⋯ 201 lines hidden (lines 1–201)
⋯ 188 lines hidden (lines 247–434)
The buffering is the clever part. Network chunks do not respect line boundaries. So the loop accumulates into a buffer, splits on newlines, and holds the trailing partial line back for the next chunk. Each complete line is parsed and, if valid, applied at once. That is the live-repaint effect. Progress tracks against the model's own COUNT: estimate, or falls back to 120 when none arrives.
Cancellation is handled with care. The token is checked at the top of every chunk and every line. On cancel, the user chooses: keep the partial theme, or reset to the previous state. The error handler at the bottom sorts failures into network, API, or streaming by string-matching the error message, then picks a friendlier note. Brittle, but cosmetic.
Error classification by error.message.includes(...).
src/services/themeGenerationService.ts · 434 lines
⋯ 383 lines hidden (lines 1–383)
⋯ 20 lines hidden (lines 415–434)
Parsing a line, writing a setting
src/services/themeCore.tsthemeCore.ts is the biggest file and wears three hats. It parses a streaming line into a typed StreamingThemeSetting. It applies that setting to VS Code config. It reads the current theme back out for iteration. The parser itself is a clean startsWith cascade returning a discriminated union. Easy to read, easy to extend.
parseStreamingThemeLine — COUNT / SELECTOR / TOKEN / MESSAGE.
src/services/themeCore.ts · 688 lines
⋯ 314 lines hidden (lines 1–314)
⋯ 313 lines hidden (lines 376–688)
applyStreamingThemeSetting is where settings hit the editor. A REMOVE value deletes the override and lets the base theme show through. Anything else merges into the existing customizations object before the write. The scope logic lives here too, and so does one of the more interesting bugs. Act IV takes it apart.
applyStreamingThemeSetting — note the loop over targets that returns on the first success.
src/services/themeCore.ts · 688 lines
⋯ 459 lines hidden (lines 1–459)
⋯ 179 lines hidden (lines 510–688)
The satellites and the seam
Around the spine sit the smaller pieces: the thin OpenAI service wrapper, the three satellite commands, an orphaned color utility, and four files that call themselves tests but are not. Act II is where the structural critiques land.
openaiService: a layer, or an echo?
src/services/openaiService.tsThe architecture promises a Service layer that wraps Core with "public APIs and orchestration." openaiService.ts is meant to be that wrapper. Open it. Almost every function is a one-line forward to its openaiCore twin.
initialize → core, get → core, ensure → core (unwrapping the Result). The pattern repeats.
src/services/openaiService.ts · 110 lines
⋯ 36 lines hidden (lines 1–36)
⋯ 32 lines hidden (lines 79–110)
The three satellite commands
src/commands/resetThemeCommand.tssrc/commands/modelSelectCommand.tssrc/commands/clearApiKeyCommand.tsReset is the most important command in the extension. The README begs users to run it. It is also the simplest: blank both color and token customizations across workspace and global scope with Promise.allSettled, so a failure in one target never blocks the others.
Clear all four (scope × setting) targets, tolerate partial failure.
src/commands/resetThemeCommand.ts · 39 lines
⋯ 8 lines hidden (lines 1–8)
⋯ 15 lines hidden (lines 25–39)
Model selection lists the account's GPT models, filtered to ids starting gpt, and saves the pick to globalState. Clearing the key delegates to resetOpenAIClient. Nothing fancy. All three are small and correct.
src/commands/modelSelectCommand.ts · 43 lines
⋯ 5 lines hidden (lines 1–5)
⋯ 9 lines hidden (lines 35–43)
src/commands/clearApiKeyCommand.ts · 53 lines
⋯ 21 lines hidden (lines 1–21)
⋯ 1 line hidden (lines 53–53)
colorUtils: 108 lines nobody calls
src/utils/colorUtils.tscolorUtils.ts is a competent little color library — normalize any CSS color to hex, detect dark vs. light, compute a contrast color, adjust brightness. The header comment even calls it part of the "Dynamic Theme Changer extension," an earlier name. There is one problem: nothing imports it.
normalizeColor and friends — exported, tested by no one, called by no one.
src/utils/colorUtils.ts · 108 lines
⋯ 7 lines hidden (lines 1–7)
⋯ 55 lines hidden (lines 54–108)
The "tests" that aren't
src/utils/themeStateTest.tssrc/utils/countParsingTest.tsFour files end in Test.ts. None is a test. Each is a manual command that reads some state, or runs a parser over hard-coded inputs, and prints the result to a console or output channel. They fire only when you invoke them by hand, from the palette, in a Development host.
Hard-coded inputs, console.log assertions-by-eyeball. No runner, no pass/fail gate.
src/utils/countParsingTest.ts · 46 lines
⋯ 10 lines hidden (lines 1–10)
Reads current theme state and prints it. A manual inspector.
src/utils/themeStateTest.ts · 54 lines
Re-implements the context-formatting logic to preview what the AI would receive.
src/utils/contextInjectionTest.ts · 94 lines
Runs the parser over REMOVE-valued lines and tallies pass/fail by eye.
src/utils/removeValueTest.ts · 56 lines
Meanwhile the project is wired for real tests. .vscode-test.mjs, @vscode/test-cli, and @vscode/test-electron are all installed, and the test script is defined. But the runner points at out/test/**/*.test.js, and no test/ directory exists. So npm test finds nothing and exits green. The harness is a stage with no actors.
Build, bundle, and the packaging scars
esbuild.js.github/workflows/ci.ymlpackage.jsonesbuild bundles src/extension.ts into one CJS file, with vscode marked external because the host provides it. The two runtime dependencies, openai and zod, get bundled in. That bundling was itself a hard-won fix. CHANGELOG v1.0.8 is titled "MAJOR FIX" for shipping without its dependencies and breaking every install.
esbuild.js · 54 lines
⋯ 25 lines hidden (lines 1–25)
The manifest: 5 user commands + 4 dev commands, activation events, the two runtime deps, and the compile/test scripts.
package.json · 106 lines
CI is honest about its scope. It runs npm ci, then npm run compile (types, lint, bundle) on every push and PR. A comment notes that the electron suite is left out because it needs a headless display. So CI guards compilation and style. It says nothing about behavior.
The whole oracle: install, then compile. No test step (there are no tests to run).
.github/workflows/ci.yml · 30 lines
⋯ 15 lines hidden (lines 1–15)
Documentation: thorough, and ahead of the code
package.jsonFor a 2,600-line extension the documentation is unusually rich. A polished README. An ARCHITECTURE doc. Two ADRs that bother with an alternatives-considered section, a detailed CHANGELOG, and a 300-line Copilot file defining the project's voice. The intent and the craft are real.
But the docs describe a project slightly bigger than the one that exists. ADR-002's Phase 3, AI-generated prompt suggestions, is written up as "next up," and suggestionCore.ts was built as its foundation. It never shipped. Only the curated fallbacks exist, yet the Copilot file still calls it the current frontier. Smaller drifts pile up underneath. The engine version in package.json (^1.74.0) disagrees with the README's "1.68.0 or higher," and the CHANGELOG points at design docs that left the tree.
Confirmed bugs
This review was itself a fan-out. Seven agents swept the code by dimension. Then every behavioral claim went to an adversarial verifier whose only job was to refute it against the source. Thirteen findings survived. But the verifiers are language models. They can read code, not run it, so two of their loudest findings still needed a human check. Both were over-claims. In a project whose identity is "zero manual edits," the review process earns the same scrutiny as the code.
What remains is real. One finding is a security bug to fix today. The rest are a cost leak, a few correctness slips, some UX rough edges. None is catastrophic. And the buffer "double-apply" the panel worried about was refuted: the trailing-buffer flush runs only on clean completion, so it never double-counts.
| Where | What's actually wrong | Impact | |
|---|---|---|---|
| 🔒 Security | themeGenerationService.ts:426 | On any generation error, console.error logs getCurrentClientState(), which holds the plaintext apiKey (openaiCore.ts:49) | The OpenAI key lands in the extension-host console/logs the first time generation fails. The one to fix now. |
| 💸 Cost | streamingThemePrompt.txt | 5 sections duplicated verbatim — PROFILES, SIDE BAR, MINIMAP, EDITOR GROUPS & TABS, EDITOR COLORS | ~19% of every request's input tokens wasted, on every call, forever |
| 🪲 Correctness | themeCore.ts:591–651 | getCurrent*Customizations / …Scope read the same merged key twice via config.get instead of config.inspect | Workspace vs global can't be told apart; harmless today only because nothing user-facing reads scope |
| 🪲 Correctness | extension.ts:49 | Each command passes a fresh { current: lastGeneratedTheme } wrapper, so writes to .current never reach the module variable | The "last generated theme" is never actually remembered; the ref-cell is a no-op |
| 🐞 Dev-only | package.json:75 vs extension.ts:90 | Command id testRemoveValue (manifest) ≠ testRemoveValues (handler) | The dev palette entry resolves to no handler — a one-character typo |
| 🎛️ UX | themeGenerationService.ts:173 | lastMessageTime starts at Date.now(), so the intended === 0 first-message bypass never fires | The first AI progress message can be withheld up to 800 ms |
| 🎛️ UX | themeGenerationService.ts:56–60 | Completeness label uses hardcoded <50 / <80 thresholds unrelated to expectedSettingsCount | A complete 45-setting theme gets mislabeled "Partial" |
State of the project: a scorecard
Across seven dimensions the panel landed on an overall C, nudged to C+ here once the two over-claims above deflated the correctness picture. The pattern is consistent end to end: nothing is broken enough to fail, nothing is finished enough to shine.
| Dimension | Grade | One-line read |
|---|---|---|
| Architecture & structure | C | Coherent happy path and an exemplary suggestionCore, undercut by a passthrough "service" layer, a services→commands backward edge, and dev harnesses living in src/utils |
| Correctness & bugs | C | One real security bug plus a cluster of minor slips; the two scary headline bugs deflated on fact-check |
| Code quality & idioms | C | Genuine discriminated-union typing beside 16 anys, three copies of the scope-construction block, and JSDoc that oversells |
| Testing, CI & packaging | D | Build and packaging are sound; tests are zero, behind a false-green npm test |
| Security & dependencies | C | Good secrets baseline, pulled down by the key-logging leak; end-user dependency risk is actually fine |
| Product, UX & prompt | C | The core experience works; the token tax, modal-heavy flow, and undiscoverable iteration hold it back |
| Docs, hygiene & roadmap | C | Unusually thorough, but describes a slightly bigger project than exists |
What's genuinely good
What should worry you
What to fix, in order
Sequenced by leverage, not by size. The first two are an afternoon's work and buy most of the value.
| # | Effort | Fix |
|---|---|---|
| P0 | S | Stop logging the key. Redact clientState (mask/omit apiKey) before console.error at themeGenerationService.ts:426. Security-critical, one line. |
| P1 | S | De-duplicate the prompt. Delete the second copy of the 5 repeated sections; verify every selector still appears once. Cuts ~19% off every call's input tokens — a recurring cost/latency win. |
| P1 | M | **Make npm test honest.** Add unit tests for parseStreamingThemeLine, color validation, and a package.json↔handler command-id check (which would've caught the testRemoveValue(s) typo). Wire into CI to end the packaging-regression pattern. |
| P1 | M | Fix the scope reader. Rewrite getCurrent*Customizations / …Scope to use config.inspect()'s globalValue / workspaceValue so workspace and global are actually distinguished. |
| P2 | M | Sweep dead code & the redundant layer. Delete colorUtils.ts, applyThemeCustomizations, the ThemeData/lastGeneratedTheme ref (and its no-op wrapper); delete openaiService.ts or give it a job; move the *Test.ts harnesses out of src/utils. |
| P2 | S | Reconcile the docs. Engine version (README ↔ package.json), dead CHANGELOG doc links, the two 002-* ADR files, ARCHITECTURE module list, and mark ADR-002 Phase 3 deferred. Fix the command-id typo and the lastMessageTime init while you're in there. |
| P3 | M | UX polish. Non-modal success popup; a principled completeness signal tied to expectedSettingsCount instead of <50/<80; a discoverable "Refine Current Theme" entry once the scope fix lands. |
| Opt | S | Make the default model configurable with a graceful fallback. gpt-4.1 is valid today, but hardcoding it is fragile if it's ever retired or an account lacks access. |
Can you vouch for it?
For what it is, mostly yes. This is a single-purpose, AI-authored hobby extension that turns sentences into themes. The happy path is coherent, and the code is more disciplined than most things its size. But "vouch" has one hard blocker: the API-key logging. No extension should write a user's secret to a log. Not on an error path, not even locally. Fix that one line and the rest is polish: a prompt to dedupe, a few tests to write, some dead code to sweep.
The deeper lesson is the one the project set out to demonstrate. "Zero manual edits" produced something that compiles, ships, and works. That is a genuine result, and not a small one. It also produced exactly the failures an unsupervised loop produces. A secret in a log. A prompt paying rent on duplicated text. A feature documented but never wired. A test harness with no tests. The repository is the proof of concept and the cautionary tale at once. Even this review, itself AI-run, needed a human to catch where its own reviewers over-reached. The machine gets you most of the way. The last mile is still the one that matters.