@heyhuynhgiabuu/pi-diff 0.1.1 → 0.1.3

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.
Files changed (4) hide show
  1. package/README.md +75 -1
  2. package/biome.json +20 -15
  3. package/package.json +50 -50
  4. package/src/index.ts +642 -175
package/src/index.ts CHANGED
@@ -28,11 +28,292 @@ import { codeToANSI } from "@shikijs/cli";
28
28
  import * as Diff from "diff";
29
29
  import type { BundledLanguage, BundledTheme } from "shiki";
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Diff Theme System — presets, auto-derive, and per-color overrides
33
+ //
34
+ // Resolution chain (per color, highest priority first):
35
+ // 1. Environment variable override (e.g. DIFF_BG_ADD="#1a3320")
36
+ // 2. diffColors.bgAdd from .pi/settings.json (explicit per-color hex)
37
+ // 3. diffTheme preset value (named preset like "midnight")
38
+ // 4. Auto-derived from pi theme fg colors (default behavior)
39
+ // 5. Hardcoded fallback
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Hex color palette for a diff theme preset. All values "#RRGGBB". */
43
+ interface DiffPreset {
44
+ name: string;
45
+ description: string;
46
+ shikiTheme?: string;
47
+ bgAdd?: string;
48
+ bgDel?: string;
49
+ bgAddHighlight?: string;
50
+ bgDelHighlight?: string;
51
+ bgGutterAdd?: string;
52
+ bgGutterDel?: string;
53
+ bgEmpty?: string;
54
+ fgAdd?: string;
55
+ fgDel?: string;
56
+ fgDim?: string;
57
+ fgLnum?: string;
58
+ fgRule?: string;
59
+ fgStripe?: string;
60
+ fgSafeMuted?: string;
61
+ }
62
+
63
+ /** User diff config read from .pi/settings.json */
64
+ interface DiffUserConfig {
65
+ diffTheme?: string;
66
+ diffColors?: Record<string, string>;
67
+ }
68
+
69
+ const DIFF_PRESETS: Record<string, DiffPreset> = {
70
+ default: {
71
+ name: "default",
72
+ description: "Original pi-diff colors — tuned for dark theme bases (~#1e1e2e)",
73
+ bgAdd: "#162620",
74
+ bgDel: "#2d1919",
75
+ bgAddHighlight: "#234b32",
76
+ bgDelHighlight: "#502323",
77
+ bgGutterAdd: "#12201a",
78
+ bgGutterDel: "#261616",
79
+ bgEmpty: "#121212",
80
+ fgDim: "#505050",
81
+ fgLnum: "#646464",
82
+ fgRule: "#323232",
83
+ fgStripe: "#282828",
84
+ fgSafeMuted: "#8b949e",
85
+ },
86
+ midnight: {
87
+ name: "midnight",
88
+ description: "Subtle tints for pure black (#000000) terminal backgrounds",
89
+ bgAdd: "#0d1a12",
90
+ bgDel: "#1a0d0d",
91
+ bgAddHighlight: "#1a3825",
92
+ bgDelHighlight: "#381a1a",
93
+ bgGutterAdd: "#091208",
94
+ bgGutterDel: "#120908",
95
+ bgEmpty: "#080808",
96
+ fgDim: "#404040",
97
+ fgLnum: "#505050",
98
+ fgRule: "#282828",
99
+ fgStripe: "#1e1e1e",
100
+ fgSafeMuted: "#8b949e",
101
+ },
102
+ subtle: {
103
+ name: "subtle",
104
+ description: "Minimal backgrounds — barely-there tints for a clean look",
105
+ bgAdd: "#081008",
106
+ bgDel: "#100808",
107
+ bgAddHighlight: "#122818",
108
+ bgDelHighlight: "#281212",
109
+ bgGutterAdd: "#060c06",
110
+ bgGutterDel: "#0c0606",
111
+ bgEmpty: "#060606",
112
+ fgDim: "#383838",
113
+ fgLnum: "#484848",
114
+ fgRule: "#242424",
115
+ fgStripe: "#181818",
116
+ fgSafeMuted: "#8b949e",
117
+ },
118
+ neon: {
119
+ name: "neon",
120
+ description: "Higher contrast backgrounds for better visibility",
121
+ bgAdd: "#1a3320",
122
+ bgDel: "#331a16",
123
+ bgAddHighlight: "#2d5c3a",
124
+ bgDelHighlight: "#5c2d2d",
125
+ bgGutterAdd: "#142818",
126
+ bgGutterDel: "#28120e",
127
+ bgEmpty: "#141414",
128
+ fgDim: "#606060",
129
+ fgLnum: "#787878",
130
+ fgRule: "#404040",
131
+ fgStripe: "#303030",
132
+ fgSafeMuted: "#9da5ae",
133
+ },
134
+ };
135
+
136
+ /** Parse 24-bit ANSI color code → RGB. Works for both fg and bg escapes. */
137
+ function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
138
+ const esc = "\u001b";
139
+ const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
140
+ return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
141
+ }
142
+
143
+ /** Convert "#RRGGBB" hex → ANSI 24-bit background escape. */
144
+ function hexToBgAnsi(hex: string): string {
145
+ if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
146
+ const r = Number.parseInt(hex.slice(1, 3), 16);
147
+ const g = Number.parseInt(hex.slice(3, 5), 16);
148
+ const b = Number.parseInt(hex.slice(5, 7), 16);
149
+ return `\x1b[48;2;${r};${g};${b}m`;
150
+ }
151
+
152
+ /** Convert "#RRGGBB" hex → ANSI 24-bit foreground escape. */
153
+ function hexToFgAnsi(hex: string): string {
154
+ if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return "";
155
+ const r = Number.parseInt(hex.slice(1, 3), 16);
156
+ const g = Number.parseInt(hex.slice(3, 5), 16);
157
+ const b = Number.parseInt(hex.slice(5, 7), 16);
158
+ return `\x1b[38;2;${r};${g};${b}m`;
159
+ }
160
+
161
+ /** Derive a muted background ANSI code from a foreground ANSI code.
162
+ * Scales the fg RGB by `intensity` (0.0–1.0) to produce a subtle tint. */
163
+ function deriveBgFromFg(fgAnsi: string, intensity: number): string {
164
+ const rgb = parseAnsiRgb(fgAnsi);
165
+ if (!rgb) return "";
166
+ const r = Math.round(rgb.r * intensity);
167
+ const g = Math.round(rgb.g * intensity);
168
+ const b = Math.round(rgb.b * intensity);
169
+ return `\x1b[48;2;${r};${g};${b}m`;
170
+ }
171
+
172
+ /** Mix an accent color into a base color at the given intensity (0.0–1.0).
173
+ * Returns an ANSI 24-bit background escape. Used to derive diff backgrounds
174
+ * that blend with the tool box background (toolSuccessBg). */
175
+ function mixBg(base: { r: number; g: number; b: number }, accent: { r: number; g: number; b: number }, intensity: number): string {
176
+ const r = Math.round(base.r + (accent.r - base.r) * intensity);
177
+ const g = Math.round(base.g + (accent.g - base.g) * intensity);
178
+ const b = Math.round(base.b + (accent.b - base.b) * intensity);
179
+ return `\x1b[48;2;${r};${g};${b}m`;
180
+ }
181
+
182
+ /** Whether auto-derive from theme is still pending (runs lazily on first render). */
183
+ let _autoDerivePending = true;
184
+
185
+ /** Whether user set explicit bg config (via preset or per-color overrides). */
186
+ let _hasExplicitBgConfig = false;
187
+
188
+ /** Auto-derive all diff background colors from the pi theme's fg diff colors.
189
+ * Reads toolSuccessBg as the base and mixes accent colors into it.
190
+ * Falls back to black (0,0,0) as base if toolSuccessBg is unavailable. */
191
+ function autoDeriveBgFromTheme(theme: any): void {
192
+ if (!theme?.getFgAnsi) return;
193
+ try {
194
+ const fgAdd = theme.getFgAnsi("toolDiffAdded");
195
+ const fgDel = theme.getFgAnsi("toolDiffRemoved");
196
+ const addRgb = parseAnsiRgb(fgAdd);
197
+ const delRgb = parseAnsiRgb(fgDel);
198
+ if (!addRgb || !delRgb) return;
199
+
200
+ // Read toolSuccessBg as the base background color
201
+ let base = { r: 0, g: 0, b: 0 };
202
+ if (theme.getBgAnsi) {
203
+ try {
204
+ const bgAnsi = theme.getBgAnsi("toolSuccessBg");
205
+ const parsed = parseAnsiRgb(bgAnsi);
206
+ if (parsed) {
207
+ base = parsed;
208
+ BG_BASE = bgAnsi;
209
+ }
210
+ } catch { /* no toolSuccessBg — use black */ }
211
+ }
212
+
213
+ // Line backgrounds — subtle accent mixed into base (8–10%)
214
+ BG_ADD = mixBg(base, addRgb, 0.08);
215
+ BG_DEL = mixBg(base, delRgb, 0.10);
216
+
217
+ // Word-level highlights — more visible (20–22%)
218
+ BG_ADD_W = mixBg(base, addRgb, 0.20);
219
+ BG_DEL_W = mixBg(base, delRgb, 0.22);
220
+
221
+ // Gutters — subtler than lines (5–6%)
222
+ BG_GUTTER_ADD = mixBg(base, addRgb, 0.05);
223
+ BG_GUTTER_DEL = mixBg(base, delRgb, 0.06);
224
+
225
+ // Empty filler and context — match the base
226
+ BG_EMPTY = BG_BASE;
227
+
228
+ // Rebuild derived constants
229
+ DIVIDER = `${FG_RULE}│${RST}`;
230
+ } catch {
231
+ // Fall back to defaults silently
232
+ }
233
+ }
234
+
235
+ /** Load diff theme config from .pi/settings.json (project-level, then global). */
236
+ function loadDiffConfig(): DiffUserConfig {
237
+ const paths = [
238
+ `${process.cwd()}/.pi/settings.json`,
239
+ `${process.env.HOME ?? ""}/.pi/settings.json`,
240
+ ];
241
+ for (const p of paths) {
242
+ try {
243
+ if (existsSync(p)) {
244
+ const raw = JSON.parse(readFileSync(p, "utf-8"));
245
+ if (raw.diffTheme || raw.diffColors) {
246
+ return { diffTheme: raw.diffTheme, diffColors: raw.diffColors };
247
+ }
248
+ }
249
+ } catch {
250
+ // skip invalid files
251
+ }
252
+ }
253
+ return {};
254
+ }
255
+
256
+ /** Apply diff palette from settings → preset → (auto-derive deferred) → defaults.
257
+ * Called once during extension initialization. */
258
+ function applyDiffPalette(): void {
259
+ const config = loadDiffConfig();
260
+
261
+ // Load preset if specified
262
+ const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
263
+ if (preset) _hasExplicitBgConfig = true;
264
+
265
+ // Per-color overrides from settings
266
+ const ov = config.diffColors ?? {};
267
+ if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true;
268
+
269
+ // Helper: apply a hex bg color if not env-overridden
270
+ const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
271
+ if (envName && process.env[envName]) return; // env override wins
272
+ const hex = ov[key] ?? presetVal;
273
+ if (hex) { const a = hexToBgAnsi(hex); if (a) set(a); }
274
+ };
275
+ // Helper: apply a hex fg color if not env-overridden
276
+ const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
277
+ if (envName && process.env[envName]) return;
278
+ const hex = ov[key] ?? presetVal;
279
+ if (hex) { const a = hexToFgAnsi(hex); if (a) set(a); }
280
+ };
281
+
282
+ // --- Apply backgrounds ---
283
+ applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => { BG_ADD = v; });
284
+ applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => { BG_DEL = v; });
285
+ applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => { BG_ADD_W = v; });
286
+ applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => { BG_DEL_W = v; });
287
+ applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v; });
288
+ applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v; });
289
+ applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => { BG_EMPTY = v; });
290
+
291
+ // --- Apply foregrounds ---
292
+ applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => { FG_ADD = v; });
293
+ applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => { FG_DEL = v; });
294
+ applyFg(null, "fgDim", preset?.fgDim, (v) => { FG_DIM = v; });
295
+ applyFg(null, "fgLnum", preset?.fgLnum, (v) => { FG_LNUM = v; });
296
+ applyFg(null, "fgRule", preset?.fgRule, (v) => { FG_RULE = v; });
297
+ applyFg(null, "fgStripe", preset?.fgStripe, (v) => { FG_STRIPE = v; });
298
+ applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => { FG_SAFE_MUTED = v; });
299
+
300
+ // --- Shiki syntax theme ---
301
+ const shiki = ov.shikiTheme ?? preset?.shikiTheme;
302
+ if (shiki) THEME = shiki as BundledTheme;
303
+
304
+ // --- Rebuild derived constants ---
305
+ DIVIDER = `${FG_RULE}│${RST}`;
306
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
307
+
308
+ // If no explicit bg config, auto-derive will run on first render
309
+ _autoDerivePending = !_hasExplicitBgConfig;
310
+ }
311
+
31
312
  // ---------------------------------------------------------------------------
