@giladbarnea/pi-user-agents 0.0.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.
@@ -0,0 +1,957 @@
1
+ import {
2
+ getLanguageFromPath,
3
+ highlightCode,
4
+ type ThemeColor,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import {
8
+ ANSI_BG_RESET,
9
+ type AnsiParts,
10
+ ansiHasBackground,
11
+ ansiPartsFromStyled,
12
+ hasAnsi,
13
+ padVisible,
14
+ sgrClearsBackground,
15
+ stripAnsi,
16
+ STYLE_MARKER,
17
+ truncateAnsi,
18
+ updateActiveAnsiStyle,
19
+ visibleLength,
20
+ visibleWidth,
21
+ } from "./ansi.js";
22
+ import { CAPS, DIFF_SPLIT_MIN_WIDTH, GLYPHS } from "./constants.js";
23
+ import { borderMuted, type RenderTheme } from "./theme.js";
24
+ import { normalizeTerminalText } from "./text.js";
25
+
26
+ const DIFF_SPLIT_MIN_CODE_WIDTH = 24;
27
+ const DIFF_SPLIT_MAX_WRAP_LINES = 8;
28
+ const DIFF_SPLIT_MAX_WRAP_RATIO = 0.55;
29
+ const DIFF_LCS_CELL_LIMIT = 250_000;
30
+ const DIFF_CONTEXT_LINES = 3;
31
+ const DIFF_HIGHLIGHT_MAX_CHARS = 5000;
32
+ const WORD_DIFF_CELL_LIMIT = 32_000;
33
+ const WORD_DIFF_MIN_SIMILARITY = 0.2;
34
+ const HIGHLIGHT_CACHE_LIMIT = 4000;
35
+
36
+ const DIFF_ADD_BG_TOKEN = "toolSuccessBg";
37
+ const DIFF_DEL_BG_TOKEN = "toolErrorBg";
38
+ const DIFF_WORD_BG_TOKEN = "selectedBg";
39
+ const DIFF_ADD_BG_FALLBACK = "\x1b[48;2;24;58;38m";
40
+ const DIFF_DEL_BG_FALLBACK = "\x1b[48;2;66;31;43m";
41
+
42
+ export type DiffKind = "ctx" | "add" | "del" | "sep";
43
+
44
+ export interface StructuredDiffLine {
45
+ content: string;
46
+ hunk?: number;
47
+ newNum: number | null;
48
+ oldNum: number | null;
49
+ type: DiffKind;
50
+ }
51
+
52
+ export interface StructuredDiff {
53
+ additions: number;
54
+ hunks?: number;
55
+ lines: StructuredDiffLine[];
56
+ path?: string;
57
+ removals: number;
58
+ }
59
+
60
+ /** Background escape codes resolved once per render instead of once per line. */
61
+ interface DiffPalette {
62
+ add: AnsiParts;
63
+ del: AnsiParts;
64
+ word: AnsiParts;
65
+ }
66
+
67
+ const NO_BG: AnsiParts = { open: "", close: "" };
68
+
69
+ function bgPartsFor(theme: RenderTheme, token: string, fallbackOpen: string): AnsiParts {
70
+ if (theme.bg) {
71
+ const parts = ansiPartsFromStyled(theme.bg(token, STYLE_MARKER));
72
+ if (ansiHasBackground(parts.open)) return parts;
73
+ }
74
+ return fallbackOpen ? { open: fallbackOpen, close: ANSI_BG_RESET } : NO_BG;
75
+ }
76
+
77
+ function diffPalette(theme: RenderTheme): DiffPalette {
78
+ return {
79
+ add: bgPartsFor(theme, DIFF_ADD_BG_TOKEN, DIFF_ADD_BG_FALLBACK),
80
+ del: bgPartsFor(theme, DIFF_DEL_BG_TOKEN, DIFF_DEL_BG_FALLBACK),
81
+ word: bgPartsFor(theme, DIFF_WORD_BG_TOKEN, ""),
82
+ };
83
+ }
84
+
85
+ function wrapBg(parts: AnsiParts, text: string): string {
86
+ return parts.open ? `${parts.open}${text}${parts.close}` : text;
87
+ }
88
+
89
+ /** Re-open the background after any escape that resets it, so the whole row stays tinted. */
90
+ function applyFullLineBg(parts: AnsiParts, text: string): string {
91
+ if (!parts.open) return text;
92
+ const reapplied = text.replace(/\x1b\[[0-9;]*m/g, (code) =>
93
+ sgrClearsBackground(code) ? `${code}${parts.open}` : code,
94
+ );
95
+ return `${parts.open}${reapplied}${parts.close}`;
96
+ }
97
+
98
+ function lineBg(palette: DiffPalette, line: StructuredDiffLine | null): AnsiParts {
99
+ if (line?.type === "add") return palette.add;
100
+ if (line?.type === "del") return palette.del;
101
+ return NO_BG;
102
+ }
103
+
104
+ // --- syntax highlighting -----------------------------------------------------------------
105
+
106
+ const highlightCache = new Map<string, string>();
107
+
108
+ function languageForPath(path: string | undefined): string | undefined {
109
+ if (!path) return undefined;
110
+ return getLanguageFromPath(path) as string | undefined;
111
+ }
112
+
113
+ function highlightDiffContent(content: string, path: string | undefined): string {
114
+ const display = normalizeTerminalText(content);
115
+ if (!display || display.length > DIFF_HIGHLIGHT_MAX_CHARS) return display;
116
+ const language = languageForPath(path);
117
+ if (!language) return display;
118
+ const key = `${language}:${display}`;
119
+ const cached = highlightCache.get(key);
120
+ if (cached !== undefined) return cached;
121
+ const highlighted = highlightCode(display, language)[0] ?? display;
122
+ if (highlightCache.size > HIGHLIGHT_CACHE_LIMIT) highlightCache.clear();
123
+ highlightCache.set(key, highlighted);
124
+ return highlighted;
125
+ }
126
+
127
+ // --- word-level highlighting -------------------------------------------------------------
128
+
129
+ interface WordToken {
130
+ end: number;
131
+ start: number;
132
+ text: string;
133
+ }
134
+
135
+ interface WordDiffRanges {
136
+ newRanges: Array<[number, number]>;
137
+ oldRanges: Array<[number, number]>;
138
+ similarity: number;
139
+ }
140
+
141
+ function wordTokens(text: string): WordToken[] {
142
+ const tokens: WordToken[] = [];
143
+ const pattern = /\s+|[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+/g;
144
+ let match: RegExpExecArray | null;
145
+ while ((match = pattern.exec(text)))
146
+ tokens.push({ end: pattern.lastIndex, start: match.index, text: match[0] });
147
+ return tokens;
148
+ }
149
+
150
+ function changedRanges(tokens: WordToken[], common: boolean[]): Array<[number, number]> {
151
+ const ranges: Array<[number, number]> = [];
152
+ let start: number | null = null;
153
+ let end = 0;
154
+ for (let index = 0; index < tokens.length; index++) {
155
+ const token = tokens[index]!;
156
+ if (!common[index] && token.text.trim().length > 0) {
157
+ if (start === null) start = token.start;
158
+ end = token.end;
159
+ } else if (start !== null) {
160
+ ranges.push([start, end]);
161
+ start = null;
162
+ }
163
+ }
164
+ if (start !== null) ranges.push([start, end]);
165
+ return ranges;
166
+ }
167
+
168
+ function wordDiffRanges(oldText: string, newText: string): WordDiffRanges {
169
+ const oldTokens = wordTokens(oldText);
170
+ const newTokens = wordTokens(newText);
171
+ if (oldTokens.length === 0 && newTokens.length === 0)
172
+ return { newRanges: [], oldRanges: [], similarity: 1 };
173
+ if (oldTokens.length * newTokens.length > WORD_DIFF_CELL_LIMIT)
174
+ return { newRanges: [], oldRanges: [], similarity: 0 };
175
+ const width = newTokens.length + 1;
176
+ const table = new Uint16Array((oldTokens.length + 1) * (newTokens.length + 1));
177
+ for (let i = oldTokens.length - 1; i >= 0; i--) {
178
+ for (let j = newTokens.length - 1; j >= 0; j--) {
179
+ table[i * width + j] =
180
+ oldTokens[i]!.text === newTokens[j]!.text
181
+ ? table[(i + 1) * width + j + 1]! + 1
182
+ : Math.max(table[(i + 1) * width + j]!, table[i * width + j + 1]!);
183
+ }
184
+ }
185
+ const oldCommon = new Array(oldTokens.length).fill(false);
186
+ const newCommon = new Array(newTokens.length).fill(false);
187
+ let i = 0;
188
+ let j = 0;
189
+ let commonChars = 0;
190
+ while (i < oldTokens.length && j < newTokens.length) {
191
+ if (oldTokens[i]!.text === newTokens[j]!.text) {
192
+ oldCommon[i] = true;
193
+ newCommon[j] = true;
194
+ commonChars += oldTokens[i]!.text.length;
195
+ i++;
196
+ j++;
197
+ } else if (table[(i + 1) * width + j]! >= table[i * width + j + 1]!) {
198
+ i++;
199
+ } else {
200
+ j++;
201
+ }
202
+ }
203
+ return {
204
+ newRanges: changedRanges(newTokens, newCommon),
205
+ oldRanges: changedRanges(oldTokens, oldCommon),
206
+ similarity: commonChars / Math.max(oldText.length, newText.length, 1),
207
+ };
208
+ }
209
+
210
+ function styleRanges(
211
+ text: string,
212
+ ranges: Array<[number, number]>,
213
+ baseStyle: (value: string) => string,
214
+ highlightStyle: (value: string) => string,
215
+ ): string {
216
+ if (ranges.length === 0 || hasAnsi(text)) return baseStyle(text);
217
+ const sorted = [...ranges].sort((left, right) => left[0] - right[0]);
218
+ let out = "";
219
+ let offset = 0;
220
+ for (const [start, end] of sorted) {
221
+ if (end <= offset || start >= text.length) continue;
222
+ const safeStart = Math.max(offset, start);
223
+ const safeEnd = Math.min(text.length, end);
224
+ if (safeStart > offset) out += baseStyle(text.slice(offset, safeStart));
225
+ out += highlightStyle(text.slice(safeStart, safeEnd));
226
+ offset = safeEnd;
227
+ }
228
+ if (offset < text.length) out += baseStyle(text.slice(offset));
229
+ return out;
230
+ }
231
+
232
+ /** Apply word highlighting to text that already carries syntax-highlighting escapes. */
233
+ function styleAnsiVisibleRanges(
234
+ text: string,
235
+ ranges: Array<[number, number]>,
236
+ theme: RenderTheme,
237
+ fgToken: ThemeColor,
238
+ baseBg: AnsiParts,
239
+ highlightBg: AnsiParts,
240
+ ): string {
241
+ if (!hasAnsi(text)) {
242
+ const base = (value: string) => wrapBg(baseBg, theme.fg(fgToken, value));
243
+ const highlight = (value: string) => wrapBg(highlightBg, theme.fg(fgToken, value));
244
+ return styleRanges(text, ranges, base, highlight);
245
+ }
246
+
247
+ const sorted = [...ranges].sort((left, right) => left[0] - right[0]);
248
+ let rangeIndex = 0;
249
+ let visibleIndex = 0;
250
+ let activeStyle = "";
251
+ let out = "";
252
+ const ansiPattern = /\x1b\[[0-9;]*m/g;
253
+
254
+ function inHighlightRange(index: number): boolean {
255
+ while (rangeIndex < sorted.length && index >= sorted[rangeIndex]![1]) rangeIndex++;
256
+ const range = sorted[rangeIndex];
257
+ return Boolean(range && index >= range[0] && index < range[1]);
258
+ }
259
+
260
+ function emitChunk(chunk: string, highlighted: boolean): void {
261
+ if (!chunk) return;
262
+ const content = activeStyle ? chunk : theme.fg(fgToken, chunk);
263
+ out += wrapBg(highlighted ? highlightBg : baseBg, content);
264
+ if (activeStyle) out += activeStyle;
265
+ }
266
+
267
+ function emitPlain(plain: string): void {
268
+ let chunk = "";
269
+ let highlighted: boolean | undefined;
270
+ for (const character of plain) {
271
+ const nextHighlighted = inHighlightRange(visibleIndex);
272
+ if (highlighted !== undefined && nextHighlighted !== highlighted) {
273
+ emitChunk(chunk, highlighted);
274
+ chunk = "";
275
+ }
276
+ highlighted = nextHighlighted;
277
+ chunk += character;
278
+ visibleIndex++;
279
+ }
280
+ if (highlighted !== undefined) emitChunk(chunk, highlighted);
281
+ }
282
+
283
+ let offset = 0;
284
+ let match: RegExpExecArray | null;
285
+ while ((match = ansiPattern.exec(text))) {
286
+ emitPlain(text.slice(offset, match.index));
287
+ out += match[0];
288
+ activeStyle = updateActiveAnsiStyle(match[0]);
289
+ offset = match.index + match[0].length;
290
+ }
291
+ emitPlain(text.slice(offset));
292
+ return out;
293
+ }
294
+
295
+ // --- building a structured diff ----------------------------------------------------------
296
+
297
+ function splitContentLines(text: string): string[] {
298
+ if (!text) return [];
299
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
300
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
301
+ return lines;
302
+ }
303
+
304
+ function diffOps(
305
+ oldLines: string[],
306
+ newLines: string[],
307
+ ): Array<{ text: string; type: "ctx" | "add" | "del" }> {
308
+ let start = 0;
309
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start])
310
+ start++;
311
+ let oldEnd = oldLines.length - 1;
312
+ let newEnd = newLines.length - 1;
313
+ while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
314
+ oldEnd--;
315
+ newEnd--;
316
+ }
317
+ const ops: Array<{ text: string; type: "ctx" | "add" | "del" }> = [];
318
+ for (let index = 0; index < start; index++) ops.push({ text: oldLines[index] ?? "", type: "ctx" });
319
+ const oldMid = oldLines.slice(start, oldEnd + 1);
320
+ const newMid = newLines.slice(start, newEnd + 1);
321
+ if (oldMid.length * newMid.length > DIFF_LCS_CELL_LIMIT) {
322
+ for (const text of oldMid) ops.push({ text, type: "del" });
323
+ for (const text of newMid) ops.push({ text, type: "add" });
324
+ } else {
325
+ const rows = oldMid.length;
326
+ const columns = newMid.length;
327
+ const width = columns + 1;
328
+ const table = new Uint32Array((rows + 1) * (columns + 1));
329
+ for (let i = rows - 1; i >= 0; i--) {
330
+ for (let j = columns - 1; j >= 0; j--) {
331
+ table[i * width + j] =
332
+ oldMid[i] === newMid[j]
333
+ ? table[(i + 1) * width + j + 1]! + 1
334
+ : Math.max(table[(i + 1) * width + j]!, table[i * width + j + 1]!);
335
+ }
336
+ }
337
+ let i = 0;
338
+ let j = 0;
339
+ while (i < rows && j < columns) {
340
+ if (oldMid[i] === newMid[j]) {
341
+ ops.push({ text: oldMid[i] ?? "", type: "ctx" });
342
+ i++;
343
+ j++;
344
+ } else if (table[(i + 1) * width + j]! >= table[i * width + j + 1]!) {
345
+ ops.push({ text: oldMid[i] ?? "", type: "del" });
346
+ i++;
347
+ } else {
348
+ ops.push({ text: newMid[j] ?? "", type: "add" });
349
+ j++;
350
+ }
351
+ }
352
+ while (i < rows) ops.push({ text: oldMid[i++] ?? "", type: "del" });
353
+ while (j < columns) ops.push({ text: newMid[j++] ?? "", type: "add" });
354
+ }
355
+ for (let index = oldEnd + 1; index < oldLines.length; index++)
356
+ ops.push({ text: oldLines[index] ?? "", type: "ctx" });
357
+ return ops;
358
+ }
359
+
360
+ export function hiddenDiffLine(count: number): StructuredDiffLine {
361
+ return {
362
+ content: count > 0 ? `… ${count} unchanged line${count === 1 ? "" : "s"} …` : "…",
363
+ newNum: null,
364
+ oldNum: null,
365
+ type: "sep",
366
+ };
367
+ }
368
+
369
+ export function assignHunkNumbers(lines: StructuredDiffLine[]): {
370
+ hunks: number;
371
+ lines: StructuredDiffLine[];
372
+ } {
373
+ let hunk = 0;
374
+ let inHunk = false;
375
+ const numbered = lines.map((line) => {
376
+ if (line.type === "sep") {
377
+ inHunk = false;
378
+ return { ...line, hunk: undefined };
379
+ }
380
+ if (line.type === "add" || line.type === "del") {
381
+ if (!inHunk) {
382
+ hunk++;
383
+ inHunk = true;
384
+ }
385
+ return { ...line, hunk };
386
+ }
387
+ return inHunk ? { ...line, hunk } : line;
388
+ });
389
+ return { hunks: hunk, lines: numbered };
390
+ }
391
+
392
+ export function countStructuredHunks(lines: StructuredDiffLine[]): number {
393
+ return assignHunkNumbers(lines).hunks;
394
+ }
395
+
396
+ function hiddenHunksAfter(allRows: StructuredDiffLine[], shownRows: StructuredDiffLine[]): number {
397
+ const hunkNumbers = (lines: StructuredDiffLine[]) =>
398
+ new Set(lines.map((line) => line.hunk).filter((hunk): hunk is number => typeof hunk === "number"));
399
+ const shown = hunkNumbers(shownRows);
400
+ let count = 0;
401
+ for (const hunk of hunkNumbers(allRows.slice(shownRows.length))) if (!shown.has(hunk)) count++;
402
+ return count;
403
+ }
404
+
405
+ function compactStructuredDiffLines(lines: StructuredDiffLine[]): StructuredDiffLine[] {
406
+ const changed = lines
407
+ .map((line, index) => (line.type === "add" || line.type === "del" ? index : -1))
408
+ .filter((index) => index >= 0);
409
+ if (changed.length === 0) return lines;
410
+
411
+ const ranges: Array<{ end: number; start: number }> = [];
412
+ for (const index of changed) {
413
+ const start = Math.max(0, index - DIFF_CONTEXT_LINES);
414
+ const end = Math.min(lines.length - 1, index + DIFF_CONTEXT_LINES);
415
+ const previous = ranges[ranges.length - 1];
416
+ if (!previous || start > previous.end + 1) ranges.push({ start, end });
417
+ else previous.end = Math.max(previous.end, end);
418
+ }
419
+
420
+ const compacted: StructuredDiffLine[] = [];
421
+ let previousEnd = -1;
422
+ for (const range of ranges) {
423
+ const hidden = range.start - previousEnd - 1;
424
+ if (hidden > 0) compacted.push(hiddenDiffLine(hidden));
425
+ compacted.push(...lines.slice(range.start, range.end + 1));
426
+ previousEnd = range.end;
427
+ }
428
+ const trailingHidden = lines.length - previousEnd - 1;
429
+ if (trailingHidden > 0) compacted.push(hiddenDiffLine(trailingHidden));
430
+ return compacted;
431
+ }
432
+
433
+ /** @example buildStructuredDiff("a", "b").additions // 1 */
434
+ export function buildStructuredDiff(oldText: string, newText: string): StructuredDiff {
435
+ const ops = diffOps(splitContentLines(oldText), splitContentLines(newText));
436
+ let oldNum = 1;
437
+ let newNum = 1;
438
+ let additions = 0;
439
+ let removals = 0;
440
+ const lines: StructuredDiffLine[] = [];
441
+ for (const op of ops) {
442
+ if (op.type === "ctx") {
443
+ lines.push({ content: op.text, newNum, oldNum, type: "ctx" });
444
+ oldNum++;
445
+ newNum++;
446
+ } else if (op.type === "del") {
447
+ lines.push({ content: op.text, newNum: null, oldNum, type: "del" });
448
+ oldNum++;
449
+ removals++;
450
+ } else {
451
+ lines.push({ content: op.text, newNum, oldNum: null, type: "add" });
452
+ newNum++;
453
+ additions++;
454
+ }
455
+ }
456
+ const numbered = assignHunkNumbers(compactStructuredDiffLines(lines));
457
+ return { additions, hunks: numbered.hunks, lines: numbered.lines, removals };
458
+ }
459
+
460
+ /** Read the edit operations an `edit` call requested, tolerating both argument spellings. */
461
+ export function editOperationsFromArgs(
462
+ args: Record<string, unknown>,
463
+ ): Array<{ newText: string; oldText: string }> {
464
+ const asText = (value: unknown): string => (typeof value === "string" ? value : "");
465
+ if (Array.isArray(args.edits)) {
466
+ return (args.edits as Array<Record<string, unknown>>)
467
+ .map((edit) => ({
468
+ oldText: asText(edit?.oldText ?? edit?.old_text),
469
+ newText: asText(edit?.newText ?? edit?.new_text),
470
+ }))
471
+ .filter((edit) => edit.oldText.length > 0 && edit.oldText !== edit.newText);
472
+ }
473
+ const oldText = asText(args.oldText ?? args.old_text);
474
+ const newText = asText(args.newText ?? args.new_text);
475
+ return oldText.length > 0 && oldText !== newText ? [{ oldText, newText }] : [];
476
+ }
477
+
478
+ // --- summaries ---------------------------------------------------------------------------
479
+
480
+ function diffStatBar(additions: number, removals: number, theme: RenderTheme): string {
481
+ const total = additions + removals;
482
+ if (total <= 0) return "";
483
+ const slots = Math.max(6, Math.min(18, Math.ceil(total / 3)));
484
+ let addSlots = Math.round((additions / total) * slots);
485
+ if (additions > 0 && addSlots === 0) addSlots = 1;
486
+ if (removals > 0 && addSlots === slots) addSlots = slots - 1;
487
+ return `${theme.fg("dim", "[")}${theme.fg("toolDiffAdded", GLYPHS.line.repeat(addSlots))}${theme.fg(
488
+ "toolDiffRemoved",
489
+ GLYPHS.line.repeat(slots - addSlots),
490
+ )}${theme.fg("dim", "]")}`;
491
+ }
492
+
493
+ export function diffSummary(diff: StructuredDiff, theme: RenderTheme): string {
494
+ const parts: string[] = [];
495
+ if (diff.additions > 0) parts.push(theme.fg("success", `+${diff.additions}`));
496
+ if (diff.removals > 0) parts.push(theme.fg("error", `-${diff.removals}`));
497
+ if (parts.length === 0) return theme.fg("muted", "no changes");
498
+ const bar = diffStatBar(diff.additions, diff.removals, theme);
499
+ const hunks = diff.hunks ?? countStructuredHunks(diff.lines);
500
+ let summary = `${parts.join(" ")}${bar ? ` ${bar}` : ""}`;
501
+ if (hunks > 0)
502
+ summary += theme.fg("dim", `${GLYPHS.dot}${hunks} hunk${hunks === 1 ? "" : "s"}`);
503
+ return summary;
504
+ }
505
+
506
+ // --- rendering ---------------------------------------------------------------------------
507
+
508
+ function colorDiffText(
509
+ line: StructuredDiffLine,
510
+ text: string,
511
+ theme: RenderTheme,
512
+ palette: DiffPalette,
513
+ ranges: Array<[number, number]>,
514
+ ): string {
515
+ if (line.type === "sep") return theme.fg("dim", text);
516
+ if (line.type === "ctx") return hasAnsi(text) ? text : theme.fg("toolDiffContext", text);
517
+ const fgToken = line.type === "add" ? "toolDiffAdded" : "toolDiffRemoved";
518
+ return styleAnsiVisibleRanges(text, ranges, theme, fgToken, lineBg(palette, line), palette.word);
519
+ }
520
+
521
+ function formatNum(value: number | null, width: number): string {
522
+ return value === null ? " ".repeat(width) : String(value).padStart(width);
523
+ }
524
+
525
+ function lineWordRanges(
526
+ line: StructuredDiffLine,
527
+ mate: StructuredDiffLine | null,
528
+ ): Array<[number, number]> {
529
+ if (!mate) return [];
530
+ const opposed =
531
+ (line.type === "del" && mate.type === "add") || (line.type === "add" && mate.type === "del");
532
+ if (!opposed) return [];
533
+ const oldText = normalizeTerminalText(line.type === "del" ? line.content : mate.content);
534
+ const newText = normalizeTerminalText(line.type === "add" ? line.content : mate.content);
535
+ const ranges = wordDiffRanges(oldText, newText);
536
+ if (ranges.similarity < WORD_DIFF_MIN_SIMILARITY) return [];
537
+ return line.type === "del" ? ranges.oldRanges : ranges.newRanges;
538
+ }
539
+
540
+ function highlightedLineBody(line: StructuredDiffLine, path: string | undefined): string {
541
+ if (line.type === "sep") return line.content || " ";
542
+ return highlightDiffContent(line.content, path) || " ";
543
+ }
544
+
545
+ const FRAME = { bl: "└", br: "┘", h: "─", joint: "┴", tl: "┌", tm: "┬", tr: "┐", v: "│" } as const;
546
+
547
+ /** Resolve the border escape codes once instead of styling every glyph separately. */
548
+ function diffBorderStyler(theme: RenderTheme): (text: string) => string {
549
+ const sample = FRAME.h.repeat(2);
550
+ const styledSample = borderMuted(theme, sample);
551
+ const sampleIndex = styledSample.indexOf(sample);
552
+ if (sampleIndex < 0 || styledSample === sample) return (text: string) => borderMuted(theme, text);
553
+ const open = styledSample.slice(0, sampleIndex);
554
+ const close = styledSample.slice(sampleIndex + sample.length);
555
+ return (text: string) => `${open}${text}${close}`;
556
+ }
557
+
558
+ function renderUnifiedLine(
559
+ line: StructuredDiffLine,
560
+ width: number,
561
+ numWidth: number,
562
+ theme: RenderTheme,
563
+ palette: DiffPalette,
564
+ path: string | undefined,
565
+ ranges: Array<[number, number]> = [],
566
+ ): string {
567
+ const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
568
+ const signToken =
569
+ line.type === "add"
570
+ ? "toolDiffAdded"
571
+ : line.type === "del"
572
+ ? "toolDiffRemoved"
573
+ : "toolDiffContext";
574
+ const gutter = `${theme.fg("muted", `${formatNum(line.oldNum, numWidth)} ${formatNum(line.newNum, numWidth)}`)} ${theme.fg(signToken, sign)} `;
575
+ const contentWidth = Math.max(10, width - visibleLength(gutter));
576
+ const body = colorDiffText(line, highlightedLineBody(line, path), theme, palette, ranges);
577
+ return padVisible(`${gutter}${truncateAnsi(body, contentWidth)}`, width);
578
+ }
579
+
580
+ /** Group consecutive deletions with the additions that replaced them, so they can be paired. */
581
+ function walkDiffRows(
582
+ rows: StructuredDiffLine[],
583
+ onContext: (line: StructuredDiffLine) => void,
584
+ onPair: (del: StructuredDiffLine | null, add: StructuredDiffLine | null) => void,
585
+ ): void {
586
+ let index = 0;
587
+ while (index < rows.length) {
588
+ const line = rows[index]!;
589
+ if (line.type === "ctx" || line.type === "sep") {
590
+ onContext(line);
591
+ index++;
592
+ continue;
593
+ }
594
+ const dels: StructuredDiffLine[] = [];
595
+ const adds: StructuredDiffLine[] = [];
596
+ while (index < rows.length && rows[index]!.type === "del") dels.push(rows[index++]!);
597
+ while (index < rows.length && rows[index]!.type === "add") adds.push(rows[index++]!);
598
+ for (let offset = 0; offset < Math.max(dels.length, adds.length); offset++)
599
+ onPair(dels[offset] ?? null, adds[offset] ?? null);
600
+ }
601
+ }
602
+
603
+ function numberWidth(diff: StructuredDiff): number {
604
+ const maxNum = Math.max(1, ...diff.lines.map((line) => Math.max(line.oldNum ?? 0, line.newNum ?? 0)));
605
+ return Math.max(2, String(maxNum).length);
606
+ }
607
+
608
+ function renderUnifiedDiff(
609
+ diff: StructuredDiff,
610
+ rows: StructuredDiffLine[],
611
+ width: number,
612
+ theme: RenderTheme,
613
+ palette: DiffPalette,
614
+ path: string | undefined,
615
+ ): string[] {
616
+ const numWidth = numberWidth(diff);
617
+ const border = diffBorderStyler(theme);
618
+ const cellWidth = Math.max(1, width - 2);
619
+ const contentWidth = Math.max(1, cellWidth - 2);
620
+ const rule = border(FRAME.h.repeat(cellWidth));
621
+ const out: string[] = [`${border(FRAME.tl)}${rule}${border(FRAME.tr)}`];
622
+ const push = (line: StructuredDiffLine, ranges: Array<[number, number]> = []) => {
623
+ const cell = ` ${renderUnifiedLine(line, contentWidth, numWidth, theme, palette, path, ranges)} `;
624
+ out.push(`${border(FRAME.v)}${applyFullLineBg(lineBg(palette, line), cell)}${border(FRAME.v)}`);
625
+ };
626
+ walkDiffRows(
627
+ rows,
628
+ (line) => push(line),
629
+ (del, add) => {
630
+ if (del) push(del, lineWordRanges(del, add));
631
+ if (add) push(add, lineWordRanges(add, del));
632
+ },
633
+ );
634
+ out.push(`${border(FRAME.bl)}${rule}${border(FRAME.br)}`);
635
+ return out;
636
+ }
637
+
638
+ function renderDiffHalf(
639
+ line: StructuredDiffLine | null,
640
+ side: "old" | "new",
641
+ width: number,
642
+ numWidth: number,
643
+ theme: RenderTheme,
644
+ palette: DiffPalette,
645
+ path: string | undefined,
646
+ ranges: Array<[number, number]>,
647
+ ): string {
648
+ if (!line) return " ".repeat(width);
649
+ const num = side === "old" ? line.oldNum : line.newNum;
650
+ const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
651
+ const prefix = `${formatNum(num, numWidth)} ${sign} `;
652
+ const raw = `${prefix}${highlightedLineBody(line, path)}`;
653
+ const shifted = ranges.map(([start, end]): [number, number] => [
654
+ start + prefix.length,
655
+ end + prefix.length,
656
+ ]);
657
+ return padVisible(truncateAnsi(colorDiffText(line, raw, theme, palette, shifted), width), width);
658
+ }
659
+
660
+ function renderDiffCell(
661
+ line: StructuredDiffLine | null,
662
+ side: "old" | "new",
663
+ cellWidth: number,
664
+ numWidth: number,
665
+ theme: RenderTheme,
666
+ palette: DiffPalette,
667
+ path: string | undefined,
668
+ ranges: Array<[number, number]>,
669
+ ): string {
670
+ const contentWidth = Math.max(1, cellWidth - 2);
671
+ const cell = ` ${renderDiffHalf(line, side, contentWidth, numWidth, theme, palette, path, ranges)} `;
672
+ return applyFullLineBg(lineBg(palette, line), padVisible(cell, cellWidth));
673
+ }
674
+
675
+ function shouldUseSplitDiff(
676
+ diff: StructuredDiff,
677
+ rows: StructuredDiffLine[],
678
+ width: number,
679
+ ): boolean {
680
+ if (width < DIFF_SPLIT_MIN_WIDTH) return false;
681
+ const innerWidth = Math.max(2, width - 3);
682
+ const codeWidth = Math.max(24, Math.floor(innerWidth / 2)) - 2 - numberWidth(diff) - 3;
683
+ if (codeWidth < DIFF_SPLIT_MIN_CODE_WIDTH) return false;
684
+ let contentLines = 0;
685
+ let wrapCandidates = 0;
686
+ for (const line of rows) {
687
+ if (line.type === "sep") continue;
688
+ contentLines++;
689
+ if (visibleLength(normalizeTerminalText(line.content)) > codeWidth) wrapCandidates++;
690
+ }
691
+ if (contentLines === 0) return true;
692
+ if (wrapCandidates >= DIFF_SPLIT_MAX_WRAP_LINES) return false;
693
+ return wrapCandidates / contentLines < DIFF_SPLIT_MAX_WRAP_RATIO;
694
+ }
695
+
696
+ function renderSplitDiff(
697
+ diff: StructuredDiff,
698
+ rows: StructuredDiffLine[],
699
+ width: number,
700
+ theme: RenderTheme,
701
+ palette: DiffPalette,
702
+ path: string | undefined,
703
+ ): string[] {
704
+ const numWidth = numberWidth(diff);
705
+ const border = diffBorderStyler(theme);
706
+ const innerWidth = Math.max(2, width - 3);
707
+ const leftWidth = Math.max(1, Math.floor(innerWidth / 2));
708
+ const rightWidth = Math.max(1, innerWidth - leftWidth);
709
+ const rule = (cells: number) => border(FRAME.h.repeat(Math.max(1, cells)));
710
+ const out = [
711
+ `${border(FRAME.tl)}${rule(leftWidth)}${border(FRAME.tm)}${rule(rightWidth)}${border(FRAME.tr)}`,
712
+ ];
713
+ const push = (left: StructuredDiffLine | null, right: StructuredDiffLine | null) => {
714
+ const leftRanges = left && right ? lineWordRanges(left, right) : [];
715
+ const rightRanges = left && right ? lineWordRanges(right, left) : [];
716
+ out.push(
717
+ `${border(FRAME.v)}${renderDiffCell(left, "old", leftWidth, numWidth, theme, palette, path, leftRanges)}${border(FRAME.v)}${renderDiffCell(right, "new", rightWidth, numWidth, theme, palette, path, rightRanges)}${border(FRAME.v)}`,
718
+ );
719
+ };
720
+ walkDiffRows(
721
+ rows,
722
+ (line) => push(line, line),
723
+ (del, add) => push(del, add),
724
+ );
725
+ out.push(
726
+ `${border(FRAME.bl)}${rule(leftWidth)}${border(FRAME.joint)}${rule(rightWidth)}${border(FRAME.br)}`,
727
+ );
728
+ return out;
729
+ }
730
+
731
+ function overflowHint(remainingLines: number, hiddenHunks: number, width: number): string {
732
+ const candidates = [
733
+ `${GLYPHS.ellipsis} ${remainingLines} more diff lines${hiddenHunks > 0 ? `${GLYPHS.dot}${hiddenHunks} more hunks` : ""}`,
734
+ `${GLYPHS.ellipsis} +${remainingLines}${hiddenHunks > 0 ? `${GLYPHS.dot}+${hiddenHunks}h` : ""}`,
735
+ GLYPHS.ellipsis,
736
+ ];
737
+ return candidates.find((candidate) => visibleWidth(candidate) <= width) ?? GLYPHS.ellipsis;
738
+ }
739
+
740
+ /**
741
+ * Render a diff table at exactly `width` columns.
742
+ *
743
+ * The source extension reads `process.stdout.columns` here, which overflows the overlay and
744
+ * gets silently cut on the right. The overlay always supplies its own inner width instead.
745
+ */
746
+ export function renderStructuredDiff(
747
+ diff: StructuredDiff,
748
+ theme: RenderTheme,
749
+ width: number,
750
+ path?: string,
751
+ rowLimit: number = CAPS.diffRows,
752
+ ): string[] {
753
+ if (diff.additions === 0 && diff.removals === 0) return [theme.fg("muted", "no changes")];
754
+ const tableWidth = Math.max(1, width);
755
+ const rows = diff.lines.slice(0, Math.max(1, rowLimit));
756
+ const palette = diffPalette(theme);
757
+ const effectivePath = path ?? diff.path;
758
+ const rendered = shouldUseSplitDiff(diff, rows, tableWidth)
759
+ ? renderSplitDiff(diff, rows, tableWidth, theme, palette, effectivePath)
760
+ : renderUnifiedDiff(diff, rows, tableWidth, theme, palette, effectivePath);
761
+ const remaining = diff.lines.length - rows.length;
762
+ if (remaining > 0)
763
+ rendered.push(
764
+ theme.fg("dim", overflowHint(remaining, hiddenHunksAfter(diff.lines, rows), tableWidth)),
765
+ );
766
+ return rendered;
767
+ }
768
+
769
+ // --- parsing a stored unified diff --------------------------------------------------------
770
+
771
+ interface UnifiedDiffFile {
772
+ diff: StructuredDiff;
773
+ path: string;
774
+ }
775
+
776
+ interface UnifiedDiffBuilder {
777
+ additions: number;
778
+ hunkCount: number;
779
+ lines: StructuredDiffLine[];
780
+ newHunkEnd: number | null;
781
+ newPath?: string;
782
+ oldHunkEnd: number | null;
783
+ oldPath?: string;
784
+ path: string;
785
+ removals: number;
786
+ sawHunk: boolean;
787
+ }
788
+
789
+ function splitGitHeaderPaths(rest: string): string[] {
790
+ const paths: string[] = [];
791
+ const tokenPattern = /"((?:\\.|[^"])*)"|(\S+)/g;
792
+ let match: RegExpExecArray | null;
793
+ while ((match = tokenPattern.exec(rest))) {
794
+ const raw = match[1] ?? match[2] ?? "";
795
+ if (!raw) continue;
796
+ if (match[1] === undefined) {
797
+ paths.push(raw);
798
+ continue;
799
+ }
800
+ try {
801
+ paths.push(JSON.parse(`"${raw}"`));
802
+ } catch {
803
+ paths.push(raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\"));
804
+ }
805
+ }
806
+ return paths;
807
+ }
808
+
809
+ function cleanDiffPath(raw: string): string {
810
+ const path = raw.trim();
811
+ if (!path || path === "/dev/null") return path;
812
+ if ((path.startsWith("a/") || path.startsWith("b/")) && path.length > 2) return path.slice(2);
813
+ return path;
814
+ }
815
+
816
+ function diffPathFromHeader(line: string): string {
817
+ const value = line.replace(/^(?:---|\+\+\+)\s+/, "").trim().split(/\t/)[0] ?? "";
818
+ return cleanDiffPath(value);
819
+ }
820
+
821
+ function displayUnifiedDiffPath(path: string, oldPath?: string, newPath?: string): string {
822
+ if (newPath && newPath !== "/dev/null") return newPath;
823
+ if (oldPath && oldPath !== "/dev/null") return oldPath;
824
+ return path || "diff";
825
+ }
826
+
827
+ function parseHunkHeader(
828
+ line: string,
829
+ ): { newCount: number; newStart: number; oldCount: number; oldStart: number } | null {
830
+ const match = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
831
+ if (!match) return null;
832
+ return {
833
+ oldStart: Number.parseInt(match[1]!, 10),
834
+ oldCount: match[2] === undefined ? 1 : Number.parseInt(match[2], 10),
835
+ newStart: Number.parseInt(match[3]!, 10),
836
+ newCount: match[4] === undefined ? 1 : Number.parseInt(match[4], 10),
837
+ };
838
+ }
839
+
840
+ /** Turn Pi's persisted `edit` patch into renderable rows with their real line numbers. */
841
+ export function parseUnifiedDiffOutput(output: string): UnifiedDiffFile[] | null {
842
+ const lines = stripAnsi(output).replace(/\r\n/g, "\n").split("\n");
843
+ const files: UnifiedDiffFile[] = [];
844
+ // A holder object instead of a bare `let`: the nested `start`/`finish` closures reassign it,
845
+ // which control-flow analysis cannot see through.
846
+ const state: { current: UnifiedDiffBuilder | null } = { current: null };
847
+
848
+ function finish(): void {
849
+ if (!state.current) return;
850
+ if (state.current.sawHunk && (state.current.additions > 0 || state.current.removals > 0)) {
851
+ const path = displayUnifiedDiffPath(state.current.path, state.current.oldPath, state.current.newPath);
852
+ files.push({
853
+ diff: {
854
+ additions: state.current.additions,
855
+ hunks: state.current.hunkCount || countStructuredHunks(state.current.lines),
856
+ lines: state.current.lines,
857
+ path,
858
+ removals: state.current.removals,
859
+ },
860
+ path,
861
+ });
862
+ }
863
+ state.current = null;
864
+ }
865
+
866
+ function start(path = "diff"): void {
867
+ finish();
868
+ state.current = {
869
+ additions: 0,
870
+ hunkCount: 0,
871
+ lines: [],
872
+ newHunkEnd: null,
873
+ oldHunkEnd: null,
874
+ path,
875
+ removals: 0,
876
+ sawHunk: false,
877
+ };
878
+ }
879
+
880
+ let index = 0;
881
+ while (index < lines.length) {
882
+ const line = lines[index] ?? "";
883
+ if (line.startsWith("diff --git ")) {
884
+ const paths = splitGitHeaderPaths(line.slice("diff --git ".length)).map(cleanDiffPath);
885
+ start(paths[1] || paths[0] || "diff");
886
+ index++;
887
+ continue;
888
+ }
889
+ if (!state.current && line.startsWith("--- ") && (lines[index + 1] ?? "").startsWith("+++ "))
890
+ start(diffPathFromHeader(lines[index + 1] ?? line));
891
+ if (state.current && line.startsWith("--- ")) {
892
+ state.current.oldPath = diffPathFromHeader(line);
893
+ index++;
894
+ continue;
895
+ }
896
+ if (state.current && line.startsWith("+++ ")) {
897
+ state.current.newPath = diffPathFromHeader(line);
898
+ state.current.path = displayUnifiedDiffPath(state.current.path, state.current.oldPath, state.current.newPath);
899
+ index++;
900
+ continue;
901
+ }
902
+ const hunk = state.current ? parseHunkHeader(line) : null;
903
+ if (!state.current || !hunk) {
904
+ index++;
905
+ continue;
906
+ }
907
+
908
+ if (state.current.sawHunk && state.current.oldHunkEnd !== null && state.current.newHunkEnd !== null) {
909
+ const hidden = Math.max(
910
+ hunk.oldStart - state.current.oldHunkEnd - 1,
911
+ hunk.newStart - state.current.newHunkEnd - 1,
912
+ );
913
+ if (hidden > 0) state.current.lines.push(hiddenDiffLine(hidden));
914
+ }
915
+ state.current.sawHunk = true;
916
+ state.current.hunkCount++;
917
+ const hunkNumber = state.current.hunkCount;
918
+ let oldLine = hunk.oldStart;
919
+ let newLine = hunk.newStart;
920
+ let oldConsumed = 0;
921
+ let newConsumed = 0;
922
+ index++;
923
+ while (index < lines.length && (oldConsumed < hunk.oldCount || newConsumed < hunk.newCount)) {
924
+ const raw = lines[index] ?? "";
925
+ if (raw.startsWith("\")) {
926
+ index++;
927
+ continue;
928
+ }
929
+ const marker = raw[0];
930
+ const content = raw.slice(1);
931
+ if (marker === " ") {
932
+ state.current.lines.push({ content, hunk: hunkNumber, newNum: newLine, oldNum: oldLine, type: "ctx" });
933
+ oldLine++;
934
+ newLine++;
935
+ oldConsumed++;
936
+ newConsumed++;
937
+ } else if (marker === "-") {
938
+ state.current.lines.push({ content, hunk: hunkNumber, newNum: null, oldNum: oldLine, type: "del" });
939
+ oldLine++;
940
+ oldConsumed++;
941
+ state.current.removals++;
942
+ } else if (marker === "+") {
943
+ state.current.lines.push({ content, hunk: hunkNumber, newNum: newLine, oldNum: null, type: "add" });
944
+ newLine++;
945
+ newConsumed++;
946
+ state.current.additions++;
947
+ } else {
948
+ break;
949
+ }
950
+ index++;
951
+ }
952
+ state.current.oldHunkEnd = oldLine - 1;
953
+ state.current.newHunkEnd = newLine - 1;
954
+ }
955
+ finish();
956
+ return files.length > 0 ? files : null;
957
+ }