@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,411 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Mock } from "vitest";
3
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import type { TUI } from "@earendil-works/pi-tui";
5
+
6
+ // logo.js decides TRUECOLOR once, at import time, from the environment. Pin it
7
+ // before anything imports the splash, so the assertions below are about which
8
+ // animation *frame* got rendered rather than about the machine running them.
9
+ vi.hoisted(() => {
10
+ process.env.COLORTERM = "truecolor";
11
+ });
12
+
13
+ // ── Screen mode (ADR 0027) ───────────────────────────────────────────────────
14
+ //
15
+ // Two things used to be gated on the screen: the splash animation and the
16
+ // startup resource listing. The listing was scraped out of pi's component tree
17
+ // and re-rendered into the header, appending into terminal scrollback — which
18
+ // fullscreen screen does not have. Task 8 deleted that half (pi owns its
19
+ // listing now, and `quietStartup` is the supported knob), leaving the logo
20
+ // animation, which only advances a frame counter and asks *the renderer we were
21
+ // just handed* for a frame. That is screen-safe, so the gate is gone; what
22
+ // still differs per screen is the height the header sizes itself against, read
23
+ // from the same per-call capability check.
24
+ //
25
+ // Nothing in `./startup/index.js` is stubbed here. These tests are about which
26
+ // renderer the animation drives, how tall the header comes out, and whether the
27
+ // animation *finishes* — and the finished state is the reveal's doing: the
28
+ // `[Version]` row only exists once `revealStartupListing` has fired, and
29
+ // `ref.settled` — the flag that stops the redraws — is only ever set by
30
+ // `renderHeader` itself. Stub either half and the tests measure a splash
31
+ // production never shows, so `requestRender` is wired to actually render the
32
+ // header, exactly as pi's renderer does.
33
+ //
34
+ // `isViewportTUI` is faked by marker so a single test file can present both
35
+ // screens to the same module instance: a fake renderer carrying
36
+ // `__viewport: true` is a viewport TUI, anything else is the regular one.
37
+
38
+ vi.mock("@earendil-works/pi-tui", async (importOriginal) => {
39
+ const actual = await importOriginal<typeof import("@earendil-works/pi-tui")>();
40
+ return {
41
+ ...actual,
42
+ isViewportTUI: vi.fn((tui: unknown) => (tui as { __viewport?: boolean })?.__viewport === true),
43
+ };
44
+ });
45
+
46
+ // Real `readEffectiveSettings` (the splash reads pi's `quietStartup` through
47
+ // it); the namespace helpers are stubbed because the UI config they serve is
48
+ // stubbed below.
49
+ vi.mock("@pi-archimedes/core/settings-io", async (importOriginal) => {
50
+ const actual = await importOriginal<typeof import("@pi-archimedes/core/settings-io")>();
51
+ return {
52
+ ...actual,
53
+ loadConfig: vi.fn(),
54
+ saveConfig: vi.fn(),
55
+ removeConfig: vi.fn(),
56
+ };
57
+ });
58
+
59
+ vi.mock("./config.js", async (importOriginal) => {
60
+ const actual = await importOriginal<typeof import("./config.js")>();
61
+ return { ...actual, loadUIConfig: vi.fn() };
62
+ });
63
+
64
+ vi.mock("./thinking/patch.js", () => ({ patchThinkingRenderer: vi.fn() }));
65
+ vi.mock("./thinking/transform.js", () => ({ transformThinkingContent: vi.fn() }));
66
+
67
+ vi.mock("./migration.js", () => ({
68
+ migrateCoreToUIConfig: vi.fn(),
69
+ migrateCompactThinkingToStyle: vi.fn(),
70
+ migrateRemovedToolPatch: vi.fn(),
71
+ UI_CONFIG_KEYS: [],
72
+ }));
73
+
74
+ vi.mock("./bash/index.js", () => ({
75
+ renderBashCall: vi.fn(),
76
+ renderBashResult: vi.fn(),
77
+ }));
78
+
79
+ vi.mock("./codemode/index.js", () => ({
80
+ renderCodemodeCall: vi.fn(),
81
+ renderCodemodeResult: vi.fn(),
82
+ }));
83
+
84
+ import { VERSION } from "@earendil-works/pi-coding-agent";
85
+ import { registerUI } from "./index.js";
86
+ import { stripAnsi } from "@pi-archimedes/core/text";
87
+ import { loadUIConfig, DEFAULT_UI_CONFIG } from "./config.js";
88
+ import { stopStartupListing } from "./startup/index.js";
89
+ import { getShinedLogo, LOGO, LOGO_SETTLE_FRAME } from "./startup/logo.js";
90
+
91
+ // ── Harness ─────────────────────────────────────────────────────────────────
92
+
93
+ const ROWS = 24;
94
+ const WIDTH = 80;
95
+
96
+ type HeaderFactory = (tui: TUI, theme: Theme) => {
97
+ render(width: number): string[];
98
+ invalidate(): void;
99
+ dispose(): void;
100
+ };
101
+
102
+ /** Start a session the way pi does and hand back the header factory it installed. */
103
+ function startSession(): { headerFactory: HeaderFactory } {
104
+ const listeners: Record<string, ((event: any, ctx: any) => void)[]> = {};
105
+ const pi = {
106
+ on: vi.fn((event: string, handler: (event: any, ctx: any) => void) => {
107
+ listeners[event] = listeners[event] ?? [];
108
+ listeners[event]!.push(handler);
109
+ }),
110
+ registerTool: vi.fn(),
111
+ getSettings: vi.fn(() => ({})),
112
+ } as unknown as ExtensionAPI;
113
+
114
+ const setHeader = vi.fn();
115
+ const ctx = {
116
+ hasUI: true,
117
+ cwd: "/workspace/test-project",
118
+ scopedModels: [],
119
+ ui: {
120
+ setHeader,
121
+ setEditorComponent: vi.fn(),
122
+ setWorkingVisible: vi.fn(),
123
+ setWorkingIndicator: vi.fn(),
124
+ setHiddenThinkingLabel: vi.fn(),
125
+ notify: vi.fn(),
126
+ theme: {} as Theme,
127
+ },
128
+ isIdle: vi.fn(() => true),
129
+ shutdown: vi.fn(),
130
+ } as unknown as ExtensionContext;
131
+
132
+ registerUI(pi);
133
+ for (const handler of listeners["session_start"] ?? []) handler(null, ctx);
134
+
135
+ return { headerFactory: setHeader.mock.calls[0]![0] as HeaderFactory };
136
+ }
137
+
138
+ const THEME = { fg: (_token: string, text: string) => text } as unknown as Theme;
139
+
140
+ /**
141
+ * Install the splash on a renderer for one of the two screens, with
142
+ * `requestRender` doing what pi's renderer does — draw the header — so the
143
+ * animation can reach the state that stops it.
144
+ */
145
+ function installSplash(viewport: boolean, rows = ROWS): Splash {
146
+ const { headerFactory } = startSession();
147
+ const holder: { comp?: HeaderFactory extends (t: TUI, th: Theme) => infer C ? C : never } = {};
148
+ const frames: string[][] = [];
149
+ const tui = {
150
+ terminal: { rows },
151
+ requestRender: vi.fn(() => {
152
+ if (holder.comp) frames.push(holder.comp.render(WIDTH).map((line) => line.trim()));
153
+ }),
154
+ __viewport: viewport,
155
+ } as unknown as TUI;
156
+ const comp = headerFactory(tui, THEME);
157
+ holder.comp = comp;
158
+ return {
159
+ tui,
160
+ comp,
161
+ frames,
162
+ requestRender: (tui as unknown as { requestRender: Mock }).requestRender,
163
+ };
164
+ }
165
+
166
+ interface Splash {
167
+ tui: TUI;
168
+ comp: { render(width: number): string[]; dispose(): void };
169
+ /** Every frame the renderer drew, oldest first (trimmed of padding). */
170
+ frames: string[][];
171
+ requestRender: Mock;
172
+ }
173
+
174
+ /** The settled logo rows, as the completed splash draws them. */
175
+ const settledLogoRows = (): string[] =>
176
+ getShinedLogo(LOGO_SETTLE_FRAME, DEFAULT_UI_CONFIG.animationStyle, DEFAULT_UI_CONFIG.colorfulLogo)
177
+ .map((l) => l.trim());
178
+
179
+ /**
180
+ * The settled logo, drawn contiguously somewhere in the header. Contiguously
181
+ * rather than "at line 0", because on regular screen the splash is padded to
182
+ * the terminal above and below, so the logo starts part-way down the block.
183
+ */
184
+ const drawsSettledLogo = (lines: readonly string[]): boolean => {
185
+ const haystack = lines.join("\n");
186
+ return haystack.includes(settledLogoRows().join("\n"));
187
+ };
188
+
189
+ const hasVersionRow = (lines: readonly string[]): boolean =>
190
+ lines.some((l) => l.includes("[Version]")) && lines.some((l) => l.includes(`v${VERSION}`));
191
+
192
+ describe("startup splash on either screen", () => {
193
+ let warn: ReturnType<typeof vi.spyOn>;
194
+ let error: ReturnType<typeof vi.spyOn>;
195
+
196
+ beforeEach(() => {
197
+ vi.clearAllMocks();
198
+ // These tests are the splash's own screen-mode coverage, so they ask for the
199
+ // splash: `startupSplash` is opt-in and defaults to off, which would leave
200
+ // every one of them holding `undefined` instead of a header factory.
201
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, startupSplash: true });
202
+ warn = vi.spyOn(console, "warn").mockImplementation(() => {});
203
+ error = vi.spyOn(console, "error").mockImplementation(() => {});
204
+ });
205
+
206
+ afterEach(() => {
207
+ stopStartupListing();
208
+ vi.useRealTimers();
209
+ vi.restoreAllMocks();
210
+ });
211
+
212
+ for (const [label, viewport] of [
213
+ ["regular screen", false],
214
+ ["fullscreen screen", true],
215
+ ] as const) {
216
+ it(`animates the splash on ${label}, driving the renderer it was handed`, () => {
217
+ vi.useFakeTimers();
218
+ const splash = installSplash(viewport);
219
+ vi.advanceTimersByTime(100);
220
+
221
+ // The frame timer runs, and it asks *this* renderer for its frames.
222
+ expect(splash.requestRender).toHaveBeenCalled();
223
+ expect(warn).not.toHaveBeenCalled();
224
+ expect(error).not.toHaveBeenCalled();
225
+ });
226
+
227
+ // The reveal is production's own path — `ui/src/index.ts` calls
228
+ // `revealStartupListing` on every install, even with no model scope to
229
+ // show — and it carries the header's second half: the `[Version]` row.
230
+ // A splash that stops repainting before the text arrived is the failure
231
+ // #92 was about, so "the redraws stopped" is only worth asserting *after*
232
+ // the revealed text is on screen, and on both screens: the text ramp is
233
+ // what sets `ref.settled`, the flag that stops the timer.
234
+ it(`draws the revealed version row on ${label}, then stops asking for frames`, () => {
235
+ vi.useFakeTimers();
236
+ const splash = installSplash(viewport);
237
+
238
+ vi.advanceTimersByTime(5_000);
239
+ const framesAtSettle = splash.requestRender.mock.calls.length;
240
+ expect(framesAtSettle).toBeGreaterThan(0);
241
+
242
+ // The text half arrived and finished ramping — the header is at rest.
243
+ const last = splash.frames.at(-1);
244
+ expect(last, "the renderer was asked for frames but nothing was drawn").toBeDefined();
245
+ expect(hasVersionRow(last!)).toBe(true);
246
+ expect(drawsSettledLogo(last!)).toBe(true);
247
+
248
+ vi.advanceTimersByTime(5_000);
249
+ expect(splash.requestRender.mock.calls.length).toBe(framesAtSettle);
250
+ });
251
+
252
+ // `settleHeader()` is gone (Task 8): nothing has to freeze the header at
253
+ // `LOGO_SETTLE_FRAME` any more, because the frame timer runs on both
254
+ // screens and settles by itself. This is that claim, per screen — the
255
+ // first frame the renderer is asked to draw is already mid-reveal and the
256
+ // last one is the finished logo, so a header rendered after the timer
257
+ // stopped cannot come out frozen at frame 0 (an invisible logo).
258
+ it(`finishes the logo on ${label} without anything freezing it`, () => {
259
+ vi.useFakeTimers();
260
+ const splash = installSplash(viewport);
261
+
262
+ vi.advanceTimersByTime(5_000);
263
+ const last = splash.frames.at(-1)!;
264
+ expect(drawsSettledLogo(last)).toBe(true);
265
+ expect(warn).not.toHaveBeenCalled();
266
+ });
267
+
268
+ // pi disposes the previous header before it builds the next one
269
+ // (`setExtensionHeader()`), and this splash has no stored renderer to lean
270
+ // on, so `dispose()` is the only thing that can stop the timers. If it
271
+ // missed, a replaced splash would keep asking a renderer nobody draws from.
272
+ it(`stops the old splash when the header is replaced on ${label}`, () => {
273
+ vi.useFakeTimers();
274
+ const oldSplash = installSplash(viewport);
275
+ vi.advanceTimersByTime(50);
276
+ const framesBeforeReplace = oldSplash.requestRender.mock.calls.length;
277
+ expect(framesBeforeReplace).toBeGreaterThan(0);
278
+
279
+ oldSplash.comp.dispose();
280
+ vi.advanceTimersByTime(1_000);
281
+ expect(oldSplash.requestRender.mock.calls.length).toBe(framesBeforeReplace);
282
+ });
283
+ }
284
+
285
+ it("renders the same block on either screen, whatever renderer it is handed", () => {
286
+ // One splash is on screen at a time, so the two installs run in sequence —
287
+ // the way `/reload` or a new session replaces the header, each of which
288
+ // re-invokes the factory with the renderer now in hand. What the block is
289
+ // made of no longer depends on which renderer that is: it is its own content
290
+ // on both screens, so there is no per-screen sizing left to go stale when
291
+ // the renderer is swapped (ADR 0027's promise, in its strongest form).
292
+ vi.useFakeTimers();
293
+
294
+ const regular = installSplash(false);
295
+ vi.advanceTimersByTime(5_000);
296
+ const regularLines = regular.comp.render(WIDTH);
297
+
298
+ const viewport = installSplash(true);
299
+ vi.advanceTimersByTime(5_000);
300
+ const viewportLines = viewport.comp.render(WIDTH);
301
+
302
+ expect(regularLines).toEqual(viewportLines);
303
+ expect(viewportLines.length).toBeLessThan(ROWS - 3);
304
+ });
305
+ });
306
+
307
+ // ── What the header actually draws on each screen ───────────────────────────
308
+ //
309
+ // Installing the splash on both screens is only half of it. The header is
310
+ // drawn from the animation ref, so it must arrive at the finished state
311
+ // whatever the screen, and it must not be sized to a terminal it does not
312
+ // scroll through: inside a viewport a header padded to `rows - 3` would crowd
313
+ // out everything the layout still has to fit.
314
+ describe("header output per screen mode", () => {
315
+ beforeEach(() => {
316
+ // Same reason as above: this describe renders the finished splash, which
317
+ // only exists when the opt-in flag is on.
318
+ vi.mocked(loadUIConfig).mockReturnValue({ ...DEFAULT_UI_CONFIG, startupSplash: true });
319
+ });
320
+
321
+ afterEach(() => {
322
+ stopStartupListing();
323
+ vi.useRealTimers();
324
+ });
325
+
326
+ /** Install the splash, then let the animation run to its end. */
327
+ function settled(viewport: boolean, rows = ROWS): string[] {
328
+ vi.useFakeTimers();
329
+ const splash = installSplash(viewport, rows);
330
+ vi.advanceTimersByTime(5_000);
331
+ return splash.comp.render(WIDTH).map((l) => l.trim());
332
+ }
333
+
334
+ it("draws the settled logo on fullscreen screen, not the animation's first frame", () => {
335
+ const lines = settled(true);
336
+
337
+ expect(drawsSettledLogo(lines)).toBe(true);
338
+ // Stripped: a truecolor logo colours every block character separately, so
339
+ // a run of blocks is not a literal run of block code points.
340
+ expect(stripAnsi(lines.join("\n"))).toContain("████");
341
+ });
342
+
343
+ it("draws the version row on both screens at rest", () => {
344
+ expect(hasVersionRow(settled(false))).toBe(true);
345
+ expect(hasVersionRow(settled(true))).toBe(true);
346
+ });
347
+
348
+ it("does not pad the header to terminal height on a frame-bounded screen", () => {
349
+ const lines = settled(true);
350
+
351
+ // The viewport allocates its own height, so the header is its own content:
352
+ // logo, separator, `[Version]`, version row. Padded to `rows - 3` it would
353
+ // be the whole screen and the editor would get nothing.
354
+ expect(lines.length).toBe(LOGO.length + 3);
355
+ expect(lines.length).toBeLessThan(ROWS - 3);
356
+ // Only the structural blank: the separator under the logo. No centre-the-
357
+ // splash padding rows, and no trailing blank either — pi's header container
358
+ // already puts a `Spacer(1)` below the header and its resource listing
359
+ // opens with one more, so any blank we add here doubles pi's own gap.
360
+ expect(lines.filter((l) => l === "")).toHaveLength(1);
361
+ });
362
+
363
+ it("keeps a frame-bounded header the same height whatever the terminal rows", () => {
364
+ // The viewport owns the height, so the terminal must not leak into it.
365
+ expect(settled(true, 24)).toEqual(settled(true, 60));
366
+ });
367
+
368
+ it("does not pad the header to the terminal on regular screen either", () => {
369
+ // The splash used to be padded to `rows - 3` here, with a bias, so that the
370
+ // logo sat part-way down a screen of its own and pi's startup listing
371
+ // started below the padding. The maintainer's complaint is exactly that: the
372
+ // header and pi's listing read as two documents. pi's own header is one
373
+ // component of its own height, bracketed by spacers it owns; so is ours now.
374
+ const lines = settled(false);
375
+
376
+ expect(lines).toEqual(settled(true));
377
+ expect(lines.length).toBe(LOGO.length + 3);
378
+ });
379
+
380
+ // A finished splash caches its lines to avoid re-running the text layout on
381
+ // every frame, and those lines carry the theme's colours. pi tells a
382
+ // component to drop cached rendering state by calling `invalidate()`, and it
383
+ // does that for the whole tree on every theme change (`onThemeChange()` →
384
+ // `ui.invalidate()`, interactive-mode.js:792) and from the theme file
385
+ // watcher. So the hook exists, the cache has to honour it, and the cache key
386
+ // (the width it was drawn at) cannot save it: a theme switch changes neither
387
+ // the width nor the lines' colours, so only `invalidate()` can tell them apart.
388
+ it("drops its cached lines when pi invalidates it, so a theme change shows", () => {
389
+ vi.useFakeTimers();
390
+ let colour = "38;2;100;100;100";
391
+ const theme = {
392
+ fg: (_token: string, text: string) => `\x1b[${colour}m${text}\x1b[0m`,
393
+ getFgAnsi: () => "",
394
+ } as unknown as Theme;
395
+
396
+ const { headerFactory } = startSession();
397
+ const tui = { terminal: { rows: ROWS }, requestRender: vi.fn() } as unknown as TUI;
398
+ const comp = headerFactory(tui, theme);
399
+ vi.advanceTimersByTime(5_000);
400
+
401
+ const before = comp.render(WIDTH).join("\n");
402
+ expect(before).toContain(`\x1b[${colour}m`);
403
+
404
+ colour = "38;2;0;200;0";
405
+ comp.invalidate();
406
+ const after = comp.render(WIDTH).join("\n");
407
+
408
+ expect(after).toContain(`\x1b[${colour}m`);
409
+ expect(after).not.toContain("\x1b[38;2;100;100;100m");
410
+ });
411
+ });