@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.
- package/README.md +3 -3
- package/package.json +7 -4
- package/src/bridge/events.test.ts +438 -0
- package/src/bridge/events.ts +98 -39
- package/src/bridge/index.test.ts +50 -22
- package/src/bridge/index.ts +18 -5
- package/src/bridge/self-usage.test.ts +90 -12
- package/src/bridge/self-usage.ts +19 -6
- package/src/chrome-old-host.test.ts +84 -0
- package/src/chrome.test.ts +435 -2
- package/src/chrome.ts +138 -20
- package/src/events-host-bus.test.ts +113 -0
- package/src/events.test.ts +85 -0
- package/src/events.ts +107 -0
- package/src/index.test.ts +3 -9
- package/src/index.ts +5 -5
- package/src/log-discipline.test.ts +187 -0
- package/src/log.test.ts +172 -0
- package/src/log.ts +69 -0
- package/src/overlay.test.ts +178 -6
- package/src/overlay.ts +46 -62
- package/src/pi-support.test.ts +444 -0
- package/src/pi-support.ts +190 -0
- package/src/profiler.ts +14 -5
- package/src/screen-mode-discipline.test.ts +214 -0
- package/src/screen-mode.test.ts +48 -0
- package/src/screen-mode.ts +45 -0
- package/src/settings-io.test.ts +174 -1
- package/src/settings-io.ts +54 -0
- package/src/text.test.ts +58 -0
- package/src/text.ts +21 -20
- package/src/tool-render.test.ts +48 -0
- package/src/tool-render.ts +44 -0
- package/src/bus.test.ts +0 -154
- package/src/bus.ts +0 -137
|
@@ -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
|
+
});
|
package/src/chrome.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
+
});
|