@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,290 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // The logo module decides TRUECOLOR once, at import time, from the environment.
4
+ // Pin it, so the rows below are the *finished* rows a truecolor terminal shows
5
+ // rather than whatever the machine running the suite happens to advertise.
6
+ vi.hoisted(() => {
7
+ process.env.COLORTERM = "truecolor";
8
+ });
9
+
10
+ import { Theme, VERSION } from "@earendil-works/pi-coding-agent";
11
+ import { visibleWidth } from "@earendil-works/pi-tui";
12
+ import { renderHeader, modelScopeSection, type ListingRef } from "./index.js";
13
+ import { LOGO } from "./logo.js";
14
+
15
+ // ── The intro has to read as one listing ────────────────────────────────────
16
+ //
17
+ // The splash header sits in pi's `headerContainer`, and pi's own startup listing
18
+ // (`[Context]`, `[Skills]`, `[Prompts]`, `[Extensions]`) is the container
19
+ // straight below it (`interactive-mode.js:389-390`), so the two are drawn one
20
+ // under the other. They used to look like two documents: our rows set their
21
+ // label in `dim` and their values in `muted`, indented to the centre column the
22
+ // logo is centred on, while pi writes its rows from `showLoadedResources()`:
23
+ //
24
+ // const sectionHeader = (name, color = "mdHeading") => theme.fg(color, `[${name}]`);
25
+ // ... theme.fg("dim", ` ${labels.join(", ")}`) ... // :1335-1341
26
+ //
27
+ // — the bracketed name in `mdHeading` on its own line, the values `dim` on the
28
+ // line under it at a two-space indent, both at column 1 (`ExpandableText(…, 0, 0)`
29
+ // followed by `Spacer(1)`, :1345-1347). These tests are that recipe, asserted on
30
+ // the rendered lines of a real header, so the two halves of the intro are the
31
+ // same listing rather than two listings that happen to share a screen.
32
+ //
33
+ // A real `Theme` instance from pi does the colouring here — not a stub. Two
34
+ // reasons: the claim being tested is about *which token* each row asks for, and
35
+ // `Theme` throws `Unknown theme color: <token>` for a token it does not have
36
+ // (`theme.js:212-216`), so a token that does not exist on the host cannot pass
37
+ // unnoticed. The colours below are arbitrary labels; every assertion compares
38
+ // against what the same instance renders, never against a hardcoded escape.
39
+
40
+ /** Placeholder palette: two slots that must not be confused with each other. */
41
+ const FG: Record<string, string> = {
42
+ accent: "#aa77ff",
43
+ border: "#7788ff",
44
+ borderAccent: "#ff77aa",
45
+ borderMuted: "#556677",
46
+ success: "#44cc88",
47
+ error: "#ff5555",
48
+ warning: "#ffcc44",
49
+ muted: "#999999",
50
+ dim: "#6c7086",
51
+ text: "",
52
+ thinkingText: "#888899",
53
+ searchMatchText: "#999999",
54
+ userMessageText: "#eeeeee",
55
+ customMessageText: "#999999",
56
+ customMessageLabel: "#aa77ff",
57
+ toolTitle: "#eeeeee",
58
+ toolOutput: "#999999",
59
+ mdHeading: "#ff8700",
60
+ mdLink: "#7788ff",
61
+ mdLinkUrl: "#6c7086",
62
+ mdCode: "#44cc88",
63
+ mdCodeBlock: "#cccccc",
64
+ mdCodeBlockBorder: "#556677",
65
+ mdQuote: "#999999",
66
+ mdQuoteBorder: "#556677",
67
+ mdHr: "#556677",
68
+ mdListBullet: "#ffcc44",
69
+ toolDiffAdded: "#44cc88",
70
+ toolDiffRemoved: "#ff5555",
71
+ toolDiffContext: "#999999",
72
+ syntaxComment: "#666677",
73
+ syntaxKeyword: "#aa77ff",
74
+ syntaxFunction: "#7788ff",
75
+ syntaxVariable: "#cccccc",
76
+ syntaxString: "#44cc88",
77
+ syntaxNumber: "#ffcc44",
78
+ syntaxType: "#7788ff",
79
+ syntaxOperator: "#cccccc",
80
+ syntaxPunctuation: "#999999",
81
+ thinkingOff: "#666677",
82
+ thinkingMinimal: "#666677",
83
+ thinkingLow: "#7788ff",
84
+ thinkingMedium: "#aa77ff",
85
+ thinkingHigh: "#ff8700",
86
+ thinkingXhigh: "#ff5555",
87
+ bashMode: "#44cc88",
88
+ };
89
+
90
+ const BG: Record<string, string> = {
91
+ selectedBg: "#1a2033",
92
+ searchMatchBg: "#3a3020",
93
+ userMessageBg: "#1a2033",
94
+ customMessageBg: "#201a33",
95
+ toolPendingBg: "#222233",
96
+ toolSuccessBg: "#1a3322",
97
+ toolErrorBg: "#331a1a",
98
+ };
99
+
100
+ const WIDTH = 100;
101
+
102
+ /** A real pi `Theme`, at rest: past every animation ramp, colours are the tokens'. */
103
+ const makeTheme = (): Theme => new Theme(FG as never, BG as never, "truecolor", { name: "test" });
104
+
105
+ /** The ref the splash leaves behind once the reveal and the ramps have finished. */
106
+ const atRest = (sections: ListingRef["sections"]): ListingRef => ({
107
+ sections,
108
+ frame: 400,
109
+ revealed: true,
110
+ revealedAt: 0,
111
+ scaffoldAt: 0,
112
+ settled: false,
113
+ });
114
+
115
+ /** The rows with the escape sequences taken back off. */
116
+ const plain = (row: string): string => row.replace(/\x1b\[[0-9;]*m/g, "");
117
+
118
+ const models = (): ListingRef["sections"] => [
119
+ modelScopeSection([
120
+ { model: { id: "claude-sonnet-4-5" }, thinkingLevel: "high" },
121
+ { model: { id: "gpt-5" } },
122
+ ] as never)!,
123
+ ];
124
+
125
+ describe("the intro rows follow pi's own listing recipe", () => {
126
+ const theme = makeTheme();
127
+ const lines = (sections: ListingRef["sections"]): string[] =>
128
+ renderHeader(theme, atRest(sections), WIDTH);
129
+
130
+ it("paints the bracketed name in the theme's mdHeading token, at column 1", () => {
131
+ const rows = lines(models());
132
+ // `theme.fg()` throws for a token the host does not define, so this
133
+ // assertion doubles as the claim that pi 1.2.0 has `mdHeading`.
134
+ expect(rows).toContain(theme.fg("mdHeading", "[Version]"));
135
+ expect(rows).toContain(theme.fg("mdHeading", "[Models]"));
136
+ for (const row of rows.filter((l) => plain(l).includes("["))) {
137
+ expect(row.startsWith(" ")).toBe(false);
138
+ }
139
+ });
140
+
141
+ it("takes every listing colour from the theme, not from an escape it wrote itself", () => {
142
+ // ADR 0029: the header may not paste an escape it worked out on its own,
143
+ // because that is a colour the theme cannot change. So each coloured listing
144
+ // row has to open with an escape this very Theme emits for one of its own
145
+ // foreground tokens.
146
+ const openers = new Set(Object.keys(FG).map((token) => theme.getFgAnsi(token as never)));
147
+ const listing = lines(models()).slice(LOGO.length + 1);
148
+ for (const row of listing) {
149
+ if (plain(row) === "") continue;
150
+ const open = row.match(/^\x1b\[[0-9;]*m/)?.[0];
151
+ expect(open, `row is not theme-coloured: ${JSON.stringify(row)}`).toBeDefined();
152
+ expect(openers.has(open!), `escape ${JSON.stringify(open)} is nobody's token`).toBe(true);
153
+ }
154
+ });
155
+
156
+ it("paints the values in the dim token on the row under the name, at pi's two-space indent", () => {
157
+ const rows = lines(models());
158
+ expect(rows).toContain(theme.fg("dim", ` v${VERSION}`));
159
+ expect(rows).toContain(theme.fg("dim", " claude-sonnet-4-5:high, gpt-5"));
160
+
161
+ // The name and the value are different tokens in pi's recipe; if both rows
162
+ // asked for the same one the listing would read flat.
163
+ expect(theme.fg("mdHeading", "[Version]")).not.toBe(theme.fg("dim", "[Version]"));
164
+ });
165
+
166
+ it("gives each value its own row rather than laying the listing out in columns", () => {
167
+ // pi writes `${sectionHeader(name)}\n${body}` — the body always starts on
168
+ // the next row, so a section is two rows minimum and the name never shares
169
+ // a row with a value.
170
+ const rows = lines(models());
171
+ for (const name of ["Version", "Models"]) {
172
+ const at = rows.indexOf(theme.fg("mdHeading", `[${name}]`));
173
+ expect(at, `[${name}] header missing`).toBeGreaterThanOrEqual(0);
174
+ expect(plain(rows[at + 1]!)).toMatch(/^ {2}\S/);
175
+ }
176
+ });
177
+
178
+ it("puts one blank row between our own sections, the way pi's Spacer(1) does", () => {
179
+ const rows = lines(models());
180
+ const version = rows.indexOf(theme.fg("mdHeading", "[Version]"));
181
+ const modelsAt = rows.indexOf(theme.fg("mdHeading", "[Models]"));
182
+ expect(rows.slice(version + 2, modelsAt)).toEqual([""]);
183
+ });
184
+
185
+ it("ends on its last content row, because pi's own listing supplies the gap below it", () => {
186
+ // pi brackets the header container with `Spacer(1)` on both sides
187
+ // (`interactive-mode.js:764-766`) and the resource listing opens with one
188
+ // more, so padding our block with a trailing blank would double the gap
189
+ // pi already draws — the vertical gap the intro is criticised for.
190
+ const rows = lines(models());
191
+ expect(rows.at(-1)).toBe(theme.fg("dim", " claude-sonnet-4-5:high, gpt-5"));
192
+ });
193
+
194
+ it("adds no padding rows: the block is exactly the logo, a separator and the listing", () => {
195
+ const rows = lines([]);
196
+ expect(rows).toHaveLength(LOGO.length + 1 + 2);
197
+ // The only blank is the separator under the logo — no top/bottom padding.
198
+ expect(rows.filter((l) => l.trim() === "")).toHaveLength(1);
199
+ expect(rows[0]!.trim()).not.toBe("");
200
+ expect(rows.at(-1)).toBe(theme.fg("dim", ` v${VERSION}`));
201
+ });
202
+
203
+ it("keeps the logo centred above the listing, and the listing at column 1", () => {
204
+ const rows = lines([]);
205
+ const logoRow = rows[0]!;
206
+ const indent = logoRow.length - logoRow.trimStart().length;
207
+ // Centred means half the leftover on each side of the logo's own width.
208
+ expect(indent).toBe(Math.floor((WIDTH - plain(LOGO[0]!).length) / 2));
209
+ // The listing below it is not centred with it — pi writes its rows at column 1.
210
+ expect(rows[LOGO.length + 1]).toBe(theme.fg("mdHeading", "[Version]"));
211
+ expect(plain(rows[LOGO.length + 1]!)).toBe("[Version]");
212
+ });
213
+ });
214
+
215
+ // ── The rows have to fit the terminal they were asked for ───────────────────
216
+ //
217
+ // pi writes its own body as one long row and lets `ExpandableText` (a `Text`,
218
+ // so word-wrapped at the frame width) absorb the overflow. Our header is a
219
+ // plain component: a row wider than the frame is a row the terminal wraps under
220
+ // us, mid-colour, which is the one way this block can still break. So the two
221
+ // departures the recipe documents — values broken across rows, and a value
222
+ // truncated rather than spilled — are asserted here on a narrow frame, through
223
+ // the real `visibleWidth`/`truncateToWidth` the layout calls (the unit tests in
224
+ // `sections.test.ts` stub those two, so they cannot see either rule).
225
+
226
+ describe("the intro rows fit the frame", () => {
227
+ const theme = makeTheme();
228
+
229
+ /** Every row below the logo and its separator. */
230
+ const listing = (sections: ListingRef["sections"], width: number): string[] =>
231
+ renderHeader(theme, atRest(sections), width).slice(LOGO.length + 1);
232
+
233
+ // Each of these fits 40 columns on its own; the joined list does not, so the
234
+ // layout has to break it — pi's one-row-per-section shape, but across rows.
235
+ const threeModels = (): ListingRef["sections"] => [
236
+ { name: "Models", items: ["claude-sonnet-4-5", "gpt-5", "gemini-2.5-pro"] },
237
+ ];
238
+
239
+ it("breaks a value list that will not fit the frame, keeping every value", () => {
240
+ const rows = listing(threeModels(), 40);
241
+
242
+ expect(rows).toEqual([
243
+ theme.fg("mdHeading", "[Version]"),
244
+ theme.fg("dim", ` v${VERSION}`),
245
+ "",
246
+ theme.fg("mdHeading", "[Models]"),
247
+ // ` ` + "claude-sonnet-4-5, gpt-5, gemini-2.5-pro" is 42 columns at a
248
+ // 40-column frame, so the third value starts the next row.
249
+ theme.fg("dim", " claude-sonnet-4-5, gpt-5"),
250
+ theme.fg("dim", " gemini-2.5-pro"),
251
+ ]);
252
+
253
+ // Nothing is dropped on the way: every value is still on screen.
254
+ const all = rows.map(plain).join("\n");
255
+ for (const item of threeModels()[0]!.items) {
256
+ expect(all, `${item} was lost by the break`).toContain(item);
257
+ }
258
+ });
259
+
260
+ it("keeps every row of the block inside the frame, at any width", () => {
261
+ // Provider-prefixed ids with thinking levels are what `--model` scoping
262
+ // actually produces, and they are long enough to need every rule above.
263
+ const scoped = modelScopeSection(
264
+ [
265
+ { model: { id: "anthropic/claude-sonnet-4-5-20250929" }, thinkingLevel: "high" },
266
+ { model: { id: "openai/gpt-5-codex-mini" }, thinkingLevel: "minimal" },
267
+ { model: { id: "google/gemini-2.5-pro-exp-03-25" }, thinkingLevel: "xhigh" },
268
+ { model: { id: "x-ai/grok-4-fast-reasoning" } },
269
+ { model: { id: "deepseek/deepseek-r1-zero-671b" }, thinkingLevel: "low" },
270
+ ] as never,
271
+ )!;
272
+
273
+ for (const width of [200, 100, 80, 60, 40, 30, 20]) {
274
+ for (const row of renderHeader(theme, atRest([scoped]), width)) {
275
+ expect(visibleWidth(row), `width ${width}: ${JSON.stringify(plain(row))}`).toBeLessThanOrEqual(width);
276
+ }
277
+ }
278
+ });
279
+
280
+ it("truncates a single value wider than the frame instead of spilling past it", () => {
281
+ // One value cannot be broken, so it is cut with pi-tui's ellipsis: pi lets
282
+ // its expander deal with this case and we have no expander to hand it to.
283
+ const rows = listing([{ name: "Models", items: ["qwen3-max-preview-2026-10-14-nightly-build"] }], 40);
284
+ const body = rows.at(-1)!;
285
+
286
+ expect(visibleWidth(body)).toBe(40);
287
+ expect(plain(body)).toMatch(/\.\.\.$/);
288
+ expect(plain(body).startsWith(" qwen3-max")).toBe(true);
289
+ });
290
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
 
3
3
  // ── Mocks ────────────────────────────────────────────────────────────────────
4
4
 
@@ -12,209 +12,15 @@ vi.mock("@earendil-works/pi-tui", () => ({
12
12
  }));
13
13
 
14
14
  // ── Imports ──────────────────────────────────────────────────────────────────
15
+ //
16
+ // The layout half only. The detection/parsing half of this module (`detectSection`,
17
+ // `parseSectionText`, `parseModelScope`, `extractName`) existed to read pi's
18
+ // scraped startup sections back out of component text; Task 8 deleted the
19
+ // scrape, and with it the reason for those functions — pi renders its own
20
+ // listing and `ctx.scopedModels` supplies the model scope, so nothing in the
21
+ // suite parses pi's text any more.
15
22
 
16
- import {
17
- detectSection,
18
- parseSectionText,
19
- parseModelScope,
20
- extractName,
21
- formatColumns,
22
- buildItemWrapper,
23
- SECTION_KEYS,
24
- RAMP_FRAMES,
25
- } from "./sections.js";
26
-
27
- // ── detectSection ────────────────────────────────────────────────────────────
28
-
29
- describe("detectSection", () => {
30
- it("finds Models section key", () => {
31
- expect(detectSection("[Models]\nclaude-sonnet")).toBe("Models");
32
- });
33
-
34
- it("finds Context section key", () => {
35
- expect(detectSection("some text [Context] more")).toBe("Context");
36
- });
37
-
38
- it("finds Prompts section key", () => {
39
- expect(detectSection("[Prompts]")).toBe("Prompts");
40
- });
41
-
42
- it("finds Skills section key", () => {
43
- expect(detectSection("Skills: [Skills] list")).toBe("Skills");
44
- });
45
-
46
- it("finds Extensions section key", () => {
47
- expect(detectSection("[Extensions]\nfoo")).toBe("Extensions");
48
- });
49
-
50
- it("finds Themes section key", () => {
51
- expect(detectSection("[Themes]\ndark")).toBe("Themes");
52
- });
53
-
54
- it("returns undefined for non-section text", () => {
55
- expect(detectSection("just some random text")).toBeUndefined();
56
- });
57
-
58
- it("returns undefined for empty string", () => {
59
- expect(detectSection("")).toBeUndefined();
60
- });
61
-
62
- it("returns undefined for bracketed text that is not a section key", () => {
63
- expect(detectSection("[NotASection]")).toBeUndefined();
64
- });
65
-
66
- it("returns the first matching section key", () => {
67
- const text = "[Models]\n[Context]";
68
- expect(detectSection(text)).toBe("Models");
69
- });
70
- });
71
-
72
- // ── parseSectionText ─────────────────────────────────────────────────────────
73
-
74
- describe("parseSectionText", () => {
75
- it("extracts items correctly", () => {
76
- const text = "[Models]\nclaude-sonnet\ngpt-4";
77
- const result = parseSectionText(text);
78
- expect(result).not.toBeUndefined();
79
- expect(result!.name).toBe("Models");
80
- expect(result!.items).toContain("claude-sonnet");
81
- expect(result!.items).toContain("gpt-4");
82
- });
83
-
84
- it("returns undefined for non-section text", () => {
85
- expect(parseSectionText("no section here")).toBeUndefined();
86
- });
87
-
88
- it("deduplicates items", () => {
89
- const text = "[Models]\nclaude-sonnet\nclaude-sonnet\ngpt-4";
90
- const result = parseSectionText(text);
91
- expect(result).not.toBeUndefined();
92
- const sonnetCount = result!.items.filter(i => i === "claude-sonnet").length;
93
- expect(sonnetCount).toBe(1);
94
- });
95
-
96
- it("prefers prefixed over bare names", () => {
97
- const text = "[Extensions]\nnpm:@foo/bar\n@foo/bar";
98
- const result = parseSectionText(text);
99
- expect(result).not.toBeUndefined();
100
- // Should prefer the prefixed version
101
- expect(result!.items.some(i => i.startsWith("npm:"))).toBe(true);
102
- });
103
-
104
- it("skips empty lines and bracket lines", () => {
105
- const text = "[Themes]\n\ndark\n[Other]\nlight";
106
- const result = parseSectionText(text);
107
- expect(result).not.toBeUndefined();
108
- expect(result!.items).toContain("dark");
109
- // "light" is under [Other] which isn't a recognized section,
110
- // but since we detected [Themes] first, items under [Other] should be skipped
111
- });
112
-
113
- it("handles empty section (no items)", () => {
114
- const text = "[Models]";
115
- const result = parseSectionText(text);
116
- expect(result).not.toBeUndefined();
117
- expect(result!.items.length).toBe(0);
118
- });
119
-
120
- it("splits a comma-joined collapsed body into items", () => {
121
- const text = "[Skills]\n check-pr, codebase-improvement, daily-summary";
122
- const result = parseSectionText(text);
123
- expect(result!.name).toBe("Skills");
124
- expect(result!.items).toEqual(["check-pr", "codebase-improvement", "daily-summary"]);
125
- });
126
-
127
- it("keeps per-item path lines intact (no comma split)", () => {
128
- const text = "[Skills]\n /home/u/skills/alpha/SKILL.md\n /home/u/skills/beta/SKILL.md";
129
- const result = parseSectionText(text);
130
- expect(result!.items).toEqual(["alpha", "beta"]);
131
- });
132
-
133
- it("prefixes comma-joined items under a source header", () => {
134
- const text = "[Extensions]\n npm:@foo/bar\n alpha, beta";
135
- const result = parseSectionText(text);
136
- expect(result!.items).toEqual(["npm:bar", "npm:alpha", "npm:beta"]);
137
- });
138
- });
139
-
140
- // ── parseModelScope ──────────────────────────────────────────────────────────
141
-
142
- describe("parseModelScope", () => {
143
- it("extracts model names from 'Model scope:' line", () => {
144
- const text = "Model scope: claude-sonnet, gpt-4";
145
- const result = parseModelScope(text);
146
- expect(result).not.toBeUndefined();
147
- expect(result!.name).toBe("Models");
148
- expect(result!.items).toContain("claude-sonnet");
149
- expect(result!.items).toContain("gpt-4");
150
- });
151
-
152
- it("strips keyboard shortcut hints", () => {
153
- const text = "Model scope: claude-sonnet (Ctrl+1), gpt-4 (Ctrl+2)";
154
- const result = parseModelScope(text);
155
- expect(result).not.toBeUndefined();
156
- expect(result!.items).toContain("claude-sonnet");
157
- expect(result!.items).not.toContain("(Ctrl+1)");
158
- });
159
-
160
- it("returns undefined when no Model scope line", () => {
161
- expect(parseModelScope("some random text")).toBeUndefined();
162
- });
163
-
164
- it("returns undefined for empty items", () => {
165
- expect(parseModelScope("Model scope: ")).toBeUndefined();
166
- });
167
-
168
- it("handles single model", () => {
169
- const result = parseModelScope("Model scope: only-one");
170
- expect(result).not.toBeUndefined();
171
- expect(result!.items).toEqual(["only-one"]);
172
- });
173
- });
174
-
175
- // ── extractName ──────────────────────────────────────────────────────────────
176
-
177
- describe("extractName", () => {
178
- it("extracts Models name from path", () => {
179
- expect(extractName("/path/to/claude-sonnet", "Models")).toBe("claude-sonnet");
180
- });
181
-
182
- it("extracts Themes name from path", () => {
183
- expect(extractName("/path/to/dark-theme", "Themes")).toBe("dark-theme");
184
- });
185
-
186
- it("extracts Prompts name (strips extension)", () => {
187
- expect(extractName("/path/to/my-prompt.ts", "Prompts")).toBe("my-prompt");
188
- });
189
-
190
- it("extracts Context name (basename only)", () => {
191
- expect(extractName("/path/to/context-file.md", "Context")).toBe("context-file.md");
192
- });
193
-
194
- it("extracts Skills name from SKILL.md path", () => {
195
- expect(extractName("/path/to/my-skill/SKILL.md", "Skills")).toBe("my-skill");
196
- });
197
-
198
- it("extracts Skills name from SKILL.ts path", () => {
199
- expect(extractName("/path/to/my-skill/SKILL.ts", "Skills")).toBe("my-skill");
200
- });
201
-
202
- it("handles npm: prefix for Extensions", () => {
203
- expect(extractName("npm:@foo/bar", "Extensions")).toBe("npm:bar");
204
- });
205
-
206
- it("handles git: prefix for Extensions", () => {
207
- expect(extractName("git:github.com/user/repo", "Extensions")).toBe("git:repo");
208
- });
209
-
210
- it("strips file extensions for Models", () => {
211
- expect(extractName("/path/to/model.ts", "Models")).toBe("model");
212
- });
213
-
214
- it("handles simple name without path", () => {
215
- expect(extractName("simple-name", "Models")).toBe("simple-name");
216
- });
217
- });
23
+ import { formatColumns, buildItemWrapper, RAMP_FRAMES } from "./sections.js";
218
24
 
219
25
  // ── formatColumns ────────────────────────────────────────────────────────────
220
26
 
@@ -259,18 +65,42 @@ describe("formatColumns", () => {
259
65
  expect(result.some(line => line.includes("[Themes]"))).toBe(true);
260
66
  });
261
67
 
262
- it("adds blank line after Version section", () => {
68
+ it("separates two sections with one blank row, the way pi's Spacer(1) does", () => {
69
+ const sections = [
70
+ { name: "Version" as const, items: ["1.0.0"] },
71
+ { name: "Models" as const, items: ["gpt-5"] },
72
+ ];
73
+ expect(formatColumns(sections, mockTheme, 80, mockRef)).toEqual([
74
+ "[Version]",
75
+ " 1.0.0",
76
+ "",
77
+ "[Models]",
78
+ " gpt-5",
79
+ ]);
80
+ });
81
+
82
+ it("adds no blank row after the last section: pi supplies the gap below the header", () => {
83
+ // pi brackets the header container with a Spacer and opens its resource
84
+ // listing with another, so a trailing blank here is a double gap.
263
85
  const sections = [{ name: "Version" as const, items: ["1.0.0"] }];
264
- const result = formatColumns(sections, mockTheme, 80, mockRef);
265
- expect(result).toContain("");
86
+ expect(formatColumns(sections, mockTheme, 80, mockRef)).toEqual(["[Version]", " 1.0.0"]);
266
87
  });
267
88
 
268
89
  it("wraps items to new lines when exceeding width", () => {
269
90
  const longItems = Array.from({ length: 20 }, (_, i) => `model-${i}`);
270
91
  const sections = [{ name: "Models" as const, items: longItems }];
271
92
  const result = formatColumns(sections, mockTheme, 40, mockRef);
272
- // With narrow width, items should wrap to multiple lines
273
- expect(result.length).toBeGreaterThan(1);
93
+ // Not just "more than one row" — that is true of the two-row section shape
94
+ // on its own, so it would still pass with the breaking removed. Every row
95
+ // has to fit the 40 columns it was asked for, and no value may be lost.
96
+ for (const line of result) {
97
+ expect(line.length).toBeLessThanOrEqual(40);
98
+ }
99
+ const all = result.join("\n");
100
+ for (const item of longItems) {
101
+ expect(all, `${item} was lost by the break`).toContain(item);
102
+ }
103
+ expect(result.length).toBeGreaterThan(2);
274
104
  });
275
105
 
276
106
  it("handles unrevealed state", () => {