@pi-archimedes/ui 2.10.4 → 2.11.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,546 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Mock } from "vitest";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ Theme,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { TUI } from "@earendil-works/pi-tui";
9
+
10
+ // ── The gate: `archimedes.ui.startupSplash` × pi's `quietStartup` ──────────
11
+ //
12
+ // The suite's splash is opt-in. The maintainer's answer to "do we own the
13
+ // startup screen?" is that pi does, unless the user asks for the splash, so the
14
+ // whole 2×3 table is pinned here rather than left to be inferred from two
15
+ // independent settings:
16
+ //
17
+ // startupSplash | quietStartup | header installed | splash timer
18
+ // ---------------|--------------|-----------------------------|-------------
19
+ // false (default)| false | pi's own (setHeader(undef)) | none armed
20
+ // false (default)| "header" | pi's own (setHeader(undef)) | none armed
21
+ // false (default)| true | pi's own (setHeader(undef)) | none armed
22
+ // true | false | ours (the splash factory) | frame + reveal
23
+ // true | "header" | ours (the splash factory) | frame + reveal
24
+ // true | true | pi's own (setHeader(undef)) | none armed
25
+ //
26
+ // `startupSplash: false` wins outright — pi's own startup, whatever
27
+ // `quietStartup` says, and pi's own setting then decides pi's own header and
28
+ // resource listing exactly as it does with no archimedes installed at all.
29
+ // `startupSplash: true` is today's behaviour, unchanged: `quietStartup` still
30
+ // decides whether pi's resource listing (and so the splash's model scope)
31
+ // shows, and `quietStartup: true` — where pi asked for no header at all — still
32
+ // gets the header handed back rather than an animation drawn over pi's silence.
33
+ //
34
+ // Nothing in `./startup/index.js` is stubbed here: "no timer is armed" is only
35
+ // evidence if the real animation module is the thing that could have armed one,
36
+ // so the frame/reveal timers are counted with fake timers rather than inferred
37
+ // from a mock call.
38
+
39
+ vi.mock("@pi-archimedes/core/settings-io", async (importOriginal) => {
40
+ const actual = await importOriginal<typeof import("@pi-archimedes/core/settings-io")>();
41
+ return {
42
+ ...actual,
43
+ loadConfig: vi.fn(),
44
+ saveConfig: vi.fn(),
45
+ removeConfig: vi.fn(),
46
+ };
47
+ });
48
+
49
+ vi.mock("./config.js", async (importOriginal) => {
50
+ const actual = await importOriginal<typeof import("./config.js")>();
51
+ return { ...actual, loadUIConfig: vi.fn() };
52
+ });
53
+
54
+ vi.mock("./thinking/patch.js", () => ({ patchThinkingRenderer: vi.fn() }));
55
+ vi.mock("./thinking/transform.js", () => ({ transformThinkingContent: vi.fn() }));
56
+ vi.mock("./migration.js", () => ({
57
+ migrateCoreToUIConfig: vi.fn(),
58
+ migrateCompactThinkingToStyle: vi.fn(),
59
+ migrateRemovedToolPatch: vi.fn(),
60
+ UI_CONFIG_KEYS: [],
61
+ }));
62
+ vi.mock("./bash/index.js", () => ({
63
+ renderBashCall: vi.fn(),
64
+ renderBashResult: vi.fn(),
65
+ }));
66
+ vi.mock("./codemode/index.js", () => ({
67
+ renderCodemodeCall: vi.fn(),
68
+ renderCodemodeResult: vi.fn(),
69
+ }));
70
+
71
+ // The one spy in this file, and it is a pass-through: it wraps the real
72
+ // `getShinedLogo` rather than replacing it, so the animation still runs and the
73
+ // timer counts above stay honest. It is here to answer "no logo work" directly —
74
+ // this is the function that builds the logo rows — with a control case in the
75
+ // `startupSplash: true` path proving the spy would have caught it.
76
+ vi.mock("./startup/logo.js", async (importOriginal) => {
77
+ const actual = await importOriginal<typeof import("./startup/logo.js")>();
78
+ return { ...actual, getShinedLogo: vi.fn(actual.getShinedLogo) };
79
+ });
80
+
81
+ import { VERSION } from "@earendil-works/pi-coding-agent";
82
+ import { stripAnsi } from "@pi-archimedes/core/text";
83
+ import { registerUI } from "./index.js";
84
+ import { loadUIConfig, DEFAULT_UI_CONFIG, type UIConfig } from "./config.js";
85
+ import { stopStartupListing } from "./startup/index.js";
86
+ import { getShinedLogo } from "./startup/logo.js";
87
+
88
+ // ── Harness ─────────────────────────────────────────────────────────────────
89
+
90
+ const MODEL_SCOPE = [{ model: { id: "claude-sonnet-4-5" } }];
91
+ const WIDTH = 80;
92
+ /** Long enough for the reveal debounce, the text ramp and the logo to finish. */
93
+ const SETTLE_MS = 5_000;
94
+
95
+ interface Header {
96
+ /** Render the header that is installed, trimmed of padding. */
97
+ lines(): string[];
98
+ dispose(): void;
99
+ }
100
+
101
+ interface Started {
102
+ /** What pi was handed for the header slot: a factory, or `undefined`. */
103
+ headerArg: unknown;
104
+ setHeader: Mock;
105
+ setEditorComponent: Mock;
106
+ setWorkingVisible: Mock;
107
+ /** Build the header pi was handed, on a renderer that counts redraws. */
108
+ installHeader(): Header;
109
+ /** How many times `pi.getSettings()` was consulted (the `quietStartup` read). */
110
+ getSettingsCalls: number;
111
+ /** How many times `ctx.scopedModels` was read. */
112
+ scopedModelsReads: number;
113
+ }
114
+
115
+ /**
116
+ * Register, start one session, and report what the header slot was given.
117
+ * `uiConfig` is what the suite's own settings answer; `piSettings` is what pi's
118
+ * effective settings answer (i.e. `quietStartup`).
119
+ */
120
+ function startSession(args: {
121
+ uiConfig?: Partial<UIConfig>;
122
+ piSettings?: Record<string, unknown>;
123
+ scopedModels?: unknown[];
124
+ } = {}): Started {
125
+ vi.mocked(loadUIConfig).mockReturnValue({
126
+ ...DEFAULT_UI_CONFIG,
127
+ ...(args.uiConfig ?? {}),
128
+ } as UIConfig);
129
+
130
+ const listeners: Record<string, ((event: any, ctx: any) => void)[]> = {};
131
+ const getSettings = vi.fn(() => args.piSettings ?? {});
132
+ const pi = {
133
+ on: vi.fn((event: string, handler: (event: any, ctx: any) => void) => {
134
+ listeners[event] = listeners[event] ?? [];
135
+ listeners[event]!.push(handler);
136
+ }),
137
+ registerTool: vi.fn(),
138
+ registerToolRenderer: vi.fn(),
139
+ getSettings,
140
+ } as unknown as ExtensionAPI;
141
+
142
+ const setHeader = vi.fn();
143
+ const setEditorComponent = vi.fn();
144
+ const setWorkingVisible = vi.fn();
145
+ const theme = { fg: (_token: string, text: string) => text } as unknown as Theme;
146
+ const ui = {
147
+ setHeader,
148
+ setEditorComponent,
149
+ setWorkingVisible,
150
+ setWorkingIndicator: vi.fn(),
151
+ setHiddenThinkingLabel: vi.fn(),
152
+ notify: vi.fn(),
153
+ theme,
154
+ };
155
+
156
+ let scopedModelsReads = 0;
157
+ const ctx: Record<string, unknown> = {
158
+ hasUI: true,
159
+ cwd: "/workspace/test-project",
160
+ ui,
161
+ isIdle: () => true,
162
+ shutdown: vi.fn(),
163
+ };
164
+ // A counting getter, so "the splash is never built" can be told apart from
165
+ // "the splash is built and then thrown away".
166
+ Object.defineProperty(ctx, "scopedModels", {
167
+ enumerable: true,
168
+ get: () => {
169
+ scopedModelsReads++;
170
+ return args.scopedModels ?? MODEL_SCOPE;
171
+ },
172
+ });
173
+
174
+ registerUI(pi);
175
+ for (const handler of listeners["session_start"] ?? []) {
176
+ handler(null, ctx as unknown as ExtensionContext);
177
+ }
178
+
179
+ const headerArg = setHeader.mock.calls[0]?.[0];
180
+
181
+ return {
182
+ headerArg,
183
+ setHeader,
184
+ setEditorComponent,
185
+ setWorkingVisible,
186
+ installHeader: () => {
187
+ const factory = headerArg as (
188
+ tui: TUI,
189
+ theme: Theme,
190
+ ) => { render(width: number): string[]; dispose(): void };
191
+ const tui = {
192
+ terminal: { rows: 24 },
193
+ requestRender: vi.fn(),
194
+ } as unknown as TUI;
195
+ const comp = factory(tui, theme);
196
+ return {
197
+ lines: () => comp.render(WIDTH).map((l) => l.trim()),
198
+ dispose: () => comp.dispose(),
199
+ };
200
+ },
201
+ getSettingsCalls: getSettings.mock.calls.length,
202
+ scopedModelsReads,
203
+ };
204
+ }
205
+
206
+ const hasVersionRow = (lines: readonly string[]): boolean =>
207
+ lines.some((l) => l.includes("[Version]")) && lines.some((l) => l.includes(`v${VERSION}`));
208
+
209
+ describe("startupSplash: the 2×3 truth table", () => {
210
+ beforeEach(() => {
211
+ vi.clearAllMocks();
212
+ vi.useFakeTimers();
213
+ });
214
+
215
+ afterEach(() => {
216
+ stopStartupListing();
217
+ vi.useRealTimers();
218
+ });
219
+
220
+ interface Cell {
221
+ label: string;
222
+ /** `undefined` = the key is absent from settings.json, i.e. the default. */
223
+ startupSplash: boolean | undefined;
224
+ quietStartup: boolean | "header" | undefined;
225
+ /** "pi" = the header slot is handed back; "ours" = our factory is installed. */
226
+ header: "pi" | "ours";
227
+ /** Whether a splash timer is armed once the header pi was handed is built. */
228
+ timer: boolean;
229
+ /** Whether the splash reveals the session's model scope. */
230
+ models: boolean;
231
+ }
232
+
233
+ const CELLS: Cell[] = [
234
+ { label: "off (default) × quietStartup absent (false)", startupSplash: undefined, quietStartup: undefined, header: "pi", timer: false, models: false },
235
+ { label: "off (default) × quietStartup:\"header\"", startupSplash: undefined, quietStartup: "header", header: "pi", timer: false, models: false },
236
+ { label: "off (default) × quietStartup:true", startupSplash: undefined, quietStartup: true, header: "pi", timer: false, models: false },
237
+ { label: "on × quietStartup absent (false)", startupSplash: true, quietStartup: undefined, header: "ours", timer: true, models: true },
238
+ { label: "on × quietStartup:\"header\"", startupSplash: true, quietStartup: "header", header: "ours", timer: true, models: false },
239
+ { label: "on × quietStartup:true", startupSplash: true, quietStartup: true, header: "pi", timer: false, models: false },
240
+ ];
241
+
242
+ for (const cell of CELLS) {
243
+ it(`${cell.label}: header = ${cell.header}, splash timer armed = ${cell.timer}`, () => {
244
+ const started = startSession({
245
+ uiConfig: cell.startupSplash === undefined ? {} : { startupSplash: cell.startupSplash },
246
+ piSettings: cell.quietStartup === undefined ? {} : { quietStartup: cell.quietStartup },
247
+ });
248
+
249
+ // Which header is installed. `undefined` puts pi's own header back in its
250
+ // slot (`setExtensionHeader(undefined)` restores `builtInHeader`,
251
+ // interactive-mode.js:2006); a function is our splash factory.
252
+ expect(started.setHeader).toHaveBeenCalledTimes(1);
253
+ if (cell.header === "pi") {
254
+ expect(started.headerArg).toBeUndefined();
255
+ } else {
256
+ expect(typeof started.headerArg).toBe("function");
257
+ }
258
+
259
+ // Whether a splash timer is armed. Building the header is what arms the
260
+ // frame timer (`patchStartupListing`) and the reveal timer
261
+ // (`revealStartupListing`) — two for a splash, zero for pi's own header,
262
+ // which is already built and has no factory for us to invoke.
263
+ const header = cell.header === "ours" ? started.installHeader() : undefined;
264
+ expect(vi.getTimerCount()).toBe(cell.timer ? 2 : 0);
265
+
266
+ // And the same after time has passed: a cell with no splash never starts
267
+ // one, and a cell with one reveals its text.
268
+ vi.advanceTimersByTime(SETTLE_MS);
269
+ if (header) {
270
+ const lines = header.lines();
271
+ expect(hasVersionRow(lines)).toBe(true);
272
+ expect(lines.some((l) => l.includes("[Models]"))).toBe(cell.models);
273
+ if (cell.models) {
274
+ expect(lines.some((l) => l.includes("claude-sonnet-4-5"))).toBe(true);
275
+ }
276
+ }
277
+ });
278
+ }
279
+
280
+ it("the splash is off for the whole table when the setting is absent", () => {
281
+ // The three off rows differ in nothing but pi's own setting, so the same
282
+ // answer is expected for each — spelled out rather than assumed.
283
+ for (const quietStartup of [undefined, false, "header", true] as const) {
284
+ const started = startSession({
285
+ piSettings: quietStartup === undefined ? {} : { quietStartup },
286
+ });
287
+ expect(started.setHeader, `quietStartup=${String(quietStartup)}`).toHaveBeenCalledWith(
288
+ undefined,
289
+ );
290
+ expect(vi.getTimerCount(), `quietStartup=${String(quietStartup)}`).toBe(0);
291
+ stopStartupListing();
292
+ }
293
+ });
294
+ });
295
+
296
+ // ── Default off costs nothing ──────────────────────────────────────────────
297
+ //
298
+ // "Default off" has to mean the splash is not merely hidden but never built:
299
+ // no header factory registered, no frame/reveal timer, no model-scope read off
300
+ // the context, and — because the gate comes first — not even a settings read to
301
+ // ask pi how quiet its startup should be.
302
+
303
+ describe("the default path costs nothing", () => {
304
+ beforeEach(() => {
305
+ vi.clearAllMocks();
306
+ vi.useFakeTimers();
307
+ });
308
+
309
+ afterEach(() => {
310
+ stopStartupListing();
311
+ vi.useRealTimers();
312
+ });
313
+
314
+ it("hands the header to pi with no factory, and arms no timer", () => {
315
+ const started = startSession({ scopedModels: MODEL_SCOPE });
316
+
317
+ // `undefined` — pi's own header, not a splash we installed and dimmed.
318
+ expect(started.setHeader).toHaveBeenCalledTimes(1);
319
+ expect(started.setHeader).toHaveBeenCalledWith(undefined);
320
+ expect(vi.getTimerCount()).toBe(0);
321
+
322
+ // Nothing was installed, so nothing can animate: time passes and still no
323
+ // timer exists.
324
+ vi.advanceTimersByTime(10_000);
325
+ expect(vi.getTimerCount()).toBe(0);
326
+
327
+ // And no logo work happened — the frame builder was never asked for a row.
328
+ expect(getShinedLogo).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it("reads neither ctx.scopedModels nor pi's quietStartup when the splash is off", () => {
332
+ const started = startSession({ piSettings: { quietStartup: false } });
333
+
334
+ expect(started.headerArg).toBeUndefined();
335
+ // The model scope exists only to fill the splash's `[Models]` row; with no
336
+ // splash there is nothing to fill, so the context is not even asked.
337
+ expect(started.scopedModelsReads).toBe(0);
338
+ // And the gate runs before `readQuietStartup`, so pi's effective settings
339
+ // are not consulted for the header at all.
340
+ expect(started.getSettingsCalls).toBe(0);
341
+ });
342
+
343
+ it("does the rest of the UI anyway — the gate is the splash, not the package", () => {
344
+ const started = startSession({});
345
+
346
+ // The border spinner still owns the editor, and pi's working row is still
347
+ // hidden for it (`editorSpinBorder` defaults to on). Turning the splash off
348
+ // must not take the rest of the package with it.
349
+ expect(started.setEditorComponent).toHaveBeenCalledTimes(1);
350
+ expect(started.setWorkingVisible).toHaveBeenCalledWith(false);
351
+ expect(started.setHeader).toHaveBeenCalledWith(undefined);
352
+ });
353
+ });
354
+
355
+ // ── The flag is read off the config, whatever its shape ────────────────────
356
+ //
357
+ // A hand-edited settings.json is the other way this arrives: the flag is read
358
+ // from the merged config every session, so `true` turns the splash on and
359
+ // anything else — including a corrupt value — leaves pi's startup alone.
360
+
361
+ describe("startupSplash values", () => {
362
+ beforeEach(() => {
363
+ vi.clearAllMocks();
364
+ vi.useFakeTimers();
365
+ });
366
+
367
+ afterEach(() => {
368
+ stopStartupListing();
369
+ vi.useRealTimers();
370
+ });
371
+
372
+ /** Start one session with a raw `startupSplash` value and report the header. */
373
+ const headerFor = (startupSplash: unknown): unknown => {
374
+ // Passed through the merge, exactly the way a hand-edited settings.json
375
+ // reaches it: `loadConfig` spreads the stored namespace over the defaults,
376
+ // so whatever the file says lands here verbatim.
377
+ const started = startSession({ uiConfig: { startupSplash } as unknown as Partial<UIConfig> });
378
+ return started.headerArg;
379
+ };
380
+
381
+ it("installs the splash for `true`", () => {
382
+ expect(typeof headerFor(true)).toBe("function");
383
+ stopStartupListing();
384
+ });
385
+
386
+ it("leaves pi's header alone for `false`, and for anything unusable", () => {
387
+ for (const value of [false, undefined, null, "true", 1, {}]) {
388
+ expect(headerFor(value), `startupSplash=${String(value)}`).toBeUndefined();
389
+ expect(vi.getTimerCount(), `startupSplash=${String(value)}`).toBe(0);
390
+ stopStartupListing();
391
+ }
392
+ });
393
+
394
+ it("re-reads the setting every session, so it can be turned off between sessions", () => {
395
+ expect(typeof headerFor(true)).toBe("function");
396
+ stopStartupListing();
397
+ expect(headerFor(false)).toBeUndefined();
398
+ expect(vi.getTimerCount()).toBe(0);
399
+ });
400
+ });
401
+
402
+ // ── Turning it off in a process that already drew a splash ─────────────────
403
+ //
404
+ // The gate's comment in `index.ts` claims more than "the next session gets no
405
+ // header": handing the slot back is also what ends a splash that an earlier
406
+ // session in THIS process left animating (`/reload` after turning the setting
407
+ // off). `startSession()` above cannot show that — it registers afresh for every
408
+ // value and never builds a header, so no animation is running when the gated
409
+ // session starts. This block registers ONCE, drives both sessions through the
410
+ // same handler, and models pi's header slot rather than a bare spy:
411
+ // `setExtensionHeader()` disposes the component it is replacing and only then
412
+ // restores `builtInHeader` (`interactive-mode.js:1979-1988` and `:1911` for the
413
+ // session restart). Nothing here calls `stopStartupListing()` by hand, so the
414
+ // handback is the only thing that could stop the timers.
415
+
416
+ describe("turning the splash off in a process that already drew one", () => {
417
+ beforeEach(() => {
418
+ vi.clearAllMocks();
419
+ vi.useFakeTimers();
420
+ });
421
+
422
+ afterEach(() => {
423
+ stopStartupListing();
424
+ vi.useRealTimers();
425
+ });
426
+
427
+ it("the handback ends the animation the previous session left running", () => {
428
+ const listeners: Record<string, ((event: any, ctx: any) => void)[]> = {};
429
+ const pi = {
430
+ on: vi.fn((event: string, handler: (event: any, ctx: any) => void) => {
431
+ listeners[event] = listeners[event] ?? [];
432
+ listeners[event]!.push(handler);
433
+ }),
434
+ registerTool: vi.fn(),
435
+ registerToolRenderer: vi.fn(),
436
+ getSettings: vi.fn(() => ({})),
437
+ } as unknown as ExtensionAPI;
438
+
439
+ // pi's header slot: a replacement disposes what it is replacing, then either
440
+ // builds the new header or puts pi's own back.
441
+ let installed: { render(width: number): string[]; dispose(): void } | undefined;
442
+ let slot: "pi" | "ours" = "pi";
443
+ const requestRender = vi.fn();
444
+ const tui = { terminal: { rows: 24 }, requestRender } as unknown as TUI;
445
+ const theme = { fg: (_token: string, text: string) => text } as unknown as Theme;
446
+ const setHeader = vi.fn((factory?: unknown) => {
447
+ installed?.dispose();
448
+ installed =
449
+ typeof factory === "function"
450
+ ? (factory as (t: TUI, th: Theme) => typeof installed)(tui, theme)
451
+ : undefined;
452
+ slot = installed ? "ours" : "pi";
453
+ });
454
+
455
+ const ctx = {
456
+ hasUI: true,
457
+ cwd: "/workspace/test-project",
458
+ isIdle: () => true,
459
+ shutdown: vi.fn(),
460
+ scopedModels: MODEL_SCOPE,
461
+ ui: {
462
+ setHeader,
463
+ setEditorComponent: vi.fn(),
464
+ setWorkingVisible: vi.fn(),
465
+ setWorkingIndicator: vi.fn(),
466
+ setHiddenThinkingLabel: vi.fn(),
467
+ notify: vi.fn(),
468
+ theme,
469
+ },
470
+ } as unknown as ExtensionContext;
471
+
472
+ const session = (uiConfig: Partial<UIConfig>): void => {
473
+ vi.mocked(loadUIConfig).mockReturnValue({
474
+ ...DEFAULT_UI_CONFIG,
475
+ ...uiConfig,
476
+ } as UIConfig);
477
+ for (const handler of listeners["session_start"] ?? []) {
478
+ handler(null, ctx);
479
+ }
480
+ };
481
+
482
+ registerUI(pi);
483
+
484
+ // Session 1: the splash is on, so pi's slot holds our animated header and
485
+ // the real animation module has its frame and reveal timers armed.
486
+ session({ startupSplash: true });
487
+ expect(slot).toBe("ours");
488
+ expect(vi.getTimerCount()).toBe(2);
489
+ vi.advanceTimersByTime(200);
490
+ const renders = requestRender.mock.calls.length;
491
+ expect(renders).toBeGreaterThan(0);
492
+
493
+ // Session 2, same process, same registration: the setting is off now. The
494
+ // slot is pi's again and the animation that came with the old header is
495
+ // gone — no timer left armed, nothing left asking the renderer for frames.
496
+ session({ startupSplash: false });
497
+ expect(slot).toBe("pi");
498
+ expect(vi.getTimerCount()).toBe(0);
499
+
500
+ vi.advanceTimersByTime(SETTLE_MS);
501
+ expect(requestRender.mock.calls.length).toBe(renders);
502
+ });
503
+ });
504
+
505
+ // ── A splash that is on is still the same splash ───────────────────────────
506
+ //
507
+ // The flag must not change what the splash is, only whether it appears: with it
508
+ // on, the header is the animated block (logo + `[Version]`), drawn as its own
509
+ // content, animating on the renderer it was handed and settling by itself.
510
+
511
+ describe("startupSplash: true installs the same splash as before", () => {
512
+ beforeEach(() => {
513
+ vi.clearAllMocks();
514
+ vi.useFakeTimers();
515
+ });
516
+
517
+ afterEach(() => {
518
+ stopStartupListing();
519
+ vi.useRealTimers();
520
+ });
521
+
522
+ it("draws the logo and the version row, and stops animating once settled", () => {
523
+ const started = startSession({ uiConfig: { startupSplash: true } });
524
+ const factory = started.headerArg as (
525
+ tui: TUI,
526
+ theme: Theme,
527
+ ) => { render(width: number): string[]; dispose(): void };
528
+ const requestRender = vi.fn();
529
+ const tui = { terminal: { rows: 24 }, requestRender } as unknown as TUI;
530
+ const comp = factory(tui, { fg: (_t: string, s: string) => s } as unknown as Theme);
531
+
532
+ vi.advanceTimersByTime(SETTLE_MS);
533
+ const lines = comp.render(WIDTH).map((l) => l.trim());
534
+ expect(hasVersionRow(lines)).toBe(true);
535
+ expect(stripAnsi(lines.join("\n"))).toContain("████");
536
+ // The control for the zero-cost test's `getShinedLogo` assertion: the spy is
537
+ // live, so its absence on the default path means something.
538
+ expect(vi.mocked(getShinedLogo)).toHaveBeenCalled();
539
+
540
+ // At rest: the frame timer stopped, so no more redraws.
541
+ const renders = requestRender.mock.calls.length;
542
+ expect(renders).toBeGreaterThan(0);
543
+ vi.advanceTimersByTime(SETTLE_MS);
544
+ expect(requestRender.mock.calls.length).toBe(renders);
545
+ });
546
+ });
@@ -18,7 +18,12 @@ describe("patchThinkingRenderer", () => {
18
18
  vi.resetModules();
19
19
  });
20
20
 
21
- it("returns early when AssistantMessageComponent is undefined", async () => {
21
+ // This is the first test in the file, so it pays the cold `vi.resetModules()` +
22
+ // `import("./patch.js")` before the mocks are in place: 419 ms idle, 3803 ms with
23
+ // 3 type-check workers on the same 8 cores, and 6105 ms — past the 5 s default —
24
+ // with 5. 10 s is the shield this failure class already uses in
25
+ // `core/src/bridge/index.test.ts`, and clears the worst episode measured here.
26
+ it("returns early when AssistantMessageComponent is undefined", { timeout: 10_000 }, async () => {
22
27
  vi.doMock("@earendil-works/pi-coding-agent", () => ({
23
28
  AssistantMessageComponent: undefined,
24
29
  VERSION: "1.0.0",
@@ -97,6 +102,81 @@ describe("patchThinkingRenderer", () => {
97
102
  expect(MockClass.prototype[PATCHED_KEY]).toBeUndefined();
98
103
  });
99
104
 
105
+ it("tells the user through the injected surface when the patch is skipped", async () => {
106
+ // A skipped patch is a silent feature loss: thinking blocks quietly keep
107
+ // pi's default styling. The console is not a surface anyone reads (and a
108
+ // write there smears a fullscreen frame), so the notice is handed to the
109
+ // caller's notification callback, and the probe detail goes to `diag`.
110
+ const MockClass = function AssistantMessageComponent() {};
111
+ MockClass.prototype.updateContent = function updateContent() {
112
+ if (this.content.type === "text") {
113
+ this.markdownTheme.codeBlockIndent = "";
114
+ }
115
+ };
116
+
117
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
118
+ AssistantMessageComponent: MockClass,
119
+ VERSION: "1.0.0",
120
+ highlightCode: vi.fn(),
121
+ }));
122
+ const diag = vi.fn();
123
+ vi.doMock("@pi-archimedes/core/log", async (importOriginal) => {
124
+ const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
125
+ return { ...real, diag };
126
+ });
127
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
128
+ const notify = vi.fn();
129
+
130
+ try {
131
+ const patch = await importPatch();
132
+ patch(() => ({} as any), undefined, notify);
133
+
134
+ expect(notify).toHaveBeenCalledTimes(1);
135
+ const message = notify.mock.calls[0]![0] as string;
136
+ // A notify line, not a paragraph: one line, names the lost behaviour.
137
+ expect(message).not.toContain("\n");
138
+ expect(message.toLowerCase()).toContain("thinking");
139
+ expect(warn).not.toHaveBeenCalled();
140
+ // The detail a developer needs lives in the log file instead.
141
+ expect(diag).toHaveBeenCalledWith(
142
+ "ui",
143
+ expect.stringContaining("hasThinkingCheck: false"),
144
+ );
145
+ } finally {
146
+ vi.doUnmock("@pi-archimedes/core/log");
147
+ warn.mockRestore();
148
+ }
149
+ });
150
+
151
+ it("says nothing to the user when no notification surface was supplied", async () => {
152
+ // The callback is optional: callers without a ctx (tests, future hosts)
153
+ // must still get the diagnostic in the log rather than a crash.
154
+ const MockClass = function AssistantMessageComponent() {};
155
+ MockClass.prototype.updateContent = function updateContent() {
156
+ if (this.content.type === "text") {
157
+ this.markdownTheme.codeBlockIndent = "";
158
+ }
159
+ };
160
+ vi.doMock("@earendil-works/pi-coding-agent", () => ({
161
+ AssistantMessageComponent: MockClass,
162
+ VERSION: "1.0.0",
163
+ highlightCode: vi.fn(),
164
+ }));
165
+ const diag = vi.fn();
166
+ vi.doMock("@pi-archimedes/core/log", async (importOriginal) => {
167
+ const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
168
+ return { ...real, diag };
169
+ });
170
+
171
+ try {
172
+ const patch = await importPatch();
173
+ expect(() => patch(() => ({} as any))).not.toThrow();
174
+ expect(diag).toHaveBeenCalled();
175
+ } finally {
176
+ vi.doUnmock("@pi-archimedes/core/log");
177
+ }
178
+ });
179
+
100
180
  it("returns early when source lacks markdownTheme reference", async () => {
101
181
  const MockClass = function AssistantMessageComponent() {};
102
182
  MockClass.prototype.updateContent = function updateContent() {