@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.
package/src/index.test.ts CHANGED
@@ -9,26 +9,25 @@ import type { TUI, EditorTheme, Component } from "@earendil-works/pi-tui";
9
9
 
10
10
  // ── Mock modules ─────────────────────────────────────────────────────────────
11
11
 
12
- vi.mock("@pi-archimedes/core/settings-io", () => ({
13
- loadConfig: vi.fn(),
14
- saveConfig: vi.fn(),
15
- removeConfig: vi.fn(),
16
- }));
17
-
18
- vi.mock("./config.js", async (importOriginal) => {
19
- const actual = await importOriginal<typeof import("./config.js")>();
12
+ // Real `readEffectiveSettings`, so the harness's `pi.getSettings()` is what the
13
+ // splash's `quietStartup` read actually goes through; the namespace helpers are
14
+ // stubbed because every other settings read in these tests is about `ui`'s own
15
+ // config.
16
+ vi.mock("@pi-archimedes/core/settings-io", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("@pi-archimedes/core/settings-io")>();
20
18
  return {
21
19
  ...actual,
22
- loadUIConfig: vi.fn(),
20
+ loadConfig: vi.fn(),
21
+ saveConfig: vi.fn(),
22
+ removeConfig: vi.fn(),
23
23
  };
24
24
  });
25
25
 
26
- vi.mock("./startup/capture.js", async (importOriginal) => {
27
- const actual = await importOriginal<typeof import("./startup/capture.js")>();
26
+ vi.mock("./config.js", async (importOriginal) => {
27
+ const actual = await importOriginal<typeof import("./config.js")>();
28
28
  return {
29
29
  ...actual,
30
- patchConsoleLog: vi.fn(),
31
- unpatchConsoleLog: vi.fn(),
30
+ loadUIConfig: vi.fn(),
32
31
  };
33
32
  });
34
33
 
@@ -46,6 +45,8 @@ vi.mock("./startup/index.js", async (importOriginal) => {
46
45
  ...actual,
47
46
  renderHeader: vi.fn(() => ["header line"]),
48
47
  patchStartupListing: vi.fn(),
48
+ revealStartupListing: vi.fn(),
49
+ stopStartupListing: vi.fn(),
49
50
  };
50
51
  });
51
52
 
@@ -56,32 +57,27 @@ vi.mock("./migration.js", () => ({
56
57
  UI_CONFIG_KEYS: [],
57
58
  }));
58
59
 
59
- vi.mock("./bash/index.js", () => ({
60
- registerBashToolOverride: vi.fn(),
61
- clearActiveBashIntervals: vi.fn(),
62
- }));
63
-
64
- vi.mock("./codemode/index.js", () => ({
65
- registerCodemodeToolOverride: vi.fn(async () => true),
66
- clearActiveCodemodeIntervals: vi.fn(),
67
- }));
68
-
60
+ // The renderers stay real: the resolver hands them straight to pi, so the
61
+ // tests can assert on their identity.
69
62
  import defaultExport, {
70
63
  registerUI,
71
- unpatchConsoleLog,
72
64
  getUISettingsItems,
73
65
  loadUIConfig,
74
66
  saveUIConfig,
75
67
  DEFAULT_UI_CONFIG,
76
68
  ANIMATION_STYLES,
77
69
  } from "./index.js";
78
- import { patchConsoleLog } from "./startup/capture.js";
79
70
  import { migrateCoreToUIConfig } from "./migration.js";
80
- import { registerBashToolOverride, clearActiveBashIntervals } from "./bash/index.js";
81
- import { registerCodemodeToolOverride, clearActiveCodemodeIntervals } from "./codemode/index.js";
71
+ import { renderBashCall, renderBashResult } from "./bash/index.js";
72
+ import { renderCodemodeCall, renderCodemodeResult } from "./codemode/index.js";
82
73
  import { patchThinkingRenderer } from "./thinking/patch.js";
83
74
  import { transformThinkingContent } from "./thinking/transform.js";
84
- import { renderHeader, patchStartupListing } from "./startup/index.js";
75
+ import {
76
+ renderHeader,
77
+ patchStartupListing,
78
+ revealStartupListing,
79
+ stopStartupListing,
80
+ } from "./startup/index.js";
85
81
 
86
82
  // ── Test helpers ─────────────────────────────────────────────────────────────
87
83
 
@@ -89,6 +85,9 @@ interface UiSpies {
89
85
  setHeader: ReturnType<typeof vi.fn>;
90
86
  setEditorComponent: ReturnType<typeof vi.fn>;
91
87
  setWorkingVisible: ReturnType<typeof vi.fn>;
88
+ setWorkingIndicator: ReturnType<typeof vi.fn>;
89
+ notify: ReturnType<typeof vi.fn>;
90
+ setHiddenThinkingLabel: ReturnType<typeof vi.fn>;
92
91
  }
93
92
 
94
93
  function makeCtx(hasUI = true): { ctx: ExtensionContext; ui: UiSpies } {
@@ -96,6 +95,9 @@ function makeCtx(hasUI = true): { ctx: ExtensionContext; ui: UiSpies } {
96
95
  setHeader: vi.fn(),
97
96
  setEditorComponent: vi.fn(),
98
97
  setWorkingVisible: vi.fn(),
98
+ setWorkingIndicator: vi.fn(),
99
+ notify: vi.fn(),
100
+ setHiddenThinkingLabel: vi.fn(),
99
101
  };
100
102
  const ctx = {
101
103
  hasUI,
@@ -103,29 +105,67 @@ function makeCtx(hasUI = true): { ctx: ExtensionContext; ui: UiSpies } {
103
105
  ui: { ...ui, theme: {} as Theme },
104
106
  isIdle: vi.fn(() => true),
105
107
  shutdown: vi.fn(),
108
+ scopedModels: [],
106
109
  } as unknown as ExtensionContext;
107
110
  return { ctx, ui };
108
111
  }
109
112
 
110
- function setupHarness(): {
113
+ /** Stand-in for what `next()` hands back: pi's own renderers for the tool. */
114
+ const NATIVE = Object.freeze({ native: true });
115
+
116
+ /** What a resolver resolves for a tool name, and whether it called `next()`. */
117
+ function resolve(
118
+ resolver: ((name: string, next: () => any) => any) | undefined,
119
+ toolName: string,
120
+ ): { result: unknown; nextCalls: number } {
121
+ let nextCalls = 0;
122
+ const result = resolver?.(toolName, () => {
123
+ nextCalls++;
124
+ return NATIVE;
125
+ });
126
+ return { result, nextCalls };
127
+ }
128
+
129
+ /** A `pi` handle whose `getSettings()` answers what pi would apply. */
130
+ function makePi(settings: Record<string, unknown> = {}): {
111
131
  pi: ExtensionAPI;
112
- triggerStart: (ctx: ExtensionContext) => void;
113
- triggerShutdown: (ctx: ExtensionContext) => void;
114
- triggerMessageEnd: (msg: unknown) => void;
132
+ listeners: Record<string, ((event: any, ctx: any) => void)[]>;
133
+ renderers: ((name: string, next: () => any) => any)[];
115
134
  } {
116
135
  const listeners: Record<string, ((event: any, ctx: any) => void)[]> = {};
136
+ const renderers: ((name: string, next: () => any) => any)[] = [];
117
137
  const pi = {
118
138
  on: vi.fn((event: string, handler: (event: any, ctx: any) => void) => {
119
139
  listeners[event] = listeners[event] ?? [];
120
140
  listeners[event]!.push(handler);
121
141
  }),
122
142
  registerTool: vi.fn(),
143
+ registerToolRenderer: vi.fn(
144
+ (resolver: (name: string, next: () => any) => any) => {
145
+ renderers.push(resolver);
146
+ },
147
+ ),
148
+ getSettings: vi.fn(() => settings),
123
149
  } as unknown as ExtensionAPI;
150
+ return { pi, listeners, renderers };
151
+ }
152
+
153
+ function setupHarness(
154
+ settings: Record<string, unknown> = {},
155
+ ): {
156
+ pi: ExtensionAPI;
157
+ renderers: ((name: string, next: () => any) => any)[];
158
+ triggerStart: (ctx: ExtensionContext) => void;
159
+ triggerShutdown: (ctx: ExtensionContext) => void;
160
+ triggerMessageEnd: (msg: unknown) => void;
161
+ } {
162
+ const { pi, listeners, renderers } = makePi(settings);
124
163
 
125
164
  registerUI(pi);
126
165
 
127
166
  return {
128
167
  pi,
168
+ renderers,
129
169
  triggerStart: (ctx: ExtensionContext) => {
130
170
  for (const h of listeners["session_start"] ?? []) {
131
171
  h(null, ctx);
@@ -147,7 +187,11 @@ function setupHarness(): {
147
187
  describe("packages/ui lifecycle and registration", () => {
148
188
  beforeEach(() => {
149
189
  vi.clearAllMocks();
150
- vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG });
190
+ // The splash is opt-in (`startupSplash` defaults to off), and this file is
191
+ // where the splash's installation and teardown are asserted — so the
192
+ // harness asks for it. The default-off path, and the whole
193
+ // `startupSplash` × `quietStartup` table, is `startup-splash-gate.test.ts`.
194
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, startupSplash: true });
151
195
  });
152
196
 
153
197
  afterEach(() => {
@@ -156,7 +200,6 @@ describe("packages/ui lifecycle and registration", () => {
156
200
 
157
201
  describe("exports", () => {
158
202
  it("re-exports expected functions and constants", () => {
159
- expect(typeof unpatchConsoleLog).toBe("function");
160
203
  expect(typeof getUISettingsItems).toBe("function");
161
204
  expect(typeof loadUIConfig).toBe("function");
162
205
  expect(typeof saveUIConfig).toBe("function");
@@ -166,35 +209,146 @@ describe("packages/ui lifecycle and registration", () => {
166
209
  });
167
210
 
168
211
  it("default export delegates to registerUI", () => {
169
- const pi = { on: vi.fn() } as unknown as ExtensionAPI;
212
+ const pi = { on: vi.fn(), getSettings: vi.fn(() => ({})) } as unknown as ExtensionAPI;
170
213
  defaultExport(pi);
171
214
  expect(migrateCoreToUIConfig).toHaveBeenCalled();
172
- expect(patchConsoleLog).toHaveBeenCalled();
173
215
  });
174
216
  });
175
217
 
176
218
  describe("registerUI initialization", () => {
177
- it("calls migrateCoreToUIConfig and patchConsoleLog", () => {
219
+ it("calls migrateCoreToUIConfig and leaves the console alone", () => {
178
220
  const { pi } = setupHarness();
179
221
  expect(migrateCoreToUIConfig).toHaveBeenCalledTimes(1);
180
- expect(patchConsoleLog).toHaveBeenCalledTimes(1);
222
+ // Nobody may wrap console.log to watch pi's startup output: the scrape is
223
+ // gone, and a wrapped log both smears the frame and hides pi's own line.
224
+ expect(console.log.name).toBe("log");
181
225
  expect(pi.on).toHaveBeenCalledWith("session_shutdown", expect.any(Function));
182
226
  expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function));
183
227
  });
184
228
  });
185
229
 
186
- describe("session_start with interactive UI (hasUI: true)", () => {
187
- it("configures bash tool override, codemode tool override, header, editor, and thinking renderer", () => {
188
- const { triggerStart } = setupHarness();
189
- const { ctx, ui } = makeCtx(true);
230
+ // ADR 0029: presentation is resolved through `pi.registerToolRenderer()`,
231
+ // so the styling settings choose between our row and pi's native row inside
232
+ // one resolver, instead of re-registering the tool.
233
+ describe("tool renderer resolution", () => {
234
+ it("resolves bash to the Archimedes renderers when styling is on", () => {
235
+ const { renderers, triggerStart } = setupHarness();
236
+ const { ctx } = makeCtx(true);
190
237
 
191
238
  triggerStart(ctx);
192
239
 
193
- // Bash tool override
194
- expect(registerBashToolOverride).toHaveBeenCalledWith(expect.anything(), "/workspace/test-project");
240
+ expect(renderers.length).toBe(1);
241
+ const { result, nextCalls } = resolve(renderers[0], "bash");
242
+ expect(result).toMatchObject({
243
+ renderCall: renderBashCall,
244
+ renderResult: renderBashResult,
245
+ });
246
+ // `next()` is consulted and spread first, so a key the built-in
247
+ // definition declares besides our two — `renderShell` is the one that
248
+ // matters today — survives resolution instead of being dropped.
249
+ expect(nextCalls).toBe(1);
250
+ expect(result).toHaveProperty("native", true);
251
+ });
195
252
 
196
- // Codemode tool override (fire-and-forget)
197
- expect(registerCodemodeToolOverride).toHaveBeenCalledWith(expect.anything());
253
+ it("gives back pi's native bash row when bashToolStyling is off", () => {
254
+ vi.mocked(loadUIConfig).mockReturnValue({
255
+ ...DEFAULT_UI_CONFIG,
256
+ bashToolStyling: false,
257
+ });
258
+ const { renderers, triggerStart } = setupHarness();
259
+ const { ctx } = makeCtx(true);
260
+
261
+ triggerStart(ctx);
262
+
263
+ const { result, nextCalls } = resolve(renderers[0], "bash");
264
+ expect(result).toBe(NATIVE);
265
+ expect(nextCalls).toBe(1);
266
+ });
267
+
268
+ it("gives back pi's native bash row when toolStyle is Native", () => {
269
+ vi.mocked(loadUIConfig).mockReturnValue({
270
+ ...DEFAULT_UI_CONFIG,
271
+ toolStyle: "Native",
272
+ });
273
+ const { renderers, triggerStart } = setupHarness();
274
+ const { ctx } = makeCtx(true);
275
+
276
+ triggerStart(ctx);
277
+
278
+ expect(resolve(renderers[0], "bash")).toEqual({ result: NATIVE, nextCalls: 1 });
279
+ });
280
+
281
+ it("follows a settings change made between sessions", () => {
282
+ const { renderers, triggerStart } = setupHarness();
283
+ const { ctx } = makeCtx(true);
284
+
285
+ triggerStart(ctx);
286
+ expect(resolve(renderers[0], "bash").result).toMatchObject({ renderCall: renderBashCall });
287
+
288
+ // The user turns bash styling off in `/archimedes` and starts a session.
289
+ vi.mocked(loadUIConfig).mockReturnValue({
290
+ ...DEFAULT_UI_CONFIG,
291
+ bashToolStyling: false,
292
+ });
293
+ triggerStart(ctx);
294
+
295
+ expect(resolve(renderers[0], "bash")).toEqual({ result: NATIVE, nextCalls: 1 });
296
+ });
297
+
298
+ it("leaves every other tool to the remaining resolvers", () => {
299
+ const { renderers, triggerStart } = setupHarness();
300
+ const { ctx } = makeCtx(true);
301
+
302
+ triggerStart(ctx);
303
+
304
+ for (const name of ["read", "edit", "write", "grep", "todo"]) {
305
+ expect(resolve(renderers[0], name)).toEqual({ result: NATIVE, nextCalls: 1 });
306
+ }
307
+ });
308
+
309
+ it("resolves codemode to the Archimedes renderers when styling is on", () => {
310
+ const { renderers, triggerStart } = setupHarness();
311
+ const { ctx } = makeCtx(true);
312
+
313
+ triggerStart(ctx);
314
+
315
+ expect(resolve(renderers[0], "codemode").result).toMatchObject({
316
+ renderCall: renderCodemodeCall,
317
+ renderResult: renderCodemodeResult,
318
+ native: true,
319
+ });
320
+ });
321
+
322
+ it("gives back pi's own codemode row when codemodeToolStyling is off", () => {
323
+ vi.mocked(loadUIConfig).mockReturnValue({
324
+ ...DEFAULT_UI_CONFIG,
325
+ codemodeToolStyling: false,
326
+ });
327
+ const { renderers, triggerStart } = setupHarness();
328
+ const { ctx } = makeCtx(true);
329
+
330
+ triggerStart(ctx);
331
+
332
+ expect(resolve(renderers[0], "codemode")).toEqual({ result: NATIVE, nextCalls: 1 });
333
+ });
334
+
335
+ it("registers the resolver once even across repeated session_start (/reload)", () => {
336
+ const { renderers, triggerStart } = setupHarness();
337
+ const { ctx } = makeCtx(true);
338
+
339
+ triggerStart(ctx);
340
+ triggerStart(ctx);
341
+
342
+ expect(renderers.length).toBe(1);
343
+ });
344
+ });
345
+
346
+ describe("session_start with interactive UI (hasUI: true)", () => {
347
+ it("configures the header, editor and thinking renderer", () => {
348
+ const { renderers, triggerStart } = setupHarness();
349
+ const { ctx, ui } = makeCtx(true);
350
+
351
+ triggerStart(ctx);
198
352
 
199
353
  // Header installation
200
354
  expect(ui.setHeader).toHaveBeenCalledTimes(1);
@@ -204,11 +358,16 @@ describe("packages/ui lifecycle and registration", () => {
204
358
  const comp = headerFactory(fakeTui, fakeTheme);
205
359
  expect(typeof comp.render).toBe("function");
206
360
  comp.render(80);
207
- expect(renderHeader).toHaveBeenCalledWith(fakeTheme, expect.any(Object), 80, 21);
208
- expect(patchStartupListing).toHaveBeenCalledWith(fakeTui, fakeTheme, expect.any(Object));
209
-
210
- // Working visibility and Editor component
211
- expect(ui.setWorkingVisible).toHaveBeenCalledWith(false); // spinFlag is true by default
361
+ expect(renderHeader).toHaveBeenCalledWith(fakeTheme, expect.any(Object), 80);
362
+ expect(patchStartupListing).toHaveBeenCalledWith(fakeTui, expect.any(Object));
363
+
364
+ // Working indicator and Editor component. pi's working row ("Working",
365
+ // plus its own animated indicator) and our border spinner say the same
366
+ // thing — `editorSpinLabel` defaults to "Working" too — so with the spinner
367
+ // on the row is hidden through pi's own API rather than left showing an
368
+ // indicator-less label.
369
+ expect(ui.setWorkingVisible).toHaveBeenCalledWith(false);
370
+ expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
212
371
  expect(ui.setEditorComponent).toHaveBeenCalledTimes(1);
213
372
  const editorFactory = ui.setEditorComponent.mock.calls[0]![0];
214
373
  const editorInstance = editorFactory(fakeTui, { borderColor: (s: string) => s } as EditorTheme, {} as KeybindingsManager);
@@ -224,33 +383,41 @@ describe("packages/ui lifecycle and registration", () => {
224
383
  autoCollapseThinking: false,
225
384
  thinkingStyle: "Full",
226
385
  }),
386
+ // The user-facing surface is injected: `patch.ts` has no ctx of its
387
+ // own, and a skipped patch must not be a silent feature loss.
388
+ expect.any(Function),
227
389
  );
228
-
229
- // Master gate (default toolStyle is Minimal): both overrides registered
230
- expect(registerBashToolOverride).toHaveBeenCalled();
231
- expect(registerCodemodeToolOverride).toHaveBeenCalled();
390
+ const patchNotify = vi.mocked(patchThinkingRenderer).mock.calls[0]![2] as (m: string) => void;
391
+ patchNotify("patch skipped");
392
+ expect(ui.notify).toHaveBeenCalledWith("patch skipped", "warning");
393
+
394
+ // Master gate (default toolStyle is Minimal): both rows are ours.
395
+ expect(resolve(renderers[0], "bash").result).toMatchObject({ renderCall: renderBashCall });
396
+ expect(resolve(renderers[0], "codemode").result).toMatchObject({
397
+ renderCall: renderCodemodeCall,
398
+ });
232
399
  });
233
400
 
234
- it("skips the tool overrides entirely when toolStyle is Native", () => {
401
+ it("yields pi's native rows for every tool when toolStyle is Native", () => {
235
402
  vi.mocked(loadUIConfig).mockReturnValue({
236
403
  ...DEFAULT_UI_CONFIG,
237
404
  toolStyle: "Native",
238
405
  });
239
406
 
240
- const { triggerStart } = setupHarness();
407
+ const { renderers, triggerStart } = setupHarness();
241
408
  const { ctx, ui } = makeCtx(true);
242
409
 
243
410
  triggerStart(ctx);
244
411
 
245
- // Native = no archimedes tool styling: neither override is registered
246
- // (pi's native rendering stands, no built-in-takeover notice).
247
- expect(registerBashToolOverride).not.toHaveBeenCalled();
248
- expect(registerCodemodeToolOverride).not.toHaveBeenCalled();
412
+ // Native = no archimedes tool styling: both rows go back through
413
+ // `next()`, so pi's own rendering stands and nothing is taken over.
414
+ expect(resolve(renderers[0], "bash")).toEqual({ result: NATIVE, nextCalls: 1 });
415
+ expect(resolve(renderers[0], "codemode")).toEqual({ result: NATIVE, nextCalls: 1 });
249
416
  // The rest of the UI still comes up.
250
417
  expect(ui.setHeader).toHaveBeenCalledTimes(1);
251
418
  });
252
419
 
253
- it("respects editorSpinBorder: false by leaving workingVisible=true and not installing editor component", () => {
420
+ it("respects editorSpinBorder: false by leaving pi's indicator running and installing no editor component", () => {
254
421
  vi.mocked(loadUIConfig).mockReturnValue({
255
422
  ...DEFAULT_UI_CONFIG,
256
423
  editorSpinBorder: false,
@@ -261,22 +428,144 @@ describe("packages/ui lifecycle and registration", () => {
261
428
 
262
429
  triggerStart(ctx);
263
430
 
431
+ // No border spinner, so pi's own working row comes back, looking exactly
432
+ // like pi's default: visible, with its own animated indicator (omitting the
433
+ // argument is how pi restores the default frames).
264
434
  expect(ui.setWorkingVisible).toHaveBeenCalledWith(true);
435
+ expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
265
436
  expect(ui.setEditorComponent).not.toHaveBeenCalled();
266
437
  });
267
438
 
268
- it("skips bashToolOverride when bashToolStyling is false", () => {
439
+ it("puts the configured thinking label on hidden blocks through pi", () => {
269
440
  vi.mocked(loadUIConfig).mockReturnValue({
270
441
  ...DEFAULT_UI_CONFIG,
271
- bashToolStyling: false,
442
+ labelText: "Pondering...",
272
443
  });
273
444
 
274
445
  const { triggerStart } = setupHarness();
446
+ const { ctx, ui } = makeCtx(true);
447
+
448
+ triggerStart(ctx);
449
+
450
+ // The hidden row is pi's own (`AssistantMessageComponent` reads the label
451
+ // pi was given), so the setting is handed to pi rather than rebuilt here.
452
+ expect(ui.setHiddenThinkingLabel).toHaveBeenCalledWith("Pondering...");
453
+ });
454
+
455
+ it("hands the hidden-thinking label back to pi when the setting is empty", () => {
456
+ vi.mocked(loadUIConfig).mockReturnValue({
457
+ ...DEFAULT_UI_CONFIG,
458
+ labelText: " ",
459
+ });
460
+
461
+ const { triggerStart } = setupHarness();
462
+ const { ctx, ui } = makeCtx(true);
463
+
464
+ triggerStart(ctx);
465
+
466
+ expect(ui.setHiddenThinkingLabel).toHaveBeenCalledWith(undefined);
467
+ });
468
+
469
+ it("gives the working row and the thinking label back to pi on shutdown", () => {
470
+ const { triggerStart, triggerShutdown } = setupHarness();
471
+ const { ctx, ui } = makeCtx(true);
472
+
473
+ triggerStart(ctx);
474
+ triggerShutdown(ctx);
475
+
476
+ expect(ui.setWorkingIndicator).toHaveBeenLastCalledWith(undefined);
477
+ expect(ui.setHiddenThinkingLabel).toHaveBeenLastCalledWith(undefined);
478
+ expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(true);
479
+ });
480
+
481
+ // The working row is pi's "Working" line above the editor; our border spinner
482
+ // lives in the editor's own top border and its default label is "Working" as
483
+ // well. With both on screen the word appeared twice, which is what the
484
+ // maintainer's screenshot shows. The setting that decides is
485
+ // `editorSpinBorder`, and the row is handed back and taken through pi's own
486
+ // `setWorkingVisible()` — `types.d.ts:88`.
487
+ describe("pi's working row and the border spinner", () => {
488
+ it("hides pi's working row while the border spinner carries the announcement", () => {
489
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: true });
490
+ const { triggerStart } = setupHarness();
491
+ const { ctx, ui } = makeCtx(true);
492
+
493
+ triggerStart(ctx);
494
+
495
+ expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
496
+ // No indicator configuration is needed once the row is hidden — and
497
+ // nothing here asks pi for `frames: []` any more, so the row that *is*
498
+ // shown (spinner off) is pi's default down to its spinner.
499
+ expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
500
+ expect(ui.setWorkingIndicator).not.toHaveBeenCalledWith({ frames: [] });
501
+ });
502
+
503
+ it("shows pi's working row, with pi's own indicator, when the spinner is off", () => {
504
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: false });
505
+ const { triggerStart } = setupHarness();
506
+ const { ctx, ui } = makeCtx(true);
507
+
508
+ triggerStart(ctx);
509
+
510
+ expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(true);
511
+ expect(ui.setWorkingIndicator).toHaveBeenCalledWith(undefined);
512
+ });
513
+
514
+ it("re-reads the setting for every session, so a replaced session gets the row back", () => {
515
+ // pi resets the extension UI on session replacement (`resetExtensionUI()`,
516
+ // interactive-mode.js:1921 sets `workingVisible = true` again), and the
517
+ // setting can change between sessions, so the answer has to be read from
518
+ // the config each time rather than remembered from the first install.
519
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: true });
520
+ const { triggerStart } = setupHarness();
521
+ const spinnerOn = makeCtx(true);
522
+ triggerStart(spinnerOn.ctx);
523
+
524
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: false });
525
+ const spinnerOff = makeCtx(true);
526
+ triggerStart(spinnerOff.ctx);
527
+
528
+ // Two sessions, two answers: the row went away and came back.
529
+ const calls = spinnerOn.ui.setWorkingVisible.mock.calls.map((c) => c[0]);
530
+ expect(calls).toEqual([false]);
531
+ expect(spinnerOff.ui.setWorkingVisible.mock.calls.map((c) => c[0])).toEqual([true]);
532
+ });
533
+
534
+ it("hides the row for a session installed on a second pi handle", () => {
535
+ // pi caches an extension's factory and only clears that cache on some
536
+ // reloads: `/new`, `/fork` and `/resume` build a fresh loader and run the
537
+ // same module instance against a NEW handle. Nothing the first handle did
538
+ // may suppress the second one's answer (#106 learned this the hard way).
539
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, editorSpinBorder: true });
540
+ const first = setupHarness();
541
+ const firstCtx = makeCtx(true);
542
+ first.triggerStart(firstCtx.ctx);
543
+
544
+ const second = setupHarness();
545
+ const secondCtx = makeCtx(true);
546
+ second.triggerStart(secondCtx.ctx);
547
+
548
+ expect(firstCtx.ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
549
+ expect(secondCtx.ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
550
+ });
551
+ });
552
+
553
+ it("registers no bash tool and yields pi's native row when bashToolStyling is false", () => {
554
+ vi.mocked(loadUIConfig).mockReturnValue({
555
+ ...DEFAULT_UI_CONFIG,
556
+ bashToolStyling: false,
557
+ });
558
+
559
+ const { pi, renderers, triggerStart } = setupHarness();
275
560
  const { ctx } = makeCtx(true);
276
561
 
277
562
  triggerStart(ctx);
278
563
 
279
- expect(registerBashToolOverride).not.toHaveBeenCalled();
564
+ // The take-over is gone: nothing re-registers `bash`, and the resolver
565
+ // hands the row back through `next()` — the first time this setting can
566
+ // genuinely give back pi's own rendering.
567
+ expect(pi.registerTool).not.toHaveBeenCalled();
568
+ expect(resolve(renderers[0], "bash")).toEqual({ result: NATIVE, nextCalls: 1 });
280
569
  });
281
570
 
282
571
  it("skips the codemode tool override when codemodeToolStyling is false", () => {
@@ -285,12 +574,15 @@ describe("packages/ui lifecycle and registration", () => {
285
574
  codemodeToolStyling: false,
286
575
  });
287
576
 
288
- const { triggerStart } = setupHarness();
577
+ const { pi, renderers, triggerStart } = setupHarness();
289
578
  const { ctx } = makeCtx(true);
290
579
 
291
580
  triggerStart(ctx);
292
581
 
293
- expect(registerCodemodeToolOverride).not.toHaveBeenCalled();
582
+ // Nothing re-registers `codemode`, so pi's built-in extension stays
583
+ // loaded; the resolver hands the row to pi's own renderer.
584
+ expect(pi.registerTool).not.toHaveBeenCalled();
585
+ expect(resolve(renderers[0], "codemode")).toEqual({ result: NATIVE, nextCalls: 1 });
294
586
  });
295
587
 
296
588
  it("does not accumulate message_end listeners on repeated session_start (simulating /reload)", () => {
@@ -330,73 +622,186 @@ describe("packages/ui lifecycle and registration", () => {
330
622
  });
331
623
 
332
624
  describe("session_start in non-interactive mode (hasUI: false)", () => {
333
- it("still registers bashToolOverride but skips UI setup", () => {
334
- const { triggerStart } = setupHarness();
625
+ it("registers no tool and skips UI setup", () => {
626
+ const { pi, triggerStart } = setupHarness();
335
627
  const { ctx, ui } = makeCtx(false);
336
628
 
337
629
  triggerStart(ctx);
338
630
 
339
- expect(registerBashToolOverride).toHaveBeenCalledWith(expect.anything(), "/workspace/test-project");
631
+ expect(pi.registerTool).not.toHaveBeenCalled();
340
632
  expect(ui.setHeader).not.toHaveBeenCalled();
341
633
  expect(ui.setEditorComponent).not.toHaveBeenCalled();
342
634
  expect(patchThinkingRenderer).not.toHaveBeenCalled();
343
635
  });
344
636
  });
345
637
 
638
+ describe("startup header: the splash is opt-in (default off)", () => {
639
+ // `startup-splash-gate.test.ts` drives the real animation module; this uses
640
+ // the stubbed one so the claim "nothing of the splash runs" is the absence
641
+ // of calls into it, which is the direct form of "no logo work":
642
+ // `renderHeader` is what asks `logo.ts` for a frame, and the two listing
643
+ // calls are what arm the animation.
644
+ it("hands the header to pi and touches no part of the splash module", () => {
645
+ // The default config, unmodified: this is what a settings file with no
646
+ // `startupSplash` key resolves to.
647
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG });
648
+ const { triggerStart, triggerShutdown } = setupHarness({});
649
+ const { ctx, ui } = makeCtx(true);
650
+
651
+ triggerStart(ctx);
652
+
653
+ expect(ui.setHeader).toHaveBeenCalledTimes(1);
654
+ expect(ui.setHeader).toHaveBeenCalledWith(undefined);
655
+ expect(renderHeader).not.toHaveBeenCalled();
656
+ expect(patchStartupListing).not.toHaveBeenCalled();
657
+ expect(revealStartupListing).not.toHaveBeenCalled();
658
+
659
+ // Teardown still runs its one unconditional stop — cheap and idempotent —
660
+ // while the ref freeze is guarded on there being a ref, so an off session
661
+ // cannot resurrect an animation that was never built.
662
+ triggerShutdown(ctx);
663
+ expect(stopStartupListing).toHaveBeenCalledTimes(1);
664
+ expect(renderHeader).not.toHaveBeenCalled();
665
+ });
666
+ });
667
+
668
+ describe("startup header: quietStartup and ctx.scopedModels", () => {
669
+ // pi 1.x owns the startup screen: `quietStartup` (`false` / `"header"` /
670
+ // `true`) decides what is shown, and `ctx.scopedModels` is the session's
671
+ // model scope — the same set `/scoped-models` lists. Both are read, neither
672
+ // is scraped any more.
673
+ const scopedModels = [
674
+ { model: { id: "claude-sonnet-4-5" } },
675
+ { model: { id: "gpt-5" }, thinkingLevel: "high" },
676
+ ];
677
+ let fetchSpy: ReturnType<typeof vi.fn>;
678
+
679
+ beforeEach(() => {
680
+ fetchSpy = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as never);
681
+ vi.stubGlobal("fetch", fetchSpy);
682
+ });
683
+
684
+ afterEach(() => {
685
+ vi.unstubAllGlobals();
686
+ });
687
+
688
+ /** Register, start a session and invoke the header factory pi was given. */
689
+ function startHeader(settings: Record<string, unknown>, models: unknown[] = scopedModels) {
690
+ const { triggerStart } = setupHarness(settings);
691
+ const { ctx, ui } = makeCtx(true);
692
+ (ctx as unknown as { scopedModels: unknown[] }).scopedModels = models;
693
+
694
+ triggerStart(ctx);
695
+ const headerFactory = ui.setHeader.mock.calls[0]![0];
696
+ const tui = { terminal: { rows: 24 }, requestRender: vi.fn() } as unknown as TUI;
697
+ headerFactory(tui, {} as Theme);
698
+ return { ui, tui };
699
+ }
700
+
701
+ it("reveals the model scope from ctx.scopedModels with console.log untouched", () => {
702
+ const { tui } = startHeader({});
703
+
704
+ expect(revealStartupListing).toHaveBeenCalledTimes(1);
705
+ expect(revealStartupListing).toHaveBeenCalledWith(tui, expect.any(Object), [
706
+ { name: "Models", items: ["claude-sonnet-4-5", "gpt-5:high"] },
707
+ ]);
708
+ expect(console.log.name).toBe("log");
709
+ });
710
+
711
+ it('quietStartup: "header" keeps the splash and drops the model scope', () => {
712
+ startHeader({ quietStartup: "header" });
713
+
714
+ // The header is ours (the splash stays), but the details half is not.
715
+ expect(revealStartupListing).toHaveBeenCalledTimes(1);
716
+ expect(revealStartupListing).toHaveBeenCalledWith(expect.anything(), expect.any(Object), []);
717
+ });
718
+
719
+ it("quietStartup: true hands the header back to pi and starts no splash", () => {
720
+ const { triggerStart } = setupHarness({ quietStartup: true });
721
+ const { ctx, ui } = makeCtx(true);
722
+
723
+ triggerStart(ctx);
724
+
725
+ // pi replaces its own header with an empty component for a quiet start;
726
+ // restoring ours is what leaves that silence in place.
727
+ expect(ui.setHeader).toHaveBeenCalledWith(undefined);
728
+ expect(patchStartupListing).not.toHaveBeenCalled();
729
+ expect(revealStartupListing).not.toHaveBeenCalled();
730
+ });
731
+
732
+ it("reveals nothing but the version block when the session has no model scope", () => {
733
+ startHeader({}, []);
734
+
735
+ expect(revealStartupListing).toHaveBeenCalledWith(expect.anything(), expect.any(Object), []);
736
+ });
737
+
738
+ it("asks the network nothing at startup", () => {
739
+ startHeader({});
740
+ expect(fetchSpy).not.toHaveBeenCalled();
741
+ });
742
+ });
743
+
346
744
  describe("session_shutdown teardown", () => {
347
- it("restores unpatchConsoleLog, workingVisible, and clears editor component", () => {
745
+ it("stops the splash, restores workingVisible, and clears editor component", () => {
348
746
  const { triggerStart, triggerShutdown } = setupHarness();
349
747
  const { ctx, ui } = makeCtx(true);
350
748
 
351
749
  triggerStart(ctx);
352
750
 
353
- // Verify startup
354
- expect(ui.setWorkingVisible).toHaveBeenCalledWith(false);
751
+ // Verify startup: with the border spinner on, pi's working row was hidden.
752
+ expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(false);
355
753
 
356
754
  // Now trigger shutdown
357
755
  triggerShutdown(ctx);
358
756
 
359
- expect(unpatchConsoleLog).toHaveBeenCalledTimes(1);
360
- expect(clearActiveBashIntervals).toHaveBeenCalledTimes(1);
361
- expect(clearActiveCodemodeIntervals).toHaveBeenCalledTimes(1);
757
+ expect(stopStartupListing).toHaveBeenCalledTimes(1);
362
758
  expect(ui.setWorkingVisible).toHaveBeenLastCalledWith(true);
363
759
  expect(ui.setEditorComponent).toHaveBeenLastCalledWith(undefined);
364
760
  });
365
761
 
366
- it("cleans up patched TUI container children on shutdown", () => {
762
+ it("stops the splash when pi disposes the header (header replaced, session restart)", () => {
367
763
  const { triggerStart, triggerShutdown } = setupHarness();
368
764
  const { ctx, ui } = makeCtx(true);
369
765
 
370
766
  triggerStart(ctx);
371
-
372
767
  const headerFactory = ui.setHeader.mock.calls[0]![0];
373
- const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
374
- const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
375
- const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
376
- const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
377
-
378
- const origAddChildFn = vi.fn();
379
- const patchedChild = {
380
- [PATCHED_LISTING]: true,
381
- [ORIG_ADD_CHILD]: origAddChildFn,
382
- [ANIM_INTERVAL]: setInterval(() => {}, 10000),
383
- [DEBOUNCE_TIMER]: setTimeout(() => {}, 10000),
384
- addChild: vi.fn(),
385
- };
386
-
387
- const fakeTui = {
388
- terminal: { rows: 24 },
389
- children: [patchedChild],
390
- } as unknown as TUI;
768
+ const comp = headerFactory({ terminal: { rows: 24 } } as TUI, {} as Theme);
391
769
 
392
- headerFactory(fakeTui, {} as Theme);
770
+ expect(typeof comp.dispose).toBe("function");
771
+ comp.dispose!();
772
+ expect(stopStartupListing).toHaveBeenCalledTimes(1);
393
773
 
394
- // Now trigger shutdown
395
774
  triggerShutdown(ctx);
775
+ });
776
+ });
777
+
778
+ // The header component's own contract with pi, independent of lifecycle: pi
779
+ // tells a component to drop cached rendering state by calling
780
+ // `invalidate()` — `onThemeChange()` does it for the whole tree
781
+ // (`interactive-mode.js:792`), as does the theme file watcher — and the
782
+ // splash caches its finished lines, which carry the theme's colours.
783
+ describe("header component contract", () => {
784
+ it("drops the cached header lines when pi invalidates it (theme change)", () => {
785
+ const { triggerStart } = setupHarness();
786
+ const { ctx, ui } = makeCtx(true);
787
+
788
+ triggerStart(ctx);
789
+ const headerFactory = ui.setHeader.mock.calls[0]![0];
790
+ const comp = headerFactory({ terminal: { rows: 24 } } as TUI, {} as Theme);
791
+
792
+ comp.render(80);
793
+ const ref = vi.mocked(renderHeader).mock.calls.at(-1)![1] as {
794
+ settled: boolean;
795
+ cachedLines?: string[];
796
+ };
797
+ // The state a finished splash leaves behind (the mocked `renderHeader`
798
+ // does not fill it in, so the test does what the real one would).
799
+ ref.settled = true;
800
+ ref.cachedLines = ["\x1b[90mv1.2.0\x1b[0m"];
801
+
802
+ comp.invalidate();
396
803
 
397
- expect(patchedChild.addChild).toBe(origAddChildFn);
398
- expect(patchedChild[PATCHED_LISTING]).toBe(false);
399
- expect(patchedChild[ORIG_ADD_CHILD]).toBeUndefined();
804
+ expect(ref.cachedLines).toBeUndefined();
400
805
  });
401
806
  });
402
807
  });