@heyhuynhgiabuu/pi-diff 0.1.1 → 0.1.2

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 +608 -169
package/src/index.ts CHANGED
@@ -28,11 +28,275 @@ 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
+ /** Whether auto-derive from theme is still pending (runs lazily on first render). */
173
+ let _autoDerivePending = true;
174
+
175
+ /** Whether user set explicit bg config (via preset or per-color overrides). */
176
+ let _hasExplicitBgConfig = false;
177
+
178
+ /** Auto-derive all diff background colors from the pi theme's fg diff colors.
179
+ * Uses different intensity levels for line bg, word highlights, and gutters. */
180
+ function autoDeriveBgFromTheme(theme: any): void {
181
+ if (!theme?.getFgAnsi) return;
182
+ try {
183
+ const fgAdd = theme.getFgAnsi("toolDiffAdded");
184
+ const fgDel = theme.getFgAnsi("toolDiffRemoved");
185
+
186
+ // Line backgrounds — subtle tint (8–10% of fg color)
187
+ const bgAdd = deriveBgFromFg(fgAdd, 0.08);
188
+ if (bgAdd) BG_ADD = bgAdd;
189
+ const bgDel = deriveBgFromFg(fgDel, 0.10);
190
+ if (bgDel) BG_DEL = bgDel;
191
+
192
+ // Word-level highlights — more visible (20–22%)
193
+ const bgAddW = deriveBgFromFg(fgAdd, 0.20);
194
+ if (bgAddW) BG_ADD_W = bgAddW;
195
+ const bgDelW = deriveBgFromFg(fgDel, 0.22);
196
+ if (bgDelW) BG_DEL_W = bgDelW;
197
+
198
+ // Gutters — subtler than lines (5–6%)
199
+ const bgGA = deriveBgFromFg(fgAdd, 0.05);
200
+ if (bgGA) BG_GUTTER_ADD = bgGA;
201
+ const bgGD = deriveBgFromFg(fgDel, 0.06);
202
+ if (bgGD) BG_GUTTER_DEL = bgGD;
203
+
204
+ // Empty filler — neutral dark from add color luminance
205
+ const addRgb = parseAnsiRgb(fgAdd);
206
+ if (addRgb) {
207
+ const lum = Math.round((addRgb.r * 0.3 + addRgb.g * 0.6 + addRgb.b * 0.1) * 0.04);
208
+ BG_EMPTY = `\x1b[48;2;${lum};${lum};${lum}m`;
209
+ }
210
+
211
+ // Rebuild derived constants
212
+ DIVIDER = `${FG_RULE}│${RST}`;
213
+ } catch {
214
+ // Fall back to defaults silently
215
+ }
216
+ }
217
+
218
+ /** Load diff theme config from .pi/settings.json (project-level, then global). */
219
+ function loadDiffConfig(): DiffUserConfig {
220
+ const paths = [
221
+ `${process.cwd()}/.pi/settings.json`,
222
+ `${process.env.HOME ?? ""}/.pi/settings.json`,
223
+ ];
224
+ for (const p of paths) {
225
+ try {
226
+ if (existsSync(p)) {
227
+ const raw = JSON.parse(readFileSync(p, "utf-8"));
228
+ if (raw.diffTheme || raw.diffColors) {
229
+ return { diffTheme: raw.diffTheme, diffColors: raw.diffColors };
230
+ }
231
+ }
232
+ } catch {
233
+ // skip invalid files
234
+ }
235
+ }
236
+ return {};
237
+ }
238
+
239
+ /** Apply diff palette from settings → preset → (auto-derive deferred) → defaults.
240
+ * Called once during extension initialization. */
241
+ function applyDiffPalette(): void {
242
+ const config = loadDiffConfig();
243
+
244
+ // Load preset if specified
245
+ const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
246
+ if (preset) _hasExplicitBgConfig = true;
247
+
248
+ // Per-color overrides from settings
249
+ const ov = config.diffColors ?? {};
250
+ if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true;
251
+
252
+ // Helper: apply a hex bg color if not env-overridden
253
+ const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
254
+ if (envName && process.env[envName]) return; // env override wins
255
+ const hex = ov[key] ?? presetVal;
256
+ if (hex) { const a = hexToBgAnsi(hex); if (a) set(a); }
257
+ };
258
+ // Helper: apply a hex fg color if not env-overridden
259
+ const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
260
+ if (envName && process.env[envName]) return;
261
+ const hex = ov[key] ?? presetVal;
262
+ if (hex) { const a = hexToFgAnsi(hex); if (a) set(a); }
263
+ };
264
+
265
+ // --- Apply backgrounds ---
266
+ applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => { BG_ADD = v; });
267
+ applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => { BG_DEL = v; });
268
+ applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => { BG_ADD_W = v; });
269
+ applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => { BG_DEL_W = v; });
270
+ applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v; });
271
+ applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v; });
272
+ applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => { BG_EMPTY = v; });
273
+
274
+ // --- Apply foregrounds ---
275
+ applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => { FG_ADD = v; });
276
+ applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => { FG_DEL = v; });
277
+ applyFg(null, "fgDim", preset?.fgDim, (v) => { FG_DIM = v; });
278
+ applyFg(null, "fgLnum", preset?.fgLnum, (v) => { FG_LNUM = v; });
279
+ applyFg(null, "fgRule", preset?.fgRule, (v) => { FG_RULE = v; });
280
+ applyFg(null, "fgStripe", preset?.fgStripe, (v) => { FG_STRIPE = v; });
281
+ applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => { FG_SAFE_MUTED = v; });
282
+
283
+ // --- Shiki syntax theme ---
284
+ const shiki = ov.shikiTheme ?? preset?.shikiTheme;
285
+ if (shiki) THEME = shiki as BundledTheme;
286
+
287
+ // --- Rebuild derived constants ---
288
+ DIVIDER = `${FG_RULE}│${RST}`;
289
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
290
+
291
+ // If no explicit bg config, auto-derive will run on first render
292
+ _autoDerivePending = !_hasExplicitBgConfig;
293
+ }
294
+
31
295
  // ---------------------------------------------------------------------------
