@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,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
|
|
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("
|
|
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
|
-
|
|
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
|
-
//
|
|
273
|
-
|
|
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", () => {
|