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