What changed, and why

The old model picker listed every model the OpenAI account could see. Some worked for theme generation, some 404'd, none came with guidance. This PR replaces that with a short curated catalog and a sensible default, and — because the v2 rewrite put a port between the application and the OpenAI SDK — adds Anthropic as a real second provider.

The shape of the change is the point. A new Provider axis runs through the domain; Model becomes {provider, id}; the single OpenAI gateway becomes a provider-blind ModelGateway that dispatches to per-provider adapters. Most of the diff is additive — a new Anthropic adapter, a dispatcher, a provider parameter threaded through the key and secret layers — which is exactly the payoff the hexagonal architecture was supposed to buy.

Read it in the direction dependencies point: the pure domain first (Provider, Model, ApiKey), then the ports that name the new boundary, then the adapters that satisfy it, then the application flow that ties it together, and finally the VS Code surface and wiring. Blast radius is contained to the model/key path; the streaming parser, theme application, and reset flows are untouched.

The new axis: Provider

Everything starts here. Provider is a closed two-variant union, and providerInfo is the single place that knows a provider's display name and key prefix. Adding a third provider later is one variant plus one adapter — nothing in the pure core changes shape.

A closed union, a values list for iteration, and per-provider metadata in one spot.

src/domain/provider.ts · 22 lines
src/domain/provider.ts22 lines · TypeScript
⋯ 6 lines hidden (lines 1–6)
1/**
2 * `Provider` — which model vendor a model and its API key belong to. Adding a third
3 * provider later is a new variant here plus a new adapter; nothing in the pure core
4 * changes shape.
5 */
6 
7export type Provider = 'openai' | 'anthropic';
8 
9export const allProviders: ReadonlyArray<Provider> = ['openai', 'anthropic'];
10 
11export interface ProviderInfo {
12 readonly displayName: string;
13 /** The literal prefix a valid key for this provider must start with. */
14 readonly keyPrefix: string;
15 /** Placeholder shown in the key input box. */
16 readonly keyHint: string;
18 
19export const providerInfo = (provider: Provider): ProviderInfo =>
20 provider === 'openai'
21 ? { displayName: 'OpenAI', keyPrefix: 'sk-', keyHint: 'sk-…' }
22 : { displayName: 'Anthropic', keyPrefix: 'sk-ant-', keyHint: 'sk-ant-…' };

Model is now provider + id, chosen from a curated catalog

A Model carries the two facts downstream code needs: which provider routes the request, and which key unlocks it. The id stays a branded free-text string, so a custom model id is still expressible — but the recommended path is the small CATALOG.

Model, its smart constructor, and structural equality by provider + id.

src/domain/model.ts · 62 lines
src/domain/model.ts62 lines · TypeScript
⋯ 13 lines hidden (lines 1–13)
1/**
2 * A `Model` is a provider plus a model id — the two facts the gateway needs to route
3 * a request and the credential layer needs to pick the right key. Selection is driven
4 * by a small curated `CATALOG` (latest flagship + mini per provider) rather than the
5 * raw provider model list, which is how v1/v2 ended up offering models that don't work
6 * for this task. A free-text "custom" id is still allowed via `makeModel`.
7 */
8 
9import { type Brand, none, type OptionType, some } from '../fp';
10import { type Provider } from './provider';
11 
12export type ModelId = Brand<string, 'ModelId'>;
13 
14export interface Model {
15 readonly provider: Provider;
16 readonly id: ModelId;
18 
19export const makeModel = (provider: Provider, id: string): Model => ({
20 provider,
21 id: id as ModelId,
22});
23 
24export const modelText = (id: ModelId): string => id;
25 
26export const sameModel = (a: Model, b: Model): boolean =>
27 a.provider === b.provider && a.id === b.id;
28 
29export const parseModelId = (raw: string): OptionType<ModelId> => {
30 const trimmed = raw.trim();
31 return trimmed.length > 0 ? some(trimmed as ModelId) : none;
32};
⋯ 30 lines hidden (lines 33–62)
33 
34/** A curated, recommendable model with display copy for the picker. */
35export interface SupportedModel {
36 readonly model: Model;
37 readonly displayName: string;
38 readonly blurb: string;
40 
41// Verified against each provider's live model list (June 2026). Note: there is no
42// `gpt-5.5-mini` — the 5.5 line is flagship + pro only — so the mini slot uses the
43// current strongest mini, `gpt-5.4-mini`. Bump these strings as new models ship.
44export const CATALOG: ReadonlyArray<SupportedModel> = [
45 {
46 model: makeModel('openai', 'gpt-5.5'),
47 displayName: 'GPT-5.5',
48 blurb: 'OpenAI · latest flagship (recommended)',
49 },
50 {
51 model: makeModel('openai', 'gpt-5.4-mini'),
52 displayName: 'GPT-5.4 mini',
53 blurb: 'OpenAI · faster and cheaper',
54 },
55 {
56 model: makeModel('anthropic', 'claude-sonnet-4-6'),
57 displayName: 'Claude Sonnet 4.6',
58 blurb: 'Anthropic · strong instruction-following',
59 },
60];
61 
62export const DEFAULT_MODEL: Model = makeModel('openai', 'gpt-5.5');

The curated catalog and the default. Note the comment on the missing mini.

src/domain/model.ts · 62 lines
src/domain/model.ts62 lines · TypeScript
⋯ 40 lines hidden (lines 1–40)
1/**
2 * A `Model` is a provider plus a model id — the two facts the gateway needs to route
3 * a request and the credential layer needs to pick the right key. Selection is driven
4 * by a small curated `CATALOG` (latest flagship + mini per provider) rather than the
5 * raw provider model list, which is how v1/v2 ended up offering models that don't work
6 * for this task. A free-text "custom" id is still allowed via `makeModel`.
7 */
8 
9import { type Brand, none, type OptionType, some } from '../fp';
10import { type Provider } from './provider';
11 
12export type ModelId = Brand<string, 'ModelId'>;
13 
14export interface Model {
15 readonly provider: Provider;
16 readonly id: ModelId;
18 
19export const makeModel = (provider: Provider, id: string): Model => ({
20 provider,
21 id: id as ModelId,
22});
23 
24export const modelText = (id: ModelId): string => id;
25 
26export const sameModel = (a: Model, b: Model): boolean =>
27 a.provider === b.provider && a.id === b.id;
28 
29export const parseModelId = (raw: string): OptionType<ModelId> => {
30 const trimmed = raw.trim();
31 return trimmed.length > 0 ? some(trimmed as ModelId) : none;
32};
33 
34/** A curated, recommendable model with display copy for the picker. */
35export interface SupportedModel {
36 readonly model: Model;
37 readonly displayName: string;
38 readonly blurb: string;
40 
41// Verified against each provider's live model list (June 2026). Note: there is no
42// `gpt-5.5-mini` — the 5.5 line is flagship + pro only — so the mini slot uses the
43// current strongest mini, `gpt-5.4-mini`. Bump these strings as new models ship.
44export const CATALOG: ReadonlyArray<SupportedModel> = [
45 {
46 model: makeModel('openai', 'gpt-5.5'),
47 displayName: 'GPT-5.5',
48 blurb: 'OpenAI · latest flagship (recommended)',
49 },
50 {
51 model: makeModel('openai', 'gpt-5.4-mini'),
52 displayName: 'GPT-5.4 mini',
53 blurb: 'OpenAI · faster and cheaper',
54 },
55 {
56 model: makeModel('anthropic', 'claude-sonnet-4-6'),
57 displayName: 'Claude Sonnet 4.6',
58 blurb: 'Anthropic · strong instruction-following',
59 },
60];
61 
62export const DEFAULT_MODEL: Model = makeModel('openai', 'gpt-5.5');

Provider-aware key validation

Keys are validated per provider, and the prefix check is where a subtle bug would hide: OpenAI's sk- is a prefix of Anthropic's sk-ant-, so a naive startsWith would accept an Anthropic key as an OpenAI one and silently misroute it. hasValidPrefix excludes the foreign prefix explicitly.

The prefix guard, then the validating constructor that returns a Redacted key.

src/domain/apiKey.ts · 60 lines
src/domain/apiKey.ts60 lines · TypeScript
⋯ 24 lines hidden (lines 1–24)
1/**
2 * `ApiKey` — a provider secret. Two safety properties are baked into the types:
3 *
4 * 1. Validation is the only constructor (provider-specific prefix + length), so an
5 * `ApiKey` is always well-formed for its provider. OpenAI keys start `sk-`,
6 * Anthropic keys start `sk-ant-` — and an `sk-ant-` key is rejected for OpenAI.
7 * 2. `parseApiKey` hands back a `Redacted<ApiKey>`, never a bare key. The value is
8 * wrapped from birth, and the error variants carry no raw material. This makes
9 * the v1 key-leak (the key reaching `console.error`) unrepresentable.
10 */
11 
12import { type Brand, matchTag, redact, type Redacted, Result, type ResultType } from '../fp';
13import { type Provider, providerInfo } from './provider';
14 
15export type ApiKey = Brand<string, 'ApiKey'>;
16 
17export type ApiKeyError =
18 | { readonly _tag: 'KeyEmpty' }
19 | { readonly _tag: 'KeyBadPrefix'; readonly provider: Provider }
20 | { readonly _tag: 'KeyTooShort' };
21 
22const MIN_KEY_LENGTH = 20;
23const OPENAI_FOREIGN_PREFIX = 'sk-ant-';
24 
25const hasValidPrefix = (provider: Provider, key: string): boolean => {
26 const { keyPrefix } = providerInfo(provider);
27 if (!key.startsWith(keyPrefix)) {
28 return false;
29 }
30 // OpenAI's `sk-` also matches Anthropic's `sk-ant-`; exclude it so a key entered
31 // under the wrong provider is caught instead of silently misrouted.
32 return provider === 'openai' ? !key.startsWith(OPENAI_FOREIGN_PREFIX) : true;
33};
34 
35export const parseApiKey = (
36 provider: Provider,
37 raw: string,
38): ResultType<ApiKeyError, Redacted<ApiKey>> => {
39 const trimmed = raw.trim();
40 if (trimmed.length === 0) {
41 return Result.err({ _tag: 'KeyEmpty' });
42 }
43 if (!hasValidPrefix(provider, trimmed)) {
44 return Result.err({ _tag: 'KeyBadPrefix', provider });
45 }
46 if (trimmed.length <= MIN_KEY_LENGTH) {
47 return Result.err({ _tag: 'KeyTooShort' });
48 }
49 return Result.ok(redact(trimmed as ApiKey));
50};
⋯ 10 lines hidden (lines 51–60)
51 
52export const renderApiKeyError = (e: ApiKeyError): string =>
53 matchTag(e, {
54 KeyEmpty: () => 'An API key is required',
55 KeyBadPrefix: ({ provider }) => {
56 const { displayName, keyPrefix } = providerInfo(provider);
57 return `${displayName} API keys start with "${keyPrefix}"`;
58 },
59 KeyTooShort: () => 'That API key looks too short',
60 });

The ports: a provider-blind ModelGateway

The application talks to one ModelGateway and never names a provider's SDK. A ProviderAdapter is what a single provider implements (verify a key, open a streamTheme); the gateway is the façade that dispatches to the right one by the provider on the request. OpenAiError became the provider-neutral ProviderError.

A closed, provider-neutral error union — no OpenAI-specific variants.

src/ports/errors.ts · 99 lines
src/ports/errors.ts99 lines · TypeScript
⋯ 36 lines hidden (lines 1–36)
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 ProviderError =
38 | { readonly _tag: 'AuthFailed' }
39 | { readonly _tag: 'RateLimited' }
40 | { readonly _tag: 'Network' }
41 | { readonly _tag: 'Unexpected'; readonly detail: string };
⋯ 58 lines hidden (lines 42–99)
42 
43export type ConfigError =
44 | { readonly _tag: 'WriteFailed'; readonly target: WriteTarget }
45 | { readonly _tag: 'AllTargetsFailed' };
46 
47export type UiError = { readonly _tag: 'UiFailure' };
48 
49// ── Rendering ─────────────────────────────────────────────────────────────────
50 
51export const renderProviderError = (e: ProviderError): UserMessage =>
52 matchTag(e, {
53 AuthFailed: () =>
54 userMessage('🔑 The provider rejected the API key', {
55 suggestion: 'Clear the key and enter a valid one, then check your account access.',
56 }),
57 RateLimited: () =>
58 userMessage('🔑 Provider rate limit or quota reached', {
59 suggestion: 'Check your plan and usage on the provider dashboard, then try again.',
60 }),
61 Network: () =>
62 userMessage('🌐 Could not reach the model provider', {
63 suggestion: 'Check your internet connection and try again.',
64 }),
65 Unexpected: ({ detail }) =>
66 userMessage('❌ The model request failed', { detail, suggestion: 'Please try again.' }),
67 });
68 
69export const renderPromptError = (e: PromptError): UserMessage =>
70 matchTag(e, {
71 PromptUnavailable: () =>
72 userMessage('❌ Could not load the theme prompt', {
73 suggestion: 'Try reinstalling Vibe Themer.',
74 }),
75 PromptEmpty: () =>
76 userMessage('❌ The theme prompt is empty', {
77 suggestion: 'Try reinstalling Vibe Themer.',
78 }),
79 });
80 
81export const renderConfigError = (e: ConfigError): UserMessage =>
82 matchTag(e, {
83 WriteFailed: ({ target }) =>
84 userMessage(`❌ Could not write theme to ${target} settings`, {
85 suggestion: 'Check VS Code permissions and try restarting the editor.',
86 }),
87 AllTargetsFailed: () =>
88 userMessage('❌ Could not apply the theme to any settings scope', {
89 suggestion: 'Check VS Code permissions and try restarting the editor.',
90 }),
91 });
92 
93export const renderStorageError = (e: StorageError): UserMessage =>
94 userMessage(`❌ Secure storage ${e.operation} failed`, {
95 suggestion: 'Try restarting VS Code.',
96 });
97 
98export const renderUiError = (_e: UiError): UserMessage =>
99 userMessage('❌ A UI operation failed unexpectedly', { suggestion: 'Please try again.' });

ProviderRequest / ProviderAdapter (per-provider) vs GenerationRequest / ModelGateway (the façade).

src/ports/ports.ts · 165 lines
src/ports/ports.ts165 lines · TypeScript
⋯ 56 lines hidden (lines 1–56)
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, the OpenAI SDK, and the Anthropic SDK appear only
5 * in `adapters/`.
6 */
7 
8import {
9 type AsyncResultType,
10 type Brand,
11 type NonEmptyArray,
12 type NonEmptyString,
13 type OptionType,
14 type Redacted,
15} from '../fp';
16import { type ApiKey } from '../domain/apiKey';
17import { type Coverage } from '../domain/coverage';
18import { type Model, type ModelId, type SupportedModel } from '../domain/model';
19import { type Provider } from '../domain/provider';
20import { type WriteTarget } from '../domain/scope';
21import { type CurrentTheme, type ThemeSetting } from '../domain/theme';
22import { type Vibe } from '../domain/vibe';
23import {
24 type ConfigError,
25 type ProviderError,
26 type PromptError,
27 type Severity,
28 type StorageError,
29 type UiError,
30 type UserMessage,
31} from './errors';
32 
33/** Milliseconds since an arbitrary epoch — injected so the message throttle is testable. */
34export type Millis = Brand<number, 'Millis'>;
35 
36export interface Clock {
37 readonly now: () => Millis;
39 
40/** Structured logging. Secrets are `Redacted`, so they render as `<redacted>` even here. */
41export interface Logger {
42 readonly debug: (message: string, data?: Readonly<Record<string, unknown>>) => void;
43 readonly error: (message: string, data?: Readonly<Record<string, unknown>>) => void;
45 
46/** One key per provider, in VS Code's encrypted storage. */
47export interface SecretStore {
48 readonly get: (provider: Provider) => AsyncResultType<StorageError, OptionType<string>>;
49 readonly set: (
50 provider: Provider,
51 key: Redacted<ApiKey>,
52 ) => AsyncResultType<StorageError, void>;
53 /** Clear every provider's key (the "Clear API Keys" command). */
54 readonly clearAll: () => AsyncResultType<StorageError, void>;
56 
57// ── Model gateway ───────────────────────────────────────────────────────────────
58 
59/** What a single provider's adapter is asked to do — it already knows its provider. */
60export interface ProviderRequest {
61 readonly key: Redacted<ApiKey>;
62 readonly model: ModelId;
63 readonly system: NonEmptyString;
64 readonly user: NonEmptyString;
66 
67/** One provider's concrete capabilities. Adapters in `adapters/{openai,anthropic}`. */
68export interface ProviderAdapter {
69 /** Prove a key works (used after the user enters one). */
70 readonly verify: (key: Redacted<ApiKey>) => AsyncResultType<ProviderError, void>;
71 /** Open a streamed completion; the iterable yields raw content deltas. */
72 readonly streamTheme: (
73 request: ProviderRequest,
74 ) => AsyncResultType<ProviderError, AsyncIterable<string>>;
76 
77export interface GenerationRequest extends ProviderRequest {
78 readonly provider: Provider;
80 
81/** Provider-agnostic façade the application talks to; dispatches by provider. */
82export interface ModelGateway {
83 readonly verify: (
84 provider: Provider,
85 key: Redacted<ApiKey>,
86 ) => AsyncResultType<ProviderError, void>;
87 readonly streamTheme: (
88 request: GenerationRequest,
89 ) => AsyncResultType<ProviderError, AsyncIterable<string>>;
⋯ 75 lines hidden (lines 91–165)
91 
92export interface ConfigStore {
93 readonly readCurrentTheme: () => CurrentTheme;
94 readonly hasWorkspaceFolders: () => boolean;
95 /** Apply one setting, trying the preference order until one target succeeds. */
96 readonly applySetting: (
97 setting: ThemeSetting,
98 preference: NonEmptyArray<WriteTarget>,
99 ) => AsyncResultType<ConfigError, WriteTarget>;
100 readonly reset: () => AsyncResultType<ConfigError, void>;
102 
103export interface PromptLibrary {
104 readonly systemPrompt: () => AsyncResultType<PromptError, NonEmptyString>;
106 
107/** Persisted, non-secret preferences (backed by VS Code `globalState`). */
108export interface Preferences {
109 readonly selectedModel: () => OptionType<Model>;
110 readonly selectModel: (model: Model) => Promise<void>;
111 readonly clearModel: () => Promise<void>;
113 
114// ── UI ────────────────────────────────────────────────────────────────────────
115 
116export interface Suggestion {
117 readonly label: string;
119 
120export interface ProgressUpdate {
121 readonly message: string;
122 readonly percent: OptionType<number>;
124 
125export interface ProgressReporter {
126 readonly report: (update: ProgressUpdate) => void;
128 
129export interface CancellationSignal {
130 readonly isCancelled: () => boolean;
132 
133export type KeepOrReset = 'keep' | 'reset';
134 
135export interface CompletionSummary {
136 readonly applied: number;
137 readonly coverage: Coverage;
139 
140export interface Ui {
141 /** QuickPick seeded with suggestions; validates input to a `Vibe`, `None` on cancel. */
142 readonly pickVibe: (
143 suggestions: ReadonlyArray<Suggestion>,
144 ) => AsyncResultType<UiError, OptionType<Vibe>>;
145 /** Input box for a provider's API key; returns the raw string, `None` on cancel. */
146 readonly promptForApiKey: (
147 provider: Provider,
148 ) => AsyncResultType<UiError, OptionType<string>>;
149 /** Pick from the curated catalog (or enter a custom provider+id), `None` on cancel. */
150 readonly pickModel: (
151 catalog: ReadonlyArray<SupportedModel>,
152 current: OptionType<Model>,
153 ) => AsyncResultType<UiError, OptionType<Model>>;
154 /** Run a long task inside a cancellable progress notification. */
155 readonly runWithProgress: <A>(
156 title: string,
157 task: (reporter: ProgressReporter, signal: CancellationSignal) => Promise<A>,
158 ) => Promise<A>;
159 /** On cancel mid-stream: keep the partial theme or reset to the prior state. */
160 readonly confirmCancellation: (appliedCount: number) => AsyncResultType<UiError, KeepOrReset>;
161 /** On success: acknowledge, offering an immediate reset. */
162 readonly announceCompletion: (summary: CompletionSummary) => AsyncResultType<UiError, KeepOrReset>;
163 readonly announceReset: () => Promise<void>;
164 readonly notify: (message: UserMessage, severity: Severity) => Promise<void>;

SecretStore is now keyed by provider; clearAll backs the 'Clear API Keys' command.

src/ports/ports.ts · 165 lines
src/ports/ports.ts165 lines · TypeScript
⋯ 45 lines hidden (lines 1–45)
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, the OpenAI SDK, and the Anthropic SDK appear only
5 * in `adapters/`.
6 */
7 
8import {
9 type AsyncResultType,
10 type Brand,
11 type NonEmptyArray,
12 type NonEmptyString,
13 type OptionType,
14 type Redacted,
15} from '../fp';
16import { type ApiKey } from '../domain/apiKey';
17import { type Coverage } from '../domain/coverage';
18import { type Model, type ModelId, type SupportedModel } from '../domain/model';
19import { type Provider } from '../domain/provider';
20import { type WriteTarget } from '../domain/scope';
21import { type CurrentTheme, type ThemeSetting } from '../domain/theme';
22import { type Vibe } from '../domain/vibe';
23import {
24 type ConfigError,
25 type ProviderError,
26 type PromptError,
27 type Severity,
28 type StorageError,
29 type UiError,
30 type UserMessage,
31} from './errors';
32 
33/** Milliseconds since an arbitrary epoch — injected so the message throttle is testable. */
34export type Millis = Brand<number, 'Millis'>;
35 
36export interface Clock {
37 readonly now: () => Millis;
39 
40/** Structured logging. Secrets are `Redacted`, so they render as `<redacted>` even here. */
41export interface Logger {
42 readonly debug: (message: string, data?: Readonly<Record<string, unknown>>) => void;
43 readonly error: (message: string, data?: Readonly<Record<string, unknown>>) => void;
45 
46/** One key per provider, in VS Code's encrypted storage. */
47export interface SecretStore {
48 readonly get: (provider: Provider) => AsyncResultType<StorageError, OptionType<string>>;
49 readonly set: (
50 provider: Provider,
51 key: Redacted<ApiKey>,
52 ) => AsyncResultType<StorageError, void>;
53 /** Clear every provider's key (the "Clear API Keys" command). */
54 readonly clearAll: () => AsyncResultType<StorageError, void>;
⋯ 110 lines hidden (lines 56–165)
56 
57// ── Model gateway ───────────────────────────────────────────────────────────────
58 
59/** What a single provider's adapter is asked to do — it already knows its provider. */
60export interface ProviderRequest {
61 readonly key: Redacted<ApiKey>;
62 readonly model: ModelId;
63 readonly system: NonEmptyString;
64 readonly user: NonEmptyString;
66 
67/** One provider's concrete capabilities. Adapters in `adapters/{openai,anthropic}`. */
68export interface ProviderAdapter {
69 /** Prove a key works (used after the user enters one). */
70 readonly verify: (key: Redacted<ApiKey>) => AsyncResultType<ProviderError, void>;
71 /** Open a streamed completion; the iterable yields raw content deltas. */
72 readonly streamTheme: (
73 request: ProviderRequest,
74 ) => AsyncResultType<ProviderError, AsyncIterable<string>>;
76 
77export interface GenerationRequest extends ProviderRequest {
78 readonly provider: Provider;
80 
81/** Provider-agnostic façade the application talks to; dispatches by provider. */
82export interface ModelGateway {
83 readonly verify: (
84 provider: Provider,
85 key: Redacted<ApiKey>,
86 ) => AsyncResultType<ProviderError, void>;
87 readonly streamTheme: (
88 request: GenerationRequest,
89 ) => AsyncResultType<ProviderError, AsyncIterable<string>>;
91 
92export interface ConfigStore {
93 readonly readCurrentTheme: () => CurrentTheme;
94 readonly hasWorkspaceFolders: () => boolean;
95 /** Apply one setting, trying the preference order until one target succeeds. */
96 readonly applySetting: (
97 setting: ThemeSetting,
98 preference: NonEmptyArray<WriteTarget>,
99 ) => AsyncResultType<ConfigError, WriteTarget>;
100 readonly reset: () => AsyncResultType<ConfigError, void>;
102 
103export interface PromptLibrary {
104 readonly systemPrompt: () => AsyncResultType<PromptError, NonEmptyString>;
106 
107/** Persisted, non-secret preferences (backed by VS Code `globalState`). */
108export interface Preferences {
109 readonly selectedModel: () => OptionType<Model>;
110 readonly selectModel: (model: Model) => Promise<void>;
111 readonly clearModel: () => Promise<void>;
113 
114// ── UI ────────────────────────────────────────────────────────────────────────
115 
116export interface Suggestion {
117 readonly label: string;
119 
120export interface ProgressUpdate {
121 readonly message: string;
122 readonly percent: OptionType<number>;
124 
125export interface ProgressReporter {
126 readonly report: (update: ProgressUpdate) => void;
128 
129export interface CancellationSignal {
130 readonly isCancelled: () => boolean;
132 
133export type KeepOrReset = 'keep' | 'reset';
134 
135export interface CompletionSummary {
136 readonly applied: number;
137 readonly coverage: Coverage;
139 
140export interface Ui {
141 /** QuickPick seeded with suggestions; validates input to a `Vibe`, `None` on cancel. */
142 readonly pickVibe: (
143 suggestions: ReadonlyArray<Suggestion>,
144 ) => AsyncResultType<UiError, OptionType<Vibe>>;
145 /** Input box for a provider's API key; returns the raw string, `None` on cancel. */
146 readonly promptForApiKey: (
147 provider: Provider,
148 ) => AsyncResultType<UiError, OptionType<string>>;
149 /** Pick from the curated catalog (or enter a custom provider+id), `None` on cancel. */
150 readonly pickModel: (
151 catalog: ReadonlyArray<SupportedModel>,
152 current: OptionType<Model>,
153 ) => AsyncResultType<UiError, OptionType<Model>>;
154 /** Run a long task inside a cancellable progress notification. */
155 readonly runWithProgress: <A>(
156 title: string,
157 task: (reporter: ProgressReporter, signal: CancellationSignal) => Promise<A>,
158 ) => Promise<A>;
159 /** On cancel mid-stream: keep the partial theme or reset to the prior state. */
160 readonly confirmCancellation: (appliedCount: number) => AsyncResultType<UiError, KeepOrReset>;
161 /** On success: acknowledge, offering an immediate reset. */
162 readonly announceCompletion: (summary: CompletionSummary) => AsyncResultType<UiError, KeepOrReset>;
163 readonly announceReset: () => Promise<void>;
164 readonly notify: (message: UserMessage, severity: Severity) => Promise<void>;

The dispatcher and the two adapters

The dispatcher is tiny — index a record of adapters by provider and forward the call. The two adapters are where the SDKs live, and they're the only files that import openai / @anthropic-ai/sdk.

Record-indexed dispatch. The whole provider-blindness rests on this seam.

src/adapters/gateway.ts · 21 lines
src/adapters/gateway.ts21 lines · TypeScript
⋯ 9 lines hidden (lines 1–9)
1/**
2 * The dispatching gateway: a provider-agnostic `ModelGateway` that routes each call
3 * to the right per-provider adapter. This is the seam that lets the application stay
4 * provider-blind — it only ever holds one `ModelGateway`.
5 */
6 
7import { type Provider } from '../domain/provider';
8import { type ModelGateway, type ProviderAdapter } from '../ports';
9 
10export const createModelGateway = (
11 adapters: Readonly<Record<Provider, ProviderAdapter>>,
12): ModelGateway => ({
13 verify: (provider, key) => adapters[provider].verify(key),
14 streamTheme: (request) =>
15 adapters[request.provider].streamTheme({
16 key: request.key,
17 model: request.model,
18 system: request.system,
19 user: request.user,
20 }),
21});

reasoning_effort: 'minimal' only for reasoning-family models; status-code error classification.

src/adapters/openai/gateway.ts · 69 lines
src/adapters/openai/gateway.ts69 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
1import OpenAI from 'openai';
2import { AsyncResult, type AsyncResultType, expose, ok, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { modelText } from '../../domain/model';
5import { type ProviderAdapter, type ProviderError, type ProviderRequest } from '../../ports';
6 
7/** Classify an SDK error by status code, instead of v1's substring sniffing. */
8const classify = (e: unknown): ProviderError => {
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 
26// GPT-5 / o-series are reasoning models; `minimal` effort keeps them fast and
27// streaming token-by-token (the "watch it paint" UX) rather than pausing to think.
28// Non-reasoning custom models (e.g. gpt-4o) reject the parameter, so only send it
29// where it's supported.
30const REASONING_FAMILY = /^(gpt-5|o[0-9])/i;
31const isReasoningModel = (id: string): boolean => REASONING_FAMILY.test(id);
⋯ 18 lines hidden (lines 32–49)
32 
33async function* toContentStream(
34 stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
35): AsyncIterable<string> {
36 for await (const chunk of stream) {
37 const content = chunk.choices[0]?.delta?.content;
38 if (content) {
39 yield content;
40 }
41 }
43 
44export const createOpenAiAdapter = (): ProviderAdapter => ({
45 verify: (key) =>
46 AsyncResult.tryCatch(async () => {
47 await clientFor(key).models.list();
48 }, classify),
49 
50 streamTheme: async (
51 request: ProviderRequest,
52 ): AsyncResultType<ProviderError, AsyncIterable<string>> => {
53 const id = modelText(request.model);
54 const opened = await AsyncResult.tryCatch(
55 () =>
56 clientFor(request.key).chat.completions.create({
57 model: id,
58 messages: [
59 { role: 'system', content: request.system },
60 { role: 'user', content: request.user },
61 ],
62 stream: true,
63 ...(isReasoningModel(id) ? { reasoning_effort: 'minimal' as const } : {}),
64 }),
65 classify,
66 );
67 return opened._tag === 'Err' ? opened : ok(toContentStream(opened.value));
68 },
⋯ 1 line hidden (lines 69–69)
69});

Messages API streaming; the static system prompt is sent with ephemeral cache_control.

src/adapters/anthropic/gateway.ts · 62 lines
src/adapters/anthropic/gateway.ts62 lines · TypeScript
⋯ 43 lines hidden (lines 1–43)
1import Anthropic from '@anthropic-ai/sdk';
2import { AsyncResult, type AsyncResultType, expose, ok, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { modelText } from '../../domain/model';
5import { type ProviderAdapter, type ProviderError, type ProviderRequest } from '../../ports';
6 
7// The theme protocol is short (~120 one-line settings); this is generous headroom.
8const MAX_TOKENS = 16000;
9 
10const classify = (e: unknown): ProviderError => {
11 if (e instanceof Anthropic.APIError) {
12 if (e.status === 401 || e.status === 403) {
13 return { _tag: 'AuthFailed' };
14 }
15 if (e.status === 429) {
16 return { _tag: 'RateLimited' };
17 }
18 if (e.status === undefined) {
19 return { _tag: 'Network' };
20 }
21 return { _tag: 'Unexpected', detail: e.message };
22 }
23 return { _tag: 'Network' };
24};
25 
26const clientFor = (key: Redacted<ApiKey>): Anthropic => new Anthropic({ apiKey: expose(key) });
27 
28async function* toContentStream(
29 stream: AsyncIterable<Anthropic.Messages.RawMessageStreamEvent>,
30): AsyncIterable<string> {
31 for await (const event of stream) {
32 if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
33 yield event.delta.text;
34 }
35 }
37 
38export const createAnthropicAdapter = (): ProviderAdapter => ({
39 verify: (key) =>
40 AsyncResult.tryCatch(async () => {
41 await clientFor(key).models.list();
42 }, classify),
43 
44 streamTheme: async (
45 request: ProviderRequest,
46 ): AsyncResultType<ProviderError, AsyncIterable<string>> => {
47 const opened = await AsyncResult.tryCatch(
48 () =>
49 clientFor(request.key).messages.create({
50 model: modelText(request.model),
51 max_tokens: MAX_TOKENS,
52 // The 1,000-line prompt is static — cache it so repeat generations are
53 // cheaper and lower-latency within the cache TTL.
54 system: [{ type: 'text', text: request.system, cache_control: { type: 'ephemeral' } }],
55 messages: [{ role: 'user', content: request.user }],
56 stream: true,
57 }),
58 classify,
59 );
60 return opened._tag === 'Err' ? opened : ok(toContentStream(opened.value));
61 },
⋯ 1 line hidden (lines 62–62)
62});

The application flow: select, provision, route

The use case stays provider-blind. It reads the selected model (falling back to the default), provisions that provider's key, then routes the stream through the gateway with the model's provider attached. No branch on provider anywhere in the application layer.

Resolve the model first; provision the key for its provider.

src/application/generateTheme.ts · 300 lines
src/application/generateTheme.ts300 lines · TypeScript
⋯ 258 lines hidden (lines 1–258)
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, type 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 ProgressReporter,
42 type PromptError,
43 type ProviderError,
44 renderConfigError,
45 renderProviderError,
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: 'Provider'; readonly error: ProviderError }
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 });
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 model: Model,
232 key: Redacted<ApiKey>,
233 vibe: Vibe,
234 system: NonEmptyString,
235): AsyncResultType<GenerationError, GenerationOutcome> => {
236 const current = caps.config.readCurrentTheme();
237 const user = buildUserPrompt(current, vibe);
238 const preference = writePreference(caps.config.hasWorkspaceFolders());
239 
240 const streamResult = await caps.gateway.streamTheme({
241 provider: model.provider,
242 key,
243 model: model.id,
244 system,
245 user,
246 });
247 if (streamResult._tag === 'Err') {
248 return err({ _tag: 'Provider', error: streamResult.error });
249 }
250 
251 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
252 reporter.report({ message: INITIAL_MESSAGE, percent: none });
253 return consumeStream(caps, streamResult.value, reporter, signal, preference);
254 });
255 
256 return finalize(caps, consumed);
257};
258 
259export const generateTheme = async (
260 caps: Capabilities,
261): AsyncResultType<GenerationError, GenerationOutcome> => {
262 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
263 
264 const keyResult = await provisionApiKey(caps, model.provider);
265 if (keyResult._tag === 'Err') {
266 return keyResult.error._tag === 'Cancelled'
267 ? ok({ _tag: 'NoKey' })
268 : err({ _tag: 'Provision', error: keyResult.error });
269 }
⋯ 31 lines hidden (lines 270–300)
270 
271 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
272 if (vibeResult._tag === 'Err') {
273 return err({ _tag: 'Ui', error: vibeResult.error });
274 }
275 if (vibeResult.value._tag === 'None') {
276 return ok({ _tag: 'NoVibe' });
277 }
278 
279 const promptResult = await caps.prompts.systemPrompt();
280 if (promptResult._tag === 'Err') {
281 return err({ _tag: 'Prompt', error: promptResult.error });
282 }
283 
284 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
285};
286 
287export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
288 matchTag(e, {
289 Provision: ({ error }) => renderProvisionError(error),
290 Ui: ({ error }) => some(renderUiError(error)),
291 Prompt: ({ error }) => some(renderPromptError(error)),
292 Provider: ({ error }) => some(renderProviderError(error)),
293 Aborted: ({ applied, lastError }) =>
294 some(
295 userMessage('⚠️ Theme generation was interrupted', {
296 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
297 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
298 }),
299 ),
300 });

The stream request carries provider + id; the gateway dispatches.

src/application/generateTheme.ts · 300 lines
src/application/generateTheme.ts300 lines · TypeScript
⋯ 239 lines hidden (lines 1–239)
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, type 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 ProgressReporter,
42 type PromptError,
43 type ProviderError,
44 renderConfigError,
45 renderProviderError,
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: 'Provider'; readonly error: ProviderError }
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 });
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 model: Model,
232 key: Redacted<ApiKey>,
233 vibe: Vibe,
234 system: NonEmptyString,
235): AsyncResultType<GenerationError, GenerationOutcome> => {
236 const current = caps.config.readCurrentTheme();
237 const user = buildUserPrompt(current, vibe);
238 const preference = writePreference(caps.config.hasWorkspaceFolders());
239 
240 const streamResult = await caps.gateway.streamTheme({
241 provider: model.provider,
242 key,
243 model: model.id,
244 system,
245 user,
246 });
⋯ 54 lines hidden (lines 247–300)
247 if (streamResult._tag === 'Err') {
248 return err({ _tag: 'Provider', error: streamResult.error });
249 }
250 
251 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
252 reporter.report({ message: INITIAL_MESSAGE, percent: none });
253 return consumeStream(caps, streamResult.value, reporter, signal, preference);
254 });
255 
256 return finalize(caps, consumed);
257};
258 
259export const generateTheme = async (
260 caps: Capabilities,
261): AsyncResultType<GenerationError, GenerationOutcome> => {
262 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
263 
264 const keyResult = await provisionApiKey(caps, model.provider);
265 if (keyResult._tag === 'Err') {
266 return keyResult.error._tag === 'Cancelled'
267 ? ok({ _tag: 'NoKey' })
268 : err({ _tag: 'Provision', error: keyResult.error });
269 }
270 
271 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
272 if (vibeResult._tag === 'Err') {
273 return err({ _tag: 'Ui', error: vibeResult.error });
274 }
275 if (vibeResult.value._tag === 'None') {
276 return ok({ _tag: 'NoVibe' });
277 }
278 
279 const promptResult = await caps.prompts.systemPrompt();
280 if (promptResult._tag === 'Err') {
281 return err({ _tag: 'Prompt', error: promptResult.error });
282 }
283 
284 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
285};
286 
287export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
288 matchTag(e, {
289 Provision: ({ error }) => renderProvisionError(error),
290 Ui: ({ error }) => some(renderUiError(error)),
291 Prompt: ({ error }) => some(renderPromptError(error)),
292 Provider: ({ error }) => some(renderProviderError(error)),
293 Aborted: ({ applied, lastError }) =>
294 some(
295 userMessage('⚠️ Theme generation was interrupted', {
296 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
297 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
298 }),
299 ),
300 });

Stored-and-valid → trust it; otherwise prompt, verify, store — all per provider.

src/application/provisionApiKey.ts · 98 lines
src/application/provisionApiKey.ts98 lines · TypeScript
⋯ 65 lines hidden (lines 1–65)
1/**
2 * Provisioning a provider's 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 the provider, 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 { type Provider } from '../domain/provider';
16import {
17 type Capabilities,
18 type ProviderError,
19 renderProviderError,
20 renderStorageError,
21 type StorageError,
22 type UiError,
23 type UserMessage,
24 userMessage,
25} from '../ports';
26 
27export type ProvisionError =
28 | { readonly _tag: 'Cancelled' }
29 | { readonly _tag: 'InvalidKeyFormat'; readonly error: ApiKeyError }
30 | { readonly _tag: 'Storage'; readonly error: StorageError }
31 | { readonly _tag: 'Provider'; readonly error: ProviderError }
32 | { readonly _tag: 'Ui'; readonly error: UiError };
33 
34type ProvisionDeps = Pick<Capabilities, 'secrets' | 'gateway' | 'ui'>;
35 
36const promptAndStore = async (
37 caps: ProvisionDeps,
38 provider: Provider,
39): AsyncResultType<ProvisionError, Redacted<ApiKey>> => {
40 const entered = await caps.ui.promptForApiKey(provider);
41 if (entered._tag === 'Err') {
42 return err({ _tag: 'Ui', error: entered.error });
43 }
44 if (entered.value._tag === 'None') {
45 return err({ _tag: 'Cancelled' });
46 }
47 
48 const parsed = parseApiKey(provider, entered.value.value);
49 if (parsed._tag === 'Err') {
50 return err({ _tag: 'InvalidKeyFormat', error: parsed.error });
51 }
52 const key = parsed.value;
53 
54 const verified = await caps.gateway.verify(provider, key);
55 if (verified._tag === 'Err') {
56 return err({ _tag: 'Provider', error: verified.error });
57 }
58 
59 const saved = await caps.secrets.set(provider, key);
60 if (saved._tag === 'Err') {
61 return err({ _tag: 'Storage', error: saved.error });
62 }
63 return ok(key);
64};
65 
66export const provisionApiKey = async (
67 caps: ProvisionDeps,
68 provider: Provider,
69): AsyncResultType<ProvisionError, Redacted<ApiKey>> => {
70 const stored = await caps.secrets.get(provider);
71 if (stored._tag === 'Err') {
72 return err({ _tag: 'Storage', error: stored.error });
73 }
74 
75 if (stored.value._tag === 'Some') {
76 const parsed = parseApiKey(provider, stored.value.value);
77 if (parsed._tag === 'Ok') {
78 return ok(parsed.value);
79 }
80 // A malformed stored key falls through to a fresh prompt.
81 }
82 return promptAndStore(caps, provider);
83};
⋯ 15 lines hidden (lines 84–98)
84 
85/** `None` for benign exits (cancellation); `Some` when there is something to tell the user. */
86export const renderProvisionError = (e: ProvisionError): OptionType<UserMessage> =>
87 matchTag(e, {
88 Cancelled: () => none,
89 Ui: () => none,
90 InvalidKeyFormat: ({ error }) =>
91 some(
92 userMessage(renderApiKeyError(error), {
93 suggestion: 'Run the command again and enter a valid key.',
94 }),
95 ),
96 Storage: ({ error }) => some(renderStorageError(error)),
97 Provider: ({ error }) => some(renderProviderError(error)),
98 });

Select Model drives the curated catalog; the confirmation names the provider.

src/application/maintenance.ts · 50 lines
src/application/maintenance.ts50 lines · TypeScript
⋯ 35 lines hidden (lines 1–35)
1/**
2 * The smaller commands: reset theme, clear keys, 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, renderStorageError, userMessage } from '../ports';
7import { CATALOG, modelText } from '../domain/model';
8import { providerInfo } from '../domain/provider';
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.clearAll();
21 if (result._tag === 'Ok') {
22 await caps.ui.notify(userMessage('🔑 API keys cleared (all providers)'), '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 (GPT-5.5) will be used.' }),
32 'info',
33 );
34};
35 
36export const selectModel = async (caps: Capabilities): Promise<void> => {
37 const picked = await caps.ui.pickModel(CATALOG, caps.preferences.selectedModel());
38 if (picked._tag === 'Err' || picked.value._tag === 'None') {
39 return;
40 }
41 
42 const model = picked.value.value;
43 await caps.preferences.selectModel(model);
44 await caps.ui.notify(
45 userMessage(`🎯 Model set to ${providerInfo(model.provider).displayName} · ${modelText(model.id)}`, {
46 detail: 'Used for future theme generations. The provider key is requested on first use.',
47 }),
48 'info',
49 );
50};

Persistence, the picker, and the wiring

The VS Code adapters carry the provider through to storage and the UI. Secrets are keyed per provider; the selected model is persisted as {provider, id} and validated on read; the picker shows the catalog plus a custom escape hatch.

One storage slot per provider; clearAll deletes them all in parallel.

src/adapters/vscode/secrets.ts · 41 lines
src/adapters/vscode/secrets.ts41 lines · TypeScript
⋯ 6 lines hidden (lines 1–6)
1import type * as vscode from 'vscode';
2import { AsyncResult, expose, Option, type Redacted } from '../../fp';
3import { type ApiKey } from '../../domain/apiKey';
4import { allProviders, type Provider } from '../../domain/provider';
5import { type SecretStore, type StorageError } from '../../ports';
6 
7const STORAGE_KEY: Readonly<Record<Provider, string>> = {
8 openai: 'openaiApiKey',
9 anthropic: 'anthropicApiKey',
10};
⋯ 23 lines hidden (lines 11–33)
11 
12const storageError = (operation: StorageError['operation']): StorageError => ({
13 _tag: 'StorageFailure',
14 operation,
15});
16 
17/**
18 * Backs each provider's key with VS Code's encrypted `SecretStorage`. `expose`
19 * appears exactly once, in `set`, which is the only place the raw key is needed.
20 */
21export const createSecretStore = (secrets: vscode.SecretStorage): SecretStore => ({
22 get: (provider) =>
23 AsyncResult.tryCatch(
24 async () => Option.fromNullable(await secrets.get(STORAGE_KEY[provider])),
25 (): StorageError => storageError('read'),
26 ),
27 set: (provider, key: Redacted<ApiKey>) =>
28 AsyncResult.tryCatch(
29 async () => {
30 await secrets.store(STORAGE_KEY[provider], expose(key));
31 },
32 (): StorageError => storageError('write'),
33 ),
34 clearAll: () =>
35 AsyncResult.tryCatch(
36 async () => {
37 await Promise.all(allProviders.map((p) => secrets.delete(STORAGE_KEY[p])));
38 },
39 (): StorageError => storageError('clear'),
40 ),
41});

Stored model is validated back into the domain — an unknown provider reads as None.

src/adapters/vscode/preferences.ts · 32 lines
src/adapters/vscode/preferences.ts32 lines · TypeScript
⋯ 13 lines hidden (lines 1–13)
1import type * as vscode from 'vscode';
2import { none, type OptionType, some } from '../../fp';
3import { makeModel, type Model, modelText } from '../../domain/model';
4import { allProviders, type Provider } from '../../domain/provider';
5import { type Preferences } from '../../ports';
6 
7const MODEL_KEY = 'selectedModel';
8 
9interface StoredModel {
10 readonly provider: string;
11 readonly id: string;
13 
14const isProvider = (value: string): value is Provider =>
15 (allProviders as ReadonlyArray<string>).includes(value);
16 
17const toModel = (stored: StoredModel | undefined): OptionType<Model> => {
18 if (stored === undefined || typeof stored.id !== 'string' || !isProvider(stored.provider)) {
19 return none;
20 }
21 return stored.id.trim().length > 0 ? some(makeModel(stored.provider, stored.id)) : none;
22};
23 
24export const createPreferences = (state: vscode.Memento): Preferences => ({
25 selectedModel: (): OptionType<Model> => toModel(state.get<StoredModel>(MODEL_KEY)),
26 selectModel: async (model: Model) => {
27 await state.update(MODEL_KEY, { provider: model.provider, id: modelText(model.id) });
28 },
29 clearModel: async () => {
30 await state.update(MODEL_KEY, undefined);
31 },
32});

pickModel: the curated list with a 'current' check, plus the custom-id flow.

src/adapters/vscode/ui.ts · 237 lines
src/adapters/vscode/ui.ts237 lines · TypeScript
⋯ 128 lines hidden (lines 1–128)
1import * as vscode from 'vscode';
2import { none, ok, Option, type OptionType, some } from '../../fp';
3import { parseApiKey, renderApiKeyError } from '../../domain/apiKey';
4import { describeCoverage } from '../../domain/coverage';
5import { renderValidationError } from '../../domain/errors';
6import { makeModel, type Model, modelText, sameModel, type SupportedModel } from '../../domain/model';
7import { allProviders, type Provider, providerInfo } from '../../domain/provider';
8import { parseVibe, type Vibe } from '../../domain/vibe';
9import {
10 type CancellationSignal,
11 type CompletionSummary,
12 type ProgressReporter,
13 type Severity,
14 type Suggestion,
15 type Ui,
16 type UserMessage,
17} from '../../ports';
18 
19const shuffle = <A>(items: ReadonlyArray<A>): A[] => {
20 const copy = [...items];
21 for (let i = copy.length - 1; i > 0; i -= 1) {
22 const j = Math.floor(Math.random() * (i + 1));
23 const a = copy[i] as A;
24 const b = copy[j] as A;
25 copy[i] = b;
26 copy[j] = a;
27 }
28 return copy;
29};
30 
31const messageOptions = (detail: OptionType<string>): vscode.MessageOptions =>
32 detail._tag === 'Some' ? { modal: true, detail: detail.value } : { modal: true };
33 
34const messageText = (message: UserMessage): string =>
35 message.suggestion._tag === 'Some'
36 ? `${message.title}${message.suggestion.value}`
37 : message.title;
38 
39interface ModelPickItem extends vscode.QuickPickItem {
40 readonly model?: Model;
41 readonly custom?: boolean;
43 
44interface ProviderPickItem extends vscode.QuickPickItem {
45 readonly provider: Provider;
47 
48/** The "Custom model id…" flow: pick a provider, then type any model id. */
49const pickCustomModel = async (): Promise<OptionType<Model>> => {
50 const providerPick = await vscode.window.showQuickPick<ProviderPickItem>(
51 allProviders.map((p) => ({ label: providerInfo(p).displayName, provider: p })),
52 { title: 'Custom model — choose a provider', ignoreFocusOut: true },
53 );
54 if (providerPick === undefined) {
55 return none;
56 }
57 const id = await vscode.window.showInputBox({
58 prompt: `Enter the ${providerInfo(providerPick.provider).displayName} model id`,
59 ignoreFocusOut: true,
60 placeHolder: providerPick.provider === 'openai' ? 'gpt-5.5' : 'claude-sonnet-4-6',
61 validateInput: (value) => (value.trim().length > 0 ? undefined : 'Enter a model id'),
62 });
63 if (id === undefined || id.trim().length === 0) {
64 return none;
65 }
66 return some(makeModel(providerPick.provider, id.trim()));
67};
68 
69export const createUi = (): Ui => ({
70 pickVibe: (suggestions: ReadonlyArray<Suggestion>) =>
71 new Promise((resolve) => {
72 const quickPick = vscode.window.createQuickPick();
73 quickPick.title = '🎨 Create New Theme or Modify Current Theme';
74 quickPick.placeholder = '✨ Describe any vibe or mood… (modifying needs an existing vibe theme)';
75 
76 const sampled = shuffle(suggestions).slice(0, 6);
77 const baseItems: vscode.QuickPickItem[] = sampled.map((s) => ({ label: s.label }));
78 quickPick.items = baseItems;
79 
80 let settled = false;
81 const finish = (value: OptionType<Vibe>): void => {
82 if (settled) {
83 return;
84 }
85 settled = true;
86 resolve(ok(value));
87 quickPick.dispose();
88 };
89 
90 quickPick.onDidChangeValue((value) => {
91 const typed = value.trim();
92 if (typed.length > 0 && !sampled.some((s) => s.label === typed)) {
93 quickPick.items = [{ label: typed }, ...baseItems];
94 } else if (typed.length === 0) {
95 quickPick.items = baseItems;
96 }
97 });
98 
99 quickPick.onDidAccept(() => {
100 const selected = quickPick.selectedItems[0];
101 const raw = selected !== undefined ? selected.label : quickPick.value.trim();
102 const parsed = parseVibe(raw);
103 if (parsed._tag === 'Err') {
104 void vscode.window.showErrorMessage(renderValidationError(parsed.error));
105 return;
106 }
107 finish(some(parsed.value));
108 });
109 
110 quickPick.onDidHide(() => finish(none));
111 quickPick.show();
112 }),
113 
114 promptForApiKey: async (provider) => {
115 const info = providerInfo(provider);
116 const raw = await vscode.window.showInputBox({
117 prompt: `Enter your ${info.displayName} API Key`,
118 password: true,
119 ignoreFocusOut: true,
120 placeHolder: info.keyHint,
121 validateInput: (value) => {
122 const parsed = parseApiKey(provider, value);
123 return parsed._tag === 'Err' ? renderApiKeyError(parsed.error) : undefined;
124 },
125 });
126 return ok(Option.fromNullable(raw));
127 },
128 
129 pickModel: async (catalog: ReadonlyArray<SupportedModel>, current: OptionType<Model>) => {
130 const currentModel = current._tag === 'Some' ? current.value : undefined;
131 const items: ModelPickItem[] = catalog.map((entry) => ({
132 label: entry.displayName,
133 description: entry.blurb,
134 model: entry.model,
135 ...(currentModel !== undefined && sameModel(currentModel, entry.model)
136 ? { detail: '$(check) current' }
137 : {}),
138 }));
139 items.push({
140 label: '$(edit) Custom model id…',
141 description: 'Enter any provider + model id',
142 custom: true,
143 });
144 
145 const picked = await vscode.window.showQuickPick<ModelPickItem>(items, {
146 title: '🤖 Select a model for theme generation',
147 ignoreFocusOut: true,
148 placeHolder:
149 currentModel !== undefined ? `Current: ${modelText(currentModel.id)}` : 'Pick a model',
150 });
151 
152 if (picked === undefined) {
153 return ok(none);
154 }
155 if (picked.custom === true) {
156 return ok(await pickCustomModel());
157 }
158 return ok(picked.model !== undefined ? some(picked.model) : none);
159 },
⋯ 78 lines hidden (lines 160–237)
160 
161 runWithProgress: <A>(
162 title: string,
163 task: (reporter: ProgressReporter, signal: CancellationSignal) => Promise<A>,
164 ): Promise<A> =>
165 Promise.resolve(
166 vscode.window.withProgress(
167 { location: vscode.ProgressLocation.Notification, title, cancellable: true },
168 (progress, token) => {
169 let lastPercent = 0;
170 const reporter: ProgressReporter = {
171 report: (update) => {
172 let increment = 0;
173 if (update.percent._tag === 'Some') {
174 increment = Math.max(0, update.percent.value - lastPercent);
175 lastPercent = update.percent.value;
176 }
177 progress.report({ message: update.message, increment });
178 },
179 };
180 const signal: CancellationSignal = {
181 isCancelled: () => token.isCancellationRequested,
182 };
183 return task(reporter, signal);
184 },
185 ),
186 ),
187 
188 confirmCancellation: async (appliedCount: number) => {
189 if (appliedCount === 0) {
190 await vscode.window.showInformationMessage(
191 '🚫 Theme generation cancelled. No changes were made.',
192 { modal: true },
193 );
194 return ok('keep');
195 }
196 const choice = await vscode.window.showWarningMessage(
197 `🛑 Cancelled after applying ${appliedCount} settings. Keep the partial theme or reset?`,
198 { modal: true, detail: 'A partial theme may look incomplete — not every element was styled.' },
199 'Keep Partial Theme',
200 'Reset to Original',
201 );
202 return ok(choice === 'Reset to Original' ? 'reset' : 'keep');
203 },
204 
205 announceCompletion: async (summary: CompletionSummary) => {
206 const choice = await vscode.window.showInformationMessage(
207 `🎨 Theme applied — ${summary.applied} settings.\n\n${describeCoverage(summary.coverage)}`,
208 {
209 modal: true,
210 detail:
211 'Applied live as the AI generated each setting. Changing your VS Code theme will not remove these overrides — use "Reset Theme Customizations".',
212 },
213 'Keep Theme',
214 'Reset Theme (Remove All Customizations)',
215 );
216 return ok(choice === 'Reset Theme (Remove All Customizations)' ? 'reset' : 'keep');
217 },
218 
219 announceReset: async () => {
220 await vscode.window.showInformationMessage(
221 '🔄 Theme customizations cleared — your original theme is restored.',
222 { modal: true, detail: 'All Vibe Themer color and token overrides have been removed.' },
223 );
224 },
225 
226 notify: async (message: UserMessage, severity: Severity) => {
227 const text = messageText(message);
228 const options = messageOptions(message.detail);
229 if (severity === 'info') {
230 await vscode.window.showInformationMessage(text, options);
231 } else if (severity === 'warning') {
232 await vscode.window.showWarningMessage(text, options);
233 } else {
234 await vscode.window.showErrorMessage(text, options);
235 }
236 },
237});

The composition root assembles the gateway from both adapters.

src/extension.ts · 48 lines
src/extension.ts48 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
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 { createAnthropicAdapter } from './adapters/anthropic/gateway';
10import { createModelGateway } from './adapters/gateway';
11import { createOpenAiAdapter } from './adapters/openai/gateway';
12import { systemClock } from './adapters/vscode/clock';
13import { createConfigStore } from './adapters/vscode/config';
14import { createLogger } from './adapters/vscode/logger';
15import { createPreferences } from './adapters/vscode/preferences';
16import { createPromptLibrary } from './adapters/vscode/prompts';
17import { createSecretStore } from './adapters/vscode/secrets';
18import { createUi } from './adapters/vscode/ui';
19import { commandHandlers } from './commands';
20import { type Capabilities } from './ports';
21 
22export function activate(context: vscode.ExtensionContext): void {
23 const channel = vscode.window.createOutputChannel('Vibe Themer');
24 context.subscriptions.push(channel);
25 
26 const capabilities: Capabilities = {
27 secrets: createSecretStore(context.secrets),
28 gateway: createModelGateway({
29 openai: createOpenAiAdapter(),
30 anthropic: createAnthropicAdapter(),
31 }),
32 config: createConfigStore(),
33 prompts: createPromptLibrary(context),
34 preferences: createPreferences(context.globalState),
35 ui: createUi(),
36 clock: systemClock,
37 logger: createLogger(channel),
38 };
⋯ 10 lines hidden (lines 39–48)
39 
40 const handlers = commandHandlers(capabilities);
41 for (const [id, handler] of Object.entries(handlers)) {
42 context.subscriptions.push(vscode.commands.registerCommand(id, () => handler()));
43 }
45 
46export function deactivate(): void {
47 // Nothing to clean up: all disposables are registered on the extension context.

Verification

tsc --noEmit and eslint are clean; 53/53 tests pass under Node's built-in runner. Two new tests pin the behavior that the provider abstraction is supposed to guarantee.

Selecting a Claude model routes to Anthropic and stores an sk-ant- key (no provider branch leaks into the app).

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

An Anthropic key is invalid for OpenAI and vice versa — the misroute guard, asserted.

test/domain.test.ts · 127 lines
test/domain.test.ts127 lines · TypeScript
⋯ 95 lines hidden (lines 1–95)
1import { describe, it } from 'node:test';
2import assert from 'node:assert/strict';
3import { parseColor, toApplication } from '../src/domain/color';
4import { parseVibe } from '../src/domain/vibe';
5import { parseSelector } from '../src/domain/selector';
6import { parseTokenScope } from '../src/domain/tokenScope';
7import { parseFontStyle } from '../src/domain/fontStyle';
8import { parseApiKey, renderApiKeyError } from '../src/domain/apiKey';
9import { CATALOG, DEFAULT_MODEL, makeModel, modelText, parseModelId, sameModel } from '../src/domain/model';
10import { expose } from '../src/fp';
11 
12describe('parseColor', () => {
13 it('accepts 3/6/8-digit hex (lowercased), keywords, and REMOVE', () => {
14 assert.deepEqual(parseColor('#ABC'), { _tag: 'Ok', value: { _tag: 'Hex', value: '#abc' } });
15 assert.deepEqual(parseColor(' #1e1e1e '), {
16 _tag: 'Ok',
17 value: { _tag: 'Hex', value: '#1e1e1e' },
18 });
19 assert.deepEqual(parseColor('UNSET'), { _tag: 'Ok', value: { _tag: 'Named', value: 'unset' } });
20 assert.deepEqual(parseColor('remove'), { _tag: 'Ok', value: { _tag: 'Remove' } });
21 });
22 
23 it('rejects malformed colors', () => {
24 assert.equal(parseColor('blue')._tag, 'Err');
25 assert.equal(parseColor('#12')._tag, 'Err');
26 assert.equal(parseColor('rgb(0,0,0)')._tag, 'Err');
27 });
28 
29 it('maps to an application instruction', () => {
30 const hex = parseColor('#fff');
31 if (hex._tag === 'Ok') {
32 assert.deepEqual(toApplication(hex.value), { _tag: 'Set', value: '#fff' });
33 }
34 const rm = parseColor('REMOVE');
35 if (rm._tag === 'Ok') {
36 assert.deepEqual(toApplication(rm.value), { _tag: 'Delete' });
37 }
38 });
39});
40 
41describe('parseVibe', () => {
42 it('trims and requires at least three characters', () => {
43 assert.equal(parseVibe(' cozy autumn ')._tag, 'Ok');
44 assert.equal(parseVibe(' ')._tag, 'Err');
45 assert.equal(parseVibe('ab')._tag, 'Err');
46 });
47});
48 
49describe('parseSelector / parseTokenScope', () => {
50 it('accepts dotted identifiers and rejects spaces or empties', () => {
51 assert.equal(parseSelector('editorIndentGuide.activeBackground6')._tag, 'Ok');
52 assert.equal(parseSelector('has space')._tag, 'Err');
53 assert.equal(parseSelector('')._tag, 'Err');
54 assert.equal(parseTokenScope('comment.line.double-slash')._tag, 'Ok');
55 assert.equal(parseTokenScope('with=equals')._tag, 'Err');
56 });
57});
58 
59describe('parseFontStyle', () => {
60 it('accepts none, single, and combined flags; rejects unknown', () => {
61 assert.equal(parseFontStyle('none')._tag, 'Ok');
62 assert.equal(parseFontStyle('Bold')._tag, 'Ok');
63 assert.equal(parseFontStyle('bold underline')._tag, 'Ok');
64 assert.equal(parseFontStyle('wobbly')._tag, 'Err');
65 });
66});
67 
68describe('parseApiKey', () => {
69 it('validates the provider prefix and length, wrapping in Redacted', () => {
70 const good = parseApiKey('openai', `sk-${'x'.repeat(40)}`);
71 assert.equal(good._tag, 'Ok');
72 if (good._tag === 'Ok') {
73 assert.equal(String(good.value), '<redacted>');
74 assert.equal(JSON.stringify({ key: good.value }), '{"key":"<redacted>"}');
75 assert.equal(expose(good.value).startsWith('sk-'), true);
76 }
77 assert.equal(parseApiKey('anthropic', `sk-ant-${'x'.repeat(40)}`)._tag, 'Ok');
78 });
79 
80 it('rejects empty, wrong-prefix, and too-short keys without echoing the key', () => {
81 assert.deepEqual(parseApiKey('openai', ''), { _tag: 'Err', error: { _tag: 'KeyEmpty' } });
82 assert.deepEqual(parseApiKey('openai', 'pk-123456789012345678901234'), {
83 _tag: 'Err',
84 error: { _tag: 'KeyBadPrefix', provider: 'openai' },
85 });
86 assert.deepEqual(parseApiKey('openai', 'sk-short'), {
87 _tag: 'Err',
88 error: { _tag: 'KeyTooShort' },
89 });
90 assert.equal(
91 renderApiKeyError({ _tag: 'KeyBadPrefix', provider: 'openai' }).includes('pk-'),
92 false,
93 );
94 });
95 
96 it('routes keys per provider — an Anthropic key is invalid for OpenAI and vice versa', () => {
97 assert.deepEqual(parseApiKey('openai', `sk-ant-${'x'.repeat(40)}`), {
98 _tag: 'Err',
99 error: { _tag: 'KeyBadPrefix', provider: 'openai' },
100 });
101 assert.deepEqual(parseApiKey('anthropic', `sk-${'x'.repeat(40)}`), {
102 _tag: 'Err',
103 error: { _tag: 'KeyBadPrefix', provider: 'anthropic' },
104 });
105 });
⋯ 22 lines hidden (lines 106–127)
106});
107 
108describe('model & catalog', () => {
109 it('defaults to gpt-5.5 on OpenAI', () => {
110 assert.equal(DEFAULT_MODEL.provider, 'openai');
111 assert.equal(modelText(DEFAULT_MODEL.id), 'gpt-5.5');
112 });
113 
114 it('curates a small catalog that includes the default', () => {
115 const ids = CATALOG.map((s) => modelText(s.model.id));
116 assert.ok(ids.includes('gpt-5.5'));
117 assert.ok(ids.includes('claude-sonnet-4-6'));
118 assert.ok(CATALOG.some((s) => sameModel(s.model, DEFAULT_MODEL)));
119 });
120 
121 it('parses a custom id and compares models by provider + id', () => {
122 assert.equal(parseModelId('gpt-4o')._tag, 'Some');
123 assert.equal(parseModelId(' ')._tag, 'None');
124 assert.equal(sameModel(makeModel('openai', 'x'), makeModel('openai', 'x')), true);
125 assert.equal(sameModel(makeModel('openai', 'x'), makeModel('anthropic', 'x')), false);
126 });
127});