@pi-archimedes/ui 2.10.3 → 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/README.md +11 -7
- package/package.json +8 -8
- package/src/bash/index.ts +0 -2
- package/src/bash/renderer.test.ts +50 -5
- package/src/bash/renderer.ts +28 -30
- package/src/codemode/index.ts +0 -9
- package/src/codemode/renderer.test.ts +32 -31
- package/src/codemode/renderer.ts +29 -61
- package/src/config.test.ts +62 -1
- package/src/config.ts +45 -1
- package/src/editor/index.test.ts +118 -2
- package/src/editor/index.ts +34 -3
- package/src/editor/spin-quips.ts +1 -1
- package/src/index.screen-mode.test.ts +411 -0
- package/src/index.test.ts +506 -101
- package/src/index.ts +252 -104
- package/src/settings.test.ts +37 -2
- package/src/settings.ts +16 -3
- package/src/startup/index.test.ts +239 -129
- package/src/startup/index.ts +179 -272
- package/src/startup/pi-listing.test.ts +290 -0
- package/src/startup/sections.test.ts +38 -208
- package/src/startup/sections.ts +83 -236
- package/src/startup-splash-gate.test.ts +546 -0
- package/src/thinking/patch.test.ts +81 -1
- package/src/thinking/patch.ts +22 -6
- package/src/bash/tool.test.ts +0 -26
- package/src/bash/tool.ts +0 -17
- package/src/codemode/loader.test.ts +0 -139
- package/src/codemode/loader.ts +0 -115
- package/src/codemode/tool.test.ts +0 -149
- package/src/codemode/tool.ts +0 -96
- package/src/startup/capture.ts +0 -47
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
|
@@ -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
|
+
});
|