Prologue·🎬 Verdict first

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.

Prologue·🎬 Map + table

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.

LayerRoleImports
fp/Result · Option · Brand · matchTag · Redacted · parser combinatorsnothing
domain/Value objects + smart constructors; illegal states unrepresentablefp
protocol/The streaming wire format as a grammarfp, 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 portseverything inward
commands.ts · extension.tsTyped command registry + composition rooteverything
Inner layers are pure and total. The mess lives only in the outer two.
Act I · The functional kernel1.1🎬 Spotlight

Errors and absence, as values

src/fp/result.tssrc/fp/option.ts

Result<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
src/fp/result.ts80 lines · TypeScript
⋯ 9 lines hidden (lines 1–9)
1/**
2 * `Result<E, A>` — a computation that either fails with an `E` or succeeds with an
3 * `A`. Errors are ordinary values, so the pure core never throws and every failure
4 * is visible in a function's type. Import as a namespace: `import * as Result`.
5 */
6 
7export interface Err<E> {
8 readonly _tag: 'Err';
9 readonly error: E;
11 
12export interface Ok<A> {
13 readonly _tag: 'Ok';
14 readonly value: A;
16 
17export type Result<E, A> = Err<E> | Ok<A>;
18 
19export const err = <E = never, A = never>(error: E): Result<E, A> => ({ _tag: 'Err', error });
20 
21export const ok = <A = never, E = never>(value: A): Result<E, A> => ({ _tag: 'Ok', value });
22 
23export const isOk = <E, A>(r: Result<E, A>): r is Ok<A> => r._tag === 'Ok';
24 
25export const isErr = <E, A>(r: Result<E, A>): r is Err<E> => r._tag === 'Err';
26 
27/** Transform the success channel; an error passes through untouched. */
28export const map =
29 <A, B>(f: (a: A) => B) =>
30 <E>(r: Result<E, A>): Result<E, B> =>
31 r._tag === 'Ok' ? ok(f(r.value)) : r;
32 
33/** Transform the error channel; a success passes through untouched. */
34export const mapErr =
35 <E, F>(f: (e: E) => F) =>
36 <A>(r: Result<E, A>): Result<F, A> =>
37 r._tag === 'Err' ? err(f(r.error)) : r;
38 
39/** Monadic bind: sequence a second fallible step. Error types accumulate as a union. */
40export const flatMap =
41 <A, E2, B>(f: (a: A) => Result<E2, B>) =>
42 <E>(r: Result<E, A>): Result<E | E2, B> =>
43 r._tag === 'Ok' ? f(r.value) : r;
44 
45/** Collapse both channels to a single value — the only way out of a `Result`. */
46export const match =
47 <E, A, R>(handlers: { readonly onErr: (e: E) => R; readonly onOk: (a: A) => R }) =>
48 (r: Result<E, A>): R =>
49 r._tag === 'Ok' ? handlers.onOk(r.value) : handlers.onErr(r.error);
50 
51export const getOrElse =
52 <E, A>(onErr: (e: E) => A) =>
⋯ 28 lines hidden (lines 53–80)
53 (r: Result<E, A>): A =>
54 r._tag === 'Ok' ? r.value : onErr(r.error);
55 
56export const fromNullable =
57 <E>(onNull: () => E) =>
58 <A>(a: A | null | undefined): Result<E, NonNullable<A>> =>
59 a == null ? err(onNull()) : ok(a as NonNullable<A>);
60 
61/** Lift a throwing thunk into a `Result`, mapping the thrown value. */
62export const tryCatch = <E, A>(thunk: () => A, onThrow: (u: unknown) => E): Result<E, A> => {
63 try {
64 return ok(thunk());
65 } catch (u) {
66 return err(onThrow(u));
67 }
68};
69 
70/** Sequence an array of results, failing fast on the first error. */
71export const all = <E, A>(results: ReadonlyArray<Result<E, A>>): Result<E, ReadonlyArray<A>> => {
72 const values: A[] = [];
73 for (const r of results) {
74 if (r._tag === 'Err') {
75 return r;
76 }
77 values.push(r.value);
78 }
79 return ok(values);
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
src/fp/option.ts56 lines · TypeScript
1/**
2 * `Option<A>` — a value that may be absent, made explicit in the type so the
3 * compiler forces the empty case to be handled. Import as a namespace:
4 * `import * as Option`.
5 */
6 
7export interface None {
8 readonly _tag: 'None';
9}
10 
11export interface Some<A> {
12 readonly _tag: 'Some';
13 readonly value: A;
15 
16export type Option<A> = None | Some<A>;
17 
18export const none: Option<never> = { _tag: 'None' };
19 
20export const some = <A>(value: A): Option<A> => ({ _tag: 'Some', value });
21 
22export const isSome = <A>(o: Option<A>): o is Some<A> => o._tag === 'Some';
23 
24export const isNone = <A>(o: Option<A>): o is None => o._tag === 'None';
25 
26export const fromNullable = <A>(a: A | null | undefined): Option<NonNullable<A>> =>
27 a == null ? none : some(a as NonNullable<A>);
28 
29export const map =
30 <A, B>(f: (a: A) => B) =>
31 (o: Option<A>): Option<B> =>
32 o._tag === 'Some' ? some(f(o.value)) : o;
33 
34export const flatMap =
35 <A, B>(f: (a: A) => Option<B>) =>
36 (o: Option<A>): Option<B> =>
37 o._tag === 'Some' ? f(o.value) : o;
38 
39export const filter =
40 <A>(predicate: (a: A) => boolean) =>
41 (o: Option<A>): Option<A> =>
42 o._tag === 'Some' && predicate(o.value) ? o : none;
43 
44export const getOrElse =
45 <A>(onNone: () => A) =>
46 (o: Option<A>): A =>
47 o._tag === 'Some' ? o.value : onNone();
48 
49export const match =
50 <A, R>(handlers: { readonly onNone: () => R; readonly onSome: (a: A) => R }) =>
51 (o: Option<A>): R =>
52 o._tag === 'Some' ? handlers.onSome(o.value) : handlers.onNone();
53 
54/** Drop the `Option`, substituting a fallback. The inverse of `fromNullable`. */
55export const toUndefined = <A>(o: Option<A>): A | undefined =>
56 o._tag === 'Some' ? o.value : undefined;

tryCatch handles a rejected promise AND a synchronous throw (a fix from the review).

src/fp/asyncResult.ts · 57 lines
src/fp/asyncResult.ts57 lines · TypeScript
1/**
2 * `AsyncResult<E, A>` is just `Promise<Result<E, A>>` — an asynchronous, fallible
3 * computation whose error is a value, not a rejection. The combinators let the
4 * application layer sequence effects without `try/catch`, keeping the happy path
5 * linear and the error type honest.
6 */
7 
8import * as Result from './result';
9 
10export type AsyncResult<E, A> = Promise<Result.Result<E, A>>;
11 
12export const ok = <A = never, E = never>(value: A): AsyncResult<E, A> =>
13 Promise.resolve(Result.ok(value));
14 
15export const err = <E = never, A = never>(error: E): AsyncResult<E, A> =>
16 Promise.resolve(Result.err(error));
17 
18export const fromResult = <E, A>(r: Result.Result<E, A>): AsyncResult<E, A> => Promise.resolve(r);
19 
20export const map =
21 <A, B>(f: (a: A) => B) =>
22 <E>(ar: AsyncResult<E, A>): AsyncResult<E, B> =>
23 ar.then(Result.map(f));
24 
25export const mapErr =
26 <E, F>(f: (e: E) => F) =>
27 <A>(ar: AsyncResult<E, A>): AsyncResult<F, A> =>
28 ar.then(Result.mapErr(f));
29 
30export const flatMap =
31 <A, E2, B>(f: (a: A) => AsyncResult<E2, B>) =>
32 <E>(ar: AsyncResult<E, A>): AsyncResult<E | E2, B> =>
33 ar.then((r): AsyncResult<E | E2, B> => (r._tag === 'Ok' ? f(r.value) : Promise.resolve(r)));
34 
35export const match =
36 <E, A, R>(handlers: { readonly onErr: (e: E) => R; readonly onOk: (a: A) => R }) =>
37 (ar: AsyncResult<E, A>): Promise<R> =>
38 ar.then(Result.match(handlers));
39 
40/**
41 * Lift a promise-returning thunk into an `AsyncResult`, mapping any failure to the
42 * error channel. Handles both a rejected promise and a *synchronous* throw from the
43 * thunk itself (the latter would otherwise escape `.then` and reject the result).
44 */
45export const tryCatch = <E, A>(
46 thunk: () => Promise<A>,
47 onThrow: (u: unknown) => E,
48): AsyncResult<E, A> => {
49 try {
50 return thunk().then(
51 (value) => Result.ok<A, E>(value),
52 (u: unknown) => Result.err<E, A>(onThrow(u)),
53 );
54 } catch (u) {
55 return Promise.resolve(Result.err<E, A>(onThrow(u)));
56 }
57};
src/fp/function.ts · 64 lines
src/fp/function.ts64 lines · TypeScript
1/**
2 * Function plumbing for the functional core: `pipe` for left-to-right data flow
3 * and a handful of combinators. Everything downstream reads as a pipeline, which
4 * is what gives the codebase its DSL feel.
5 */
6 
7export const identity = <A>(a: A): A => a;
8 
9export const constant =
10 <A>(a: A) =>
11 (): A =>
12 a;
13 
14/** Side-effecting tap that returns its input unchanged — for logging in a pipe. */
15export const tap =
16 <A>(f: (a: A) => void) =>
17 (a: A): A => {
18 f(a);
19 return a;
20 };
21 
22// `pipe` threads a value through a sequence of unary functions. The overloads
23// preserve full type inference at each step; the implementation is a fold.
24export function pipe<A>(a: A): A;
25export function pipe<A, B>(a: A, ab: (a: A) => B): B;
26export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
27export function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
28export function pipe<A, B, C, D, E>(
29 a: A,
30 ab: (a: A) => B,
31 bc: (b: B) => C,
32 cd: (c: C) => D,
33 de: (d: D) => E,
34): E;
35export function pipe<A, B, C, D, E, F>(
36 a: A,
37 ab: (a: A) => B,
38 bc: (b: B) => C,
39 cd: (c: C) => D,
40 de: (d: D) => E,
41 ef: (e: E) => F,
42): F;
43export function pipe<A, B, C, D, E, F, G>(
44 a: A,
45 ab: (a: A) => B,
46 bc: (b: B) => C,
47 cd: (c: C) => D,
48 de: (d: D) => E,
49 ef: (e: E) => F,
50 fg: (f: F) => G,
51): G;
52export function pipe<A, B, C, D, E, F, G, H>(
53 a: A,
54 ab: (a: A) => B,
55 bc: (b: B) => C,
56 cd: (c: C) => D,
57 de: (d: D) => E,
58 ef: (e: E) => F,
59 fg: (f: F) => G,
60 gh: (g: G) => H,
61): H;
62export function pipe(a: unknown, ...fns: ReadonlyArray<(x: unknown) => unknown>): unknown {
63 return fns.reduce((acc, fn) => fn(acc), a);
Act I · The functional kernel1.2🎬 Spotlight

Three primitives that do the heavy lifting

src/fp/brand.tssrc/fp/match.tssrc/fp/redacted.ts

Brand 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
src/fp/brand.ts13 lines · TypeScript
⋯ 7 lines hidden (lines 1–7)
1/**
2 * Nominal typing via branding. A `Brand<A, "Name">` is structurally an `A` but
3 * the compiler refuses to accept a bare `A` where the brand is required. The only
4 * way to obtain one is through a smart constructor in the domain, so possession of
5 * a branded value is itself proof that it satisfies the business rule.
6 */
7 
8declare const brand: unique symbol;
9 
10export type Brand<A, B extends string> = A & { readonly [brand]: B };
11 
12/** Convenience for the common case of a branded `string`. */
13export type Tagged<B extends string> = Brand<string, B>;

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
src/fp/match.ts29 lines · TypeScript
⋯ 10 lines hidden (lines 1–10)
1/**
2 * Exhaustive pattern matching over discriminated unions keyed by a `_tag` field.
3 *
4 * `matchTag(value, handlers)` requires one handler per variant: omit a case and
5 * the object literal fails to type-check. This is how control flow over a union
6 * is made total at compile time, with no `default` branch to silently swallow a
7 * new variant added later.
8 */
9 
10export interface HasTag<T extends string = string> {
11 readonly _tag: T;
13 
14export const matchTag = <T extends HasTag, R>(
15 value: T,
16 handlers: { readonly [K in T['_tag']]: (variant: Extract<T, { _tag: K }>) => R },
17): R => handlers[value._tag as T['_tag']](value as Extract<T, { _tag: T['_tag'] }>);
18 
19/**
20 * Compile-time proof that a point is unreachable. Pass a value the type system
21 * believes to be `never`; if a real variant ever reaches here the build breaks.
22 */
23export const assertNever = (value: never): never => {
24 const tag =
25 typeof value === 'object' && value !== null && '_tag' in value
26 ? String((value as HasTag)._tag)
27 : String(value);
28 throw new Error(`Unreachable variant reached: ${tag}`);
⋯ 1 line hidden (lines 29–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
src/fp/redacted.ts27 lines · TypeScript
⋯ 19 lines hidden (lines 1–19)
1/**
2 * `Redacted<A>` wraps a secret so it cannot be read by accident. The wrapped value
3 * lives behind a module-private symbol, and `toString`/`toJSON` both yield a
4 * placeholder. So a secret can be threaded through logs, error objects, and
5 * `JSON.stringify` without ever leaking — the value comes out only via the explicit
6 * `expose`, which is easy to grep for and audit.
7 *
8 * This makes the v1 bug (the API key reaching `console.error` via the client state)
9 * unrepresentable: a `Redacted<ApiKey>` printed anywhere renders as `<redacted>`.
10 */
11 
12const secret = Symbol('redacted');
13 
14export interface Redacted<A> {
15 readonly [secret]: A;
16 toString(): string;
17 toJSON(): string;
19 
20export const redact = <A>(value: A): Redacted<A> => ({
21 [secret]: value,
22 toString: () => '<redacted>',
23 toJSON: () => '<redacted>',
24});
25 
26/** The single, auditable escape hatch. Use only at the boundary that needs the raw value. */
27export const expose = <A>(r: Redacted<A>): A => r[secret];
src/fp/nonEmpty.ts · 19 lines
src/fp/nonEmpty.ts19 lines · TypeScript
1/**
2 * Non-emptiness lifted into the type system. A `NonEmptyArray<A>` is known by the
3 * compiler to have a first element, so `head` needs no runtime null check.
4 */
5 
6import { Brand } from './brand';
7 
8export type NonEmptyArray<A> = readonly [A, ...A[]];
9 
10export const isNonEmptyArray = <A>(as: ReadonlyArray<A>): as is NonEmptyArray<A> => as.length > 0;
11 
12export const head = <A>(as: NonEmptyArray<A>): A => as[0];
13 
14export const tail = <A>(as: NonEmptyArray<A>): ReadonlyArray<A> => as.slice(1);
15 
16/** A `string` proven, by construction, to contain a non-whitespace character. */
17export type NonEmptyString = Brand<string, 'NonEmptyString'>;
18 
19export const isNonEmptyString = (s: string): s is NonEmptyString => s.trim().length > 0;

The single import surface (the prelude).

src/fp/index.ts · 36 lines
src/fp/index.ts36 lines · TypeScript
1/**
2 * The functional prelude. One import surface for the whole core:
3 *
4 * import { pipe, Result, Option, matchTag } from '../fp';
5 *
6 * Effect-laden types (`Result`, `Option`, `AsyncResult`, `Parser`) are exposed as
7 * namespaces so their identically-named combinators (`map`, `flatMap`, `match`)
8 * never collide.
9 */
10 
11export { pipe, identity, constant, tap } from './function';
12export { matchTag, assertNever, type HasTag } from './match';
13export { type Brand, type Tagged } from './brand';
14export { redact, expose, type Redacted } from './redacted';
15export {
16 type NonEmptyArray,
17 type NonEmptyString,
18 isNonEmptyArray,
19 isNonEmptyString,
20 head,
21 tail,
22} from './nonEmpty';
23 
24// Bare constructors are exposed for ergonomics (no name collisions); the
25// combinators that *do* collide (`map`, `flatMap`, `match`) stay namespaced.
26export { ok, err, isOk, isErr } from './result';
27export { some, none, isSome, isNone } from './option';
28 
29export * as Result from './result';
30export * as Option from './option';
31export * as AsyncResult from './asyncResult';
32export * as P from './parser';
33 
34export type { Result as ResultType } from './result';
35export type { Option as OptionType } from './option';
36export type { AsyncResult as AsyncResultType } from './asyncResult';
Act I · The functional kernel1.3🎬 Spotlight

A tiny parser-combinator kernel

src/fp/parser.ts

A 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
src/fp/parser.ts113 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1/**
2 * A minimal parser-combinator kernel. A `Parser<A>` consumes the front of a string
3 * and either yields a value plus the remaining input, or an error. Combinators
4 * compose small parsers into larger ones, letting the streaming protocol be
5 * expressed as a grammar (see `protocol/streamingParser.ts`) rather than a tangle
6 * of `startsWith`/`split`/`indexOf`.
7 */
8 
9export interface ParseOk<A> {
10 readonly _tag: 'ParseOk';
11 readonly value: A;
12 readonly rest: string;
14 
15export interface ParseErr {
16 readonly _tag: 'ParseErr';
17 readonly expected: string;
18 readonly found: string;
20 
21export type Parsed<A> = ParseOk<A> | ParseErr;
22 
23export type Parser<A> = (input: string) => Parsed<A>;
24 
25const parseOk = <A>(value: A, rest: string): Parsed<A> => ({ _tag: 'ParseOk', value, rest });
26 
27const parseErr = (expected: string, found: string): ParseErr => ({
28 _tag: 'ParseErr',
29 expected,
30 found,
31});
32 
33export const succeed =
34 <A>(value: A): Parser<A> =>
35 (input) =>
36 parseOk(value, input);
37 
38export const fail =
39 (expected: string): Parser<never> =>
40 (input) =>
41 parseErr(expected, input);
42 
43/** Match an exact prefix (case-sensitive), consuming it. */
44export const literal =
45 (prefix: string): Parser<string> =>
46 (input) =>
47 input.startsWith(prefix) ? parseOk(prefix, input.slice(prefix.length)) : parseErr(prefix, input);
48 
49/** Match an anchored regular expression, consuming the match. */
50export const regex =
51 (re: RegExp, label: string): Parser<string> =>
52 (input) => {
53 const anchored = re.source.startsWith('^') ? re : new RegExp(`^(?:${re.source})`, re.flags);
54 const matched = anchored.exec(input)?.[0];
55 return matched !== undefined
56 ? parseOk(matched, input.slice(matched.length))
57 : parseErr(label, input);
58 };
59 
60/** Consume the entire remaining input. */
61export const rest: Parser<string> = (input) => parseOk(input, '');
62 
⋯ 51 lines hidden (lines 63–113)
63export const map =
64 <A, B>(f: (a: A) => B) =>
65 (p: Parser<A>): Parser<B> =>
66 (input) => {
67 const r = p(input);
68 return r._tag === 'ParseOk' ? parseOk(f(r.value), r.rest) : r;
69 };
70 
71/** Run `p`, then feed its value to a parser-producing function (monadic bind). */
72export const flatMap =
73 <A, B>(f: (a: A) => Parser<B>) =>
74 (p: Parser<A>): Parser<B> =>
75 (input) => {
76 const r = p(input);
77 return r._tag === 'ParseOk' ? f(r.value)(r.rest) : r;
78 };
79 
80/** Sequence two parsers, keeping only the right-hand value. */
81export const then =
82 <B>(next: Parser<B>) =>
83 <A>(p: Parser<A>): Parser<B> =>
84 (input) => {
85 const r = p(input);
86 return r._tag === 'ParseOk' ? next(r.rest) : r;
87 };
88 
89/** Try `p`; on failure fall back to `alternative` against the original input. */
90export const orElse =
91 <A>(alternative: Parser<A>) =>
92 (p: Parser<A>): Parser<A> =>
93 (input) => {
94 const r = p(input);
95 return r._tag === 'ParseOk' ? r : alternative(input);
96 };
97 
98/** First successful parser wins. */
99export const oneOf =
100 <A>(parsers: ReadonlyArray<Parser<A>>): Parser<A> =>
101 (input) => {
102 let lastError: ParseErr = parseErr('one of several alternatives', input);
103 for (const p of parsers) {
104 const r = p(input);
105 if (r._tag === 'ParseOk') {
106 return r;
107 }
108 lastError = r;
109 }
110 return lastError;
111 };
112 
113export const run = <A>(p: Parser<A>, input: string): Parsed<A> => p(input);
Act II · The domain2.1🎬 Spotlight

Smart constructors: the only way in

src/domain/color.ts

Every 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
src/domain/color.ts65 lines · TypeScript
⋯ 15 lines hidden (lines 1–15)
1/**
2 * `ColorValue` — the only representation of a color the rest of the system accepts.
3 * A value exists in exactly one of three shapes: a normalized hex string, a CSS
4 * keyword, or the `Remove` sentinel used during iteration to clear an override.
5 * Arbitrary strings cannot enter; `parseColor` is the sole constructor.
6 */
7 
8import { type Brand, matchTag, Result, type ResultType } from '../fp';
9import { malformed, type ValidationError } from './errors';
10 
11/** A lowercase `#rgb` / `#rrggbb` / `#rrggbbaa` string, proven by construction. */
12export type HexColor = Brand<string, 'HexColor'>;
13 
14export type NamedColor = 'transparent' | 'inherit' | 'initial' | 'unset';
15 
16export type ColorValue =
17 | { readonly _tag: 'Hex'; readonly value: HexColor }
18 | { readonly _tag: 'Named'; readonly value: NamedColor }
19 | { readonly _tag: 'Remove' };
20 
21const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
22const NAMED: ReadonlySet<string> = new Set<NamedColor>([
23 'transparent',
24 'inherit',
25 'initial',
26 'unset',
27]);
28const REMOVE_SENTINEL = 'REMOVE';
29 
30export const remove: ColorValue = { _tag: 'Remove' };
31export const hex = (value: HexColor): ColorValue => ({ _tag: 'Hex', value });
32export const named = (value: NamedColor): ColorValue => ({ _tag: 'Named', value });
33 
34export const parseColor = (raw: string): ResultType<ValidationError, ColorValue> => {
35 const trimmed = raw.trim();
36 
37 if (trimmed.toUpperCase() === REMOVE_SENTINEL) {
38 return Result.ok(remove);
39 }
40 if (HEX_RE.test(trimmed)) {
41 return Result.ok(hex(trimmed.toLowerCase() as HexColor));
42 }
43 const lower = trimmed.toLowerCase();
44 if (NAMED.has(lower)) {
45 return Result.ok(named(lower as NamedColor));
46 }
47 return Result.err(
48 malformed('color', 'a hex code (#rgb/#rrggbb/#rrggbbaa), a CSS keyword, or REMOVE', trimmed),
49 );
50};
51 
52/**
53 * How a color resolves against VS Code config: either set a string value, or delete
54 * the key entirely (the `Remove` case, which lets the base theme show through).
55 */
56export type ColorApplication =
57 | { readonly _tag: 'Set'; readonly value: string }
58 | { readonly _tag: 'Delete' };
59 
60export const toApplication = (color: ColorValue): ColorApplication =>
⋯ 5 lines hidden (lines 61–65)
61 matchTag(color, {
62 Hex: ({ value }) => ({ _tag: 'Set', value }),
63 Named: ({ value }) => ({ _tag: 'Set', value }),
64 Remove: () => ({ _tag: 'Delete' }),
65 });

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/vibe.ts26 lines · TypeScript
1/**
2 * `Vibe` — the user's natural-language theme description, validated to the same rule
3 * the v1 QuickPick enforced: non-empty after trimming, at least three characters.
4 * Downstream code receives a `Vibe`, never a raw string, so "did we validate this?"
5 * is answered by the type.
6 */
7 
8import { type Brand, Result, type ResultType } from '../fp';
9import { empty, tooShort, type ValidationError } from './errors';
10 
11export type Vibe = Brand<string, 'Vibe'>;
12 
13export const MIN_VIBE_LENGTH = 3;
14 
15export const parseVibe = (raw: string): ResultType<ValidationError, Vibe> => {
16 const trimmed = raw.trim();
17 if (trimmed.length === 0) {
18 return Result.err(empty('theme description'));
19 }
20 if (trimmed.length < MIN_VIBE_LENGTH) {
21 return Result.err(tooShort('theme description', MIN_VIBE_LENGTH, trimmed.length));
22 }
23 return Result.ok(trimmed as Vibe);
24};
25 
26export const vibeText = (v: Vibe): string => v;
src/domain/selector.ts · 28 lines
src/domain/selector.ts28 lines · TypeScript
1/**
2 * `WorkbenchSelector` — a `workbench.colorCustomizations` key such as
3 * `editor.background`. VS Code's catalogue is large and evolves, so rather than
4 * enumerate it we accept any dotted identifier and reject obvious garbage. The
5 * brand guarantees callers can't pass a raw, unvalidated string.
6 */
7 
8import { type Brand, Result, type ResultType } from '../fp';
9import { empty, malformed, type ValidationError } from './errors';
10 
11export type WorkbenchSelector = Brand<string, 'WorkbenchSelector'>;
12 
13// A letter, then letters/digits in dot-separated segments: `editorGroup.dropBackground`,
14// `editorIndentGuide.activeBackground6`, `editorBracketHighlight.foreground1`.
15const SELECTOR_RE = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)*$/;
16 
17export const parseSelector = (raw: string): ResultType<ValidationError, WorkbenchSelector> => {
18 const trimmed = raw.trim();
19 if (trimmed.length === 0) {
20 return Result.err(empty('selector'));
21 }
22 if (!SELECTOR_RE.test(trimmed)) {
23 return Result.err(malformed('selector', 'a dotted identifier like editor.background', trimmed));
24 }
25 return Result.ok(trimmed as WorkbenchSelector);
26};
27 
28export const selectorText = (s: WorkbenchSelector): string => s;
src/domain/tokenScope.ts · 25 lines
src/domain/tokenScope.ts25 lines · TypeScript
1/**
2 * `TokenScope` — a TextMate scope for syntax highlighting, e.g. `comment` or
3 * `comment.line.double-slash`. Dots and hyphens are allowed; whitespace and the
4 * `=` delimiter are not (they would break the streaming protocol).
5 */
6 
7import { type Brand, Result, type ResultType } from '../fp';
8import { empty, malformed, type ValidationError } from './errors';
9 
10export type TokenScope = Brand<string, 'TokenScope'>;
11 
12const SCOPE_RE = /^[A-Za-z][A-Za-z0-9.-]*$/;
13 
14export const parseTokenScope = (raw: string): ResultType<ValidationError, TokenScope> => {
15 const trimmed = raw.trim();
16 if (trimmed.length === 0) {
17 return Result.err(empty('token scope'));
18 }
19 if (!SCOPE_RE.test(trimmed)) {
20 return Result.err(malformed('token scope', 'a TextMate scope like comment.line', trimmed));
21 }
22 return Result.ok(trimmed as TokenScope);
23};
24 
25export const tokenScopeText = (s: TokenScope): string => s;

Tightened vs v1: validates none / italic / bold / underline / strikethrough combos.

src/domain/fontStyle.ts · 35 lines
src/domain/fontStyle.ts35 lines · TypeScript
1/**
2 * `FontStyle` — the optional styling on a token color rule. VS Code accepts either
3 * the literal `none` or a space-separated subset of italic/bold/underline/
4 * strikethrough. We validate to that grammar and normalize, so a rule can only ever
5 * carry a font style VS Code understands.
6 *
7 * (v1 passed the raw substring through unchecked; this rejects genuine garbage while
8 * still accepting every real style and combination.)
9 */
10 
11import { type Brand, Result, type ResultType } from '../fp';
12import { empty, malformed, type ValidationError } from './errors';
13 
14export type FontStyle = Brand<string, 'FontStyle'>;
15 
16const FLAGS: ReadonlySet<string> = new Set(['italic', 'bold', 'underline', 'strikethrough']);
17 
18export const parseFontStyle = (raw: string): ResultType<ValidationError, FontStyle> => {
19 const normalized = raw.trim().toLowerCase();
20 if (normalized.length === 0) {
21 return Result.err(empty('font style'));
22 }
23 if (normalized === 'none') {
24 return Result.ok('none' as FontStyle);
25 }
26 const flags = normalized.split(/\s+/);
27 if (flags.every((flag) => FLAGS.has(flag))) {
28 return Result.ok(flags.join(' ') as FontStyle);
29 }
30 return Result.err(
31 malformed('font style', 'none, or italic/bold/underline/strikethrough', normalized),
32 );
33};
34 
35export const fontStyleText = (s: FontStyle): string => s;

Default model gpt-4.1 (a valid, current id).

src/domain/model.ts · 20 lines
src/domain/model.ts20 lines · TypeScript
1/**
2 * `ModelId` — an OpenAI model identifier. The default is `gpt-4.1`, a current and
3 * valid Chat Completions model (preserved from v1; the review's "gpt-4.1 is invalid"
4 * claim was a false positive). Selection filters to GPT-family ids to match v1's UX.
5 */
6 
7import { type Brand, none, type OptionType, some } from '../fp';
8 
9export type ModelId = Brand<string, 'ModelId'>;
10 
11export const DEFAULT_MODEL = 'gpt-4.1' as ModelId;
12 
13export const parseModelId = (raw: string): OptionType<ModelId> => {
14 const trimmed = raw.trim();
15 return trimmed.length > 0 ? some(trimmed as ModelId) : none;
16};
17 
18export const isGptModel = (id: ModelId): boolean => id.toLowerCase().startsWith('gpt');
19 
20export const modelText = (id: ModelId): string => id;

Validation errors as a tagged union, rendered in one place.

src/domain/errors.ts · 43 lines
src/domain/errors.ts43 lines · TypeScript
1/**
2 * Validation failures from smart constructors, as a closed tagged union. Rendering
3 * is centralized so user-facing copy lives in one place and exhaustiveness is
4 * enforced. Note: these errors never carry secret material — see `apiKey.ts` for
5 * the key-specific error type that deliberately omits the raw value.
6 */
7 
8import { matchTag } from '../fp';
9 
10export type ValidationError =
11 | { readonly _tag: 'Empty'; readonly field: string }
12 | { readonly _tag: 'TooShort'; readonly field: string; readonly min: number; readonly actual: number }
13 | {
14 readonly _tag: 'Malformed';
15 readonly field: string;
16 readonly expected: string;
17 readonly received: string;
18 };
19 
20export const empty = (field: string): ValidationError => ({ _tag: 'Empty', field });
21 
22export const tooShort = (field: string, min: number, actual: number): ValidationError => ({
23 _tag: 'TooShort',
24 field,
25 min,
26 actual,
27});
28 
29export const malformed = (field: string, expected: string, received: string): ValidationError => ({
30 _tag: 'Malformed',
31 field,
32 expected,
33 received,
34});
35 
36export const renderValidationError = (e: ValidationError): string =>
37 matchTag(e, {
38 Empty: ({ field }) => `${field} must not be empty`,
39 TooShort: ({ field, min, actual }) =>
40 `${field} is too short (${actual} chars; need at least ${min})`,
41 Malformed: ({ field, expected, received }) =>
42 `${field} is malformed: expected ${expected}, got "${received}"`,
43 });
Act II · The domain2.2🎬 Q&A

The API key, validated and pre-wrapped

src/domain/apiKey.ts

parseApiKey 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
src/domain/apiKey.ts42 lines · TypeScript
⋯ 15 lines hidden (lines 1–15)
1/**
2 * `ApiKey` — an OpenAI secret. Two safety properties are baked into the types:
3 *
4 * 1. Validation is the only constructor (`sk-` prefix, length > 20, matching v1),
5 * so an `ApiKey` is always well-formed.
6 * 2. `parseApiKey` hands back a `Redacted<ApiKey>`, never a bare key. The value is
7 * wrapped from birth, and the error variants carry no raw material. This makes
8 * the v1 key-leak (the key reaching `console.error`) unrepresentable.
9 */
10 
11import { type Brand, matchTag, redact, type Redacted, Result, type ResultType } from '../fp';
12 
13export type ApiKey = Brand<string, 'ApiKey'>;
14 
15export type ApiKeyError =
16 | { readonly _tag: 'KeyEmpty' }
17 | { readonly _tag: 'KeyBadPrefix' }
18 | { readonly _tag: 'KeyTooShort' };
19 
20const MIN_KEY_LENGTH = 20;
21const REQUIRED_PREFIX = 'sk-';
22 
23export const parseApiKey = (raw: string): ResultType<ApiKeyError, Redacted<ApiKey>> => {
24 const trimmed = raw.trim();
25 if (trimmed.length === 0) {
26 return Result.err({ _tag: 'KeyEmpty' });
27 }
28 if (!trimmed.startsWith(REQUIRED_PREFIX)) {
29 return Result.err({ _tag: 'KeyBadPrefix' });
30 }
31 if (trimmed.length <= MIN_KEY_LENGTH) {
32 return Result.err({ _tag: 'KeyTooShort' });
33 }
34 return Result.ok(redact(trimmed as ApiKey));
35};
36 
37export const renderApiKeyError = (e: ApiKeyError): string =>
⋯ 5 lines hidden (lines 38–42)
38 matchTag(e, {
39 KeyEmpty: () => 'An OpenAI API key is required',
40 KeyBadPrefix: () => 'OpenAI API keys start with "sk-"',
41 KeyTooShort: () => 'That API key looks too short',
42 });
Act II · The domain2.3🎬 Union + model

The directive union and the theme model

src/domain/directive.tssrc/domain/theme.ts

StreamingDirective 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
src/domain/directive.ts55 lines · TypeScript
⋯ 15 lines hidden (lines 1–15)
1/**
2 * `StreamingDirective` — one decoded line of the model's output stream. The wire
3 * protocol has exactly four shapes (COUNT / SELECTOR / TOKEN / MESSAGE) and this
4 * union is their typed form. The streaming parser produces these; the application
5 * consumes them through an exhaustive match.
6 */
7 
8import {
9 type Brand,
10 type NonEmptyString,
11 type OptionType,
12 Result,
13 type ResultType,
14} from '../fp';
15import type { ColorValue } from './color';
16import { malformed, type ValidationError } from './errors';
17import type { FontStyle } from './fontStyle';
18import type { WorkbenchSelector } from './selector';
19import type { TokenScope } from './tokenScope';
20 
21export type PositiveInt = Brand<number, 'PositiveInt'>;
22 
23export const parsePositiveInt = (raw: string): ResultType<ValidationError, PositiveInt> => {
24 const trimmed = raw.trim();
25 const n = Number.parseInt(trimmed, 10);
26 return Number.isInteger(n) && n > 0 && String(n) === trimmed
27 ? Result.ok(n as PositiveInt)
28 : Result.err(malformed('count', 'a positive integer', trimmed));
29};
30 
31export type StreamingDirective =
32 | { readonly _tag: 'Count'; readonly total: PositiveInt }
33 | { readonly _tag: 'Selector'; readonly selector: WorkbenchSelector; readonly color: ColorValue }
34 | {
35 readonly _tag: 'Token';
36 readonly scope: TokenScope;
37 readonly color: ColorValue;
38 readonly fontStyle: OptionType<FontStyle>;
39 }
40 | { readonly _tag: 'Message'; readonly text: NonEmptyString };
41 
42export const count = (total: PositiveInt): StreamingDirective => ({ _tag: 'Count', total });
43 
44export const selectorDirective = (
⋯ 11 lines hidden (lines 45–55)
45 selector: WorkbenchSelector,
46 color: ColorValue,
47): StreamingDirective => ({ _tag: 'Selector', selector, color });
48 
49export const tokenDirective = (
50 scope: TokenScope,
51 color: ColorValue,
52 fontStyle: OptionType<FontStyle>,
53): StreamingDirective => ({ _tag: 'Token', scope, color, fontStyle });
54 
55export const message = (text: NonEmptyString): StreamingDirective => ({ _tag: 'Message', text });

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
src/domain/theme.ts86 lines · TypeScript
⋯ 59 lines hidden (lines 1–59)
1/**
2 * The theme as a domain object: the individual `ThemeSetting`s we apply, and the
3 * `CurrentTheme` snapshot we read back for iteration.
4 *
5 * `CurrentTheme` keeps the global and workspace scopes separate, which is what
6 * fixes the v1 bug where both were read from the same merged config value and scope
7 * detection became meaningless.
8 */
9 
10import { matchTag, none, type OptionType, some } from '../fp';
11import type { ColorValue } from './color';
12import type { StreamingDirective } from './directive';
13import type { FontStyle } from './fontStyle';
14import type { ConfigurationScope } from './scope';
15import type { WorkbenchSelector } from './selector';
16import type { TokenScope } from './tokenScope';
17 
18/** An applicable theme change. `Count`/`Message` directives are not settings. */
19export type ThemeSetting =
20 | { readonly _tag: 'SelectorSetting'; readonly selector: WorkbenchSelector; readonly color: ColorValue }
21 | {
22 readonly _tag: 'TokenSetting';
23 readonly scope: TokenScope;
24 readonly color: ColorValue;
25 readonly fontStyle: OptionType<FontStyle>;
26 };
27 
28export const settingOf = (directive: StreamingDirective): OptionType<ThemeSetting> =>
29 matchTag(directive, {
30 Count: () => none,
31 Message: () => none,
32 Selector: ({ selector, color }): OptionType<ThemeSetting> =>
33 some({ _tag: 'SelectorSetting', selector, color }),
34 Token: ({ scope, color, fontStyle }): OptionType<ThemeSetting> =>
35 some({ _tag: 'TokenSetting', scope, color, fontStyle }),
36 });
37 
38export type ColorMap = Readonly<Record<string, string>>;
39export type TokenCustomizations = Readonly<Record<string, unknown>>;
40 
41export interface ScopedTheme {
42 readonly colors: ColorMap;
43 readonly tokens: TokenCustomizations;
45 
46export interface CurrentTheme {
47 readonly global: ScopedTheme;
48 readonly workspace: ScopedTheme;
50 
51export const emptyScopedTheme: ScopedTheme = { colors: {}, tokens: {} };
52 
53const isEmptyRecord = (record: Readonly<Record<string, unknown>>): boolean =>
54 Object.keys(record).length === 0;
55 
56const scopeHasCustomizations = (scope: ScopedTheme): boolean =>
57 !isEmptyRecord(scope.colors) || !isEmptyRecord(scope.tokens);
58 
59/** Workspace overrides global, matching VS Code's own precedence. */
60export const effectiveColors = (theme: CurrentTheme): ColorMap => ({
61 ...theme.global.colors,
62 ...theme.workspace.colors,
63});
64 
65export const effectiveTokens = (theme: CurrentTheme): TokenCustomizations => ({
66 ...theme.global.tokens,
67 ...theme.workspace.tokens,
68});
69 
70export const hasCustomizations = (theme: CurrentTheme): boolean =>
71 scopeHasCustomizations(theme.global) || scopeHasCustomizations(theme.workspace);
72 
73export const effectiveScope = (theme: CurrentTheme): ConfigurationScope => {
74 const global = scopeHasCustomizations(theme.global);
75 const workspace = scopeHasCustomizations(theme.workspace);
76 if (global && workspace) {
77 return { _tag: 'Both' };
78 }
79 if (workspace) {
80 return { _tag: 'Workspace' };
81 }
82 if (global) {
83 return { _tag: 'Global' };
84 }
85 return { _tag: 'None' };
86};

Write preference: prefer global, fall back to workspace.

src/domain/scope.ts · 33 lines
src/domain/scope.ts33 lines · TypeScript
1/**
2 * Where theme customizations live. `ConfigurationScope` describes *where existing*
3 * customizations were found; `writePreference` describes where new ones go.
4 *
5 * The write preference is an ordered, non-empty list: try the first target, fall
6 * back to the next only if it fails. With a workspace open we prefer `global` (the
7 * theme follows the user everywhere) and keep `workspace` as a fallback — preserving
8 * v1's first-success-wins behavior, but named honestly instead of the old "both".
9 */
10 
11import { matchTag, type NonEmptyArray } from '../fp';
12 
13export type WriteTarget = 'global' | 'workspace';
14 
15export type ConfigurationScope =
16 | { readonly _tag: 'Global' }
17 | { readonly _tag: 'Workspace' }
18 | { readonly _tag: 'Both' }
19 | { readonly _tag: 'None' };
20 
21export const writePreference = (hasWorkspaceFolders: boolean): NonEmptyArray<WriteTarget> =>
22 hasWorkspaceFolders ? ['global', 'workspace'] : ['global'];
23 
24export const describeTarget = (target: WriteTarget): string =>
25 target === 'global' ? 'global settings' : 'workspace settings';
26 
27export const describeScope = (scope: ConfigurationScope): string =>
28 matchTag(scope, {
29 Global: () => 'global settings',
30 Workspace: () => 'workspace settings',
31 Both: () => 'global and workspace settings',
32 None: () => 'no settings',
33 });

Completeness relative to the model's estimate, not fixed thresholds.

src/domain/coverage.ts · 26 lines
src/domain/coverage.ts26 lines · TypeScript
1/**
2 * How complete a generated theme is, judged against the model's own estimated
3 * setting count rather than v1's fixed `<50/<80` thresholds. A deliberately small
4 * iteration is no longer mislabeled "partial".
5 */
6 
7export type Coverage = 'partial' | 'good' | 'comprehensive';
8 
9export const coverage = (applied: number, expected: number): Coverage => {
10 if (expected <= 0) {
11 return 'comprehensive';
12 }
13 const ratio = applied / expected;
14 return ratio < 0.5 ? 'partial' : ratio < 0.85 ? 'good' : 'comprehensive';
15};
16 
17export const describeCoverage = (c: Coverage): string => {
18 switch (c) {
19 case 'partial':
20 return '⚠️ Partial theme — you may want to regenerate for fuller coverage';
21 case 'good':
22 return '✅ Good coverage — most UI elements are styled';
23 case 'comprehensive':
24 return '🎨 Comprehensive theme — all major UI areas styled';
25 }
26};
Act II · The domain2.4🎬 Spotlight

The merge logic, pure and testable

src/domain/customizations.ts

In 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
src/domain/customizations.ts63 lines · TypeScript
⋯ 23 lines hidden (lines 1–23)
1/**
2 * Pure merge logic for VS Code customizations — extracted from the config adapter
3 * so the fiddly parts (incremental color set/delete, token-rule replace/remove by
4 * scope, including array-valued scopes) are unit-testable without the editor host.
5 * The adapter is left as a thin read → merge → write.
6 */
7 
8import { type OptionType } from '../fp';
9import { type ColorValue, toApplication } from './color';
10import { type FontStyle, fontStyleText } from './fontStyle';
11import { type WorkbenchSelector, selectorText } from './selector';
12import { type ColorMap } from './theme';
13import { type TokenScope, tokenScopeText } from './tokenScope';
14 
15/** VS Code's `editor.tokenColorCustomizations.textMateRules` entry shape. */
16export interface TextMateRule {
17 readonly scope?: string | ReadonlyArray<string>;
18 readonly settings?: Record<string, unknown>;
20 
21const scopeMatches = (ruleScope: TextMateRule['scope'], scope: string): boolean =>
22 Array.isArray(ruleScope) ? ruleScope.includes(scope) : ruleScope === scope;
23 
24/** Set or delete one selector in a color map, returning a new map. */
25export const applyColor = (
26 existing: ColorMap,
27 selector: WorkbenchSelector,
28 color: ColorValue,
29): ColorMap => {
30 const key = selectorText(selector);
31 const application = toApplication(color);
32 if (application._tag === 'Delete') {
33 const next: Record<string, string> = { ...existing };
34 delete next[key];
35 return next;
36 }
37 return { ...existing, [key]: application.value };
38};
39 
40/** Replace, add, or remove the rule(s) for a scope, returning a new rule list. */
41export const applyTokenRule = (
42 rules: ReadonlyArray<TextMateRule>,
43 scope: TokenScope,
44 color: ColorValue,
45 fontStyle: OptionType<FontStyle>,
46): ReadonlyArray<TextMateRule> => {
47 const scopeText = tokenScopeText(scope);
48 const withoutScope = rules.filter((rule) => !scopeMatches(rule.scope, scopeText));
49 const application = toApplication(color);
50 if (application._tag === 'Delete') {
51 return withoutScope;
52 }
53 return [
54 ...withoutScope,
55 {
56 scope: scopeText,
57 settings: {
58 foreground: application.value,
59 ...(fontStyle._tag === 'Some' ? { fontStyle: fontStyleText(fontStyle.value) } : {}),
60 },
61 },
62 ];
63};
Act III · The protocol3.1🎬 Grammar

The wire format as a grammar

src/protocol/streamingParser.ts

The 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
src/protocol/streamingParser.ts167 lines · TypeScript
⋯ 44 lines hidden (lines 1–44)
1/**
2 * The streaming wire protocol, as a grammar.
3 *
4 * The model emits one directive per line: `COUNT:n`, `SELECTOR:key=color`,
5 * `TOKEN:scope=color[,fontStyle]`, or `MESSAGE:text`. Structure is recognized with
6 * the parser combinators (`fp/parser`); the *fields* are then validated by the
7 * domain smart constructors, so a parsed directive is fully well-typed — an invalid
8 * color or selector can never ride along inside one.
9 *
10 * Two channels of failure are distinguished: a line that matches no directive
11 * (`UnknownDirective`) versus a recognized directive whose fields don't validate
12 * (`MalformedField`/`MissingDelimiter`/`EmptyMessage`). The application treats both
13 * as recoverable and tolerates a bounded number before giving up.
14 */
15 
16import {
17 isNonEmptyString,
18 matchTag,
19 none,
20 Option,
21 type OptionType,
22 P,
23 pipe,
24 Result,
25 type ResultType,
26 some,
27} from '../fp';
28import { parseColor } from '../domain/color';
29import {
30 count,
31 message,
32 parsePositiveInt,
33 selectorDirective,
34 type StreamingDirective,
35 tokenDirective,
36} from '../domain/directive';
37import { type ValidationError } from '../domain/errors';
38import { type FontStyle, parseFontStyle } from '../domain/fontStyle';
39import { parseSelector } from '../domain/selector';
40import { parseTokenScope } from '../domain/tokenScope';
41 
42export type ParseError =
43 | { readonly _tag: 'UnknownDirective'; readonly line: string }
44 | { readonly _tag: 'MissingDelimiter'; readonly expected: string; readonly line: string }
45 | { readonly _tag: 'MalformedField'; readonly cause: ValidationError }
46 | { readonly _tag: 'EmptyMessage' };
47 
48const malformedField = (cause: ValidationError): ParseError => ({ _tag: 'MalformedField', cause });
49 
50// ── Structural layer: split a line into (directive kind, raw payload) ──────────
51 
52type RawDirective =
53 | { readonly _tag: 'Count'; readonly payload: string }
54 | { readonly _tag: 'Selector'; readonly payload: string }
55 | { readonly _tag: 'Token'; readonly payload: string }
56 | { readonly _tag: 'Message'; readonly payload: string };
57 
58const tagged = (tag: RawDirective['_tag'], prefix: string): P.Parser<RawDirective> =>
59 pipe(
60 P.literal(prefix),
61 P.then(P.rest),
62 P.map((payload): RawDirective => ({ _tag: tag, payload }) as RawDirective),
63 );
64 
65const directiveStructure: P.Parser<RawDirective> = P.oneOf([
66 tagged('Count', 'COUNT:'),
67 tagged('Selector', 'SELECTOR:'),
68 tagged('Token', 'TOKEN:'),
69 tagged('Message', 'MESSAGE:'),
70]);
71 
72// ── Semantic layer: validate fields via the domain ────────────────────────────
73 
74/** Split on the first `=` only, so a value may itself contain `=` (it never does here). */
75const splitAssignment = (payload: string): OptionType<{ name: string; value: string }> => {
76 const i = payload.indexOf('=');
⋯ 91 lines hidden (lines 77–167)
77 return i < 0 ? none : some({ name: payload.slice(0, i), value: payload.slice(i + 1) });
78};
79 
80const decodeFontStyle = (
81 raw: OptionType<string>,
82): ResultType<ValidationError, OptionType<FontStyle>> =>
83 pipe(
84 raw,
85 Option.match({
86 onNone: (): ResultType<ValidationError, OptionType<FontStyle>> => Result.ok(none),
87 onSome: (s) => pipe(parseFontStyle(s), Result.map(some)),
88 }),
89 );
90 
91const decodeSelector = (payload: string): ResultType<ParseError, StreamingDirective> =>
92 pipe(
93 splitAssignment(payload),
94 Option.match({
95 onNone: (): ResultType<ParseError, StreamingDirective> =>
96 Result.err({ _tag: 'MissingDelimiter', expected: 'selector=color', line: payload }),
97 onSome: ({ name, value }) =>
98 pipe(
99 parseSelector(name),
100 Result.flatMap((selector) =>
101 pipe(
102 parseColor(value),
103 Result.map((color) => selectorDirective(selector, color)),
104 ),
105 ),
106 Result.mapErr(malformedField),
107 ),
108 }),
109 );
110 
111const decodeToken = (payload: string): ResultType<ParseError, StreamingDirective> =>
112 pipe(
113 splitAssignment(payload),
114 Option.match({
115 onNone: (): ResultType<ParseError, StreamingDirective> =>
116 Result.err({ _tag: 'MissingDelimiter', expected: 'scope=color[,fontStyle]', line: payload }),
117 onSome: ({ name, value }) => {
118 const comma = value.indexOf(',');
119 const colorRaw = comma < 0 ? value : value.slice(0, comma);
120 const styleRaw: OptionType<string> = comma < 0 ? none : some(value.slice(comma + 1));
121 return pipe(
122 parseTokenScope(name),
123 Result.flatMap((scope) =>
124 pipe(
125 parseColor(colorRaw),
126 Result.flatMap((color) =>
127 pipe(
128 decodeFontStyle(styleRaw),
129 Result.map((fontStyle) => tokenDirective(scope, color, fontStyle)),
130 ),
131 ),
132 ),
133 ),
134 Result.mapErr(malformedField),
135 );
136 },
137 }),
138 );
139 
140const decode = (raw: RawDirective): ResultType<ParseError, StreamingDirective> =>
141 matchTag(raw, {
142 Count: ({ payload }) =>
143 pipe(parsePositiveInt(payload), Result.map(count), Result.mapErr(malformedField)),
144 Message: ({ payload }): ResultType<ParseError, StreamingDirective> => {
145 const text = payload.trim();
146 return isNonEmptyString(text) ? Result.ok(message(text)) : Result.err({ _tag: 'EmptyMessage' });
147 },
148 Selector: ({ payload }) => decodeSelector(payload),
149 Token: ({ payload }) => decodeToken(payload),
150 });
151 
152/** Parse a single line into a typed directive, or a categorized parse error. */
153export const parseLine = (line: string): ResultType<ParseError, StreamingDirective> => {
154 const trimmed = line.trim();
155 const structure = P.run(directiveStructure, trimmed);
156 return structure._tag === 'ParseOk'
157 ? decode(structure.value)
158 : Result.err({ _tag: 'UnknownDirective', line: trimmed });
159};
160 
161export const renderParseError = (e: ParseError): string =>
162 matchTag(e, {
163 UnknownDirective: ({ line }) => `unrecognized directive: "${line}"`,
164 MissingDelimiter: ({ expected }) => `missing delimiter (expected ${expected})`,
165 MalformedField: ({ cause }) => cause._tag,
166 EmptyMessage: () => 'empty message',
167 });
Act IV · Ports & use cases4.1🎬 Interface table

The seam

src/ports/ports.ts

Every 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
src/ports/ports.ts140 lines · TypeScript
⋯ 39 lines hidden (lines 1–39)
1/**
2 * The ports: every interaction with the outside world, as an interface. The
3 * application layer depends only on these, so it is fully exercisable with in-memory
4 * fakes (see the tests). VS Code and the OpenAI SDK appear only in `adapters/`.
5 */
6 
7import {
8 type AsyncResultType,
9 type Brand,
10 type NonEmptyArray,
11 type NonEmptyString,
12 type OptionType,
13 type Redacted,
14} from '../fp';
15import { type ApiKey } from '../domain/apiKey';
16import { type Coverage } from '../domain/coverage';
17import { type ModelId } from '../domain/model';
18import { type WriteTarget } from '../domain/scope';
19import { type CurrentTheme, type ThemeSetting } from '../domain/theme';
20import { type Vibe } from '../domain/vibe';
21import {
22 type ConfigError,
23 type OpenAiError,
24 type PromptError,
25 type Severity,
26 type StorageError,
27 type UiError,
28 type UserMessage,
29} from './errors';
30 
31/** Milliseconds since an arbitrary epoch — injected so the message throttle is testable. */
32export type Millis = Brand<number, 'Millis'>;
33 
34export interface Clock {
35 readonly now: () => Millis;
37 
38/** Structured logging. Secrets are `Redacted`, so they render as `<redacted>` even here. */
39export interface Logger {
40 readonly debug: (message: string, data?: Readonly<Record<string, unknown>>) => void;
41 readonly error: (message: string, data?: Readonly<Record<string, unknown>>) => void;
43 
44export interface SecretStore {
45 readonly get: () => AsyncResultType<StorageError, OptionType<string>>;
46 readonly set: (key: Redacted<ApiKey>) => AsyncResultType<StorageError, void>;
47 readonly clear: () => AsyncResultType<StorageError, void>;
49 
50export interface GenerationRequest {
51 readonly key: Redacted<ApiKey>;
52 readonly model: ModelId;
53 readonly system: NonEmptyString;
54 readonly user: NonEmptyString;
56 
57export interface OpenAiGateway {
58 /** Prove a key works (used after the user enters one). */
59 readonly verify: (key: Redacted<ApiKey>) => AsyncResultType<OpenAiError, void>;
60 readonly listGptModels: (
61 key: Redacted<ApiKey>,
62 ) => AsyncResultType<OpenAiError, NonEmptyArray<ModelId>>;
63 /** Open a streamed completion; the iterable yields raw content deltas. */
64 readonly streamTheme: (
65 request: GenerationRequest,
66 ) => AsyncResultType<OpenAiError, AsyncIterable<string>>;
68 
69export interface ConfigStore {
70 readonly readCurrentTheme: () => CurrentTheme;
71 readonly hasWorkspaceFolders: () => boolean;
72 /** Apply one setting, trying the preference order until one target succeeds. */
73 readonly applySetting: (
74 setting: ThemeSetting,
75 preference: NonEmptyArray<WriteTarget>,
76 ) => AsyncResultType<ConfigError, WriteTarget>;
77 readonly reset: () => AsyncResultType<ConfigError, void>;
⋯ 62 lines hidden (lines 79–140)
79 
80export interface PromptLibrary {
81 readonly systemPrompt: () => AsyncResultType<PromptError, NonEmptyString>;
83 
84/** Persisted, non-secret preferences (backed by VS Code `globalState`). */
85export interface Preferences {
86 readonly selectedModel: () => OptionType<ModelId>;
87 readonly selectModel: (model: ModelId) => Promise<void>;
88 readonly clearModel: () => Promise<void>;
90 
91// ── UI ────────────────────────────────────────────────────────────────────────
92 
93export interface Suggestion {
94 readonly label: string;
96 
97export interface ProgressUpdate {
98 readonly message: string;
99 readonly percent: OptionType<number>;
101 
102export interface ProgressReporter {
103 readonly report: (update: ProgressUpdate) => void;
105 
106export interface CancellationSignal {
107 readonly isCancelled: () => boolean;
109 
110export type KeepOrReset = 'keep' | 'reset';
111 
112export interface CompletionSummary {
113 readonly applied: number;
114 readonly coverage: Coverage;
116 
117export interface Ui {
118 /** QuickPick seeded with suggestions; validates input to a `Vibe`, `None` on cancel. */
119 readonly pickVibe: (
120 suggestions: ReadonlyArray<Suggestion>,
121 ) => AsyncResultType<UiError, OptionType<Vibe>>;
122 /** Input box for the API key; returns the raw string, `None` on cancel. */
123 readonly promptForApiKey: () => AsyncResultType<UiError, OptionType<string>>;
124 /** Pick from a non-empty model list, `None` on cancel. */
125 readonly pickModel: (
126 models: NonEmptyArray<ModelId>,
127 current: OptionType<ModelId>,
128 ) => AsyncResultType<UiError, OptionType<ModelId>>;
129 /** Run a long task inside a cancellable progress notification. */
130 readonly runWithProgress: <A>(
131 title: string,
132 task: (reporter: ProgressReporter, signal: CancellationSignal) => Promise<A>,
133 ) => Promise<A>;
134 /** On cancel mid-stream: keep the partial theme or reset to the prior state. */
135 readonly confirmCancellation: (appliedCount: number) => AsyncResultType<UiError, KeepOrReset>;
136 /** On success: acknowledge, offering an immediate reset. */
137 readonly announceCompletion: (summary: CompletionSummary) => AsyncResultType<UiError, KeepOrReset>;
138 readonly announceReset: () => Promise<void>;
139 readonly notify: (message: UserMessage, severity: Severity) => Promise<void>;

Port error unions + their UserMessage rendering, centralized.

src/ports/errors.ts · 104 lines
src/ports/errors.ts104 lines · TypeScript
1/**
2 * Error vocabularies for the side-effecting boundary, plus their translation into
3 * user-facing copy. Each port speaks a small, closed error union; the `render*`
4 * functions are the single place those become `UserMessage`s, so wording lives in
5 * one spot and adding a variant forces an explicit copy decision (exhaustive match).
6 */
7 
8import { matchTag, none, type OptionType, some } from '../fp';
9import { type WriteTarget } from '../domain/scope';
10 
11export type Severity = 'info' | 'warning' | 'error';
12 
13export interface UserMessage {
14 readonly title: string;
15 readonly detail: OptionType<string>;
16 readonly suggestion: OptionType<string>;
18 
19export const userMessage = (
20 title: string,
21 options: { readonly suggestion?: string; readonly detail?: string } = {},
22): UserMessage => ({
23 title,
24 detail: options.detail === undefined ? none : some(options.detail),
25 suggestion: options.suggestion === undefined ? none : some(options.suggestion),
26});
27 
28// ── Port error unions ─────────────────────────────────────────────────────────
29 
30export type StorageError = {
31 readonly _tag: 'StorageFailure';
32 readonly operation: 'read' | 'write' | 'clear';
33};
34 
35export type PromptError = { readonly _tag: 'PromptUnavailable' } | { readonly _tag: 'PromptEmpty' };
36 
37export type OpenAiError =
38 | { readonly _tag: 'AuthFailed' }
39 | { readonly _tag: 'RateLimited' }
40 | { readonly _tag: 'Network' }
41 | { readonly _tag: 'NoModelsAvailable' }
42 | { readonly _tag: 'Unexpected'; readonly detail: string };
43 
44export type ConfigError =
45 | { readonly _tag: 'WriteFailed'; readonly target: WriteTarget }
46 | { readonly _tag: 'AllTargetsFailed' };
47 
48export type UiError = { readonly _tag: 'UiFailure' };
49 
50// ── Rendering ─────────────────────────────────────────────────────────────────
51 
52export const renderOpenAiError = (e: OpenAiError): UserMessage =>
53 matchTag(e, {
54 AuthFailed: () =>
55 userMessage('🔑 OpenAI rejected the API key', {
56 suggestion: 'Clear the key and enter a valid one, then check your account access.',
57 }),
58 RateLimited: () =>
59 userMessage('🔑 OpenAI rate limit or quota reached', {
60 suggestion: 'Check your plan and usage on the OpenAI dashboard, then try again.',
61 }),
62 Network: () =>
63 userMessage('🌐 Could not reach OpenAI', {
64 suggestion: 'Check your internet connection and try again.',
65 }),
66 NoModelsAvailable: () =>
67 userMessage('⚠️ No models available for this API key', {
68 suggestion: 'Verify the key has access to GPT models.',
69 }),
70 Unexpected: ({ detail }) =>
71 userMessage('❌ The OpenAI request failed', { detail, suggestion: 'Please try again.' }),
72 });
73 
74export const renderPromptError = (e: PromptError): UserMessage =>
75 matchTag(e, {
76 PromptUnavailable: () =>
77 userMessage('❌ Could not load the theme prompt', {
78 suggestion: 'Try reinstalling Vibe Themer.',
79 }),
80 PromptEmpty: () =>
81 userMessage('❌ The theme prompt is empty', {
82 suggestion: 'Try reinstalling Vibe Themer.',
83 }),
84 });
85 
86export const renderConfigError = (e: ConfigError): UserMessage =>
87 matchTag(e, {
88 WriteFailed: ({ target }) =>
89 userMessage(`❌ Could not write theme to ${target} settings`, {
90 suggestion: 'Check VS Code permissions and try restarting the editor.',
91 }),
92 AllTargetsFailed: () =>
93 userMessage('❌ Could not apply the theme to any settings scope', {
94 suggestion: 'Check VS Code permissions and try restarting the editor.',
95 }),
96 });
97 
98export const renderStorageError = (e: StorageError): UserMessage =>
99 userMessage(`❌ Secure storage ${e.operation} failed`, {
100 suggestion: 'Try restarting VS Code.',
101 });
102 
103export const renderUiError = (_e: UiError): UserMessage =>
104 userMessage('❌ A UI operation failed unexpectedly', { suggestion: 'Please try again.' });

The Capabilities record.

src/ports/index.ts · 29 lines
src/ports/index.ts29 lines · TypeScript
1export * from './errors';
2export * from './ports';
3 
4import {
5 type Clock,
6 type ConfigStore,
7 type Logger,
8 type OpenAiGateway,
9 type Preferences,
10 type PromptLibrary,
11 type SecretStore,
12 type Ui,
13} from './ports';
14 
15/**
16 * The full set of capabilities the application needs, wired once at the composition
17 * root and threaded into use cases. Passing one record keeps signatures small and
18 * makes the dependency surface of every use case explicit.
19 */
20export interface Capabilities {
21 readonly secrets: SecretStore;
22 readonly openai: OpenAiGateway;
23 readonly config: ConfigStore;
24 readonly prompts: PromptLibrary;
25 readonly preferences: Preferences;
26 readonly ui: Ui;
27 readonly clock: Clock;
28 readonly logger: Logger;
Act IV · Ports & use cases4.2🎬 State machine

Provisioning the key

src/application/provisionApiKey.ts

A 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
src/application/provisionApiKey.ts95 lines · TypeScript
⋯ 39 lines hidden (lines 1–39)
1/**
2 * Provisioning the API key, as an explicit flow:
3 *
4 * stored & well-formed → trust it (no network round-trip on every command)
5 * stored & malformed → prompt for a new one
6 * absent → prompt, verify against OpenAI, then store
7 *
8 * The result is a `Redacted<ApiKey>`; the raw key never appears in a return value,
9 * a log line, or an error variant. Benign exits (the user dismissing the prompt)
10 * render to `None`, so the command layer shows nothing.
11 */
12 
13import { type AsyncResultType, err, matchTag, none, ok, type OptionType, type Redacted, some } from '../fp';
14import { type ApiKey, type ApiKeyError, parseApiKey, renderApiKeyError } from '../domain/apiKey';
15import {
16 type Capabilities,
17 type OpenAiError,
18 renderOpenAiError,
19 renderStorageError,
20 type StorageError,
21 type UiError,
22 type UserMessage,
23 userMessage,
24} from '../ports';
25 
26export type ProvisionError =
27 | { readonly _tag: 'Cancelled' }
28 | { readonly _tag: 'InvalidKeyFormat'; readonly error: ApiKeyError }
29 | { readonly _tag: 'Storage'; readonly error: StorageError }
30 | { readonly _tag: 'OpenAi'; readonly error: OpenAiError }
31 | { readonly _tag: 'Ui'; readonly error: UiError };
32 
33type ProvisionDeps = Pick<Capabilities, 'secrets' | 'openai' | 'ui'>;
34 
35const promptAndStore = async (
36 caps: ProvisionDeps,
37): AsyncResultType<ProvisionError, Redacted<ApiKey>> => {
38 const entered = await caps.ui.promptForApiKey();
39 if (entered._tag === 'Err') {
40 return err({ _tag: 'Ui', error: entered.error });
41 }
42 if (entered.value._tag === 'None') {
43 return err({ _tag: 'Cancelled' });
44 }
45 
46 const parsed = parseApiKey(entered.value.value);
47 if (parsed._tag === 'Err') {
48 return err({ _tag: 'InvalidKeyFormat', error: parsed.error });
49 }
50 const key = parsed.value;
51 
52 const verified = await caps.openai.verify(key);
53 if (verified._tag === 'Err') {
54 return err({ _tag: 'OpenAi', error: verified.error });
55 }
56 
57 const saved = await caps.secrets.set(key);
58 if (saved._tag === 'Err') {
59 return err({ _tag: 'Storage', error: saved.error });
60 }
61 return ok(key);
62};
63 
64export const provisionApiKey = async (
65 caps: ProvisionDeps,
66): AsyncResultType<ProvisionError, Redacted<ApiKey>> => {
67 const stored = await caps.secrets.get();
68 if (stored._tag === 'Err') {
69 return err({ _tag: 'Storage', error: stored.error });
70 }
71 
72 if (stored.value._tag === 'Some') {
73 const parsed = parseApiKey(stored.value.value);
74 if (parsed._tag === 'Ok') {
75 return ok(parsed.value);
76 }
77 // A malformed stored key falls through to a fresh prompt.
78 }
⋯ 17 lines hidden (lines 79–95)
79 return promptAndStore(caps);
80};
81 
82/** `None` for benign exits (cancellation); `Some` when there is something to tell the user. */
83export const renderProvisionError = (e: ProvisionError): OptionType<UserMessage> =>
84 matchTag(e, {
85 Cancelled: () => none,
86 Ui: () => none,
87 InvalidKeyFormat: ({ error }) =>
88 some(
89 userMessage(renderApiKeyError(error), {
90 suggestion: 'Run the command again and enter a valid key.',
91 }),
92 ),
93 Storage: ({ error }) => some(renderStorageError(error)),
94 OpenAi: ({ error }) => some(renderOpenAiError(error)),
95 });
Act IV · Ports & use cases4.3🎬 Annotated walkthrough

The streaming loop — the heart

src/application/generateTheme.ts

generateTheme 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
src/application/generateTheme.ts292 lines · TypeScript
⋯ 95 lines hidden (lines 1–95)
1/**
2 * The Change Theme use case: provision a key, get a vibe, open the model stream,
3 * and apply each directive to the editor as it arrives.
4 *
5 * The orchestration lives here; every *decision* is delegated to a pure function
6 * (`parseLine`, `progressPercent`, `shouldShowMessage`, `coverage`) or a domain
7 * constructor, and every *effect* goes through a port. That split is what makes the
8 * whole flow testable with in-memory fakes — see `test/generateTheme.test.ts`.
9 *
10 * Benign exits (user dismisses the key prompt or the vibe picker) are *outcomes*,
11 * not errors. Genuine failures (prompt missing, OpenAI down, too many malformed
12 * lines) are errors.
13 */
14 
15import {
16 type AsyncResultType,
17 err,
18 matchTag,
19 type NonEmptyArray,
20 type NonEmptyString,
21 none,
22 ok,
23 Option,
24 type Redacted,
25 type ResultType,
26 some,
27} from '../fp';
28import { type ApiKey } from '../domain/apiKey';
29import { coverage } from '../domain/coverage';
30import { type StreamingDirective } from '../domain/directive';
31import { DEFAULT_MODEL } from '../domain/model';
32import { type WriteTarget, writePreference } from '../domain/scope';
33import { type ThemeSetting } from '../domain/theme';
34import { type Vibe } from '../domain/vibe';
35import { parseLine, renderParseError } from '../protocol/streamingParser';
36import {
37 type CancellationSignal,
38 type Capabilities,
39 type KeepOrReset,
40 type Millis,
41 type OpenAiError,
42 type ProgressReporter,
43 type PromptError,
44 renderConfigError,
45 renderOpenAiError,
46 renderPromptError,
47 renderUiError,
48 type UiError,
49 type UserMessage,
50 userMessage,
51} from '../ports';
52import { buildUserPrompt } from './context';
53import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
54import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
55import { curatedSuggestions } from './suggestions';
56 
57const MAX_RECOVERABLE_ERRORS = 5;
58const DEFAULT_EXPECTED_SETTINGS = 120;
59const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
60const PROGRESS_TITLE = 'Vibe Themer';
61 
62export type GenerationOutcome =
63 | { readonly _tag: 'NoKey' }
64 | { readonly _tag: 'NoVibe' }
65 | { readonly _tag: 'Completed'; readonly applied: number }
66 | { readonly _tag: 'CancelledKept'; readonly applied: number }
67 | { readonly _tag: 'CancelledReset' };
68 
69export type GenerationError =
70 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
71 | { readonly _tag: 'Ui'; readonly error: UiError }
72 | { readonly _tag: 'Prompt'; readonly error: PromptError }
73 | { readonly _tag: 'OpenAi'; readonly error: OpenAiError }
74 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
75 
76type ApplyStatus = 'ok' | 'error';
77 
78type ConsumeOutcome =
79 | { readonly _tag: 'Completed'; readonly applied: number }
80 | { readonly _tag: 'Cancelled'; readonly applied: number }
81 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
82 
83// ── The streaming consume loop ─────────────────────────────────────────────────
84 
85const consumeStream = async (
86 caps: Capabilities,
87 stream: AsyncIterable<string>,
88 reporter: ProgressReporter,
89 signal: CancellationSignal,
90 preference: NonEmptyArray<WriteTarget>,
91): Promise<ConsumeOutcome> => {
92 let buffer = '';
93 let applied = 0;
94 let expected = DEFAULT_EXPECTED_SETTINGS;
95 let errors = 0;
96 let lastError = '';
97 let currentMessage = INITIAL_MESSAGE;
98 let lastMessageAt = neverShown;
99 
100 const reportProgress = (): void =>
101 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
102 
103 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
104 const result = await caps.config.applySetting(setting, preference);
105 if (result._tag === 'Ok') {
106 applied += 1;
107 reportProgress();
108 return 'ok';
109 }
110 lastError = renderConfigError(result.error).title;
111 return 'error';
112 };
113 
114 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
115 matchTag(directive, {
116 Count: async ({ total }): Promise<ApplyStatus> => {
117 expected = total;
118 currentMessage = `🎯 Planning ${total} theme settings...`;
119 reportProgress();
120 return 'ok';
121 },
122 Message: async ({ text }): Promise<ApplyStatus> => {
123 const now: Millis = caps.clock.now();
124 if (shouldShowMessage(lastMessageAt, now)) {
125 currentMessage = `${text}`;
126 lastMessageAt = markShown(now);
127 reporter.report({ message: currentMessage, percent: none });
128 }
129 return 'ok';
130 },
131 Selector: ({ selector, color }): Promise<ApplyStatus> =>
132 applySetting({ _tag: 'SelectorSetting', selector, color }),
133 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
134 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
135 });
136 
137 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
138 if (line.trim() === '') {
139 return null;
140 }
141 const parsed = parseLine(line);
142 if (parsed._tag === 'Ok') {
143 const status = await applyDirective(parsed.value);
144 if (status === 'error') {
145 errors += 1;
146 }
147 } else {
148 errors += 1;
149 lastError = renderParseError(parsed.error);
150 caps.logger.debug('directive parse failed', { line, error: lastError });
⋯ 142 lines hidden (lines 151–292)
151 }
152 return errors >= MAX_RECOVERABLE_ERRORS ? { _tag: 'Aborted', applied, lastError } : null;
153 };
154 
155 for await (const chunk of stream) {
156 if (signal.isCancelled()) {
157 break;
158 }
159 buffer += chunk;
160 const segments = buffer.split('\n');
161 buffer = segments.pop() ?? '';
162 for (const line of segments) {
163 if (signal.isCancelled()) {
164 break;
165 }
166 const aborted = await handleLine(line);
167 if (aborted !== null) {
168 return aborted;
169 }
170 }
171 }
172 
173 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
174 if (!signal.isCancelled() && buffer.trim() !== '') {
175 const parsed = parseLine(buffer);
176 if (parsed._tag === 'Ok') {
177 await applyDirective(parsed.value);
178 }
179 }
180 
181 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
182};
183 
184// ── Finalization: keep/reset modals after the progress notification closes ─────
185 
186const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
187 result._tag === 'Ok' ? result.value : 'keep';
188 
189const finalize = (
190 caps: Capabilities,
191 consumed: ConsumeOutcome,
192): AsyncResultType<GenerationError, GenerationOutcome> =>
193 matchTag(consumed, {
194 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
195 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
196 
197 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
198 const choice = await caps.ui.announceCompletion({
199 applied,
200 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
201 });
202 if (decideKeepOrReset(choice) === 'reset') {
203 await resetQuietly(caps);
204 }
205 return ok({ _tag: 'Completed', applied });
206 },
207 
208 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
209 const choice = await caps.ui.confirmCancellation(applied);
210 if (decideKeepOrReset(choice) === 'reset') {
211 await resetQuietly(caps);
212 return ok({ _tag: 'CancelledReset' });
213 }
214 return ok({ _tag: 'CancelledKept', applied });
215 },
216 });
217 
218const resetQuietly = async (caps: Capabilities): Promise<void> => {
219 const result = await caps.config.reset();
220 if (result._tag === 'Err') {
221 caps.logger.error('reset after generation failed', {
222 error: renderConfigError(result.error).title,
223 });
224 }
225};
226 
227// ── The use case ───────────────────────────────────────────────────────────────
228 
229const runGeneration = async (
230 caps: Capabilities,
231 key: Redacted<ApiKey>,
232 vibe: Vibe,
233 system: NonEmptyString,
234): AsyncResultType<GenerationError, GenerationOutcome> => {
235 const current = caps.config.readCurrentTheme();
236 const user = buildUserPrompt(current, vibe);
237 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
238 const preference = writePreference(caps.config.hasWorkspaceFolders());
239 
240 const streamResult = await caps.openai.streamTheme({ key, model, system, user });
241 if (streamResult._tag === 'Err') {
242 return err({ _tag: 'OpenAi', error: streamResult.error });
243 }
244 
245 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
246 reporter.report({ message: INITIAL_MESSAGE, percent: none });
247 return consumeStream(caps, streamResult.value, reporter, signal, preference);
248 });
249 
250 return finalize(caps, consumed);
251};
252 
253export const generateTheme = async (
254 caps: Capabilities,
255): AsyncResultType<GenerationError, GenerationOutcome> => {
256 const keyResult = await provisionApiKey(caps);
257 if (keyResult._tag === 'Err') {
258 return keyResult.error._tag === 'Cancelled'
259 ? ok({ _tag: 'NoKey' })
260 : err({ _tag: 'Provision', error: keyResult.error });
261 }
262 
263 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
264 if (vibeResult._tag === 'Err') {
265 return err({ _tag: 'Ui', error: vibeResult.error });
266 }
267 if (vibeResult.value._tag === 'None') {
268 return ok({ _tag: 'NoVibe' });
269 }
270 
271 const promptResult = await caps.prompts.systemPrompt();
272 if (promptResult._tag === 'Err') {
273 return err({ _tag: 'Prompt', error: promptResult.error });
274 }
275 
276 return runGeneration(caps, keyResult.value, vibeResult.value.value, promptResult.value);
277};
278 
279export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
280 matchTag(e, {
281 Provision: ({ error }) => renderProvisionError(error),
282 Ui: ({ error }) => some(renderUiError(error)),
283 Prompt: ({ error }) => some(renderPromptError(error)),
284 OpenAi: ({ error }) => some(renderOpenAiError(error)),
285 Aborted: ({ applied, lastError }) =>
286 some(
287 userMessage('⚠️ Theme generation was interrupted', {
288 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
289 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
290 }),
291 ),
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
src/application/progress.ts37 lines · TypeScript
⋯ 23 lines hidden (lines 1–23)
1/**
2 * Pure progress arithmetic for the streaming UX. Kept separate from the effectful
3 * loop so the fiddly bits — the percentage cap, the message throttle, the coverage
4 * label — are unit-testable in isolation.
5 *
6 * Two v1 bugs are fixed here by construction:
7 * - the first progress message is shown immediately (the throttle's "last shown"
8 * time is an `Option`, `None` until a message actually appears — v1 seeded it
9 * with `Date.now()`, so the intended first-message bypass never fired);
10 * - the completeness label is relative to the model's own estimate rather than the
11 * fixed `<50/<80` thresholds, so a deliberately small theme isn't mislabeled.
12 */
13 
14import { type Brand, matchTag, none, type OptionType, some } from '../fp';
15import { type Millis } from '../ports';
16 
17export type Percent = Brand<number, 'Percent'>;
18 
19const clampPercent = (n: number): Percent =>
20 (n < 0 ? 0 : n > 100 ? 100 : Math.floor(n)) as Percent;
21 
22/** Progress toward the expected count, capped at 99 until completion (matches v1). */
23export const progressPercent = (applied: number, expected: number): Percent =>
24 expected <= 0 ? clampPercent(0) : clampPercent(Math.min((applied / expected) * 100, 99));
25 
26export const MIN_MESSAGE_GAP_MS = 800;
27 
28/** Show a message if none has shown yet, or if the minimum gap has elapsed. */
29export const shouldShowMessage = (lastShownAt: OptionType<Millis>, now: Millis): boolean =>
30 matchTag(lastShownAt, {
31 None: () => true,
32 Some: ({ value }) => now - value >= MIN_MESSAGE_GAP_MS,
33 });
34 
35export const markShown = (now: Millis): OptionType<Millis> => some(now);
36 
37export const neverShown: OptionType<Millis> = none;

Builds the user prompt; injects current customizations for iteration.

src/application/context.ts · 62 lines
src/application/context.ts62 lines · TypeScript
1/**
2 * Builds the user-prompt sent to the model. When the editor already carries theme
3 * customizations, their effective values are summarized first so the model can
4 * iterate ("make it warmer") instead of starting over. The format matches v1's
5 * `formatCurrentThemeContext`, but the data is now sourced correctly per scope.
6 */
7 
8import { none, type NonEmptyString, type OptionType, some } from '../fp';
9import {
10 type CurrentTheme,
11 effectiveColors,
12 effectiveTokens,
13 hasCustomizations,
14} from '../domain/theme';
15import { type Vibe, vibeText } from '../domain/vibe';
16 
17const formatContext = (current: CurrentTheme): OptionType<string> => {
18 if (!hasCustomizations(current)) {
19 return none;
20 }
21 
22 const lines: string[] = [];
23 
24 const colors = effectiveColors(current);
25 const colorEntries = Object.entries(colors);
26 if (colorEntries.length > 0) {
27 lines.push('CURRENT THEME CONTEXT:');
28 lines.push(`Active workbench color overrides (${colorEntries.length} settings):`);
29 for (const [key, value] of colorEntries) {
30 lines.push(`- ${key}: ${value}`);
31 }
32 }
33 
34 const tokens = effectiveTokens(current);
35 const tokenEntries = Object.entries(tokens);
36 if (tokenEntries.length > 0) {
37 if (lines.length > 0) {
38 lines.push('');
39 }
40 lines.push(`Active syntax highlighting overrides (${tokenEntries.length} settings):`);
41 for (const [key, value] of tokenEntries) {
42 lines.push(`- ${key}: ${JSON.stringify(value)}`);
43 }
44 }
45 
46 if (lines.length === 0) {
47 return none;
48 }
49 lines.push('');
50 lines.push('User request:');
51 return some(lines.join('\n'));
52};
53 
54export const buildUserPrompt = (current: CurrentTheme, vibe: Vibe): NonEmptyString => {
55 const context = formatContext(current);
56 // `vibe` is non-empty by construction, so either branch yields a non-empty string.
57 const body =
58 context._tag === 'Some'
59 ? `${context.value}\n${vibeText(vibe)}`
60 : `Theme description: ${vibeText(vibe)}`;
61 return body as NonEmptyString;
62};

Reset theme, clear key, select/reset model.

src/application/maintenance.ts · 65 lines
src/application/maintenance.ts65 lines · TypeScript
1/**
2 * The smaller commands: reset theme, clear key, select/reset model. Each is a short
3 * port choreography returning `void` (their feedback is a notification, not a value).
4 */
5 
6import { type Capabilities, renderConfigError, renderOpenAiError, renderStorageError, userMessage } from '../ports';
7import { modelText } from '../domain/model';
8import { provisionApiKey, renderProvisionError } from './provisionApiKey';
9 
10export const resetTheme = async (caps: Capabilities): Promise<void> => {
11 const result = await caps.config.reset();
12 if (result._tag === 'Ok') {
13 await caps.ui.announceReset();
14 } else {
15 await caps.ui.notify(renderConfigError(result.error), 'error');
16 }
17};
18 
19export const clearApiKey = async (caps: Capabilities): Promise<void> => {
20 const result = await caps.secrets.clear();
21 if (result._tag === 'Ok') {
22 await caps.ui.notify(userMessage('🔑 OpenAI API key cleared'), 'info');
23 } else {
24 await caps.ui.notify(renderStorageError(result.error), 'error');
25 }
26};
27 
28export const resetModel = async (caps: Capabilities): Promise<void> => {
29 await caps.preferences.clearModel();
30 await caps.ui.notify(
31 userMessage('🔄 Model selection reset', { detail: 'The default model will be used.' }),
32 'info',
33 );
34};
35 
36export const selectModel = async (caps: Capabilities): Promise<void> => {
37 const keyResult = await provisionApiKey(caps);
38 if (keyResult._tag === 'Err') {
39 const message = renderProvisionError(keyResult.error);
40 if (message._tag === 'Some') {
41 await caps.ui.notify(message.value, 'error');
42 }
43 return;
44 }
45 
46 const modelsResult = await caps.openai.listGptModels(keyResult.value);
47 if (modelsResult._tag === 'Err') {
48 await caps.ui.notify(renderOpenAiError(modelsResult.error), 'error');
49 return;
50 }
51 
52 const picked = await caps.ui.pickModel(modelsResult.value, caps.preferences.selectedModel());
53 if (picked._tag === 'Err' || picked.value._tag === 'None') {
54 return;
55 }
56 
57 const model = picked.value.value;
58 await caps.preferences.selectModel(model);
59 await caps.ui.notify(
60 userMessage(`🎯 Model set to ${modelText(model)}`, {
61 detail: 'This model will be used for future theme generations.',
62 }),
63 'info',
64 );
65};
src/application/suggestions.ts · 15 lines
src/application/suggestions.ts15 lines · TypeScript
1/**
2 * Curated example vibes, shown in the QuickPick to solve the blank-page problem.
3 * Pure data; the UI adapter samples and shuffles them for variety per session.
4 * (These are v1's five README examples, unchanged.)
5 */
6 
7import { type Suggestion } from '../ports';
8 
9export const curatedSuggestions: ReadonlyArray<Suggestion> = [
10 { label: 'warm sunset over mountains' },
11 { label: 'minimal dark forest' },
12 { label: 'vibrant retro 80s' },
13 { label: 'the feeling of finding a $20 bill in old jeans' },
14 { label: 'existential dread but make it cozy' },
15];
Act V · The shell5.1🎬 Spotlight

VS Code adapters

src/adapters/vscode/config.tssrc/adapters/vscode/secrets.ts

The 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
src/adapters/vscode/config.ts105 lines · TypeScript
⋯ 37 lines hidden (lines 1–37)
1import * as vscode from 'vscode';
2import { type AsyncResultType, err, type NonEmptyArray, ok } from '../../fp';
3import { applyColor, applyTokenRule, type TextMateRule } from '../../domain/customizations';
4import { type WriteTarget } from '../../domain/scope';
5import {
6 type ColorMap,
7 type CurrentTheme,
8 type ScopedTheme,
9 type ThemeSetting,
10 type TokenCustomizations,
11} from '../../domain/theme';
12import { type ConfigError, type ConfigStore } from '../../ports';
13 
14const COLOR_KEY = 'workbench.colorCustomizations';
15const TOKEN_KEY = 'editor.tokenColorCustomizations';
16 
17interface TokenCustomizationsShape {
18 readonly textMateRules?: ReadonlyArray<TextMateRule>;
19 readonly [key: string]: unknown;
21 
22// VS Code config is user-editable JSON, so reads are validated, never trusted blindly.
23const isPlainObject = (value: unknown): value is Record<string, unknown> =>
24 typeof value === 'object' && value !== null && !Array.isArray(value);
25 
26const asColorMap = (value: unknown): ColorMap => (isPlainObject(value) ? (value as ColorMap) : {});
27 
28const asTokenShape = (value: unknown): TokenCustomizationsShape =>
29 isPlainObject(value) ? (value as TokenCustomizationsShape) : {};
30 
31const targetOf = (target: WriteTarget): vscode.ConfigurationTarget =>
32 target === 'global'
33 ? vscode.ConfigurationTarget.Global
34 : vscode.ConfigurationTarget.Workspace;
35 
36const scopedTheme = (colors: unknown, tokens: unknown): ScopedTheme => ({
37 colors: asColorMap(colors),
38 tokens: isPlainObject(tokens) ? (tokens as TokenCustomizations) : {},
39});
40 
41const applyToTarget = async (
42 setting: ThemeSetting,
43 target: WriteTarget,
44): AsyncResultType<ConfigError, void> => {
45 try {
46 const config = vscode.workspace.getConfiguration();
47 const vsTarget = targetOf(target);
48 
49 if (setting._tag === 'SelectorSetting') {
50 const existing = asColorMap(config.get<unknown>(COLOR_KEY));
51 const next = applyColor(existing, setting.selector, setting.color);
52 await config.update(COLOR_KEY, next, vsTarget);
53 } else {
54 const existing = asTokenShape(config.get<unknown>(TOKEN_KEY));
55 const rules = Array.isArray(existing.textMateRules) ? existing.textMateRules : [];
56 const nextRules = applyTokenRule(rules, setting.scope, setting.color, setting.fontStyle);
57 await config.update(TOKEN_KEY, { ...existing, textMateRules: nextRules }, vsTarget);
58 }
59 return ok(undefined);
60 } catch {
⋯ 37 lines hidden (lines 61–97)
61 return err({ _tag: 'WriteFailed', target });
62 }
63};
64 
65export const createConfigStore = (): ConfigStore => ({
66 readCurrentTheme: (): CurrentTheme => {
67 const config = vscode.workspace.getConfiguration();
68 const colors = config.inspect<unknown>(COLOR_KEY);
69 const tokens = config.inspect<unknown>(TOKEN_KEY);
70 return {
71 global: scopedTheme(colors?.globalValue, tokens?.globalValue),
72 workspace: scopedTheme(colors?.workspaceValue, tokens?.workspaceValue),
73 };
74 },
75 
76 hasWorkspaceFolders: (): boolean => (vscode.workspace.workspaceFolders?.length ?? 0) > 0,
77 
78 applySetting: async (
79 setting: ThemeSetting,
80 preference: NonEmptyArray<WriteTarget>,
81 ): AsyncResultType<ConfigError, WriteTarget> => {
82 for (const target of preference) {
83 const result = await applyToTarget(setting, target);
84 if (result._tag === 'Ok') {
85 return ok(target);
86 }
87 }
88 return err({ _tag: 'AllTargetsFailed' });
89 },
90 
91 reset: async (): AsyncResultType<ConfigError, void> => {
92 const config = vscode.workspace.getConfiguration();
93 // `allSettled` never rejects, so inspect the outcomes: surface failure only when
94 // every target failed (a workspace-target failure with no folder open is normal).
95 const outcomes = await Promise.allSettled([
96 config.update(COLOR_KEY, undefined, vscode.ConfigurationTarget.Workspace),
97 config.update(TOKEN_KEY, undefined, vscode.ConfigurationTarget.Workspace),
98 config.update(COLOR_KEY, undefined, vscode.ConfigurationTarget.Global),
99 config.update(TOKEN_KEY, undefined, vscode.ConfigurationTarget.Global),
100 ]);
101 return outcomes.every((o) => o.status === 'rejected')
102 ? err({ _tag: 'AllTargetsFailed' })
103 : ok(undefined);
104 },
105});

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
src/adapters/vscode/secrets.ts37 lines · TypeScript
⋯ 19 lines hidden (lines 1–19)
1import type * as vscode from 'vscode';
2import { AsyncResult, expose, Option, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { type SecretStore, type StorageError } from '../../ports';
5 
6const STORAGE_KEY = 'openaiApiKey';
7 
8const storageError = (operation: StorageError['operation']): StorageError => ({
9 _tag: 'StorageFailure',
10 operation,
11});
12 
13/**
14 * Backs the key with VS Code's encrypted `SecretStorage`. `expose` appears exactly
15 * once, in `set`, which is the only place the raw key is needed.
16 */
17export const createSecretStore = (secrets: vscode.SecretStorage): SecretStore => ({
18 get: () =>
19 AsyncResult.tryCatch(
20 async () => Option.fromNullable(await secrets.get(STORAGE_KEY)),
21 (): StorageError => storageError('read'),
22 ),
23 set: (key: Redacted<ApiKey>) =>
24 AsyncResult.tryCatch(
25 async () => {
26 await secrets.store(STORAGE_KEY, expose(key));
27 },
28 (): StorageError => storageError('write'),
29 ),
30 clear: () =>
31 AsyncResult.tryCatch(
32 async () => {
33 await secrets.delete(STORAGE_KEY);
34 },
35 (): StorageError => storageError('clear'),
36 ),
37});
src/adapters/vscode/ui.ts · 186 lines
src/adapters/vscode/ui.ts186 lines · TypeScript
1import * as vscode from 'vscode';
2import { none, type NonEmptyArray, ok, Option, type OptionType, some } from '../../fp';
3import { parseApiKey, renderApiKeyError } from '../../domain/apiKey';
4import { describeCoverage } from '../../domain/coverage';
5import { renderValidationError } from '../../domain/errors';
6import { type ModelId, modelText, parseModelId } from '../../domain/model';
7import { parseVibe, type Vibe } from '../../domain/vibe';
8import {
9 type CancellationSignal,
10 type CompletionSummary,
11 type ProgressReporter,
12 type Severity,
13 type Suggestion,
14 type Ui,
15 type UserMessage,
16} from '../../ports';
17 
18const shuffle = <A>(items: ReadonlyArray<A>): A[] => {
19 const copy = [...items];
20 for (let i = copy.length - 1; i > 0; i -= 1) {
21 const j = Math.floor(Math.random() * (i + 1));
22 const a = copy[i] as A;
23 const b = copy[j] as A;
24 copy[i] = b;
25 copy[j] = a;
26 }
27 return copy;
28};
29 
30const messageOptions = (detail: OptionType<string>): vscode.MessageOptions =>
31 detail._tag === 'Some' ? { modal: true, detail: detail.value } : { modal: true };
32 
33const messageText = (message: UserMessage): string =>
34 message.suggestion._tag === 'Some'
35 ? `${message.title}${message.suggestion.value}`
36 : message.title;
37 
38export const createUi = (): Ui => ({
39 pickVibe: (suggestions: ReadonlyArray<Suggestion>) =>
40 new Promise((resolve) => {
41 const quickPick = vscode.window.createQuickPick();
42 quickPick.title = '🎨 Create New Theme or Modify Current Theme';
43 quickPick.placeholder = '✨ Describe any vibe or mood… (modifying needs an existing vibe theme)';
44 
45 const sampled = shuffle(suggestions).slice(0, 6);
46 const baseItems: vscode.QuickPickItem[] = sampled.map((s) => ({ label: s.label }));
47 quickPick.items = baseItems;
48 
49 let settled = false;
50 const finish = (value: OptionType<Vibe>): void => {
51 if (settled) {
52 return;
53 }
54 settled = true;
55 resolve(ok(value));
56 quickPick.dispose();
57 };
58 
59 quickPick.onDidChangeValue((value) => {
60 const typed = value.trim();
61 if (typed.length > 0 && !sampled.some((s) => s.label === typed)) {
62 quickPick.items = [{ label: typed }, ...baseItems];
63 } else if (typed.length === 0) {
64 quickPick.items = baseItems;
65 }
66 });
67 
68 quickPick.onDidAccept(() => {
69 const selected = quickPick.selectedItems[0];
70 const raw = selected !== undefined ? selected.label : quickPick.value.trim();
71 const parsed = parseVibe(raw);
72 if (parsed._tag === 'Err') {
73 void vscode.window.showErrorMessage(renderValidationError(parsed.error));
74 return;
75 }
76 finish(some(parsed.value));
77 });
78 
79 quickPick.onDidHide(() => finish(none));
80 quickPick.show();
81 }),
82 
83 promptForApiKey: async () => {
84 const raw = await vscode.window.showInputBox({
85 prompt: 'Enter your OpenAI API Key',
86 password: true,
87 ignoreFocusOut: true,
88 placeHolder: 'sk-…',
89 validateInput: (value) => {
90 const parsed = parseApiKey(value);
91 return parsed._tag === 'Err' ? renderApiKeyError(parsed.error) : undefined;
92 },
93 });
94 return ok(Option.fromNullable(raw));
95 },
96 
97 pickModel: async (models: NonEmptyArray<ModelId>, current: OptionType<ModelId>) => {
98 const currentText = current._tag === 'Some' ? modelText(current.value) : undefined;
99 const picked = await vscode.window.showQuickPick(models.map(modelText), {
100 title: '🤖 Select OpenAI Model for Theme Generation',
101 placeHolder:
102 currentText !== undefined
103 ? `Currently using: ${currentText}`
104 : 'Choose a model (a GPT-4 class model is recommended)',
105 ignoreFocusOut: true,
106 });
107 return ok(picked === undefined ? none : parseModelId(picked));
108 },
109 
110 runWithProgress: <A>(
111 title: string,
112 task: (reporter: ProgressReporter, signal: CancellationSignal) => Promise<A>,
113 ): Promise<A> =>
114 Promise.resolve(
115 vscode.window.withProgress(
116 { location: vscode.ProgressLocation.Notification, title, cancellable: true },
117 (progress, token) => {
118 let lastPercent = 0;
119 const reporter: ProgressReporter = {
120 report: (update) => {
121 let increment = 0;
122 if (update.percent._tag === 'Some') {
123 increment = Math.max(0, update.percent.value - lastPercent);
124 lastPercent = update.percent.value;
125 }
126 progress.report({ message: update.message, increment });
127 },
128 };
129 const signal: CancellationSignal = {
130 isCancelled: () => token.isCancellationRequested,
131 };
132 return task(reporter, signal);
133 },
134 ),
135 ),
136 
137 confirmCancellation: async (appliedCount: number) => {
138 if (appliedCount === 0) {
139 await vscode.window.showInformationMessage(
140 '🚫 Theme generation cancelled. No changes were made.',
141 { modal: true },
142 );
143 return ok('keep');
144 }
145 const choice = await vscode.window.showWarningMessage(
146 `🛑 Cancelled after applying ${appliedCount} settings. Keep the partial theme or reset?`,
147 { modal: true, detail: 'A partial theme may look incomplete — not every element was styled.' },
148 'Keep Partial Theme',
149 'Reset to Original',
150 );
151 return ok(choice === 'Reset to Original' ? 'reset' : 'keep');
152 },
153 
154 announceCompletion: async (summary: CompletionSummary) => {
155 const choice = await vscode.window.showInformationMessage(
156 `🎨 Theme applied — ${summary.applied} settings.\n\n${describeCoverage(summary.coverage)}`,
157 {
158 modal: true,
159 detail:
160 'Applied live as the AI generated each setting. Changing your VS Code theme will not remove these overrides — use "Reset Theme Customizations".',
161 },
162 'Keep Theme',
163 'Reset Theme (Remove All Customizations)',
164 );
165 return ok(choice === 'Reset Theme (Remove All Customizations)' ? 'reset' : 'keep');
166 },
167 
168 announceReset: async () => {
169 await vscode.window.showInformationMessage(
170 '🔄 Theme customizations cleared — your original theme is restored.',
171 { modal: true, detail: 'All Vibe Themer color and token overrides have been removed.' },
172 );
173 },
174 
175 notify: async (message: UserMessage, severity: Severity) => {
176 const text = messageText(message);
177 const options = messageOptions(message.detail);
178 if (severity === 'info') {
179 await vscode.window.showInformationMessage(text, options);
180 } else if (severity === 'warning') {
181 await vscode.window.showWarningMessage(text, options);
182 } else {
183 await vscode.window.showErrorMessage(text, options);
184 }
185 },
186});
src/adapters/vscode/clock.ts · 5 lines
src/adapters/vscode/clock.ts5 lines · TypeScript
1import { type Clock, type Millis } from '../../ports';
2 
3export const systemClock: Clock = {
4 now: (): Millis => Date.now() as Millis,
5};
src/adapters/vscode/logger.ts · 14 lines
src/adapters/vscode/logger.ts14 lines · TypeScript
1import type * as vscode from 'vscode';
2import { type Logger } from '../../ports';
3 
4const format = (level: string, message: string, data?: Readonly<Record<string, unknown>>): string =>
5 data === undefined ? `[${level}] ${message}` : `[${level}] ${message} ${JSON.stringify(data)}`;
6 
7/**
8 * Logs to a VS Code output channel. Secrets passed in `data` are `Redacted`, whose
9 * `toJSON` returns `<redacted>`, so `JSON.stringify` here can never spill a key.
10 */
11export const createLogger = (channel: vscode.OutputChannel): Logger => ({
12 debug: (message, data) => channel.appendLine(format('debug', message, data)),
13 error: (message, data) => channel.appendLine(format('error', message, data)),
14});
src/adapters/vscode/preferences.ts · 19 lines
src/adapters/vscode/preferences.ts19 lines · TypeScript
1import type * as vscode from 'vscode';
2import { none, type OptionType } from '../../fp';
3import { type ModelId, modelText, parseModelId } from '../../domain/model';
4import { type Preferences } from '../../ports';
5 
6const MODEL_KEY = 'openaiModel';
7 
8export const createPreferences = (state: vscode.Memento): Preferences => ({
9 selectedModel: (): OptionType<ModelId> => {
10 const raw = state.get<string>(MODEL_KEY);
11 return raw === undefined ? none : parseModelId(raw);
12 },
13 selectModel: async (model) => {
14 await state.update(MODEL_KEY, modelText(model));
15 },
16 clearModel: async () => {
17 await state.update(MODEL_KEY, undefined);
18 },
19});
src/adapters/vscode/prompts.ts · 20 lines
src/adapters/vscode/prompts.ts20 lines · TypeScript
1import { promises as fs } from 'fs';
2import * as path from 'path';
3import type * as vscode from 'vscode';
4import { type AsyncResultType, err, isNonEmptyString, type NonEmptyString, ok } from '../../fp';
5import { type PromptError, type PromptLibrary } from '../../ports';
6 
7const PROMPT_RELATIVE_PATH = path.join('prompts', 'streamingThemePrompt.txt');
8 
9/** Loads the system prompt shipped with the extension via the resource-safe path API. */
10export const createPromptLibrary = (context: vscode.ExtensionContext): PromptLibrary => ({
11 systemPrompt: async (): AsyncResultType<PromptError, NonEmptyString> => {
12 try {
13 const absolute = context.asAbsolutePath(PROMPT_RELATIVE_PATH);
14 const content = await fs.readFile(absolute, 'utf8');
15 return isNonEmptyString(content) ? ok(content) : err({ _tag: 'PromptEmpty' });
16 } catch {
17 return err({ _tag: 'PromptUnavailable' });
18 }
19 },
20});
Act V · The shell5.2🎬 Spotlight

The OpenAI gateway

src/adapters/openai/gateway.ts

The 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
src/adapters/openai/gateway.ts81 lines · TypeScript
⋯ 8 lines hidden (lines 1–8)
1import OpenAI from 'openai';
2import { AsyncResult, type AsyncResultType, err, expose, isNonEmptyArray, type NonEmptyArray, ok, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { isGptModel, type ModelId, modelText, parseModelId } from '../../domain/model';
5import { type GenerationRequest, type OpenAiError, type OpenAiGateway } from '../../ports';
6 
7/** Classify an SDK error by status code, instead of v1's substring sniffing. */
8const classify = (e: unknown): OpenAiError => {
9 if (e instanceof OpenAI.APIError) {
10 if (e.status === 401 || e.status === 403) {
11 return { _tag: 'AuthFailed' };
12 }
13 if (e.status === 429) {
14 return { _tag: 'RateLimited' };
15 }
16 if (e.status === undefined) {
17 return { _tag: 'Network' };
18 }
19 return { _tag: 'Unexpected', detail: e.message };
20 }
21 return { _tag: 'Network' };
22};
23 
24const clientFor = (key: Redacted<ApiKey>): OpenAI => new OpenAI({ apiKey: expose(key) });
25 
26async function* toContentStream(
27 stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
28): AsyncIterable<string> {
29 for await (const chunk of stream) {
30 const content = chunk.choices[0]?.delta?.content;
31 if (content) {
32 yield content;
33 }
⋯ 18 lines hidden (lines 34–51)
34 }
36 
37export const createOpenAiGateway = (): OpenAiGateway => ({
38 verify: (key) =>
39 AsyncResult.tryCatch(async () => {
40 await clientFor(key).models.list();
41 }, classify),
42 
43 listGptModels: async (
44 key,
45 ): AsyncResultType<OpenAiError, NonEmptyArray<ModelId>> => {
46 const listed = await AsyncResult.tryCatch(
47 async () => (await clientFor(key).models.list()).data,
48 classify,
49 );
50 if (listed._tag === 'Err') {
51 return listed;
52 }
53 
54 const models: ModelId[] = [];
55 for (const model of listed.value) {
56 const parsed = parseModelId(model.id);
57 if (parsed._tag === 'Some' && isGptModel(parsed.value)) {
58 models.push(parsed.value);
59 }
60 }
61 return isNonEmptyArray(models) ? ok(models) : err({ _tag: 'NoModelsAvailable' });
62 },
63 
64 streamTheme: async (
65 request: GenerationRequest,
66 ): AsyncResultType<OpenAiError, AsyncIterable<string>> => {
67 const opened = await AsyncResult.tryCatch(
68 () =>
69 clientFor(request.key).chat.completions.create({
70 model: modelText(request.model),
⋯ 11 lines hidden (lines 71–81)
71 messages: [
72 { role: 'system', content: request.system },
73 { role: 'user', content: request.user },
74 ],
75 stream: true,
76 }),
77 classify,
78 );
79 return opened._tag === 'Err' ? opened : ok(toContentStream(opened.value));
80 },
81});
Act V · The shell5.3🎬 Spotlight

Commands and the composition root

src/commands.tssrc/extension.ts

commands.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
src/commands.ts49 lines · TypeScript
⋯ 14 lines hidden (lines 1–14)
1/**
2 * The command registry: the single source of truth for command IDs and their
3 * handlers. Centralizing the IDs here (rather than re-typing string literals in
4 * `extension.ts` and `package.json`) is what makes the v1 `testRemoveValue` vs
5 * `testRemoveValues` drift impossible — and a test asserts these match the manifest.
6 */
7 
8import { generateTheme, renderGenerationError } from './application/generateTheme';
9import { clearApiKey, resetModel, resetTheme, selectModel } from './application/maintenance';
10import { type Capabilities, userMessage } from './ports';
11 
12export const COMMAND_IDS = {
13 changeTheme: 'vibeThemer.changeTheme',
14 resetTheme: 'vibeThemer.resetTheme',
15 clearApiKey: 'vibeThemer.clearApiKey',
16 selectModel: 'vibeThemer.selectModel',
17 resetModel: 'vibeThemer.resetModel',
18} as const;
19 
20export type CommandId = (typeof COMMAND_IDS)[keyof typeof COMMAND_IDS];
21 
22const runChangeTheme = async (caps: Capabilities): Promise<void> => {
23 const result = await generateTheme(caps);
24 if (result._tag === 'Err') {
25 const message = renderGenerationError(result.error);
26 if (message._tag === 'Some') {
27 await caps.ui.notify(message.value, 'error');
28 }
29 return;
30 }
31 if (result.value._tag === 'NoVibe') {
32 await caps.ui.notify(
33 userMessage(
34 "No theme description provided — try again when you're ready to create or modify your perfect coding atmosphere! 🎨",
35 ),
36 'info',
37 );
38 }
39};
40 
⋯ 9 lines hidden (lines 41–49)
41export const commandHandlers = (
42 caps: Capabilities,
43): Readonly<Record<CommandId, () => Promise<void>>> => ({
44 [COMMAND_IDS.changeTheme]: () => runChangeTheme(caps),
45 [COMMAND_IDS.resetTheme]: () => resetTheme(caps),
46 [COMMAND_IDS.clearApiKey]: () => clearApiKey(caps),
47 [COMMAND_IDS.selectModel]: () => selectModel(caps),
48 [COMMAND_IDS.resetModel]: () => resetModel(caps),
49});
src/extension.ts · 43 lines
src/extension.ts43 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1/**
2 * Composition root. The only file that knows about both VS Code and the use cases:
3 * it builds the concrete adapters, assembles the `Capabilities`, and registers each
4 * command from the typed registry. Activation is fully synchronous — commands exist
5 * the instant the extension loads (no async-ordering hazard like v1's).
6 */
7 
8import * as vscode from 'vscode';
9import { createOpenAiGateway } from './adapters/openai/gateway';
10import { systemClock } from './adapters/vscode/clock';
11import { createConfigStore } from './adapters/vscode/config';
12import { createLogger } from './adapters/vscode/logger';
13import { createPreferences } from './adapters/vscode/preferences';
14import { createPromptLibrary } from './adapters/vscode/prompts';
15import { createSecretStore } from './adapters/vscode/secrets';
16import { createUi } from './adapters/vscode/ui';
17import { commandHandlers } from './commands';
18import { type Capabilities } from './ports';
19 
20export function activate(context: vscode.ExtensionContext): void {
21 const channel = vscode.window.createOutputChannel('Vibe Themer');
22 context.subscriptions.push(channel);
23 
24 const capabilities: Capabilities = {
25 secrets: createSecretStore(context.secrets),
26 openai: createOpenAiGateway(),
27 config: createConfigStore(),
28 prompts: createPromptLibrary(context),
29 preferences: createPreferences(context.globalState),
30 ui: createUi(),
31 clock: systemClock,
32 logger: createLogger(channel),
33 };
34 
35 const handlers = commandHandlers(capabilities);
36 for (const [id, handler] of Object.entries(handlers)) {
37 context.subscriptions.push(vscode.commands.registerCommand(id, () => handler()));
38 }
40 
⋯ 3 lines hidden (lines 41–43)
41export function deactivate(): void {
42 // Nothing to clean up: all disposables are registered on the extension context.
Act VI · Verification6.1🎬 Evidence

How it's proven

test/support/harness.tstest/generateTheme.test.ts

Because 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
test/support/harness.ts239 lines · TypeScript
⋯ 119 lines hidden (lines 1–119)
1/**
2 * In-memory fakes for every port, assembled into `Capabilities`. Because the whole
3 * application core depends only on ports, this lets us drive the full Change Theme
4 * flow — streaming, live application, cancellation, error tolerance — with no VS
5 * Code and no network, and then inspect exactly what happened.
6 */
7 
8import {
9 expose,
10 none,
11 type NonEmptyArray,
12 type NonEmptyString,
13 type OptionType,
14 ok,
15 Option,
16 some,
17} from '../../src/fp';
18import { toApplication } from '../../src/domain/color';
19import { fontStyleText } from '../../src/domain/fontStyle';
20import { type ModelId } from '../../src/domain/model';
21import { selectorText } from '../../src/domain/selector';
22import { type CurrentTheme, type ThemeSetting } from '../../src/domain/theme';
23import { tokenScopeText } from '../../src/domain/tokenScope';
24import { parseVibe } from '../../src/domain/vibe';
25import {
26 type Capabilities,
27 type KeepOrReset,
28 type Millis,
29 type OpenAiError,
30 type Severity,
31} from '../../src/ports';
32 
33export interface HarnessOptions {
34 readonly storedKey?: string;
35 readonly promptKey?: string;
36 readonly vibe?: string;
37 readonly streamText?: string;
38 readonly streamError?: OpenAiError;
39 readonly chunkSize?: number;
40 readonly cancelAfterReports?: number;
41 readonly cancellationChoice?: KeepOrReset;
42 readonly completionChoice?: KeepOrReset;
43 readonly workspaceFolders?: boolean;
44 readonly currentTheme?: CurrentTheme;
46 
47export interface TokenRule {
48 readonly scope: string;
49 readonly settings: Record<string, unknown>;
51 
52export interface Captured {
53 readonly logs: Array<{ level: string; message: string; data?: Readonly<Record<string, unknown>> }>;
54 readonly notifications: Array<{ severity: Severity; title: string }>;
55 keySet: boolean;
56 keyCleared: boolean;
57 resets: number;
58 streamUserPrompt: string;
60 
61export interface Harness {
62 readonly caps: Capabilities;
63 readonly colors: Map<string, string>;
64 readonly tokenRules: TokenRule[];
65 readonly captured: Captured;
67 
68const emptyTheme: CurrentTheme = {
69 global: { colors: {}, tokens: {} },
70 workspace: { colors: {}, tokens: {} },
71};
72 
73const chunk = (text: string, size: number): string[] => {
74 const out: string[] = [];
75 for (let i = 0; i < text.length; i += size) {
76 out.push(text.slice(i, i + size));
77 }
78 return out;
79};
80 
81async function* fromChunks(parts: ReadonlyArray<string>): AsyncIterable<string> {
82 for (const part of parts) {
83 yield part;
84 }
86 
87export const harness = (options: HarnessOptions = {}): Harness => {
88 const colors = new Map<string, string>();
89 const tokenRules: TokenRule[] = [];
90 const captured: Captured = {
91 logs: [],
92 notifications: [],
93 keySet: false,
94 keyCleared: false,
95 resets: 0,
96 streamUserPrompt: '',
97 };
98 
99 let storedKey = options.storedKey;
100 let selectedModel: OptionType<ModelId> = none;
101 let clockValue = 0;
102 
103 const applyToState = (setting: ThemeSetting): void => {
104 if (setting._tag === 'SelectorSetting') {
105 const application = toApplication(setting.color);
106 const key = selectorText(setting.selector);
107 if (application._tag === 'Delete') {
108 colors.delete(key);
109 } else {
110 colors.set(key, application.value);
111 }
112 } else {
113 const scope = tokenScopeText(setting.scope);
114 const index = tokenRules.findIndex((r) => r.scope === scope);
115 if (index >= 0) {
116 tokenRules.splice(index, 1);
117 }
118 const application = toApplication(setting.color);
119 if (application._tag !== 'Delete') {
120 tokenRules.push({
121 scope,
122 settings: {
123 foreground: application.value,
124 ...(setting.fontStyle._tag === 'Some'
125 ? { fontStyle: fontStyleText(setting.fontStyle.value) }
126 : {}),
127 },
128 });
129 }
130 }
131 };
132 
133 const caps: Capabilities = {
134 secrets: {
135 get: async () => ok(Option.fromNullable(storedKey)),
136 set: async (key) => {
137 storedKey = expose(key);
138 captured.keySet = true;
139 return ok(undefined);
140 },
141 clear: async () => {
142 storedKey = undefined;
143 captured.keyCleared = true;
144 return ok(undefined);
145 },
146 },
147 
148 openai: {
149 verify: async () => ok(undefined),
150 listGptModels: async () => {
151 const models: NonEmptyArray<ModelId> = ['gpt-4.1' as ModelId];
152 return ok(models);
153 },
154 streamTheme: async (request) => {
155 captured.streamUserPrompt = request.user;
156 return options.streamError !== undefined
157 ? { _tag: 'Err', error: options.streamError }
158 : ok(fromChunks(chunk(options.streamText ?? '', options.chunkSize ?? 7)));
159 },
160 },
161 
162 config: {
163 readCurrentTheme: () => options.currentTheme ?? emptyTheme,
164 hasWorkspaceFolders: () => options.workspaceFolders ?? false,
165 applySetting: async (setting, _preference) => {
166 applyToState(setting);
167 return ok('global');
168 },
169 reset: async () => {
170 captured.resets += 1;
171 colors.clear();
172 tokenRules.length = 0;
173 return ok(undefined);
174 },
175 },
⋯ 64 lines hidden (lines 176–239)
176 
177 prompts: {
178 systemPrompt: async () => ok('SYSTEM PROMPT' as NonEmptyString),
179 },
180 
181 preferences: {
182 selectedModel: () => selectedModel,
183 selectModel: async (model) => {
184 selectedModel = some(model);
185 },
186 clearModel: async () => {
187 selectedModel = none;
188 },
189 },
190 
191 ui: {
192 pickVibe: async () => {
193 if (options.vibe === undefined) {
194 return ok(none);
195 }
196 const parsed = parseVibe(options.vibe);
197 return ok(parsed._tag === 'Ok' ? some(parsed.value) : none);
198 },
199 promptForApiKey: async () => ok(Option.fromNullable(options.promptKey)),
200 pickModel: async () => ok(none),
201 runWithProgress: async (_title, task) => {
202 let reports = 0;
203 let cancelled = false;
204 const reporter = {
205 report: () => {
206 reports += 1;
207 if (options.cancelAfterReports !== undefined && reports >= options.cancelAfterReports) {
208 cancelled = true;
209 }
210 },
211 };
212 const signal = { isCancelled: () => cancelled };
213 return task(reporter, signal);
214 },
215 confirmCancellation: async () => ok(options.cancellationChoice ?? 'keep'),
216 announceCompletion: async () => ok(options.completionChoice ?? 'keep'),
217 announceReset: async () => {
218 /* recorded via config.reset */
219 },
220 notify: async (message, severity) => {
221 captured.notifications.push({ severity, title: message.title });
222 },
223 },
224 
225 clock: {
226 now: (): Millis => {
227 clockValue += 1000;
228 return clockValue as Millis;
229 },
230 },
231 
232 logger: {
233 debug: (message, data) => captured.logs.push({ level: 'debug', message, ...(data ? { data } : {}) }),
234 error: (message, data) => captured.logs.push({ level: 'error', message, ...(data ? { data } : {}) }),
235 },
236 };
237 
238 return { caps, colors, tokenRules, captured };
239};

End-to-end: happy path, iteration context, cancellation, abort, secret safety.

test/generateTheme.test.ts · 141 lines
test/generateTheme.test.ts141 lines · TypeScript
1import { describe, it } from 'node:test';
2import assert from 'node:assert/strict';
3import { generateTheme } from '../src/application/generateTheme';
4import { harness } from './support/harness';
5 
6const VALID_KEY = `sk-${'x'.repeat(40)}`;
7 
8const HAPPY_STREAM = [
9 'COUNT:3',
10 'MESSAGE:Warming up the editor 🔥',
11 'SELECTOR:editor.background=#1a1a1a',
12 'TOKEN:comment=#6a9955,italic',
13 'SELECTOR:activityBar.background=REMOVE',
14 '',
15].join('\n');
16 
17describe('generateTheme — happy path', () => {
18 it('applies each setting live and completes', async () => {
19 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn evening', streamText: HAPPY_STREAM });
20 const result = await generateTheme(h.caps);
21 
22 assert.equal(result._tag, 'Ok');
23 if (result._tag === 'Ok') {
24 assert.deepEqual(result.value, { _tag: 'Completed', applied: 3 });
25 }
26 assert.equal(h.colors.get('editor.background'), '#1a1a1a');
27 assert.equal(h.colors.has('activityBar.background'), false);
28 assert.deepEqual(h.tokenRules, [
29 { scope: 'comment', settings: { foreground: '#6a9955', fontStyle: 'italic' } },
30 ]);
31 });
32 
33 it('REMOVE deletes a previously applied selector (iteration)', async () => {
34 const text = ['COUNT:2', 'SELECTOR:foo.bar=#111111', 'SELECTOR:foo.bar=REMOVE', ''].join('\n');
35 const h = harness({ storedKey: VALID_KEY, vibe: 'make it warmer', streamText: text });
36 const result = await generateTheme(h.caps);
37 
38 assert.equal(h.colors.has('foo.bar'), false);
39 if (result._tag === 'Ok') {
40 assert.equal(result.value._tag, 'Completed');
41 }
42 });
43 
44 it('injects existing customizations into the streamed prompt for iteration', async () => {
45 const currentTheme = {
46 global: { colors: { 'editor.background': '#1e1e1e' }, tokens: {} },
47 workspace: { colors: {}, tokens: {} },
48 };
49 const text = ['COUNT:1', 'SELECTOR:editor.background=#2a1f1a', ''].join('\n');
50 const h = harness({ storedKey: VALID_KEY, vibe: 'make it warmer', streamText: text, currentTheme });
51 await generateTheme(h.caps);
52 
53 assert.ok(h.captured.streamUserPrompt.includes('CURRENT THEME CONTEXT:'));
54 assert.ok(h.captured.streamUserPrompt.includes('editor.background: #1e1e1e'));
55 assert.ok(h.captured.streamUserPrompt.endsWith('make it warmer'));
56 });
57 
58 it('prompts for, verifies, and stores a new key, then proceeds', async () => {
59 const text = ['COUNT:1', 'SELECTOR:editor.background=#000000', ''].join('\n');
60 const h = harness({ promptKey: VALID_KEY, vibe: 'minimal dark', streamText: text });
61 const result = await generateTheme(h.caps);
62 
63 assert.equal(h.captured.keySet, true);
64 if (result._tag === 'Ok') {
65 assert.equal(result.value._tag, 'Completed');
66 }
67 });
68});
69 
70describe('generateTheme — benign exits', () => {
71 it('returns NoVibe when the picker is dismissed', async () => {
72 const h = harness({ storedKey: VALID_KEY });
73 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoVibe' } });
74 });
75 
76 it('returns NoKey when no key is stored and the prompt is dismissed', async () => {
77 const h = harness({ vibe: 'cozy autumn' });
78 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoKey' } });
79 });
80});
81 
82describe('generateTheme — failures', () => {
83 it('surfaces an OpenAI stream error', async () => {
84 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamError: { _tag: 'RateLimited' } });
85 assert.deepEqual(await generateTheme(h.caps), {
86 _tag: 'Err',
87 error: { _tag: 'OpenAi', error: { _tag: 'RateLimited' } },
88 });
89 });
90 
91 it('aborts after too many malformed lines', async () => {
92 const garbage = Array.from({ length: 6 }, (_unused, i) => `GARBAGE:${i}`).join('\n');
93 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamText: garbage });
94 const result = await generateTheme(h.caps);
95 assert.equal(result._tag, 'Err');
96 if (result._tag === 'Err') {
97 assert.equal(result.error._tag, 'Aborted');
98 }
99 });
100});
101 
102describe('generateTheme — cancellation and reset', () => {
103 it('on cancel + reset, clears the theme', async () => {
104 const h = harness({
105 storedKey: VALID_KEY,
106 vibe: 'cozy',
107 streamText: HAPPY_STREAM,
108 cancelAfterReports: 2,
109 cancellationChoice: 'reset',
110 });
111 const result = await generateTheme(h.caps);
112 if (result._tag === 'Ok') {
113 assert.equal(result.value._tag, 'CancelledReset');
114 }
115 assert.ok(h.captured.resets >= 1);
116 });
117 
118 it('on success + reset choice, clears the theme', async () => {
119 const h = harness({
120 storedKey: VALID_KEY,
121 vibe: 'cozy',
122 streamText: HAPPY_STREAM,
123 completionChoice: 'reset',
124 });
125 const result = await generateTheme(h.caps);
126 if (result._tag === 'Ok') {
127 assert.equal(result.value._tag, 'Completed');
128 }
129 assert.equal(h.captured.resets, 1);
130 });
131});
132 
133describe('generateTheme — secret safety', () => {
134 it('never lets the raw API key reach logs or notifications', async () => {
135 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn', streamText: HAPPY_STREAM });
136 await generateTheme(h.caps);
137 const serialized = JSON.stringify(h.captured);
138 assert.equal(serialized.includes('sk-'), false);
139 assert.equal(serialized.includes('x'.repeat(40)), false);
140 });
141});

The pure merge logic, including the array-scope removal fix.

test/customizations.test.ts · 84 lines
test/customizations.test.ts84 lines · TypeScript
1import { describe, it } from 'node:test';
2import assert from 'node:assert/strict';
3import { applyColor, applyTokenRule, type TextMateRule } from '../src/domain/customizations';
4import { parseColor } from '../src/domain/color';
5import { parseSelector } from '../src/domain/selector';
6import { parseTokenScope } from '../src/domain/tokenScope';
7import { parseFontStyle } from '../src/domain/fontStyle';
8import { none, type OptionType, some } from '../src/fp';
9import { type FontStyle } from '../src/domain/fontStyle';
10 
11const sel = (s: string) => {
12 const r = parseSelector(s);
13 if (r._tag !== 'Ok') { throw new Error(`bad selector: ${s}`); }
14 return r.value;
15};
16const col = (s: string) => {
17 const r = parseColor(s);
18 if (r._tag !== 'Ok') { throw new Error(`bad color: ${s}`); }
19 return r.value;
20};
21const scope = (s: string) => {
22 const r = parseTokenScope(s);
23 if (r._tag !== 'Ok') { throw new Error(`bad scope: ${s}`); }
24 return r.value;
25};
26const style = (s: string): OptionType<FontStyle> => {
27 const r = parseFontStyle(s);
28 if (r._tag !== 'Ok') { throw new Error(`bad font style: ${s}`); }
29 return some(r.value);
30};
31 
32describe('applyColor', () => {
33 it('sets and overwrites', () => {
34 assert.deepEqual(applyColor({}, sel('editor.background'), col('#111111')), {
35 'editor.background': '#111111',
36 });
37 assert.deepEqual(applyColor({ 'editor.background': '#000000' }, sel('editor.background'), col('#ffffff')), {
38 'editor.background': '#ffffff',
39 });
40 });
41 
42 it('deletes an existing key on REMOVE and is a no-op on an absent key', () => {
43 assert.deepEqual(applyColor({ 'a.b': '#111111', 'c.d': '#222222' }, sel('a.b'), col('REMOVE')), {
44 'c.d': '#222222',
45 });
46 assert.deepEqual(applyColor({ 'c.d': '#222222' }, sel('a.b'), col('REMOVE')), {
47 'c.d': '#222222',
48 });
49 });
50 
51 it('does not mutate its input', () => {
52 const input = { 'a.b': '#111111' };
53 applyColor(input, sel('x.y'), col('#222222'));
54 assert.deepEqual(input, { 'a.b': '#111111' });
55 });
56});
57 
58describe('applyTokenRule', () => {
59 it('adds a rule, with or without a font style', () => {
60 assert.deepEqual(applyTokenRule([], scope('comment'), col('#6a9955'), style('italic')), [
61 { scope: 'comment', settings: { foreground: '#6a9955', fontStyle: 'italic' } },
62 ]);
63 assert.deepEqual(applyTokenRule([], scope('keyword'), col('#ff0000'), none), [
64 { scope: 'keyword', settings: { foreground: '#ff0000' } },
65 ]);
66 });
67 
68 it('replaces the rule for the same scope', () => {
69 const before: TextMateRule[] = [{ scope: 'comment', settings: { foreground: '#000000' } }];
70 assert.deepEqual(applyTokenRule(before, scope('comment'), col('#6a9955'), none), [
71 { scope: 'comment', settings: { foreground: '#6a9955' } },
72 ]);
73 });
74 
75 it('removes the rule on REMOVE, including an array-valued scope', () => {
76 const before: TextMateRule[] = [
77 { scope: ['string', 'punctuation'], settings: { foreground: '#aaaaaa' } },
78 { scope: 'comment', settings: { foreground: '#000000' } },
79 ];
80 assert.deepEqual(applyTokenRule(before, scope('string'), col('REMOVE'), none), [
81 { scope: 'comment', settings: { foreground: '#000000' } },
82 ]);
83 });
84});
Act VI · Verification6.2🎬 Bug-fixes-as-types

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 defectv2 mechanism
API key logged on errorRedacted<ApiKey> — prints <redacted>; expose only at the boundary
Iteration read the wrong config scopeconfig.inspect() per scope; effectiveScope tested
First progress message withheld ~800msOption<Millis> throttle, neverShown initial
~19% duplicated prompt tokensprompt de-duplicated (1,247 → 1,021 lines), selectors preserved
Command-id manifest driftcentralized COMMAND_IDS + guard test
Errors classified by substringclassified by HTTP status
reset() reported failure as successinspects allSettled, returns Err on total failure
Zero tests behind a vacuous npm test49 real tests, core covered by fakes
Each fix is structural where it can be — a type or a test, not a comment.