32
296
  // Config
33
297
  // ---------------------------------------------------------------------------
34
298
 
35
- const THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
299
+ let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
36
300
 
37
301
  function envInt(name: string, fallback: number): number {
38
302
  const v = Number.parseInt(process.env[name] ?? "", 10);
@@ -60,30 +324,30 @@ function envBg(name: string, fallback: string): string {
60
324
  // --- Split-view thresholds ---
61
325
  // Split is preferred when there's real room. At narrow widths, a clean stacked
62
326
  // (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
327
+ const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
64
328
  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
329
+ const SPLIT_MAX_WRAP_RATIO = 0.2; // if >20% lines wrap in split, fall back to stacked
330
+ const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
67
331
 
68
332
  // --- 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
333
+ const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
334
+ const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
71
335
 
72
336
  // --- 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
337
+ const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
338
+ const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
339
+ const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
340
+ const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
77
341
 
78
342
  // --- Word diff ---
79
- const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
343
+ const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
80
344
 
81
345
  // --- Wrapping ---
82
346
  // Adaptive: narrow terminals truncate aggressively, wide terminals allow wrapping.
83
347
  // 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)
348
+ const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
349
+ const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
350
+ const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
87
351
 
88
352
  // ---------------------------------------------------------------------------
89
353
  // ANSI
@@ -95,24 +359,24 @@ const DIM = "\x1b[2m";
95
359
 
96
360
  // Subtle diff backgrounds — muted tones to let syntax fg shine through
97
361
  // 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
362
+ let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
363
+ let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
364
+ let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
365
+ let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
366
+ let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
367
+ let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
368
+ const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
369
+ let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
106
370
 
107
371
  // 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";
372
+ let FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
373
+ let FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
374
+ let FG_DIM = "\x1b[38;2;80;80;80m";
375
+ let FG_LNUM = "\x1b[38;2;100;100;100m";
376
+ let FG_RULE = "\x1b[38;2;50;50;50m";
377
+ let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
114
378
 
115
- const FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
379
+ let FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
116
380
 
117
381
  const BORDER_BAR = "▌";
118
382
 
@@ -122,8 +386,11 @@ function stripes(w: number, _rowOffset: number): string {
122
386
  return FG_STRIPE + "╱".repeat(w) + RST;
123
387
  }
124
388
 
125
- const DIVIDER = `${FG_RULE}│${RST}`;
126
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
389
+ let DIVIDER = `${FG_RULE}│${RST}`;
390
+ const ESC_RE = "\u001b";
391
+ const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
392
+ const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
393
+ const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
127
394
  const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
128
395
 
129
396
  // ---------------------------------------------------------------------------
@@ -137,10 +404,17 @@ interface DiffColors {
137
404
  fgCtx: string;
138
405
  }
139
406
 
140
- const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
407
+ let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
141
408
 
142
- /** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI. */
409
+ /** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI.
410
+ * On first call with a valid theme, auto-derives bg colors if no explicit config was set. */
143
411
  function resolveDiffColors(theme?: any): DiffColors {
412
+ // Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
413
+ if (_autoDerivePending && theme?.getFgAnsi) {
414
+ autoDeriveBgFromTheme(theme);
415
+ _autoDerivePending = false;
416
+ }
417
+
144
418
  if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
145
419
  try {
146
420
  const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD;
@@ -186,16 +460,21 @@ interface ParsedDiff {
186
460
  // Utilities
187
461
  // ---------------------------------------------------------------------------
188
462
 
189
- function strip(s: string): string { return s.replace(ANSI_RE, ""); }
463
+ function strip(s: string): string {
464
+ return s.replace(ANSI_RE, "");
465
+ }
190
466
 
191
- function tabs(s: string): string { return s.replace(/\t/g, " "); }
467
+ function tabs(s: string): string {
468
+ return s.replace(/\t/g, " ");
469
+ }
192
470
 
193
471
  function termW(): number {
194
472
  // 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;
473
+ const raw =
474
+ process.stdout.columns ||
475
+ (process.stderr as any).columns ||
476
+ Number.parseInt(process.env.COLUMNS ?? "", 10) ||
477
+ DEFAULT_TERM_WIDTH;
199
478
  return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); // -4 safety margin for pi TUI padding
200
479
  }
201
480
 
@@ -206,27 +485,39 @@ function fit(s: string, w: number): string {
206
485
  if (plain.length <= w) return s + " ".repeat(w - plain.length);
207
486
  // Truncated — show content + dim › indicator
208
487
  const showW = w > 2 ? w - 1 : w;
209
- let vis = 0, i = 0;
488
+ let vis = 0,
489
+ i = 0;
210
490
  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++;
491
+ if (s[i] === "\x1b") {
492
+ const e = s.indexOf("m", i);
493
+ if (e !== -1) {
494
+ i = e + 1;
495
+ continue;
496
+ }
497
+ }
498
+ vis++;
499
+ i++;
213
500
  }
214
- return w > 2
215
- ? s.slice(0, i) + RST + FG_DIM + "›" + RST
216
- : s.slice(0, i) + RST;
501
+ return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`;
217
502
  }
218
503
 
219
504
  /** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
220
505
  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]; }
506
+ let fg = "",
507
+ bg = "";
508
+ for (const match of s.matchAll(ANSI_CAPTURE_RE)) {
509
+ const p = match[1] ?? "";
510
+ const seq = match[0] ?? "";
511
+ if (p === "0") {
512
+ fg = "";
513
+ bg = "";
514
+ } else if (p === "39") {
515
+ fg = "";
516
+ } else if (p.startsWith("38;")) {
517
+ fg = seq;
518
+ } else if (p.startsWith("48;")) {
519
+ bg = seq;
520
+ }
230
521
  }
231
522
  return bg + fg;
232
523
  }
@@ -238,14 +529,14 @@ function isLowContrastShikiFg(params: string): boolean {
238
529
  const parts = params.split(";").map(Number);
239
530
  if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
240
531
  const [, , r, g, b] = parts;
241
- const luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
532
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
242
533
  return luminance < 72;
243
534
  }
244
535
 
245
536
  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
- ));
537
+ return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
538
+ isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq,
539
+ );
249
540
  }
250
541
 
251
542
  /** Wrap ANSI-encoded string into rows of `w` visible chars. Max `maxRows` rows; last row truncates with ›. */
@@ -258,7 +549,9 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
258
549
  }
259
550
 
260
551
  const rows: string[] = [];
261
- let row = "", vis = 0, i = 0;
552
+ let row = "",
553
+ vis = 0,
554
+ i = 0;
262
555
  let onLastRow = false;
263
556
  let effW = w;
264
557
 
@@ -272,7 +565,11 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
272
565
  // Pass through ANSI escapes
273
566
  if (s[i] === "\x1b") {
274
567
  const end = s.indexOf("m", i);
275
- if (end !== -1) { row += s.slice(i, end + 1); i = end + 1; continue; }
568
+ if (end !== -1) {
569
+ row += s.slice(i, end + 1);
570
+ i = end + 1;
571
+ continue;
572
+ }
276
573
  }
277
574
 
278
575
  // Row full
@@ -281,10 +578,17 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
281
578
  // Check if remaining string has visible chars
282
579
  let hasMore = false;
283
580
  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;
581
+ if (s[j] === "\x1b") {
582
+ const e2 = s.indexOf("m", j);
583
+ if (e2 !== -1) {
584
+ j = e2;
585
+ continue;
586
+ }
587
+ }
588
+ hasMore = true;
589
+ break;
286
590
  }
287
- if (hasMore && w > 2) row += RST + FG_DIM + "›" + RST;
591
+ if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}`;
288
592
  else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST;
289
593
  rows.push(row);
290
594
  return rows;
@@ -294,10 +598,15 @@ function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = "
294
598
  rows.push(row + RST);
295
599
  row = state + fillBg;
296
600
  vis = 0;
297
- if (rows.length >= maxRows - 1) { onLastRow = true; effW = w > 2 ? w - 1 : w; }
601
+ if (rows.length >= maxRows - 1) {
602
+ onLastRow = true;
603
+ effW = w > 2 ? w - 1 : w;
604
+ }
298
605
  }
299
606
 
300
- row += s[i]; vis++; i++;
607
+ row += s[i];
608
+ vis++;
609
+ i++;
301
610
  }
302
611
 
303
612
  // Final row, padded
@@ -341,7 +650,7 @@ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINE
341
650
  if (!diff.lines.length) return false;
342
651
  if (tw < SPLIT_MIN_WIDTH) return false;
343
652
 
344
- const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
653
+ const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
345
654
  const half = Math.floor((tw - 1) / 2); // -1 for center divider
346
655
  const gw = nw + 5; // border + num + sign + sp + │ + sp
347
656
  const cw = Math.max(12, half - gw);
@@ -369,16 +678,43 @@ function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINE
369
678
  // ---------------------------------------------------------------------------
370
679
 
371
680
  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",
681
+ ts: "typescript",
682
+ tsx: "tsx",
683
+ js: "javascript",
684
+ jsx: "jsx",
685
+ mjs: "javascript",
686
+ cjs: "javascript",
687
+ py: "python",
688
+ rb: "ruby",
689
+ rs: "rust",
690
+ go: "go",
691
+ java: "java",
692
+ c: "c",
693
+ cpp: "cpp",
694
+ h: "c",
695
+ hpp: "cpp",
696
+ cs: "csharp",
697
+ swift: "swift",
698
+ kt: "kotlin",
699
+ html: "html",
700
+ css: "css",
701
+ scss: "scss",
702
+ json: "json",
703
+ yaml: "yaml",
704
+ yml: "yaml",
705
+ toml: "toml",
706
+ md: "markdown",
707
+ sql: "sql",
708
+ sh: "bash",
709
+ bash: "bash",
710
+ zsh: "bash",
711
+ lua: "lua",
712
+ php: "php",
713
+ dart: "dart",
714
+ xml: "xml",
715
+ graphql: "graphql",
716
+ svelte: "svelte",
717
+ vue: "vue",
382
718
  };
383
719
 
384
720
  function lang(fp: string): BundledLanguage | undefined {
@@ -396,7 +732,8 @@ codeToANSI("", "typescript", THEME).catch(() => {});
396
732
  const _cache = new Map<string, string[]>();
397
733
 
398
734
  function _touch(k: string, v: string[]): string[] {
399
- _cache.delete(k); _cache.set(k, v);
735
+ _cache.delete(k);
736
+ _cache.set(k, v);
400
737
  while (_cache.size > CACHE_LIMIT) {
401
738
  const first = _cache.keys().next().value;
402
739
  if (first === undefined) break;
@@ -429,7 +766,8 @@ async function hlBlock(code: string, language: BundledLanguage | undefined): Pro
429
766
  function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
430
767
  const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
431
768
  const lines: DiffLine[] = [];
432
- let added = 0, removed = 0;
769
+ let added = 0,
770
+ removed = 0;
433
771
 
434
772
  for (let hi = 0; hi < patch.hunks.length; hi++) {
435
773
  if (hi > 0) {
@@ -438,13 +776,21 @@ function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff
438
776
  lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
439
777
  }
440
778
  const h = patch.hunks[hi];
441
- let oL = h.oldStart, nL = h.newStart;
779
+ let oL = h.oldStart,
780
+ nL = h.newStart;
442
781
  for (const raw of h.lines) {
443
782
  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 }); }
783
+ const ch = raw[0],
784
+ text = raw.slice(1);
785
+ if (ch === "+") {
786
+ lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
787
+ added++;
788
+ } else if (ch === "-") {
789
+ lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
790
+ removed++;
791
+ } else {
792
+ lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
793
+ }
448
794
  }
449
795
  }
450
796
  return { lines, added, removed, chars: oldContent.length + newContent.length };
@@ -464,7 +810,10 @@ function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff
464
810
  * similarity score and character ranges for emphasis highlighting.
465
811
  * Replaces separate wordDiffRanges + wordDiffSimilarity (which called diffWords twice).
466
812
  */
467
- function wordDiffAnalysis(a: string, b: string): {
813
+ function wordDiffAnalysis(
814
+ a: string,
815
+ b: string,
816
+ ): {
468
817
  similarity: number;
469
818
  oldRanges: Array<[number, number]>;
470
819
  newRanges: Array<[number, number]>;
@@ -473,11 +822,22 @@ function wordDiffAnalysis(a: string, b: string): {
473
822
  const parts = Diff.diffWords(a, b);
474
823
  const oldRanges: Array<[number, number]> = [];
475
824
  const newRanges: Array<[number, number]> = [];
476
- let oPos = 0, nPos = 0, same = 0;
825
+ let oPos = 0,
826
+ nPos = 0,
827
+ same = 0;
477
828
  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; }
829
+ if (p.removed) {
830
+ oldRanges.push([oPos, oPos + p.value.length]);
831
+ oPos += p.value.length;
832
+ } else if (p.added) {
833
+ newRanges.push([nPos, nPos + p.value.length]);
834
+ nPos += p.value.length;
835
+ } else {
836
+ const len = p.value.length;
837
+ same += len;
838
+ oPos += len;
839
+ nPos += len;
840
+ }
481
841
  }
482
842
  const maxLen = Math.max(a.length, b.length);
483
843
  return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
@@ -490,12 +850,7 @@ function wordDiffAnalysis(a: string, b: string): {
490
850
  *
491
851
  * Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
492
852
  */
493
- function injectBg(
494
- ansiLine: string,
495
- ranges: Array<[number, number]>,
496
- baseBg: string,
497
- hlBg: string,
498
- ): string {
853
+ function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string {
499
854
  if (!ranges.length) return baseBg + ansiLine + RST;
500
855
 
501
856
  let out = baseBg;
@@ -519,9 +874,13 @@ function injectBg(
519
874
  // Advance past exhausted ranges
520
875
  while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
521
876
  const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
522
- if (want !== inHL) { inHL = want; out += inHL ? hlBg : baseBg; }
877
+ if (want !== inHL) {
878
+ inHL = want;
879
+ out += inHL ? hlBg : baseBg;
880
+ }
523
881
  out += ansiLine[i];
524
- vis++; i++;
882
+ vis++;
883
+ i++;
525
884
  }
526
885
  return out + RST;
527
886
  }
@@ -529,11 +888,15 @@ function injectBg(
529
888
  /** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
530
889
  function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
531
890
  const parts = Diff.diffWords(oldText, newText);
532
- let o = "", n = "";
891
+ let o = "",
892
+ n = "";
533
893
  for (const p of parts) {
534
894
  if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
535
895
  else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
536
- else { o += p.value; n += p.value; }
896
+ else {
897
+ o += p.value;
898
+ n += p.value;
899
+ }
537
900
  }
538
901
  return { old: o, new: n };
539
902
  }
@@ -549,18 +912,24 @@ function plainWordDiff(oldText: string, newText: string): { old: string; new: st
549
912
  // • Paired del/add lines adjacent with word-level emphasis
550
913
  // ---------------------------------------------------------------------------
551
914
 
552
- async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_RENDER_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS): Promise<string> {
915
+ async function renderUnified(
916
+ diff: ParsedDiff,
917
+ language: BundledLanguage | undefined,
918
+ max = MAX_RENDER_LINES,
919
+ dc: DiffColors = DEFAULT_DIFF_COLORS,
920
+ ): Promise<string> {
553
921
  if (!diff.lines.length) return "";
554
922
 
555
923
  const vis = diff.lines.slice(0, max);
556
924
  const tw = termW();
557
- const nw = Math.max(2, String(Math.max(...vis.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
925
+ const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
558
926
  const gw = nw + 5; // border + num + sign + sp + │ + sp
559
927
  const cw = Math.max(20, tw - gw);
560
928
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
561
929
 
562
930
  // Build separate old/new code blocks for highlighting
563
- const oldSrc: string[] = [], newSrc: string[] = [];
931
+ const oldSrc: string[] = [],
932
+ newSrc: string[] = [];
564
933
  for (const l of vis) {
565
934
  if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
566
935
  if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
@@ -569,12 +938,21 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
569
938
  ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
570
939
  : [oldSrc, newSrc];
571
940
 
572
- let oI = 0, nI = 0, idx = 0;
941
+ let oI = 0,
942
+ nI = 0,
943
+ idx = 0;
573
944
  const out: string[] = [];
574
945
  out.push(rule(tw));
575
946
 
576
947
  /** 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 {
948
+ function emitRow(
949
+ num: number | null,
950
+ sign: string,
951
+ gutterBg: string,
952
+ signFg: string,
953
+ body: string,
954
+ bodyBg = "",
955
+ ): void {
578
956
  const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
579
957
  const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_DEFAULT} `;
580
958
  const numFg = borderFg || FG_LNUM;
@@ -594,28 +972,35 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
594
972
  const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
595
973
  const totalW = Math.min(tw, 72);
596
974
  const pad = Math.max(0, totalW - label.length - 2);
597
- const half1 = Math.floor(pad / 2), half2 = pad - half1;
975
+ const half1 = Math.floor(pad / 2),
976
+ half2 = pad - half1;
598
977
  out.push(`${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
599
- idx++; continue;
978
+ idx++;
979
+ continue;
600
980
  }
601
981
 
602
982
  // Context line — dimmed, single line number
603
983
  if (l.type === "ctx") {
604
984
  const hl = oldHL[oI] ?? l.content;
605
985
  emitRow(l.newNum, " ", BG_DEFAULT, dc.fgCtx, `${BG_DEFAULT}${DIM}${hl}`, BG_DEFAULT);
606
- oI++; nI++; idx++; continue;
986
+ oI++;
987
+ nI++;
988
+ idx++;
989
+ continue;
607
990
  }
608
991
 
609
992
  // Collect del/add blocks
610
993
  const dels: Array<{ l: DiffLine; hl: string }> = [];
611
994
  while (idx < vis.length && vis[idx].type === "del") {
612
995
  dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
613
- oI++; idx++;
996
+ oI++;
997
+ idx++;
614
998
  }
615
999
  const adds: Array<{ l: DiffLine; hl: string }> = [];
616
1000
  while (idx < vis.length && vis[idx].type === "add") {
617
1001
  adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
618
- nI++; idx++;
1002
+ nI++;
1003
+ idx++;
619
1004
  }
620
1005
 
621
1006
  // 1:1 paired → word diff emphasis
@@ -658,7 +1043,12 @@ async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undef
658
1043
  // Split view (auto-fallback to unified when narrow)
659
1044
  // ---------------------------------------------------------------------------
660
1045
 
661
- async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_PREVIEW_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS): Promise<string> {
1046
+ async function renderSplit(
1047
+ diff: ParsedDiff,
1048
+ language: BundledLanguage | undefined,
1049
+ max = MAX_PREVIEW_LINES,
1050
+ dc: DiffColors = DEFAULT_DIFF_COLORS,
1051
+ ): Promise<string> {
662
1052
  const tw = termW();
663
1053
  if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
664
1054
  if (!diff.lines.length) return "";
@@ -669,23 +1059,35 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
669
1059
  let i = 0;
670
1060
  while (i < diff.lines.length) {
671
1061
  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++; }
1062
+ if (l.type === "sep" || l.type === "ctx") {
1063
+ rows.push({ left: l, right: l });
1064
+ i++;
1065
+ continue;
1066
+ }
1067
+ const dels: DiffLine[] = [],
1068
+ adds: DiffLine[] = [];
1069
+ while (i < diff.lines.length && diff.lines[i].type === "del") {
1070
+ dels.push(diff.lines[i]);
1071
+ i++;
1072
+ }
1073
+ while (i < diff.lines.length && diff.lines[i].type === "add") {
1074
+ adds.push(diff.lines[i]);
1075
+ i++;
1076
+ }
676
1077
  const n = Math.max(dels.length, adds.length);
677
1078
  for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
678
1079
  }
679
1080
 
680
1081
  const vis = rows.slice(0, max);
681
1082
  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);
1083
+ const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
683
1084
  const gw = nw + 5; // border + num + sign + sp + │ + sp
684
1085
  const cw = Math.max(12, half - gw);
685
1086
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
686
1087
 
687
1088
  // Build separate code blocks per side
688
- const leftSrc: string[] = [], rightSrc: string[] = [];
1089
+ const leftSrc: string[] = [],
1090
+ rightSrc: string[] = [];
689
1091
  for (const r of vis) {
690
1092
  if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
691
1093
  if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
@@ -694,13 +1096,19 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
694
1096
  ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
695
1097
  : [leftSrc, rightSrc];
696
1098
 
697
- let lI = 0, rI = 0;
1099
+ let lI = 0,
1100
+ rI = 0;
698
1101
  let stripeRow = 0; // tracks row index for diagonal stripe offset
699
1102
 
700
1103
  // Returns { gutter, contGutter, body } for wrapping composition
701
1104
  type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
702
1105
 
703
- function half_build(line: DiffLine | null, hl: string, ranges: Array<[number, number]> | null, side: "left" | "right"): HalfResult {
1106
+ function half_build(
1107
+ line: DiffLine | null,
1108
+ hl: string,
1109
+ ranges: Array<[number, number]> | null,
1110
+ side: "left" | "right",
1111
+ ): HalfResult {
704
1112
  // Empty filler — diagonal stripes
705
1113
  if (!line) {
706
1114
  const gw2 = nw + 2; // number + sign + space before │
@@ -716,12 +1124,13 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
716
1124
  return { gutter: g, contGutter: g, bodyRows: [`${FG_DIM}${fit(label, cw)}${RST}`] };
717
1125
  }
718
1126
 
719
- const isDel = line.type === "del", isAdd = line.type === "add";
1127
+ const isDel = line.type === "del",
1128
+ isAdd = line.type === "add";
720
1129
  const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_DEFAULT;
721
1130
  const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_DEFAULT;
722
1131
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
723
1132
  const sign = isDel ? "-" : isAdd ? "+" : " ";
724
- const num = isDel ? line.oldNum : isAdd ? line.newNum : (side === "left" ? line.oldNum : line.newNum);
1133
+ const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
725
1134
 
726
1135
  // Border bar + colored line numbers for changed lines
727
1136
  const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
@@ -751,7 +1160,8 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
751
1160
  out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
752
1161
 
753
1162
  for (const r of vis) {
754
- const leftLine = r.left, rightLine = r.right;
1163
+ const leftLine = r.left,
1164
+ rightLine = r.right;
755
1165
  const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
756
1166
  const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
757
1167
 
@@ -764,12 +1174,13 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
764
1174
  rResult = half_build(rightLine, rhl, wd.newRanges, "right");
765
1175
  } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
766
1176
  const pwd = plainWordDiff(leftLine.content, rightLine.content);
767
- lI++; rI++;
1177
+ lI++;
1178
+ rI++;
768
1179
  lResult = half_build(leftLine, pwd.old, null, "left");
769
1180
  rResult = half_build(rightLine, pwd.new, null, "right");
770
1181
  } else {
771
- const lhl = (leftLine && leftLine.type !== "sep") ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
772
- const rhl = (rightLine && rightLine.type !== "sep") ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
1182
+ const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
1183
+ const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
773
1184
  lResult = half_build(leftLine, lhl, null, "left");
774
1185
  rResult = half_build(rightLine, rhl, null, "right");
775
1186
  }
@@ -782,7 +1193,8 @@ async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefin
782
1193
  const lg = row === 0 ? lResult.gutter : lResult.contGutter;
783
1194
  const rg = row === 0 ? rResult.gutter : rResult.contGutter;
784
1195
  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}`);
1196
+ const rb =
1197
+ rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
786
1198
  out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
787
1199
  stripeRow++;
788
1200
  }
@@ -807,13 +1219,18 @@ export const __testing = {
807
1219
  };
808
1220
 
809
1221
  export default function diffRendererExtension(pi: any): void {
1222
+ // Apply diff theme palette from settings/presets before rendering
1223
+ applyDiffPalette();
1224
+
810
1225
  let createWriteTool: any, createEditTool: any, TextComponent: any;
811
1226
  try {
812
1227
  const sdk = require("@mariozechner/pi-coding-agent");
813
1228
  createWriteTool = sdk.createWriteTool;
814
1229
  createEditTool = sdk.createEditTool;
815
1230
  TextComponent = require("@mariozechner/pi-tui").Text;
816
- } catch { return; }
1231
+ } catch {
1232
+ return;
1233
+ }
817
1234
  if (!createWriteTool || !createEditTool || !TextComponent) return;
818
1235
 
819
1236
  const cwd = process.cwd();
@@ -833,7 +1250,11 @@ export default function diffRendererExtension(pi: any): void {
833
1250
  async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
834
1251
  const fp = params.path ?? params.file_path ?? "";
835
1252
  let old: string | null = null;
836
- try { if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8"); } catch { old = null; }
1253
+ try {
1254
+ if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
1255
+ } catch {
1256
+ old = null;
1257
+ }
837
1258
 
838
1259
  const result = await origWrite.execute(tid, params, sig, upd, ctx);
839
1260
  const content = params.content ?? "";
@@ -873,16 +1294,18 @@ export default function diffRendererExtension(pi: any): void {
873
1294
  ctx.state._previewKey = previewKey;
874
1295
  ctx.state._previewText = hdr;
875
1296
  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(() => {});
1297
+ hlBlock(args.content, lg)
1298
+ .then((lines: string[]) => {
1299
+ if (ctx.state._previewKey !== previewKey) return;
1300
+ const maxShow = ctx.expanded ? lines.length : 16;
1301
+ const preview = lines.slice(0, maxShow).join("\n");
1302
+ const rem = lines.length - maxShow;
1303
+ let out = `${hdr}\n\n${preview}`;
1304
+ if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
1305
+ ctx.state._previewText = out;
1306
+ ctx.invalidate();
1307
+ })
1308
+ .catch(() => {});
886
1309
  }
887
1310
  text.setText(ctx.state._previewText ?? hdr);
888
1311
  return text;
@@ -895,7 +1318,11 @@ export default function diffRendererExtension(pi: any): void {
895
1318
  renderResult(result: any, _opt: any, theme: any, ctx: any) {
896
1319
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
897
1320
  if (ctx.isError) {
898
- const e = result.content?.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join("\n") ?? "Error";
1321
+ const e =
1322
+ result.content
1323
+ ?.filter((c: any) => c.type === "text")
1324
+ .map((c: any) => c.text || "")
1325
+ .join("\n") ?? "Error";
899
1326
  text.setText(`\n${theme.fg("error", e)}`);
900
1327
  return text;
901
1328
  }
@@ -907,15 +1334,17 @@ export default function diffRendererExtension(pi: any): void {
907
1334
  ctx.state._wdk = key;
908
1335
  ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
909
1336
  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
- });
1337
+ renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
1338
+ .then((rendered: string) => {
1339
+ if (ctx.state._wdk !== key) return;
1340
+ ctx.state._wdt = ` ${d.summary}\n${rendered}`;
1341
+ ctx.invalidate();
1342
+ })
1343
+ .catch(() => {
1344
+ if (ctx.state._wdk !== key) return;
1345
+ ctx.state._wdt = ` ${d.summary}`;
1346
+ ctx.invalidate();
1347
+ });
919
1348
  }
920
1349
  text.setText(ctx.state._wdt ?? ` ${d.summary}`);
921
1350
  return text;
@@ -932,16 +1361,18 @@ export default function diffRendererExtension(pi: any): void {
932
1361
  ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
933
1362
  const lg = lang(fp);
934
1363
  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(() => {});
1364
+ hlBlock(rawContent, lg)
1365
+ .then((hlLines: string[]) => {
1366
+ if (ctx.state._nfk !== pk) return;
1367
+ const maxShow = ctx.expanded ? hlLines.length : 12;
1368
+ const preview = hlLines.slice(0, maxShow).join("\n");
1369
+ const rem = hlLines.length - maxShow;
1370
+ let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
1371
+ if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
1372
+ ctx.state._nft = out;
1373
+ ctx.invalidate();
1374
+ })
1375
+ .catch(() => {});
945
1376
  }
946
1377
  }
947
1378
  text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
@@ -977,7 +1408,9 @@ export default function diffRendererExtension(pi: any): void {
977
1408
  const idx = f.indexOf(newText);
978
1409
  if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
979
1410
  }
980
- } catch { editLine = 0; }
1411
+ } catch {
1412
+ editLine = 0;
1413
+ }
981
1414
  const diff = parseDiff(oldText, newText);
982
1415
  (result as any).details = { _type: "editInfo", summary: summarize(diff.added, diff.removed), editLine };
983
1416
  }
@@ -1003,17 +1436,19 @@ export default function diffRendererExtension(pi: any): void {
1003
1436
  const lg = lang(fp);
1004
1437
  const diff = parseDiff(oldText, newText);
1005
1438
  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
- });
1439
+ renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
1440
+ .then((rendered) => {
1441
+ if (ctx.state._pk !== pk) return;
1442
+ ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1443
+ ctx.invalidate();
1444
+ })
1445
+ .catch(() => {
1446
+ if (ctx.state._pk !== pk) return;
1447
+ // Fallback: plain word diff
1448
+ const diff2 = parseDiff(oldText, newText);
1449
+ ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
1450
+ ctx.invalidate();
1451
+ });
1017
1452
  }
1018
1453
 
1019
1454
  text.setText(ctx.state._pt ?? hdr);
@@ -1023,7 +1458,11 @@ export default function diffRendererExtension(pi: any): void {
1023
1458
  renderResult(result: any, _opt: any, theme: any, ctx: any) {
1024
1459
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1025
1460
  if (ctx.isError) {
1026
- const e = result.content?.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join("\n") ?? "Error";
1461
+ const e =
1462
+ result.content
1463
+ ?.filter((c: any) => c.type === "text")
1464
+ .map((c: any) => c.text || "")
1465
+ .join("\n") ?? "Error";
1027
1466
  text.setText(`\n${theme.fg("error", e)}`);
1028
1467
  return text;
1029
1468
  }