1. Vibe before key

A new user ran Change Theme and was met with a password box demanding an sk-… key before describing anything. The flow now asks for the vibe first; key provisioning (which does a verify round-trip) only happens once there's a vibe to act on. It also computes a model label to show in the picker.

pickVibe first, then provisionApiKey — and the model label passed in.

src/application/generateTheme.ts · 358 lines
src/application/generateTheme.ts358 lines · TypeScript
⋯ 300 lines hidden (lines 1–300)
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, modelText } from '../domain/model';
32import { type Provider, providerInfo } from '../domain/provider';
33import { type WriteTarget, writePreference } from '../domain/scope';
34import { type ThemeSetting } from '../domain/theme';
35import { type Vibe } from '../domain/vibe';
36import { parseLine, renderParseError } from '../protocol/streamingParser';
37import {
38 type CancellationSignal,
39 type Capabilities,
40 type ConfigError,
41 isProviderError,
42 type KeepOrReset,
43 type Millis,
44 type ProgressReporter,
45 type PromptError,
46 type ProviderError,
47 renderConfigError,
48 renderProviderError,
49 renderPromptError,
50 renderUiError,
51 type UiError,
52 type UserMessage,
53 userMessage,
54} from '../ports';
55import { buildUserPrompt } from './context';
56import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
57import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
58import { curatedSuggestions } from './suggestions';
59 
60// Tolerance for malformed *model output* (a parse-quality signal).
61const MAX_RECOVERABLE_ERRORS = 5;
62// Tolerance for *settings-write* failures (an environment/permissions signal,
63// kept separate so an IO problem isn't misreported as bad model output).
64const MAX_WRITE_ERRORS = 5;
65const DEFAULT_EXPECTED_SETTINGS = 120;
66const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
67const PROGRESS_TITLE = 'Vibe Themer';
68 
69export type GenerationOutcome =
70 | { readonly _tag: 'NoKey' }
71 | { readonly _tag: 'NoVibe' }
72 | { readonly _tag: 'Completed'; readonly applied: number }
73 | { readonly _tag: 'CancelledKept'; readonly applied: number }
74 | { readonly _tag: 'CancelledReset' };
75 
76export type GenerationError =
77 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
78 | { readonly _tag: 'Ui'; readonly error: UiError }
79 | { readonly _tag: 'Prompt'; readonly error: PromptError }
80 | { readonly _tag: 'Provider'; readonly error: ProviderError; readonly provider: Provider }
81 | {
82 readonly _tag: 'StreamInterrupted';
83 readonly applied: number;
84 readonly error: ProviderError;
85 readonly provider: Provider;
86 }
87 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
88 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
89 
90type ApplyStatus = 'ok' | 'error';
91 
92type ConsumeOutcome =
93 | { readonly _tag: 'Completed'; readonly applied: number }
94 | { readonly _tag: 'Cancelled'; readonly applied: number }
95 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
96 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
97 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
98 
99// ── The streaming consume loop ─────────────────────────────────────────────────
100 
101const consumeStream = async (
102 caps: Capabilities,
103 stream: AsyncIterable<string>,
104 reporter: ProgressReporter,
105 signal: CancellationSignal,
106 preference: NonEmptyArray<WriteTarget>,
107): Promise<ConsumeOutcome> => {
108 let buffer = '';
109 let applied = 0;
110 let expected = DEFAULT_EXPECTED_SETTINGS;
111 let parseErrors = 0;
112 let writeErrors = 0;
113 let lastParseError = '';
114 let lastConfigError: ConfigError | undefined;
115 let currentMessage = INITIAL_MESSAGE;
116 let lastMessageAt = neverShown;
117 
118 const reportProgress = (): void =>
119 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
120 
121 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
122 const result = await caps.config.applySetting(setting, preference);
123 if (result._tag === 'Ok') {
124 applied += 1;
125 reportProgress();
126 return 'ok';
127 }
128 // A write failure is environmental, not a model-output problem — track it on
129 // its own budget and log it, rather than spending the malformed-line tolerance.
130 writeErrors += 1;
131 lastConfigError = result.error;
132 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
133 return 'error';
134 };
135 
136 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
137 matchTag(directive, {
138 Count: async ({ total }): Promise<ApplyStatus> => {
139 expected = total;
140 currentMessage = `🎯 Planning ${total} theme settings...`;
141 reportProgress();
142 return 'ok';
143 },
144 Message: async ({ text }): Promise<ApplyStatus> => {
145 const now: Millis = caps.clock.now();
146 if (shouldShowMessage(lastMessageAt, now)) {
147 currentMessage = `${text}`;
148 lastMessageAt = markShown(now);
149 reporter.report({ message: currentMessage, percent: none });
150 }
151 return 'ok';
152 },
153 Selector: ({ selector, color }): Promise<ApplyStatus> =>
154 applySetting({ _tag: 'SelectorSetting', selector, color }),
155 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
156 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
157 });
158 
159 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
160 if (line.trim() === '') {
161 return null;
162 }
163 const parsed = parseLine(line);
164 if (parsed._tag === 'Ok') {
165 // Write failures are counted inside applySetting, on their own budget.
166 await applyDirective(parsed.value);
167 } else {
168 parseErrors += 1;
169 lastParseError = renderParseError(parsed.error);
170 caps.logger.debug('directive parse failed', { line, error: lastParseError });
171 }
172 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
173 return { _tag: 'Aborted', applied, lastError: lastParseError };
174 }
175 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
176 return { _tag: 'WriteFailed', applied, error: lastConfigError };
177 }
178 return null;
179 };
180 
181 try {
182 for await (const chunk of stream) {
183 if (signal.isCancelled()) {
184 break;
185 }
186 buffer += chunk;
187 const segments = buffer.split('\n');
188 buffer = segments.pop() ?? '';
189 for (const line of segments) {
190 if (signal.isCancelled()) {
191 break;
192 }
193 const aborted = await handleLine(line);
194 if (aborted !== null) {
195 return aborted;
196 }
197 }
198 }
199 } catch (e) {
200 // The stream threw after opening (provider error mid-generation). Adapters
201 // re-throw a classified ProviderError; anything else becomes Unexpected.
202 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
203 return { _tag: 'StreamFailed', applied, error };
204 }
205 
206 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
207 if (!signal.isCancelled() && buffer.trim() !== '') {
208 const parsed = parseLine(buffer);
209 if (parsed._tag === 'Ok') {
210 await applyDirective(parsed.value);
211 }
212 }
213 
214 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
215};
216 
217// ── Finalization: keep/reset modals after the progress notification closes ─────
218 
219const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
220 result._tag === 'Ok' ? result.value : 'keep';
221 
222const finalize = (
223 caps: Capabilities,
224 consumed: ConsumeOutcome,
225 provider: Provider,
226): AsyncResultType<GenerationError, GenerationOutcome> =>
227 matchTag(consumed, {
228 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
229 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
230 
231 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
232 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error, provider })),
233 
234 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
235 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
236 
237 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
238 const choice = await caps.ui.announceCompletion({
239 applied,
240 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
241 });
242 if (decideKeepOrReset(choice) === 'reset') {
243 await resetQuietly(caps);
244 }
245 return ok({ _tag: 'Completed', applied });
246 },
247 
248 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
249 const choice = await caps.ui.confirmCancellation(applied);
250 if (decideKeepOrReset(choice) === 'reset') {
251 await resetQuietly(caps);
252 return ok({ _tag: 'CancelledReset' });
253 }
254 return ok({ _tag: 'CancelledKept', applied });
255 },
256 });
257 
258const resetQuietly = async (caps: Capabilities): Promise<void> => {
259 const result = await caps.config.reset();
260 if (result._tag === 'Err') {
261 caps.logger.error('reset after generation failed', {
262 error: renderConfigError(result.error).title,
263 });
264 }
265};
266 
267// ── The use case ───────────────────────────────────────────────────────────────
268 
269const runGeneration = async (
270 caps: Capabilities,
271 model: Model,
272 key: Redacted<ApiKey>,
273 vibe: Vibe,
274 system: NonEmptyString,
275): AsyncResultType<GenerationError, GenerationOutcome> => {
276 const current = caps.config.readCurrentTheme();
277 const user = buildUserPrompt(current, vibe);
278 const preference = writePreference(caps.config.applyTo(), caps.config.hasWorkspaceFolders());
279 
280 const streamResult = await caps.gateway.streamTheme({
281 provider: model.provider,
282 key,
283 model: model.id,
284 system,
285 user,
286 });
287 if (streamResult._tag === 'Err') {
288 return err({ _tag: 'Provider', error: streamResult.error, provider: model.provider });
289 }
290 
291 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
292 reporter.report({ message: INITIAL_MESSAGE, percent: none });
293 return consumeStream(caps, streamResult.value, reporter, signal, preference);
294 });
295 
296 return finalize(caps, consumed, model.provider);
297};
298 
299export const generateTheme = async (
300 caps: Capabilities,
301): AsyncResultType<GenerationError, GenerationOutcome> => {
302 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
303 const modelLabel = `${providerInfo(model.provider).displayName} · ${modelText(model.id)}`;
304 
305 // Ask for the vibe first: the user commits to an action (and sees the active
306 // model) before being asked for a credential. Provisioning the key — which does a
307 // verify round-trip — only happens once there's a vibe to act on.
308 const vibeResult = await caps.ui.pickVibe(curatedSuggestions, modelLabel);
309 if (vibeResult._tag === 'Err') {
310 return err({ _tag: 'Ui', error: vibeResult.error });
311 }
312 if (vibeResult.value._tag === 'None') {
313 return ok({ _tag: 'NoVibe' });
314 }
315 
316 const keyResult = await provisionApiKey(caps, model.provider);
317 if (keyResult._tag === 'Err') {
318 return keyResult.error._tag === 'Cancelled'
319 ? ok({ _tag: 'NoKey' })
320 : err({ _tag: 'Provision', error: keyResult.error });
321 }
322 
⋯ 36 lines hidden (lines 323–358)
323 const promptResult = await caps.prompts.systemPrompt();
324 if (promptResult._tag === 'Err') {
325 return err({ _tag: 'Prompt', error: promptResult.error });
326 }
327 
328 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
329};
330 
331export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
332 matchTag(e, {
333 Provision: ({ error }) => renderProvisionError(error),
334 Ui: ({ error }) => some(renderUiError(error)),
335 Prompt: ({ error }) => some(renderPromptError(error)),
336 Provider: ({ error, provider }) => some(renderProviderError(error, provider)),
337 StreamInterrupted: ({ applied, error, provider }) =>
338 some(
339 userMessage(renderProviderError(error, provider).title, {
340 detail: `Generation stopped after applying ${applied} settings.`,
341 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
342 }),
343 ),
344 WriteAborted: ({ applied, error }) =>
345 some(
346 userMessage(renderConfigError(error).title, {
347 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
348 suggestion: 'Check VS Code permissions and try restarting the editor.',
349 }),
350 ),
351 Aborted: ({ applied, lastError }) =>
352 some(
353 userMessage('⚠️ Theme generation was interrupted', {
354 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
355 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
356 }),
357 ),
358 });

