pi-archimedes 2.7.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { SPINNER_STYLES } from "@pi-archimedes/ui/config";
3
+ import { createOnboardingOverlay, type OnboardingResult } from "./overlay.js";
4
+ import type { OverlayTheme } from "@pi-archimedes/core/overlay";
5
+
6
+ // The live-preview seam: a deterministic 4-char string that varies with `tick`
7
+ // (so the tests can verify the frame advances) — the real spinFrame is the
8
+ // stateless 4-cell frame resolver in @pi-archimedes/ui/editor.
9
+ vi.mock("@pi-archimedes/ui/editor", () => ({
10
+ spinFrame: vi.fn((style: string, tick: number) => {
11
+ const c = 0x2800 + ((tick % 8) + (style.length % 4));
12
+ return String.fromCharCode(c) + "⣷⣾⣽";
13
+ }),
14
+ }));
15
+
16
+ // Mirrors the mock above — the expected preview for a style at a tick.
17
+ const previewAt = (style: string, tick: number): string => {
18
+ const c = 0x2800 + ((tick % 8) + (style.length % 4));
19
+ return String.fromCharCode(c) + "⣷⣾⣽";
20
+ };
21
+
22
+ // A plain-object theme satisfying the structural OverlayTheme (no ANSI — the
23
+ // `fg` mock returns its input verbatim, so rendered lines are plain text).
24
+ const theme: OverlayTheme = { fg: (_t: string, s: string) => s };
25
+
26
+ // Raw terminal sequences (matchesKey compares against these, NOT the Key.*
27
+ // literals): down / up / enter / space / escape.
28
+ const DOWN = "\x1b[B";
29
+ const UP = "\x1b[A";
30
+ const ENTER = "\r";
31
+ const SPACE = " ";
32
+ const ESC = "\x1b";
33
+
34
+ type PluginSeed = { id: string; label: string; description: string; selected: boolean };
35
+ type Style = "Full" | "Compact";
36
+ type ToolOpt = "Native" | "Minimal";
37
+
38
+ function makeOverlay(opts: {
39
+ thinkingDefault?: Style;
40
+ toolDefault?: ToolOpt;
41
+ plugins?: PluginSeed[];
42
+ spinners?: readonly string[];
43
+ spinnerDefault?: string;
44
+ requestRender?: () => void;
45
+ } = {}) {
46
+ const onDone = vi.fn<(result: OnboardingResult) => void>();
47
+ const comp = createOnboardingOverlay({
48
+ theme,
49
+ thinkingDefault: opts.thinkingDefault ?? "Full",
50
+ toolDefault: opts.toolDefault ?? "Minimal",
51
+ plugins:
52
+ opts.plugins ?? [
53
+ { id: "ui", label: "UI", description: "TUI enhancements", selected: true },
54
+ { id: "diff", label: "Diff", description: "Diff rendering", selected: false },
55
+ ],
56
+ spinners: opts.spinners ?? ["typing", "pulse", "rain"],
57
+ spinnerDefault: opts.spinnerDefault ?? "pulse",
58
+ // exactOptionalPropertyTypes: only pass the key when defined.
59
+ ...(opts.requestRender !== undefined
60
+ ? { requestRender: opts.requestRender }
61
+ : {}),
62
+ onDone,
63
+ });
64
+ return { comp, onDone };
65
+ }
66
+
67
+ function resultOf(onDone: ReturnType<typeof vi.fn>): OnboardingResult {
68
+ const call = onDone.mock.calls[0];
69
+ expect(call).toBeDefined();
70
+ return call?.[0] as OnboardingResult;
71
+ }
72
+
73
+ // Advance to a given step (0..3) via Enter presses (the last one finalizes).
74
+ function toStep(comp: { handleInput: (d: string) => void }, step: number): void {
75
+ for (let i = 0; i < step; i++) comp.handleInput(ENTER);
76
+ }
77
+
78
+ describe("createOnboardingOverlay", () => {
79
+ it("returns a TUI component with the expected shape", () => {
80
+ const { comp } = makeOverlay();
81
+ expect(comp.focused).toBe(true);
82
+ expect(typeof comp.render).toBe("function");
83
+ expect(typeof comp.handleInput).toBe("function");
84
+ expect(typeof comp.invalidate).toBe("function");
85
+ expect(typeof comp.dispose).toBe("function");
86
+ });
87
+
88
+ // (a) step 0 renders both thinking options + description; the default carries the "> " cursor.
89
+ it("step 0 shows both thinking options with the default carrying the cursor", () => {
90
+ const { comp } = makeOverlay({ thinkingDefault: "Compact" });
91
+ const lines = comp.render(80);
92
+ const text = lines.join("\n");
93
+ expect(text).toContain("Full");
94
+ expect(text).toContain("Compact");
95
+ const fullLine = lines.find((l) => l.includes("Full"));
96
+ const compactLine = lines.find((l) => l.includes("Compact"));
97
+ expect(fullLine).toBeDefined();
98
+ expect(compactLine).toBeDefined();
99
+ // The pre-selected (Compact) carries the cursor; the other does not.
100
+ expect(compactLine).toContain("> Compact");
101
+ expect(fullLine).not.toContain("> Full");
102
+ // The explanatory description is rendered next to the label.
103
+ expect(fullLine).toContain("Show all of the model's reasoning");
104
+ expect(compactLine).toContain("One line of reasoning (click to expand)");
105
+ // A corrupt/unknown default clamps to index 0 (Full), never -1.
106
+ const { comp: corrupt } = makeOverlay({ thinkingDefault: "Bogus" as Style });
107
+ expect(corrupt.render(80).join("\n")).toContain("> Full");
108
+ });
109
+
110
+ // (a2) step 1 renders both tool options + description; the default carries the cursor.
111
+ it("step 1 shows both tool options with the default carrying the cursor", () => {
112
+ const { comp } = makeOverlay({ toolDefault: "Native" });
113
+ toStep(comp, 1);
114
+ const lines = comp.render(80);
115
+ const text = lines.join("\n");
116
+ expect(text).toContain("Minimal");
117
+ expect(text).toContain("Native");
118
+ const minimalLine = lines.find((l) => l.includes("Minimal"));
119
+ const nativeLine = lines.find((l) => l.includes("Native"));
120
+ expect(minimalLine).toBeDefined();
121
+ expect(nativeLine).toBeDefined();
122
+ // The pre-selected (Native) carries the cursor; the other does not.
123
+ expect(nativeLine).toContain("> Native");
124
+ expect(minimalLine).not.toContain("> Minimal");
125
+ expect(minimalLine).toContain("Archimedes-styled tools (minimal view)");
126
+ expect(nativeLine).toContain("Pi's native tool rendering (no styling)");
127
+ });
128
+
129
+ // (b) down then enter advances to step 1 (tool) and confirms thinking; the
130
+ // later finalize reports thinkingAnswered with the down-arrow selection.
131
+ it("down + enter advances to step 1 and a later finalize reports thinkingAnswered", () => {
132
+ const { comp, onDone } = makeOverlay({ thinkingDefault: "Full" });
133
+ comp.handleInput(DOWN); // move the thinking cursor Full → Compact
134
+ comp.handleInput(ENTER); // confirm thinking, advance to step 1
135
+ expect(comp.render(80).join("\n")).toContain("Tool output style");
136
+ comp.handleInput(ESC); // finalize
137
+ expect(onDone).toHaveBeenCalledTimes(1);
138
+ const result = resultOf(onDone);
139
+ expect(result.thinkingAnswered).toBe(true);
140
+ expect(result.thinkingValue).toBe("Compact"); // the down-arrow selection
141
+ expect(result.toolAnswered).toBe(false);
142
+ expect(result.pluginsAnswered).toBe(false);
143
+ expect(result.spinnerAnswered).toBe(false);
144
+ });
145
+
146
+ // (c) space on step 2 (plugins) toggles the selected plugin; finalize reports it.
147
+ it("space on step 2 toggles the selected plugin", () => {
148
+ const { comp, onDone } = makeOverlay({
149
+ thinkingDefault: "Full",
150
+ toolDefault: "Minimal",
151
+ plugins: [
152
+ { id: "ui", label: "UI", description: "TUI", selected: true },
153
+ { id: "diff", label: "Diff", description: "Diff", selected: false },
154
+ ],
155
+ });
156
+ comp.handleInput(ENTER); // confirm step 0 → step 1
157
+ comp.handleInput(ENTER); // confirm step 1 → step 2 (plugins, cursor at index 0 = ui)
158
+ comp.handleInput(SPACE); // toggle ui: true → false
159
+ comp.handleInput(ESC); // finalize
160
+ expect(onDone).toHaveBeenCalledTimes(1);
161
+ const result = resultOf(onDone);
162
+ expect(result.pluginSelections).toEqual({ ui: false, diff: false });
163
+ });
164
+
165
+ // (c2) space on the non-plugin steps does NOT toggle anything.
166
+ it("space on the thinking/tool/spinner steps is a no-op", () => {
167
+ const { comp, onDone } = makeOverlay({
168
+ plugins: [
169
+ { id: "ui", label: "UI", description: "TUI", selected: true },
170
+ { id: "diff", label: "Diff", description: "Diff", selected: false },
171
+ ],
172
+ });
173
+ comp.handleInput(SPACE); // step 0: no-op
174
+ comp.handleInput(ENTER); // step 0 → 1
175
+ comp.handleInput(SPACE); // step 1: no-op
176
+ comp.handleInput(ENTER); // step 1 → 2
177
+ comp.handleInput(ENTER); // step 2 → 3 (plugins cursor at 0 = ui, still selected)
178
+ comp.handleInput(SPACE); // step 3: no-op
179
+ comp.handleInput(ESC); // finalize
180
+ const result = resultOf(onDone);
181
+ expect(result.pluginSelections).toEqual({ ui: true, diff: false });
182
+ });
183
+
184
+ // (d) escape on step 0 finalizes with nothing answered, exactly once.
185
+ it("escape on step 0 calls onDone once with nothing answered", () => {
186
+ const { comp, onDone } = makeOverlay();
187
+ comp.handleInput(ESC);
188
+ expect(onDone).toHaveBeenCalledTimes(1);
189
+ const result = resultOf(onDone);
190
+ expect(result.thinkingAnswered).toBe(false);
191
+ expect(result.toolAnswered).toBe(false);
192
+ expect(result.pluginsAnswered).toBe(false);
193
+ expect(result.spinnerAnswered).toBe(false);
194
+ });
195
+
196
+ // (e) completing all four steps reports everything answered + selected values.
197
+ it("completing all four steps reports all answered with the selected values", () => {
198
+ const { comp, onDone } = makeOverlay({
199
+ thinkingDefault: "Full",
200
+ toolDefault: "Native",
201
+ plugins: [
202
+ { id: "ui", label: "UI", description: "TUI", selected: true },
203
+ { id: "diff", label: "Diff", description: "Diff", selected: false },
204
+ ],
205
+ spinners: ["typing", "pulse", "rain"],
206
+ spinnerDefault: "pulse",
207
+ });
208
+ comp.handleInput(ENTER); // step 0 → 1
209
+ comp.handleInput(ENTER); // step 1 → 2
210
+ comp.handleInput(ENTER); // step 2 → 3
211
+ comp.handleInput(ENTER); // step 3 → finalize
212
+ expect(onDone).toHaveBeenCalledTimes(1);
213
+ const result = resultOf(onDone);
214
+ expect(result.thinkingAnswered).toBe(true);
215
+ expect(result.toolAnswered).toBe(true);
216
+ expect(result.pluginsAnswered).toBe(true);
217
+ expect(result.spinnerAnswered).toBe(true);
218
+ expect(result.thinkingValue).toBe("Full");
219
+ expect(result.toolValue).toBe("Native");
220
+ expect(result.pluginSelections).toEqual({ ui: true, diff: false });
221
+ expect(result.spinnerValue).toBe("pulse");
222
+ });
223
+
224
+ // (e2) the user can pick DIFFERENT thinking vs tool styles (Full thinking +
225
+ // Native tool) — the payload carries both independently.
226
+ it("supports different thinking vs tool styles (Full thinking + Native tool)", () => {
227
+ const { comp, onDone } = makeOverlay({
228
+ thinkingDefault: "Full",
229
+ toolDefault: "Minimal",
230
+ });
231
+ comp.handleInput(ENTER); // step 0: confirm Full thinking
232
+ comp.handleInput(UP); // step 1: move the tool cursor Minimal → Native (Native is the first option, Minimal the second)
233
+ comp.handleInput(ENTER); // step 1 → 2 (confirm Native tool)
234
+ comp.handleInput(ENTER); // step 2 → 3
235
+ comp.handleInput(ENTER); // step 3 → finalize
236
+ expect(onDone).toHaveBeenCalledTimes(1);
237
+ const result = resultOf(onDone);
238
+ expect(result.thinkingValue).toBe("Full");
239
+ expect(result.toolValue).toBe("Native");
240
+ });
241
+
242
+ // (f) onDone is called at most once — a second finalize/escape is a no-op.
243
+ it("onDone is called at most once (repeated finalize is a no-op)", () => {
244
+ const { comp, onDone } = makeOverlay();
245
+ comp.handleInput(ENTER); // step 0 → 1
246
+ comp.handleInput(ENTER); // step 1 → 2
247
+ comp.handleInput(ENTER); // step 2 → 3
248
+ comp.handleInput(ENTER); // step 3 → finalize (first onDone)
249
+ comp.handleInput(ESC); // second finalize attempt → no-op
250
+ comp.handleInput(ENTER); // enter after finalize → no-op
251
+ comp.handleInput(DOWN); // cursor moves are still safe no-ops
252
+ expect(onDone).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ // Up-arrow clamps at the top (never a negative index).
256
+ it("up-arrow clamps the cursor at index 0", () => {
257
+ const { comp, onDone } = makeOverlay({ thinkingDefault: "Full" });
258
+ comp.handleInput(UP); // already at 0 → stays 0
259
+ comp.handleInput(UP);
260
+ comp.handleInput(ENTER); // confirm
261
+ comp.handleInput(ENTER);
262
+ comp.handleInput(ENTER);
263
+ comp.handleInput(ENTER); // finalize
264
+ const result = resultOf(onDone);
265
+ expect(result.thinkingValue).toBe("Full"); // never wrapped to a negative index
266
+ });
267
+
268
+ // (g) The spinner step renders a live 4-char preview next to each name
269
+ // (tick 0 — no requestRender means no timer, so the frame is static).
270
+ it("spinner step renders a 4-char preview next to each name", () => {
271
+ const { comp } = makeOverlay({ spinners: ["typing", "pulse", "rain"] });
272
+ toStep(comp, 3); // steps 0 → 1 → 2 → 3
273
+ const lines = comp.render(80);
274
+ expect(lines.join("\n")).toContain("Which spinner for the editor border?");
275
+ for (const name of ["typing", "pulse", "rain"]) {
276
+ const line = lines.find((l) => l.includes(name));
277
+ expect(line).toBeDefined();
278
+ expect(line).toContain(previewAt(name, 0));
279
+ }
280
+ });
281
+
282
+ // (g2) The spinner options render consecutively — NO blank line between
283
+ // them: the 10-option list must fit the overlay's 80% max height on a small
284
+ // terminal, so the per-option vertical spacing was dropped. A border-only
285
+ // line renders as `││` after trimming whitespace.
286
+ it("spinner step renders options consecutively (no blank line between them)", () => {
287
+ const { comp } = makeOverlay({ spinners: ["typing", "pulse", "rain"] });
288
+ toStep(comp, 3);
289
+ const content = comp.render(80).map((l) => l.trim());
290
+ const names = ["typing", "pulse", "rain"];
291
+ for (let i = 0; i < names.length - 1; i++) {
292
+ const name = names[i];
293
+ if (name === undefined) continue;
294
+ const line = content.findIndex((l) => l.includes(name));
295
+ expect(line).toBeGreaterThanOrEqual(0);
296
+ const below = content[line + 1];
297
+ expect(below).toBeDefined();
298
+ // The line directly below an option is the NEXT option (has text), not
299
+ // a border-only spacer.
300
+ expect((below ?? "").replace(/\s/g, "")).not.toBe("││");
301
+ }
302
+ });
303
+
304
+ // (g3) The 10-option spinner step renders a specific total height (border +
305
+ // content) so a future change that adds or removes lines is caught. The
306
+ // compact layout (no per-option spacing, no blank after the header note)
307
+ // keeps the tallest step at 19 lines: 3 header lines + question + blank +
308
+ // 10 options + separator blank + footer = 17 content; the border adds top
309
+ // + bottom = 19 total — fits an 80%-max-height overlay on a 24-row terminal
310
+ // (~19.2 rows).
311
+ it("spinner step with the 10 real SPINNER_STYLES renders exactly 19 lines", () => {
312
+ const { comp } = makeOverlay({ spinners: SPINNER_STYLES });
313
+ toStep(comp, 3);
314
+ const lines = comp.render(80);
315
+ expect(SPINNER_STYLES).toHaveLength(10);
316
+ expect(lines).toHaveLength(19);
317
+ // Every one of the 10 names is rendered.
318
+ for (const name of SPINNER_STYLES) {
319
+ expect(lines.find((l) => l.includes(name))).toBeDefined();
320
+ }
321
+ });
322
+
323
+ // (h) When requestRender is provided, the 40 ms interval fires it and the
324
+ // rendered preview advances (tick increments).
325
+ it("with requestRender: the interval fires it and the preview advances", () => {
326
+ vi.useFakeTimers();
327
+ const requestRender = vi.fn();
328
+ const { comp } = makeOverlay({ requestRender });
329
+ toStep(comp, 3); // (spinner step)
330
+ const beforeLines = comp.render(80);
331
+ expect(beforeLines.join("\n")).toContain(previewAt("typing", 0));
332
+ vi.advanceTimersByTime(40);
333
+ expect(requestRender).toHaveBeenCalledTimes(1);
334
+ vi.advanceTimersByTime(40);
335
+ expect(requestRender).toHaveBeenCalledTimes(2);
336
+ const afterLines = comp.render(80);
337
+ expect(afterLines.join("\n")).toContain(previewAt("typing", 2));
338
+ // The typing line's frame advanced (the previews are live, not static).
339
+ const typingBefore = beforeLines.find((l) => l.includes("typing"));
340
+ const typingAfter = afterLines.find((l) => l.includes("typing"));
341
+ expect(typingAfter).toBeDefined();
342
+ expect(typingBefore).toBeDefined();
343
+ expect(typingAfter).not.toBe(typingBefore);
344
+ });
345
+
346
+ // (i) dispose() stops the interval — no more requestRender calls after close.
347
+ it("dispose() stops the interval (no more requestRender calls)", () => {
348
+ vi.useFakeTimers();
349
+ const requestRender = vi.fn();
350
+ const { comp } = makeOverlay({ requestRender });
351
+ toStep(comp, 3); // (the interval only ticks here)
352
+ vi.advanceTimersByTime(40);
353
+ expect(requestRender).toHaveBeenCalledTimes(1);
354
+ comp.dispose();
355
+ vi.advanceTimersByTime(400);
356
+ expect(requestRender).toHaveBeenCalledTimes(1); // no further calls
357
+ });
358
+
359
+ // (j) The interval only ticks on the spinner step (step 3) — the
360
+ // thinking/tool/plugin steps render no live frame, so no repaint is
361
+ // requested (and no tick advances) while the user is off the spinner step.
362
+ it("the interval only ticks on the spinner step (no repaint on the other steps)", () => {
363
+ vi.useFakeTimers();
364
+ const requestRender = vi.fn();
365
+ const { comp } = makeOverlay({ requestRender });
366
+ vi.advanceTimersByTime(400);
367
+ expect(requestRender).not.toHaveBeenCalled(); // step 0: no repaint
368
+ comp.handleInput(ENTER); // step 0 → 1
369
+ vi.advanceTimersByTime(400);
370
+ expect(requestRender).not.toHaveBeenCalled(); // step 1: no repaint
371
+ comp.handleInput(ENTER); // step 1 → 2
372
+ vi.advanceTimersByTime(400);
373
+ expect(requestRender).not.toHaveBeenCalled(); // step 2: no repaint
374
+ comp.handleInput(ENTER); // step 2 → 3
375
+ vi.advanceTimersByTime(40);
376
+ expect(requestRender).toHaveBeenCalledTimes(1);
377
+ vi.advanceTimersByTime(40);
378
+ expect(requestRender).toHaveBeenCalledTimes(2);
379
+ const lines = comp.render(80);
380
+ expect(lines.join("\n")).toContain(previewAt("typing", 2)); // tick advanced here
381
+ });
382
+
383
+ // (k) finalize() stops the interval (belt-and-braces — in case the TUI tears
384
+ // down without calling dispose()).
385
+ it("finalize() stops the interval (no repaint after the wizard is done)", () => {
386
+ vi.useFakeTimers();
387
+ const requestRender = vi.fn();
388
+ const { comp, onDone } = makeOverlay({ requestRender });
389
+ toStep(comp, 3);
390
+ vi.advanceTimersByTime(40);
391
+ expect(requestRender).toHaveBeenCalledTimes(1);
392
+ comp.handleInput(ENTER); // step 3 → finalize
393
+ expect(onDone).toHaveBeenCalledTimes(1);
394
+ vi.advanceTimersByTime(400);
395
+ expect(requestRender).toHaveBeenCalledTimes(1); // the interval was cleared in finalize
396
+ });
397
+
398
+ // (l) With an empty options list the cursor is clamped at 0 — the min() bound
399
+ // alone could drive it to -1 (zero options → bound -1); Math.max(0, …) keeps it
400
+ // non-negative (finalize then yields a valid empty value, not a crash).
401
+ it("down-arrow with an empty options list keeps the cursor non-negative", () => {
402
+ const { comp, onDone } = makeOverlay({ spinners: [] });
403
+ toStep(comp, 3); // (empty spinner list)
404
+ comp.handleInput(DOWN); // must stay at 0, never -1
405
+ comp.handleInput(DOWN);
406
+ comp.handleInput(UP);
407
+ comp.handleInput(ENTER); // finalize
408
+ expect(onDone).toHaveBeenCalledTimes(1);
409
+ const result = resultOf(onDone);
410
+ expect(result.spinnerAnswered).toBe(true);
411
+ expect(result.spinnerValue).toBe(""); // empty list → empty value, no crash
412
+ });
413
+
414
+ // (l2) Down-arrow clamps at the BOTTOM of a 2-option step (the Math.min(count-1, …)
415
+ // ceiling) — repeated DOWN keeps the cursor at the last option, never past it.
416
+ it("down-arrow clamps the cursor at the last option of a 2-option step", () => {
417
+ const { comp } = makeOverlay({ thinkingDefault: "Full" });
418
+ comp.handleInput(DOWN); // Full → Compact (index 1)
419
+ comp.handleInput(DOWN); // already at the last option → stays 1
420
+ const lines = comp.render(80);
421
+ expect(lines.find((l) => l.includes("Compact"))).toContain("> Compact");
422
+ expect(lines.find((l) => l.includes("Full"))).not.toContain("> Full");
423
+ });
424
+
425
+ // (p) The plugin step shows the CURSOR on the active row alongside each
426
+ // row's enabled state (✓/·) — without the cursor, the user can't tell which
427
+ // plugin Space is about to toggle.
428
+ it("plugin step shows the cursor on the active row alongside its enabled state", () => {
429
+ const { comp } = makeOverlay({
430
+ plugins: [
431
+ { id: "footer", label: "Footer", description: "Status bar", selected: true },
432
+ { id: "diff", label: "Diff", description: "Diff rendering", selected: false },
433
+ ],
434
+ });
435
+ toStep(comp, 2);
436
+ let lines = comp.render(80);
437
+ expect(lines.find((l) => l.includes("Footer"))).toContain(">"); // cursor: the active row
438
+ expect(lines.find((l) => l.includes("Footer"))).toContain("✓"); // ...and its enabled state
439
+ expect(lines.find((l) => l.includes("Diff"))).not.toContain(">");
440
+ expect(lines.find((l) => l.includes("Diff"))).toContain("·");
441
+ comp.handleInput(DOWN); // move the cursor to diff
442
+ lines = comp.render(80);
443
+ expect(lines.find((l) => l.includes("Diff"))).toContain(">");
444
+ expect(lines.find((l) => l.includes("Footer"))).not.toContain(">");
445
+ });
446
+
447
+ // (c3) space toggles the plugin at the MOVED cursor (not always index 0).
448
+ it("space on step 2 toggles the plugin at the moved cursor (index 1, not 0)", () => {
449
+ const { comp, onDone } = makeOverlay({
450
+ plugins: [
451
+ { id: "ui", label: "UI", description: "TUI", selected: true },
452
+ { id: "diff", label: "Diff", description: "Diff", selected: false },
453
+ ],
454
+ });
455
+ comp.handleInput(ENTER); // step 0 → 1
456
+ comp.handleInput(ENTER); // step 1 → 2 (plugins, cursor at index 0 = ui)
457
+ comp.handleInput(DOWN); // move to index 1 = diff
458
+ comp.handleInput(SPACE); // toggle diff: false → true (ui stays true)
459
+ comp.handleInput(ESC); // finalize
460
+ expect(onDone).toHaveBeenCalledTimes(1);
461
+ const result = resultOf(onDone);
462
+ expect(result.pluginSelections).toEqual({ ui: true, diff: true });
463
+ });
464
+
465
+ // (n) The header area carries a static next-session note on every step —
466
+ // the style choices are written to archimedes.ui but the renderer patches
467
+ // are configured at session start, so the note sets expectations up front.
468
+ it("renders the next-session note on all four steps", () => {
469
+ for (let step = 0; step < 4; step++) {
470
+ const { comp } = makeOverlay();
471
+ toStep(comp, step);
472
+ const lines = comp.render(80);
473
+ const note = lines.find((l) => l.includes("Choices apply from your next session."));
474
+ expect(note).toBeDefined();
475
+ }
476
+ });
477
+
478
+ // (n2) The step counter reads N/4 (four steps, not three).
479
+ it("the step counter reads N/4 on every step", () => {
480
+ for (let step = 0; step < 4; step++) {
481
+ const { comp } = makeOverlay();
482
+ toStep(comp, step);
483
+ const counter = comp.render(80).find((l) => l.includes(`· ${step + 1}/4`));
484
+ expect(counter).toBeDefined();
485
+ }
486
+ });
487
+
488
+ // (m) The spinner-step footer makes the esc skip/discard semantics explicit —
489
+ // esc finalizes and reports ONLY the confirmed steps (the unconfirmed ones
490
+ // are skipped, not applied), so the hint must not read like "keep my changes".
491
+ it("spinner step footer makes the esc skip/discard semantics explicit", () => {
492
+ const { comp } = makeOverlay();
493
+ toStep(comp, 3);
494
+ const footer = comp.render(80).find((l) => l.includes("esc"));
495
+ expect(footer).toBeDefined();
496
+ expect(footer).toContain("finish (skip rest)");
497
+ });
498
+ });
499
+
500
+ afterEach(() => {
501
+ vi.useRealTimers();
502
+ vi.clearAllMocks();
503
+ });