32
313
  // Config
33
314
  // ---------------------------------------------------------------------------
34
315
 
35
- const THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
316
+ let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
36
317
 
37
318
  function envInt(name: string, fallback: number): number {
38
319
  const v = Number.parseInt(process.env[name] ?? "", 10);
@@ -60,30 +341,30 @@ function envBg(name: string, fallback: string): string {
60
341
  // --- Split-view thresholds ---
61
342
  // Split is preferred when there's real room. At narrow widths, a clean stacked
62
343
  // (unified) view is better than a cramped split with wrapping.
63
- const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
344
+ const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
64
345
  const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); // ≥60 code cols per side
65
- const SPLIT_MAX_WRAP_RATIO = 0.20; // if >20% lines wrap in split, fall back to stacked
66
- const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
346
+ const SPLIT_MAX_WRAP_RATIO = 0.2; // if >20% lines wrap in split, fall back to stacked
347
+ const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
67
348
 
68
349
  // --- Terminal bounds ---
69
- const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
70
- const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
350
+ const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
351
+ const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
71
352
 
72
353
  // --- Rendering limits ---
73
- const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
74
- const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
75
- const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
76
- const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
354
+ const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
355
+ const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
356
+ const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
357
+ const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
77
358
 
78
359
  // --- Word diff ---
79
- const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
360
+ const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
80
361
 
81
362
  // --- Wrapping ---
82
363
  // Adaptive: narrow terminals truncate aggressively, wide terminals allow wrapping.
83
364
  // Actual wrap rows are computed per-render via adaptiveWrapRows().
84
- const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
85
- const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
86
- const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
365
+ const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
366
+ const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
367
+ const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
87
368
 
88
369
  // ---------------------------------------------------------------------------
89
370
  // ANSI
@@ -95,24 +376,24 @@ const DIM = "\x1b[2m";
95
376
 
96
377
  // Subtle diff backgrounds — muted tones to let syntax fg shine through
97
378
  // Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
98
- const BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
99
- const BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
100
- const BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
101
- const BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
102
- const BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
103
- const BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
104
- const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
105
- const BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
379
+ let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
380
+ let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
381
+ let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
382
+ let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
383
+ let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
384
+ let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
385
+ const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
386
+ let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
106
387
 
107
388
  // Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
108
- const FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
109
- const FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
110
- const FG_DIM = "\x1b[38;2;80;80;80m";
111
- const FG_LNUM = "\x1b[38;2;100;100;100m";
112
- const FG_RULE = "\x1b[38;2;50;50;50m";
113
- const FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
389
+ let FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
390
+ let FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
391
+ let FG_DIM = "\x1b[38;2;80;80;80m";
392
+ let FG_LNUM = "\x1b[38;2;100;100;100m";
393
+ let FG_RULE = "\x1b[38;2;50;50;50m";
394
+ let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
114
395
 
115
- const FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
396
+ let FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
116
397
 
117
398
  const BORDER_BAR = "▌";
118
399
 
@@ -122,9 +403,13 @@ function stripes(w: number, _rowOffset: number): string {
122
403
  return FG_STRIPE + "╱".repeat(w) + RST;
123
404
  }
124
405
 
125
- const DIVIDER = `${FG_RULE}│${RST}`;
126
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
406
+ let DIVIDER = `${FG_RULE}│${RST}`;
407
+ const ESC_RE = "\u001b";
408
+ const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
409
+ const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
410
+ const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
127
411
  const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
412
+ let BG_BASE = BG_DEFAULT; // tool box base bg — updated from theme's toolSuccessBg
128
413
 
129
414
  // ---------------------------------------------------------------------------
130
415
  // Theme-aware diff colors
@@ -137,10 +422,27 @@ interface DiffColors {
137
422
  fgCtx: string;
138
423
  }
139
424
 
140
- const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
425
+ let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
141
426
 
142
- /** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI. */
427
+ /** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI.
428
+ * On first call with a valid theme, auto-derives bg colors if no explicit config was set.
429
+ * Always reads toolSuccessBg for BG_BASE (used for context line backgrounds). */
143
430
  function resolveDiffColors(theme?: any): DiffColors {
431
+ // Always read toolSuccessBg for BG_BASE (even with explicit config)
432
+ if (theme?.getBgAnsi && BG_BASE === BG_DEFAULT) {
433
+ try {
434
+ const bgAnsi = theme.getBgAnsi("toolSuccessBg");
435
+ const parsed = parseAnsiRgb(bgAnsi);
436
+ if (parsed) BG_BASE = bgAnsi;
437
+ } catch { /* ignore */ }
438
+ }
439
+
440
+ // Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
441
+ if (_autoDerivePending && theme?.getFgAnsi) {
442
+ autoDeriveBgFromTheme(theme);
443
+ _autoDerivePending = false;
444
+ }
445
+
144
446
  if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
145
447
  try {
146
448
  const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD;
@@ -186,16 +488,21 @@ interface ParsedDiff {
186
488
  // Utilities
187
489
  // ---------------------------------------------------------------------------
188
490
 
189
- function strip(s: string): string { return s.replace(ANSI_RE, ""); }
491
+ function strip(s: string): string {
492
+ return s.replace(ANSI_RE, "");
493
+ }
190
494
 
191
- function tabs(s: string): string { return s.replace(/\t/g, " "); }
495
+ function tabs(s: string): string {
496
+ return s.replace(/\t/g, " ");
497
+ }
192
498
 
193
499
  function termW(): number {
194
500
  // Try multiple sources — process.stdout.columns may be undefined in piped/subagent contexts
195
- const raw = process.stdout.columns
196
- || (process.stderr as any).columns
197
- || Number.parseInt(process.env.COLUMNS ?? "", 10)
198
- || DEFAULT_TERM_WIDTH;
501
+ const raw =
502
+ process.stdout.columns ||
503
+ (process.stderr as any).columns ||
504
+ Number.parseInt(process.env.COLUMNS ?? "", 10) ||
505
+ DEFAULT_TERM_WIDTH;
199
506
  return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); // -4 safety margin for pi TUI padding
200
507
  }
201
508
 
@@ -206,27 +513,39 @@ function fit(s: string, w: number): string {
206
513
  if (plain.length <= w) return s + " ".repeat(w - plain.length);
207
514
  // Truncated — show content + dim › indicator
208
515
  const showW = w > 2 ? w - 1 : w;
209
- let vis = 0, i = 0;
516
+ let vis = 0,
517
+ i = 0;
210
518
  while (i < s.length && vis < showW) {
211
- if (s[i] === "\x1b") { const e = s.indexOf("m", i); if (e !== -1) { i = e + 1; continue; } }
212
- vis++; i++;
519
+ if (s[i] === "\x1b") {
520
+ const e = s.indexOf("m", i);
521
+ if (e !== -1) {
522
+ i = e + 1;
523
+ continue;
524
+ }
525
+ }
526
+ vis++;
527
+ i++;
213
528
  }
214
- return w > 2
215
- ? s.slice(0, i) + RST + FG_DIM + "›" + RST
216
- : s.slice(0, i) + RST;
529
+ return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`;
217
530
  }
218
531
 
219
532
  /** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
220
533
  function ansiState(s: string): string {
221
- let fg = "", bg = "";
222
- const re = /\x1b\[([^m]*)m/g;
223
- let m: RegExpExecArray | null;
224
- while ((m = re.exec(s)) !== null) {
225
- const p = m[1];
226
- if (p === "0") { fg = ""; bg = ""; }
227
- else if (p === "39") { fg = ""; }
228
- else if (p.startsWith("38;")) { fg = m[0]; }
229
- else if (p.startsWith("48;")) { bg = m[0]; }
534
+ let fg = "",
535
+ bg = "";
536
+ for (const match of s.matchAll(ANSI_CAPTURE_RE)) {
537
+ const p = match[1] ?? "";
538
+ const seq = match[0] ?? "";
539
+ if (p === "0") {
540
+ fg = "";
541
+ bg = "";
542
+ } else if (p === "39") {
543
+ fg = "";
544
+ } else if (p.startsWith("38;")) {
545
+ fg = seq;
546
+ } else if (p.startsWith("48;")) {
547
+ bg = seq;
548
+ }
230
549
  }
231
550
  return bg + fg;
232
551
  }
@@ -238,14 +557,14 @@ function isLowContrastShikiFg(params: string): boolean {
238
557
  const parts = params.split(";").map(Number);
239
558
  if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
240
559
  const [, , r, g, b] = parts;
241
- const luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
560
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
242
561
  return luminance < 72;
243
562
  }
244
563
 
245
564
  function normalizeShikiContrast(ansi: string): string {
246
- return ansi.replace(/\x1b\[([0-9;]*)m/g, (seq, params: string) => (
247
- isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq
248
- ));
565
+ return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
566
+ isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq,
567
+ );
249
568
  }
250
569
 
251
570
  /** Wrap ANSI-encoded string into rows of `w` visible chars. Max `maxRows` rows; last row truncates with ›. */
@@ -258,7 +577,9 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
258
577
  }
259
578
 
260
579
  const rows: string[] = [];
261
- let row = "", vis = 0, i = 0;
580
+ let row = "",
581
+ vis = 0,
582
+ i = 0;
262
583
  let onLastRow = false;
263
584
  let effW = w;
264
585
 
@@ -272,7 +593,11 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
272
593
  // Pass through ANSI escapes
273
594
  if (s[i] === "\x1b") {
274
595
  const end = s.indexOf("m", i);
275
- if (end !== -1) { row += s.slice(i, end + 1); i = end + 1; continue; }
596
+ if (end !== -1) {
597
+ row += s.slice(i, end + 1);
598
+ i = end + 1;
599
+ continue;
600
+ }
276
601
  }
277
602
 
278
603
  // Row full
@@ -281,10 +606,17 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
281
606
  // Check if remaining string has visible chars
282
607
  let hasMore = false;
283
608
  for (let j = i; j < s.length; j++) {
284
- if (s[j] === "\x1b") { const e2 = s.indexOf("m", j); if (e2 !== -1) { j = e2; continue; } }
285
- hasMore = true; break;
609
+ if (s[j] === "\x1b") {
610
+ const e2 = s.indexOf("m", j);
611
+ if (e2 !== -1) {
612
+ j = e2;
613
+ continue;
614
+ }
615
+ }
616
+ hasMore = true;
617
+ break;
286
618
  }
287
- if (hasMore && w > 2) row += RST + FG_DIM + "›" + RST;
619
+ if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}`;
288
620
  else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST;
289
621
  rows.push(row);
290
622
  return rows;
@@ -294,10 +626,15 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
294
626
  rows.push(row + RST);
295
627
  row = state + fillBg;
296
628
  vis = 0;
297
- if (rows.length >= maxRows - 1) { onLastRow = true; effW = w > 2 ? w - 1 : w; }
629
+ if (rows.length >= maxRows - 1) {
630
+ onLastRow = true;
631
+ effW = w > 2 ? w - 1 : w;
632
+ }
298
633
  }
299
634
 
300
- row += s[i]; vis++; i++;
635
+ row += s[i];
636
+ vis++;
637
+ i++;
301
638
  }
302
639
 
303
640
  // Final row, padded
@@ -341,7 +678,7 @@ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINE
341
678
  if (!diff.lines.length) return false;
342
679
  if (tw < SPLIT_MIN_WIDTH) return false;
343
680
 
344
- const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
681
+ const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
345
682
  const half = Math.floor((tw - 1) / 2); // -1 for center divider
346
683
  const gw = nw + 5; // border + num + sign + sp + │ + sp
347
684
  const cw = Math.max(12, half - gw);
@@ -369,16 +706,43 @@ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINE
369
706
  // ---------------------------------------------------------------------------
370
707
 
371
708
  const EXT_LANG: Record<string, BundledLanguage> = {
372
- ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
373
- mjs: "javascript", cjs: "javascript",
374
- py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
375
- c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
376
- swift: "swift", kt: "kotlin",
377
- html: "html", css: "css", scss: "scss",
378
- json: "json", yaml: "yaml", yml: "yaml", toml: "toml",
379
- md: "markdown", sql: "sql", sh: "bash", bash: "bash", zsh: "bash",
380
- lua: "lua", php: "php", dart: "dart", xml: "xml",
381
- graphql: "graphql", svelte: "svelte", vue: "vue",
709
+ ts: "typescript",
710
+ tsx: "tsx",
711
+ js: "javascript",
712
+ jsx: "jsx",
713
+ mjs: "javascript",
714
+ cjs: "javascript",
715
+ py: "python",
716
+ rb: "ruby",
717
+ rs: "rust",
718
+ go: "go",
719
+ java: "java",
720
+ c: "c",
721
+ cpp: "cpp",
722
+ h: "c",
723
+ hpp: "cpp",
724
+ cs: "csharp",
725
+ swift: "swift",
726
+ kt: "kotlin",
727
+ html: "html",
728
+ css: "css",
729
+ scss: "scss",
730
+ json: "json",
731
+ yaml: "yaml",
732
+ yml: "yaml",
733
+ toml: "toml",
734
+ md: "markdown",
735
+ sql: "sql",
736
+ sh: "bash",
737
+ bash: "bash",
738
+ zsh: "bash",
739
+ lua: "lua",
740
+ php: "php",
741
+ dart: "dart",
742
+ xml: "xml",
743
+ graphql: "graphql",
744
+ svelte: "svelte",
745
+ vue: "vue",
382
746
  };
383
747
 
384
748
  function lang(fp: string): BundledLanguage | undefined {
@@ -396,7 +760,8 @@ codeToANSI("", "typescript", THEME).catch(() => {});
396
760
  const _cache = new Map<string, string[]>();
397
761
 
398
762
  function _touch(k: string, v: string[]): string[] {
399
- _cache.delete(k); _cache.set(k, v);
763
+ _cache.delete(k);
764
+ _cache.set(k, v);
400
765
  while (_cache.size > CACHE_LIMIT) {
401
766
  const first = _cache.keys().next().value;
402
767
  if (first === undefined) break;
@@ -429,7 +794,8 @@ async function hlBlock(code: string, language: BundledLanguage | undefined): Pro
429
794
  function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
430
795
  const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
431
796
  const lines: DiffLine[] = [];
432
- let added = 0, removed = 0;
797
+ let added = 0,
798
+ removed = 0;
433
799
 
434
800
  for (let hi = 0; hi < patch.hunks.length; hi++) {
435
801
  if (hi > 0) {
@@ -438,13 +804,21 @@ function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff
438
804
  lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
439
805
  }
440
806
  const h = patch.hunks[hi];
441
- let oL = h.oldStart, nL = h.newStart;
807
+ let oL = h.oldStart,
808
+ nL = h.newStart;
442
809
  for (const raw of h.lines) {
443
810
  if (raw === "\") continue;
444
- const ch = raw[0], text = raw.slice(1);
445
- if (ch === "+") { lines.push({ type: "add", oldNum: null, newNum: nL++, content: text }); added++; }
446
- else if (ch === "-") { lines.push({ type: "del", oldNum: oL++, newNum: null, content: text }); removed++; }
447
- else { lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text }); }
811
+ const ch = raw[0],
812
+ text = raw.slice(1);
813
+ if (ch === "+") {
814
+ lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
815
+ added++;
816
+ } else if (ch === "-") {
817
+ lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
818
+ removed++;
819
+ } else {
820
+ lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
821
+ }
448
822
  }
449
823
  }
450
824
  return { lines, added, removed, chars: oldContent.length + newContent.length };
@@ -464,7 +838,10 @@ function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff
464
838
  * similarity score and character ranges for emphasis highlighting.
465
839
  * Replaces separate wordDiffRanges + wordDiffSimilarity (which called diffWords twice).
466
840
  */
467
- function wordDiffAnalysis(a: string, b: string): {
841
+ function wordDiffAnalysis(
842
+ a: string,
843
+ b: string,
844
+ ): {
468
845
  similarity: number;
469
846
  oldRanges: Array<[number, number]>;
470
847
  newRanges: Array<[number, number]>;
@@ -473,11 +850,22 @@ function wordDiffAnalysis(a: string, b: string): {
473
850
  const parts = Diff.diffWords(a, b);
474
851
  const oldRanges: Array<[number, number]> = [];
475
852
  const newRanges: Array<[number, number]> = [];
476
- let oPos = 0, nPos = 0, same = 0;
853
+ let oPos = 0,
854
+ nPos = 0,
855
+ same = 0;
477
856
  for (const p of parts) {
478
- if (p.removed) { oldRanges.push([oPos, oPos + p.value.length]); oPos += p.value.length; }
479
- else if (p.added) { newRanges.push([nPos, nPos + p.value.length]); nPos += p.value.length; }
480
- else { const len = p.value.length; same += len; oPos += len; nPos += len; }
857
+ if (p.removed) {
858
+ oldRanges.push([oPos, oPos + p.value.length]);
859
+ oPos += p.value.length;
860
+ } else if (p.added) {
861
+ newRanges.push([nPos, nPos + p.value.length]);
862
+ nPos += p.value.length;
863
+ } else {
864
+ const len = p.value.length;
865
+ same += len;
866
+ oPos += len;
867
+ nPos += len;
868
+ }
481
869
  }
482
870
  const maxLen = Math.max(a.length, b.length);
483
871
  return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
@@ -490,12 +878,7 @@ function wordDiffAnalysis(a: string, b: string): {
490
878
  *
491
879
  * Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
492
880
  */
493
- function injectBg(
494
- ansiLine: string,
495
- ranges: Array<[number, number]>,
496
- baseBg: string,
497
- hlBg: string,
498
- ): string {
881
+ function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string {
499
882
  if (!ranges.length) return baseBg + ansiLine + RST;
500
883
 
501
884
  let out = baseBg;
@@ -519,9 +902,13 @@ function injectBg(
519
902
  // Advance past exhausted ranges
520
903
  while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
521
904
  const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
522
- if (want !== inHL) { inHL = want; out += inHL ? hlBg : baseBg; }
905
+ if (want !== inHL) {
906
+ inHL = want;
907
+ out += inHL ? hlBg : baseBg;
908
+ }
523
909
  out += ansiLine[i];
524
- vis++; i++;
910
+ vis++;
911
+ i++;
525
912
  }
526
913
  return out + RST;
527
914
  }
@@ -529,11 +916,15 @@ function injectBg(
529
916
  /** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
530
917
  function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
531
918
  const parts = Diff.diffWords(oldText, newText);
532
- let o = "", n = "";
919
+ let o = "",
920
+ n = "";
533
921
  for (const p of parts) {
534
922
  if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
535
923
  else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
536
- else { o += p.value; n += p.value; }
924
+ else {
925
+ o += p.value;
926
+ n += p.value;
927
+ }
537
928
  }
538
929
  return { old: o, new: n };
539
930
  }
@@ -549,18 +940,24 @@ function plainWordDiff(oldText: string, newText: string): { old: string; new: st
549
940
  // • Paired del/add lines adjacent with word-level emphasis
550
941
  // ---------------------------------------------------------------------------
551
942
 
552
- async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_RENDER_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS): Promise<string> {
943
+ async function renderUnified(
944
+ diff: ParsedDiff,
945
+ language: BundledLanguage | undefined,
946
+ max = MAX_RENDER_LINES,
947
+ dc: DiffColors = DEFAULT_DIFF_COLORS,
948
+ ): Promise<string> {
553
949
  if (!diff.lines.length) return "";
554
950
 
555
951
  const vis = diff.lines.slice(0, max);
556
952
  const tw = termW();
557
- const nw = Math.max(2, String(Math.max(...vis.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
953
+ const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
558
954
  const gw = nw + 5; // border + num + sign + sp + │ + sp
559
955
  const cw = Math.max(20, tw - gw);
560
956
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
561
957
 
562
958
  // Build separate old/new code blocks for highlighting
563
- const oldSrc: string[] = [], newSrc: string[] = [];
959
+ const oldSrc: string[] = [],
960
+ newSrc: string[] = [];
564
961
  for (const l of vis) {
565
962
  if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
566
963
  if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
@@ -569,14 +966,23 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
569
966
  ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
570
967
  : [oldSrc, newSrc];
571
968
 
572
- let oI = 0, nI = 0, idx = 0;
969
+ let oI = 0,
970
+ nI = 0,
971
+ idx = 0;
573
972
  const out: string[] = [];
574
973
  out.push(rule(tw));
575
974
 
576
975
  /** Emit a single stacked row with compact gutter + left border bar. */
577
- function emitRow(num: number | null, sign: string, gutterBg: string, signFg: string, body: string, bodyBg = ""): void {
976
+ function emitRow(
977
+ num: number | null,
978
+ sign: string,
979
+ gutterBg: string,
980
+ signFg: string,
981
+ body: string,
982
+ bodyBg = "",
983
+ ): void {
578
984
  const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
579
- const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_DEFAULT} `;
985
+ const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_BASE} `;
580
986
  const numFg = borderFg || FG_LNUM;
581
987
  const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `;
582
988
  const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `;
@@ -594,28 +1000,35 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
594
1000
  const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
595
1001
  const totalW = Math.min(tw, 72);
596
1002
  const pad = Math.max(0, totalW - label.length - 2);
597
- const half1 = Math.floor(pad / 2), half2 = pad - half1;
1003
+ const half1 = Math.floor(pad / 2),
1004
+ half2 = pad - half1;
598
1005
  out.push(`${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
599
- idx++; continue;
1006
+ idx++;
1007
+ continue;
600
1008
  }
601
1009
 
602
1010
  // Context line — dimmed, single line number
603
1011
  if (l.type === "ctx") {
604
1012
  const hl = oldHL[oI] ?? l.content;
605
- emitRow(l.newNum, " ", BG_DEFAULT, dc.fgCtx, `${BG_DEFAULT}${DIM}${hl}`, BG_DEFAULT);
606
- oI++; nI++; idx++; continue;
1013
+ emitRow(l.newNum, " ", BG_BASE, dc.fgCtx, `${BG_BASE}${DIM}${hl}`, BG_BASE);
1014
+ oI++;
1015
+ nI++;
1016
+ idx++;
1017
+ continue;
607
1018
  }
608
1019
 
609
1020
  // Collect del/add blocks
610
1021
  const dels: Array<{ l: DiffLine; hl: string }> = [];
611
1022
  while (idx < vis.length && vis[idx].type === "del") {
612
1023
  dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
613
- oI++; idx++;
1024
+ oI++;
1025
+ idx++;
614
1026
  }
615
1027
  const adds: Array<{ l: DiffLine; hl: string }> = [];
616
1028
  while (idx < vis.length && vis[idx].type === "add") {
617
1029
  adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
618
- nI++; idx++;
1030
+ nI++;
1031
+ idx++;
619
1032
  }
620
1033
 
621
1034
  // 1:1 paired → word diff emphasis
@@ -658,7 +1071,12 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
658
1071
  // Split view (auto-fallback to unified when narrow)
659
1072
  // ---------------------------------------------------------------------------
660
1073
 
661
- async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_PREVIEW_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS): Promise<string> {
1074
+ async function renderSplit(
1075
+ diff: ParsedDiff,
1076
+ language: BundledLanguage | undefined,
1077
+ max = MAX_PREVIEW_LINES,
1078
+ dc: DiffColors = DEFAULT_DIFF_COLORS,
1079
+ ): Promise<string> {
662
1080
  const tw = termW();
663
1081
  if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
664
1082
  if (!diff.lines.length) return "";
@@ -669,23 +1087,35 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
669
1087
  let i = 0;
670
1088
  while (i < diff.lines.length) {
671
1089
  const l = diff.lines[i];
672
- if (l.type === "sep" || l.type === "ctx") { rows.push({ left: l, right: l }); i++; continue; }
673
- const dels: DiffLine[] = [], adds: DiffLine[] = [];
674
- while (i < diff.lines.length && diff.lines[i].type === "del") { dels.push(diff.lines[i]); i++; }
675
- while (i < diff.lines.length && diff.lines[i].type === "add") { adds.push(diff.lines[i]); i++; }
1090
+ if (l.type === "sep" || l.type === "ctx") {
1091
+ rows.push({ left: l, right: l });
1092
+ i++;
1093
+ continue;
1094
+ }
1095
+ const dels: DiffLine[] = [],
1096
+ adds: DiffLine[] = [];
1097
+ while (i < diff.lines.length && diff.lines[i].type === "del") {
1098
+ dels.push(diff.lines[i]);
1099
+ i++;
1100
+ }
1101
+ while (i < diff.lines.length && diff.lines[i].type === "add") {
1102
+ adds.push(diff.lines[i]);
1103
+ i++;
1104
+ }
676
1105
  const n = Math.max(dels.length, adds.length);
677
1106
  for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
678
1107
  }
679
1108
 
680
1109
  const vis = rows.slice(0, max);
681
1110
  const half = Math.floor((tw - 1) / 2); // -1 for center divider
682
- const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
1111
+ const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
683
1112
  const gw = nw + 5; // border + num + sign + sp + │ + sp
684
1113
  const cw = Math.max(12, half - gw);
685
1114
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
686
1115
 
687
1116
  // Build separate code blocks per side
688
- const leftSrc: string[] = [], rightSrc: string[] = [];
1117
+ const leftSrc: string[] = [],
1118
+ rightSrc: string[] = [];
689
1119
  for (const r of vis) {
690
1120
  if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
691
1121
  if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
@@ -694,13 +1124,19 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
694
1124
  ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
695
1125
  : [leftSrc, rightSrc];
696
1126
 
697
- let lI = 0, rI = 0;
1127
+ let lI = 0,
1128
+ rI = 0;
698
1129
  let stripeRow = 0; // tracks row index for diagonal stripe offset
699
1130
 
700
1131
  // Returns { gutter, contGutter, body } for wrapping composition
701
1132
  type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
702
1133
 
703
- function half_build(line: DiffLine | null, hl: string, ranges: Array<[number, number]> | null, side: "left" | "right"): HalfResult {
1134
+ function half_build(
1135
+ line: DiffLine | null,
1136
+ hl: string,
1137
+ ranges: Array<[number, number]> | null,
1138
+ side: "left" | "right",
1139
+ ): HalfResult {
704
1140
  // Empty filler — diagonal stripes
705
1141
  if (!line) {
706
1142
  const gw2 = nw + 2; // number + sign + space before │
@@ -716,16 +1152,17 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
716
1152
  return { gutter: g, contGutter: g, bodyRows: [`${FG_DIM}${fit(label, cw)}${RST}`] };
717
1153
  }
718
1154
 
719
- const isDel = line.type === "del", isAdd = line.type === "add";
720
- const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_DEFAULT;
721
- const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_DEFAULT;
1155
+ const isDel = line.type === "del",
1156
+ isAdd = line.type === "add";
1157
+ const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE;
1158
+ const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
722
1159
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
723
1160
  const sign = isDel ? "-" : isAdd ? "+" : " ";
724
- const num = isDel ? line.oldNum : isAdd ? line.newNum : (side === "left" ? line.oldNum : line.newNum);
1161
+ const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
725
1162
 
726
1163
  // Border bar + colored line numbers for changed lines
727
1164
  const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
728
- const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_DEFAULT}`;
1165
+ const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_BASE}`;
729
1166
  const numFg = borderFg || FG_LNUM;
730
1167
 
731
1168
  let body: string;
@@ -734,7 +1171,7 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
734
1171
  } else if (isDel || isAdd) {
735
1172
  body = `${cBg}${hl}`;
736
1173
  } else {
737
- body = `${BG_DEFAULT}${DIM}${hl}`;
1174
+ body = `${BG_BASE}${DIM}${hl}`;
738
1175
  }
739
1176
 
740
1177
  const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `;
@@ -751,7 +1188,8 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
751
1188
  out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
752
1189
 
753
1190
  for (const r of vis) {
754
- const leftLine = r.left, rightLine = r.right;
1191
+ const leftLine = r.left,
1192
+ rightLine = r.right;
755
1193
  const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
756
1194
  const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
757
1195
 
@@ -764,12 +1202,13 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
764
1202
  rResult = half_build(rightLine, rhl, wd.newRanges, "right");
765
1203
  } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
766
1204
  const pwd = plainWordDiff(leftLine.content, rightLine.content);
767
- lI++; rI++;
1205
+ lI++;
1206
+ rI++;
768
1207
  lResult = half_build(leftLine, pwd.old, null, "left");
769
1208
  rResult = half_build(rightLine, pwd.new, null, "right");
770
1209
  } else {
771
- const lhl = (leftLine && leftLine.type !== "sep") ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
772
- const rhl = (rightLine && rightLine.type !== "sep") ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
1210
+ const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
1211
+ const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
773
1212
  lResult = half_build(leftLine, lhl, null, "left");
774
1213
  rResult = half_build(rightLine, rhl, null, "right");
775
1214
  }
@@ -782,7 +1221,8 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
782
1221
  const lg = row === 0 ? lResult.gutter : lResult.contGutter;
783
1222
  const rg = row === 0 ? rResult.gutter : rResult.contGutter;
784
1223
  const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
785
- const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1224
+ const rb =
1225
+ rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
786
1226
  out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
787
1227
  stripeRow++;
788
1228
  }
@@ -807,13 +1247,18 @@ export const __testing = {
807
1247
  };
808
1248
 
809
1249
  export default function diffRendererExtension(pi: any): void {
1250
+ // Apply diff theme palette from settings/presets before rendering
1251
+ applyDiffPalette();
1252
+
810
1253
  let createWriteTool: any, createEditTool: any, TextComponent: any;
811
1254
  try {
812
1255
  const sdk = require("@mariozechner/pi-coding-agent");
813
1256
  createWriteTool = sdk.createWriteTool;
814
1257
  createEditTool = sdk.createEditTool;
815
1258
  TextComponent = require("@mariozechner/pi-tui").Text;
816
- } catch { return; }
1259
+ } catch {
1260
+ return;
1261
+ }
817
1262
  if (!createWriteTool || !createEditTool || !TextComponent) return;
818
1263
 
819
1264
  const cwd = process.cwd();
@@ -833,7 +1278,11 @@ export default function diffRendererExtension(pi: any): void {
833
1278
  async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
834
1279
  const fp = params.path ?? params.file_path ?? "";
835
1280
  let old: string | null = null;
836
- try { if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8"); } catch { old = null; }
1281
+ try {
1282
+ if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
1283
+ } catch {
1284
+ old = null;
1285
+ }
837
1286
 
838
1287
  const result = await origWrite.execute(tid, params, sig, upd, ctx);
839
1288
  const content = params.content ?? "";
@@ -873,16 +1322,18 @@ export default function diffRendererExtension(pi: any): void {
873
1322
  ctx.state._previewKey = previewKey;
874
1323
  ctx.state._previewText = hdr;
875
1324
  const lg = lang(fp);
876
- hlBlock(args.content, lg).then((lines: string[]) => {
877
- if (ctx.state._previewKey !== previewKey) return;
878
- const maxShow = ctx.expanded ? lines.length : 16;
879
- const preview = lines.slice(0, maxShow).join("\n");
880
- const rem = lines.length - maxShow;
881
- let out = `${hdr}\n\n${preview}`;
882
- if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
883
- ctx.state._previewText = out;
884
- ctx.invalidate();
885
- }).catch(() => {});
1325
+ hlBlock(args.content, lg)
1326
+ .then((lines: string[]) => {
1327
+ if (ctx.state._previewKey !== previewKey) return;
1328
+ const maxShow = ctx.expanded ? lines.length : 16;
1329
+ const preview = lines.slice(0, maxShow).join("\n");
1330
+ const rem = lines.length - maxShow;
1331
+ let out = `${hdr}\n\n${preview}`;
1332
+ if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
1333
+ ctx.state._previewText = out;
1334
+ ctx.invalidate();
1335
+ })
1336
+ .catch(() => {});
886
1337
  }
887
1338
  text.setText(ctx.state._previewText ?? hdr);
888
1339
  return text;
@@ -895,7 +1346,11 @@ export default function diffRendererExtension(pi: any): void {
895
1346
  renderResult(result: any, _opt: any, theme: any, ctx: any) {
896
1347
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
897
1348
  if (ctx.isError) {
898
- const e = result.content?.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join("\n") ?? "Error";
1349
+ const e =
1350
+ result.content
1351
+ ?.filter((c: any) => c.type === "text")
1352
+ .map((c: any) => c.text || "")
1353
+ .join("\n") ?? "Error";
899
1354
  text.setText(`\n${theme.fg("error", e)}`);
900
1355
  return text;
901
1356
  }
@@ -907,15 +1362,17 @@ export default function diffRendererExtension(pi: any): void {
907
1362
  ctx.state._wdk = key;
908
1363
  ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
909
1364
  const dc = resolveDiffColors(theme);
910
- renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc).then((rendered: string) => {
911
- if (ctx.state._wdk !== key) return;
912
- ctx.state._wdt = ` ${d.summary}\n${rendered}`;
913
- ctx.invalidate();
914
- }).catch(() => {
915
- if (ctx.state._wdk !== key) return;
916
- ctx.state._wdt = ` ${d.summary}`;
917
- ctx.invalidate();
918
- });
1365
+ renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
1366
+ .then((rendered: string) => {
1367
+ if (ctx.state._wdk !== key) return;
1368
+ ctx.state._wdt = ` ${d.summary}\n${rendered}`;
1369
+ ctx.invalidate();
1370
+ })
1371
+ .catch(() => {
1372
+ if (ctx.state._wdk !== key) return;
1373
+ ctx.state._wdt = ` ${d.summary}`;
1374
+ ctx.invalidate();
1375
+ });
919
1376
  }
920
1377
  text.setText(ctx.state._wdt ?? ` ${d.summary}`);
921
1378
  return text;
@@ -932,16 +1389,18 @@ export default function diffRendererExtension(pi: any): void {
932
1389
  ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
933
1390
  const lg = lang(fp);
934
1391
  if (rawContent) {
935
- hlBlock(rawContent, lg).then((hlLines: string[]) => {
936
- if (ctx.state._nfk !== pk) return;
937
- const maxShow = ctx.expanded ? hlLines.length : 12;
938
- const preview = hlLines.slice(0, maxShow).join("\n");
939
- const rem = hlLines.length - maxShow;
940
- let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
941
- if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
942
- ctx.state._nft = out;
943
- ctx.invalidate();
944
- }).catch(() => {});
1392
+ hlBlock(rawContent, lg)
1393
+ .then((hlLines: string[]) => {
1394
+ if (ctx.state._nfk !== pk) return;
1395
+ const maxShow = ctx.expanded ? hlLines.length : 12;
1396
+ const preview = hlLines.slice(0, maxShow).join("\n");
1397
+ const rem = hlLines.length - maxShow;
1398
+ let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
1399
+ if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
1400
+ ctx.state._nft = out;
1401
+ ctx.invalidate();
1402
+ })
1403
+ .catch(() => {});
945
1404
  }
946
1405
  }
947
1406
  text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
@@ -977,7 +1436,9 @@ export default function diffRendererExtension(pi: any): void {
977
1436
  const idx = f.indexOf(newText);
978
1437
  if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
979
1438
  }
980
- } catch { editLine = 0; }
1439
+ } catch {
1440
+ editLine = 0;
1441
+ }
981
1442
  const diff = parseDiff(oldText, newText);
982
1443
  (result as any).details = { _type: "editInfo", summary: summarize(diff.added, diff.removed), editLine };
983
1444
  }
@@ -1003,17 +1464,19 @@ export default function diffRendererExtension(pi: any): void {
1003
1464
  const lg = lang(fp);
1004
1465
  const diff = parseDiff(oldText, newText);
1005
1466
  const dc = resolveDiffColors(theme);
1006
- renderSplit(diff, lg, MAX_PREVIEW_LINES, dc).then((rendered) => {
1007
- if (ctx.state._pk !== pk) return;
1008
- ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1009
- ctx.invalidate();
1010
- }).catch(() => {
1011
- if (ctx.state._pk !== pk) return;
1012
- // Fallback: plain word diff
1013
- const diff2 = parseDiff(oldText, newText);
1014
- ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
1015
- ctx.invalidate();
1016
- });
1467
+ renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
1468
+ .then((rendered) => {
1469
+ if (ctx.state._pk !== pk) return;
1470
+ ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1471
+ ctx.invalidate();
1472
+ })
1473
+ .catch(() => {
1474
+ if (ctx.state._pk !== pk) return;
1475
+ // Fallback: plain word diff
1476
+ const diff2 = parseDiff(oldText, newText);
1477
+ ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
1478
+ ctx.invalidate();
1479
+ });
1017
1480
  }
1018
1481
 
1019
1482
  text.setText(ctx.state._pt ?? hdr);
@@ -1023,7 +1486,11 @@ export default function diffRendererExtension(pi: any): void {
1023
1486
  renderResult(result: any, _opt: any, theme: any, ctx: any) {
1024
1487
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1025
1488
  if (ctx.isError) {
1026
- const e = result.content?.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join("\n") ?? "Error";
1489
+ const e =
1490
+ result.content
1491
+ ?.filter((c: any) => c.type === "text")
1492
+ .map((c: any) => c.text || "")
1493
+ .join("\n") ?? "Error";
1027
1494
  text.setText(`\n${theme.fg("error", e)}`);
1028
1495
  return text;
1029
1496
  }