@pi-archimedes/diff 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/diff",
3
- "version": "2.10.4",
3
+ "version": "2.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -24,12 +24,12 @@
24
24
  "get-east-asian-width": "^1.6.0"
25
25
  },
26
26
  "peerDependencies": {
27
- "@earendil-works/pi-coding-agent": ">=0.1.0",
28
- "@earendil-works/pi-tui": ">=0.1.0"
27
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
28
+ "@earendil-works/pi-tui": ">=1.2.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@earendil-works/pi-coding-agent": "^0.87.0",
32
- "@earendil-works/pi-tui": "^0.87.0",
31
+ "@earendil-works/pi-coding-agent": "1.2.0",
32
+ "@earendil-works/pi-tui": "1.2.0",
33
33
  "@types/diff": "^7.0.2",
34
34
  "typescript": "^6.0.0"
35
35
  },
@@ -212,16 +212,40 @@ describe("resolveDiffColors", () => {
212
212
  expect(result.fgCtx).toBe("\x1b[38;2;100;100;100m");
213
213
  });
214
214
 
215
- it("falls back to defaults when getFgAnsi returns empty string", () => {
215
+ it("passes a terminal-default token through instead of substituting a hardcoded colour", () => {
216
+ // 1.2.0 answers a token set to "" with `\x1b[39m` — truthy — so the old
217
+ // `|| FG_ADD` could never fire for it, and the `""` shape that *did* fire
218
+ // it (pre-0.99.0) substituted a colour where the theme had said "the
219
+ // terminal's own". Both readings agree on the fix: a terminal default is
220
+ // an answer, not a missing value.
221
+ resetDiffColors();
222
+ for (const token of ["", "\x1b[39m", "\x1b[0m"]) {
223
+ const result = resolveDiffColors({ getFgAnsi: () => token });
224
+ expect(result).toEqual({ fgAdd: token, fgDel: token, fgCtx: token });
225
+ }
226
+ });
227
+
228
+ it("substitutes the hardcoded fallback per token, only where the theme has no answer", () => {
216
229
  resetDiffColors();
217
230
  const theme = {
218
- getFgAnsi: () => "",
231
+ getFgAnsi: (token: string) => {
232
+ if (token === "toolDiffAdded") return "\x1b[38;2;50;200;50m";
233
+ // Unknown tokens throw in 1.2.0 (`tokenAnsi`), which used to take
234
+ // every colour in this row down to the hardcoded set.
235
+ throw new Error(`Unknown theme color: ${token}`);
236
+ },
219
237
  };
220
238
  const result = resolveDiffColors(theme);
221
- expect(result.fgAdd).toBe(FG_ADD);
239
+ expect(result.fgAdd).toBe("\x1b[38;2;50;200;50m");
222
240
  expect(result.fgDel).toBe(FG_DEL);
223
241
  expect(result.fgCtx).toBe(FG_DIM);
224
242
  });
243
+
244
+ it("keeps the hardcoded set for the no-theme-object path", () => {
245
+ resetDiffColors();
246
+ expect(resolveDiffColors(undefined)).toEqual(DEFAULT_DIFF_COLORS);
247
+ expect(resolveDiffColors({})).toEqual(DEFAULT_DIFF_COLORS);
248
+ });
225
249
  });
226
250
 
227
251
  // ── resetDiffColors ─────────────────────────────────────────────────────────
package/src/ansi/codes.ts CHANGED
@@ -21,7 +21,8 @@ export const FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
21
21
  export const FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes
22
22
 
23
23
  export const BORDER_BAR = "▌";
