@pi-archimedes/core 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.
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { resolvePalette } from "./chrome.js";
4
+
5
+ // ── An old host: pi-tui without a colour emitter ────────────────────────────
6
+ //
7
+ // `chrome.ts` reaches the host's foreground emitter through a *namespace*
8
+ // import, and that choice is the whole subject of this file, so it gets its own
9
+ // module graph (vitest isolates modules per file) with the emitter taken away.
10
+ //
11
+ // pi-tui 0.87.0 — inside the range this suite supports, because `pi install`
12
+ // installs extensions with peerDependencies omitted, so the host's own copy is
13
+ // what runs — exported no `foregroundAnsi` and no `backgroundAnsi`. Measured
14
+ // against the real 0.87.0 dist: 72 exports, neither name among them, and a
15
+ // namespace import of it yields `undefined` for both. A *named* import of a name
16
+ // the module never exported fails when the module **links**, before one line of
17
+ // this package runs (measured on that same dist: `SyntaxError: The requested
18
+ // module … does not provide an export named 'foregroundAnsi'`), which would take
19
+ // the palette and every importer down instead of degrading. ADR 0026's rule is
20
+ // to warn about an old host and keep working, so the emitter is looked up at
21
+ // runtime and its absence means *no edge colour*.
22
+ //
23
+ // The mock reproduces exactly that one fact — reading the emitter answers
24
+ // `undefined` — and the assertions are the degradation contract: no throw, no
25
+ // colour, and nothing else about the palette changes.
26
+ //
27
+ // The keys are set to `undefined` rather than deleted because vitest's mocked
28
+ // namespace keeps the original module's export list and raises its own
29
+ // "No \"foregroundAnsi\" export is defined on the mock" error when a listed key
30
+ // is missing — an artefact of the mock machinery, not of ESM. What the code
31
+ // under test reads is `undefined` either way.
32
+ vi.mock("@earendil-works/pi-tui", async (importOriginal) => ({
33
+ ...(await importOriginal<typeof import("@earendil-works/pi-tui")>()),
34
+ foregroundAnsi: undefined,
35
+ backgroundAnsi: undefined,
36
+ }));
37
+
38
+ /**
39
+ * A theme of the modern shape — it has a `colors` map and a colour mode, so the
40
+ * only thing missing is the emitter the host never had. The tint is deliberately
41
+ * far from the border colour, so a substitute would show up.
42
+ */
43
+ const tintedTheme = {
44
+ appearance: "dark",
45
+ getBgAnsi: (key: string) =>
46
+ key === "userMessageBg" ? "\x1b[48;2;33;59;73m" : "\x1b[49m",
47
+ getFgAnsi: (key: string) =>
48
+ key === "borderMuted" ? "\x1b[38;2;118;129;134m" : "\x1b[39m",
49
+ // `frame:` / `prefix:` go through `theme.fg()`, a different surface from the
50
+ // one the edge uses — which is the point of the second test below.
51
+ fg: (key: string, text: string) =>
52
+ key === "borderMuted"
53
+ ? `\x1b[38;2;118;129;134m${text}\x1b[0m`
54
+ : (undefined as unknown as string),
55
+ colors: { userMessageBg: { kind: "rgb", r: 33, g: 59, b: 73 } },
56
+ getColorMode: () => "truecolor",
57
+ } as unknown as Theme;
58
+
59
+ describe("resolvePalette on a host whose pi-tui has no colour emitter", () => {
60
+ it("degrades to no edge colour without throwing", () => {
61
+ // The theme can name its own panel colour; nobody can render it as a
62
+ // foreground. That is *no colour*, not an error and not a guess.
63
+ expect(() => resolvePalette(tintedTheme)).not.toThrow();
64
+ expect(resolvePalette(tintedTheme).panelEdge).toBe("");
65
+ });
66
+
67
+ it("keeps the panel tint and every other palette entry answering", () => {
68
+ // Losing the slab colour must not cost the user the rest of the palette: a
69
+ // package that half-loads on an old host is the failure ADR 0026 forbids.
70
+ const p = resolvePalette(tintedTheme);
71
+ expect(p.panelBg).toBe("\x1b[48;2;33;59;73m");
72
+ expect(p.frame("┌")).toBe("\x1b[38;2;118;129;134m┌\x1b[0m");
73
+ expect(p.prefix("> ")).toBe("\x1b[38;2;118;129;134m> \x1b[0m");
74
+ });
75
+
76
+ it("does not reach for a border token as the edge on an old host", () => {
77
+ // The failure this guards is quiet: "no emitter" turning into "use the
78
+ // border colour instead" would repaint the cushion as a grey rule, which is
79
+ // the exact regression the edge rows were fixed for.
80
+ expect(resolvePalette(tintedTheme).panelEdge).not.toBe(
81
+ "\x1b[38;2;118;129;134m",
82
+ );
83
+ });
84
+ });
@@ -1,6 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import type { Theme } from "@earendil-works/pi-coding-agent";
3
- import { resolvePalette } from "./chrome.js";
2
+ import { Theme as PiTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import { foregroundAnsi, indexedColor, rgbColor, type Color } from "@earendil-works/pi-tui";
4
+ import { readFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { isTerminalDefault, resolvePalette } from "./chrome.js";
4
7
 
5
8
  // ── Mock theme (mirrors the stubTheme / safeThemeColor assertion style) ──
6
9
 
@@ -51,3 +54,433 @@ describe("resolvePalette spin", () => {
51
54
  expect(p.spin("t")).toBe("\x1b[38;2;74;74;74mt\x1b[0m");
52
55
  });
53
56
  });
