@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,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
|
-
|
|
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() {
|