What this PR is
This is a ground-up rewrite of Vibe Themer, the extension that turns a natural-language vibe into a VS Code color theme, streaming each setting live from the OpenAI API. The behavior is the same. The foundation is new: a strictly-typed functional core behind a thin VS Code shell.
The shape to keep in mind: a pure center (fp, domain, protocol) that never imports VS Code or OpenAI; a seam of interfaces (ports); use cases that orchestrate through that seam (application); and the real outside world bolted on at the edge (adapters, extension.ts). Dependencies only ever point inward.
The design goal is one sentence: make illegal states unrepresentable, then push every side effect to the edge. A color, a vibe, an API key, a streaming directive — none can exist in an invalid form, because the only way to build one is through a constructor that validates first. The API key is wrapped so it cannot be printed. The streaming protocol is a grammar. Errors are values, never thrown.
Every source file is rendered in full below — fold them open as you read. The build is green: tsc --noEmit at maximum strictness, ESLint, and 49 node:test tests all pass. The findings that shaped the final commits came from an independent multi-agent adversarial review.
The layers, and the one rule
Seven layers, one rule. The rule: an inner layer never imports an outer one. domain knows nothing of ports; application knows the port interfaces but never a concrete adapter; only adapters and extension.ts touch vscode or openai. That is what makes the whole core testable with in-memory fakes.
| Layer | Role | Imports |
|---|---|---|
fp/ | Result · Option · Brand · matchTag · Redacted · parser combinators | nothing |
domain/ | Value objects + smart constructors; illegal states unrepresentable | fp |
protocol/ | The streaming wire format as a grammar | fp, domain |
ports/ | Interfaces for every effect (one Capabilities record) | fp, domain |
application/ | Use cases returning Result (generate, provision, maintenance) | fp, domain, ports |
adapters/ | Real VS Code + OpenAI behind the ports | everything inward |
commands.ts · extension.ts | Typed command registry + composition root | everything |
Errors and absence, as values
src/fp/result.tssrc/fp/option.tsResult<E, A> is the spine. A computation either succeeds with an A or fails with an E, and the failure is an ordinary value in the type — so the pure core never throws, and every function's signature tells you how it can fail. The combinators (map, flatMap, match) are data-last, which is what lets call sites read as pipe(x, map(...), flatMap(...)).
src/fp/result.ts · 80 lines
⋯ 9 lines hidden (lines 1–9)
⋯ 28 lines hidden (lines 53–80)
Option<A> is the same idea for absence; AsyncResult<E, A> is just Promise<Result<E, A>> with combinators so async effects sequence without try/catch. function.ts provides pipe.
src/fp/option.ts · 56 lines
tryCatch handles a rejected promise AND a synchronous throw (a fix from the review).
src/fp/asyncResult.ts · 57 lines
src/fp/function.ts · 64 lines
Three primitives that do the heavy lifting
src/fp/brand.tssrc/fp/match.tssrc/fp/redacted.tsBrand gives nominal types: a Brand<string, 'Vibe'> is a string the compiler refuses to accept anywhere a bare string is passed. Combined with smart constructors, holding a branded value is proof it's valid.
src/fp/brand.ts · 13 lines
⋯ 7 lines hidden (lines 1–7)
matchTag is exhaustive pattern matching over a _tag union: miss a case and the handlers object fails to type-check. There is no default to silently swallow a variant added later. assertNever marks the genuinely-unreachable.
src/fp/match.ts · 29 lines
⋯ 10 lines hidden (lines 1–10)
⋯ 1 line hidden (lines 29–29)
Redacted is the security primitive. A secret lives behind a module-private symbol; toString and toJSON both render <redacted>, so it can be threaded through logs and JSON.stringify without leaking. The raw value comes out only through expose, which is trivial to grep.
src/fp/redacted.ts · 27 lines
⋯ 19 lines hidden (lines 1–19)
src/fp/nonEmpty.ts · 19 lines
The single import surface (the prelude).
src/fp/index.ts · 36 lines
A tiny parser-combinator kernel
src/fp/parser.tsA Parser<A> consumes the front of a string. It yields a value and the rest, or an error. Small parsers compose into bigger ones via map, then, oneOf, and flatMap. That substrate is what lets the streaming protocol read as a grammar rather than a tangle of startsWith and split calls.
src/fp/parser.ts · 113 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 51 lines hidden (lines 63–113)
Smart constructors: the only way in
src/domain/color.tsEvery domain value follows one pattern. A branded type, plus a parse* function that is its only constructor. ColorValue shows it best: a tagged union of Hex, Named, and the Remove sentinel used during iteration, reachable only through parseColor. An arbitrary string can never become one.
src/domain/color.ts · 65 lines
⋯ 15 lines hidden (lines 1–15)
toApplication turns a ColorValue into what the config layer needs: a value to set, or a key to delete. The same shape repeats across the other value objects below. Each validates on the way in and hands back a branded type.
src/domain/vibe.ts · 26 lines
src/domain/selector.ts · 28 lines
src/domain/tokenScope.ts · 25 lines
Tightened vs v1: validates none / italic / bold / underline / strikethrough combos.
src/domain/fontStyle.ts · 35 lines
Default model gpt-4.1 (a valid, current id).
src/domain/model.ts · 20 lines
Validation errors as a tagged union, rendered in one place.
src/domain/errors.ts · 43 lines
The API key, validated and pre-wrapped
src/domain/apiKey.tsparseApiKey checks the sk- prefix and the length, the same rules as v1. What it returns is not a bare key but a Redacted<ApiKey>. So a key is always well-formed, and always wrapped. The error variants carry no raw material either, so a failed validation can't echo the key back.
src/domain/apiKey.ts · 42 lines
⋯ 15 lines hidden (lines 1–15)
The directive union and the theme model
src/domain/directive.tssrc/domain/theme.tsStreamingDirective is the typed form of one protocol line: Count, Selector, Token, or Message. Downstream code matches it exhaustively. ThemeSetting is the applicable subset (selector/token); settingOf maps a directive to Some(setting) or None.
src/domain/directive.ts · 55 lines
⋯ 15 lines hidden (lines 1–15)
⋯ 11 lines hidden (lines 45–55)
CurrentTheme is the snapshot read back for iteration, and it fixes a v1 bug by construction: it keeps the global and workspace scopes separate. v1 read both from the same merged value, so scope detection was meaningless. Here effectiveScope can actually tell them apart.
src/domain/theme.ts · 86 lines
⋯ 59 lines hidden (lines 1–59)
Write preference: prefer global, fall back to workspace.
src/domain/scope.ts · 33 lines
Completeness relative to the model's estimate, not fixed thresholds.
src/domain/coverage.ts · 26 lines
The merge logic, pure and testable
src/domain/customizations.tsIn v1 this logic lived tangled inside the config adapter, untestable without the editor host. Here it's two pure functions. applyColor sets or deletes a single selector. applyTokenRule updates the rules for a scope, and correctly handles array-valued scopes (a case the review caught). The adapter is left as a thin read-merge-write.
src/domain/customizations.ts · 63 lines
⋯ 23 lines hidden (lines 1–23)
The wire format as a grammar
src/protocol/streamingParser.tsThe model emits one directive per line. streamingParser recognizes the structure with the combinators, then validates each field through the domain constructors. A parsed directive is therefore fully well-typed; a bad color can't ride inside one. Two failure channels stay distinct: a line that matches no directive, and a recognized directive whose fields don't validate. Both are recoverable. The loop tolerates a handful before it gives up.
src/protocol/streamingParser.ts · 167 lines
⋯ 44 lines hidden (lines 1–44)
⋯ 91 lines hidden (lines 77–167)
The seam
src/ports/ports.tsEvery interaction with the outside world is an interface: the OpenAI gateway, config, secrets, preferences, prompts, UI, clock, logger. They bundle into one Capabilities record the use cases receive. This is the line that makes the core testable. Swap the real adapters for fakes and the same use cases run with no VS Code and no network.
src/ports/ports.ts · 140 lines
⋯ 39 lines hidden (lines 1–39)
⋯ 62 lines hidden (lines 79–140)
Port error unions + their UserMessage rendering, centralized.
src/ports/errors.ts · 104 lines
The Capabilities record.
src/ports/index.ts · 29 lines
Provisioning the key
src/application/provisionApiKey.tsA small, explicit flow: a well-formed stored key is trusted (no network round-trip on every command); a malformed one falls through to a prompt; an absent one is prompted, verified against OpenAI, then stored. The result is a Redacted<ApiKey>. Benign exits (the user dismissing the prompt) render to None, so the command layer shows nothing.
src/application/provisionApiKey.ts · 95 lines
⋯ 39 lines hidden (lines 1–39)
⋯ 17 lines hidden (lines 79–95)
The streaming loop — the heart
src/application/generateTheme.tsgenerateTheme is the Change Theme use case: provision a key, get a vibe, open the model stream, and apply each directive live. The orchestration lives here; every decision is a pure function (parseLine, progressPercent, coverage) and every effect goes through a port. Benign exits (no key, no vibe) are outcomes; genuine failures (prompt missing, OpenAI down, too many malformed lines) are errors.
The consume loop: buffer chunks into lines, parse, apply, track progress, honor cancellation.
src/application/generateTheme.ts · 292 lines
⋯ 95 lines hidden (lines 1–95)
⋯ 142 lines hidden (lines 151–292)
Finalization runs after the progress notification closes: a completed run offers the success/reset modal; a cancelled run offers keep/reset. The pure progress helpers and the curated suggestions sit alongside.
Percent (capped at 99) and the message throttle — first message shows immediately (a v1 fix).
src/application/progress.ts · 37 lines
⋯ 23 lines hidden (lines 1–23)
Builds the user prompt; injects current customizations for iteration.
src/application/context.ts · 62 lines
Reset theme, clear key, select/reset model.
src/application/maintenance.ts · 65 lines
src/application/suggestions.ts · 15 lines
VS Code adapters
src/adapters/vscode/config.tssrc/adapters/vscode/secrets.tsThe config adapter is the most logic-heavy, now thin: read the current value (validated, never trusted blindly), call the pure merge, write to the preferred target with a fallback. readCurrentTheme uses config.inspect() to split global vs workspace — the v1 scope-reading fix — and reset() now reports a total failure instead of swallowing it.
src/adapters/vscode/config.ts · 105 lines
⋯ 37 lines hidden (lines 1–37)
⋯ 37 lines hidden (lines 61–97)
The secrets adapter is where the key is stored — and the one place expose is called for storage. The UI adapter holds the QuickPick, the input box, the progress reporter, and the modals. The rest are tiny.
src/adapters/vscode/secrets.ts · 37 lines
⋯ 19 lines hidden (lines 1–19)
src/adapters/vscode/ui.ts · 186 lines
src/adapters/vscode/logger.ts · 14 lines
src/adapters/vscode/preferences.ts · 19 lines
src/adapters/vscode/prompts.ts · 20 lines
The OpenAI gateway
src/adapters/openai/gateway.tsThe gateway implements the OpenAI port. It classifies SDK errors by HTTP status (401/403 → auth, 429 → rate limit, no status → network) instead of v1's substring sniffing, and wraps the streamed completion into a plain AsyncIterable<string> of content deltas. expose appears once, to construct the client.
src/adapters/openai/gateway.ts · 81 lines
⋯ 8 lines hidden (lines 1–8)
⋯ 18 lines hidden (lines 34–51)
Commands and the composition root
src/commands.tssrc/extension.tscommands.ts is the single source of truth for command IDs and their handlers — a test asserts these match package.json, which makes the v1 testRemoveValue/testRemoveValues drift impossible. extension.ts is the composition root: build the adapters, assemble Capabilities, register the commands. Activation is fully synchronous.
src/commands.ts · 49 lines
⋯ 14 lines hidden (lines 1–14)
⋯ 9 lines hidden (lines 41–49)
src/extension.ts · 43 lines
⋯ 17 lines hidden (lines 1–17)
How it's proven
test/support/harness.tstest/generateTheme.test.tsBecause the core depends only on ports, the whole Change Theme flow runs against in-memory fakes. Streaming, live application, REMOVE deletes, cancellation, the too-many-errors abort, the secret never leaking — all exercised without VS Code. The parser and the merge logic are tested directly as pure functions. 49 tests in all, on Node's built-in node:test, bundled with esbuild so there is no test-runner dependency.
The fakes: one Capabilities record, fully in-memory and inspectable.
test/support/harness.ts · 239 lines
⋯ 119 lines hidden (lines 1–119)
⋯ 64 lines hidden (lines 176–239)
End-to-end: happy path, iteration context, cancellation, abort, secret safety.
test/generateTheme.test.ts · 141 lines
The pure merge logic, including the array-scope removal fix.
test/customizations.test.ts · 84 lines
What to vouch for
The thing to take away: most of v1's bugs are gone not because they were patched, but because the new types make them unrepresentable or the new tests would catch them. The table maps each to its mechanism.
| v1 defect | v2 mechanism |
|---|---|
| API key logged on error | Redacted<ApiKey> — prints <redacted>; expose only at the boundary |
| Iteration read the wrong config scope | config.inspect() per scope; effectiveScope tested |
| First progress message withheld ~800ms | Option<Millis> throttle, neverShown initial |
| ~19% duplicated prompt tokens | prompt de-duplicated (1,247 → 1,021 lines), selectors preserved |
| Command-id manifest drift | centralized COMMAND_IDS + guard test |
| Errors classified by substring | classified by HTTP status |
reset() reported failure as success | inspects allSettled, returns Err on total failure |
Zero tests behind a vacuous npm test | 49 real tests, core covered by fakes |