@heyhuynhgiabuu/pi-diff 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +117 -0
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +55 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/core/diff.d.ts +14 -0
  7. package/dist/core/diff.d.ts.map +1 -0
  8. package/dist/core/diff.js +72 -0
  9. package/dist/core/diff.js.map +1 -0
  10. package/dist/index.d.ts +42 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +1738 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/review/command.d.ts +2 -0
  15. package/dist/review/command.d.ts.map +1 -0
  16. package/dist/review/command.js +365 -0
  17. package/dist/review/command.js.map +1 -0
  18. package/dist/review/export.d.ts +7 -0
  19. package/dist/review/export.d.ts.map +1 -0
  20. package/dist/review/export.js +77 -0
  21. package/dist/review/export.js.map +1 -0
  22. package/dist/review/file-preview.d.ts +9 -0
  23. package/dist/review/file-preview.d.ts.map +1 -0
  24. package/dist/review/file-preview.js +228 -0
  25. package/dist/review/file-preview.js.map +1 -0
  26. package/dist/review/git.d.ts +49 -0
  27. package/dist/review/git.d.ts.map +1 -0
  28. package/dist/review/git.js +200 -0
  29. package/dist/review/git.js.map +1 -0
  30. package/dist/review/hunk-preview.d.ts +25 -0
  31. package/dist/review/hunk-preview.d.ts.map +1 -0
  32. package/dist/review/hunk-preview.js +973 -0
  33. package/dist/review/hunk-preview.js.map +1 -0
  34. package/dist/review/interactive.d.ts +27 -0
  35. package/dist/review/interactive.d.ts.map +1 -0
  36. package/dist/review/interactive.js +146 -0
  37. package/dist/review/interactive.js.map +1 -0
  38. package/dist/review/model.d.ts +33 -0
  39. package/dist/review/model.d.ts.map +1 -0
  40. package/dist/review/model.js +208 -0
  41. package/dist/review/model.js.map +1 -0
  42. package/dist/review/prompt.d.ts +4 -0
  43. package/dist/review/prompt.d.ts.map +1 -0
  44. package/dist/review/prompt.js +43 -0
  45. package/dist/review/prompt.js.map +1 -0
  46. package/dist/review/session.d.ts +69 -0
  47. package/dist/review/session.d.ts.map +1 -0
  48. package/dist/review/session.js +190 -0
  49. package/dist/review/session.js.map +1 -0
  50. package/dist/review/tui.d.ts +57 -0
  51. package/dist/review/tui.d.ts.map +1 -0
  52. package/dist/review/tui.js +486 -0
  53. package/dist/review/tui.js.map +1 -0
  54. package/media/review-diff.png +0 -0
  55. package/package.json +69 -5
  56. package/prompts/review-diff-agent.md +19 -0
  57. package/biome.json +0 -29
  58. package/src/index.ts +0 -1635
  59. package/tsconfig.json +0 -19
