Count each on its own budget
applySetting counts write failures and remembers the ConfigError; handleLine counts only parse failures and checks both thresholds, returning a distinct outcome for each.
Write failures tracked in applySetting; handleLine aborts parse vs write distinctly.
src/application/generateTheme.ts · 347 lines
src/application/generateTheme.ts
⋯ 118 lines hidden (lines 1–118)
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 */
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 ConfigError,
40 isProviderError,
41 type KeepOrReset,
42 type Millis,
43 type ProgressReporter,
44 type PromptError,
45 type ProviderError,
46 renderConfigError,
47 renderProviderError,
48 renderPromptError,
49 renderUiError,
50 type UiError,
51 type UserMessage,
52 userMessage,
53} from '../ports';
54import { buildUserPrompt } from './context';
55import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
56import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
57import { curatedSuggestions } from './suggestions';
59// Tolerance for malformed *model output* (a parse-quality signal).
60const MAX_RECOVERABLE_ERRORS = 5;
61// Tolerance for *settings-write* failures (an environment/permissions signal,
62// kept separate so an IO problem isn't misreported as bad model output).
63const MAX_WRITE_ERRORS = 5;
64const DEFAULT_EXPECTED_SETTINGS = 120;
65const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
66const PROGRESS_TITLE = 'Vibe Themer';
68export type GenerationOutcome =
69 | { readonly _tag: 'NoKey' }
70 | { readonly _tag: 'NoVibe' }
71 | { readonly _tag: 'Completed'; readonly applied: number }
72 | { readonly _tag: 'CancelledKept'; readonly applied: number }
73 | { readonly _tag: 'CancelledReset' };
75export type GenerationError =
76 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
77 | { readonly _tag: 'Ui'; readonly error: UiError }
78 | { readonly _tag: 'Prompt'; readonly error: PromptError }
79 | { readonly _tag: 'Provider'; readonly error: ProviderError }
80 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
81 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
82 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
84type ApplyStatus = 'ok' | 'error';
86type ConsumeOutcome =
87 | { readonly _tag: 'Completed'; readonly applied: number }
88 | { readonly _tag: 'Cancelled'; readonly applied: number }
89 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
90 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
91 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
93// ── The streaming consume loop ─────────────────────────────────────────────────
95const consumeStream = async (
96 caps: Capabilities,
97 stream: AsyncIterable<string>,
98 reporter: ProgressReporter,
99 signal: CancellationSignal,
100 preference: NonEmptyArray<WriteTarget>,
101): Promise<ConsumeOutcome> => {
102 let buffer = '';
103 let applied = 0;
104 let expected = DEFAULT_EXPECTED_SETTINGS;
105 let parseErrors = 0;
106 let writeErrors = 0;
107 let lastParseError = '';
108 let lastConfigError: ConfigError | undefined;
109 let currentMessage = INITIAL_MESSAGE;
110 let lastMessageAt = neverShown;
112 const reportProgress = (): void =>
113 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
115 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
116 const result = await caps.config.applySetting(setting, preference);
117 if (result._tag === 'Ok') {
118 applied += 1;
119 reportProgress();
120 return 'ok';
121 }
122 // A write failure is environmental, not a model-output problem — track it on
123 // its own budget and log it, rather than spending the malformed-line tolerance.
124 writeErrors += 1;
125 lastConfigError = result.error;
126 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
127 return 'error';
⋯ 30 lines hidden (lines 128–157)
128 };
130 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
131 matchTag(directive, {
132 Count: async ({ total }): Promise<ApplyStatus> => {
133 expected = total;
134 currentMessage = `🎯 Planning ${total} theme settings...`;
135 reportProgress();
136 return 'ok';
137 },
138 Message: async ({ text }): Promise<ApplyStatus> => {
139 const now: Millis = caps.clock.now();
140 if (shouldShowMessage(lastMessageAt, now)) {
141 currentMessage = `✨ ${text}`;
142 lastMessageAt = markShown(now);
143 reporter.report({ message: currentMessage, percent: none });
144 }
145 return 'ok';
146 },
147 Selector: ({ selector, color }): Promise<ApplyStatus> =>
148 applySetting({ _tag: 'SelectorSetting', selector, color }),
149 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
150 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
151 });
153 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
154 if (line.trim() === '') {
155 return null;
156 }
157 const parsed = parseLine(line);
158 if (parsed._tag === 'Ok') {
159 // Write failures are counted inside applySetting, on their own budget.
160 await applyDirective(parsed.value);
161 } else {
162 parseErrors += 1;
163 lastParseError = renderParseError(parsed.error);
164 caps.logger.debug('directive parse failed', { line, error: lastParseError });
165 }
166 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
167 return { _tag: 'Aborted', applied, lastError: lastParseError };
168 }
169 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
170 return { _tag: 'WriteFailed', applied, error: lastConfigError };
171 }
172 return null;
⋯ 175 lines hidden (lines 173–347)
173 };
175 try {
176 for await (const chunk of stream) {
177 if (signal.isCancelled()) {
178 break;
179 }
180 buffer += chunk;
181 const segments = buffer.split('\n');
182 buffer = segments.pop() ?? '';
183 for (const line of segments) {
184 if (signal.isCancelled()) {
185 break;
186 }
187 const aborted = await handleLine(line);
188 if (aborted !== null) {
189 return aborted;
190 }
191 }
192 }
193 } catch (e) {
194 // The stream threw after opening (provider error mid-generation). Adapters
195 // re-throw a classified ProviderError; anything else becomes Unexpected.
196 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
197 return { _tag: 'StreamFailed', applied, error };
198 }
200 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
201 if (!signal.isCancelled() && buffer.trim() !== '') {
202 const parsed = parseLine(buffer);
203 if (parsed._tag === 'Ok') {
204 await applyDirective(parsed.value);
205 }
206 }
208 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
209};
211// ── Finalization: keep/reset modals after the progress notification closes ─────
213const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
214 result._tag === 'Ok' ? result.value : 'keep';
216const finalize = (
217 caps: Capabilities,
218 consumed: ConsumeOutcome,
219): AsyncResultType<GenerationError, GenerationOutcome> =>
220 matchTag(consumed, {
221 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
222 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
224 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
225 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
227 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
228 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
230 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
231 const choice = await caps.ui.announceCompletion({
232 applied,
233 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
234 });
235 if (decideKeepOrReset(choice) === 'reset') {
236 await resetQuietly(caps);
237 }
238 return ok({ _tag: 'Completed', applied });
239 },
241 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
242 const choice = await caps.ui.confirmCancellation(applied);
243 if (decideKeepOrReset(choice) === 'reset') {
244 await resetQuietly(caps);
245 return ok({ _tag: 'CancelledReset' });
246 }
247 return ok({ _tag: 'CancelledKept', applied });
248 },
249 });
251const resetQuietly = async (caps: Capabilities): Promise<void> => {
252 const result = await caps.config.reset();
253 if (result._tag === 'Err') {
254 caps.logger.error('reset after generation failed', {
255 error: renderConfigError(result.error).title,
256 });
257 }
258};
260// ── The use case ───────────────────────────────────────────────────────────────
262const runGeneration = async (
263 caps: Capabilities,
264 model: Model,
265 key: Redacted<ApiKey>,
266 vibe: Vibe,
267 system: NonEmptyString,
268): AsyncResultType<GenerationError, GenerationOutcome> => {
269 const current = caps.config.readCurrentTheme();
270 const user = buildUserPrompt(current, vibe);
271 const preference = writePreference(caps.config.hasWorkspaceFolders());
273 const streamResult = await caps.gateway.streamTheme({
274 provider: model.provider,
275 key,
276 model: model.id,
277 system,
278 user,
279 });
280 if (streamResult._tag === 'Err') {
281 return err({ _tag: 'Provider', error: streamResult.error });
282 }
284 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
285 reporter.report({ message: INITIAL_MESSAGE, percent: none });
286 return consumeStream(caps, streamResult.value, reporter, signal, preference);
287 });
289 return finalize(caps, consumed);
290};
292export const generateTheme = async (
293 caps: Capabilities,
294): AsyncResultType<GenerationError, GenerationOutcome> => {
295 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
297 const keyResult = await provisionApiKey(caps, model.provider);
298 if (keyResult._tag === 'Err') {
299 return keyResult.error._tag === 'Cancelled'
300 ? ok({ _tag: 'NoKey' })
301 : err({ _tag: 'Provision', error: keyResult.error });
302 }
304 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
305 if (vibeResult._tag === 'Err') {
306 return err({ _tag: 'Ui', error: vibeResult.error });
307 }
308 if (vibeResult.value._tag === 'None') {
309 return ok({ _tag: 'NoVibe' });
310 }
312 const promptResult = await caps.prompts.systemPrompt();
313 if (promptResult._tag === 'Err') {
314 return err({ _tag: 'Prompt', error: promptResult.error });
315 }
317 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
318};
320export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
321 matchTag(e, {
322 Provision: ({ error }) => renderProvisionError(error),
323 Ui: ({ error }) => some(renderUiError(error)),
324 Prompt: ({ error }) => some(renderPromptError(error)),
325 Provider: ({ error }) => some(renderProviderError(error)),
326 StreamInterrupted: ({ applied, error }) =>
327 some(
328 userMessage(renderProviderError(error).title, {
329 detail: `Generation stopped after applying ${applied} settings.`,
330 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
331 }),
332 ),
333 WriteAborted: ({ applied, error }) =>
334 some(
335 userMessage(renderConfigError(error).title, {
336 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
337 suggestion: 'Check VS Code permissions and try restarting the editor.',
338 }),
339 ),
340 Aborted: ({ applied, lastError }) =>
341 some(
342 userMessage('⚠️ Theme generation was interrupted', {
343 detail: `${applied} settings were applied before too many errors (last: ${lastError}).`,
344 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
345 }),
346 ),
347 });
WriteFailed -> WriteAborted, rendered as a settings/permissions problem.
src/application/generateTheme.ts · 347 lines
src/application/generateTheme.ts
⋯ 226 lines hidden (lines 1–226)
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 */
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 ConfigError,
40 isProviderError,
41 type KeepOrReset,
42 type Millis,
43 type ProgressReporter,
44 type PromptError,
45 type ProviderError,
46 renderConfigError,
47 renderProviderError,
48 renderPromptError,
49 renderUiError,
50 type UiError,
51 type UserMessage,
52 userMessage,
53} from '../ports';
54import { buildUserPrompt } from './context';
55import { markShown, neverShown, progressPercent, shouldShowMessage } from './progress';
56import { type ProvisionError, provisionApiKey, renderProvisionError } from './provisionApiKey';
57import { curatedSuggestions } from './suggestions';
59// Tolerance for malformed *model output* (a parse-quality signal).
60const MAX_RECOVERABLE_ERRORS = 5;
61// Tolerance for *settings-write* failures (an environment/permissions signal,
62// kept separate so an IO problem isn't misreported as bad model output).
63const MAX_WRITE_ERRORS = 5;
64const DEFAULT_EXPECTED_SETTINGS = 120;
65const INITIAL_MESSAGE = '🤖 AI analyzing your vibe...';
66const PROGRESS_TITLE = 'Vibe Themer';
68export type GenerationOutcome =
69 | { readonly _tag: 'NoKey' }
70 | { readonly _tag: 'NoVibe' }
71 | { readonly _tag: 'Completed'; readonly applied: number }
72 | { readonly _tag: 'CancelledKept'; readonly applied: number }
73 | { readonly _tag: 'CancelledReset' };
75export type GenerationError =
76 | { readonly _tag: 'Provision'; readonly error: ProvisionError }
77 | { readonly _tag: 'Ui'; readonly error: UiError }
78 | { readonly _tag: 'Prompt'; readonly error: PromptError }
79 | { readonly _tag: 'Provider'; readonly error: ProviderError }
80 | { readonly _tag: 'StreamInterrupted'; readonly applied: number; readonly error: ProviderError }
81 | { readonly _tag: 'WriteAborted'; readonly applied: number; readonly error: ConfigError }
82 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
84type ApplyStatus = 'ok' | 'error';
86type ConsumeOutcome =
87 | { readonly _tag: 'Completed'; readonly applied: number }
88 | { readonly _tag: 'Cancelled'; readonly applied: number }
89 | { readonly _tag: 'StreamFailed'; readonly applied: number; readonly error: ProviderError }
90 | { readonly _tag: 'WriteFailed'; readonly applied: number; readonly error: ConfigError }
91 | { readonly _tag: 'Aborted'; readonly applied: number; readonly lastError: string };
93// ── The streaming consume loop ─────────────────────────────────────────────────
95const consumeStream = async (
96 caps: Capabilities,
97 stream: AsyncIterable<string>,
98 reporter: ProgressReporter,
99 signal: CancellationSignal,
100 preference: NonEmptyArray<WriteTarget>,
101): Promise<ConsumeOutcome> => {
102 let buffer = '';
103 let applied = 0;
104 let expected = DEFAULT_EXPECTED_SETTINGS;
105 let parseErrors = 0;
106 let writeErrors = 0;
107 let lastParseError = '';
108 let lastConfigError: ConfigError | undefined;
109 let currentMessage = INITIAL_MESSAGE;
110 let lastMessageAt = neverShown;
112 const reportProgress = (): void =>
113 reporter.report({ message: currentMessage, percent: some(progressPercent(applied, expected)) });
115 const applySetting = async (setting: ThemeSetting): Promise<ApplyStatus> => {
116 const result = await caps.config.applySetting(setting, preference);
117 if (result._tag === 'Ok') {
118 applied += 1;
119 reportProgress();
120 return 'ok';
121 }
122 // A write failure is environmental, not a model-output problem — track it on
123 // its own budget and log it, rather than spending the malformed-line tolerance.
124 writeErrors += 1;
125 lastConfigError = result.error;
126 caps.logger.debug('apply setting failed', { error: renderConfigError(result.error).title });
127 return 'error';
128 };
130 const applyDirective = (directive: StreamingDirective): Promise<ApplyStatus> =>
131 matchTag(directive, {
132 Count: async ({ total }): Promise<ApplyStatus> => {
133 expected = total;
134 currentMessage = `🎯 Planning ${total} theme settings...`;
135 reportProgress();
136 return 'ok';
137 },
138 Message: async ({ text }): Promise<ApplyStatus> => {
139 const now: Millis = caps.clock.now();
140 if (shouldShowMessage(lastMessageAt, now)) {
141 currentMessage = `✨ ${text}`;
142 lastMessageAt = markShown(now);
143 reporter.report({ message: currentMessage, percent: none });
144 }
145 return 'ok';
146 },
147 Selector: ({ selector, color }): Promise<ApplyStatus> =>
148 applySetting({ _tag: 'SelectorSetting', selector, color }),
149 Token: ({ scope, color, fontStyle }): Promise<ApplyStatus> =>
150 applySetting({ _tag: 'TokenSetting', scope, color, fontStyle }),
151 });
153 const handleLine = async (line: string): Promise<ConsumeOutcome | null> => {
154 if (line.trim() === '') {
155 return null;
156 }
157 const parsed = parseLine(line);
158 if (parsed._tag === 'Ok') {
159 // Write failures are counted inside applySetting, on their own budget.
160 await applyDirective(parsed.value);
161 } else {
162 parseErrors += 1;
163 lastParseError = renderParseError(parsed.error);
164 caps.logger.debug('directive parse failed', { line, error: lastParseError });
165 }
166 if (parseErrors >= MAX_RECOVERABLE_ERRORS) {
167 return { _tag: 'Aborted', applied, lastError: lastParseError };
168 }
169 if (writeErrors >= MAX_WRITE_ERRORS && lastConfigError !== undefined) {
170 return { _tag: 'WriteFailed', applied, error: lastConfigError };
171 }
172 return null;
173 };
175 try {
176 for await (const chunk of stream) {
177 if (signal.isCancelled()) {
178 break;
179 }
180 buffer += chunk;
181 const segments = buffer.split('\n');
182 buffer = segments.pop() ?? '';
183 for (const line of segments) {
184 if (signal.isCancelled()) {
185 break;
186 }
187 const aborted = await handleLine(line);
188 if (aborted !== null) {
189 return aborted;
190 }
191 }
192 }
193 } catch (e) {
194 // The stream threw after opening (provider error mid-generation). Adapters
195 // re-throw a classified ProviderError; anything else becomes Unexpected.
196 const error: ProviderError = isProviderError(e) ? e : { _tag: 'Unexpected', detail: String(e) };
197 return { _tag: 'StreamFailed', applied, error };
198 }
200 // Flush a trailing partial line on clean completion (v1 parity); ignore its errors.
201 if (!signal.isCancelled() && buffer.trim() !== '') {
202 const parsed = parseLine(buffer);
203 if (parsed._tag === 'Ok') {
204 await applyDirective(parsed.value);
205 }
206 }
208 return signal.isCancelled() ? { _tag: 'Cancelled', applied } : { _tag: 'Completed', applied };
209};
211// ── Finalization: keep/reset modals after the progress notification closes ─────
213const decideKeepOrReset = (result: ResultType<UiError, KeepOrReset>): KeepOrReset =>
214 result._tag === 'Ok' ? result.value : 'keep';
216const finalize = (
217 caps: Capabilities,
218 consumed: ConsumeOutcome,
219): AsyncResultType<GenerationError, GenerationOutcome> =>
220 matchTag(consumed, {
221 Aborted: ({ applied, lastError }): AsyncResultType<GenerationError, GenerationOutcome> =>
222 Promise.resolve(err({ _tag: 'Aborted', applied, lastError })),
224 StreamFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
225 Promise.resolve(err({ _tag: 'StreamInterrupted', applied, error })),
227 WriteFailed: ({ applied, error }): AsyncResultType<GenerationError, GenerationOutcome> =>
228 Promise.resolve(err({ _tag: 'WriteAborted', applied, error })),
⋯ 104 lines hidden (lines 229–332)
230 Completed: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
231 const choice = await caps.ui.announceCompletion({
232 applied,
233 coverage: coverage(applied, DEFAULT_EXPECTED_SETTINGS),
234 });
235 if (decideKeepOrReset(choice) === 'reset') {
236 await resetQuietly(caps);
237 }
238 return ok({ _tag: 'Completed', applied });
239 },
241 Cancelled: async ({ applied }): AsyncResultType<GenerationError, GenerationOutcome> => {
242 const choice = await caps.ui.confirmCancellation(applied);
243 if (decideKeepOrReset(choice) === 'reset') {
244 await resetQuietly(caps);
245 return ok({ _tag: 'CancelledReset' });
246 }
247 return ok({ _tag: 'CancelledKept', applied });
248 },
249 });
251const resetQuietly = async (caps: Capabilities): Promise<void> => {
252 const result = await caps.config.reset();
253 if (result._tag === 'Err') {
254 caps.logger.error('reset after generation failed', {
255 error: renderConfigError(result.error).title,
256 });
257 }
258};
260// ── The use case ───────────────────────────────────────────────────────────────
262const runGeneration = async (
263 caps: Capabilities,
264 model: Model,
265 key: Redacted<ApiKey>,
266 vibe: Vibe,
267 system: NonEmptyString,
268): AsyncResultType<GenerationError, GenerationOutcome> => {
269 const current = caps.config.readCurrentTheme();
270 const user = buildUserPrompt(current, vibe);
271 const preference = writePreference(caps.config.hasWorkspaceFolders());
273 const streamResult = await caps.gateway.streamTheme({
274 provider: model.provider,
275 key,
276 model: model.id,
277 system,
278 user,
279 });
280 if (streamResult._tag === 'Err') {
281 return err({ _tag: 'Provider', error: streamResult.error });
282 }
284 const consumed = await caps.ui.runWithProgress(PROGRESS_TITLE, (reporter, signal) => {
285 reporter.report({ message: INITIAL_MESSAGE, percent: none });
286 return consumeStream(caps, streamResult.value, reporter, signal, preference);
287 });
289 return finalize(caps, consumed);
290};
292export const generateTheme = async (
293 caps: Capabilities,
294): AsyncResultType<GenerationError, GenerationOutcome> => {
295 const model = Option.getOrElse(() => DEFAULT_MODEL)(caps.preferences.selectedModel());
297 const keyResult = await provisionApiKey(caps, model.provider);
298 if (keyResult._tag === 'Err') {
299 return keyResult.error._tag === 'Cancelled'
300 ? ok({ _tag: 'NoKey' })
301 : err({ _tag: 'Provision', error: keyResult.error });
302 }
304 const vibeResult = await caps.ui.pickVibe(curatedSuggestions);
305 if (vibeResult._tag === 'Err') {
306 return err({ _tag: 'Ui', error: vibeResult.error });
307 }
308 if (vibeResult.value._tag === 'None') {
309 return ok({ _tag: 'NoVibe' });
310 }
312 const promptResult = await caps.prompts.systemPrompt();
313 if (promptResult._tag === 'Err') {
314 return err({ _tag: 'Prompt', error: promptResult.error });
315 }
317 return runGeneration(caps, model, keyResult.value, vibeResult.value.value, promptResult.value);
318};
320export const renderGenerationError = (e: GenerationError): Option.Option<UserMessage> =>
321 matchTag(e, {
322 Provision: ({ error }) => renderProvisionError(error),
323 Ui: ({ error }) => some(renderUiError(error)),
324 Prompt: ({ error }) => some(renderPromptError(error)),
325 Provider: ({ error }) => some(renderProviderError(error)),
326 StreamInterrupted: ({ applied, error }) =>
327 some(
328 userMessage(renderProviderError(error).title, {
329 detail: `Generation stopped after applying ${applied} settings.`,
330 suggestion: 'Try again, or run "Reset Theme Customizations" to start fresh.',
331 }),
332 ),
333 WriteAborted: ({ applied, error }) =>
334 some(
335 userMessage(renderConfigError(error).title, {
336 detail: `Stopped after applying ${applied} settings — the editor kept refusing writes.`,
337 suggestion: 'Check VS Code permissions and try restarting the editor.',
338 }),
339 ),
⋯ 8 lines hidden (lines 340–347)
The discriminating test
With failApply, six valid directives whose writes all fail must surface WriteAborted (a config/IO error), not Aborted (malformed output). Before the split, this produced the wrong, output-blaming message.
Valid directives + failing writes -> WriteAborted, not Aborted.
test/generateTheme.test.ts · 212 lines
test/generateTheme.test.ts
⋯ 147 lines hidden (lines 1–147)
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';
7const VALID_KEY = `sk-${'x'.repeat(40)}`;
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');
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);
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 });
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);
39 assert.equal(h.colors.has('foo.bar'), false);
40 if (result._tag === 'Ok') {
41 assert.equal(result.value._tag, 'Completed');
42 }
43 });
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);
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 });
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);
64 assert.equal(h.captured.keySet, true);
65 if (result._tag === 'Ok') {
66 assert.equal(result.value._tag, 'Completed');
67 }
68 });
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);
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});
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 });
98 it('returns NoKey when no key is stored and the prompt is dismissed', async () => {
99 const h = harness({ vibe: 'cozy autumn' });
100 assert.deepEqual(await generateTheme(h.caps), { _tag: 'Ok', value: { _tag: 'NoKey' } });
101 });
102});
104describe('generateTheme — failures', () => {
105 it('surfaces a provider stream error', async () => {
106 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamError: { _tag: 'RateLimited' } });
107 assert.deepEqual(await generateTheme(h.caps), {
108 _tag: 'Err',
109 error: { _tag: 'Provider', error: { _tag: 'RateLimited' } },
110 });
111 });
113 it('surfaces a mid-stream provider error and keeps the partial theme', async () => {
114 // The stream opens, applies some settings, then throws (e.g. a 429 once tokens
115 // flow). Before the fix this escaped as an unhandled rejection; now it is a
116 // typed StreamInterrupted error and the partial theme is left for the user.
117 const h = harness({
118 storedKey: VALID_KEY,
119 vibe: 'cozy',
120 streamText: HAPPY_STREAM,
121 chunkSize: 1000,
122 streamThrowAfter: 1,
123 streamThrowError: { _tag: 'RateLimited' },
124 });
125 const result = await generateTheme(h.caps);
127 assert.equal(result._tag, 'Err');
128 if (result._tag === 'Err' && result.error._tag === 'StreamInterrupted') {
129 assert.equal(result.error.error._tag, 'RateLimited');
130 assert.equal(result.error.applied, 3);
131 } else {
132 assert.fail(`expected StreamInterrupted, got ${JSON.stringify(result)}`);
133 }
134 assert.equal(h.colors.get('editor.background'), '#1a1a1a');
135 assert.equal(h.captured.resets, 0);
136 });
138 it('aborts after too many malformed lines', async () => {
139 const garbage = Array.from({ length: 6 }, (_unused, i) => `GARBAGE:${i}`).join('\n');
140 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy', streamText: garbage });
141 const result = await generateTheme(h.caps);
142 assert.equal(result._tag, 'Err');
143 if (result._tag === 'Err') {
144 assert.equal(result.error._tag, 'Aborted');
145 }
146 });
148 it('reports a write failure distinctly from malformed output', async () => {
149 // Valid directives whose *writes* all fail must abort as a config/IO error,
150 // not as "too many malformed lines" — they never spend the parse budget.
151 const lines = ['COUNT:6'];
152 for (let i = 0; i < 6; i += 1) {
153 lines.push(`SELECTOR:editor.k${i}=#111111`);
154 }
155 const h = harness({
156 storedKey: VALID_KEY,
157 vibe: 'cozy',
158 streamText: `${lines.join('\n')}\n`,
159 chunkSize: 1000,
160 failApply: true,
161 });
162 const result = await generateTheme(h.caps);
164 assert.equal(result._tag, 'Err');
165 if (result._tag === 'Err' && result.error._tag === 'WriteAborted') {
166 assert.equal(result.error.applied, 0);
167 } else {
168 assert.fail(`expected WriteAborted, got ${JSON.stringify(result)}`);
169 }
170 });
⋯ 42 lines hidden (lines 171–212)
171});
173describe('generateTheme — cancellation and reset', () => {
174 it('on cancel + reset, clears the theme', async () => {
175 const h = harness({
176 storedKey: VALID_KEY,
177 vibe: 'cozy',
178 streamText: HAPPY_STREAM,
179 cancelAfterReports: 2,
180 cancellationChoice: 'reset',
181 });
182 const result = await generateTheme(h.caps);
183 if (result._tag === 'Ok') {
184 assert.equal(result.value._tag, 'CancelledReset');
185 }
186 assert.ok(h.captured.resets >= 1);
187 });
189 it('on success + reset choice, clears the theme', async () => {
190 const h = harness({
191 storedKey: VALID_KEY,
192 vibe: 'cozy',
193 streamText: HAPPY_STREAM,
194 completionChoice: 'reset',
195 });
196 const result = await generateTheme(h.caps);
197 if (result._tag === 'Ok') {
198 assert.equal(result.value._tag, 'Completed');
199 }
200 assert.equal(h.captured.resets, 1);
201 });
202});
204describe('generateTheme — secret safety', () => {
205 it('never lets the raw API key reach logs or notifications', async () => {
206 const h = harness({ storedKey: VALID_KEY, vibe: 'cozy autumn', streamText: HAPPY_STREAM });
207 await generateTheme(h.caps);
208 const serialized = JSON.stringify(h.captured);
209 assert.equal(serialized.includes('sk-'), false);
210 assert.equal(serialized.includes('x'.repeat(40)), false);
211 });
212});