2. The model is discoverable

The active model — and that it can be changed — was invisible from the main flow. The vibe picker title now shows it, and the placeholder names the "Select Model" command.

Title carries the model; placeholder points at Select Model.

src/adapters/vscode/ui.ts · 238 lines
src/adapters/vscode/ui.ts238 lines · TypeScript
⋯ 68 lines hidden (lines 1–68)
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>, currentModel: string) =>
71 new Promise((resolve) => {
72 const quickPick = vscode.window.createQuickPick();
73 quickPick.title = `🎨 Create or Modify Theme · ${currentModel}`;
74 quickPick.placeholder =
75 '✨ Describe any vibe or mood… (model is set via "Select Model"; modifying needs an existing vibe theme)';
76 
⋯ 162 lines hidden (lines 77–238)
77 const sampled = shuffle(suggestions).slice(0, 6);
78 const baseItems: vscode.QuickPickItem[] = sampled.map((s) => ({ label: s.label }));
79 quickPick.items = baseItems;
80 
81 let settled = false;
82 const finish = (value: OptionType<Vibe>): void => {
83 if (settled) {
84 return;
85 }
86 settled = true;
87 resolve(ok(value));
88 quickPick.dispose();
89 };
90 
91 quickPick.onDidChangeValue((value) => {
92 const typed = value.trim();
93 if (typed.length > 0 && !sampled.some((s) => s.label === typed)) {
94 quickPick.items = [{ label: typed }, ...baseItems];
95 } else if (typed.length === 0) {
96 quickPick.items = baseItems;
97 }
98 });
99 
100 quickPick.onDidAccept(() => {
101 const selected = quickPick.selectedItems[0];
102 const raw = selected !== undefined ? selected.label : quickPick.value.trim();
103 const parsed = parseVibe(raw);
104 if (parsed._tag === 'Err') {
105 void vscode.window.showErrorMessage(renderValidationError(parsed.error));
106 return;
107 }
108 finish(some(parsed.value));
109 });
110 
111 quickPick.onDidHide(() => finish(none));
112 quickPick.show();
113 }),
114 
115 promptForApiKey: async (provider) => {
116 const info = providerInfo(provider);
117 const raw = await vscode.window.showInputBox({
118 prompt: `Enter your ${info.displayName} API Key`,
119 password: true,
120 ignoreFocusOut: true,
121 placeHolder: info.keyHint,
122 validateInput: (value) => {
123 const parsed = parseApiKey(provider, value);
124 return parsed._tag === 'Err' ? renderApiKeyError(parsed.error) : undefined;
125 },
126 });
127 return ok(Option.fromNullable(raw));
128 },
129 
130 pickModel: async (catalog: ReadonlyArray<SupportedModel>, current: OptionType<Model>) => {
131 const currentModel = current._tag === 'Some' ? current.value : undefined;
132 const items: ModelPickItem[] = catalog.map((entry) => ({
133 label: entry.displayName,
134 description: entry.blurb,
135 model: entry.model,
136 ...(currentModel !== undefined && sameModel(currentModel, entry.model)
137 ? { detail: '$(check) current' }
138 : {}),
139 }));
140 items.push({
141 label: '$(edit) Custom model id…',
142 description: 'Enter any provider + model id',
143 custom: true,
144 });
145 
146 const picked = await vscode.window.showQuickPick<ModelPickItem>(items, {
147 title: '🤖 Select a model for theme generation',
148 ignoreFocusOut: true,
149 placeHolder:
150 currentModel !== undefined ? `Current: ${modelText(currentModel.id)}` : 'Pick a model',
151 });
152 
153 if (picked === undefined) {
154 return ok(none);
155 }
156 if (picked.custom === true) {
157 return ok(await pickCustomModel());
158 }
159 return ok(picked.model !== undefined ? some(picked.model) : none);
160 },
161 
162 runWithProgress: <A>(
163 title: string,
164 task: (reporter: ProgressReporter, signal: CancellationSignal) => Promise<A>,
165 ): Promise<A> =>
166 Promise.resolve(
167 vscode.window.withProgress(
168 { location: vscode.ProgressLocation.Notification, title, cancellable: true },
169 (progress, token) => {
170 let lastPercent = 0;
171 const reporter: ProgressReporter = {
172 report: (update) => {
173 let increment = 0;
174 if (update.percent._tag === 'Some') {
175 increment = Math.max(0, update.percent.value - lastPercent);
176 lastPercent = update.percent.value;
177 }
178 progress.report({ message: update.message, increment });
179 },
180 };
181 const signal: CancellationSignal = {
182 isCancelled: () => token.isCancellationRequested,
183 };
184 return task(reporter, signal);
185 },
186 ),
187 ),
188 
189 confirmCancellation: async (appliedCount: number) => {
190 if (appliedCount === 0) {
191 await vscode.window.showInformationMessage(
192 '🚫 Theme generation cancelled. No changes were made.',
193 { modal: true },
194 );
195 return ok('keep');
196 }
197 const choice = await vscode.window.showWarningMessage(
198 `🛑 Cancelled after applying ${appliedCount} settings. Keep the partial theme or reset?`,
199 { modal: true, detail: 'A partial theme may look incomplete — not every element was styled.' },
200 'Keep Partial Theme',
201 'Reset to Original',
202 );
203 return ok(choice === 'Reset to Original' ? 'reset' : 'keep');
204 },
205 
206 announceCompletion: async (summary: CompletionSummary) => {
207 const choice = await vscode.window.showInformationMessage(
208 `🎨 Theme applied — ${summary.applied} settings.\n\n${describeCoverage(summary.coverage)}`,
209 {
210 modal: true,
211 detail:
212 'Applied live as the AI generated each setting. Changing your VS Code theme will not remove these overrides — use "Reset Theme Customizations".',
213 },
214 'Keep Theme',
215 'Reset Theme (Remove All Customizations)',
216 );
217 return ok(choice === 'Reset Theme (Remove All Customizations)' ? 'reset' : 'keep');
218 },
219 
220 announceReset: async () => {
221 await vscode.window.showInformationMessage(
222 '🔄 Theme customizations cleared — your original theme is restored.',
223 { modal: true, detail: 'All Vibe Themer color and token overrides have been removed.' },
224 );
225 },
226 
227 notify: async (message: UserMessage, severity: Severity) => {
228 const text = messageText(message);
229 const options = messageOptions(message.detail);
230 if (severity === 'info') {
231 await vscode.window.showInformationMessage(text, options);
232 } else if (severity === 'warning') {
233 await vscode.window.showWarningMessage(text, options);
234 } else {
235 await vscode.window.showErrorMessage(text, options);
236 }
237 },
238});