24
- const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
24
+ /** Reset to the terminal's own background — "no background color", not a color. */
25
+ export const BG_DEFAULT = "\x1b[49m";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // DiffBg — instance-scoped background colors derived from a theme
@@ -107,6 +108,27 @@ export function themeCacheKey(theme?: any): string {
107
108
 
108
109
  export { deriveBgFromTheme };
109
110
 
111
+ /**
112
+ * Read one foreground token from a theme, or undefined when the theme has no
113
+ * answer for it.
114
+ *
115
+ * A terminal-default answer is returned as-is. Since 0.99.0 a token set to `""`
116
+ * comes back as `\x1b[39m` — truthy — so a `getFgAnsi(…) || FG_*` chain could
117
+ * never fire for it, while the `""` shape that *did* fire it substituted a
118
+ * hardcoded color where the theme had said "the terminal's own". Both readings
119
+ * agree: a terminal default is an answer, and the hardcoded `FG_*` constants are
120
+ * only for a theme that cannot answer at all.
121
+ */
122
+ function themeFgAnsi(theme: any, token: string): string | undefined {
123
+ if (typeof theme?.getFgAnsi !== "function") return undefined;
124
+ try {
125
+ const ansi = theme.getFgAnsi(token);
126
+ return typeof ansi === "string" ? ansi : undefined;
127
+ } catch {
128
+ return undefined; // unknown token: 1.x throws from `tokenAnsi`
129
+ }
130
+ }
131
+
110
132
  export function resolveDiffColors(theme?: any): DiffColors {
111
133
  const themeKey = themeCacheKey(theme);
112
134
 
@@ -115,13 +137,11 @@ export function resolveDiffColors(theme?: any): DiffColors {
115
137
  _lastThemeKey = themeKey;
116
138
  }
117
139
  if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
118
- try {
119
- return {
120
- fgAdd: theme.getFgAnsi("toolDiffAdded") || FG_ADD,
121
- fgDel: theme.getFgAnsi("toolDiffRemoved") || FG_DEL,
122
- fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
123
- };
124
- } catch {
125
- return DEFAULT_DIFF_COLORS;
126
- }
140
+ // Per token, so one unknown token cannot drag the whole row back to the
141
+ // hardcoded set.
142
+ return {
143
+ fgAdd: themeFgAnsi(theme, "toolDiffAdded") ?? FG_ADD,
144
+ fgDel: themeFgAnsi(theme, "toolDiffRemoved") ?? FG_DEL,
145
+ fgCtx: themeFgAnsi(theme, "toolDiffContext") ?? FG_DIM,
146
+ };
127
147
  }
@@ -1,8 +1,16 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import * as fc from "fast-check";
3
- import { deriveBgFromTheme } from "./colors.js";
3
+ import { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
4
+ import { deriveBgFromTheme, isTerminalDefault } from "./colors.js";
4
5
  import { DEFAULT_DIFF_BG, FG_RULE } from "./codes.js";
5
6
 
7
+ // The escape that means "paint nothing": the terminal's own background. A theme
8
+ // token set to "" (terminal default) reaches us as `\x1b[39m` / `\x1b[49m`, and
9
+ // `\x1b[49m` is exactly what `bgBase` already uses when there is no base colour,
10
+ // so "no colour" needs no new colour value — it needs a fallthrough that stops
11
+ // inventing one.
12
+ const BG_NONE = "\x1b[49m";
13
+
6
14
  // ── deriveBgFromTheme ───────────────────────────────────────────────────────
7
15
 
8
16
  describe("deriveBgFromTheme", () => {
@@ -199,7 +207,7 @@ describe("deriveBgFromTheme", () => {
199
207
  );
200
208
  });
201
209
 
202
- it("property: all bg fields are valid truecolor bg escapes or defaults", () => {
210
+ it("property: all bg fields are valid truecolor bg escapes, the terminal default, or defaults", () => {
203
211
  fc.assert(
204
212
  fc.property(
205
213
  fc.string({ maxLength: 50 }),
@@ -212,23 +220,197 @@ describe("deriveBgFromTheme", () => {
212
220
  },
213
221
  };
214
222
  const result = deriveBgFromTheme(theme);
215
- // Either we get valid truecolor bg escapes, or we get defaults
216
- const isTruecolorBg = (s: string) => /\x1b\[48;2;\d+;\d+;\d+m/.test(s);
217
- const allValid = [
223
+ // Three legal answers: a derived truecolour bg, "no colour" (a
224
+ // terminal-default token), or the no-theme-object fallback.
225
+ const isLegal = (s: string) =>
226
+ /\x1b\[48;2;\d+;\d+;\d+m/.test(s) || s === BG_NONE;
227
+ const allLegal = [
218
228
  result.bgAdd,
219
229
  result.bgDel,
220
230
  result.bgAddW,
221
231
  result.bgDelW,
222
232
  result.bgGutterAdd,
223
233
  result.bgGutterDel,
224
- ].every((bg) => isTruecolorBg(bg));
234
+ ].every(isLegal);
225
235
  const isDefault = result === DEFAULT_DIFF_BG || result.bgAdd === DEFAULT_DIFF_BG.bgAdd;
226
- return allValid || isDefault;
236
+ return allLegal || isDefault;
227
237
  },
228
238
  ),
229
239
  );
230
240
  });
231
241
 
242
+ // ── Palette tokens and terminal-default tokens ───────────────────────────
243
+ //
244
+ // The parser used to match only `38|48;2;r;g;b`. Every other token — the
245
+ // `38;5;n`/`48;5;n` sequences a non-truecolour terminal produces, and the
246
+ // indexed tokens pi's generated `system` theme uses when the terminal
247
+ // reports nothing — fell through to the hardcoded `DEFAULT_DIFF_BG` slabs,
248
+ // so the diff showed fixed dark green/red no matter what the theme said.
249
+
250
+ it("resolves a 38;5;n diff colour through the xterm-256 table", () => {
251
+ // Index 2 is (0,128,0) in the same table `core/src/color.ts` uses, so the
252
+ // added-line background is that accent mixed into the base at 0.08.
253
+ const theme = {
254
+ getFgAnsi: (token: string) => {
255
+ if (token === "toolDiffAdded") return "\x1b[38;5;2m";
256
+ if (token === "toolDiffRemoved") return "\x1b[38;5;1m";
257
+ return "\x1b[38;5;7m";
258
+ },
259
+ };
260
+ const result = deriveBgFromTheme(theme);
261
+ expect(result.bgAdd).toBe("\x1b[48;2;0;10;0m");
262
+ expect(result.bgDel).toBe("\x1b[48;2;13;0;0m");
263
+ expect(result.bgAddW).toBe("\x1b[48;2;0;26;0m");
264
+ expect(result.bgGutterDel).toBe("\x1b[48;2;8;0;0m");
265
+ // Not the hardcoded slab any more — the theme's own palette drove it.
266
+ expect(result.bgAdd).not.toBe(DEFAULT_DIFF_BG.bgAdd);
267
+ });
268
+
269
+ it("resolves a 48;5;n base background through the same table", () => {
270
+ // The asymmetry that makes core's helper insufficient on its own: this
271
+ // parser is fed background strings too (`theme.getBgAnsi(...)`), so 48;5;
272
+ // must resolve, not just 38;5;. 236 is the gray ramp at (48,48,48).
273
+ const theme = {
274
+ getFgAnsi: (token: string) => {
275
+ if (token === "toolDiffAdded") return "\x1b[38;5;2m";
276
+ if (token === "toolDiffRemoved") return "\x1b[38;5;1m";
277
+ return "\x1b[38;5;7m";
278
+ },
279
+ getBgAnsi: (token: string) => {
280
+ if (token === "toolSuccessBg") return "\x1b[48;5;236m";
281
+ if (token === "toolErrorBg") return "\x1b[48;5;237m";
282
+ return BG_NONE;
283
+ },
284
+ };
285
+ const result = deriveBgFromTheme(theme);
286
+ // The token itself is the base — passed through, not reparsed into a
287
+ // truecolour equivalent — and it is what the mix starts from.
288
+ expect(result.bgBase).toBe("\x1b[48;5;236m");
289
+ expect(result.bgEmpty).toBe("\x1b[48;5;236m");
290
+ expect(result.bgAdd).toBe("\x1b[48;2;44;54;44m");
291
+ expect(result.bgDel).toBe("\x1b[48;2;65;52;52m");
292
+ });
293
+
294
+ it("treats a terminal-default diff colour as no colour, not as DEFAULT_DIFF_BG", () => {
295
+ for (const token of ["", "\x1b[39m", "\x1b[0m"]) {
296
+ const theme = { getFgAnsi: () => token };
297
+ const result = deriveBgFromTheme(theme);
298
+ expect(JSON.stringify(token)).not.toBe("undefined");
299
+ for (const field of [
300
+ result.bgAdd,
301
+ result.bgDel,
302
+ result.bgAddW,
303
+ result.bgDelW,
304
+ result.bgGutterAdd,
305
+ result.bgGutterDel,
306
+ result.bgEmpty,
307
+ ] as const) {
308
+ expect(field, `token ${JSON.stringify(token)}`).toBe(BG_NONE);
309
+ }
310
+ expect(result).not.toEqual(DEFAULT_DIFF_BG);
311
+ expect(result.bgAdd).not.toBe(DEFAULT_DIFF_BG.bgAdd);
312
+ // The reset still closes whatever state the line opened.
313
+ expect(result.rst).toContain("\x1b[0m");
314
+ }
315
+ });
316
+
317
+ it("keeps the side that has a colour tinted when only the other side declines one", () => {
318
+ const theme = {
319
+ getFgAnsi: (token: string) =>
320
+ token === "toolDiffAdded" ? "\x1b[38;2;0;255;0m" : "\x1b[39m",
321
+ };
322
+ const result = deriveBgFromTheme(theme);
323
+ expect(result.bgAdd).toBe("\x1b[48;2;0;20;0m");
324
+ expect(result.bgDel).toBe(BG_NONE);
325
+ expect(result.bgDelW).toBe(BG_NONE);
326
+ expect(result.bgGutterDel).toBe(BG_NONE);
327
+ });
328
+
329
+ it("treats a terminal-default base background as no base colour, keeping the accent tint", () => {
330
+ // `toolSuccessBg`/`toolErrorBg` are panel tokens: pi's generated system
331
+ // theme gives panels no background, so they arrive as `\x1b[49m`. That
332
+ // must not be read as a colour, and it must not discard the accent tint.
333
+ const theme = {
334
+ getFgAnsi: (token: string) => {
335
+ if (token === "toolDiffAdded") return "\x1b[38;2;0;255;0m";
336
+ if (token === "toolDiffRemoved") return "\x1b[38;2;255;0;0m";
337
+ return "\x1b[38;5;7m";
338
+ },
339
+ getBgAnsi: () => BG_NONE,
340
+ };
341
+ const result = deriveBgFromTheme(theme);
342
+ expect(result.bgBase).toBe(BG_NONE);
343
+ expect(result.bgAdd).toBe("\x1b[48;2;0;20;0m");
344
+ });
345
+
346
+ // ── Real pi 1.2.0 Theme objects ──────────────────────────────────
347
+ //
348
+ // The fixtures above hand-build escape strings. These build the real thing,
349
+ // because the whole point is that the shape of the incoming token is pi's
350
+ // business, not ours.
351
+
352
+ /**
353
+ * A real pi 1.2.0 `Theme`. Every token that another token falls back to
354
+ * (`scrollbarTrack`→`muted`, `scrollbarThumb`/`searchMatchText`→`text`,
355
+ * `searchMatchBg`→`selectedBg`, `thinkingMax`→`thinkingXhigh`) has to be
356
+ * present, because the constructor resolves the fallbacks eagerly.
357
+ */
358
+ function realPiTheme(
359
+ tokens: Record<string, string | number>,
360
+ options: { appearance: "light" | "dark"; mode?: "truecolor" | "256color" },
361
+ ): PiTheme {
362
+ const base: Record<string, string | number> = {
363
+ text: "#d8d8d8",
364
+ muted: "#8a8a8a",
365
+ selectedBg: "#213b49",
366
+ thinkingXhigh: "#c586c0",
367
+ ...tokens,
368
+ };
369
+ return new PiTheme(
370
+ base as never,
371
+ base as never,
372
+ options.mode ?? "truecolor",
373
+ options,
374
+ ) as PiTheme;
375
+ }
376
+
377
+ it("derives from an indexed (38;5;n) theme token on a real pi Theme", () => {
378
+ // What pi does when the terminal reports no colours: the system theme
379
+ // hands out ANSI indices, and the escape comes back as `38;5;n` even in
380
+ // truecolour mode because the *token* is indexed.
381
+ const theme = realPiTheme(
382
+ { toolDiffAdded: 2, toolDiffRemoved: 1, toolDiffContext: 7 },
383
+ { appearance: "dark" },
384
+ );
385
+ expect(theme.getFgAnsi("toolDiffAdded")).toBe("\x1b[38;5;2m");
386
+ const result = deriveBgFromTheme(theme);
387
+ expect(result.bgAdd).toBe("\x1b[48;2;0;10;0m");
388
+ expect(result.bgAdd).not.toBe(DEFAULT_DIFF_BG.bgAdd);
389
+ });
390
+
391
+ it("paints no background for a real pi Theme whose diff tokens are terminal default", () => {
392
+ const theme = realPiTheme(
393
+ { toolDiffAdded: "", toolDiffRemoved: "", toolDiffContext: "" },
394
+ { appearance: "light" },
395
+ );
396
+ expect(theme.getFgAnsi("toolDiffAdded")).toBe("\x1b[39m");
397
+ const result = deriveBgFromTheme(theme);
398
+ expect(result.bgAdd).toBe(BG_NONE);
399
+ expect(result.bgDel).toBe(BG_NONE);
400
+ expect(result).not.toEqual(DEFAULT_DIFF_BG);
401
+ });
402
+
403
+ it("classifies terminal-default sequences without cracking them open", () => {
404
+ expect(isTerminalDefault("")).toBe(true);
405
+ expect(isTerminalDefault("\x1b[39m")).toBe(true);
406
+ expect(isTerminalDefault("\x1b[49m")).toBe(true);
407
+ expect(isTerminalDefault("\x1b[0m")).toBe(true);
408
+ expect(isTerminalDefault("\x1b[m")).toBe(true);
409
+ expect(isTerminalDefault(undefined)).toBe(true);
410
+ expect(isTerminalDefault("\x1b[38;5;2m")).toBe(false);
411
+ expect(isTerminalDefault("\x1b[48;2;18;32;26m")).toBe(false);
412
+ });
413
+
232
414
  it("property: divider always contains the rule character", () => {
233
415
  fc.assert(
234
416
  fc.property(
@@ -1,23 +1,143 @@
1
1
  /** Diff background color derivation from theme. */
2
2
 
3
3
  import type { DiffBg } from "./codes.js";
4
- import { DEFAULT_DIFF_BG, FG_RULE } from "./codes.js";
4
+ import { BG_DEFAULT, DEFAULT_DIFF_BG, FG_RULE } from "./codes.js";
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Color helpers
8
8
  // ---------------------------------------------------------------------------
9
+ //
10
+ // This parser is a deliberate sibling of `parseAnsiFgToRgb` in
11
+ // `packages/core/src/color.ts`; `pi-support.ts` is the one place that says what
12
+ // keeping such a copy costs and why it is worth it. The sibling is not reusable
13
+ // as-is for one reason: core is anchored to `^\x1b[38;` (foreground slot only),
14
+ // while this one reads `38|48` because it is fed **background** strings, straight
15
+ // from `theme.getBgAnsi(...)`.
9
16
 
10
- /** Parse 24-bit ANSI color code → RGB. Works for both fg and bg escapes. */
11
- function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
12
- const esc = "\x1b";
13
- const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
14
- return m ? { r: +m[1]!, g: +m[2]!, b: +m[3]! } : null;
17
+ interface RGB {
18
+ r: number;
19
+ g: number;
20
+ b: number;
21
+ }
22
+
23
+ // xterm standard 16-color palette (ANSI indices 0..15), same table as core.
24
+ // Source: https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
25
+ const XTERM_16: readonly RGB[] = [
26
+ { r: 0x00, g: 0x00, b: 0x00 }, // 0 black
27
+ { r: 0x80, g: 0x00, b: 0x00 }, // 1 maroon
28
+ { r: 0x00, g: 0x80, b: 0x00 }, // 2 green
29
+ { r: 0x80, g: 0x80, b: 0x00 }, // 3 olive
30
+ { r: 0x00, g: 0x00, b: 0x80 }, // 4 navy
31
+ { r: 0x80, g: 0x80, b: 0x80 }, // 5 purple
32
+ { r: 0x00, g: 0x80, b: 0x80 }, // 6 teal
33
+ { r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 silver
34
+ { r: 0x80, g: 0x80, b: 0x80 }, // 8 grey
35
+ { r: 0xff, g: 0x00, b: 0x00 }, // 9 red
36
+ { r: 0x00, g: 0xff, b: 0x00 }, // 10 lime
37
+ { r: 0xff, g: 0xff, b: 0x00 }, // 11 yellow
38
+ { r: 0x00, g: 0x00, b: 0xff }, // 12 blue
39
+ { r: 0xff, g: 0x00, b: 0xff }, // 13 fuchsia
40
+ { r: 0x00, g: 0xff, b: 0xff }, // 14 aqua
41
+ { r: 0xff, g: 0xff, b: 0xff }, // 15 white
42
+ ];
43
+
44
+ // 6x6x6 cube levels for ANSI 16..231; 232..255 are the gray ramp.
45
+ const CUBE_LEVELS: readonly number[] = [0, 95, 135, 175, 215, 255];
46
+
47
+ const toByte = (value: number): number => Math.min(255, Math.max(0, Math.round(value)));
48
+
49
+ /** xterm-256 index → RGB, or null for an index out of range. */
50
+ function ansi256ToRgb(code: number): RGB | null {
51
+ const c = Math.floor(code);
52
+ if (!Number.isFinite(c) || c < 0 || c > 255) return null;
53
+ if (c < 16) return { ...XTERM_16[c]! };
54
+ if (c < 232) {
55
+ const idx = c - 16;
56
+ return {
57
+ r: CUBE_LEVELS[Math.floor(idx / 36) % 6]!,
58
+ g: CUBE_LEVELS[Math.floor(idx / 6) % 6]!,
59
+ b: CUBE_LEVELS[idx % 6]!,
60
+ };
61
+ }
62
+ const v = 8 + (c - 232) * 10;
63
+ return { r: v, g: v, b: v };
64
+ }
65
+
66
+ /**
67
+ * The escape sequences that mean *"the terminal's own color — paint nothing"*.
68
+ * A pi theme token set to `""` (terminal default) reaches us as `\x1b[39m` /
69
+ * `\x1b[49m` — truthy strings, so `|| FALLBACK` chains cannot see them. This is
70
+ * a comparison against a known set, never a decode of the color. Mirrors
71
+ * `isTerminalDefault` in `packages/core/src/chrome.ts`, duplicated for the same
72
+ * standalone-package reason as the table above.
73
+ */
74
+ const TERMINAL_DEFAULT_SEQUENCES: ReadonlySet<string> = new Set([
75
+ "", // the answer itself, pre-0.99.0
76
+ "\x1b[0m", // full reset: no color left set
77
+ "\x1b[m", // …and its parameter-less form
78
+ "\x1b[39m", // a foreground token set to ""
79
+ "\x1b[49m", // a background token set to ""
80
+ ]);
81
+
82
+ /** Does this theme answer mean "the terminal's own color", i.e. no color? */
83
+ export function isTerminalDefault(ansi: string | undefined): boolean {
84
+ return ansi === undefined || TERMINAL_DEFAULT_SEQUENCES.has(ansi);
85
+ }
86
+
87
+ const TRUECOLOR_RE = /\x1b\[(?:38|48);2;(\d{1,3});(\d{1,3});(\d{1,3})m/;
88
+ const PALETTE_RE = /\x1b\[(?:38|48);5;(\d{1,3})m/;
89
+
90
+ /**
91
+ * Parse a theme escape into RGB. Both slots are read (`38;…` and `48;…`) with
92
+ * 24-bit and xterm-256 parameters, because a non-truecolor terminal — and
93
+ * pi's generated `system` theme on a terminal that reported no colors — hands
94
+ * out `38;5;n`/`48;5;n`. A terminal-default reset parses to *no color* (null),
95
+ * never to a color we then mix.
96
+ */
97
+ function parseAnsiRgb(ansi: string): RGB | null {
98
+ if (!ansi || isTerminalDefault(ansi)) return null;
99
+ const truecolor = TRUECOLOR_RE.exec(ansi);
100
+ if (truecolor) {
101
+ return {
102
+ r: toByte(Number.parseInt(truecolor[1]!, 10)),
103
+ g: toByte(Number.parseInt(truecolor[2]!, 10)),
104
+ b: toByte(Number.parseInt(truecolor[3]!, 10)),
105
+ };
106
+ }
107
+ const palette = PALETTE_RE.exec(ansi);
108
+ if (palette) return ansi256ToRgb(Number.parseInt(palette[1]!, 10));
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * What one theme token says about color. `"none"` (terminal default) and
114
+ * `"unknown"` (no such token, no accessor, or a value we cannot read) are
115
+ * different answers: "none" is the theme declining to paint, "unknown" is the
116
+ * theme not being a usable source at all.
117
+ */
118
+ type TokenColor =
119
+ | { kind: "color"; ansi: string; rgb: RGB }
120
+ | { kind: "none" }
121
+ | { kind: "unknown" };
122
+
123
+ function readThemeToken(read: (() => unknown) | undefined): TokenColor {
124
+ if (!read) return { kind: "unknown" };
125
+ let ansi: unknown;
126
+ try {
127
+ ansi = read(); // 1.x throws for an unknown token (`tokenAnsi`)
128
+ } catch {
129
+ return { kind: "unknown" };
130
+ }
131
+ if (typeof ansi !== "string") return { kind: "unknown" };
132
+ if (isTerminalDefault(ansi)) return { kind: "none" };
133
+ const rgb = parseAnsiRgb(ansi);
134
+ return rgb ? { kind: "color", ansi, rgb } : { kind: "unknown" };
15
135
  }
16
136
 
17
137
  /** Mix an accent color into a base color at the given intensity (0.0–1.0). */
18
138
  function mixBg(
19
- base: { r: number; g: number; b: number },
20
- accent: { r: number; g: number; b: number },
139
+ base: RGB,
140
+ accent: RGB,
21
141
  intensity: number,
22
142
  ): string {
23
143
  const r = Math.round(base.r + (accent.r - base.r) * intensity);
@@ -32,43 +152,46 @@ function mixBg(
32
152
 
33
153
  /** Auto-derive diff background colors from the pi theme's fg diff colors. */
34
154
  export function deriveBgFromTheme(theme: any): DiffBg {
155
+ // No theme object to ask: the hardcoded fallbacks are the honest answer,
156
+ // which is the only place they still apply.
35
157
  if (!theme?.getFgAnsi) return { ...DEFAULT_DIFF_BG };
36
158
  try {
37
- const fgAdd = theme.getFgAnsi("toolDiffAdded");
38
- const fgDel = theme.getFgAnsi("toolDiffRemoved");
39
- const addRgb = parseAnsiRgb(fgAdd);
40
- const delRgb = parseAnsiRgb(fgDel);
41
- if (!addRgb || !delRgb) return { ...DEFAULT_DIFF_BG };
159
+ const add = readThemeToken(() => theme.getFgAnsi("toolDiffAdded"));
160
+ const del = readThemeToken(() => theme.getFgAnsi("toolDiffRemoved"));
161
+ // Nothing readable to derive from — not a theme we can paint with.
162
+ if (add.kind === "unknown" || del.kind === "unknown") return { ...DEFAULT_DIFF_BG };
42
163
 
43
- let addBase = { r: 0, g: 0, b: 0 };
44
- let delBase = addBase;
45
- let bgBase = "\x1b[49m"; // BG_DEFAULT
164
+ let addBase: RGB = { r: 0, g: 0, b: 0 };
165
+ let delBase: RGB = addBase;
166
+ let bgBase = BG_DEFAULT; // no base color: the terminal's own
46
167
 
47
168
  if (theme.getBgAnsi) {
48
- try {
49
- const successBgAnsi = theme.getBgAnsi("toolSuccessBg");
50
- const successParsed = parseAnsiRgb(successBgAnsi);
51
- if (successParsed) {
52
- addBase = successParsed;
53
- delBase = successParsed;
54
- bgBase = successBgAnsi;
55
- }
56
- } catch { /* no toolSuccessBg */ }
57
-
58
- try {
59
- const errorParsed = parseAnsiRgb(theme.getBgAnsi("toolErrorBg"));
60
- if (errorParsed) delBase = errorParsed;
61
- } catch { /* no toolErrorBg */ }
169
+ const success = readThemeToken(() => theme.getBgAnsi("toolSuccessBg"));
170
+ // A terminal-default panel background is *no base color*: leave the
171
+ // mix at its neutral start rather than inventing one.
172
+ if (success.kind === "color") {
173
+ addBase = success.rgb;
174
+ delBase = success.rgb;
175
+ bgBase = success.ansi;
176
+ }
177
+ const error = readThemeToken(() => theme.getBgAnsi("toolErrorBg"));
178
+ if (error.kind === "color") delBase = error.rgb;
62
179
  }
63
180
 
64
- const rst = bgBase === "\x1b[49m" ? "\x1b[0m" : `\x1b[0m${bgBase}`;
181
+ // A side whose accent token is terminal default gets no background at
182
+ // all — used to be the hardcoded `DEFAULT_DIFF_BG` slab, which is why a
183
+ // palette terminal showed fixed dark green/red whatever the theme said.
184
+ const tint = (base: RGB, accent: TokenColor, intensity: number): string =>
185
+ accent.kind === "color" ? mixBg(base, accent.rgb, intensity) : BG_DEFAULT;
186
+
187
+ const rst = bgBase === BG_DEFAULT ? "\x1b[0m" : `\x1b[0m${bgBase}`;
65
188
  return {
66
- bgAdd: mixBg(addBase, addRgb, 0.08),
67
- bgDel: mixBg(delBase, delRgb, 0.1),
68
- bgAddW: mixBg(addBase, addRgb, 0.2),
69
- bgDelW: mixBg(delBase, delRgb, 0.22),
70
- bgGutterAdd: mixBg(addBase, addRgb, 0.05),
71
- bgGutterDel: mixBg(delBase, delRgb, 0.06),
189
+ bgAdd: tint(addBase, add, 0.08),
190
+ bgDel: tint(delBase, del, 0.1),
191
+ bgAddW: tint(addBase, add, 0.2),
192
+ bgDelW: tint(delBase, del, 0.22),
193
+ bgGutterAdd: tint(addBase, add, 0.05),
194
+ bgGutterDel: tint(delBase, del, 0.06),
72
195
  bgEmpty: bgBase,
73
196
  bgBase,
74
197
  rst,
@@ -2,12 +2,13 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import defaultExport from "./index.js";
3
3
 
4
4
  describe("diff default export (standalone factory)", () => {
5
- it("is a function that registers diff tools on session_start with a per-session theme", () => {
5
+ it("installs the diff rows once on session_start, without registering a tool", () => {
6
6
  expect(typeof defaultExport).toBe("function");
7
7
 
8
8
  const on = vi.fn();
9
9
  const registerTool = vi.fn();
10
- const pi = { on, registerTool } as any;
10
+ const registerToolRenderer = vi.fn();
11
+ const pi = { on, registerTool, registerToolRenderer } as any;
11
12
 
12
13
  defaultExport(pi);
13
14
 
@@ -15,10 +16,19 @@ describe("diff default export (standalone factory)", () => {
15
16
  expect(typeof startHandler).toBe("function");
16
17
 
17
18
  // Mirror of meta's wiring: registerDiffTools(pi, getTheme, readConfig)
18
- // runs per session with the session's ctx.theme.
19
+ // runs per session with the session's ctx.theme. Twice, to stand in for a
20
+ // `/reload` in the same process.
21
+ startHandler!({}, { ui: { theme: {} } });
19
22
  startHandler!({}, { ui: { theme: {} } });
20
23
 
21
- // write/edit tool overrides registered when the SDK loaded
22
- expect(registerTool).toHaveBeenCalled();
24
+ // Nothing is taken over: pi's own `edit`/`write` stay registered, and the
25
+ // Archimedes rows come from renderer resolution (ADR 0029).
26
+ expect(registerTool).not.toHaveBeenCalled();
27
+ // One resolver per tool, and no duplicate stacked by the second session:
28
+ // pi keeps a list of resolvers and handlers per extension.
29
+ expect(registerToolRenderer).toHaveBeenCalledTimes(2);
30
+ const events = on.mock.calls.map((c: Array<unknown>) => c[0]);
31
+ expect(events.filter((e) => e === "tool_result")).toHaveLength(2); // edit + write
32
+ expect(events.filter((e) => e === "tool_call")).toHaveLength(1); // write only
23
33
  });
24
- });
34
+ });