package/src/index.ts DELETED
@@ -1,1635 +0,0 @@
1
- /**
2
- * pi-diff — Shiki-powered terminal diff renderer for pi.
3
- *
4
- * @module pi-diff
5
- * @see https://github.com/buddingnewinsights/pi-diff
6
- *
7
- * Architecture (like OpenTUI / delta):
8
- * 1. Syntax-highlight full code blocks via Shiki → ANSI (fg-only codes)
9
- * 2. Layer diff background colors underneath (composites at cell level)
10
- * 3. For word-level changes, inject brighter bg at changed char positions
11
- * 4. Result: syntax fg + diff bg + word emphasis — all three visible together
12
- *
13
- * Views:
14
- * • Split (side-by-side) — edit tool, auto-falls back to unified on narrow terminals
15
- * • Unified (stacked) — write tool overwrites
16
- *
17
- * Performance:
18
- * • Singleton Shiki highlighter (managed by @shikijs/cli)
19
- * • LRU memo cache per highlighted block
20
- * • Large-diff fallback (skip highlighting, still show diff)
21
- * • Async rendering with invalidate() for non-blocking preview
22
- */
23
-
24
- import { existsSync, readFileSync } from "node:fs";
25
- import { extname, relative } from "node:path";
26
-
27
- import { codeToANSI } from "@shikijs/cli";
28
- import * as Diff from "diff";
29
- import type { BundledLanguage, BundledTheme } from "shiki";
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(
176
- base: { r: number; g: number; b: number },
177
- accent: { r: number; g: number; b: number },
178
- intensity: number,
179
- ): string {
180
- const r = Math.round(base.r + (accent.r - base.r) * intensity);
181
- const g = Math.round(base.g + (accent.g - base.g) * intensity);
182
- const b = Math.round(base.b + (accent.b - base.b) * intensity);
183
- return `\x1b[48;2;${r};${g};${b}m`;
184
- }
185
-
186
- /** Whether auto-derive from theme is still pending (runs lazily on first render). */
187
- let _autoDerivePending = true;
188
-
189
- /** Whether user set explicit bg config (via preset or per-color overrides). */
190
- let _hasExplicitBgConfig = false;
191
-
192
- /** Auto-derive all diff background colors from the pi theme's fg diff colors.
193
- * Reads toolSuccessBg as the add/context base and toolErrorBg as the delete base,
194
- * then mixes accent colors into each. Falls back to black (0,0,0) when a theme
195
- * background is unavailable; toolErrorBg falls back to toolSuccessBg. */
196
- function autoDeriveBgFromTheme(theme: any): void {
197
- if (!theme?.getFgAnsi) return;
198
- try {
199
- const fgAdd = theme.getFgAnsi("toolDiffAdded");
200
- const fgDel = theme.getFgAnsi("toolDiffRemoved");
201
- const addRgb = parseAnsiRgb(fgAdd);
202
- const delRgb = parseAnsiRgb(fgDel);
203
- if (!addRgb || !delRgb) return;
204
-
205
- let addBase = { r: 0, g: 0, b: 0 };
206
- let delBase = addBase;
207
- if (theme.getBgAnsi) {
208
- try {
209
- const successBgAnsi = theme.getBgAnsi("toolSuccessBg");
210
- const successParsed = parseAnsiRgb(successBgAnsi);
211
- if (successParsed) {
212
- addBase = successParsed;
213
- delBase = successParsed;
214
- BG_BASE = successBgAnsi;
215
- }
216
- } catch {
217
- /* no toolSuccessBg — use black */
218
- }
219
-
220
- try {
221
- const errorParsed = parseAnsiRgb(theme.getBgAnsi("toolErrorBg"));
222
- if (errorParsed) delBase = errorParsed;
223
- } catch {
224
- /* no toolErrorBg — use toolSuccessBg/black */
225
- }
226
- }
227
-
228
- // Line backgrounds — subtle accent mixed into the matching tool-state base (8–10%)
229
- BG_ADD = mixBg(addBase, addRgb, 0.08);
230
- BG_DEL = mixBg(delBase, delRgb, 0.1);
231
-
232
- // Word-level highlights — more visible (20–22%)
233
- BG_ADD_W = mixBg(addBase, addRgb, 0.2);
234
- BG_DEL_W = mixBg(delBase, delRgb, 0.22);
235
-
236
- // Gutters — subtler than lines (5–6%)
237
- BG_GUTTER_ADD = mixBg(addBase, addRgb, 0.05);
238
- BG_GUTTER_DEL = mixBg(delBase, delRgb, 0.06);
239
-
240
- // Empty filler and context — match the success/context base
241
- BG_EMPTY = BG_BASE;
242
-
243
- // Update RST to re-apply base bg after every reset — prevents black
244
- // flashes between styled segments when toolSuccessBg is non-black
245
- RST = `\x1b[0m${BG_BASE}`;
246
-
247
- // Rebuild derived constants
248
- DIVIDER = `${FG_RULE}│${RST}`;
249
- } catch {
250
- // Fall back to defaults silently
251
- }
252
- }
253
-
254
- /** Load diff theme config from .pi/settings.json (project-level, then global). */
255
- function loadDiffConfig(): DiffUserConfig {
256
- const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
257
- for (const p of paths) {
258
- try {
259
- if (existsSync(p)) {
260
- const raw = JSON.parse(readFileSync(p, "utf-8"));
261
- if (raw.diffTheme || raw.diffColors) {
262
- return { diffTheme: raw.diffTheme, diffColors: raw.diffColors };
263
- }
264
- }
265
- } catch {
266
- // skip invalid files
267
- }
268
- }
269
- return {};
270
- }
271
-
272
- /** Apply diff palette from settings → preset → (auto-derive deferred) → defaults.
273
- * Called once during extension initialization. */
274
- function applyDiffPalette(): void {
275
- const config = loadDiffConfig();
276
-
277
- // Load preset if specified
278
- const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
279
- if (preset) _hasExplicitBgConfig = true;
280
-
281
- // Per-color overrides from settings
282
- const ov = config.diffColors ?? {};
283
- if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true;
284
-
285
- // Helper: apply a hex bg color if not env-overridden
286
- const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
287
- if (envName && process.env[envName]) return; // env override wins
288
- const hex = ov[key] ?? presetVal;
289
- if (hex) {
290
- const a = hexToBgAnsi(hex);
291
- if (a) set(a);
292
- }
293
- };
294
- // Helper: apply a hex fg color if not env-overridden
295
- const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => {
296
- if (envName && process.env[envName]) return;
297
- const hex = ov[key] ?? presetVal;
298
- if (hex) {
299
- const a = hexToFgAnsi(hex);
300
- if (a) set(a);
301
- }
302
- };
303
-
304
- // --- Apply backgrounds ---
305
- applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => {
306
- BG_ADD = v;
307
- });
308
- applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => {
309
- BG_DEL = v;
310
- });
311
- applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => {
312
- BG_ADD_W = v;
313
- });
314
- applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => {
315
- BG_DEL_W = v;
316
- });
317
- applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => {
318
- BG_GUTTER_ADD = v;
319
- });
320
- applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => {
321
- BG_GUTTER_DEL = v;
322
- });
323
- applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => {
324
- BG_EMPTY = v;
325
- });
326
-
327
- // --- Apply foregrounds ---
328
- applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => {
329
- FG_ADD = v;
330
- });
331
- applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => {
332
- FG_DEL = v;
333
- });
334
- applyFg(null, "fgDim", preset?.fgDim, (v) => {
335
- FG_DIM = v;
336
- });
337
- applyFg(null, "fgLnum", preset?.fgLnum, (v) => {
338
- FG_LNUM = v;
339
- });
340
- applyFg(null, "fgRule", preset?.fgRule, (v) => {
341
- FG_RULE = v;
342
- });
343
- applyFg(null, "fgStripe", preset?.fgStripe, (v) => {
344
- FG_STRIPE = v;
345
- });
346
- applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => {
347
- FG_SAFE_MUTED = v;
348
- });
349
-
350
- // --- Shiki syntax theme ---
351
- const shiki = ov.shikiTheme ?? preset?.shikiTheme;
352
- if (shiki) THEME = shiki as BundledTheme;
353
-
354
- // --- Rebuild derived constants ---
355
- DIVIDER = `${FG_RULE}│${RST}`;
356
- DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
357
-
358
- // If no explicit bg config, auto-derive will run on first render
359
- _autoDerivePending = !_hasExplicitBgConfig;
360
- }
361
-
362
- // ---------------------------------------------------------------------------
363
- // Config
364
- // ---------------------------------------------------------------------------
365
-
366
- let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
367
-
368
- function envInt(name: string, fallback: number): number {
369
- const v = Number.parseInt(process.env[name] ?? "", 10);
370
- return Number.isFinite(v) && v > 0 ? v : fallback;
371
- }
372
-
373
- /** Parse env hex color "#RRGGBB" → ANSI 24-bit fg/bg escape, or return fallback. */
374
- function envFg(name: string, fallback: string): string {
375
- const hex = process.env[name];
376
- if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
377
- const r = Number.parseInt(hex.slice(1, 3), 16);
378
- const g = Number.parseInt(hex.slice(3, 5), 16);
379
- const b = Number.parseInt(hex.slice(5, 7), 16);
380
- return `\x1b[38;2;${r};${g};${b}m`;
381
- }
382
- function envBg(name: string, fallback: string): string {
383
- const hex = process.env[name];
384
- if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
385
- const r = Number.parseInt(hex.slice(1, 3), 16);
386
- const g = Number.parseInt(hex.slice(3, 5), 16);
387
- const b = Number.parseInt(hex.slice(5, 7), 16);
388
- return `\x1b[48;2;${r};${g};${b}m`;
389
- }
390
-
391
- // --- Split-view thresholds ---
392
- // Split is preferred when there's real room. At narrow widths, a clean stacked
393
- // (unified) view is better than a cramped split with wrapping.
394
- const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
395
- const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); // ≥60 code cols per side
396
- const SPLIT_MAX_WRAP_RATIO = 0.2; // if >20% lines wrap in split, fall back to stacked
397
- const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
398
-
399
- // --- Terminal bounds ---
400
- const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
401
- const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
402
-
403
- // --- Rendering limits ---
404
- const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
405
- const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
406
- const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
407
- const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
408
-
409
- // --- Word diff ---
410
- const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
411
-
412
- // --- Wrapping ---
413
- // Adaptive: narrow terminals truncate aggressively, wide terminals allow wrapping.
414
- // Actual wrap rows are computed per-render via adaptiveWrapRows().
415
- const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
416
- const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
417
- const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
418
-
419
- // ---------------------------------------------------------------------------
420
- // ANSI
421
- // ---------------------------------------------------------------------------
422
-
423
- let RST = "\x1b[0m";
424
- const BOLD = "\x1b[1m";
425
- const DIM = "\x1b[2m";
426
-
427
- // Subtle diff backgrounds — muted tones to let syntax fg shine through
428
- // Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
429
- let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
430
- let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
431
- let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
432
- let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
433
- let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
434
- let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
435
- const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
436
- let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
437
-
438
- // Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
439
- let FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
440
- let FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
441
- let FG_DIM = "\x1b[38;2;80;80;80m";
442
- let FG_LNUM = "\x1b[38;2;100;100;100m";
443
- let FG_RULE = "\x1b[38;2;50;50;50m";
444
- let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
445
-
446
- let FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
447
-
448
- const BORDER_BAR = "▌";
449
-
450
- /** Generate a dense diagonal stripe fill for empty filler cells.
451
- * Solid ╱ characters — uniform direction like CSS diagonal hatching. */
452
- function stripes(w: number, _rowOffset: number): string {
453
- return BG_BASE + FG_STRIPE + "╱".repeat(w) + RST;
454
- }
455
-
456
- let DIVIDER = `${FG_RULE}│${RST}`;
457
- const ESC_RE = "\u001b";
458
- const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
459
- const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
460
- const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
461
- const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
462
- let BG_BASE = BG_DEFAULT; // tool box base bg — updated from theme's toolSuccessBg
463
-
464
- // ---------------------------------------------------------------------------
465
- // Theme-aware diff colors
466
- // ---------------------------------------------------------------------------
467
-
468
- /** Resolved ANSI colors for diff rendering — theme overrides hardcoded defaults. */
469
- interface DiffColors {
470
- fgAdd: string;
471
- fgDel: string;
472
- fgCtx: string;
473
- }
474
-
475
- let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
476
-
477
- /** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI.
478
- * On first call with a valid theme, auto-derives bg colors if no explicit config was set.
479
- * Always reads toolSuccessBg for BG_BASE (used for context/add line backgrounds). */
480
- function resolveDiffColors(theme?: any): DiffColors {
481
- // Always read toolSuccessBg for BG_BASE (even with explicit config)
482
- if (theme?.getBgAnsi && BG_BASE === BG_DEFAULT) {
483
- try {
484
- const bgAnsi = theme.getBgAnsi("toolSuccessBg");
485
- const parsed = parseAnsiRgb(bgAnsi);
486
- if (parsed) {
487
- BG_BASE = bgAnsi;
488
- RST = `\x1b[0m${BG_BASE}`;
489
- }
490
- } catch {
491
- /* ignore */
492
- }
493
- }
494
-
495
- // Auto-derive bg colors from theme on first render (if no explicit preset/overrides)
496
- if (_autoDerivePending && theme?.getFgAnsi) {
497
- autoDeriveBgFromTheme(theme);
498
- _autoDerivePending = false;
499
- }
500
-
501
- if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
502
- try {
503
- const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD;
504
- const fgDel = theme.getFgAnsi("toolDiffRemoved") || FG_DEL;
505
- const fgCtx = theme.getFgAnsi("toolDiffContext") || FG_DIM;
506
- return { fgAdd, fgDel, fgCtx };
507
- } catch {
508
- return DEFAULT_DIFF_COLORS;
509
- }
510
- }
511
-
512
- // ---------------------------------------------------------------------------
513
- // Adaptive helpers
514
- // ---------------------------------------------------------------------------
515
-
516
- /** Returns max wrap rows based on current terminal width. Narrow = truncate, wide = allow wrapping. */
517
- function adaptiveWrapRows(tw?: number): number {
518
- const w = tw ?? termW();
519
- if (w >= 180) return MAX_WRAP_ROWS_WIDE;
520
- if (w >= 120) return MAX_WRAP_ROWS_MED;
521
- return MAX_WRAP_ROWS_NARROW;
522
- }
523
-
524
- // ---------------------------------------------------------------------------
525
- // Types
526
- // ---------------------------------------------------------------------------
527
-
528
- interface DiffLine {
529
- type: "add" | "del" | "ctx" | "sep";
530
- oldNum: number | null;
531
- newNum: number | null;
532
- content: string;
533
- }
534
-
535
- interface ParsedDiff {
536
- lines: DiffLine[];
537
- added: number;
538
- removed: number;
539
- chars: number;
540
- }
541
-
542
- // ---------------------------------------------------------------------------
543
- // Utilities
544
- // ---------------------------------------------------------------------------
545
-
546
- function strip(s: string): string {
547
- return s.replace(ANSI_RE, "");
548
- }
549
-
550
- function tabs(s: string): string {
551
- return s.replace(/\t/g, " ");
552
- }
553
-
554
- function termW(): number {
555
- // Try multiple sources — process.stdout.columns may be undefined in piped/subagent contexts
556
- const raw =
557
- process.stdout.columns ||
558
- (process.stderr as any).columns ||
559
- Number.parseInt(process.env.COLUMNS ?? "", 10) ||
560
- DEFAULT_TERM_WIDTH;
561
- return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); // -4 safety margin for pi TUI padding
562
- }
563
-
564
- /** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
565
- function fit(s: string, w: number): string {
566
- if (w <= 0) return "";
567
- const plain = strip(s);
568
- if (plain.length <= w) return s + " ".repeat(w - plain.length);
569
- // Truncated — show content + dim › indicator
570
- const showW = w > 2 ? w - 1 : w;
571
- let vis = 0,
572
- i = 0;
573
- while (i < s.length && vis < showW) {
574
- if (s[i] === "\x1b") {
575
- const e = s.indexOf("m", i);
576
- if (e !== -1) {
577
- i = e + 1;
578
- continue;
579
- }
580
- }
581
- vis++;
582
- i++;
583
- }
584
- return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`;
585
- }
586
-
587
- /** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
588
- function ansiState(s: string): string {
589
- let fg = "",
590
- bg = "";
591
- for (const match of s.matchAll(ANSI_CAPTURE_RE)) {
592
- const p = match[1] ?? "";
593
- const seq = match[0] ?? "";
594
- if (p === "0") {
595
- fg = "";
596
- bg = "";
597
- } else if (p === "39") {
598
- fg = "";
599
- } else if (p.startsWith("38;")) {
600
- fg = seq;
601
- } else if (p.startsWith("48;")) {
602
- bg = seq;
603
- }
604
- }
605
- return bg + fg;
606
- }
607
-
608
- function isLowContrastShikiFg(params: string): boolean {
609
- if (params === "30" || params === "90") return true;
610
- if (params === "38;5;0" || params === "38;5;8") return true;
611
- if (!params.startsWith("38;2;")) return false;
612
- const parts = params.split(";").map(Number);
613
- if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
614
- const [, , r, g, b] = parts;
615
- const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
616
- return luminance < 72;
617
- }
618
-
619
- function normalizeShikiContrast(ansi: string): string {
620
- return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
621
- isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq,
622
- );
623
- }
624
-
625
- /** Wrap ANSI-encoded string into rows of `w` visible chars. Max `maxRows` rows; last row truncates with ›. */
626
- function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = ""): string[] {
627
- if (w <= 0) return [""];
628
- const plain = strip(s);
629
- if (plain.length <= w) {
630
- const pad = w - plain.length;
631
- return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? RST : "")] : [s];
632
- }
633
-
634
- const rows: string[] = [];
635
- let row = "",
636
- vis = 0,
637
- i = 0;
638
- let onLastRow = false;
639
- let effW = w;
640
-
641
- while (i < s.length) {
642
- // When we reach the last allowed row, reserve 1 char for › indicator
643
- if (!onLastRow && rows.length >= maxRows - 1) {
644
- onLastRow = true;
645
- effW = w > 2 ? w - 1 : w;
646
- }
647
-
648
- // Pass through ANSI escapes
649
- if (s[i] === "\x1b") {
650
- const end = s.indexOf("m", i);
651
- if (end !== -1) {
652
- row += s.slice(i, end + 1);
653
- i = end + 1;
654
- continue;
655
- }
656
- }
657
-
658
- // Row full
659
- if (vis >= effW) {
660
- if (onLastRow) {
661
- // Check if remaining string has visible chars
662
- let hasMore = false;
663
- for (let j = i; j < s.length; j++) {
664
- if (s[j] === "\x1b") {
665
- const e2 = s.indexOf("m", j);
666
- if (e2 !== -1) {
667
- j = e2;
668
- continue;
669
- }
670
- }
671
- hasMore = true;
672
- break;
673
- }
674
- if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}`;
675
- else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST;
676
- rows.push(row);
677
- return rows;
678
- }
679
- // Normal wrap — carry ANSI state forward
680
- const state = ansiState(row);
681
- rows.push(row + RST);
682
- row = state + fillBg;
683
- vis = 0;
684
- if (rows.length >= maxRows - 1) {
685
- onLastRow = true;
686
- effW = w > 2 ? w - 1 : w;
687
- }
688
- }
689
-
690
- row += s[i];
691
- vis++;
692
- i++;
693
- }
694
-
695
- // Final row, padded
696
- if (row.length > 0 || rows.length === 0) {
697
- rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + RST);
698
- }
699
- return rows;
700
- }
701
-
702
- function lnum(n: number | null, w: number, fg = FG_LNUM): string {
703
- if (n === null) return " ".repeat(w);
704
- const v = String(n);
705
- return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
706
- }
707
-
708
- function shortPath(cwd: string, home: string, p: string): string {
709
- if (!p) return "";
710
- const r = relative(cwd, p);
711
- if (!r.startsWith("..") && !r.startsWith("/")) return r;
712
- return p.replace(home, "~");
713
- }
714
-
715
- function summarize(a: number, d: number): string {
716
- const p: string[] = [];
717
- if (a > 0) p.push(`${FG_ADD}+${a}${RST}`);
718
- if (d > 0) p.push(`${FG_DEL}-${d}${RST}`);
719
- return p.length ? p.join(" ") : `${FG_DIM}no changes${RST}`;
720
- }
721
-
722
- function rule(w: number): string {
723
- return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
724
- }
725
-
726
- /**
727
- * Decide whether split view is readable for the given terminal width.
728
- * Prefers split view — side-by-side is always easier to scan.
729
- * Falls back to unified only when code columns would be too cramped
730
- * or too many lines would wrap even with adaptive truncation.
731
- */
732
- function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
733
- if (!diff.lines.length) return false;
734
- if (tw < SPLIT_MIN_WIDTH) return false;
735
-
736
- const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
737
- const half = Math.floor((tw - 1) / 2); // -1 for center divider
738
- const gw = nw + 5; // border + num + sign + sp + │ + sp
739
- const cw = Math.max(12, half - gw);
740
- if (cw < SPLIT_MIN_CODE_WIDTH) return false;
741
-
742
- // Estimate how many lines would need wrapping at this code width
743
- const vis = diff.lines.slice(0, maxRows);
744
- let contentLines = 0;
745
- let wrapCandidates = 0;
746
- for (const l of vis) {
747
- if (l.type === "sep") continue;
748
- contentLines++;
749
- if (tabs(l.content).length > cw) wrapCandidates++;
750
- }
751
- if (contentLines === 0) return true;
752
-
753
- const wrapRatio = wrapCandidates / contentLines;
754
- if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false;
755
- if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false;
756
- return true;
757
- }
758
-
759
- // ---------------------------------------------------------------------------
760
- // Language detection
761
- // ---------------------------------------------------------------------------
762
-
763
- const EXT_LANG: Record<string, BundledLanguage> = {
764
- ts: "typescript",
765
- tsx: "tsx",
766
- js: "javascript",
767
- jsx: "jsx",
768
- mjs: "javascript",
769
- cjs: "javascript",
770
- py: "python",
771
- rb: "ruby",
772
- rs: "rust",
773
- go: "go",
774
- java: "java",
775
- c: "c",
776
- cpp: "cpp",
777
- h: "c",
778
- hpp: "cpp",
779
- cs: "csharp",
780
- swift: "swift",
781
- kt: "kotlin",
782
- html: "html",
783
- css: "css",
784
- scss: "scss",
785
- json: "json",
786
- yaml: "yaml",
787
- yml: "yaml",
788
- toml: "toml",
789
- md: "markdown",
790
- sql: "sql",
791
- sh: "bash",
792
- bash: "bash",
793
- zsh: "bash",
794
- lua: "lua",
795
- php: "php",
796
- dart: "dart",
797
- xml: "xml",
798
- graphql: "graphql",
799
- svelte: "svelte",
800
- vue: "vue",
801
- };
802
-
803
- function lang(fp: string): BundledLanguage | undefined {
804
- return EXT_LANG[extname(fp).slice(1).toLowerCase()];
805
- }
806
-
807
- // ---------------------------------------------------------------------------
808
- // Shiki ANSI cache + pre-warm
809
- // ---------------------------------------------------------------------------
810
-
811
- // Pre-warm the Shiki singleton (loads WASM grammars + theme) so the first
812
- // diff render doesn't pay the ~200-500ms startup cost.
813
- codeToANSI("", "typescript", THEME).catch(() => {});
814
-
815
- const _cache = new Map<string, string[]>();
816
-
817
- function _touch(k: string, v: string[]): string[] {
818
- _cache.delete(k);
819
- _cache.set(k, v);
820
- while (_cache.size > CACHE_LIMIT) {
821
- const first = _cache.keys().next().value;
822
- if (first === undefined) break;
823
- _cache.delete(first);
824
- }
825
- return v;
826
- }
827
-
828
- async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
829
- if (!code) return [""];
830
- if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
831
-
832
- const k = `${THEME}\0${language}\0${code}`;
833
- const hit = _cache.get(k);
834
- if (hit) return _touch(k, hit);
835
-
836
- try {
837
- const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
838
- const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
839
- return _touch(k, out);
840
- } catch {
841
- return code.split("\n");
842
- }
843
- }
844
-
845
- // ---------------------------------------------------------------------------
846
- // Diff parsing
847
- // ---------------------------------------------------------------------------
848
-
849
- function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
850
- const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
851
- const lines: DiffLine[] = [];
852
- let added = 0,
853
- removed = 0;
854
-
855
- for (let hi = 0; hi < patch.hunks.length; hi++) {
856
- if (hi > 0) {
857
- const prev = patch.hunks[hi - 1];
858
- const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
859
- lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
860
- }
861
- const h = patch.hunks[hi];
862
- let oL = h.oldStart,
863
- nL = h.newStart;
864
- for (const raw of h.lines) {
865
- if (raw === "\") continue;
866
- const ch = raw[0],
867
- text = raw.slice(1);
868
- if (ch === "+") {
869
- lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
870
- added++;
871
- } else if (ch === "-") {
872
- lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
873
- removed++;
874
- } else {
875
- lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
876
- }
877
- }
878
- }
879
- return { lines, added, removed, chars: oldContent.length + newContent.length };
880
- }
881
-
882
- // ---------------------------------------------------------------------------
883
- // Word diff + bg injection
884
- //
885
- // Key insight: Shiki's codeToANSI only emits fg codes (\x1b[38;...m and
886
- // \x1b[39m). It never sets backgrounds. So we can layer a diff bg underneath
887
- // and it persists through all fg switches. For word-level emphasis we swap
888
- // the bg to a brighter shade at changed character positions.
889
- // ---------------------------------------------------------------------------
890
-
891
- /**
892
- * Combined word diff analysis — single Diff.diffWords() call returns both
893
- * similarity score and character ranges for emphasis highlighting.
894
- * Replaces separate wordDiffRanges + wordDiffSimilarity (which called diffWords twice).
895
- */
896
- function wordDiffAnalysis(
897
- a: string,
898
- b: string,
899
- ): {
900
- similarity: number;
901
- oldRanges: Array<[number, number]>;
902
- newRanges: Array<[number, number]>;
903
- } {
904
- if (!a && !b) return { similarity: 1, oldRanges: [], newRanges: [] };
905
- const parts = Diff.diffWords(a, b);
906
- const oldRanges: Array<[number, number]> = [];
907
- const newRanges: Array<[number, number]> = [];
908
- let oPos = 0,
909
- nPos = 0,
910
- same = 0;
911
- for (const p of parts) {
912
- if (p.removed) {
913
- oldRanges.push([oPos, oPos + p.value.length]);
914
- oPos += p.value.length;
915
- } else if (p.added) {
916
- newRanges.push([nPos, nPos + p.value.length]);
917
- nPos += p.value.length;
918
- } else {
919
- const len = p.value.length;
920
- same += len;
921
- oPos += len;
922
- nPos += len;
923
- }
924
- }
925
- const maxLen = Math.max(a.length, b.length);
926
- return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
927
- }
928
-
929
- /**
930
- * Inject diff background into Shiki ANSI output.
931
- * `baseBg` on unchanged spans, `hlBg` on changed character ranges.
932
- * Re-injects bg after any full reset (\x1b[0m).
933
- *
934
- * Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
935
- */
936
- function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string {
937
- if (!ranges.length) return baseBg + ansiLine + RST;
938
-
939
- let out = baseBg;
940
- let vis = 0;
941
- let inHL = false;
942
- let ri = 0; // current range index
943
- let i = 0;
944
-
945
- while (i < ansiLine.length) {
946
- if (ansiLine[i] === "\x1b") {
947
- const m = ansiLine.indexOf("m", i);
948
- if (m !== -1) {
949
- const seq = ansiLine.slice(i, m + 1);
950
- out += seq;
951
- // Re-inject bg after full reset
952
- if (seq === "\x1b[0m") out += inHL ? hlBg : baseBg;
953
- i = m + 1;
954
- continue;
955
- }
956
- }
957
- // Advance past exhausted ranges
958
- while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
959
- const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
960
- if (want !== inHL) {
961
- inHL = want;
962
- out += inHL ? hlBg : baseBg;
963
- }
964
- out += ansiLine[i];
965
- vis++;
966
- i++;
967
- }
968
- return out + RST;
969
- }
970
-
971
- /** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
972
- function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
973
- const parts = Diff.diffWords(oldText, newText);
974
- let o = "",
975
- n = "";
976
- for (const p of parts) {
977
- if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
978
- else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
979
- else {
980
- o += p.value;
981
- n += p.value;
982
- }
983
- }
984
- return { old: o, new: n };
985
- }
986
-
987
- // ---------------------------------------------------------------------------
988
- // Stacked (unified) view — clean single-column layout
989
- //
990
- // Modelled after Shiki diff/GitHub stacked view:
991
- // • Single line-number column (shows old num for del/ctx, new num for add)
992
- // • Compact gutter: "NNN-│" or "NNN+│" or "NNN │"
993
- // • Full-width code — no side-by-side cramming
994
- // • Hunk separators as "··· N unmodified lines ···"
995
- // • Paired del/add lines adjacent with word-level emphasis
996
- // ---------------------------------------------------------------------------
997
-
998
- async function renderUnified(
999
- diff: ParsedDiff,
1000
- language: BundledLanguage | undefined,
1001
- max = MAX_RENDER_LINES,
1002
- dc: DiffColors = DEFAULT_DIFF_COLORS,
1003
- ): Promise<string> {
1004
- if (!diff.lines.length) return "";
1005
-
1006
- const vis = diff.lines.slice(0, max);
1007
- const tw = termW();
1008
- const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
1009
- const gw = nw + 5; // border + num + sign + sp + │ + sp
1010
- const cw = Math.max(20, tw - gw);
1011
- const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
1012
-
1013
- // Build separate old/new code blocks for highlighting
1014
- const oldSrc: string[] = [],
1015
- newSrc: string[] = [];
1016
- for (const l of vis) {
1017
- if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
1018
- if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
1019
- }
1020
- const [oldHL, newHL] = canHL
1021
- ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
1022
- : [oldSrc, newSrc];
1023
-
1024
- let oI = 0,
1025
- nI = 0,
1026
- idx = 0;
1027
- const out: string[] = [];
1028
- out.push(rule(tw));
1029
-
1030
- /** Emit a single stacked row with compact gutter + left border bar. */
1031
- function emitRow(
1032
- num: number | null,
1033
- sign: string,
1034
- gutterBg: string,
1035
- signFg: string,
1036
- body: string,
1037
- bodyBg = "",
1038
- ): void {
1039
- const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
1040
- const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_BASE} `;
1041
- const numFg = borderFg || FG_LNUM;
1042
- const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `;
1043
- const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `;
1044
- const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg);
1045
- out.push(`${gutter}${rows[0]}${RST}`);
1046
- for (let r = 1; r < rows.length; r++) out.push(`${contGutter}${rows[r]}${RST}`);
1047
- }
1048
-
1049
- while (idx < vis.length) {
1050
- const l = vis[idx];
1051
-
1052
- // Hunk separator — collapsed context
1053
- if (l.type === "sep") {
1054
- const gap = l.newNum;
1055
- const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
1056
- const totalW = Math.min(tw, 72);
1057
- const pad = Math.max(0, totalW - label.length - 2);
1058
- const half1 = Math.floor(pad / 2),
1059
- half2 = pad - half1;
1060
- out.push(`${BG_BASE}${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
1061
- idx++;
1062
- continue;
1063
- }
1064
-
1065
- // Context line — dimmed, single line number
1066
- if (l.type === "ctx") {
1067
- const hl = oldHL[oI] ?? l.content;
1068
- emitRow(l.newNum, " ", BG_BASE, dc.fgCtx, `${BG_BASE}${DIM}${hl}`, BG_BASE);
1069
- oI++;
1070
- nI++;
1071
- idx++;
1072
- continue;
1073
- }
1074
-
1075
- // Collect del/add blocks
1076
- const dels: Array<{ l: DiffLine; hl: string }> = [];
1077
- while (idx < vis.length && vis[idx].type === "del") {
1078
- dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
1079
- oI++;
1080
- idx++;
1081
- }
1082
- const adds: Array<{ l: DiffLine; hl: string }> = [];
1083
- while (idx < vis.length && vis[idx].type === "add") {
1084
- adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
1085
- nI++;
1086
- idx++;
1087
- }
1088
-
1089
- // 1:1 paired → word diff emphasis
1090
- const isPaired = dels.length === 1 && adds.length === 1;
1091
- const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null;
1092
-
1093
- if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
1094
- const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W);
1095
- const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W);
1096
- emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL);
1097
- emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD);
1098
- continue;
1099
- }
1100
- if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
1101
- const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
1102
- emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL);
1103
- emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD);
1104
- continue;
1105
- }
1106
-
1107
- // Multi-line blocks — syntax highlighted with diff bg
1108
- for (const d of dels) {
1109
- const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`;
1110
- emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL);
1111
- }
1112
- for (const a of adds) {
1113
- const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`;
1114
- emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD);
1115
- }
1116
- }
1117
-
1118
- out.push(rule(tw));
1119
- if (diff.lines.length > vis.length) {
1120
- out.push(`${BG_BASE}${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`);
1121
- }
1122
- return out.join("\n");
1123
- }
1124
-
1125
- // ---------------------------------------------------------------------------
1126
- // Split view (auto-fallback to unified when narrow)
1127
- // ---------------------------------------------------------------------------
1128
-
1129
- async function renderSplit(
1130
- diff: ParsedDiff,
1131
- language: BundledLanguage | undefined,
1132
- max = MAX_PREVIEW_LINES,
1133
- dc: DiffColors = DEFAULT_DIFF_COLORS,
1134
- ): Promise<string> {
1135
- const tw = termW();
1136
- if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
1137
- if (!diff.lines.length) return "";
1138
-
1139
- // Build rows
1140
- type Row = { left: DiffLine | null; right: DiffLine | null };
1141
- const rows: Row[] = [];
1142
- let i = 0;
1143
- while (i < diff.lines.length) {
1144
- const l = diff.lines[i];
1145
- if (l.type === "sep" || l.type === "ctx") {
1146
- rows.push({ left: l, right: l });
1147
- i++;
1148
- continue;
1149
- }
1150
- const dels: DiffLine[] = [],
1151
- adds: DiffLine[] = [];
1152
- while (i < diff.lines.length && diff.lines[i].type === "del") {
1153
- dels.push(diff.lines[i]);
1154
- i++;
1155
- }
1156
- while (i < diff.lines.length && diff.lines[i].type === "add") {
1157
- adds.push(diff.lines[i]);
1158
- i++;
1159
- }
1160
- const n = Math.max(dels.length, adds.length);
1161
- for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
1162
- }
1163
-
1164
- const vis = rows.slice(0, max);
1165
- const half = Math.floor((tw - 1) / 2); // -1 for center divider
1166
- const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
1167
- const gw = nw + 5; // border + num + sign + sp + │ + sp
1168
- const cw = Math.max(12, half - gw);
1169
- const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
1170
-
1171
- // Build separate code blocks per side
1172
- const leftSrc: string[] = [],
1173
- rightSrc: string[] = [];
1174
- for (const r of vis) {
1175
- if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
1176
- if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
1177
- }
1178
- const [leftHL, rightHL] = canHL
1179
- ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
1180
- : [leftSrc, rightSrc];
1181
-
1182
- let lI = 0,
1183
- rI = 0;
1184
- let stripeRow = 0; // tracks row index for diagonal stripe offset
1185
-
1186
- // Returns { gutter, contGutter, body } for wrapping composition
1187
- type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
1188
-
1189
- function half_build(
1190
- line: DiffLine | null,
1191
- hl: string,
1192
- ranges: Array<[number, number]> | null,
1193
- side: "left" | "right",
1194
- ): HalfResult {
1195
- // Empty filler — diagonal stripes
1196
- if (!line) {
1197
- const gw2 = nw + 2; // number + sign + space before │
1198
- const gPat = FG_STRIPE + "╱".repeat(gw2) + RST;
1199
- const g = ` ${gPat}${FG_RULE}│${RST} `;
1200
- return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
1201
- }
1202
- // Hunk separator
1203
- if (line.type === "sep") {
1204
- const gap = line.newNum;
1205
- const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
1206
- const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
1207
- return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
1208
- }
1209
-
1210
- const isDel = line.type === "del",
1211
- isAdd = line.type === "add";
1212
- const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE;
1213
- const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
1214
- const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
1215
- const sign = isDel ? "-" : isAdd ? "+" : " ";
1216
- const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
1217
-
1218
- // Border bar + colored line numbers for changed lines
1219
- const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
1220
- const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_BASE}`;
1221
- const numFg = borderFg || FG_LNUM;
1222
-
1223
- let body: string;
1224
- if (ranges && ranges.length > 0) {
1225
- body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
1226
- } else if (isDel || isAdd) {
1227
- body = `${cBg}${hl}`;
1228
- } else {
1229
- body = `${BG_BASE}${DIM}${hl}`;
1230
- }
1231
-
1232
- const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `;
1233
- const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${RST} ${FG_RULE}│${RST} `;
1234
- const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg);
1235
- return { gutter, contGutter, bodyRows };
1236
- }
1237
-
1238
- const out: string[] = [];
1239
- // Column headers — "old" / "new" positioned above line numbers
1240
- const hdrOld = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}`;
1241
- const hdrNew = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}`;
1242
- out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`);
1243
- out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
1244
-
1245
- for (const r of vis) {
1246
- const leftLine = r.left,
1247
- rightLine = r.right;
1248
- const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
1249
- const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
1250
-
1251
- let lResult: HalfResult, rResult: HalfResult;
1252
-
1253
- if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
1254
- const lhl = leftHL[lI++] ?? leftLine.content;
1255
- const rhl = rightHL[rI++] ?? rightLine.content;
1256
- lResult = half_build(leftLine, lhl, wd.oldRanges, "left");
1257
- rResult = half_build(rightLine, rhl, wd.newRanges, "right");
1258
- } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
1259
- const pwd = plainWordDiff(leftLine.content, rightLine.content);
1260
- lI++;
1261
- rI++;
1262
- lResult = half_build(leftLine, pwd.old, null, "left");
1263
- rResult = half_build(rightLine, pwd.new, null, "right");
1264
- } else {
1265
- const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
1266
- const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
1267
- lResult = half_build(leftLine, lhl, null, "left");
1268
- rResult = half_build(rightLine, rhl, null, "right");
1269
- }
1270
-
1271
- // Compose wrapped rows — pad shorter side with striped continuation rows
1272
- const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
1273
- const leftIsEmpty = !r.left;
1274
- const rightIsEmpty = !r.right;
1275
- for (let row = 0; row < maxRows; row++) {
1276
- const lg = row === 0 ? lResult.gutter : lResult.contGutter;
1277
- const rg = row === 0 ? rResult.gutter : rResult.contGutter;
1278
- const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1279
- const rb =
1280
- rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1281
- out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
1282
- stripeRow++;
1283
- }
1284
- }
1285
-
1286
- out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
1287
- if (rows.length > vis.length) {
1288
- out.push(`${BG_BASE}${FG_DIM} … ${rows.length - vis.length} more lines${RST}`);
1289
- }
1290
- return out.join("\n");
1291
- }
1292
-
1293
- // ---------------------------------------------------------------------------
1294
- // Extension
1295
- // ---------------------------------------------------------------------------
1296
-
1297
- export const __testing = {
1298
- normalizeShikiContrast,
1299
- parseDiff,
1300
- renderSplit,
1301
- renderUnified,
1302
- };
1303
-
1304
- export default function diffRendererExtension(pi: any): void {
1305
- // Apply diff theme palette from settings/presets before rendering
1306
- applyDiffPalette();
1307
-
1308
- let createWriteTool: any, createEditTool: any, TextComponent: any;
1309
- try {
1310
- const sdk = require("@mariozechner/pi-coding-agent");
1311
- createWriteTool = sdk.createWriteTool;
1312
- createEditTool = sdk.createEditTool;
1313
- TextComponent = require("@mariozechner/pi-tui").Text;
1314
- } catch {
1315
- return;
1316
- }
1317
- if (!createWriteTool || !createEditTool || !TextComponent) return;
1318
-
1319
- const cwd = process.cwd();
1320
- const home = process.env.HOME ?? "";
1321
- const sp = (p: string) => shortPath(cwd, home, p);
1322
-
1323
- // =======================================================================
1324
- // write
1325
- // =======================================================================
1326
-
1327
- const origWrite = createWriteTool(cwd);
1328
-
1329
- pi.registerTool({
1330
- ...origWrite,
1331
- name: "write",
1332
-
1333
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1334
- const fp = params.path ?? params.file_path ?? "";
1335
- let old: string | null = null;
1336
- try {
1337
- if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
1338
- } catch {
1339
- old = null;
1340
- }
1341
-
1342
- const result = await origWrite.execute(tid, params, sig, upd, ctx);
1343
- const content = params.content ?? "";
1344
-
1345
- // Store in details — the only custom field TUI preserves in renderResult
1346
- if (old !== null && old !== content) {
1347
- const diff = parseDiff(old, content);
1348
- const lg = lang(fp);
1349
- (result as any).details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg };
1350
- } else if (old === null) {
1351
- const lineCount = content ? content.split("\n").length : 0;
1352
- (result as any).details = { _type: "new", lines: lineCount, content: content ?? "", filePath: fp };
1353
- } else if (old === content) {
1354
- (result as any).details = { _type: "noChange" };
1355
- }
1356
- return result;
1357
- },
1358
-
1359
- renderCall(args: any, theme: any, ctx: any) {
1360
- const fp = args?.path ?? args?.file_path ?? "";
1361
- const isNew = !fp || !existsSync(fp);
1362
- const label = isNew ? "create" : "write";
1363
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1364
- const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
1365
-
1366
- // Streaming
1367
- if (args?.content && !ctx.argsComplete) {
1368
- const n = String(args.content).split("\n").length;
1369
- text.setText(`${hdr} ${theme.fg("muted", `(${n} lines…)`)}`);
1370
- return text;
1371
- }
1372
-
1373
- // New file preview with Shiki
1374
- if (args?.content && ctx.argsComplete && isNew) {
1375
- const previewKey = `create:${fp}:${String(args.content).length}`;
1376
- if (ctx.state._previewKey !== previewKey) {
1377
- ctx.state._previewKey = previewKey;
1378
- ctx.state._previewText = hdr;
1379
- const lg = lang(fp);
1380
- hlBlock(args.content, lg)
1381
- .then((lines: string[]) => {
1382
- if (ctx.state._previewKey !== previewKey) return;
1383
- const maxShow = ctx.expanded ? lines.length : 16;
1384
- const preview = lines.slice(0, maxShow).join("\n");
1385
- const rem = lines.length - maxShow;
1386
- let out = `${hdr}\n\n${preview}`;
1387
- if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
1388
- ctx.state._previewText = out;
1389
- ctx.invalidate();
1390
- })
1391
- .catch(() => {});
1392
- }
1393
- text.setText(ctx.state._previewText ?? hdr);
1394
- return text;
1395
- }
1396
-
1397
- text.setText(hdr);
1398
- return text;
1399
- },
1400
-
1401
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1402
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1403
- if (ctx.isError) {
1404
- const e =
1405
- result.content
1406
- ?.filter((c: any) => c.type === "text")
1407
- .map((c: any) => c.text || "")
1408
- .join("\n") ?? "Error";
1409
- text.setText(`\n${theme.fg("error", e)}`);
1410
- return text;
1411
- }
1412
- const d = result.details;
1413
- if (d?._type === "diff") {
1414
- const w = termW();
1415
- const key = `wd:${w}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`;
1416
- if (ctx.state._wdk !== key) {
1417
- ctx.state._wdk = key;
1418
- ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
1419
- const dc = resolveDiffColors(theme);
1420
- renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
1421
- .then((rendered: string) => {
1422
- if (ctx.state._wdk !== key) return;
1423
- ctx.state._wdt = ` ${d.summary}\n${rendered}`;
1424
- ctx.invalidate();
1425
- })
1426
- .catch(() => {
1427
- if (ctx.state._wdk !== key) return;
1428
- ctx.state._wdt = ` ${d.summary}`;
1429
- ctx.invalidate();
1430
- });
1431
- }
1432
- text.setText(ctx.state._wdt ?? ` ${d.summary}`);
1433
- return text;
1434
- }
1435
- if (d?._type === "noChange") {
1436
- text.setText(` ${theme.fg("muted", "✓ no changes")}`);
1437
- return text;
1438
- }
1439
- if (d?._type === "new") {
1440
- const { lines: lineCount, content: rawContent, filePath: fp } = d;
1441
- const pk = `nf:${fp}:${lineCount}`;
1442
- if (ctx.state._nfk !== pk) {
1443
- ctx.state._nfk = pk;
1444
- ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
1445
- const lg = lang(fp);
1446
- if (rawContent) {
1447
- hlBlock(rawContent, lg)
1448
- .then((hlLines: string[]) => {
1449
- if (ctx.state._nfk !== pk) return;
1450
- const maxShow = ctx.expanded ? hlLines.length : 12;
1451
- const preview = hlLines.slice(0, maxShow).join("\n");
1452
- const rem = hlLines.length - maxShow;
1453
- let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
1454
- if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
1455
- ctx.state._nft = out;
1456
- ctx.invalidate();
1457
- })
1458
- .catch(() => {});
1459
- }
1460
- }
1461
- text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
1462
- return text;
1463
- }
1464
- text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
1465
- return text;
1466
- },
1467
- });
1468
-
1469
- // =======================================================================
1470
- // edit
1471
- // =======================================================================
1472
-
1473
- const origEdit = createEditTool(cwd);
1474
-
1475
- function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
1476
- if (Array.isArray(input?.edits)) {
1477
- return input.edits
1478
- .map((edit: any) => ({
1479
- oldText:
1480
- typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
1481
- newText:
1482
- typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
1483
- }))
1484
- .filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
1485
- }
1486
-
1487
- const oldText =
1488
- typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
1489
- const newText =
1490
- typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1491
- return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1492
- }
1493
-
1494
- function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) {
1495
- const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
1496
- const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
1497
- const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
1498
- return {
1499
- diffs,
1500
- totalAdded,
1501
- totalRemoved,
1502
- summary: summarize(totalAdded, totalRemoved),
1503
- };
1504
- }
1505
-
1506
- pi.registerTool({
1507
- ...origEdit,
1508
- name: "edit",
1509
-
1510
- async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
1511
- const fp = params.path ?? params.file_path ?? "";
1512
- const operations = getEditOperations(params);
1513
- const result = await origEdit.execute(tid, params, sig, upd, ctx);
1514
-
1515
- if (operations.length === 0) return result;
1516
-
1517
- const { diffs, summary } = summarizeEditOperations(operations);
1518
- if (operations.length === 1) {
1519
- let editLine = 0;
1520
- try {
1521
- if (fp && existsSync(fp)) {
1522
- const f = readFileSync(fp, "utf-8");
1523
- const idx = f.indexOf(operations[0].newText);
1524
- if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
1525
- }
1526
- } catch {
1527
- editLine = 0;
1528
- }
1529
- (result as any).details = { _type: "editInfo", summary, editLine };
1530
- return result;
1531
- }
1532
-
1533
- (result as any).details = {
1534
- _type: "multiEditInfo",
1535
- summary,
1536
- editCount: operations.length,
1537
- diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
1538
- };
1539
- return result;
1540
- },
1541
-
1542
- renderCall(args: any, theme: any, ctx: any) {
1543
- const fp = args?.path ?? args?.file_path ?? "";
1544
- const operations = getEditOperations(args);
1545
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1546
- const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1547
-
1548
- if (!(ctx.argsComplete && operations.length > 0)) {
1549
- text.setText(hdr);
1550
- return text;
1551
- }
1552
-
1553
- const pk = JSON.stringify({ fp, operations, w: termW() });
1554
- if (ctx.state._pk !== pk) {
1555
- ctx.state._pk = pk;
1556
- ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
1557
- const lg = lang(fp);
1558
- const dc = resolveDiffColors(theme);
1559
-
1560
- if (operations.length === 1) {
1561
- const diff = parseDiff(operations[0].oldText, operations[0].newText);
1562
- renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
1563
- .then((rendered) => {
1564
- if (ctx.state._pk !== pk) return;
1565
- ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1566
- ctx.invalidate();
1567
- })
1568
- .catch(() => {
1569
- if (ctx.state._pk !== pk) return;
1570
- ctx.state._pt = `${hdr} ${summarize(diff.added, diff.removed)}`;
1571
- ctx.invalidate();
1572
- });
1573
- } else {
1574
- const { diffs, summary } = summarizeEditOperations(operations);
1575
- const maxShown = Math.min(operations.length, 3);
1576
- const previewLines = Math.max(8, Math.floor(MAX_PREVIEW_LINES / maxShown));
1577
- Promise.all(
1578
- diffs.slice(0, maxShown).map((diff, index) =>
1579
- renderSplit(diff, lg, previewLines, dc)
1580
- .then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`)
1581
- .catch(() => `Edit ${index + 1}/${operations.length} ${summarize(diff.added, diff.removed)}`),
1582
- ),
1583
- )
1584
- .then((sections) => {
1585
- if (ctx.state._pk !== pk) return;
1586
- const remainder = operations.length - maxShown;
1587
- const suffix = remainder > 0 ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` : "";
1588
- ctx.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
1589
- ctx.invalidate();
1590
- })
1591
- .catch(() => {
1592
- if (ctx.state._pk !== pk) return;
1593
- ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`;
1594
- ctx.invalidate();
1595
- });
1596
- }
1597
- }
1598
-
1599
- text.setText(ctx.state._pt ?? hdr);
1600
- return text;
1601
- },
1602
-
1603
- renderResult(result: any, _opt: any, theme: any, ctx: any) {
1604
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1605
- if (ctx.isError) {
1606
- const e =
1607
- result.content
1608
- ?.filter((c: any) => c.type === "text")
1609
- .map((c: any) => c.text || "")
1610
- .join("\n") ?? "Error";
1611
- text.setText(`\n${theme.fg("error", e)}`);
1612
- return text;
1613
- }
1614
- if (result.details?._type === "editInfo") {
1615
- const { summary: s, editLine } = result.details;
1616
- const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
1617
- const content = ` ${s}${loc}`;
1618
- const vis = content.replace(ANSI_RE, "").length;
1619
- const pad = Math.max(0, termW() - vis);
1620
- text.setText(`${content}${" ".repeat(pad)}`);
1621
- return text;
1622
- }
1623
- if (result.details?._type === "multiEditInfo") {
1624
- const { summary: s, editCount, diffLineCount } = result.details;
1625
- const content = ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`;
1626
- const vis = content.replace(ANSI_RE, "").length;
1627
- const pad = Math.max(0, termW() - vis);
1628
- text.setText(`${content}${" ".repeat(pad)}`);
1629
- return text;
1630
- }
1631
- text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
1632
- return text;
1633
- },
1634
- });
1635
- }