3. Provider-aware error copy

With keys stored per provider, "The provider rejected the API key" couldn't tell a Claude user which key was wrong. renderProviderError takes an optional provider and names it; the provider is threaded through the Provision/Generation error variants that carry a ProviderError.

Optional provider -> the vendor name in the title; AuthFailed points at Clear API Keys.

src/ports/errors.ts · 123 lines
src/ports/errors.ts123 lines · TypeScript
⋯ 70 lines hidden (lines 1–70)
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 Provider, providerInfo } from '../domain/provider';
10import { type WriteTarget } from '../domain/scope';
11 
12export type Severity = 'info' | 'warning' | 'error';
13 
14export interface UserMessage {
15 readonly title: string;
16 readonly detail: OptionType<string>;
17 readonly suggestion: OptionType<string>;
19 
20export const userMessage = (
21 title: string,
22 options: { readonly suggestion?: string; readonly detail?: string } = {},
23): UserMessage => ({
24 title,
25 detail: options.detail === undefined ? none : some(options.detail),
26 suggestion: options.suggestion === undefined ? none : some(options.suggestion),
27});
28 
29// ── Port error unions ─────────────────────────────────────────────────────────
30 
31export type StorageError = {
32 readonly _tag: 'StorageFailure';
33 readonly operation: 'read' | 'write' | 'clear';
34};
35 
36export type PromptError = { readonly _tag: 'PromptUnavailable' } | { readonly _tag: 'PromptEmpty' };
37 
38export type ProviderError =
39 | { readonly _tag: 'AuthFailed' }
40 | { readonly _tag: 'RateLimited' }
41 | { readonly _tag: 'Network' }
42 | { readonly _tag: 'Unexpected'; readonly detail: string };
43 
44const PROVIDER_ERROR_TAGS: ReadonlyArray<ProviderError['_tag']> = [
45 'AuthFailed',
46 'RateLimited',
47 'Network',
48 'Unexpected',
49];
50 
51/**
52 * A stream can only fail by *throwing*, so a mid-stream provider failure reaches the
53 * consumer as a thrown value. Adapters classify it to a `ProviderError` before
54 * re-throwing; this guard lets the application turn that thrown value back into a
55 * typed error instead of letting it escape as an unhandled rejection.
56 */
57export const isProviderError = (e: unknown): e is ProviderError =>
58 typeof e === 'object' &&
59 e !== null &&
60 '_tag' in e &&
61 (PROVIDER_ERROR_TAGS as ReadonlyArray<string>).includes((e as { readonly _tag: string })._tag);
62 
63export type ConfigError =
64 | { readonly _tag: 'WriteFailed'; readonly target: WriteTarget }
65 | { readonly _tag: 'AllTargetsFailed' };
66 
67export type UiError = { readonly _tag: 'UiFailure' };
68 
69// ── Rendering ─────────────────────────────────────────────────────────────────
70 
71// `provider`, when known, names which vendor failed — keys are stored per provider,
72// so "Anthropic rejected the API key" is actionable where "the provider" is not.
73export const renderProviderError = (e: ProviderError, provider?: Provider): UserMessage => {
74 const name = provider !== undefined ? providerInfo(provider).displayName : undefined;
75 return matchTag(e, {
76 AuthFailed: () =>
77 userMessage(`🔑 ${name ?? 'The provider'} rejected the API key`, {
78 suggestion: 'Run "Clear API Keys", then enter a valid key and check your account access.',
79 }),
80 RateLimited: () =>
81 userMessage(`🔑 ${name ?? 'Provider'} rate limit or quota reached`, {
82 suggestion: 'Check your plan and usage on the provider dashboard, then try again.',
83 }),
84 Network: () =>
85 userMessage(`🌐 Could not reach ${name ?? 'the model provider'}`, {
86 suggestion: 'Check your internet connection and try again.',
87 }),
88 Unexpected: ({ detail }) =>
89 userMessage('❌ The model request failed', { detail, suggestion: 'Please try again.' }),
90 });
91};
⋯ 32 lines hidden (lines 92–123)
92 
93export const renderPromptError = (e: PromptError): UserMessage =>
94 matchTag(e, {
95 PromptUnavailable: () =>
96 userMessage('❌ Could not load the theme prompt', {
97 suggestion: 'Try reinstalling Vibe Themer.',
98 }),
99 PromptEmpty: () =>
100 userMessage('❌ The theme prompt is empty', {
101 suggestion: 'Try reinstalling Vibe Themer.',
102 }),
103 });
104 
105export const renderConfigError = (e: ConfigError): UserMessage =>
106 matchTag(e, {
107 WriteFailed: ({ target }) =>
108 userMessage(`❌ Could not write theme to ${target} settings`, {
109 suggestion: 'Check VS Code permissions and try restarting the editor.',
110 }),
111 AllTargetsFailed: () =>
112 userMessage('❌ Could not apply the theme to any settings scope', {
113 suggestion: 'Check VS Code permissions and try restarting the editor.',
114 }),
115 });
116 
117export const renderStorageError = (e: StorageError): UserMessage =>
118 userMessage(`❌ Secure storage ${e.operation} failed`, {
119 suggestion: 'Try restarting VS Code.',
120 });
121 
122export const renderUiError = (_e: UiError): UserMessage =>
123 userMessage('❌ A UI operation failed unexpectedly', { suggestion: 'Please try again.' });

The provider is passed through at render time.

src/application/generateTheme.ts · 358 lines
src/application/generateTheme.ts358 lines · TypeScript
⋯ 335 lines hidden (lines 1–335)
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, modelText } from '../domain/model';
32import { type Provider, providerInfo } from '../domain/provider';
33import { type WriteTarget, writePreference } from '../domain/scope';
34import { type ThemeSetting } from '../domain/theme';
35import { type Vibe } from '../domain/vibe';
36import { parseLine, renderParseError } from '../protocol/streamingParser';
37import {
38 type CancellationSignal,
39 type Capabilities,
40 type ConfigError,
41 isProviderError,
42 type KeepOrReset,
43 type Millis,
44 type ProgressReporter,
45 type PromptError,
46 type ProviderError,
47 renderConfigError,
48 renderProviderError,
49 renderPromptError,
50 renderUiError,
51 type UiError,
52 type UserMessage,
53 userMessage,
54} from '../ports';
55import { buildUserPrompt } from './context';
56import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
57import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
58import { curatedSuggestions } from './suggestions';
59 
60// Tolerance for malformed *model output* (a parse-quality signal).
61const MAX_RECOVERABLE_ERRORS = 5;
62// Tolerance for *settings-write* failures (an environment/permissions signal,
63// kept separate so an IO problem isn't misreported as bad model output).
64const MAX_WRITE_ERRORS = 5;
65const DEFAULT_EXPECTED_SETTINGS = 120;
66const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
67const PROGRESS_TITLE = 'Vibe Themer';
68 
69export type GenerationOutcome =
70 | { readonly _tag: 'NoKey' }
71 | { readonly _tag: 'NoVibe' }
72 | { readonly _tag: 'Completed'; readonly applied: number }
73 | { readonly _tag: 'CancelledKept'; readonly applied: number }
74 | { readonly _tag: 'CancelledReset' };
75 
76export type GenerationError =
77 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
78 | { readonly _tag: 'Ui'; readonly error: UiError }
79 | { readonly _tag: 'Prompt'; readonly error: PromptError }
80 | { readonly _tag: 'Provider'; readonly error: ProviderError; readonly provider: Provider }
81 | {
82 readonly _tag: 'StreamInterrupted';
83 readonly applied: number;
84 readonly error: ProviderError;
85 readonly provider: Provider;
86 }
87 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
88 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
89 
90type ApplyStatus = 'ok' | 'error';
91 
92type ConsumeOutcome =
93 | { readonly _tag: 'Completed'; readonly applied: number }
94 | { readonly _tag: 'Cancelled'; readonly applied: number }
95 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
96 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
97 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
98 
99// ── The streaming consume loop ─────────────────────────────────────────────────
100 
101const consumeStream = async (
102 caps: Capabilities,
103 stream: AsyncIterable<string>,
104 reporter: ProgressReporter,
105 signal: CancellationSignal,
106 preference: NonEmptyArray<WriteTarget>,
107): Promise<ConsumeOutcome> => {
108 let buffer = '';
109 let applied = 0;
110 let expected = DEFAULT_EXPECTED_SETTINGS;
111 let parseErrors = 0;
112 let writeErrors = 0;
113 let lastParseError = '';
114 let lastConfigError: ConfigError | undefined;
115 let currentMessage = INITIAL_MESSAGE;
116 let lastMessageAt = neverShown;
117 
118 const reportProgress = (): void =>
119 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
120 
121 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
122 const result = await caps.config.applySetting(setting, preference);
123 if (result._tag === 'Ok') {
124 applied += 1;
125 reportProgress();
126 return 'ok';
127 }
128 // A write failure is environmental, not a model-output problem — track it on
129 // its own budget and log it, rather than spending the malformed-line tolerance.
130 writeErrors += 1;
131 lastConfigError = result.error;
132 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
133 return 'error';
134 };
135 
136 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
137 matchTag(directive, {
138 Count: async ({ total }): Promise<ApplyStatus> => {
139 expected = total;
140 currentMessage = `🎯 Planning ${total} theme settings...`;
141 reportProgress();
142 return 'ok';
143 },
144 Message: async ({ text }): Promise<ApplyStatus> => {
145 const now: Millis = caps.clock.now();
146 if (shouldShowMessage(lastMessageAt, now)) {
147 currentMessage = `${text}`;
148 lastMessageAt = markShown(now);
149 reporter.report({ message: currentMessage, percent: none });
150 }
151 return 'ok';
152 },
153 Selector: ({ selector, color }): Promise<ApplyStatus> =>
154 applySetting({ _tag: 'SelectorSetting', selector, color }),
155 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
156 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
157 });
158 
159 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
160 if (line.trim() === '') {
161 return null;
162 }
163 const parsed = parseLine(line);
164 if (parsed._tag === 'Ok') {
165 // Write failures are counted inside applySetting, on their own budget.
166 await applyDirective(parsed.value);
167 } else {
168 parseErrors += 1;
169 lastParseError = renderParseError(parsed.error);
170 caps.logger.debug('directive parse failed', { line, error: lastParseError });
171 }
172 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
173 return { _tag: 'Aborted', applied, lastError: lastParseError };
174 }
175 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
176 return { _tag: 'WriteFailed', applied, error: lastConfigError };
177 }
178 return null;
179 };
180 
181 try {
182 for await (const chunk of stream) {
183 if (signal.isCancelled()) {
184 break;
185 }
186 buffer += chunk;
187 const segments = buffer.split('\n');
188 buffer = segments.pop() ?? '';
189 for (const line of segments) {
190 if (signal.isCancelled()) {
191 break;
192 }
193 const aborted = await handleLine(line);
194 if (aborted !== null) {
195 return aborted;
196 }
197 }
198 }
199 } catch (e) {
200 // The stream threw after opening (provider error mid-generation). Adapters
201 // re-throw a classified ProviderError; anything else becomes Unexpected.
202 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
203 return { _tag: 'StreamFailed', applied, error };
204 }
205 
206 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
207 if (!signal.isCancelled() && buffer.trim() !== '') {
208 const parsed = parseLine(buffer);
209 if (parsed._tag === 'Ok') {
210 await applyDirective(parsed.value);
211 }
212 }
213 
214 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
215};
216 
217// ── Finalization: keep/reset modals after the progress notification closes ─────
218 
219const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
220 result._tag === 'Ok' ? result.value : 'keep';
221 
222const finalize = (
223 caps: Capabilities,
224 consumed: ConsumeOutcome,
225 provider: Provider,
226): AsyncResultType<GenerationError, GenerationOutcome> =>
227 matchTag(consumed, {
228 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
229 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
230 
231 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
232 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error, provider })),
233 
234 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
235 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
236 
237 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
238 const choice = await caps.ui.announceCompletion({
239 applied,
240 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
241 });
242 if (decideKeepOrReset(choice) === 'reset') {
243 await resetQuietly(caps);
244 }
245 return ok({ _tag: 'Completed', applied });
246 },
247 
248 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
249 const choice = await caps.ui.confirmCancellation(applied);
250 if (decideKeepOrReset(choice) === 'reset') {
251 await resetQuietly(caps);
252 return ok({ _tag: 'CancelledReset' });
253 }
254 return ok({ _tag: 'CancelledKept', applied });
255 },
256 });
257 
258const resetQuietly = async (caps: Capabilities): Promise<void> => {
259 const result = await caps.config.reset();
260 if (result._tag === 'Err') {
261 caps.logger.error('reset after generation failed', {
262 error: renderConfigError(result.error).title,
263 });
264 }
265};
266 
267// ── The use case ───────────────────────────────────────────────────────────────
268 
269const runGeneration = async (
270 caps: Capabilities,
271 model: Model,
272 key: Redacted<ApiKey>,
273 vibe: Vibe,
274 system: NonEmptyString,
275): AsyncResultType<GenerationError, GenerationOutcome> => {
276 const current = caps.config.readCurrentTheme();
277 const user = buildUserPrompt(current, vibe);
278 const preference = writePreference(caps.config.applyTo(), caps.config.hasWorkspaceFolders());
279 
280 const streamResult = await caps.gateway.streamTheme({
281 provider: model.provider,
282 key,
283 model: model.id,
284 system,
285 user,
286 });
287 if (streamResult._tag === 'Err') {
288 return err({ _tag: 'Provider', error: streamResult.error, provider: model.provider });
289 }
290 
291 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
292 reporter.report({ message: INITIAL_MESSAGE, percent: none });
293 return consumeStream(caps, streamResult.value, reporter, signal, preference);
294 });
295 
296 return finalize(caps, consumed, model.provider);
297};
298 
299export const generateTheme = async (
300 caps: Capabilities,
301): AsyncResultType<GenerationError, GenerationOutcome> => {
302 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
303 const modelLabel = `${providerInfo(model.provider).displayName} · ${modelText(model.id)}`;
304 
305 // Ask for the vibe first: the user commits to an action (and sees the active
306 // model) before being asked for a credential. Provisioning the key — which does a
307 // verify round-trip — only happens once there's a vibe to act on.
308 const vibeResult = await caps.ui.pickVibe(curatedSuggestions, modelLabel);
309 if (vibeResult._tag === 'Err') {
310 return err({ _tag: 'Ui', error: vibeResult.error });
311 }
312 if (vibeResult.value._tag === 'None') {
313 return ok({ _tag: 'NoVibe' });
314 }
315 
316 const keyResult = await provisionApiKey(caps, model.provider);
317 if (keyResult._tag === 'Err') {
318 return keyResult.error._tag === 'Cancelled'
319 ? ok({ _tag: 'NoKey' })
320 : err({ _tag: 'Provision', error: keyResult.error });
321 }
322 
323 const promptResult = await caps.prompts.systemPrompt();
324 if (promptResult._tag === 'Err') {
325 return err({ _tag: 'Prompt', error: promptResult.error });
326 }
327 
328 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
329};
330 
331export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
332 matchTag(e, {
333 Provision: ({ error }) => renderProvisionError(error),
334 Ui: ({ error }) => some(renderUiError(error)),
335 Prompt: ({ error }) => some(renderPromptError(error)),
336 Provider: ({ error, provider }) => some(renderProviderError(error, provider)),
337 StreamInterrupted: ({ applied, error, provider }) =>
338 some(
339 userMessage(renderProviderError(error, provider).title, {
340 detail: `Generation stopped after applying ${applied} settings.`,
341 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
342 }),
343 ),
344 WriteAborted: ({ applied, error }) =>
⋯ 14 lines hidden (lines 345–358)
345 some(
346 userMessage(renderConfigError(error).title, {
347 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
348 suggestion: 'Check VS Code permissions and try restarting the editor.',
349 }),
350 ),
351 Aborted: ({ applied, lastError }) =>
352 some(
353 userMessage('⚠️ Theme generation was interrupted', {
354 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
355 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
356 }),
357 ),
358 });

