@pi-archimedes/ui 2.10.4 → 2.11.1

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.
@@ -1,175 +1,285 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
2
 
3
3
  // ── Mocks ────────────────────────────────────────────────────────────────────
4
4
 
5
- // pi-tui — Text/Spacer/Container must be real classes for the instanceof checks
5
+ // The splash no longer reads pi's component tree, so the only pi-tui surface
6
+ // these tests need is the width arithmetic `sections.ts` wraps text with. The
7
+ // mock keeps the same SGR-only semantics core/text's `stripAnsi` falls back to.
6
8
  vi.mock("@earendil-works/pi-tui", () => ({
7
- Text: class Text {
8
- render(): string[] {
9
- return ["rendered"];
10
- }
11
- },
12
- Spacer: class Spacer {},
13
- Container: class Container {
14
- children: unknown[] = [];
15
- addChild(c: unknown) {
16
- this.children.push(c);
17
- return this;
18
- }
19
- clear() {
20
- this.children = [];
21
- return this;
22
- }
23
- },
24
9
  truncateToWidth: (s: string, w: number) => s.slice(0, w),
25
- visibleWidth: (s: string) => s.length,
26
- }));
27
-
28
- // version — no network in tests
29
- vi.mock("./version.js", () => ({
30
- fetchLatestVersion: vi.fn().mockResolvedValue(undefined),
31
- compareVersions: () => 0,
10
+ visibleWidth: (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "").length,
11
+ stripTerminalSequences: (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""),
32
12
  }));
33
13
 
34
14
  // ── Imports ──────────────────────────────────────────────────────────────────
35
15
 
36
- import { Text, Container } from "@earendil-works/pi-tui";
37
- import { patchStartupListing, type ListingRef } from "./index.js";
16
+ import { VERSION } from "@earendil-works/pi-coding-agent";
17
+ import { stripAnsi } from "@pi-archimedes/core/text";
18
+ import {
19
+ patchStartupListing,
20
+ revealStartupListing,
21
+ stopStartupListing,
22
+ renderHeader,
23
+ modelScopeSection,
24
+ type ListingRef,
25
+ } from "./index.js";
26
+ import { LOGO, LOGO_SETTLE_FRAME } from "./logo.js";
38
27
 
39
28
  // ── Test helpers ─────────────────────────────────────────────────────────────
40
29
 
