@xynogen/pix-pretty 1.7.20 → 1.7.22

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 CHANGED
@@ -71,17 +71,23 @@ Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file h
71
71
  ```jsonc
72
72
  {
73
73
  "pretty": {
74
- "syntaxTheme": "monokai", // syntax-highlight theme
75
74
  "icons": "nerd", // nerd | unicode | ascii
76
- "maxPreviewLines": 50,
77
- "diffColors": true
75
+ "maxPreviewLines": 80,
76
+ "maxRenderLines": 150,
77
+ "maxHighlightChars": 80000,
78
+ "cacheLimit": 128,
79
+ "diff": {
80
+ "splitMinWidth": 150,
81
+ "splitMinCodeWidth": 60
82
+ }
78
83
  }
79
84
  }
80
85
  ```
81
86
 
87
+ Syntax highlighting, diffs, and tool surfaces use the active Pi theme. Color overrides do not live in `pix.json`.
88
+
82
89
  ### Environment Variables (override `pix.json`)
83
90
 
84
- - `PRETTY_THEME` — color theme for syntax highlighting
85
91
  - `PRETTY_MAX_HL_CHARS` — max characters to highlight (default: 80000)
86
92
  - `PRETTY_MAX_PREVIEW_LINES` — max lines in preview output
87
93
  - `PRETTY_CACHE_LIMIT` — FFF cache size limit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.7.20",
3
+ "version": "1.7.22",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -58,10 +58,10 @@
58
58
  "access": "public"
59
59
  },
