@xynogen/pix-pretty 1.7.21 → 1.7.24

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.21",
3
+ "version": "1.7.24",
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,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as ansi from "./ansi.ts";
3
+ import { resolveBaseBackground } from "./ansi.ts";
4
+
5
+ describe("tool surfaces", () => {
6
+ test("always preserves terminal background", () => {
7
+ resolveBaseBackground({ getBgAnsi: () => "\x1b[48;2;10;20;30m" });
8
+ expect(ansi.BG_BASE).toBe("\x1b[49m");
9
+ expect(ansi.BG_ERROR).toBe("\x1b[49m");
10
+ expect(ansi.RST).toBe("\x1b[0m");
11
+ });
12
+ });
package/src/ansi.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { BgTheme } from "./types.js";
2
-
3
1
  export let RST = "\x1b[0m";
4
2
  export const BOLD = "\x1b[1m";
5
3
 
@@ -13,37 +11,17 @@ export const FG_BLUE = "\x1b[38;2;100;140;220m";
13
11
  const FG_MUTED = "\x1b[38;2;139;148;158m";
14
12
 
15
13
  const BG_DEFAULT = "\x1b[49m";
16
- export let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolSuccessBg
17
- export let BG_ERROR = BG_DEFAULT; // tool box error bg — updated from theme's toolErrorBg
18
-
19
- /** Parse an ANSI 24-bit color escape into { r, g, b }. Handles both fg (38;2) and bg (48;2). */
20
- function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
21
- const m = ansi.match(new RegExp(`${ESC_RE}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
22
- return m ? { r: +(m[1] ?? 0), g: +(m[2] ?? 0), b: +(m[3] ?? 0) } : null;
23
- }
24
-
25
- function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
26
- try {
27
- const bgAnsi = theme.getBgAnsi?.(key);
28
- return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
29
- } catch {
30
- return null;
31
- }
14
+ export let BG_BASE = BG_DEFAULT;
15
+ export let BG_ERROR = BG_DEFAULT;
16
+
17
+ /** Tool and diff renderers always preserve the terminal background. */
18
+ export function resolveBaseBackground(_theme: unknown): void {
19
+ BG_BASE = BG_DEFAULT;
20
+ BG_ERROR = BG_DEFAULT;
21
+ RST = "\x1b[0m";
32
22
  }
33
23
 
34
- /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
35
- * Recompute on each render so runtime theme changes are respected. */
36
- export function resolveBaseBackground(theme: BgTheme | null | undefined): void {
37
- if (!theme?.getBgAnsi) return;
38
-
39
- BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
40
- BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
41
- RST = `\x1b[0m${BG_BASE}`;
42
- }
43
-
44
- const ESC_RE = "\u001b";
45
-
46
- export const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
24
+ export const ANSI_CAPTURE_RE = /\x1b\[([0-9;]*)m/g;
47
25
 
48
26
  // ---------------------------------------------------------------------------
49
27
  // Low-contrast fix (same as pi-diff)
@@ -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
+ });
@@ -3,18 +3,16 @@
3
3
  // vendored pretty extension's primitives (cli-highlight `hlBlock`, shared
4
4
  // theme-aware `RST`/`BG_BASE` from ansi.ts).
5
5
  //
6
- // Engine note: pi-diff used Shiki's codeToANSI (fg-only output). pretty's
7
- // hlBlock (cli-highlight) likewise emits only fg codes, so the bg-injection
8
- // technique below works unchanged — diff backgrounds layer underneath and
9
- // persist through fg switches.
6
+ // Engine note: pi-diff used Shiki's codeToANSI. pretty uses cli-highlight,
7
+ // shared with other pix-pretty renderers, and paints foreground tokens only.
10
8
 
11
9
  import { pixConfig } from "@xynogen/pix-data/pix-config";
12
10
  import * as Diff from "diff";
13
- import { BG_BASE, BOLD, FG_DIM, FG_LNUM, FG_RULE, RST } from "./ansi.js";
11
+ import { BG_BASE, BOLD, FG_DIM, FG_GREEN, FG_LNUM, FG_RED, FG_RULE, RST } from "./ansi.js";
14
12
  import { MAX_HL_CHARS, MAX_RENDER_LINES, WORD_DIFF_MIN_SIM } from "./config.js";
15
13
  import type { DiffLine, ParsedDiff } from "./diff.js";
16
14
  import { hlBlock } from "./highlight.js";
17
- import type { BundledLanguage } from "./types.js";
15
+ import type { BundledLanguage, FgTheme } from "./types.js";
18
16
  import { termW as utilsTermW } from "./utils.js";
19
17
 
20
18
  // ---------------------------------------------------------------------------
@@ -26,59 +24,17 @@ function envInt(name: string, fallback: number): number {
26
24
  return Number.isFinite(v) ? v : fallback;
27
25
  }
28
26
 
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
27
  // ---------------------------------------------------------------------------
48
- // Diff-specific ANSI (override via env pix.json hardcoded)
28
+ // Diff-specific ANSI. Theme-backed colors are resolved per render; these
29
+ // constants are readable fallbacks for hosts that do not expose raw theme ANSI.
49
30
  // ---------------------------------------------------------------------------
50
31
 
51
32
  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
33
  const dc = pixConfig().pretty.diff;
70
34
 
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");
35
+ // Diff add/remove share the canonical green/red from ansi.ts (single source).
36
+ const FG_ADD = FG_GREEN;
37
+ const FG_DEL = FG_RED;
82
38
  const FG_STRIPE = "\x1b[38;2;40;40;40m"; // diagonal stripes on filler cells
83
39
 
84
40
  const BORDER_BAR = "▌";
@@ -113,81 +69,61 @@ export interface DiffColors {
113
69
  fgAdd: string;
114
70
  fgDel: string;
115
71
  fgCtx: string;
72
+ /** Active Pi theme, forwarded to syntax highlighting. */
73
+ theme?: FgTheme;
74
+ bgAdd: string;
75
+ bgDel: string;
76
+ bgAddHighlight: string;
77
+ bgDelHighlight: string;
78
+ bgGutterAdd: string;
79
+ bgGutterDel: string;
116
80
  }
117
81
 
118
82
  export const DEFAULT_DIFF_COLORS: DiffColors = {
119
83
  fgAdd: FG_ADD,
120
84
  fgDel: FG_DEL,
121
85
  fgCtx: FG_DIM,
86
+ bgAdd: BG_BASE,
87
+ bgDel: BG_BASE,
88
+ bgAddHighlight: BG_BASE,
89
+ bgDelHighlight: BG_BASE,
90
+ bgGutterAdd: BG_BASE,
91
+ bgGutterDel: BG_BASE,
122
92
  };
123
93
 
124
- // --- contrast helpers -------------------------------------------------------
125
- // The gutter (line number + sign) paints the diff fg over a dark gutter bg.
126
- // A theme whose diff fg is itself dark renders the number/sign as black-on-
127
- // black. We keep the theme's hue but lift its luminance until it clears a
128
- // minimum contrast ratio against the gutter background it sits on.
129
-
130
- type Rgb = [number, number, number];
131
-
132
- function parseAnsiRgb(seq: string, kind: "38" | "48"): Rgb | null {
133
- const m = seq.match(new RegExp(`\\x1b\\[${kind};2;(\\d+);(\\d+);(\\d+)m`));
134
- if (!m) return null;
135
- return [Number(m[1]), Number(m[2]), Number(m[3])];
136
- }
137
-
138
- function relLuminance([r, g, b]: Rgb): number {
139
- const f = (c: number) => {
140
- const s = c / 255;
141
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
142
- };
143
- return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
144
- }
145
-
146
- function contrastRatio(a: Rgb, b: Rgb): number {
147
- const la = relLuminance(a);
148
- const lb = relLuminance(b);
149
- const [hi, lo] = la > lb ? [la, lb] : [lb, la];
150
- return (hi + 0.05) / (lo + 0.05);
151
- }
152
-
153
- /** Keep hue, raise lightness toward white until contrast >= min (or capped). */
154
- function ensureContrast(fg: string, bgSeq: string, min = 3): string {
155
- const rgb = parseAnsiRgb(fg, "38");
156
- const bg = parseAnsiRgb(bgSeq, "48");
157
- if (!rgb || !bg) return fg; // can't reason about it — leave theme value
158
- if (contrastRatio(rgb, bg) >= min) return fg; // already legible
159
- let [r, g, b] = rgb;
160
- for (let i = 0; i < 12 && contrastRatio([r, g, b], bg) < min; i++) {
161
- r = Math.round(r + (255 - r) * 0.25);
162
- g = Math.round(g + (255 - g) * 0.25);
163
- b = Math.round(b + (255 - b) * 0.25);
164
- }
165
- return `\x1b[38;2;${r};${g};${b}m`;
166
- }
94
+ type DiffTheme = {
95
+ fg?: FgTheme["fg"];
96
+ getFgAnsi?: (key: string) => string;
97
+ };
167
98
 
168
- /** Resolve diff fg colors from pi's theme (if it exposes getFgAnsi), falling
169
- * back to hardcoded ANSI. BG_BASE is already kept in sync by ansi.ts's
170
- * resolveBaseBackground (called from the tool renderers).
171
- *
172
- * Theme hue is preserved, but each add/del fg is contrast-checked against the
173
- * 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;
99
+ function themeFg(theme: DiffTheme, key: string, fallback: string): string {
176
100
  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
- };
101
+ return theme.getFgAnsi?.(key) || fallback;
182
102
  } catch {
183
- return DEFAULT_DIFF_COLORS;
103
+ return fallback;
184
104
  }
185
105
  }
186
106
 
107
+ export function resolveDiffColors(theme?: DiffTheme): DiffColors {
108
+ if (!theme) return DEFAULT_DIFF_COLORS;
109
+ return {
110
+ fgAdd: themeFg(theme, "toolDiffAdded", FG_ADD),
111
+ fgDel: themeFg(theme, "toolDiffRemoved", FG_DEL),
112
+ fgCtx: themeFg(theme, "toolDiffContext", FG_DIM),
113
+ theme: typeof theme.fg === "function" ? (theme as FgTheme) : undefined,
114
+ bgAdd: BG_BASE,
115
+ bgDel: BG_BASE,
116
+ bgAddHighlight: BG_BASE,
117
+ bgDelHighlight: BG_BASE,
118
+ bgGutterAdd: BG_BASE,
119
+ bgGutterDel: BG_BASE,
120
+ };
121
+ }
122
+
187
123
  /** 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}`;
124
+ export function diffThemeCacheKey(theme?: DiffTheme): string {
125
+ const { theme: _theme, ...colors } = resolveDiffColors(theme);
126
+ return `${Object.values(colors).join("|")}|${BG_BASE}`;
191
127
  }
192
128
 
193
129
  // ---------------------------------------------------------------------------
@@ -371,12 +307,27 @@ function rule(w: number): string {
371
307
  return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
372
308
  }
373
309
 
374
- /** Compact "+a -d" summary string (or "no changes"). */
310
+ /** Compact plain "+a -d" summary for persisted renderer details. */
375
311
  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}`;
312
+ const parts: string[] = [];
313
+ if (a > 0) parts.push(`+${a}`);
314
+ if (d > 0) parts.push(`-${d}`);
315
+ return parts.length ? parts.join(" ") : "no changes";
316
+ }
317
+
318
+ /** Apply active Pi theme colors to a plain diff summary at render time. */
319
+ export function renderDiffSummary(summary: string, theme: FgTheme): string {
320
+ if (summary === "no changes") return theme.fg("toolDiffContext", summary);
321
+ return summary
322
+ .split(" ")
323
+ .map((part) =>
324
+ part.startsWith("+")
325
+ ? theme.fg("toolDiffAdded", part)
326
+ : part.startsWith("-")
327
+ ? theme.fg("toolDiffRemoved", part)
328
+ : part,
329
+ )
330
+ .join(" ");
380
331
  }
381
332
 
382
333
  // ---------------------------------------------------------------------------
@@ -467,13 +418,17 @@ function injectBg(
467
418
  }
468
419
 
469
420
  /** Simple word diff (no syntax hl) — fallback when highlighting is unavailable. */
470
- function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
421
+ function plainWordDiff(
422
+ oldText: string,
423
+ newText: string,
424
+ dc: DiffColors,
425
+ ): { old: string; new: string } {
471
426
  const parts = Diff.diffWords(oldText, newText);
472
427
  let o = "";
473
428
  let n = "";
474
429
  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}`;
430
+ if (p.removed) o += `${dc.bgDelHighlight}${p.value}${RST}${dc.bgDel}`;
431
+ else if (p.added) n += `${dc.bgAddHighlight}${p.value}${RST}${dc.bgAdd}`;
477
432
  else {
478
433
  o += p.value;
479
434
  n += p.value;
@@ -548,8 +503,8 @@ export async function renderUnified(
548
503
  }
549
504
  const [oldHL, newHL] = canHL
550
505
  ? await Promise.all([
551
- hlBlock(oldSrc.join("\n"), language),
552
- hlBlock(newSrc.join("\n"), language),
506
+ hlBlock(oldSrc.join("\n"), language, dc.theme),
507
+ hlBlock(newSrc.join("\n"), language, dc.theme),
553
508
  ])
554
509
  : [oldSrc, newSrc];
555
510
 
@@ -634,42 +589,42 @@ export async function renderUnified(
634
589
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
635
590
  const del0 = at(dels, 0);
636
591
  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);
592
+ const delBody = injectBg(del0.hl, wd.oldRanges, dc.bgDel, dc.bgDelHighlight);
593
+ const addBody = injectBg(add0.hl, wd.newRanges, dc.bgAdd, dc.bgAddHighlight);
594
+ emitRow(del0.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, delBody, dc.bgDel);
595
+ emitRow(add0.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, addBody, dc.bgAdd);
641
596
  continue;
642
597
  }
643
598
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
644
599
  const del0 = at(dels, 0);
645
600
  const add0 = at(adds, 0);
646
- const pwd = plainWordDiff(del0.l.content, add0.l.content);
601
+ const pwd = plainWordDiff(del0.l.content, add0.l.content, dc);
647
602
  emitRow(
648
603
  del0.l.oldNum,
649
604
  "-",
650
- BG_GUTTER_DEL,
605
+ dc.bgGutterDel,
651
606
  `${dc.fgDel}${BOLD}`,
652
- `${BG_DEL}${pwd.old}`,
653
- BG_DEL,
607
+ `${dc.bgDel}${pwd.old}`,
608
+ dc.bgDel,
654
609
  );
655
610
  emitRow(
656
611
  add0.l.newNum,
657
612
  "+",
658
- BG_GUTTER_ADD,
613
+ dc.bgGutterAdd,
659
614
  `${dc.fgAdd}${BOLD}`,
660
- `${BG_ADD}${pwd.new}`,
661
- BG_ADD,
615
+ `${dc.bgAdd}${pwd.new}`,
616
+ dc.bgAdd,
662
617
  );
663
618
  continue;
664
619
  }
665
620
 
666
621
  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);
622
+ const body = canHL ? `${dc.bgDel}${d.hl}` : `${dc.bgDel}${d.l.content}`;
623
+ emitRow(d.l.oldNum, "-", dc.bgGutterDel, `${dc.fgDel}${BOLD}`, body, dc.bgDel);
669
624
  }
670
625
  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);
