@pi-archimedes/diff 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/package.json +5 -5
- package/src/ansi/codes.test.ts +27 -3
- package/src/ansi/codes.ts +30 -10
- package/src/ansi/colors.test.ts +189 -7
- package/src/ansi/colors.ts +160 -37
- package/src/default-export.test.ts +16 -6
- package/src/index.ts +64 -23
- package/src/log.test.ts +68 -0
- package/src/log.ts +34 -0
- package/src/pi-support.test.ts +113 -0
- package/src/pi-support.ts +98 -0
- package/src/register-diff-tools.test.ts +81 -0
- package/src/tools/edit.test.ts +215 -0
- package/src/tools/edit.ts +97 -61
- package/src/tools/index.ts +8 -3
- package/src/tools/register.test.ts +265 -0
- package/src/tools/write.test.ts +161 -0
- package/src/tools/write.ts +99 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/diff",
|
|
3
|
-
"version": "2.
|
|
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": ">=
|
|
28
|
-
"@earendil-works/pi-tui": ">=
|
|
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": "
|
|
32
|
-
"@earendil-works/pi-tui": "
|
|
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
|
},
|
package/src/ansi/codes.test.ts
CHANGED
|
@@ -212,16 +212,40 @@ describe("resolveDiffColors", () => {
|
|
|
212
212
|
expect(result.fgCtx).toBe("\x1b[38;2;100;100;100m");
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it("
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
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
|
}
|
package/src/ansi/colors.test.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import * as fc from "fast-check";
|
|
3
|
-
import {
|
|
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
|
-
//
|
|
216
|
-
|
|
217
|
-
const
|
|
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(
|
|
234
|
+
].every(isLegal);
|
|
225
235
|
const isDefault = result === DEFAULT_DIFF_BG || result.bgAdd === DEFAULT_DIFF_BG.bgAdd;
|
|
226
|
-
return
|
|
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(
|
package/src/ansi/colors.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
20
|
-
accent:
|
|
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
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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:
|
|
67
|
-
bgDel:
|
|
68
|
-
bgAddW:
|
|
69
|
-
bgDelW:
|
|
70
|
-
bgGutterAdd:
|
|
71
|
-
bgGutterDel:
|
|
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("
|
|
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
|
|
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
|
|
22
|
-
|
|
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
|
+
});
|