60
60
  "dependencies": {
61
- "@xynogen/pix-data": "^0.3.0",
61
+ "@xynogen/pix-data": "^0.3.4",
62
62
  "cli-highlight": "^2.1.11",
63
63
  "@ff-labs/fff-node": "^0.5.2",
64
- "diff": "^7.0.0"
64
+ "diff": "^8.0.3"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@earendil-works/pi-coding-agent": "*",
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as ansi from "./ansi.ts";
3
+ import { resolveBaseBackground } from "./ansi.ts";
4
+
5
+ const bg = (r: number, g: number, b: number) => `\x1b[48;2;${r};${g};${b}m`;
6
+
7
+ describe("themed tool surfaces", () => {
8
+ test("uses semantic success and error backgrounds from the active theme", () => {
9
+ resolveBaseBackground({
10
+ getBgAnsi: (key) => {
11
+ if (key === "toolSuccessBg") return bg(10, 20, 30);
12
+ if (key === "toolErrorBg") return bg(40, 50, 60);
13
+ return "";
14
+ },
15
+ });
16
+ expect(ansi.BG_BASE).toBe(bg(10, 20, 30));
17
+ expect(ansi.BG_ERROR).toBe(bg(40, 50, 60));
18
+ expect(ansi.RST).toContain(bg(10, 20, 30));
19
+ });
20
+
21
+ test("resets stale theme backgrounds when no raw theme accessor exists", () => {
22
+ resolveBaseBackground(undefined);
23
+ expect(ansi.BG_BASE).toBe("\x1b[49m");
24
+ expect(ansi.BG_ERROR).toBe("\x1b[49m");
25
+ expect(ansi.RST).toBe("\x1b[0m");
26
+ });
27
+ });
package/src/ansi.ts CHANGED
@@ -34,9 +34,19 @@ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
34
34
  /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
35
35
  * Recompute on each render so runtime theme changes are respected. */
36
36
  export function resolveBaseBackground(theme: BgTheme | null | undefined): void {
37
- if (!theme?.getBgAnsi) return;
37
+ if (!theme?.getBgAnsi) {
38
+ BG_BASE = BG_DEFAULT;
39
+ BG_ERROR = BG_DEFAULT;
40
+ RST = "\x1b[0m";
41
+ return;
42
+ }
38
43
 
39
- BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
44
+ BG_BASE =
45
+ getThemeBgAnsi(theme, "toolSuccessBg") ??
46
+ getThemeBgAnsi(theme, "toolPendingBg") ??
47
+ getThemeBgAnsi(theme, "toolBg") ??
48
+ getThemeBgAnsi(theme, "background") ??
49
+ BG_DEFAULT;
40
50
  BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
41
51
  RST = `\x1b[0m${BG_BASE}`;
42
52
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
+ const repoRoot = dirname(dirname(packageRoot));
8
+
9
+ interface PackageManifest {
10
+ dependencies?: Record<string, string>;
11
+ overrides?: Record<string, string>;
12
+ }
13
+
14
+ function readManifest(path: string): PackageManifest {
15
+ try {
16
+ return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
17
+ } catch (cause) {
18
+ throw new Error(`Unable to read package manifest: ${path}`, { cause });
19
+ }
20
+ }
21
+
22
+ describe("dependency security floors", () => {
23
+ it("uses a jsdiff release without GHSA-73rr-hh4g-fpgx", () => {
24
+ const manifest = readManifest(join(packageRoot, "package.json"));
25
+ expect(manifest.dependencies?.diff).toBe("^8.0.3");
26
+ });
27
+
28
+ it("pins protobufjs above GHSA-j3f2-48v5-ccww", () => {
29
+ const manifest = readManifest(join(repoRoot, "package.json"));
30
+ expect(manifest.overrides?.protobufjs).toBe("7.6.5");
31
+ });
32
+ });
@@ -14,7 +14,7 @@ import { BG_BASE, BOLD, FG_DIM, FG_LNUM, FG_RULE, RST } from "./ansi.js";
14
14
  import { MAX_HL_CHARS, MAX_RENDER_LINES, WORD_DIFF_MIN_SIM } from "./config.js";
15
15
  import type { DiffLine, ParsedDiff } from "./diff.js";
16
16
  import { hlBlock } from "./highlight.js";
17
- import type { BundledLanguage } from "./types.js";
17
+ import type { BundledLanguage, FgTheme } from "./types.js";
18
18
  import { termW as utilsTermW } from "./utils.js";
19
19
 
20
20
  // ---------------------------------------------------------------------------
@@ -26,59 +26,22 @@ function envInt(name: string, fallback: number): number {
26
26
  return Number.isFinite(v) ? v : fallback;
27
27
  }
28
28
 
29
- function envFg(name: string, fallback: string): string {
30
- const hex = process.env[name];
31
- if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
32
- const r = Number.parseInt(hex.slice(1, 3), 16);
33
- const g = Number.parseInt(hex.slice(3, 5), 16);
34
- const b = Number.parseInt(hex.slice(5, 7), 16);
35
- return `\x1b[38;2;${r};${g};${b}m`;
36
- }
37
-
38
- function envBg(name: string, fallback: string): string {
39
- const hex = process.env[name];
40
- if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
41
- const r = Number.parseInt(hex.slice(1, 3), 16);
42
- const g = Number.parseInt(hex.slice(3, 5), 16);
43
- const b = Number.parseInt(hex.slice(5, 7), 16);
44
- return `\x1b[48;2;${r};${g};${b}m`;
45
- }
46
-
47
29
  // ---------------------------------------------------------------------------
48
- // Diff-specific ANSI (override via env pix.json hardcoded)
30
+ // Diff-specific ANSI. Theme-backed colors are resolved per render; these
31
+ // constants are readable fallbacks for hosts that do not expose raw theme ANSI.
49
32
  // ---------------------------------------------------------------------------
50
33
 
51
34
  const DIM = "\x1b[2m";
52
-
53
- function hexToBg(hex: string): string {
54
- if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
55
- const r = Number.parseInt(hex.slice(1, 3), 16);
56
- const g = Number.parseInt(hex.slice(3, 5), 16);
57
- const b = Number.parseInt(hex.slice(5, 7), 16);
58
- return `\x1b[48;2;${r};${g};${b}m`;
59
- }
60
-
61
- function hexToFg(hex: string): string {
62
- if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
63
- const r = Number.parseInt(hex.slice(1, 3), 16);
64
- const g = Number.parseInt(hex.slice(3, 5), 16);
65
- const b = Number.parseInt(hex.slice(5, 7), 16);
66
- return `\x1b[38;2;${r};${g};${b}m`;
67
- }
68
-
69
35
  const dc = pixConfig().pretty.diff;
70
36
 
71
- // Subtle diff backgrounds — muted tones to let syntax fg shine through.
72
- // Precedence: env → pix.json → hardcoded default
73
- const BG_ADD = envBg("DIFF_BG_ADD", hexToBg(dc.bgAdd) || "\x1b[48;2;22;38;32m");
74
- const BG_DEL = envBg("DIFF_BG_DEL", hexToBg(dc.bgDel) || "\x1b[48;2;45;25;25m");
75
- const BG_ADD_W = envBg("DIFF_BG_ADD_HL", hexToBg(dc.bgAddHighlight) || "\x1b[48;2;35;75;50m");
76
- const BG_DEL_W = envBg("DIFF_BG_DEL_HL", hexToBg(dc.bgDelHighlight) || "\x1b[48;2;80;35;35m");
77
- const BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", hexToBg(dc.bgGutterAdd) || "\x1b[48;2;18;32;26m");
78
- const BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", hexToBg(dc.bgGutterDel) || "\x1b[48;2;38;22;22m");
79
-
80
- const FG_ADD = envFg("DIFF_FG_ADD", hexToFg(dc.fgAdd) || "\x1b[38;2;100;180;120m");
81
- const FG_DEL = envFg("DIFF_FG_DEL", hexToFg(dc.fgDel) || "\x1b[38;2;200;100;100m");
37
+ const FALLBACK_BG_ADD = "\x1b[48;2;22;38;32m";
38
+ const FALLBACK_BG_DEL = "\x1b[48;2;45;25;25m";
39
+ const FALLBACK_BG_ADD_W = "\x1b[48;2;35;75;50m";
40
+ const FALLBACK_BG_DEL_W = "\x1b[48;2;80;35;35m";
41
+ const FALLBACK_BG_GUTTER_ADD = "\x1b[48;2;18;32;26m";
42
+ const FALLBACK_BG_GUTTER_DEL = "\x1b[48;2;38;22;22m";
43
+ const FG_ADD = "\x1b[38;2;100;180;120m";
44
+ const FG_DEL = "\x1b[38;2;200;100;100m";
82
45
  const FG_STRIPE = "\x1b[38;2;40;40;40m"; // diagonal stripes on filler cells
83
46
 
84
47
  const BORDER_BAR = "▌";
@@ -113,12 +76,26 @@ export interface DiffColors {
113
76
  fgAdd: string;
114
77
  fgDel: string;
115
78
  fgCtx: string;
79
+ /** Active Pi theme, forwarded to syntax highlighting. */
80
+ theme?: FgTheme;
81
+ bgAdd: string;
82
+ bgDel: string;
83
+ bgAddHighlight: string;
84
+ bgDelHighlight: string;
85
+ bgGutterAdd: string;
86
+ bgGutterDel: string;
116
87
  }
117
88
 
118
89
  export const DEFAULT_DIFF_COLORS: DiffColors = {
119
90
  fgAdd: FG_ADD,
120
91
  fgDel: FG_DEL,
121
92
  fgCtx: FG_DIM,
93
+ bgAdd: FALLBACK_BG_ADD,
94
+ bgDel: FALLBACK_BG_DEL,
95
+ bgAddHighlight: FALLBACK_BG_ADD_W,
96
+ bgDelHighlight: FALLBACK_BG_DEL_W,
97
+ bgGutterAdd: FALLBACK_BG_GUTTER_ADD,
98
+ bgGutterDel: FALLBACK_BG_GUTTER_DEL,
122
99
  };
123
100
 
124
101
  // --- contrast helpers -------------------------------------------------------
@@ -171,23 +148,53 @@ function ensureContrast(fg: string, bgSeq: string, min = 3): string {
171
148
  *
172
149
  * Theme hue is preserved, but each add/del fg is contrast-checked against the
173
150
  * gutter bg it is painted on and lifted if it would render too dark to read. */
174
- export function resolveDiffColors(theme?: { getFgAnsi?: (key: string) => string }): DiffColors {
175
- if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
151
+ type DiffTheme = {
152
+ fg?: FgTheme["fg"];
153
+ getFgAnsi?: (key: string) => string;
154
+ getBgAnsi?: (key: string) => string;
155
+ };
156
+
157
+ function themeFg(theme: DiffTheme, key: string, fallback: string): string {
176
158
  try {
177
- return {
178
- fgAdd: ensureContrast(theme.getFgAnsi("toolDiffAdded") || FG_ADD, BG_GUTTER_ADD),
179
- fgDel: ensureContrast(theme.getFgAnsi("toolDiffRemoved") || FG_DEL, BG_GUTTER_DEL),
180
- fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
181
- };
159
+ return theme.getFgAnsi?.(key) || fallback;
182
160
  } catch {
183
- return DEFAULT_DIFF_COLORS;
161
+ return fallback;
184
162
  }
185
163
  }
186
164
 
165
+ function tintedBackground(fg: string, strength: number, fallback: string): string {
166
+ const rgb = parseAnsiRgb(fg, "38");
167
+ if (!rgb) return fallback;
168
+ const [r, g, b] = rgb.map((channel) => Math.round(channel * strength)) as Rgb;
169
+ return `\x1b[48;2;${r};${g};${b}m`;
170
+ }
171
+
172
+ export function resolveDiffColors(theme?: DiffTheme): DiffColors {
173
+ if (!theme) return DEFAULT_DIFF_COLORS;
174
+ const rawFgAdd = themeFg(theme, "toolDiffAdded", FG_ADD);
175
+ const rawFgDel = themeFg(theme, "toolDiffRemoved", FG_DEL);
176
+ const bgAdd = tintedBackground(rawFgAdd, 0.2, FALLBACK_BG_ADD);
177
+ const bgDel = tintedBackground(rawFgDel, 0.2, FALLBACK_BG_DEL);
178
+ const bgGutterAdd = tintedBackground(rawFgAdd, 0.15, FALLBACK_BG_GUTTER_ADD);
179
+ const bgGutterDel = tintedBackground(rawFgDel, 0.15, FALLBACK_BG_GUTTER_DEL);
180
+ return {
181
+ fgAdd: ensureContrast(rawFgAdd, bgGutterAdd),
182
+ fgDel: ensureContrast(rawFgDel, bgGutterDel),
183
+ fgCtx: themeFg(theme, "toolDiffContext", FG_DIM),
184
+ theme: typeof theme.fg === "function" ? (theme as FgTheme) : undefined,
185
+ bgAdd,
186
+ bgDel,
187
+ bgAddHighlight: tintedBackground(rawFgAdd, 0.35, FALLBACK_BG_ADD_W),
188
+ bgDelHighlight: tintedBackground(rawFgDel, 0.35, FALLBACK_BG_DEL_W),
189
+ bgGutterAdd,
190
+ bgGutterDel,
191
+ };
192
+ }
193
+
187
194
  /** Stable cache key for the resolved diff theme colors. */
188
- export function diffThemeCacheKey(theme?: { getFgAnsi?: (key: string) => string }): string {
189
- const c = resolveDiffColors(theme);
190
- return `${c.fgAdd}|${c.fgDel}|${c.fgCtx}|${BG_BASE}`;
195
+ export function diffThemeCacheKey(theme?: DiffTheme): string {
196
+ const { theme: _theme, ...colors } = resolveDiffColors(theme);
197
+ return `${Object.values(colors).join("|")}|${BG_BASE}`;
191
198
  }
192
199
 
193
200
  // ---------------------------------------------------------------------------
@@ -371,12 +378,27 @@ function rule(w: number): string {
371
378
  return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
372
379
  }
373
380
 
374
- /** Compact "+a -d" summary string (or "no changes"). */
381
+ /** Compact plain "+a -d" summary for persisted renderer details. */
375
382
  export function summarize(a: number, d: number): string {
376
- const p: string[] = [];
377
- if (a > 0) p.push(`${FG_ADD}+${a}${RST}`);
378
- if (d > 0) p.push(`${FG_DEL}-${d}${RST}`);
379
- return p.length ? p.join(" ") : `${FG_DIM}no changes${RST}`;
383
+ const parts: string[] = [];
384
+ if (a > 0) parts.push(`+${a}`);
385
+ if (d > 0) parts.push(`-${d}`);
386
+ return parts.length ? parts.join(" ") : "no changes";
387
+ }
388
+
389
+ /** Apply active Pi theme colors to a plain diff summary at render time. */
390
+ export function renderDiffSummary(summary: string, theme: FgTheme): string {
391
+ if (summary === "no changes") return theme.fg("toolDiffContext", summary);
392
+ return summary
393
+ .split(" ")
394
+ .map((part) =>
395
+ part.startsWith("+")
396
+ ? theme.fg("toolDiffAdded", part)
397
+ : part.startsWith("-")
398
+ ? theme.fg("toolDiffRemoved", part)
399
+ : part,
400
+ )
401
+ .join(" ");
380
402
  }
381
403
 
382
404
  // ---------------------------------------------------------------------------
@@ -467,13 +489,17 @@ function injectBg(
467
489
  }
468
490
 
469
491
  /** Simple word diff (no syntax hl) — fallback when highlighting is unavailable. */
470
- function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
492
+ function plainWordDiff(
493
+ oldText: string,
494
+ newText: string,
495
+ dc: DiffColors,
496
+ ): { old: string; new: string } {
471
497
  const parts = Diff.diffWords(oldText, newText);
472
498
  let o = "";
473
499
  let n = "";
474
500
  for (const p of parts) {
475
- if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
476
- else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
501
+ if (p.removed) o += `${dc.bgDelHighlight}${p.value}${RST}${dc.bgDel}`;
502
+ else if (p.added) n += `${dc.bgAddHighlight}${p.value}${RST}${dc.bgAdd}`;
477
503
  else {
478
504
  o += p.value;
479
505
  n += p.value;
@@ -548,8 +574,8 @@ export async function renderUnified(
548
574
  }
549
575
  const [oldHL, newHL] = canHL
550
576
  ? await Promise.all([
551
- hlBlock(oldSrc.join("\n"), language),
552
- hlBlock(newSrc.join("\n"), language),
577
+ hlBlock(oldSrc.join("\n"), language, dc.theme),
578
+ hlBlock(newSrc.join("\n"), language, dc.theme),
553
579
  ])
554
580
  : [oldSrc, newSrc];
555
581
 
@@ -634,42 +660,42 @@ export async function renderUnified(
634
660
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
635
661
  const del0 = at(dels, 0);
636
662
  const add0 = at(adds, 0);
637
- const delBody = injectBg(del0.hl, wd.oldRanges, BG_DEL, BG_DEL_W);
638
- const addBody = injectBg(add0.hl, wd.newRanges, BG_ADD, BG_ADD_W);
639
- emitRow(del0.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL);
640
- emitRow(add0.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD);
663
+ const delBody = injectBg(del0.hl, wd.oldRanges, dc.bgDel, dc.bgDelHighlight);
664
+ const addBody = injectBg(add0.hl, wd.newRanges, dc.bgAdd, dc.bgAddHighlight);
665
+ emitRow(del0.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, delBody, dc.bgDel);
666
+ emitRow(add0.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, addBody, dc.bgAdd);
641
667
  continue;
642
668
  }
643
669
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
644
670
  const del0 = at(dels, 0);
645
671
  const add0 = at(adds, 0);
646
- const pwd = plainWordDiff(del0.l.content, add0.l.content);
672
+ const pwd = plainWordDiff(del0.l.content, add0.l.content, dc);
647
673
  emitRow(
648
674
  del0.l.oldNum,
649
675
  "-",
650
- BG_GUTTER_DEL,
676
+ dc.bgGutterDel,
651
677
  `${dc.fgDel}${BOLD}`,
652
- `${BG_DEL}${pwd.old}`,
653
- BG_DEL,
678
+ `${dc.bgDel}${pwd.old}`,
679
+ dc.bgDel,
654
680
  );
655
681
  emitRow(
656
682
  add0.l.newNum,
657
683
  "+",
658
- BG_GUTTER_ADD,
684
+ dc.bgGutterAdd,
659
685
  `${dc.fgAdd}${BOLD}`,
660
- `${BG_ADD}${pwd.new}`,
661
- BG_ADD,
686
+ `${dc.bgAdd}${pwd.new}`,
687
+ dc.bgAdd,
662
688
  );
663
689
  continue;
664
690
  }
665
691
 
666
692
  for (const d of dels) {
667
- const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`;
668
- emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL);
693
+ const body = canHL ? `${dc.bgDel}${d.hl}` : `${dc.bgDel}${d.l.content}`;
694
+ emitRow(d.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, body, dc.bgDel);
669
695
  }
670
696
  for (const a of adds) {
671
- const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`;
672
- emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD);
697
+ const body = canHL ? `${dc.bgAdd}${a.hl}` : `${dc.bgAdd}${a.l.content}`;
698
+ emitRow(a.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, body, dc.bgAdd);
673
699
  }
674
700
  }
675
701
 
@@ -740,8 +766,8 @@ export async function renderSplit(
740
766
  }
741
767
  const [leftHL, rightHL] = canHL
742
768
  ? await Promise.all([
743
- hlBlock(leftSrc.join("\n"), language),
744
- hlBlock(rightSrc.join("\n"), language),
769
+ hlBlock(leftSrc.join("\n"), language, dc.theme),
770
+ hlBlock(rightSrc.join("\n"), language, dc.theme),
745
771
  ])
746
772
  : [leftSrc, rightSrc];
747
773
 
@@ -776,8 +802,8 @@ export async function renderSplit(
776
802
 
777
803
  const isDel = line.type === "del";
778
804
  const isAdd = line.type === "add";
779
- const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE;
780
- const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
805
+ const gBg = isDel ? dc.bgGutterDel : isAdd ? dc.bgGutterAdd : BG_BASE;
806
+ const cBg = isDel ? dc.bgDel : isAdd ? dc.bgAdd : BG_BASE;
781
807
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
782
808
  const sign = isDel ? "-" : isAdd ? "+" : " ";
783
809
  const num = isDel
@@ -793,7 +819,7 @@ export async function renderSplit(
793
819
 
794
820
  let body: string;
795
821
  if (ranges && ranges.length > 0) {
796
- body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
822
+ body = injectBg(hl, ranges, cBg, isDel ? dc.bgDelHighlight : dc.bgAddHighlight);
797
823
  } else if (isDel || isAdd) {
798
824
  body = `${cBg}${hl}`;
799
825
  } else {
@@ -828,7 +854,7 @@ export async function renderSplit(
828
854
  lResult = halfBuild(leftLine, lhl, wd.oldRanges, "left");
829
855
  rResult = halfBuild(rightLine, rhl, wd.newRanges, "right");
830
856
  } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
831
- const pwd = plainWordDiff(leftLine.content, rightLine.content);
857
+ const pwd = plainWordDiff(leftLine.content, rightLine.content, dc);
832
858
  lI++;
833
859
  rI++;
834
860
  lResult = halfBuild(leftLine, pwd.old, null, "left");
package/src/diff.test.ts CHANGED
@@ -1,10 +1,45 @@
1
1
  import { describe, expect, it } from "bun:test";
2
-
3
2
  import { parseDiff } from "./diff.js";
3
+ import { diffThemeCacheKey, renderDiffSummary, resolveDiffColors } from "./diff-render.js";
4
4
 
5
5
  const OLD = "line1\nline2\nline3";
6
6
  const NEW = "line1\nCHANGED\nline3";
7
7
 
8
+ describe("theme-derived diff rendering", () => {
9
+ const theme = {
10
+ fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
11
+ getFgAnsi: (key: string) => {
12
+ if (key === "toolDiffAdded") return "\x1b[38;2;120;210;150m";
13
+ if (key === "toolDiffRemoved") return "\x1b[38;2;230;120;130m";
14
+ if (key === "toolDiffContext") return "\x1b[38;2;130;140;150m";
15
+ return "";
16
+ },
17
+ };
18
+
19
+ it("derives foregrounds and tint backgrounds from semantic theme tokens", () => {
20
+ const colors = resolveDiffColors(theme);
21
+ expect(colors.fgAdd).toBe("\x1b[38;2;120;210;150m");
22
+ expect(colors.fgDel).toBe("\x1b[38;2;230;120;130m");
23
+ expect(colors.fgCtx).toBe("\x1b[38;2;130;140;150m");
24
+ expect(colors.bgAdd).toBe("\x1b[48;2;24;42;30m");
25
+ expect(colors.bgDel).toBe("\x1b[48;2;46;24;26m");
26
+ });
27
+
28
+ it("includes semantic theme colors in cache identity", () => {
29
+ const changed = { ...theme, getFgAnsi: () => "\x1b[38;2;1;2;3m" };
30
+ expect(diffThemeCacheKey(theme)).not.toBe(diffThemeCacheKey(changed));
31
+ });
32
+
33
+ it("colors persisted plain summaries only at render time", () => {
34
+ expect(renderDiffSummary("+3 -2", theme)).toBe(
35
+ "<toolDiffAdded>+3</toolDiffAdded> <toolDiffRemoved>-2</toolDiffRemoved>",
36
+ );
37
+ expect(renderDiffSummary("no changes", theme)).toBe(
38
+ "<toolDiffContext>no changes</toolDiffContext>",
39
+ );
40
+ });
41
+ });
42
+
8
43
  describe("parseDiff baseLine", () => {
9
44
  it("is snippet-relative when baseLine omitted (default 0)", () => {
10
45
  const { lines } = parseDiff(OLD, NEW);
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, jest, test } from "bun:test";
2
2
  import { type OverlayUI, showOverlay } from "./gate-overlay.ts";
3
3
 
4
4
  // ── Mock host ─────────────────────────────────────────────────────────────────
@@ -240,28 +240,40 @@ function makeTimerUI(onReady?: (comp: Wired) => void): OverlayUI {
240
240
 
241
241
  describe("showOverlay — auto-deny timer", () => {
242
242
  test("expires to timeout when left untouched", async () => {
243
- const result = await showOverlay(makeTimerUI(), {
244
- mode: "confirm",
245
- title: "T",
246
- timeoutMs: 1000, // ceil → 1s, fires on first tick
247
- });
248
- expect(result.action).toBe("timeout");
243
+ jest.useFakeTimers();
244
+ try {
245
+ const pending = showOverlay(makeTimerUI(), {
246
+ mode: "confirm",
247
+ title: "T",
248
+ timeoutMs: 1000, // ceil → 1s, fires on first tick
249
+ });
250
+ jest.advanceTimersByTime(1000); // fire the auto-deny tick without a real wait
251
+ const result = await pending;
252
+ expect(result.action).toBe("timeout");
253
+ } finally {
254
+ jest.useRealTimers();
255
+ }
249
256
  });
250
257
 
251
258
  test("first keypress cancels the timer (no auto-deny)", async () => {
252
- let live: Wired | undefined;
253
- const pending = showOverlay(
254
- makeTimerUI((comp) => {
255
- live = comp;
256
- comp.handleInput(DOWN); // any key — cancels the dead-man's switch
257
- }),
258
- { mode: "confirm", title: "T", timeoutMs: 1000 },
259
- );
260
- // Wait well past the 1s window. A live timer would have resolved "timeout";
261
- // since the keypress cancelled it, the promise is still pending here.
262
- await new Promise((r) => setTimeout(r, 1300));
263
- live?.handleInput(ENTER); // now deny explicitly
264
- const result = await pending;
265
- expect(result.action).toBe("denied");
259
+ jest.useFakeTimers();
260
+ try {
261
+ let live: Wired | undefined;
262
+ const pending = showOverlay(
263
+ makeTimerUI((comp) => {
264
+ live = comp;
265
+ comp.handleInput(DOWN); // any key cancels the dead-man's switch
266
+ }),
267
+ { mode: "confirm", title: "T", timeoutMs: 1000 },
268
+ );
269
+ // Advance well past the 1s window. A live timer would have resolved
270
+ // "timeout"; the keypress cancelled it, so the promise stays pending.
271
+ jest.advanceTimersByTime(1300);
272
+ live?.handleInput(ENTER); // now deny explicitly
273
+ const result = await pending;
274
+ expect(result.action).toBe("denied");
275
+ } finally {
276
+ jest.useRealTimers();
277
+ }
266
278
  });
267
279
  });
@@ -0,0 +1,26 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { _cache, clearHighlightCache, hlBlock } from "./highlight.ts";
3
+
4
+ function theme(color: string) {
5
+ return {
6
+ fg: (key: string, text: string) => `\x1b[38;2;${color}m${key}:${text}\x1b[0m`,
7
+ getFgAnsi: (key: string) => `\x1b[38;2;${color}m:${key}`,
8
+ };
9
+ }
10
+
11
+ describe("active-theme syntax highlighting", () => {
12
+ beforeEach(() => clearHighlightCache());
13
+
14
+ test("maps JSON scopes to semantic Pi syntax roles", async () => {
15
+ const out = (await hlBlock('{"name":"pix","count":2}', "json", theme("10;20;30"))).join("\n");
16
+ expect(out).toContain("syntaxVariable");
17
+ expect(out).toContain("syntaxString");
18
+ expect(out).toContain("syntaxNumber");
19
+ });
20
+
21
+ test("separates cached output by active theme colors", async () => {
22
+ await hlBlock("const value = 1", "typescript", theme("10;20;30"));
23
+ await hlBlock("const value = 1", "typescript", theme("30;40;50"));
24
+ expect(_cache.size).toBe(2);
25
+ });
26
+ });
package/src/highlight.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { normalizeShikiContrast } from "./ansi.js";
2
2
  import { CACHE_LIMIT, MAX_HL_CHARS } from "./config.js";
3
- import type { BundledLanguage } from "./types.js";
3
+ import type { BundledLanguage, FgTheme } from "./types.js";
4
4
 
5
5
  // Engine: cli-highlight (highlight.js-backed, synchronous ANSI output).
6
6
  //
@@ -63,6 +63,60 @@ function toHljsLang(language: BundledLanguage): string | undefined {
63
63
  return hl.supportsLanguage(mapped) ? mapped : undefined;
64
64
  }
65
65
 
66
+ type HighlightTheme = Record<string, (text: string) => string>;
67
+
68
+ const SYNTAX_THEME_KEYS = [
69
+ "syntaxComment",
70
+ "syntaxKeyword",
71
+ "syntaxFunction",
72
+ "syntaxVariable",
73
+ "syntaxString",
74
+ "syntaxNumber",
75
+ "syntaxType",
76
+ "syntaxOperator",
77
+ "syntaxPunctuation",
78
+ ] as const;
79
+
80
+ function highlightThemeKey(theme?: FgTheme): string {
81
+ if (!theme?.getFgAnsi) return "default";
82
+ try {
83
+ return SYNTAX_THEME_KEYS.map((key) => theme.getFgAnsi?.(key) ?? "").join("|");
84
+ } catch {
85
+ return "default";
86
+ }
87
+ }
88
+
89
+ /** Map highlight.js scopes onto the active Pi theme's semantic syntax colors. */
90
+ function buildHighlightTheme(theme?: FgTheme): HighlightTheme | undefined {
91
+ if (!theme) return undefined;
92
+ const fg = (key: string) => (text: string) => theme.fg(key, text);
93
+ return {
94
+ keyword: fg("syntaxKeyword"),
95
+ built_in: fg("syntaxType"),
96
+ literal: fg("syntaxKeyword"),
97
+ number: fg("syntaxNumber"),
98
+ regexp: fg("syntaxString"),
99
+ string: fg("syntaxString"),
100
+ comment: fg("syntaxComment"),
101
+ doctag: fg("syntaxComment"),
102
+ meta: fg("syntaxComment"),
103
+ function: fg("syntaxFunction"),
104
+ title: fg("syntaxFunction"),
105
+ class: fg("syntaxType"),
106
+ type: fg("syntaxType"),
107
+ tag: fg("syntaxPunctuation"),
108
+ name: fg("syntaxKeyword"),
109
+ attr: fg("syntaxVariable"),
110
+ attribute: fg("syntaxVariable"),
111
+ variable: fg("syntaxVariable"),
112
+ params: fg("syntaxVariable"),
113
+ operator: fg("syntaxOperator"),
114
+ punctuation: fg("syntaxPunctuation"),
115
+ addition: fg("toolDiffAdded"),
116
+ deletion: fg("toolDiffRemoved"),
117
+ };
118
+ }
119
+
66
120
  export const _cache = new Map<string, string[]>();
67
121
 
68
122
  function _touch(k: string, v: string[]): string[] {
@@ -81,6 +135,7 @@ function _touch(k: string, v: string[]): string[] {
81
135
  export async function hlBlock(
82
136
  code: string,
83
137
  language: BundledLanguage | undefined,
138
+ theme?: FgTheme,
84
139
  ): Promise<string[]> {
85
140
  if (!code) return [""];
86
141
  if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
@@ -88,7 +143,7 @@ export async function hlBlock(
88
143
  const hljsLang = toHljsLang(language);
89
144
  if (!hljsLang) return code.split("\n");
90
145
 
91
- const k = `${hljsLang}\0${code}`;
146
+ const k = `${hljsLang}\0${highlightThemeKey(theme)}\0${code}`;
92
147
  const hit = _cache.get(k);
93
148
  if (hit) return _touch(k, hit);
94
149
 
@@ -97,7 +152,11 @@ export async function hlBlock(
97
152
 
98
153
  try {
99
154
  const ansi = normalizeShikiContrast(
100
- hl.highlight(code, { language: hljsLang, ignoreIllegals: true }),
155
+ hl.highlight(code, {
156
+ language: hljsLang,
157
+ ignoreIllegals: true,
158
+ theme: buildHighlightTheme(theme),
159
+ }),
101
160
  );
102
161
  const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
103
162
  return _touch(k, out);
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { dirIcon, fileIcon } from "./icons.ts";
3
+
4
+ const theme = {
5
+ fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
6
+ };
7
+
8
+ describe("theme-derived file icons", () => {
9
+ test("uses semantic theme roles instead of embedded ANSI colors", () => {
10
+ expect(fileIcon("example.ts", theme)).toContain("<syntaxType>");
11
+ expect(fileIcon("data.json", theme)).toContain("<syntaxNumber>");
12
+ expect(fileIcon("unknown.zzz", theme)).toContain("<muted>");
13
+ });
14
+
15
+ test("themes directory icons with the active accent", () => {
16
+ expect(dirIcon(theme)).toContain("<accent>");
17
+ });
18
+ });
package/src/icons.ts CHANGED
@@ -1,120 +1,102 @@
1
1
  import { basename, extname } from "node:path";
2
-
3
- import { FG_BLUE, FG_DIM, RST } from "./ansi.js";
2
+ import type { FgTheme } from "./types.js";
4
3
 
5
4
  const ICONS_MODE = (process.env.PRETTY_ICONS ?? "nerd").toLowerCase();
6
-
7
5
  const USE_ICONS = ICONS_MODE !== "none" && ICONS_MODE !== "off";
8
6
 
9
- // Nerd Font codepoints + ANSI color per file type
10
- const NF_DIR = `${FG_BLUE}\ue5ff${RST}`; // folder
11
-
12
- const NF_DEFAULT = `${FG_DIM}\uf15b${RST}`; // generic file
13
-
14
- const EXT_ICON: Record<string, string> = {
15
- // TypeScript / JavaScript
16
- ts: `\x1b[38;2;49;120;198m\ue628${RST}`, // blue
17
- tsx: `\x1b[38;2;49;120;198m\ue7ba${RST}`, // react blue
18
- js: `\x1b[38;2;241;224;90m\ue74e${RST}`, // yellow
19
- jsx: `\x1b[38;2;97;218;251m\ue7ba${RST}`, // react cyan
20
- mjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
21
- cjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
22
-
23
- // Systems / Backend
24
- py: `\x1b[38;2;55;118;171m\ue73c${RST}`, // python blue
25
- rs: `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust orange
26
- go: `\x1b[38;2;0;173;216m\ue724${RST}`, // go cyan
27
- java: `\x1b[38;2;204;62;68m\ue738${RST}`, // java red
28
- swift: `\x1b[38;2;255;172;77m\ue755${RST}`, // swift orange
29
- rb: `\x1b[38;2;204;52;45m\ue739${RST}`, // ruby red
30
- kt: `\x1b[38;2;126;103;200m\ue634${RST}`, // kotlin purple
31
- c: `\x1b[38;2;85;154;211m\ue61e${RST}`, // c blue
32
- cpp: `\x1b[38;2;85;154;211m\ue61d${RST}`, // cpp blue
33
- h: `\x1b[38;2;140;160;185m\ue61e${RST}`, // header muted
34
- hpp: `\x1b[38;2;140;160;185m\ue61d${RST}`,
35
- cs: `\x1b[38;2;104;33;122m\ue648${RST}`, // c# purple
36
-
37
- // Web
38
- html: `\x1b[38;2;228;77;38m\ue736${RST}`, // html orange
39
- css: `\x1b[38;2;66;165;245m\ue749${RST}`, // css blue
40
- scss: `\x1b[38;2;207;100;154m\ue749${RST}`, // scss pink
41
- less: `\x1b[38;2;66;165;245m\ue749${RST}`,
42
- vue: `\x1b[38;2;65;184;131m\ue6a0${RST}`, // vue green
43
- svelte: `\x1b[38;2;255;62;0m\ue697${RST}`, // svelte red-orange
44
-
45
- // Config / Data
46
- json: `\x1b[38;2;241;224;90m\ue60b${RST}`, // json yellow
47
- jsonc: `\x1b[38;2;241;224;90m\ue60b${RST}`,
48
- yaml: `\x1b[38;2;160;116;196m\ue6a8${RST}`, // yaml purple
49
- yml: `\x1b[38;2;160;116;196m\ue6a8${RST}`,
50
- toml: `\x1b[38;2;160;116;196m\ue6b2${RST}`, // toml purple
51
- xml: `\x1b[38;2;228;77;38m\ue619${RST}`, // xml orange
52
- sql: `\x1b[38;2;218;218;218m\ue706${RST}`, // sql gray
53
-
54
- // Markdown / Docs
55
- md: `\x1b[38;2;66;165;245m\ue73e${RST}`, // markdown blue
56
- mdx: `\x1b[38;2;66;165;245m\ue73e${RST}`,
57
-
58
- // Shell / Scripts
59
- sh: `\x1b[38;2;137;180;130m\ue795${RST}`, // shell green
60
- bash: `\x1b[38;2;137;180;130m\ue795${RST}`,
61
- zsh: `\x1b[38;2;137;180;130m\ue795${RST}`,
62
- fish: `\x1b[38;2;137;180;130m\ue795${RST}`,
63
- lua: `\x1b[38;2;81;160;207m\ue620${RST}`, // lua blue
64
- php: `\x1b[38;2;137;147;186m\ue73d${RST}`, // php purple
65
- dart: `\x1b[38;2;87;182;240m\ue798${RST}`, // dart blue
66
-
67
- // Images
68
- png: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
69
- jpg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
70
- jpeg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
71
- gif: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
72
- svg: `\x1b[38;2;255;180;50m\uf1c5${RST}`,
73
- webp: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
74
- ico: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
7
+ interface IconSpec {
8
+ glyph: string;
9
+ color: string;
10
+ }
75
11
 
76
- // Misc
77
- lock: `\x1b[38;2;130;130;130m\uf023${RST}`, // lock gray
78
- env: `\x1b[38;2;241;224;90m\ue615${RST}`, // env yellow
79
- graphql: `\x1b[38;2;224;51;144m\ue662${RST}`, // graphql pink
80
- dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`,
12
+ const FILE = "\uf15b";
13
+ const EXT_ICON: Record<string, IconSpec> = {
14
+ ts: { glyph: "\ue628", color: "syntaxType" },
15
+ tsx: { glyph: "\ue7ba", color: "syntaxType" },
16
+ js: { glyph: "\ue74e", color: "syntaxNumber" },
17
+ jsx: { glyph: "\ue7ba", color: "syntaxVariable" },
18
+ mjs: { glyph: "\ue74e", color: "syntaxNumber" },
19
+ cjs: { glyph: "\ue74e", color: "syntaxNumber" },
20
+ py: { glyph: "\ue73c", color: "syntaxFunction" },
21
+ rs: { glyph: "\ue7a8", color: "syntaxType" },
22
+ go: { glyph: "\ue724", color: "syntaxVariable" },
23
+ java: { glyph: "\ue738", color: "syntaxKeyword" },
24
+ swift: { glyph: "\ue755", color: "syntaxNumber" },
25
+ rb: { glyph: "\ue739", color: "syntaxKeyword" },
26
+ kt: { glyph: "\ue634", color: "syntaxType" },
27
+ c: { glyph: "\ue61e", color: "syntaxFunction" },
28
+ cpp: { glyph: "\ue61d", color: "syntaxFunction" },
29
+ h: { glyph: "\ue61e", color: "muted" },
30
+ hpp: { glyph: "\ue61d", color: "muted" },
31
+ cs: { glyph: "\ue648", color: "syntaxType" },
32
+ html: { glyph: "\ue736", color: "syntaxKeyword" },
33
+ css: { glyph: "\ue749", color: "syntaxFunction" },
34
+ scss: { glyph: "\ue749", color: "syntaxType" },
35
+ less: { glyph: "\ue749", color: "syntaxFunction" },
36
+ vue: { glyph: "\ue6a0", color: "syntaxString" },
37
+ svelte: { glyph: "\ue697", color: "syntaxKeyword" },
38
+ json: { glyph: "\ue60b", color: "syntaxNumber" },
39
+ jsonc: { glyph: "\ue60b", color: "syntaxNumber" },
40
+ yaml: { glyph: "\ue6a8", color: "syntaxType" },
41
+ yml: { glyph: "\ue6a8", color: "syntaxType" },
42
+ toml: { glyph: "\ue6b2", color: "syntaxType" },
43
+ xml: { glyph: "\ue619", color: "syntaxKeyword" },
44
+ sql: { glyph: "\ue706", color: "text" },
45
+ md: { glyph: "\ue73e", color: "accent" },
46
+ mdx: { glyph: "\ue73e", color: "accent" },
47
+ sh: { glyph: "\ue795", color: "syntaxString" },
48
+ bash: { glyph: "\ue795", color: "syntaxString" },
49
+ zsh: { glyph: "\ue795", color: "syntaxString" },
50
+ fish: { glyph: "\ue795", color: "syntaxString" },
51
+ lua: { glyph: "\ue620", color: "syntaxFunction" },
52
+ php: { glyph: "\ue73d", color: "syntaxType" },
53
+ dart: { glyph: "\ue798", color: "syntaxFunction" },
54
+ png: { glyph: "\uf1c5", color: "syntaxType" },
55
+ jpg: { glyph: "\uf1c5", color: "syntaxType" },
56
+ jpeg: { glyph: "\uf1c5", color: "syntaxType" },
57
+ gif: { glyph: "\uf1c5", color: "syntaxType" },
58
+ svg: { glyph: "\uf1c5", color: "syntaxNumber" },
59
+ webp: { glyph: "\uf1c5", color: "syntaxType" },
60
+ ico: { glyph: "\uf1c5", color: "syntaxType" },
61
+ lock: { glyph: "\uf023", color: "muted" },
62
+ env: { glyph: "\ue615", color: "syntaxNumber" },
63
+ graphql: { glyph: "\ue662", color: "syntaxType" },
64
+ dockerfile: { glyph: "\ue7b0", color: "syntaxFunction" },
81
65
  };
82
66
 
83
- const NAME_ICON: Record<string, string> = {
84
- "package.json": `\x1b[38;2;137;180;130m\ue71e${RST}`, // npm green
85
- "package-lock.json": `\x1b[38;2;130;130;130m\ue71e${RST}`, // npm gray
86
- "tsconfig.json": `\x1b[38;2;49;120;198m\ue628${RST}`, // ts blue
87
- "biome.json": `\x1b[38;2;96;165;250m\ue615${RST}`, // config blue
88
- ".gitignore": `\x1b[38;2;222;165;132m\ue702${RST}`, // git orange
89
- ".git": `\x1b[38;2;222;165;132m\ue702${RST}`,
90
- ".env": `\x1b[38;2;241;224;90m\ue615${RST}`, // env yellow
91
- ".envrc": `\x1b[38;2;241;224;90m\ue615${RST}`,
92
- dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`, // docker blue
93
- makefile: `\x1b[38;2;130;130;130m\ue615${RST}`, // make gray
94
- gnumakefile: `\x1b[38;2;130;130;130m\ue615${RST}`,
95
- "readme.md": `\x1b[38;2;66;165;245m\ue73e${RST}`, // readme blue
96
- license: `\x1b[38;2;218;218;218m\ue60a${RST}`, // license white
97
- "cargo.toml": `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust
98
- "go.mod": `\x1b[38;2;0;173;216m\ue724${RST}`, // go
99
- "pyproject.toml": `\x1b[38;2;55;118;171m\ue73c${RST}`, // python
67
+ const NAME_ICON: Record<string, IconSpec> = {
68
+ "package.json": { glyph: "\ue71e", color: "syntaxString" },
69
+ "package-lock.json": { glyph: "\ue71e", color: "muted" },
70
+ "tsconfig.json": { glyph: "\ue628", color: "syntaxType" },
71
+ "biome.json": { glyph: "\ue615", color: "syntaxFunction" },
72
+ ".gitignore": { glyph: "\ue702", color: "syntaxType" },
73
+ ".git": { glyph: "\ue702", color: "syntaxType" },
74
+ ".env": { glyph: "\ue615", color: "syntaxNumber" },
75
+ ".envrc": { glyph: "\ue615", color: "syntaxNumber" },
76
+ dockerfile: { glyph: "\ue7b0", color: "syntaxFunction" },
77
+ makefile: { glyph: "\ue615", color: "muted" },
78
+ gnumakefile: { glyph: "\ue615", color: "muted" },
79
+ "readme.md": { glyph: "\ue73e", color: "accent" },
80
+ license: { glyph: "\ue60a", color: "text" },
81
+ "cargo.toml": { glyph: "\ue7a8", color: "syntaxType" },
82
+ "go.mod": { glyph: "\ue724", color: "syntaxVariable" },
83
+ "pyproject.toml": { glyph: "\ue73c", color: "syntaxFunction" },
100
84
  };
101
85
 
102
- export function fileIcon(fp: string): string {
86
+ function paint(spec: IconSpec, theme?: FgTheme): string {
87
+ return theme ? theme.fg(spec.color, spec.glyph) : spec.glyph;
88
+ }
89
+
90
+ /** File icon whose color is derived from the active Pi theme when available. */
91
+ export function fileIcon(fp: string, theme?: FgTheme): string {
103
92
  if (!USE_ICONS) return "";
104
93
  const base = basename(fp).toLowerCase();
105
- if (NAME_ICON[base]) return `${NAME_ICON[base]} `;
106
94
  const ext = extname(fp).slice(1).toLowerCase();
107
- return EXT_ICON[ext] ? `${EXT_ICON[ext]} ` : `${NF_DEFAULT} `;
95
+ const spec = NAME_ICON[base] ?? EXT_ICON[ext] ?? { glyph: FILE, color: "muted" };
96
+ return `${paint(spec, theme)} `;
108
97
  }
109
98
 
110
- export function dirIcon(): string {
111
- return USE_ICONS ? `${NF_DIR} ` : "";
99
+ /** Directory icon whose color is derived from the active Pi theme. */
100
+ export function dirIcon(theme?: FgTheme): string {
101
+ return USE_ICONS ? `${paint({ glyph: "\ue5ff", color: "accent" }, theme)} ` : "";
112
102
  }
113
-
114
- // ---------------------------------------------------------------------------
115
- // cli-highlight ANSI cache
116
- //
117
- // highlight.js uses different language ids than shiki for a few entries
118
- // (no tsx/jsx grammar, jsonc, mdx, make, etc.). Map the shiki-style ids the
119
- // EXT_LANG table produces onto highlight.js-supported ids.
120
- // ---------------------------------------------------------------------------
package/src/renderers.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { getLsStyle } from "@xynogen/pix-data/pix-config";
3
3
 
4
- import { BOLD, FG_BLUE, FG_DIM, FG_GREEN, FG_RED, FG_RULE, FG_YELLOW, RST } from "./ansi.js";
4
+ import { FG_DIM, FG_RULE, RST } from "./ansi.js";
5
5
  import { MAX_PREVIEW_LINES } from "./config.js";
6
6
  import { hlBlock } from "./highlight.js";
7
7
  import { dirIcon, fileIcon } from "./icons.js";
8
8
  import { lang } from "./lang.js";
9
+ import type { FgTheme } from "./types.js";
9
10
  import { lnum, normalizeLineEndings, pluralize, rule, termW } from "./utils.js";
10
11
 
11
12
  /** Render syntax-highlighted file content with line numbers. */
@@ -14,13 +15,14 @@ export async function renderFileContent(
14
15
  filePath: string,
15
16
  offset = 1,
16
17
  maxLines = MAX_PREVIEW_LINES,
18
+ theme?: FgTheme,
17
19
  ): Promise<string> {
18
20
  const normalizedContent = normalizeLineEndings(content);
19
21
  const lines = normalizedContent.split("\n");
20
22
  const total = lines.length;
21
23
  const show = lines.slice(0, maxLines);
22
24
  const lg = lang(filePath);
23
- const hl = await hlBlock(show.join("\n"), lg);
25
+ const hl = await hlBlock(show.join("\n"), lg, theme);
24
26
 
25
27
  const tw = termW();
26
28
  const startLine = offset;
@@ -50,14 +52,13 @@ export async function renderFileContent(
50
52
  export function renderBashOutput(
51
53
  text: string,
52
54
  exitCode: number | null,
55
+ theme?: FgTheme,
53
56
  ): { summary: string; body: string } {
54
57
  const isOk = exitCode === 0;
55
- const statusFg = isOk ? FG_GREEN : FG_RED;
56
58
  const statusIcon = isOk ? "✓" : "✗";
57
- const codeStr =
58
- exitCode !== null
59
- ? `${statusFg}${statusIcon} exit ${exitCode}${RST}`
60
- : `${FG_YELLOW}⚡ killed${RST}`;
59
+ const semantic = isOk ? "success" : "error";
60
+ const codeText = exitCode !== null ? `${statusIcon} exit ${exitCode}` : "⚡ killed";
61
+ const codeStr = theme ? theme.fg(exitCode !== null ? semantic : "warning", codeText) : codeText;
61
62
 
62
63
  const lines = text.split("\n");
63
64
  const maxShow = MAX_PREVIEW_LINES;
@@ -73,12 +74,14 @@ export function renderBashOutput(
73
74
  }
74
75
 
75
76
  /** Render ls output using the configured style (grid or tree). */
76
- export function renderTree(text: string, basePath: string): string {
77
- return getLsStyle() === "tree" ? renderLsTree(text, basePath) : renderLsGrid(text, basePath);
77
+ export function renderTree(text: string, basePath: string, theme?: FgTheme): string {
78
+ return getLsStyle() === "tree"
79
+ ? renderLsTree(text, basePath, theme)
80
+ : renderLsGrid(text, basePath, theme);
78
81
  }
79
82
 
80
83
  /** Vertical tree view with connectors and icons. */
81
- function renderLsTree(text: string, _basePath: string): string {
84
+ function renderLsTree(text: string, _basePath: string, theme?: FgTheme): string {
82
85
  const lines = text.trim().split("\n").filter(Boolean);
83
86
  if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
84
87
 
@@ -94,11 +97,10 @@ function renderLsTree(text: string, _basePath: string): string {
94
97
 
95
98
  const isDir = entry.endsWith("/");
96
99
  const name = isDir ? entry.slice(0, -1) : entry;
97
- const icon = isDir ? dirIcon() : fileIcon(name);
98
- const fg = isDir ? FG_BLUE + BOLD : "";
99
- const reset = isDir ? RST : "";
100
+ const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
101
+ const displayName = isDir && theme ? theme.fg("accent", name) : name;
100
102
 
101
- out.push(`${connector}${icon}${fg}${name}${reset}`);
103
+ out.push(`${connector}${icon}${displayName}`);
102
104
  }
103
105
 
104
106
  if (total > MAX_PREVIEW_LINES) {
@@ -111,7 +113,7 @@ function renderLsTree(text: string, _basePath: string): string {
111
113
  }
112
114
 
113
115
  /** Horizontal grid with icons (like eza/ls). */
114
- function renderLsGrid(text: string, _basePath: string): string {
116
+ function renderLsGrid(text: string, _basePath: string, theme?: FgTheme): string {
115
117
  const lines = text.trim().split("\n").filter(Boolean);
116
118
  if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
117
119
 
@@ -126,10 +128,9 @@ function renderLsGrid(text: string, _basePath: string): string {
126
128
  const entry = raw.trim();
127
129
  const isDir = entry.endsWith("/");
128
130
  const name = isDir ? entry.slice(0, -1) : entry;
129
- const icon = isDir ? dirIcon() : fileIcon(name);
130
- const fg = isDir ? FG_BLUE + BOLD : "";
131
- const reset = isDir ? RST : "";
132
- const cell = `${icon}${fg}${name}${reset}`;
131
+ const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
132
+ const displayName = isDir && theme ? theme.fg("accent", name) : name;
133
+ const cell = `${icon}${displayName}`;
133
134
  cells.push(cell);
134
135
  cellWidths.push(visibleWidth(cell));
135
136
  }
@@ -14,4 +14,6 @@ export interface ToolContext {
14
14
  fffState: FffState;
15
15
  /** FFF cursor store */
16
16
  cursorStore: CursorStore;
17
+ /** Optional terminal-width override — used by tests to avoid process-global mutation. */
18
+ terminalWidth?: () => number;
17
19
  }
package/src/types.ts CHANGED
@@ -28,8 +28,8 @@ export type BgTheme = { getBgAnsi?: (key: string) => string };
28
28
 
29
29
  export type FgTheme = {
30
30
  fg: (key: string, text: string) => string;
31
- // Optional raw-ANSI accessor pi's theme exposes; used by the diff renderer
32
- // to pull toolDiffAdded/Removed/Context colors. Absent on minimal themes.
31
+ // Optional raw-ANSI accessors exposed by Pi's theme. Diff foregrounds and
32
+ // generated tint backgrounds derive from these semantic theme colors.
33
33
  getFgAnsi?: (key: string) => string;
34
34
  };
35
35
 
package/src/utils.test.ts CHANGED
@@ -43,9 +43,8 @@ describe("collapsed tool rows", () => {
43
43
  expect(formatCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines")).toBe(
44
44
  "✓ read src/a.ts · 12 lines",
45
45
  );
46
- expect(plain(renderCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines"))).toContain(
47
- "✓ read src/a.ts · 12 lines",
48
- );
46
+ const rendered = plain(renderCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines"));
47
+ expect(rendered).toStartWith("✓ read src/a.ts · 12 lines");
49
48
  });
50
49
 
51
50
  it("hides only collapsed, non-expanded call rows", () => {
package/src/utils.ts CHANGED
@@ -35,14 +35,14 @@ function preserveToolBackground(ansi: string, bg: string): string {
35
35
  });
36
36
  }
37
37
 
38
- export function fillToolBackground(text: string, bg = BG_BASE): string {
39
- const width = termW();
38
+ export function fillToolBackground(text: string, bg = BG_BASE, width?: number): string {
39
+ const resolvedWidth = width ?? termW();
40
40
  return text
41
41
  .split("\n")
42
42
  .map((line) => {
43
43
  const normalized = preserveToolBackground(line, bg);
44
- const fitted = preserveToolBackground(truncateToWidth(normalized, width, ""), bg);
45
- const padding = Math.max(0, width - visibleWidth(fitted));
44
+ const fitted = preserveToolBackground(truncateToWidth(normalized, resolvedWidth, ""), bg);
45
+ const padding = Math.max(0, resolvedWidth - visibleWidth(fitted));
46
46
  return `${bg}${fitted}${" ".repeat(padding)}${RST}`;
47
47
  })
48
48
  .join("\n");
@@ -87,7 +87,7 @@ export function renderCollapsedToolRow(
87
87
  meta = "",
88
88
  status: CollapsedToolStatus = "success",
89
89
  ): string {
90
- return fillToolBackground(` ${formatCollapsedToolRow(theme, tool, target, meta, status)}`);
90
+ return fillToolBackground(formatCollapsedToolRow(theme, tool, target, meta, status));
91
91
  }
92
92
 
93
93
  /** Hide renderCall after its paired result has auto-collapsed. */