41
- function makeTui(): { tui: any; resources: any; header: any } {
42
- // pi ≥0.84 layout: documentContainer → [headerContainer, loadedResourcesContainer, chatContainer]
43
- const header = new Container();
44
- const resources = new Container();
45
- const chat = new Container();
46
- const doc = new Container();
47
- doc.children.push(header, resources, chat);
48
- const tui = {
49
- terminal: { rows: 24 },
50
- children: [doc],
51
- requestRender: vi.fn(),
52
- };
53
- return { tui, resources, header };
30
+ function makeTui(): { tui: any; requestRender: ReturnType<typeof vi.fn> } {
31
+ const requestRender = vi.fn();
32
+ return { tui: { terminal: { rows: 24 }, requestRender }, requestRender };
54
33
  }
55
34
 
56
35
  function makeRef(): ListingRef {
57
36
  return {
58
37
  sections: [],
59
38
  frame: 0,
60
- revealed: true, // skip the debounce timer path
39
+ revealed: true, // skip the reveal debounce path
61
40
  revealedAt: 0,
62
41
  scaffoldAt: 0,
63
42
  settled: false,
64
43
  };
65
44
  }
66
45
 
67
- /** Fake ExpandableText: render() returns whatever the current state yields. */
68
- function makeSectionComponent(renderLines: string[], opts: { setExpanded?: ReturnType<typeof vi.fn>; getExpandedText?: () => string } = {}): any {
69
- const comp: any = new (Text as any)();
70
- comp.render = () => renderLines;
71
- if (opts.setExpanded) comp.setExpanded = opts.setExpanded;
72
- if (opts.getExpandedText) comp.getExpandedText = opts.getExpandedText;
73
- return comp;
74
- }
75
-
76
46
  const THEME = {
77
47
  fg: (_token: string, t: string) => t,
78
48
  getFgAnsi: (_token: string) => "",
79
49
  } as any;
80
50
 
81
- let lastResources: any;
82
- const after = () => {
83
- // Stop the animation interval + any pending debounce timer
84
- const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
85
- const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
86
- if (lastResources?.[ANIM_INTERVAL]) clearInterval(lastResources[ANIM_INTERVAL]);
87
- if (lastResources?.[DEBOUNCE_TIMER]) clearTimeout(lastResources[DEBOUNCE_TIMER]);
88
- lastResources = undefined;
89
- };
90
- afterEach(after);
91
-
92
- // ── patchStartupListing: section text extraction ─────────────────────────────
93
-
94
- describe("patchStartupListing section parsing", () => {
95
- it("forces expansion via setExpanded when the getExpandedText method is absent (pi ≥0.99)", () => {
96
- const { tui, resources } = makeTui();
97
- lastResources = resources;
98
- const ref = makeRef();
99
- patchStartupListing(tui, THEME, ref);
100
-
101
- // pi ≥0.99 ExpandableText: text builders live in the constructor,
102
- // the component starts collapsed. render() after setExpanded(true)
103
- // returns the full per-item body.
104
- const setExpanded = vi.fn();
105
- const comp = makeSectionComponent(
106
- ["[Skills]", " /home/u/skills/alpha/SKILL.md", " /home/u/skills/beta/SKILL.md"],
107
- { setExpanded },
108
- );
109
- resources.addChild(comp);
110
-
111
- expect(setExpanded).toHaveBeenCalledWith(true);
112
- expect(ref.sections).toEqual([{ name: "Skills", items: ["alpha", "beta"] }]);
113
- // Section consumed by the splash — original component not added
114
- expect((resources as any).children.length).toBe(0);
115
- });
116
-
117
- it("prefers the getExpandedText method when present (older pi)", () => {
118
- const { tui, resources } = makeTui();
119
- lastResources = resources;
120
- const ref = makeRef();
121
- patchStartupListing(tui, THEME, ref);
51
+ afterEach(() => {
52
+ // The splash owns two module-level timers; never leak one into a test.
53
+ stopStartupListing();
54
+ });
55
+
56
+ // ── renderHeader: the section set it draws ───────────────────────────────────
122
57
 
123
- const setExpanded = vi.fn();
124
- const comp = makeSectionComponent(
125
- ["[Skills]", " collapsed-should-not-be-read"],
126
- {
127
- setExpanded,
128
- getExpandedText: () => "[Skills]\n /home/u/skills/alpha/SKILL.md",
129
- },
130
- );
131
- resources.addChild(comp);
58
+ describe("renderHeader section set", () => {
59
+ // The rendered set is whatever the caller revealed into the ref — pi decides
60
+ // which of its own sections exist, so nothing here may assert a fixed list.
61
+ const linesFor = (sections: ListingRef["sections"]): string[] =>
62
+ renderHeader(THEME, { ...makeRef(), sections }, 200);
132
63
 
133
- expect(setExpanded).not.toHaveBeenCalled();
134
- expect(ref.sections).toEqual([{ name: "Skills", items: ["alpha"] }]);
64
+ it("renders revealed sections in the order they were given", () => {
65
+ const lines = linesFor([
66
+ { name: "Context", items: ["AGENTS.md"] },
67
+ { name: "Skills", items: ["tdd"] },
68
+ { name: "Prompts", items: ["/commit"] },
69
+ { name: "Extensions", items: ["archimedes"] },
70
+ ]);
71
+ const at = (name: string) => lines.findIndex((l) => l.includes(`[${name}]`));
72
+ expect(at("Context")).toBeGreaterThanOrEqual(0);
73
+ expect(at("Context")).toBeLessThan(at("Skills"));
74
+ expect(at("Skills")).toBeLessThan(at("Prompts"));
75
+ expect(at("Prompts")).toBeLessThan(at("Extensions"));
135
76
  });
136
77
 
137
- it("splits a collapsed comma-joined body when no expansion API exists (safety net)", () => {
138
- const { tui, resources } = makeTui();
139
- lastResources = resources;
140
- const ref = makeRef();
141
- patchStartupListing(tui, THEME, ref);
78
+ it("renders a section name the suite has never seen before", () => {
79
+ const lines = linesFor([{ name: "Widgets", items: ["gauge"] }]);
80
+ expect(lines.some((l) => l.includes("[Widgets]"))).toBe(true);
81
+ expect(lines.some((l) => l.includes("gauge"))).toBe(true);
82
+ });
142
83
 
143
- // No setExpanded, no getExpandedText — render() yields the collapsed body
144
- const comp = makeSectionComponent(["[Skills]", " alpha, beta, gamma"]);
145
- resources.addChild(comp);
84
+ it("renders nothing but the version block when no section was revealed", () => {
85
+ const lines = linesFor([]);
86
+ for (const phantom of ["Models", "Context", "Prompts", "Skills", "Extensions", "Themes"]) {
87
+ expect(lines.some((l) => l.includes(`[${phantom}]`))).toBe(false);
88
+ }
89
+ expect(lines.some((l) => l.includes("[Version]"))).toBe(true);
90
+ });
146
91
 
147
- expect(ref.sections).toEqual([{ name: "Skills", items: ["alpha", "beta", "gamma"] }]);
148
- expect((resources as any).children.length).toBe(0);
92
+ it("skips a revealed section whose item list is empty", () => {
93
+ const lines = linesFor([
94
+ { name: "Skills", items: [] },
95
+ { name: "Context", items: ["AGENTS.md"] },
96
+ ]);
97
+ expect(lines.some((l) => l.includes("[Skills]"))).toBe(false);
98
+ expect(lines.some((l) => l.includes("[Context]"))).toBe(true);
149
99
  });
100
+ });
150
101
 
151
- it("passes non-section text through to the original addChild", () => {
152
- const { tui, resources } = makeTui();
153
- lastResources = resources;
154
- const ref = makeRef();
155
- patchStartupListing(tui, THEME, ref);
102
+ // ── The version block: pi's own version, no update check ─────────────────────
103
+ //
104
+ // The second row used to be `Latest: v…`, filled in by a
105
+ // `registry.npmjs.org` fetch the splash kicked off on every start. pi has its
106
+ // own update check (`--update`, `/changelog`, `PI_SKIP_VERSION_CHECK`), so ours
107
+ // is gone: the block states the running version and nothing else. Nothing here
108
+ // may reach the network — a startup that blocks on a socket is a startup that
109
+ // blocks.
110
+
111
+ describe("renderHeader version block", () => {
112
+ it("states the running pi version", () => {
113
+ const lines = renderHeader(THEME, makeRef(), 200);
114
+ expect(lines.some((l) => l.includes("[Version]"))).toBe(true);
115
+ expect(lines.some((l) => l.includes(`v${VERSION}`))).toBe(true);
116
+ });
117
+
118
+ it("has no Latest row and asks no update question", () => {
119
+ const lines = renderHeader(THEME, makeRef(), 200);
120
+ expect(lines.join("\n")).not.toContain("Latest");
121
+ expect(lines.join("\n")).not.toContain("Local:");
122
+ });
123
+ });
124
+
125
+ // ── The model scope, from ctx.scopedModels ───────────────────────────────────
126
+ //
127
+ // This section used to be scraped: a `console.log` patch watched for pi's
128
+ // `Model scope:` line, and pi's own resource components were intercepted out of
129
+ // its container. Both predate `ctx.scopedModels` — the same set the
130
+ // `/scoped-models` command lists — which is handed to us on the context.
131
+
132
+ describe("modelScopeSection", () => {
133
+ it("lists the scoped models the way /scoped-models does", () => {
134
+ const section = modelScopeSection([
135
+ { model: { id: "claude-sonnet-4-5" } },
136
+ { model: { id: "gpt-5" }, thinkingLevel: "high" },
137
+ ] as never);
138
+
139
+ expect(section).toEqual({
140
+ name: "Models",
141
+ items: ["claude-sonnet-4-5", "gpt-5:high"],
142
+ });
143
+ });
144
+
145
+ it("is absent when the session has no model scope", () => {
146
+ expect(modelScopeSection([])).toBeUndefined();
147
+ // A host without the property (older pi) is the same answer as no scope.
148
+ expect(modelScopeSection(undefined)).toBeUndefined();
149
+ });
156
150
 
157
- const comp = makeSectionComponent(["some random text"]);
158
- resources.addChild(comp);
151
+ it("ignores an entry with no model behind it", () => {
152
+ expect(modelScopeSection([{ thinkingLevel: "low" }] as never)).toBeUndefined();
153
+ expect(
154
+ modelScopeSection([{ model: { id: "gpt-5" } }, { thinkingLevel: "low" }] as never),
155
+ ).toEqual({ name: "Models", items: ["gpt-5"] });
156
+ });
157
+ });
158
+
159
+ // ── The animation timer ──────────────────────────────────────────────────────
160
+
161
+ describe("patchStartupListing animation timer", () => {
162
+ afterEach(() => vi.useRealTimers());
159
163
 
160
- expect((resources as any).children).toContain(comp);
161
- expect(ref.sections).toEqual([]);
164
+ it("stops redrawing once the logo settles when no sections arrive (quietStartup)", () => {
165
+ vi.useFakeTimers();
166
+ const { tui, requestRender } = makeTui();
167
+ patchStartupListing(tui, { ...makeRef(), revealed: false });
168
+
169
+ vi.advanceTimersByTime(3_000);
170
+ const renderCountAfterSettle = requestRender.mock.calls.length;
171
+ vi.advanceTimersByTime(3_000);
172
+ expect(requestRender.mock.calls.length).toBe(renderCountAfterSettle);
162
173
  });
163
174
 
164
- it("merges items from repeated section components", () => {
165
- const { tui, resources } = makeTui();
166
- lastResources = resources;
175
+ it("restarts the timer when sections are revealed after the logo settled", () => {
176
+ vi.useFakeTimers();
177
+ const { tui, requestRender } = makeTui();
178
+ const ref: ListingRef = { ...makeRef(), revealed: false };
179
+ // Real renders settle the listing, which is what stops the restarted timer
180
+ requestRender.mockImplementation(() => renderHeader(THEME, ref, 200));
181
+ patchStartupListing(tui, ref);
182
+ vi.advanceTimersByTime(3_000);
183
+
184
+ revealStartupListing(tui, ref, [{ name: "Models", items: ["claude-sonnet-4-5"] }]);
185
+ vi.advanceTimersByTime(500);
186
+ expect(ref.revealed).toBe(true);
187
+ const renderCountAfterReveal = requestRender.mock.calls.length;
188
+ vi.advanceTimersByTime(200);
189
+ expect(requestRender.mock.calls.length).toBeGreaterThan(renderCountAfterReveal);
190
+
191
+ vi.advanceTimersByTime(2_000); // past the text fade-in
192
+ const renderCountAfterFade = requestRender.mock.calls.length;
193
+ vi.advanceTimersByTime(2_000);
194
+ expect(requestRender.mock.calls.length).toBe(renderCountAfterFade);
195
+ });
196
+
197
+ it("restarts from scratch when the header is rebuilt (new session, /reload)", () => {
198
+ vi.useFakeTimers();
199
+ const { tui } = makeTui();
200
+ const ref: ListingRef = { ...makeRef(), revealed: false };
201
+ patchStartupListing(tui, ref);
202
+ vi.advanceTimersByTime(100);
203
+ const frameMidAnimation = ref.frame;
204
+ expect(frameMidAnimation).toBeGreaterThan(0);
205
+
206
+ // The header factory is re-invoked with a fresh renderer and ref — what
207
+ // a new session, `/reload` or any other `setHeader()` replacement does.
208
+ // A `tuiMode` switch is not one of those: pi re-mounts the header it
209
+ // already has, which is why nothing here may hold on to a renderer.
210
+ const firstRenders = tui.requestRender.mock.calls.length;
211
+ expect(firstRenders).toBe(frameMidAnimation);
212
+
213
+ const second = makeTui();
214
+ const ref2: ListingRef = { ...makeRef(), revealed: false };
215
+ patchStartupListing(second.tui, ref2);
216
+ vi.advanceTimersByTime(100);
217
+
218
+ expect(ref2.frame).toBeGreaterThan(0);
219
+ // The first splash stopped with its own renderer: only the second one is
220
+ // requesting frames now.
221
+ expect(tui.requestRender.mock.calls.length).toBe(firstRenders);
222
+ });
223
+
224
+ it("stopStartupListing stops both the frame timer and a pending reveal", () => {
225
+ vi.useFakeTimers();
226
+ const { tui, requestRender } = makeTui();
227
+ const ref: ListingRef = { ...makeRef(), revealed: false };
228
+ patchStartupListing(tui, ref);
229
+ revealStartupListing(tui, ref, [{ name: "Models", items: ["m"] }]);
230
+
231
+ stopStartupListing();
232
+ const renders = requestRender.mock.calls.length;
233
+ vi.advanceTimersByTime(3_000);
234
+
235
+ expect(requestRender.mock.calls.length).toBe(renders);
236
+ expect(ref.revealed).toBe(false);
237
+ });
238
+ });
239
+
240
+ // ── Nothing else happens on the splash path ──────────────────────────────────
241
+
242
+ describe("startup splash side effects", () => {
243
+ beforeEach(() => {
244
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({}) }) as never));
245
+ // Nothing may print: on fullscreen a console write lands inside the frame,
246
+ // and the whole reason the scrape died is that writing to stdout was the
247
+ // only way to learn what pi had loaded.
248
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
249
+ });
250
+
251
+ afterEach(() => {
252
+ vi.unstubAllGlobals();
253
+ vi.restoreAllMocks();
254
+ });
255
+
256
+ it("makes no network request and leaves the console alone", async () => {
257
+ const { tui } = makeTui();
167
258
  const ref = makeRef();
168
- patchStartupListing(tui, THEME, ref);
259
+ patchStartupListing(tui, ref);
260
+ revealStartupListing(tui, ref, [{ name: "Models", items: ["m"] }]);
261
+ renderHeader(THEME, ref, 200);
262
+ await Promise.resolve();
263
+
264
+ expect(fetch).not.toHaveBeenCalled();
265
+ // Patched by anyone? `console.log.name` reads "log" only for the original.
266
+ expect(console.log.name).toBe("log");
267
+ expect(process.stdout.write).not.toHaveBeenCalled();
268
+ });
269
+ });
270
+
271
+ // ── The header renders finished content with no reveal pending ───────────────
272
+
273
+ describe("renderHeader at rest", () => {
274
+ it("renders the logo at rest with no listing and no stray blank rows", () => {
275
+ const ref: ListingRef = { ...makeRef(), frame: LOGO_SETTLE_FRAME, revealed: false };
169
276
 
170
- resources.addChild(makeSectionComponent(["[Skills]", " alpha, beta"]));
171
- resources.addChild(makeSectionComponent(["[Skills]", " beta, gamma"]));
277
+ const lines = renderHeader(THEME, ref, 200);
172
278
 
173
- expect(ref.sections).toEqual([{ name: "Skills", items: ["alpha", "beta", "gamma"] }]);
279
+ // Logo rows plus their separator — and, unlike frame 0, actually drawn.
280
+ // (Stripped: a truecolor logo colours each block character separately.)
281
+ expect(lines).toHaveLength(LOGO.length + 1);
282
+ expect(stripAnsi(lines.join("\n"))).toContain("████");
283
+ expect(lines.filter((l) => l.includes("[") && l.includes("]"))).toEqual([]);
174
284
  });
175
285
  });