57
+
58
+ // ── Panel tint and edge ─────────────────────────────────────────────────────
59
+ //
60
+ // pi 0.99.0 made a theme token allowed to be `""` — *terminal default* — and
61
+ // `Theme.getBgAnsi()` renders that as `\x1b[49m`, a truthy string (measured
62
+ // against pi 1.2.0 below). A `getBgAnsi(...) ?? FALLBACK` chain therefore never
63
+ // reaches the fallback, and a `bgToFgAnsi()` that only understood `48;2;` and
64
+ // `48;5;` answered a terminal-default background with the near-black slab, so a
65
+ // panel got black borders on a light terminal. The rule these tests pin: the
66
+ // theme token plus `theme.appearance` decide colour; a terminal-default token
67
+ // means *no colour*; and nothing is inferred by parsing an escape sequence.
68
+ //
69
+ // The edge slab's colour comes from the SAME panel token, asked for as a
70
+ // foreground — `theme.colors[token]`, because pi takes a token only in its own
71
+ // slot. `borderMuted` colours the frame and never the cushion: a border token on
72
+ // the slab is what made a tinted panel look framed by a grey rule.
73
+
74
+ interface PanelTokens {
75
+ /** Answers for `getBgAnsi`; a missing key makes the accessor throw. */
76
+ bg?: Record<string, string>;
77
+ /** Answers for `getFgAnsi`; a missing key makes the accessor throw. */
78
+ fg?: Record<string, string>;
79
+ /**
80
+ * Answers for `theme.colors` — the cross-slot map the panel edge is read from.
81
+ * Leaving it out stands in for a host old enough not to have it.
82
+ */
83
+ colors?: Record<string, Color>;
84
+ /** `Theme.getColorMode()`: the mode the escapes are emitted in. */
85
+ mode?: "truecolor" | "256color";
86
+ /** `Theme.appearance`, which only exists from pi 0.99.0 on. */
87
+ appearance?: "light" | "dark";
88
+ }
89
+
90
+ function makePanelTheme(tokens: PanelTokens): Theme {
91
+ return {
92
+ ...(tokens.appearance === undefined ? {} : { appearance: tokens.appearance }),
93
+ // `colors` is a property, not a method: absent, it says "this host has no
94
+ // cross-slot map" — a shape, not a colour.
95
+ ...(tokens.colors === undefined
96
+ ? {}
97
+ : {
98
+ colors: tokens.colors,
99
+ getColorMode: () => tokens.mode ?? "truecolor",
100
+ }),
101
+ fg: (key: string, text: string) => {
102
+ const token = tokens.fg?.[key];
103
+ return token === undefined ? undefined : `${token}${text}\x1b[0m`;
104
+ },
105
+ getBgAnsi: (key: string) => {
106
+ const token = tokens.bg?.[key];
107
+ if (token === undefined) throw new Error(`Unknown theme color: ${key}`);
108
+ return token;
109
+ },
110
+ getFgAnsi: (key: string) => {
111
+ const token = tokens.fg?.[key];
112
+ if (token === undefined) throw new Error(`Unknown theme color: ${key}`);
113
+ return token;
114
+ },
115
+ } as unknown as Theme;
116
+ }
117
+
118
+ /**
119
+ * A **real** pi 1.2.0 `Theme`. Constructing one needs every token that another
120
+ * token falls back to (`scrollbarTrack`→`muted`, `scrollbarThumb`/`searchMatchText`→`text`,
121
+ * `searchMatchBg`→`selectedBg`, `thinkingMax`→`thinkingXhigh`), because the
122
+ * constructor resolves those eagerly. The token values below are the dark
123
+ * theme's; `appearance` is declared so a test can ask about a light terminal
124
+ * without shipping a second token table.
125
+ */
126
+ function realPiTheme(
127
+ overrides: Record<string, string | number>,
128
+ options: {
129
+ appearance: "light" | "dark";
130
+ mode?: "truecolor" | "256color";
131
+ /**
132
+ * Tokens to leave out entirely, standing in for a hand-written theme JSON
133
+ * that never declares them — `Theme` fills only tokens that declare a
134
+ * fallback, so an omitted token makes `getBgAnsi` throw.
135
+ */
136
+ omit?: string[];
137
+ },
138
+ ): Theme {
139
+ const tokens: Record<string, string | number> = {
140
+ text: "#d8d8d8",
141
+ muted: "#8a8a8a",
142
+ dim: "#6a6a6a",
143
+ accent: "#c586c0",
144
+ border: "#5f7a91",
145
+ borderMuted: "#768186",
146
+ scrollbarTrack: "#3a3a3a",
147
+ scrollbarThumb: "#d8d8d8",
148
+ selectedBg: "#213b49",
149
+ searchMatchBg: "#3a3a21",
150
+ searchMatchText: "#d8d8d8",
151
+ thinkingXhigh: "#c586c0",
152
+ thinkingMax: "#e06c75",
153
+ userMessageBg: "#213b49",
154
+ customMessageBg: "#3a3055",
155
+ toolDiffAdded: "#68b78d",
156
+ toolDiffRemoved: "#e06c75",
157
+ toolDiffContext: "#8a8a8a",
158
+ ...overrides,
159
+ };
160
+ for (const token of options.omit ?? []) delete tokens[token];
161
+ return new PiTheme(
162
+ tokens as never,
163
+ tokens as never,
164
+ options.mode ?? "truecolor",
165
+ { appearance: options.appearance },
166
+ ) as unknown as Theme;
167
+ }
168
+
169
+ describe("isTerminalDefault", () => {
170
+ it("recognises every shape of \"the terminal's own colour\"", () => {
171
+ // "" is the pre-0.99.0 answer for a default token; the two 39/49 resets are
172
+ // what Theme.getFgAnsi()/getBgAnsi() actually return for a token set to "";
173
+ // the 0m forms are a full reset, which also leaves the colour unset.
174
+ expect(isTerminalDefault("")).toBe(true);
175
+ expect(isTerminalDefault("\x1b[39m")).toBe(true);
176
+ expect(isTerminalDefault("\x1b[49m")).toBe(true);
177
+ expect(isTerminalDefault("\x1b[0m")).toBe(true);
178
+ expect(isTerminalDefault("\x1b[m")).toBe(true);
179
+ expect(isTerminalDefault(undefined)).toBe(true);
180
+ });
181
+
182
+ it("does not mistake a colour for the terminal default", () => {
183
+ // These are *compared* against the reset set — never cracked open for the
184
+ // colour they encode (see the discipline test below).
185
+ expect(isTerminalDefault("\x1b[48;2;33;59;73m")).toBe(false);
186
+ expect(isTerminalDefault("\x1b[48;5;236m")).toBe(false);
187
+ expect(isTerminalDefault("\x1b[38;5;2m")).toBe(false);
188
+ expect(isTerminalDefault("\x1b[49;38;2;1;2;3m")).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe("resolvePalette panel tint and edge", () => {
193
+ it("paints nothing when the panel token is the terminal default (\\x1b[49m)", () => {
194
+ // The reported bug: pi's default `system` theme gives its panels no
195
+ // background on a terminal that reported nothing, so getBgAnsi() answers
196
+ // `\x1b[49m` — truthy — and the old chain tinted with it while deriving a
197
+ // near-black edge.
198
+ const p = resolvePalette(
199
+ makePanelTheme({
200
+ appearance: "light",
201
+ bg: { userMessageBg: "\x1b[49m", customMessageBg: "\x1b[49m" },
202
+ fg: { borderMuted: "\x1b[38;2;154;162;167m", border: "\x1b[38;2;118;163;191m" },
203
+ }),
204
+ );
205
+ expect(p.panelBg).toBe("");
206
+ expect(p.panelEdge).toBe("");
207
+ });
208
+
209
+ it("treats the pre-0.99.0 empty token as the same \"no colour\" answer", () => {
210
+ const p = resolvePalette(makePanelTheme({ bg: { userMessageBg: "" } }));
211
+ expect(p.panelBg).toBe("");
212
+ expect(p.panelEdge).toBe("");
213
+ });
214
+
215
+ it("reads a bare reset as \"no colour\" too", () => {
216
+ for (const reset of ["\x1b[0m", "\x1b[m", "\x1b[39m", "\x1b[49m"]) {
217
+ const p = resolvePalette(makePanelTheme({ bg: { userMessageBg: reset } }));
218
+ expect(p.panelBg, `reset ${JSON.stringify(reset)}`).toBe("");
219
+ expect(p.panelEdge, `reset ${JSON.stringify(reset)}`).toBe("");
220
+ }
221
+ });
222
+
223
+ it("keeps tinting with a real truecolour panel token, byte for byte", () => {
224
+ // pi 1.2.0's dark theme measures userMessageBg as exactly this sequence.
225
+ const p = resolvePalette(
226
+ makePanelTheme({
227
+ appearance: "dark",
228
+ bg: { userMessageBg: "\x1b[48;2;33;59;73m" },
229
+ fg: { borderMuted: "\x1b[38;2;118;129;134m" },
230
+ }),
231
+ );
232
+ expect(p.panelBg).toBe("\x1b[48;2;33;59;73m");
233
+ });
234
+
235
+ it("edges the slab with the panel's own token asked as a foreground, not the border's", () => {
236
+ // The slab cushions the panel, so it wears the panel's colour. Reaching for
237
+ // `borderMuted` because it looks like a similar colour is the same class of
238
+ // mistake as rewriting the background escape: the cushion stops being the
239
+ // panel and becomes a line. Asking for the panel token in the foreground
240
+ // slot is `theme.colors[token]` — pi takes a token only in its own slot —
241
+ // and it stays a question asked of the theme, never a decode.
242
+ const p = resolvePalette(
243
+ makePanelTheme({
244
+ appearance: "dark",
245
+ bg: { userMessageBg: "\x1b[48;2;33;59;73m" },
246
+ fg: { borderMuted: "\x1b[38;2;118;129;134m", border: "\x1b[38;2;95;122;145m" },
247
+ colors: { userMessageBg: rgbColor(33, 59, 73) },
248
+ }),
249
+ );
250
+ expect(p.panelEdge).toBe("\x1b[38;2;33;59;73m");
251
+ expect(p.panelEdge).not.toBe("\x1b[38;2;118;129;134m");
252
+ expect(p.panelEdge).not.toBe("\x1b[38;2;95;122;145m");
253
+ });
254
+
255
+ it("takes no edge when the theme cannot answer its own panel token as a foreground", () => {
256
+ const borderOnly = resolvePalette(
257
+ makePanelTheme({
258
+ bg: { userMessageBg: "\x1b[48;2;33;59;73m" },
259
+ // No `borderMuted`: the old chain fell through to `border` here. That
260
+ // fallthrough is gone — the cushion is not a border, so no border token
261
+ // answers it, `border` included.
262
+ fg: { border: "\x1b[38;2;95;122;145m" },
263
+ colors: { userMessageBg: rgbColor(33, 59, 73) },
264
+ }),
265
+ );
266
+ expect(borderOnly.panelBg).toBe("\x1b[48;2;33;59;73m");
267
+ expect(borderOnly.panelEdge).toBe("\x1b[38;2;33;59;73m");
268
+
269
+ // A tinted panel whose theme has nothing for *that* token as a foreground:
270
+ // no edge, and never a substitute token. The editor blanks the rows rather
271
+ // than painting block glyphs in the terminal's own foreground.
272
+ const cantAnswer = resolvePalette(
273
+ makePanelTheme({
274
+ bg: { userMessageBg: "\x1b[48;2;33;59;73m" },
275
+ fg: { borderMuted: "\x1b[38;2;118;129;134m", border: "\x1b[38;2;95;122;145m" },
276
+ colors: { customMessageBg: rgbColor(58, 48, 85) },
277
+ }),
278
+ );
279
+ expect(cantAnswer.panelBg).toBe("\x1b[48;2;33;59;73m");
280
+ expect(cantAnswer.panelEdge).toBe("");
281
+
282
+ // The same answer from a host with no cross-slot map at all.
283
+ const noColors = resolvePalette(
284
+ makePanelTheme({
285
+ bg: { userMessageBg: "\x1b[48;2;33;59;73m" },
286
+ fg: { borderMuted: "\x1b[38;2;118;129;134m" },
287
+ }),
288
+ );
289
+ expect(noColors.panelEdge).toBe("");
290
+ });
291
+
292
+ it("keeps the frame and the prefix in the border token while the slab wears the panel's", () => {
293
+ // `frame:` and `prefix:` are real chrome and keep `borderMuted`; only the
294
+ // cushion moved, so the `┌──┐` the user sees is untouched.
295
+ const p = resolvePalette(
296
+ makePanelTheme({
297
+ bg: { userMessageBg: "\x1b[48;2;33;59;73m" },
298
+ fg: { borderMuted: "\x1b[38;2;118;129;134m", border: "\x1b[38;2;95;122;145m" },
299
+ colors: { userMessageBg: rgbColor(33, 59, 73) },
300
+ }),
301
+ );
302
+ expect(p.panelEdge).toBe("\x1b[38;2;33;59;73m");
303
+ expect(p.frame("┌")).toBe("\x1b[38;2;118;129;134m┌\x1b[0m");
304
+ expect(p.prefix("> ")).toBe("\x1b[38;2;118;129;134m> \x1b[0m");
305
+ });
306
+
307
+ it("tracks a palette terminal: the slab keeps the panel's own index, in fg form", () => {
308
+ // `"terminal": { "trueColor": false }` makes every token come back as
309
+ // 38;5;n / 48;5;n (measured: userMessageBg #262626 -> \x1b[48;5;236m). The
310
+ // cushion is the panel's own colour, so it is the panel's own index — 236,
311
+ // in the foreground form — and not the border's 240.
312
+ const p = resolvePalette(
313
+ makePanelTheme({
314
+ appearance: "dark",
315
+ mode: "256color",
316
+ bg: { userMessageBg: "\x1b[48;5;236m" },
317
+ fg: { borderMuted: "\x1b[38;5;240m" },
318
+ colors: { userMessageBg: indexedColor(236) },
319
+ }),
320
+ );
321
+ expect(p.panelBg).toBe("\x1b[48;5;236m");
322
+ expect(p.panelEdge).toBe("\x1b[38;5;236m");
323
+ expect(p.panelEdge).not.toBe("\x1b[38;5;240m");
324
+ });
325
+
326
+ it("falls back to a LIGHT slab when the theme reports a light appearance and no panel token", () => {
327
+ // The fallback slab is only for a theme that cannot answer at all; even
328
+ // then it must not paint near-black on a light terminal.
329
+ const p = resolvePalette(makePanelTheme({ appearance: "light" }));
330
+ expect(p.panelBg).toBe("\x1b[48;2;238;238;238m");
331
+ expect(p.panelEdge).toBe("\x1b[38;2;238;238;238m");
332
+ });
333
+
334
+ it("keeps the near-black slab for a dark appearance, and for no theme object at all", () => {
335
+ const dark = resolvePalette(makePanelTheme({ appearance: "dark" }));
336
+ expect(dark.panelBg).toBe("\x1b[48;2;16;16;16m");
337
+ expect(dark.panelEdge).toBe("\x1b[38;2;16;16;16m");
338
+
339
+ // Headless / no theme object: the hardcoded constants, dark, and no throw.
340
+ const none = resolvePalette(null as unknown as Theme);
341
+ expect(none.panelBg).toBe("\x1b[48;2;16;16;16m");
342
+ expect(none.panelEdge).toBe("\x1b[38;2;16;16;16m");
343
+ });
344
+ });
345
+
346
+ describe("resolvePalette against a real pi 1.2.0 Theme", () => {
347
+ it("paints no tint and no edge for the system-theme shape (panel tokens are \"\")", () => {
348
+ const theme = realPiTheme(
349
+ { userMessageBg: "", customMessageBg: "" },
350
+ { appearance: "light" },
351
+ );
352
+ // The two facts the fix turns on, measured on the real object rather than
353
+ // asserted from the plan's prose.
354
+ expect(theme.getBgAnsi("userMessageBg")).toBe("\x1b[49m");
355
+ expect(theme.appearance).toBe("light");
356
+
357
+ const p = resolvePalette(theme);
358
+ expect(p.panelBg).toBe("");
359
+ expect(p.panelEdge).toBe("");
360
+ });
361
+
362
+ it("edges the slab with the panel's OWN colour asked as a foreground, not with the border", () => {
363
+ // The regression: the slab rows sit directly against the panel body, so a
364
+ // cushion painted in the panel's own tint is invisible by construction, and
365
+ // one painted in `borderMuted` is a visible grey rule hugging the text area.
366
+ // Asking the theme for the panel token *as a foreground* is the way to get
367
+ // the tint without decoding anything: pi documents it ("Use
368
+ // `theme.colors[token]` to use a token's color in the other slot", and a
369
+ // token is *only* accepted in its own slot — `theme.fg("userMessageBg", …)`
370
+ // throws). Emitting that color through pi's own foreground emitter is
371
+ // asking the theme for a value, never parsing an escape string.
372
+ const theme = realPiTheme({}, { appearance: "dark" });
373
+ expect(theme.getBgAnsi("userMessageBg")).toBe("\x1b[48;2;33;59;73m");
374
+ expect(theme.getFgAnsi("borderMuted")).toBe("\x1b[38;2;118;129;134m");
375
+ // Measured, so the premise of the rule above cannot rot silently.
376
+ expect(() => theme.fg("userMessageBg" as never, "x")).toThrow();
377
+
378
+ const p = resolvePalette(theme);
379
+ expect(p.panelBg).toBe("\x1b[48;2;33;59;73m");
380
+ // Identity, asserted as measured bytes: the panel's own tint in the
381
+ // foreground slot …
382
+ expect(p.panelEdge).toBe("\x1b[38;2;33;59;73m");
383
+ // … which is what the theme itself would paint that color as a foreground …
384
+ expect(p.panelEdge).toBe(
385
+ foregroundAnsi(theme.colors.userMessageBg, theme.getColorMode()),
386
+ );
387
+ // … and NOT the border token, which is what the palette answers today.
388
+ expect(p.panelEdge).not.toBe("\x1b[38;2;118;129;134m");
389
+ expect(p.panelEdge).not.toBe(theme.getFgAnsi("borderMuted"));
390
+ });
391
+
392
+ it("still draws the `┌──┐` frame in the border token", () => {
393
+ // Only the slab changed hands. The frame is a real border and stays a border.
394
+ const theme = realPiTheme({}, { appearance: "dark" });
395
+ expect(resolvePalette(theme).frame("┌")).toContain("\x1b[38;2;118;129;134m");
396
+ });
397
+
398
+ it("follows the palette on a non-truecolour terminal instead of hardcoding anything", () => {
399
+ const theme = realPiTheme(
400
+ { userMessageBg: 236, borderMuted: 240 },
401
+ { appearance: "dark", mode: "256color" },
402
+ );
403
+ expect(theme.getBgAnsi("userMessageBg")).toBe("\x1b[48;5;236m");
404
+ const p = resolvePalette(theme);
405
+ expect(p.panelBg).toBe("\x1b[48;5;236m");
406
+ // The slab keeps the panel's own index, in the foreground form — 236, not
407
+ // the border's 240.
408
+ expect(p.panelEdge).toBe("\x1b[38;5;236m");
409
+ expect(p.panelEdge).not.toBe("\x1b[38;5;240m");
410
+ });
411
+
412
+ it("asks the THEME which colour mode to emit in, not a hardcoded one", () => {
413
+ // Both other palette tests use *indexed* tokens (236, 240), and the emitter
414
+ // answers `38;5;n` for an indexed colour whatever mode it is handed — so
415
+ // neither can see the mode argument at all. A real theme's tokens are rgb
416
+ // (`okhsl(...)` resolves to rgb), and those DO depend on the mode:
417
+ // `#213b49` is `38;2;33;59;73m` in truecolour and `38;5;23m` here. Handing
418
+ // the emitter a fixed mode would paint a truecolour escape on a terminal
419
+ // that said it cannot draw one, and the slab would fall back to the
420
+ // terminal's own foreground — the bright bar this whole rule avoids.
421
+ const theme = realPiTheme(
422
+ { userMessageBg: "#213b49" },
423
+ { appearance: "dark", mode: "256color" },
424
+ );
425
+ // Measured, so the premise (rgb + 256 colour ⇒ an indexed answer) is visible.
426
+ expect(theme.getBgAnsi("userMessageBg")).toBe("\x1b[48;5;23m");
427
+
428
+ const p = resolvePalette(theme);
429
+ expect(p.panelBg).toBe("\x1b[48;5;23m");
430
+ expect(p.panelEdge).toBe("\x1b[38;5;23m");
431
+ // The same index as the body, in the other slot — and no truecolour escape
432
+ // anywhere near a terminal that did not ask for one.
433
+ expect(p.panelEdge.replace("38;", "48;")).toBe(p.panelBg);
434
+ expect(p.panelEdge).not.toContain("38;2;");
435
+ });
436
+
437
+ it("edges with the token that WON the tint, not with a hardcoded one", () => {
438
+ // `panelTint` tries `userMessageBg` then `customMessageBg`, and a theme JSON
439
+ // that omits the first is legal: pi fills only tokens that declare a
440
+ // fallback, and neither panel token declares one, so `getBgAnsi`
441
+ // ("userMessageBg") throws and the second token wins. The edge has to follow
442
+ // the winner — asking `colors` for a fixed token would find nothing for a
443
+ // panel that IS tinted and blank the slab.
444
+ const theme = realPiTheme(
445
+ { customMessageBg: "#3a3055" },
446
+ { appearance: "dark", omit: ["userMessageBg"] },
447
+ );
448
+ expect(() => theme.getBgAnsi("userMessageBg")).toThrow();
449
+
450
+ const p = resolvePalette(theme);
451
+ expect(p.panelBg).toBe("\x1b[48;2;58;48;85m");
452
+ expect(p.panelEdge).toBe("\x1b[38;2;58;48;85m");
453
+ });
454
+ });
455
+
456
+ // ── Discipline: colour comes from the theme, never from an escape string ───
457
+
458
+ const CHROME_SOURCE = readFileSync(fileURLToPath(new URL("./chrome.ts", import.meta.url)), "utf8");
459
+
460
+ describe("chrome.ts discipline", () => {
461
+ it("has no setThemeBg, and no write to a theme's internal bgColors map", () => {
462
+ // 1.2.0 renamed the private maps to fgAnsi/bgAnsi, so the write was a
463
+ // silent no-op with no callers. Deleting it is the decision, not repairing it.
464
+ expect(CHROME_SOURCE).not.toMatch(/setThemeBg/);
465
+ expect(CHROME_SOURCE).not.toMatch(/bgColors/);
466
+ });
467
+
468
+ it("infers no colour by parsing an escape sequence", () => {
469
+ // Emitting `38;2;…`/`48;2;…` constants is fine; cracking open an incoming
470
+ // `getBgAnsi()` string to decide what to paint is not — that was
471
+ // `bgToFgAnsi()`, which matched `48;2;`/`48;5;` and fell through to #101010.
472
+ const offenders = CHROME_SOURCE.split("\n").filter((line) => {
473
+ const inspects = /\.(startsWith|endsWith|replace|replaceAll|match|matchAll|search|split|test)\(/.test(line);
474
+ return inspects && /\\x1b|\\u001b|\[3?8;/.test(line);
475
+ });
476
+ expect(offenders.map((l) => l.trim())).toEqual([]);
477
+ expect(CHROME_SOURCE).not.toMatch(/bgToFgAnsi/);
478
+ // The only escape-string logic allowed is the set membership test.
479
+ expect(CHROME_SOURCE).toMatch(/isTerminalDefault/);
480
+ });
481
+
482
+ it("still exports the palette and the predicate it grew", () => {
483
+ expect(typeof resolvePalette).toBe("function");
484
+ expect(typeof isTerminalDefault).toBe("function");
485
+ });
486
+ });