626
+ const body = canHL ? `${dc.bgAdd}${a.hl}` : `${dc.bgAdd}${a.l.content}`;
627
+ emitRow(a.l.newNum, "+", dc.bgGutterAdd, `${dc.fgAdd}${BOLD}`, body, dc.bgAdd);
673
628
  }
674
629
  }
675
630
 
@@ -740,8 +695,8 @@ export async function renderSplit(
740
695
  }
741
696
  const [leftHL, rightHL] = canHL
742
697
  ? await Promise.all([
743
- hlBlock(leftSrc.join("\n"), language),
744
- hlBlock(rightSrc.join("\n"), language),
698
+ hlBlock(leftSrc.join("\n"), language, dc.theme),
699
+ hlBlock(rightSrc.join("\n"), language, dc.theme),
745
700
  ])
746
701
  : [leftSrc, rightSrc];
747
702
 
@@ -776,8 +731,8 @@ export async function renderSplit(
776
731
 
777
732
  const isDel = line.type === "del";
778
733
  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;
734
+ const gBg = isDel ? dc.bgGutterDel : isAdd ? dc.bgGutterAdd : BG_BASE;
735
+ const cBg = isDel ? dc.bgDel : isAdd ? dc.bgAdd : BG_BASE;
781
736
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
782
737
  const sign = isDel ? "-" : isAdd ? "+" : " ";
783
738
  const num = isDel
@@ -793,7 +748,7 @@ export async function renderSplit(
793
748
 
794
749
  let body: string;
795
750
  if (ranges && ranges.length > 0) {
796
- body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
751
+ body = injectBg(hl, ranges, cBg, isDel ? dc.bgDelHighlight : dc.bgAddHighlight);
797
752
  } else if (isDel || isAdd) {
798
753
  body = `${cBg}${hl}`;
799
754
  } else {
@@ -828,7 +783,7 @@ export async function renderSplit(
828
783
  lResult = halfBuild(leftLine, lhl, wd.oldRanges, "left");
829
784
  rResult = halfBuild(rightLine, rhl, wd.newRanges, "right");
830
785
  } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
831
- const pwd = plainWordDiff(leftLine.content, rightLine.content);
786
+ const pwd = plainWordDiff(leftLine.content, rightLine.content, dc);
832
787
  lI++;
833
788
  rI++;
834
789
  lResult = halfBuild(leftLine, pwd.old, null, "left");
package/src/diff.test.ts CHANGED
@@ -1,9 +1,77 @@
1
1
  import { describe, expect, it } from "bun:test";
2
-
3
2
  import { parseDiff } from "./diff.js";
3
+ import {
4
+ diffThemeCacheKey,
5
+ renderDiffSummary,
6
+ renderUnified,
7
+ resolveDiffColors,
8
+ } from "./diff-render.js";
4
9
 
5
10
  const OLD = "line1\nline2\nline3";
6
11
  const NEW = "line1\nCHANGED\nline3";
12
+ const ANSI_RE = /\x1b\[[0-9;]*m|<\/?syntax\w+>/g;
13
+
14
+ describe("theme-derived diff rendering", () => {
15
+ const theme = {
16
+ fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
17
+ getFgAnsi: (key: string) => {
18
+ if (key === "toolDiffAdded") return "\x1b[38;2;120;210;150m";
19
+ if (key === "toolDiffRemoved") return "\x1b[38;2;230;120;130m";
20
+ if (key === "toolDiffContext") return "\x1b[38;2;130;140;150m";
21
+ return "";
22
+ },
23
+ };
24
+
25
+ it("uses semantic foregrounds without tint backgrounds", () => {
26
+ const colors = resolveDiffColors(theme);
27
+ expect(colors.fgAdd).toBe("\x1b[38;2;120;210;150m");
28
+ expect(colors.fgDel).toBe("\x1b[38;2;230;120;130m");
29
+ expect(colors.fgCtx).toBe("\x1b[38;2;130;140;150m");
30
+ for (const key of [
31
+ "bgAdd",
32
+ "bgDel",
33
+ "bgAddHighlight",
34
+ "bgDelHighlight",
35
+ "bgGutterAdd",
36
+ "bgGutterDel",
37
+ ] as const) {
38
+ expect(colors[key]).toBe("\x1b[49m");
39
+ }
40
+ });
41
+
42
+ it("includes semantic theme colors in cache identity", () => {
43
+ const changed = { ...theme, getFgAnsi: () => "\x1b[38;2;1;2;3m" };
44
+ expect(diffThemeCacheKey(theme)).not.toBe(diffThemeCacheKey(changed));
45
+ });
46
+
47
+ it("colors persisted plain summaries only at render time", () => {
48
+ expect(renderDiffSummary("+3 -2", theme)).toBe(
49
+ "<toolDiffAdded>+3</toolDiffAdded> <toolDiffRemoved>-2</toolDiffRemoved>",
50
+ );
51
+ expect(renderDiffSummary("no changes", theme)).toBe(
52
+ "<toolDiffContext>no changes</toolDiffContext>",
53
+ );
54
+ });
55
+
56
+ it("keeps the foreground-only diff style", async () => {
57
+ const rendered = await renderUnified(
58
+ parseDiff("const oldValue = 1;", "const newValue = 2;"),
59
+ "typescript",
60
+ 80,
61
+ resolveDiffColors({ ...theme, fg: (_key, text) => text }),
62
+ );
63
+ const lines = rendered.replace(ANSI_RE, "").split("\n");
64
+
65
+ expect(lines).toHaveLength(4);
66
+ expect(lines[0]).toMatch(/^─+$/);
67
+ expect(lines[1]).toMatch(/^▌\s+1- │ const oldValue = 1;\s*$/);
68
+ expect(lines[2]).toMatch(/^▌\s+1\+ │ const newValue = 2;\s*$/);
69
+ expect(lines[3]).toMatch(/^─+$/);
70
+ expect(rendered).toContain(theme.getFgAnsi("toolDiffRemoved"));
71
+ expect(rendered).toContain(theme.getFgAnsi("toolDiffAdded"));
72
+ expect(rendered).not.toMatch(/\x1b\[48(?:;[^m]*)?m/);
73
+ });
74
+ });
7
75
 
8
76
  describe("parseDiff baseLine", () => {
9
77
  it("is snippet-relative when baseLine omitted (default 0)", () => {
@@ -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,7 @@ 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 accessor exposed by Pi's theme for semantic foregrounds.
33
32
  getFgAnsi?: (key: string) => string;
34
33
  };
35
34
 
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");