Vibe-before-key ordering, and the rendered error naming Anthropic.

test/generateTheme.test.ts · 238 lines
test/generateTheme.test.ts238 lines · TypeScript
⋯ 97 lines hidden (lines 1–97)
1import { describe, it } from 'node:test';
2import assert from 'node:assert/strict';
3import { generateTheme, renderGenerationError } 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 // The gateway is actually asked to stream the Anthropic model, not just the key.
83 assert.equal(h.captured.streamProvider, 'anthropic');
84 assert.equal(h.captured.streamModel, 'claude-sonnet-4-6');
85 assert.equal(h.colors.get('editor.background'), '#101010');
86 if (result._tag === 'Ok') {
87 assert.equal(result.value._tag, 'Completed');
88 }
89 });
90});
91 
92describe('generateTheme — benign exits', () => {
93 it('returns NoVibe when the picker is dismissed', async () => {
94 const h = harness({ storedKey: VALID_KEY });
95 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoVibe' } });
96 });
97 
98 it('asks for the vibe before the API key', async () => {
99 // No key and no vibe: dismissing the vibe picker yields NoVibe, which proves the
100 // vibe step runs before key provisioning (it would otherwise be NoKey).
101 const h = harness({});
102 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoVibe' } });
103 assert.equal(h.captured.keySet, false);
104 });
105 
106 it('returns NoKey when no key is stored and the prompt is dismissed', async () => {
107 const h = harness({ vibe: 'cozy autumn' });
108 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoKey' } });
109 });
110});
111 
112describe('generateTheme — failures', () => {
113 it('surfaces a provider stream error', async () => {
114 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamError: { _tag: 'RateLimited' } });
115 assert.deepEqual(await generateTheme(h.caps), {
116 _tag: 'Err',
117 error: { _tag: 'Provider', error: { _tag: 'RateLimited' }, provider: 'openai' },
118 });
119 });
120 
121 it('names the provider in the rendered error (multi-provider)', async () => {
122 const h = harness({
123 selectedModel: makeModel('anthropic', 'claude-sonnet-4-6'),
124 storedKey: `sk-ant-${'x'.repeat(40)}`,
125 vibe: 'cozy',
126 streamError: { _tag: 'AuthFailed' },
127 });
128 const result = await generateTheme(h.caps);
129 assert.equal(result._tag, 'Err');
130 if (result._tag === 'Err') {
131 const msg = renderGenerationError(result.error);
132 assert.equal(msg._tag, 'Some');
133 if (msg._tag === 'Some') {
134 assert.ok(msg.value.title.includes('Anthropic'), msg.value.title);
135 }
136 }
137 });
⋯ 101 lines hidden (lines 138–238)
138 
139 it('surfaces a mid-stream provider error and keeps the partial theme', async () => {
140 // The stream opens, applies some settings, then throws (e.g. a 429 once tokens
141 // flow). Before the fix this escaped as an unhandled rejection; now it is a
142 // typed StreamInterrupted error and the partial theme is left for the user.
143 const h = harness({
144 storedKey: VALID_KEY,
145 vibe: 'cozy',
146 streamText: HAPPY_STREAM,
147 chunkSize: 1000,
148 streamThrowAfter: 1,
149 streamThrowError: { _tag: 'RateLimited' },
150 });
151 const result = await generateTheme(h.caps);
152 
153 assert.equal(result._tag, 'Err');
154 if (result._tag === 'Err' && result.error._tag === 'StreamInterrupted') {
155 assert.equal(result.error.error._tag, 'RateLimited');
156 assert.equal(result.error.applied, 3);
157 } else {
158 assert.fail(`expected StreamInterrupted, got ${JSON.stringify(result)}`);
159 }
160 assert.equal(h.colors.get('editor.background'), '#1a1a1a');
161 assert.equal(h.captured.resets, 0);
162 });
163 
164 it('aborts after too many malformed lines', async () => {
165 const garbage = Array.from({ length: 6 }, (_unused, i) => `GARBAGE:${i}`).join('\n');
166 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamText: garbage });
167 const result = await generateTheme(h.caps);
168 assert.equal(result._tag, 'Err');
169 if (result._tag === 'Err') {
170 assert.equal(result.error._tag, 'Aborted');
171 }
172 });
173 
174 it('reports a write failure distinctly from malformed output', async () => {
175 // Valid directives whose *writes* all fail must abort as a config/IO error,
176 // not as "too many malformed lines" — they never spend the parse budget.
177 const lines = ['COUNT:6'];
178 for (let i = 0; i < 6; i += 1) {
179 lines.push(`SELECTOR:editor.k${i}=#111111`);
180 }
181 const h = harness({
182 storedKey: VALID_KEY,
183 vibe: 'cozy',
184 streamText: `${lines.join('\n')}\n`,
185 chunkSize: 1000,
186 failApply: true,
187 });
188 const result = await generateTheme(h.caps);
189 
190 assert.equal(result._tag, 'Err');
191 if (result._tag === 'Err' && result.error._tag === 'WriteAborted') {
192 assert.equal(result.error.applied, 0);
193 } else {
194 assert.fail(`expected WriteAborted, got ${JSON.stringify(result)}`);
195 }
196 });
197});
198 
199describe('generateTheme — cancellation and reset', () => {
200 it('on cancel + reset, clears the theme', async () => {
201 const h = harness({
202 storedKey: VALID_KEY,
203 vibe: 'cozy',
204 streamText: HAPPY_STREAM,
205 cancelAfterReports: 2,
206 cancellationChoice: 'reset',
207 });
208 const result = await generateTheme(h.caps);
209 if (result._tag === 'Ok') {
210 assert.equal(result.value._tag, 'CancelledReset');
211 }
212 assert.ok(h.captured.resets >= 1);
213 });
214 
215 it('on success + reset choice, clears the theme', async () => {
216 const h = harness({
217 storedKey: VALID_KEY,
218 vibe: 'cozy',
219 streamText: HAPPY_STREAM,
220 completionChoice: 'reset',
221 });
222 const result = await generateTheme(h.caps);
223 if (result._tag === 'Ok') {
224 assert.equal(result.value._tag, 'Completed');
225 }
226 assert.equal(h.captured.resets, 1);
227 });
228});
229 
230describe('generateTheme — secret safety', () => {
231 it('never lets the raw API key reach logs or notifications', async () => {
232 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn', streamText: HAPPY_STREAM });
233 await generateTheme(h.caps);
234 const serialized = JSON.stringify(h.captured);
235 assert.equal(serialized.includes('sk-'), false);
236 assert.equal(serialized.includes('x'.repeat(40)), false);
237 });
238});