pi-tool-display 0.1.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,1923 @@
1
+ import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@mariozechner/pi-tui";
2
+ import { formatSize, getLanguageFromPath, highlightCode, type EditToolDetails } from "@mariozechner/pi-coding-agent";
3
+ import { pluralize, sanitizeAnsiForThemedOutput } from "./render-utils.js";
4
+ import type { ToolDisplayConfig } from "./types.js";
5
+
6
+ interface DiffTheme {
7
+ fg(color: string, text: string): string;
8
+ bold?(text: string): string;
9
+ getFgAnsi?(color: string): string;
10
+ getBgAnsi?(color: string): string;
11
+ }
12
+
13
+ type DiffLineKind = "add" | "remove" | "context";
14
+ type DiffEntryKind = "line" | "meta" | "hunk" | "file";
15
+
16
+ interface DiffLineEntry {
17
+ kind: "line";
18
+ lineKind: DiffLineKind;
19
+ oldLineNumber: number | null;
20
+ newLineNumber: number | null;
21
+ fallbackLineNumber: string;
22
+ content: string;
23
+ raw: string;
24
+ hunkIndex: number;
25
+ }
26
+
27
+ interface DiffMetaEntry {
28
+ kind: Exclude<DiffEntryKind, "line">;
29
+ raw: string;
30
+ hunkIndex: number;
31
+ }
32
+
33
+ type ParsedDiffEntry = DiffLineEntry | DiffMetaEntry;
34
+
35
+ interface ParsedDiff {
36
+ entries: ParsedDiffEntry[];
37
+ stats: DiffStats;
38
+ }
39
+
40
+ interface DiffStats {
41
+ added: number;
42
+ removed: number;
43
+ context: number;
44
+ hunks: number;
45
+ files: number;
46
+ lines: number;
47
+ }
48
+
49
+ interface RenderedRow {
50
+ text: string;
51
+ hunkIndex: number | null;
52
+ }
53
+
54
+ interface SplitDiffRow {
55
+ left?: DiffLineEntry;
56
+ right?: DiffLineEntry;
57
+ meta?: DiffMetaEntry;
58
+ hunkIndex: number | null;
59
+ }
60
+
61
+ interface DiffSpan {
62
+ start: number;
63
+ end: number;
64
+ }
65
+
66
+ interface RgbColor {
67
+ r: number;
68
+ g: number;
69
+ b: number;
70
+ }
71
+
72
+ interface DiffPalette {
73
+ addRowBgAnsi: string;
74
+ removeRowBgAnsi: string;
75
+ addEmphasisBgAnsi: string;
76
+ removeEmphasisBgAnsi: string;
77
+ }
78
+
79
+ interface DiffRenderOptions {
80
+ expanded: boolean;
81
+ filePath?: string;
82
+ previousContent?: string;
83
+ fileExistedBeforeWrite?: boolean;
84
+ }
85
+
86
+ type CodeLineHighlighter = (line: string) => string;
87
+
88
+ const CANONICAL_LINE_PATTERN = /^([+\- ])(\s*\d+)\|(.*)$/;
89
+ const LEGACY_LINE_PATTERN = /^([+\- ])(\s*\d+)\s(.*)$/;
90
+ const HUNK_HEADER_PATTERN = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/;
91
+ const SPLIT_SEPARATOR = " │ ";
92
+ const MIN_LINE_NUMBER_WIDTH = 2;
93
+ const MIN_SPLIT_COLUMN_WIDTH = 24;
94
+ const MAX_INLINE_DIFF_LINE_LENGTH = 700;
95
+ const DEFAULT_RENDER_WIDTH = 120;
96
+ const ADD_ROW_BACKGROUND_MIX_RATIO = 0.24;
97
+ const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
98
+ const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.44;
99
+ const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
100
+ const ADDITION_TINT_TARGET: RgbColor = { r: 84, g: 190, b: 118 };
101
+ const DELETION_TINT_TARGET: RgbColor = { r: 232, g: 95, b: 122 };
102
+ const ANSI_BG_RESET = "\x1b[49m";
103
+ const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
104
+ const STYLE_RESET_PARAMS = [39, 22, 23, 24, 25, 27, 28, 29, 59] as const;
105
+
106
+ function normalizeCodeWhitespace(text: string): string {
107
+ return text.replace(/\t/g, " ");
108
+ }
109
+
110
+ function emphasis(theme: DiffTheme, text: string): string {
111
+ return typeof theme.bold === "function" ? theme.bold(text) : text;
112
+ }
113
+
114
+ function toSgrParams(rawParams: string): number[] {
115
+ if (!rawParams.trim()) {
116
+ return [0];
117
+ }
118
+
119
+ const parsed = rawParams
120
+ .split(";")
121
+ .map((token) => Number.parseInt(token, 10))
122
+ .filter((value) => Number.isFinite(value));
123
+
124
+ return parsed.length > 0 ? parsed : [];
125
+ }
126
+
127
+ function sequenceAffectsBackground(params: number[]): boolean {
128
+ for (let index = 0; index < params.length; index++) {
129
+ const param = params[index] ?? 0;
130
+
131
+ if (param === 0 || param === 49) {
132
+ return true;
133
+ }
134
+
135
+ if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
136
+ return true;
137
+ }
138
+
139
+ if (param === 48) {
140
+ const colorMode = params[index + 1];
141
+ if (colorMode === 5) {
142
+ index += 2;
143
+ return true;
144
+ }
145
+ if (colorMode === 2) {
146
+ index += 4;
147
+ return true;
148
+ }
149
+ return true;
150
+ }
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ function stripBackgroundResetParams(params: number[]): number[] {
157
+ const sanitized: number[] = [];
158
+
159
+ for (let index = 0; index < params.length; index++) {
160
+ const param = params[index] ?? 0;
161
+
162
+ if (param === 0) {
163
+ sanitized.push(...STYLE_RESET_PARAMS);
164
+ continue;
165
+ }
166
+
167
+ if (param === 49) {
168
+ continue;
169
+ }
170
+
171
+ if (param === 38 || param === 48) {
172
+ const colorMode = params[index + 1];
173
+ if (colorMode === 5) {
174
+ const colorValue = params[index + 2];
175
+ if (typeof colorValue === "number" && Number.isFinite(colorValue)) {
176
+ sanitized.push(param, colorMode, colorValue);
177
+ index += 2;
178
+ continue;
179
+ }
180
+ }
181
+ if (colorMode === 2) {
182
+ const red = params[index + 2];
183
+ const green = params[index + 3];
184
+ const blue = params[index + 4];
185
+ if (
186
+ typeof red === "number"
187
+ && typeof green === "number"
188
+ && typeof blue === "number"
189
+ && Number.isFinite(red)
190
+ && Number.isFinite(green)
191
+ && Number.isFinite(blue)
192
+ ) {
193
+ sanitized.push(param, colorMode, red, green, blue);
194
+ index += 4;
195
+ continue;
196
+ }
197
+ }
198
+ }
199
+
200
+ sanitized.push(param);
201
+ }
202
+
203
+ return sanitized;
204
+ }
205
+
206
+ function stabilizeBackgroundResets(text: string): string {
207
+ if (!text || !text.includes("\x1b[")) {
208
+ return text;
209
+ }
210
+
211
+ return text.replace(ANSI_SGR_PATTERN, (_sequence, rawParams: string) => {
212
+ const parsed = toSgrParams(rawParams);
213
+ if (parsed.length === 0) {
214
+ return "";
215
+ }
216
+ const sanitized = stripBackgroundResetParams(parsed);
217
+ if (sanitized.length === 0) {
218
+ return "";
219
+ }
220
+ return `\x1b[${sanitized.join(";")}m`;
221
+ });
222
+ }
223
+
224
+ function padToWidth(text: string, width: number): string {
225
+ const trimmed = truncateToWidth(text, width);
226
+ const gap = Math.max(0, width - visibleWidth(trimmed));
227
+ return gap > 0 ? `${trimmed}${" ".repeat(gap)}` : trimmed;
228
+ }
229
+
230
+ function fitToWidth(text: string, width: number): string {
231
+ const trimmed = truncateToWidth(text, width, "");
232
+ const gap = Math.max(0, width - visibleWidth(trimmed));
233
+ return gap > 0 ? `${trimmed}${" ".repeat(gap)}` : trimmed;
234
+ }
235
+
236
+ function wrapToWidth(text: string, width: number, wordWrap: boolean): string[] {
237
+ if (width <= 0) {
238
+ return [""];
239
+ }
240
+
241
+ if (!wordWrap) {
242
+ return [fitToWidth(text, width)];
243
+ }
244
+
245
+ const wrapped = wrapTextWithAnsi(text, width);
246
+ if (wrapped.length === 0) {
247
+ return [fitToWidth("", width)];
248
+ }
249
+
250
+ return wrapped.map((line) => fitToWidth(line, width));
251
+ }
252
+
253
+ function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
254
+ if (!rawPath || !rawPath.trim()) {
255
+ return undefined;
256
+ }
257
+ const normalizedPath = rawPath.replace(/^@/, "").trim();
258
+ if (!normalizedPath) {
259
+ return undefined;
260
+ }
261
+ try {
262
+ return getLanguageFromPath(normalizedPath);
263
+ } catch {
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ function createCodeLineHighlighter(language: string | undefined): CodeLineHighlighter {
269
+ if (!language) {
270
+ return (line) => sanitizeAnsiForThemedOutput(line);
271
+ }
272
+
273
+ const cache = new Map<string, string>();
274
+ return (line) => {
275
+ if (!line) {
276
+ return line;
277
+ }
278
+ const cached = cache.get(line);
279
+ if (cached !== undefined) {
280
+ return cached;
281
+ }
282
+ try {
283
+ const highlighted = highlightCode(line, language)[0] ?? line;
284
+ const sanitized = sanitizeAnsiForThemedOutput(highlighted);
285
+ cache.set(line, sanitized);
286
+ return sanitized;
287
+ } catch {
288
+ const sanitizedFallback = sanitizeAnsiForThemedOutput(line);
289
+ cache.set(line, sanitizedFallback);
290
+ return sanitizedFallback;
291
+ }
292
+ };
293
+ }
294
+
295
+ function parseCanonicalDiffLine(line: string): {
296
+ lineKind: DiffLineKind;
297
+ lineNumber: string;
298
+ content: string;
299
+ } | null {
300
+ const canonicalMatch = line.match(CANONICAL_LINE_PATTERN);
301
+ const legacyMatch = canonicalMatch ? null : line.match(LEGACY_LINE_PATTERN);
302
+ const matched = canonicalMatch ?? legacyMatch;
303
+ if (!matched) {
304
+ return null;
305
+ }
306
+
307
+ const prefix = matched[1] ?? " ";
308
+ const lineNumber = (matched[2] ?? "").trim();
309
+ const content = matched[3] ?? "";
310
+ if (prefix === "+") {
311
+ return { lineKind: "add", lineNumber, content };
312
+ }
313
+ if (prefix === "-") {
314
+ return { lineKind: "remove", lineNumber, content };
315
+ }
316
+ return { lineKind: "context", lineNumber, content };
317
+ }
318
+
319
+ function toNumber(value: string | undefined): number | null {
320
+ if (!value) {
321
+ return null;
322
+ }
323
+ const parsed = Number.parseInt(value, 10);
324
+ return Number.isNaN(parsed) ? null : parsed;
325
+ }
326
+
327
+ function classifyMetaLine(raw: string): DiffMetaEntry["kind"] {
328
+ if (raw.startsWith("@@")) {
329
+ return "hunk";
330
+ }
331
+ if (
332
+ raw.startsWith("diff --git")
333
+ || raw.startsWith("index ")
334
+ || raw.startsWith("--- ")
335
+ || raw.startsWith("+++ ")
336
+ || raw.startsWith("rename from ")
337
+ || raw.startsWith("rename to ")
338
+ || raw.startsWith("new file mode ")
339
+ || raw.startsWith("deleted file mode ")
340
+ ) {
341
+ return "file";
342
+ }
343
+ return "meta";
344
+ }
345
+
346
+ function createMetaEntry(raw: string, hunkIndex: number): DiffMetaEntry {
347
+ return {
348
+ kind: classifyMetaLine(raw),
349
+ raw,
350
+ hunkIndex,
351
+ };
352
+ }
353
+
354
+ function ensureImplicitHunk(currentHunk: number): number {
355
+ return currentHunk > 0 ? currentHunk : 1;
356
+ }
357
+
358
+ function parseDiff(diffText: string): ParsedDiff {
359
+ const stats: DiffStats = {
360
+ added: 0,
361
+ removed: 0,
362
+ context: 0,
363
+ hunks: 0,
364
+ files: 0,
365
+ lines: 0,
366
+ };
367
+ const entries: ParsedDiffEntry[] = [];
368
+
369
+ if (!diffText.trim()) {
370
+ return { entries, stats };
371
+ }
372
+
373
+ let hunkIndex = 0;
374
+ let oldLineCursor: number | null = null;
375
+ let newLineCursor: number | null = null;
376
+
377
+ for (const rawLine of diffText.replace(/\r/g, "").split("\n")) {
378
+ stats.lines++;
379
+
380
+ const hunkMatch = rawLine.match(HUNK_HEADER_PATTERN);
381
+ if (hunkMatch) {
382
+ hunkIndex++;
383
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
384
+ oldLineCursor = toNumber(hunkMatch[1]);
385
+ newLineCursor = toNumber(hunkMatch[3]);
386
+ entries.push({ kind: "hunk", raw: rawLine, hunkIndex });
387
+ continue;
388
+ }
389
+
390
+ if (rawLine.startsWith("diff --git ")) {
391
+ stats.files++;
392
+ entries.push({ kind: "file", raw: rawLine, hunkIndex });
393
+ continue;
394
+ }
395
+
396
+ const canonical = parseCanonicalDiffLine(rawLine);
397
+ if (canonical) {
398
+ hunkIndex = ensureImplicitHunk(hunkIndex);
399
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
400
+
401
+ const parsedNumber = toNumber(canonical.lineNumber);
402
+ const oldLineNumber = canonical.lineKind !== "add" ? parsedNumber : null;
403
+ const newLineNumber = canonical.lineKind !== "remove" ? parsedNumber : null;
404
+
405
+ if (canonical.lineKind === "add") stats.added++;
406
+ if (canonical.lineKind === "remove") stats.removed++;
407
+ if (canonical.lineKind === "context") stats.context++;
408
+
409
+ entries.push({
410
+ kind: "line",
411
+ lineKind: canonical.lineKind,
412
+ oldLineNumber,
413
+ newLineNumber,
414
+ fallbackLineNumber: canonical.lineNumber,
415
+ content: canonical.content,
416
+ raw: rawLine,
417
+ hunkIndex,
418
+ });
419
+ continue;
420
+ }
421
+
422
+ if (rawLine.startsWith("-") && !rawLine.startsWith("---")) {
423
+ hunkIndex = ensureImplicitHunk(hunkIndex);
424
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
425
+ stats.removed++;
426
+ const oldLineNumber = oldLineCursor;
427
+ if (oldLineCursor !== null) {
428
+ oldLineCursor++;
429
+ }
430
+ entries.push({
431
+ kind: "line",
432
+ lineKind: "remove",
433
+ oldLineNumber,
434
+ newLineNumber: null,
435
+ fallbackLineNumber: oldLineNumber !== null ? `${oldLineNumber}` : "",
436
+ content: rawLine.slice(1),
437
+ raw: rawLine,
438
+ hunkIndex,
439
+ });
440
+ continue;
441
+ }
442
+
443
+ if (rawLine.startsWith("+") && !rawLine.startsWith("+++")) {
444
+ hunkIndex = ensureImplicitHunk(hunkIndex);
445
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
446
+ stats.added++;
447
+ const newLineNumber = newLineCursor;
448
+ if (newLineCursor !== null) {
449
+ newLineCursor++;
450
+ }
451
+ entries.push({
452
+ kind: "line",
453
+ lineKind: "add",
454
+ oldLineNumber: null,
455
+ newLineNumber,
456
+ fallbackLineNumber: newLineNumber !== null ? `${newLineNumber}` : "",
457
+ content: rawLine.slice(1),
458
+ raw: rawLine,
459
+ hunkIndex,
460
+ });
461
+ continue;
462
+ }
463
+
464
+ if (rawLine.startsWith(" ")) {
465
+ hunkIndex = ensureImplicitHunk(hunkIndex);
466
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
467
+ stats.context++;
468
+ const oldLineNumber = oldLineCursor;
469
+ const newLineNumber = newLineCursor;
470
+ if (oldLineCursor !== null) {
471
+ oldLineCursor++;
472
+ }
473
+ if (newLineCursor !== null) {
474
+ newLineCursor++;
475
+ }
476
+ entries.push({
477
+ kind: "line",
478
+ lineKind: "context",
479
+ oldLineNumber,
480
+ newLineNumber,
481
+ fallbackLineNumber: oldLineNumber !== null ? `${oldLineNumber}` : newLineNumber !== null ? `${newLineNumber}` : "",
482
+ content: rawLine.slice(1),
483
+ raw: rawLine,
484
+ hunkIndex,
485
+ });
486
+ continue;
487
+ }
488
+
489
+ entries.push(createMetaEntry(rawLine, hunkIndex));
490
+ }
491
+
492
+ if (stats.hunks === 0 && (stats.added > 0 || stats.removed > 0 || stats.context > 0)) {
493
+ stats.hunks = 1;
494
+ }
495
+ if (stats.files === 0) {
496
+ const patchStyleFileHeaders = entries.filter(
497
+ (entry) => entry.kind === "file" && entry.raw.startsWith("+++ "),
498
+ ).length;
499
+ if (patchStyleFileHeaders > 0) {
500
+ stats.files = patchStyleFileHeaders;
501
+ } else if (stats.hunks > 0) {
502
+ stats.files = 1;
503
+ }
504
+ }
505
+
506
+ return { entries, stats };
507
+ }
508
+
509
+ function getLineNumberWidth(entries: ParsedDiffEntry[]): number {
510
+ let maxWidth = MIN_LINE_NUMBER_WIDTH;
511
+
512
+ for (const entry of entries) {
513
+ if (entry.kind !== "line") {
514
+ continue;
515
+ }
516
+
517
+ const candidates = [
518
+ entry.oldLineNumber,
519
+ entry.newLineNumber,
520
+ toNumber(entry.fallbackLineNumber),
521
+ ].filter((value): value is number => value !== null);
522
+
523
+ for (const candidate of candidates) {
524
+ const digits = `${candidate}`.length;
525
+ if (digits > maxWidth) {
526
+ maxWidth = digits;
527
+ }
528
+ }
529
+ }
530
+
531
+ return maxWidth;
532
+ }
533
+
534
+ function formatLineNumber(value: number | null, fallback: string, width: number): string {
535
+ if (value !== null) {
536
+ return `${value}`.padStart(width, " ");
537
+ }
538
+ if (fallback.trim()) {
539
+ return fallback.trim().slice(-width).padStart(width, " ");
540
+ }
541
+ return " ".repeat(width);
542
+ }
543
+
544
+ function formatMetaEntryRows(entry: DiffMetaEntry, width: number, theme: DiffTheme, wordWrap: boolean): RenderedRow[] {
545
+ const normalized = sanitizeAnsiForThemedOutput(normalizeCodeWhitespace(entry.raw));
546
+ const lines = wordWrap
547
+ ? wrapToWidth(normalized, width, true)
548
+ : [truncateToWidth(normalized, width)];
549
+
550
+ const mapColor = (line: string): string => {
551
+ if (entry.kind === "hunk") {
552
+ return stabilizeBackgroundResets(theme.fg("accent", line));
553
+ }
554
+ if (entry.kind === "file") {
555
+ return stabilizeBackgroundResets(theme.fg("muted", line));
556
+ }
557
+ return stabilizeBackgroundResets(theme.fg("toolDiffContext", line));
558
+ };
559
+
560
+ return lines.map((line) => ({
561
+ text: mapColor(line),
562
+ hunkIndex: entry.kind === "file" ? null : entry.hunkIndex || null,
563
+ }));
564
+ }
565
+
566
+ function buildSplitRows(entries: ParsedDiffEntry[]): SplitDiffRow[] {
567
+ const rows: SplitDiffRow[] = [];
568
+ let index = 0;
569
+
570
+ while (index < entries.length) {
571
+ const entry = entries[index];
572
+ if (!entry) {
573
+ break;
574
+ }
575
+
576
+ if (entry.kind !== "line") {
577
+ rows.push({ meta: entry, hunkIndex: entry.hunkIndex || null });
578
+ index++;
579
+ continue;
580
+ }
581
+
582
+ if (entry.lineKind === "remove") {
583
+ const removed: DiffLineEntry[] = [];
584
+ while (index < entries.length) {
585
+ const candidate = entries[index];
586
+ if (!candidate || candidate.kind !== "line" || candidate.lineKind !== "remove") {
587
+ break;
588
+ }
589
+ removed.push(candidate);
590
+ index++;
591
+ }
592
+
593
+ const added: DiffLineEntry[] = [];
594
+ while (index < entries.length) {
595
+ const candidate = entries[index];
596
+ if (!candidate || candidate.kind !== "line" || candidate.lineKind !== "add") {
597
+ break;
598
+ }
599
+ added.push(candidate);
600
+ index++;
601
+ }
602
+
603
+ const pairCount = Math.max(removed.length, added.length);
604
+ for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
605
+ const left = removed[pairIndex];
606
+ const right = added[pairIndex];
607
+ rows.push({
608
+ left,
609
+ right,
610
+ hunkIndex: left?.hunkIndex ?? right?.hunkIndex ?? null,
611
+ });
612
+ }
613
+ continue;
614
+ }
615
+
616
+ if (entry.lineKind === "add") {
617
+ rows.push({ right: entry, hunkIndex: entry.hunkIndex || null });
618
+ index++;
619
+ continue;
620
+ }
621
+
622
+ rows.push({ left: entry, right: entry, hunkIndex: entry.hunkIndex || null });
623
+ index++;
624
+ }
625
+
626
+ return rows;
627
+ }
628
+
629
+ function getCellLineNumber(line: DiffLineEntry, side: "left" | "right"): number | null {
630
+ if (side === "left") {
631
+ return line.oldLineNumber ?? (line.lineKind === "context" ? line.newLineNumber : null);
632
+ }
633
+ return line.newLineNumber ?? (line.lineKind === "context" ? line.oldLineNumber : null);
634
+ }
635
+
636
+ function tokenizeInlineDiff(input: string): Array<{ value: string; start: number; end: number }> {
637
+ if (!input) {
638
+ return [];
639
+ }
640
+
641
+ const tokens: Array<{ value: string; start: number; end: number }> = [];
642
+ const pattern = /(\s+|[A-Za-z0-9_]+|[^A-Za-z0-9_\s])/g;
643
+ let match: RegExpExecArray | null;
644
+
645
+ while ((match = pattern.exec(input)) !== null) {
646
+ const value = match[0] ?? "";
647
+ if (!value) {
648
+ continue;
649
+ }
650
+ tokens.push({
651
+ value,
652
+ start: match.index,
653
+ end: match.index + value.length,
654
+ });
655
+ }
656
+
657
+ if (tokens.length === 0 && input.length > 0) {
658
+ tokens.push({ value: input, start: 0, end: input.length });
659
+ }
660
+
661
+ return tokens;
662
+ }
663
+
664
+ function mergeSpans(spans: DiffSpan[]): DiffSpan[] {
665
+ if (spans.length <= 1) {
666
+ return spans;
667
+ }
668
+
669
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
670
+ const merged: DiffSpan[] = [sorted[0]];
671
+
672
+ for (let index = 1; index < sorted.length; index++) {
673
+ const current = sorted[index];
674
+ const previous = merged[merged.length - 1];
675
+ if (!current || !previous) {
676
+ continue;
677
+ }
678
+
679
+ if (current.start <= previous.end) {
680
+ previous.end = Math.max(previous.end, current.end);
681
+ continue;
682
+ }
683
+
684
+ merged.push({ ...current });
685
+ }
686
+
687
+ return merged;
688
+ }
689
+
690
+ function tokensToDiffSpans(
691
+ text: string,
692
+ tokens: Array<{ value: string; start: number; end: number }>,
693
+ changedIndexes: Set<number>,
694
+ ): DiffSpan[] {
695
+ if (tokens.length === 0 || changedIndexes.size === 0) {
696
+ return [];
697
+ }
698
+
699
+ const spans: DiffSpan[] = [];
700
+ let start: number | null = null;
701
+ let end = -1;
702
+
703
+ for (let index = 0; index < tokens.length; index++) {
704
+ if (!changedIndexes.has(index)) {
705
+ if (start !== null && end > start) {
706
+ spans.push({ start, end });
707
+ start = null;
708
+ end = -1;
709
+ }
710
+ continue;
711
+ }
712
+
713
+ const token = tokens[index];
714
+ if (!token) {
715
+ continue;
716
+ }
717
+
718
+ if (start === null) {
719
+ start = token.start;
720
+ end = token.end;
721
+ } else {
722
+ end = token.end;
723
+ }
724
+ }
725
+
726
+ if (start !== null && end > start) {
727
+ spans.push({ start, end });
728
+ }
729
+
730
+ const trimmed: DiffSpan[] = [];
731
+ for (const span of spans) {
732
+ let spanStart = span.start;
733
+ let spanEnd = span.end;
734
+
735
+ while (spanStart < spanEnd && /\s/.test(text[spanStart] ?? "")) {
736
+ spanStart++;
737
+ }
738
+ while (spanEnd > spanStart && /\s/.test(text[spanEnd - 1] ?? "")) {
739
+ spanEnd--;
740
+ }
741
+ if (spanEnd > spanStart) {
742
+ trimmed.push({ start: spanStart, end: spanEnd });
743
+ }
744
+ }
745
+
746
+ return mergeSpans(trimmed);
747
+ }
748
+
749
+ function computeInlineDiffSpans(leftLine: string, rightLine: string): { left: DiffSpan[]; right: DiffSpan[] } {
750
+ if (leftLine === rightLine) {
751
+ return { left: [], right: [] };
752
+ }
753
+ if (leftLine.length > MAX_INLINE_DIFF_LINE_LENGTH || rightLine.length > MAX_INLINE_DIFF_LINE_LENGTH) {
754
+ return { left: [], right: [] };
755
+ }
756
+
757
+ const leftTokens = tokenizeInlineDiff(leftLine);
758
+ const rightTokens = tokenizeInlineDiff(rightLine);
759
+ const leftCount = leftTokens.length;
760
+ const rightCount = rightTokens.length;
761
+
762
+ if (leftCount === 0 || rightCount === 0) {
763
+ return {
764
+ left: leftLine.trim().length > 0 ? [{ start: 0, end: leftLine.length }] : [],
765
+ right: rightLine.trim().length > 0 ? [{ start: 0, end: rightLine.length }] : [],
766
+ };
767
+ }
768
+
769
+ const table: number[][] = Array.from({ length: leftCount + 1 }, () => Array<number>(rightCount + 1).fill(0));
770
+
771
+ for (let leftIndex = 1; leftIndex <= leftCount; leftIndex++) {
772
+ const leftToken = leftTokens[leftIndex - 1];
773
+ for (let rightIndex = 1; rightIndex <= rightCount; rightIndex++) {
774
+ const rightToken = rightTokens[rightIndex - 1];
775
+ if (leftToken?.value === rightToken?.value) {
776
+ table[leftIndex][rightIndex] = (table[leftIndex - 1]?.[rightIndex - 1] ?? 0) + 1;
777
+ } else {
778
+ const top = table[leftIndex - 1]?.[rightIndex] ?? 0;
779
+ const side = table[leftIndex]?.[rightIndex - 1] ?? 0;
780
+ table[leftIndex][rightIndex] = Math.max(top, side);
781
+ }
782
+ }
783
+ }
784
+
785
+ const changedLeft = new Set<number>();
786
+ const changedRight = new Set<number>();
787
+ let leftCursor = leftCount;
788
+ let rightCursor = rightCount;
789
+
790
+ while (leftCursor > 0 && rightCursor > 0) {
791
+ const leftToken = leftTokens[leftCursor - 1];
792
+ const rightToken = rightTokens[rightCursor - 1];
793
+ if (leftToken?.value === rightToken?.value) {
794
+ leftCursor--;
795
+ rightCursor--;
796
+ continue;
797
+ }
798
+
799
+ const top = table[leftCursor - 1]?.[rightCursor] ?? 0;
800
+ const side = table[leftCursor]?.[rightCursor - 1] ?? 0;
801
+ if (top >= side) {
802
+ changedLeft.add(leftCursor - 1);
803
+ leftCursor--;
804
+ } else {
805
+ changedRight.add(rightCursor - 1);
806
+ rightCursor--;
807
+ }
808
+ }
809
+
810
+ while (leftCursor > 0) {
811
+ changedLeft.add(leftCursor - 1);
812
+ leftCursor--;
813
+ }
814
+ while (rightCursor > 0) {
815
+ changedRight.add(rightCursor - 1);
816
+ rightCursor--;
817
+ }
818
+
819
+ return {
820
+ left: tokensToDiffSpans(leftLine, leftTokens, changedLeft),
821
+ right: tokensToDiffSpans(rightLine, rightTokens, changedRight),
822
+ };
823
+ }
824
+
825
+ function buildInlineHighlightMap(rows: SplitDiffRow[]): WeakMap<DiffLineEntry, DiffSpan[]> {
826
+ const highlights = new WeakMap<DiffLineEntry, DiffSpan[]>();
827
+
828
+ for (const row of rows) {
829
+ if (!row.left || !row.right) {
830
+ continue;
831
+ }
832
+ if (row.left.lineKind !== "remove" || row.right.lineKind !== "add") {
833
+ continue;
834
+ }
835
+
836
+ const leftText = normalizeCodeWhitespace(row.left.content);
837
+ const rightText = normalizeCodeWhitespace(row.right.content);
838
+ const inline = computeInlineDiffSpans(leftText, rightText);
839
+ if (inline.left.length > 0) {
840
+ highlights.set(row.left, inline.left);
841
+ }
842
+ if (inline.right.length > 0) {
843
+ highlights.set(row.right, inline.right);
844
+ }
845
+ }
846
+
847
+ return highlights;
848
+ }
849
+
850
+ function ansi256ToRgb(code: number): RgbColor {
851
+ if (code < 0) {
852
+ return { r: 0, g: 0, b: 0 };
853
+ }
854
+ if (code <= 15) {
855
+ const base16: RgbColor[] = [
856
+ { r: 0, g: 0, b: 0 },
857
+ { r: 128, g: 0, b: 0 },
858
+ { r: 0, g: 128, b: 0 },
859
+ { r: 128, g: 128, b: 0 },
860
+ { r: 0, g: 0, b: 128 },
861
+ { r: 128, g: 0, b: 128 },
862
+ { r: 0, g: 128, b: 128 },
863
+ { r: 192, g: 192, b: 192 },
864
+ { r: 128, g: 128, b: 128 },
865
+ { r: 255, g: 0, b: 0 },
866
+ { r: 0, g: 255, b: 0 },
867
+ { r: 255, g: 255, b: 0 },
868
+ { r: 0, g: 0, b: 255 },
869
+ { r: 255, g: 0, b: 255 },
870
+ { r: 0, g: 255, b: 255 },
871
+ { r: 255, g: 255, b: 255 },
872
+ ];
873
+ return base16[code] ?? { r: 255, g: 255, b: 255 };
874
+ }
875
+ if (code >= 232) {
876
+ const value = Math.max(0, Math.min(255, 8 + (code - 232) * 10));
877
+ return { r: value, g: value, b: value };
878
+ }
879
+
880
+ const cube = code - 16;
881
+ const levels = [0, 95, 135, 175, 215, 255];
882
+ const blue = cube % 6;
883
+ const green = Math.floor(cube / 6) % 6;
884
+ const red = Math.floor(cube / 36) % 6;
885
+ return {
886
+ r: levels[red] ?? 0,
887
+ g: levels[green] ?? 0,
888
+ b: levels[blue] ?? 0,
889
+ };
890
+ }
891
+
892
+ function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
893
+ if (!ansi) {
894
+ return null;
895
+ }
896
+ const rgbMatch = /\x1b\[(?:3|4)8;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
897
+ if (rgbMatch) {
898
+ const r = Number.parseInt(rgbMatch[1] ?? "0", 10);
899
+ const g = Number.parseInt(rgbMatch[2] ?? "0", 10);
900
+ const b = Number.parseInt(rgbMatch[3] ?? "0", 10);
901
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
902
+ return {
903
+ r: Math.max(0, Math.min(255, r)),
904
+ g: Math.max(0, Math.min(255, g)),
905
+ b: Math.max(0, Math.min(255, b)),
906
+ };
907
+ }
908
+ }
909
+
910
+ const bitMatch = /\x1b\[(?:3|4)8;5;(\d{1,3})m/.exec(ansi);
911
+ if (bitMatch) {
912
+ const code = Number.parseInt(bitMatch[1] ?? "0", 10);
913
+ if (Number.isFinite(code)) {
914
+ return ansi256ToRgb(code);
915
+ }
916
+ }
917
+
918
+ return null;
919
+ }
920
+
921
+ function rgbToBgAnsi(color: RgbColor): string {
922
+ const r = Math.max(0, Math.min(255, Math.round(color.r)));
923
+ const g = Math.max(0, Math.min(255, Math.round(color.g)));
924
+ const b = Math.max(0, Math.min(255, Math.round(color.b)));
925
+ return `\x1b[48;2;${r};${g};${b}m`;
926
+ }
927
+
928
+ function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
929
+ const clamped = Math.max(0, Math.min(1, ratio));
930
+ return {
931
+ r: base.r * (1 - clamped) + tint.r * clamped,
932
+ g: base.g * (1 - clamped) + tint.g * clamped,
933
+ b: base.b * (1 - clamped) + tint.b * clamped,
934
+ };
935
+ }
936
+
937
+ function readThemeAnsi(theme: DiffTheme, kind: "fg" | "bg", slot: string): string | undefined {
938
+ try {
939
+ if (kind === "fg" && typeof theme.getFgAnsi === "function") {
940
+ return theme.getFgAnsi(slot);
941
+ }
942
+ if (kind === "bg" && typeof theme.getBgAnsi === "function") {
943
+ return theme.getBgAnsi(slot);
944
+ }
945
+ } catch {
946
+ return undefined;
947
+ }
948
+ return undefined;
949
+ }
950
+
951
+ function resolveContainerBackgroundAnsi(theme: DiffTheme): string | undefined {
952
+ return readThemeAnsi(theme, "bg", "toolSuccessBg")
953
+ ?? readThemeAnsi(theme, "bg", "toolPendingBg")
954
+ ?? readThemeAnsi(theme, "bg", "toolErrorBg")
955
+ ?? readThemeAnsi(theme, "bg", "userMessageBg");
956
+ }
957
+
958
+ function resolveDiffPalette(theme: DiffTheme): DiffPalette {
959
+ const baseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg"))
960
+ ?? parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg"))
961
+ ?? parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg"))
962
+ ?? { r: 32, g: 35, b: 42 };
963
+ const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffAdded")) ?? { r: 88, g: 173, b: 88 };
964
+ const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffRemoved")) ?? { r: 196, g: 98, b: 98 };
965
+ const addTint = mixRgb(addFg, ADDITION_TINT_TARGET, 0.35);
966
+ const removeTint = mixRgb(removeFg, DELETION_TINT_TARGET, 0.65);
967
+
968
+ const addRowBg = mixRgb(baseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO);
969
+ const removeRowBg = mixRgb(baseBg, removeTint, REMOVE_ROW_BACKGROUND_MIX_RATIO);
970
+ const addEmphasisBg = mixRgb(baseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO);
971
+ const removeEmphasisBg = mixRgb(baseBg, removeTint, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
972
+
973
+ return {
974
+ addRowBgAnsi: rgbToBgAnsi(addRowBg),
975
+ removeRowBgAnsi: rgbToBgAnsi(removeRowBg),
976
+ addEmphasisBgAnsi: rgbToBgAnsi(addEmphasisBg),
977
+ removeEmphasisBgAnsi: rgbToBgAnsi(removeEmphasisBg),
978
+ };
979
+ }
980
+
981
+ function getLineRowBackground(kind: DiffLineKind, palette: DiffPalette): string | undefined {
982
+ if (kind === "add") {
983
+ return palette.addRowBgAnsi;
984
+ }
985
+ if (kind === "remove") {
986
+ return palette.removeRowBgAnsi;
987
+ }
988
+ return undefined;
989
+ }
990
+
991
+ function getLineEmphasisBackground(kind: DiffLineKind, palette: DiffPalette): string | undefined {
992
+ if (kind === "add") {
993
+ return palette.addEmphasisBgAnsi;
994
+ }
995
+ if (kind === "remove") {
996
+ return palette.removeEmphasisBgAnsi;
997
+ }
998
+ return undefined;
999
+ }
1000
+
1001
+ function applyBackgroundToVisibleRange(
1002
+ ansiText: string,
1003
+ start: number,
1004
+ end: number,
1005
+ backgroundAnsi: string,
1006
+ restoreBackgroundAnsi: string,
1007
+ ): string {
1008
+ if (!ansiText || start >= end || end <= 0) {
1009
+ return ansiText;
1010
+ }
1011
+
1012
+ const rangeStart = Math.max(0, start);
1013
+ const rangeEnd = Math.max(rangeStart, end);
1014
+ let output = "";
1015
+ let visibleIndex = 0;
1016
+ let index = 0;
1017
+ let inRange = false;
1018
+
1019
+ while (index < ansiText.length) {
1020
+ if (ansiText[index] === "\x1b") {
1021
+ const sequenceEnd = ansiText.indexOf("m", index);
1022
+ if (sequenceEnd !== -1) {
1023
+ output += ansiText.slice(index, sequenceEnd + 1);
1024
+ index = sequenceEnd + 1;
1025
+ continue;
1026
+ }
1027
+ }
1028
+
1029
+ if (visibleIndex === rangeStart && !inRange) {
1030
+ output += backgroundAnsi;
1031
+ inRange = true;
1032
+ }
1033
+ if (visibleIndex === rangeEnd && inRange) {
1034
+ output += restoreBackgroundAnsi;
1035
+ inRange = false;
1036
+ }
1037
+
1038
+ output += ansiText[index] ?? "";
1039
+ visibleIndex++;
1040
+ index++;
1041
+ }
1042
+
1043
+ if (inRange) {
1044
+ output += restoreBackgroundAnsi;
1045
+ }
1046
+
1047
+ return output;
1048
+ }
1049
+
1050
+ function applyInlineSpanHighlight(
1051
+ plainText: string,
1052
+ renderedText: string,
1053
+ spans: DiffSpan[],
1054
+ emphasisBgAnsi: string | undefined,
1055
+ rowBgAnsi: string | undefined,
1056
+ fallbackBgAnsi: string | undefined,
1057
+ ): string {
1058
+ if (!renderedText || !plainText || spans.length === 0 || !emphasisBgAnsi) {
1059
+ return renderedText;
1060
+ }
1061
+
1062
+ const sorted = mergeSpans(
1063
+ spans
1064
+ .map((span) => ({
1065
+ start: Math.max(0, Math.min(plainText.length, span.start)),
1066
+ end: Math.max(0, Math.min(plainText.length, span.end)),
1067
+ }))
1068
+ .filter((span) => span.end > span.start),
1069
+ );
1070
+ if (sorted.length === 0) {
1071
+ return renderedText;
1072
+ }
1073
+
1074
+ const restoreBackgroundAnsi = rowBgAnsi ?? fallbackBgAnsi ?? ANSI_BG_RESET;
1075
+ let highlighted = renderedText;
1076
+ for (let index = sorted.length - 1; index >= 0; index--) {
1077
+ const span = sorted[index];
1078
+ if (!span) {
1079
+ continue;
1080
+ }
1081
+ highlighted = applyBackgroundToVisibleRange(
1082
+ highlighted,
1083
+ span.start,
1084
+ span.end,
1085
+ emphasisBgAnsi,
1086
+ restoreBackgroundAnsi,
1087
+ );
1088
+ }
1089
+
1090
+ return highlighted;
1091
+ }
1092
+
1093
+ function colorizeSegment(
1094
+ theme: DiffTheme,
1095
+ color: "dim" | "toolDiffAdded" | "toolDiffRemoved",
1096
+ text: string,
1097
+ rowBg: string | undefined,
1098
+ ): string {
1099
+ let themedText: string;
1100
+ try {
1101
+ themedText = theme.fg(color, text);
1102
+ } catch {
1103
+ themedText = text;
1104
+ }
1105
+
1106
+ if (!rowBg) {
1107
+ return themedText;
1108
+ }
1109
+
1110
+ const stableText = keepBackgroundAcrossResets(themedText, rowBg);
1111
+ return `${rowBg}${stableText}${rowBg}`;
1112
+ }
1113
+
1114
+ function keepBackgroundAcrossResets(text: string, rowBg: string): string {
1115
+ if (!text) {
1116
+ return text;
1117
+ }
1118
+
1119
+ return text.replace(ANSI_SGR_PATTERN, (sequence, rawParams: string) => {
1120
+ const params = toSgrParams(rawParams);
1121
+ if (params.length === 0 || !sequenceAffectsBackground(params)) {
1122
+ return sequence;
1123
+ }
1124
+ return `${sequence}${rowBg}`;
1125
+ });
1126
+ }
1127
+
1128
+ function renderChangeMarker(kind: DiffLineKind, theme: DiffTheme, rowBg: string | undefined): string {
1129
+ if (kind === "add") {
1130
+ return colorizeSegment(theme, "toolDiffAdded", "▌", rowBg);
1131
+ }
1132
+ if (kind === "remove") {
1133
+ return colorizeSegment(theme, "toolDiffRemoved", "▌", rowBg);
1134
+ }
1135
+ return rowBg ? `${rowBg} ${rowBg}` : " ";
1136
+ }
1137
+
1138
+ function renderCodeDivider(theme: DiffTheme, rowBg: string | undefined): string {
1139
+ return colorizeSegment(theme, "dim", "│ ", rowBg);
1140
+ }
1141
+
1142
+ function getLineNumberColor(kind: DiffLineKind): "dim" | "toolDiffAdded" | "toolDiffRemoved" {
1143
+ if (kind === "add") {
1144
+ return "toolDiffAdded";
1145
+ }
1146
+ if (kind === "remove") {
1147
+ return "toolDiffRemoved";
1148
+ }
1149
+ return "dim";
1150
+ }
1151
+
1152
+ function renderLinePrefix(
1153
+ kind: DiffLineKind,
1154
+ lineNumber: string,
1155
+ theme: DiffTheme,
1156
+ rowBg: string | undefined,
1157
+ ): string {
1158
+ const marker = renderChangeMarker(kind, theme, rowBg);
1159
+ const numberColor = getLineNumberColor(kind);
1160
+ const number = colorizeSegment(theme, numberColor, lineNumber, rowBg);
1161
+ const spacer = rowBg ? `${rowBg} ` : " ";
1162
+ return `${marker}${spacer}${number}${spacer}`;
1163
+ }
1164
+
1165
+ function renderLineCell(
1166
+ kind: DiffLineKind,
1167
+ lineNumber: string,
1168
+ code: string,
1169
+ width: number,
1170
+ rowBg: string | undefined,
1171
+ restoreBgAnsi: string | undefined,
1172
+ theme: DiffTheme,
1173
+ wordWrap: boolean,
1174
+ ): string[] {
1175
+ if (width <= 0) {
1176
+ return [""];
1177
+ }
1178
+
1179
+ const prefixPlainWidth = visibleWidth(`▌ ${lineNumber} `);
1180
+ const dividerPlainWidth = 2;
1181
+ const codeWidth = Math.max(0, width - prefixPlainWidth - dividerPlainWidth);
1182
+
1183
+ if (!rowBg) {
1184
+ const prefix = renderLinePrefix(kind, lineNumber, theme, undefined);
1185
+ const divider = renderCodeDivider(theme, undefined);
1186
+ const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
1187
+ return wrappedCodeLines.map((wrappedCodeLine) =>
1188
+ stabilizeBackgroundResets(`${prefix}${divider}${wrappedCodeLine}`)
1189
+ );
1190
+ }
1191
+
1192
+ const prefix = renderLinePrefix(kind, lineNumber, theme, rowBg);
1193
+ const divider = renderCodeDivider(theme, rowBg);
1194
+ const safeRestoreBgAnsi = restoreBgAnsi ?? rowBg ?? ANSI_BG_RESET;
1195
+ const wrappedCodeLines = wrapToWidth(keepBackgroundAcrossResets(code, rowBg), codeWidth, wordWrap);
1196
+ return wrappedCodeLines.map((wrappedCodeLine) => {
1197
+ const safeWrappedCodeLine = keepBackgroundAcrossResets(wrappedCodeLine, rowBg);
1198
+ return stabilizeBackgroundResets(`${prefix}${divider}${rowBg}${safeWrappedCodeLine}${safeRestoreBgAnsi}`);
1199
+ });
1200
+ }
1201
+
1202
+ function renderUnified(
1203
+ entries: ParsedDiffEntry[],
1204
+ width: number,
1205
+ theme: DiffTheme,
1206
+ lineNumberWidth: number,
1207
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1208
+ palette: DiffPalette,
1209
+ highlightLine: CodeLineHighlighter,
1210
+ containerBgAnsi: string | undefined,
1211
+ wordWrap: boolean,
1212
+ ): RenderedRow[] {
1213
+ const rows: RenderedRow[] = [];
1214
+
1215
+ for (const entry of entries) {
1216
+ if (entry.kind !== "line") {
1217
+ rows.push(...formatMetaEntryRows(entry, width, theme, wordWrap));
1218
+ continue;
1219
+ }
1220
+
1221
+ const lineNumber = entry.lineKind === "add"
1222
+ ? formatLineNumber(entry.newLineNumber, entry.fallbackLineNumber, lineNumberWidth)
1223
+ : formatLineNumber(entry.oldLineNumber, entry.fallbackLineNumber, lineNumberWidth);
1224
+ const codeText = normalizeCodeWhitespace(entry.content);
1225
+ const syntaxHighlighted = highlightLine(codeText);
1226
+ const rowBg = getLineRowBackground(entry.lineKind, palette);
1227
+ const emphasisBg = getLineEmphasisBackground(entry.lineKind, palette);
1228
+ const inlineSpans = inlineHighlights.get(entry) ?? [];
1229
+ const highlighted = applyInlineSpanHighlight(codeText, syntaxHighlighted, inlineSpans, emphasisBg, rowBg, containerBgAnsi);
1230
+ const lines = renderLineCell(entry.lineKind, lineNumber, highlighted, width, rowBg, containerBgAnsi, theme, wordWrap);
1231
+
1232
+ rows.push(
1233
+ ...lines.map((text) => ({
1234
+ text,
1235
+ hunkIndex: entry.hunkIndex || null,
1236
+ })),
1237
+ );
1238
+ }
1239
+
1240
+ return rows;
1241
+ }
1242
+
1243
+ function toUnifiedFallbackRows(
1244
+ rows: SplitDiffRow[],
1245
+ width: number,
1246
+ theme: DiffTheme,
1247
+ lineNumberWidth: number,
1248
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1249
+ palette: DiffPalette,
1250
+ highlightLine: CodeLineHighlighter,
1251
+ containerBgAnsi: string | undefined,
1252
+ wordWrap: boolean,
1253
+ ): RenderedRow[] {
1254
+ const flattened: ParsedDiffEntry[] = [];
1255
+ for (const row of rows) {
1256
+ if (row.meta) {
1257
+ flattened.push(row.meta);
1258
+ continue;
1259
+ }
1260
+ if (row.left) {
1261
+ flattened.push(row.left);
1262
+ }
1263
+ if (row.right && row.right !== row.left) {
1264
+ flattened.push(row.right);
1265
+ }
1266
+ }
1267
+ return renderUnified(flattened, width, theme, lineNumberWidth, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap);
1268
+ }
1269
+
1270
+ function renderSplitBlankCell(columnWidth: number, lineNumberWidth: number, theme: DiffTheme): string {
1271
+ const prefixPlainWidth = visibleWidth(`▌ ${" ".repeat(lineNumberWidth)} `);
1272
+ const dividerPlainWidth = 2;
1273
+ const codeWidth = Math.max(0, columnWidth - prefixPlainWidth - dividerPlainWidth);
1274
+ const number = theme.fg("dim", " ".repeat(lineNumberWidth));
1275
+ const divider = theme.fg("dim", "│ ");
1276
+ return stabilizeBackgroundResets(` ${number} ${divider}${" ".repeat(codeWidth)}`);
1277
+ }
1278
+
1279
+ function renderSplitCell(
1280
+ line: DiffLineEntry | undefined,
1281
+ side: "left" | "right",
1282
+ columnWidth: number,
1283
+ lineNumberWidth: number,
1284
+ theme: DiffTheme,
1285
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1286
+ palette: DiffPalette,
1287
+ highlightLine: CodeLineHighlighter,
1288
+ containerBgAnsi: string | undefined,
1289
+ wordWrap: boolean,
1290
+ ): string[] {
1291
+ if (!line) {
1292
+ return [renderSplitBlankCell(columnWidth, lineNumberWidth, theme)];
1293
+ }
1294
+
1295
+ const lineNumber = formatLineNumber(getCellLineNumber(line, side), line.fallbackLineNumber, lineNumberWidth);
1296
+ const rowBg = getLineRowBackground(line.lineKind, palette);
1297
+ const emphasisBg = getLineEmphasisBackground(line.lineKind, palette);
1298
+ const codeText = normalizeCodeWhitespace(line.content);
1299
+ const syntaxHighlighted = highlightLine(codeText);
1300
+ const inlineSpans = inlineHighlights.get(line) ?? [];
1301
+ const highlighted = applyInlineSpanHighlight(codeText, syntaxHighlighted, inlineSpans, emphasisBg, rowBg, containerBgAnsi);
1302
+ return renderLineCell(line.lineKind, lineNumber, highlighted, columnWidth, rowBg, containerBgAnsi, theme, wordWrap);
1303
+ }
1304
+
1305
+ function renderSplitDivider(
1306
+ theme: DiffTheme,
1307
+ containerBgAnsi: string | undefined,
1308
+ separatorText: string = SPLIT_SEPARATOR,
1309
+ ): string {
1310
+ const dimAnsi = readThemeAnsi(theme, "fg", "dim");
1311
+ if (!containerBgAnsi) {
1312
+ return stabilizeBackgroundResets(theme.fg("dim", separatorText));
1313
+ }
1314
+ if (!dimAnsi) {
1315
+ return stabilizeBackgroundResets(`${containerBgAnsi}${theme.fg("dim", separatorText)}${containerBgAnsi}`);
1316
+ }
1317
+ return stabilizeBackgroundResets(`${containerBgAnsi}${dimAnsi}${separatorText}\x1b[39m${containerBgAnsi}`);
1318
+ }
1319
+
1320
+ function renderSplitTopBorderCell(columnWidth: number, lineNumberWidth: number, theme: DiffTheme): string {
1321
+ const safeColumnWidth = Math.max(1, columnWidth);
1322
+ const chars = "─".repeat(safeColumnWidth).split("");
1323
+ const dividerIndex = lineNumberWidth + 3;
1324
+ if (dividerIndex >= 0 && dividerIndex < chars.length) {
1325
+ chars[dividerIndex] = "┬";
1326
+ }
1327
+ return stabilizeBackgroundResets(theme.fg("dim", chars.join("")));
1328
+ }
1329
+
1330
+ function renderSplitHeaderCell(label: string, columnWidth: number, lineNumberWidth: number, theme: DiffTheme): string {
1331
+ const markerPad = " ";
1332
+ const lineNumberLabel = fitToWidth(label, lineNumberWidth);
1333
+ const prefix = `${theme.fg("dim", markerPad)}${theme.fg("muted", lineNumberLabel)}${theme.fg("dim", " │ ")}`;
1334
+ const prefixWidth = visibleWidth(`${markerPad}${lineNumberLabel} │ `);
1335
+ const codeWidth = Math.max(0, columnWidth - prefixWidth);
1336
+ return stabilizeBackgroundResets(`${prefix}${" ".repeat(codeWidth)}`);
1337
+ }
1338
+
1339
+ function canRenderSplitLayout(width: number): boolean {
1340
+ const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
1341
+ const minimumSplitWidth = MIN_SPLIT_COLUMN_WIDTH * 2 + separatorWidth;
1342
+ return width >= minimumSplitWidth;
1343
+ }
1344
+
1345
+ function renderSplit(
1346
+ rows: SplitDiffRow[],
1347
+ width: number,
1348
+ theme: DiffTheme,
1349
+ lineNumberWidth: number,
1350
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1351
+ palette: DiffPalette,
1352
+ highlightLine: CodeLineHighlighter,
1353
+ containerBgAnsi: string | undefined,
1354
+ wordWrap: boolean,
1355
+ ): RenderedRow[] {
1356
+ if (!canRenderSplitLayout(width)) {
1357
+ return toUnifiedFallbackRows(rows, width, theme, lineNumberWidth, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap);
1358
+ }
1359
+
1360
+ const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
1361
+ const leftWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, Math.floor((width - separatorWidth) / 2));
1362
+ const rightWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, width - separatorWidth - leftWidth);
1363
+ const splitLineNumberWidth = Math.max(3, lineNumberWidth);
1364
+ const separator = renderSplitDivider(theme, containerBgAnsi);
1365
+ const topSeparator = renderSplitDivider(theme, containerBgAnsi, "─┬─");
1366
+ const output: RenderedRow[] = [];
1367
+ output.push({
1368
+ text: `${renderSplitTopBorderCell(leftWidth, splitLineNumberWidth, theme)}${topSeparator}${renderSplitTopBorderCell(rightWidth, splitLineNumberWidth, theme)}`,
1369
+ hunkIndex: null,
1370
+ });
1371
+ output.push({
1372
+ text: `${renderSplitHeaderCell("old", leftWidth, splitLineNumberWidth, theme)}${separator}${renderSplitHeaderCell("new", rightWidth, splitLineNumberWidth, theme)}`,
1373
+ hunkIndex: null,
1374
+ });
1375
+
1376
+ for (const row of rows) {
1377
+ if (row.meta) {
1378
+ output.push(...formatMetaEntryRows(row.meta, width, theme, wordWrap));
1379
+ continue;
1380
+ }
1381
+
1382
+ const leftCells = renderSplitCell(
1383
+ row.left,
1384
+ "left",
1385
+ leftWidth,
1386
+ splitLineNumberWidth,
1387
+ theme,
1388
+ inlineHighlights,
1389
+ palette,
1390
+ highlightLine,
1391
+ containerBgAnsi,
1392
+ wordWrap,
1393
+ );
1394
+ const rightCells = renderSplitCell(
1395
+ row.right,
1396
+ "right",
1397
+ rightWidth,
1398
+ splitLineNumberWidth,
1399
+ theme,
1400
+ inlineHighlights,
1401
+ palette,
1402
+ highlightLine,
1403
+ containerBgAnsi,
1404
+ wordWrap,
1405
+ );
1406
+
1407
+ const rowCount = Math.max(leftCells.length, rightCells.length);
1408
+ for (let index = 0; index < rowCount; index++) {
1409
+ const leftCell = leftCells[index] ?? renderSplitBlankCell(leftWidth, splitLineNumberWidth, theme);
1410
+ const rightCell = rightCells[index] ?? renderSplitBlankCell(rightWidth, splitLineNumberWidth, theme);
1411
+ output.push({ text: `${leftCell}${separator}${rightCell}`, hunkIndex: row.hunkIndex });
1412
+ }
1413
+ }
1414
+
1415
+ return output;
1416
+ }
1417
+
1418
+ function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): string | null {
1419
+ const totalChanges = stats.added + stats.removed;
1420
+ if (totalChanges === 0 || width < 20) {
1421
+ return null;
1422
+ }
1423
+
1424
+ const barSlots = Math.max(8, Math.min(24, Math.floor(width / 12)));
1425
+ let addedSlots = Math.max(0, Math.min(barSlots, Math.round((stats.added / totalChanges) * barSlots)));
1426
+ if (stats.added > 0 && addedSlots === 0) {
1427
+ addedSlots = 1;
1428
+ }
1429
+ if (stats.removed > 0 && addedSlots >= barSlots) {
1430
+ addedSlots = barSlots - 1;
1431
+ }
1432
+ const removedSlots = Math.max(0, barSlots - addedSlots);
1433
+
1434
+ const addedBar = addedSlots > 0 ? theme.fg("toolDiffAdded", "━".repeat(addedSlots)) : "";
1435
+ const removedBar = removedSlots > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots)) : "";
1436
+ return stabilizeBackgroundResets(`${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`);
1437
+ }
1438
+
1439
+ function renderHeaderRows(stats: DiffStats, mode: "split" | "unified", width: number, theme: DiffTheme): RenderedRow[] {
1440
+ const summaryPieces = mode === "split"
1441
+ ? [
1442
+ theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
1443
+ theme.fg("toolDiffAdded", `+${stats.added}`),
1444
+ theme.fg("toolDiffRemoved", `-${stats.removed}`),
1445
+ theme.fg("muted", mode),
1446
+ ]
1447
+ : [
1448
+ theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
1449
+ theme.fg("toolDiffAdded", `+${stats.added}`),
1450
+ theme.fg("toolDiffRemoved", `-${stats.removed}`),
1451
+ theme.fg("muted", `${stats.hunks} ${pluralize(stats.hunks, "hunk")}`),
1452
+ theme.fg("muted", `${stats.files} ${pluralize(stats.files, "file")}`),
1453
+ theme.fg("muted", mode),
1454
+ ];
1455
+
1456
+ const summary = summaryPieces.join(mode === "split" ? " " : theme.fg("muted", " • "));
1457
+ const meter = renderDiffStatBar(stats, width, theme);
1458
+ if (!meter) {
1459
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
1460
+ }
1461
+
1462
+ const meterSeparator = " ";
1463
+ const meterWidth = visibleWidth(meterSeparator) + visibleWidth(meter);
1464
+ if (meterWidth >= width) {
1465
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
1466
+ }
1467
+
1468
+ const summaryWidth = Math.max(0, width - meterWidth);
1469
+ const fittedSummary = truncateToWidth(summary, summaryWidth);
1470
+ return [{ text: stabilizeBackgroundResets(`${fittedSummary}${meterSeparator}${meter}`), hunkIndex: null }];
1471
+ }
1472
+
1473
+ function renderDiffFrameLine(width: number, theme: DiffTheme): string {
1474
+ const frameWidth = Math.max(0, width);
1475
+ if (frameWidth === 0) {
1476
+ return "";
1477
+ }
1478
+ return stabilizeBackgroundResets(theme.fg("dim", "─".repeat(frameWidth)));
1479
+ }
1480
+
1481
+ function applyLineLimit(
1482
+ rows: RenderedRow[],
1483
+ expanded: boolean,
1484
+ maxCollapsedLines: number,
1485
+ totalHunks: number,
1486
+ theme: DiffTheme,
1487
+ ): string[] {
1488
+ if (expanded) {
1489
+ return rows.map((row) => stabilizeBackgroundResets(row.text));
1490
+ }
1491
+
1492
+ const limit = Math.max(1, maxCollapsedLines);
1493
+ if (rows.length <= limit) {
1494
+ return rows.map((row) => stabilizeBackgroundResets(row.text));
1495
+ }
1496
+
1497
+ const shown = rows.slice(0, limit);
1498
+ const remaining = rows.length - shown.length;
1499
+ const visibleHunks = new Set(
1500
+ shown
1501
+ .map((row) => row.hunkIndex)
1502
+ .filter((hunkIndex): hunkIndex is number => typeof hunkIndex === "number" && hunkIndex > 0),
1503
+ );
1504
+ const hiddenHunks = Math.max(0, totalHunks - visibleHunks.size);
1505
+
1506
+ const details = [`${remaining} more ${pluralize(remaining, "diff line")}`];
1507
+ if (hiddenHunks > 0) {
1508
+ details.push(`${hiddenHunks} more ${pluralize(hiddenHunks, "hunk")}`);
1509
+ }
1510
+ details.push("Ctrl+O to expand");
1511
+
1512
+ return [
1513
+ ...shown.map((row) => stabilizeBackgroundResets(row.text)),
1514
+ stabilizeBackgroundResets(theme.fg("muted", `… (${details.join(" • ")})`)),
1515
+ ];
1516
+ }
1517
+
1518
+ function shouldUseSplitMode(config: ToolDisplayConfig, width: number): boolean {
1519
+ switch (config.diffViewMode) {
1520
+ case "split":
1521
+ return true;
1522
+ case "unified":
1523
+ return false;
1524
+ case "auto":
1525
+ default:
1526
+ return width >= config.diffSplitMinWidth;
1527
+ }
1528
+ }
1529
+
1530
+ function resolveRenderWidth(width: number): number {
1531
+ const stdoutWidth = process.stdout?.columns;
1532
+ const terminalWidth = typeof stdoutWidth === "number" && stdoutWidth > 0 ? stdoutWidth : DEFAULT_RENDER_WIDTH;
1533
+ const targetWidth = width > 0 ? width : terminalWidth;
1534
+ return Math.max(28, targetWidth);
1535
+ }
1536
+
1537
+ function safeGetDiff(details: unknown): string {
1538
+ if (!details || typeof details !== "object") {
1539
+ return "";
1540
+ }
1541
+ const typed = details as Partial<EditToolDetails>;
1542
+ return typeof typed.diff === "string" ? typed.diff : "";
1543
+ }
1544
+
1545
+ export function renderEditDiffResult(
1546
+ details: unknown,
1547
+ options: DiffRenderOptions,
1548
+ config: ToolDisplayConfig,
1549
+ theme: DiffTheme,
1550
+ fallbackText: string,
1551
+ ): Component {
1552
+ const diffText = safeGetDiff(details);
1553
+ if (!diffText.trim()) {
1554
+ if (!fallbackText.trim()) {
1555
+ return new Text(theme.fg("muted", "↳ edit completed (no diff payload)"), 0, 0);
1556
+ }
1557
+ return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
1558
+ }
1559
+
1560
+ let parsed: ParsedDiff;
1561
+ try {
1562
+ parsed = parseDiff(diffText);
1563
+ } catch (error) {
1564
+ const message = error instanceof Error ? error.message : String(error);
1565
+ return new Text(theme.fg("warning", `↳ unable to render diff: ${message}`), 0, 0);
1566
+ }
1567
+
1568
+ if (parsed.entries.length === 0) {
1569
+ return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
1570
+ }
1571
+
1572
+ const splitRows = buildSplitRows(parsed.entries);
1573
+ const inlineHighlights = buildInlineHighlightMap(splitRows);
1574
+ const lineNumberWidth = getLineNumberWidth(parsed.entries);
1575
+ const palette = resolveDiffPalette(theme);
1576
+ const containerBgAnsi = resolveContainerBackgroundAnsi(theme);
1577
+ const language = resolveLanguageFromPath(options.filePath);
1578
+ const highlightLine = createCodeLineHighlighter(language);
1579
+ const wordWrap = config.diffWordWrap;
1580
+
1581
+ let cachedWidth: number | undefined;
1582
+ let cachedExpanded: boolean | undefined;
1583
+ let cachedMode: "split" | "unified" | undefined;
1584
+ let cachedLines: string[] | undefined;
1585
+
1586
+ return {
1587
+ render(width: number): string[] {
1588
+ const safeWidth = resolveRenderWidth(width);
1589
+ const preferredMode: "split" | "unified" = shouldUseSplitMode(config, safeWidth) ? "split" : "unified";
1590
+ const mode: "split" | "unified" = preferredMode === "split" && canRenderSplitLayout(safeWidth)
1591
+ ? "split"
1592
+ : "unified";
1593
+ if (
1594
+ cachedLines
1595
+ && cachedWidth === safeWidth
1596
+ && cachedExpanded === options.expanded
1597
+ && cachedMode === mode
1598
+ ) {
1599
+ return cachedLines;
1600
+ }
1601
+
1602
+ const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
1603
+ const bodyRows = mode === "split"
1604
+ ? renderSplit(
1605
+ splitRows,
1606
+ safeWidth,
1607
+ theme,
1608
+ lineNumberWidth,
1609
+ inlineHighlights,
1610
+ palette,
1611
+ highlightLine,
1612
+ containerBgAnsi,
1613
+ wordWrap,
1614
+ )
1615
+ : renderUnified(
1616
+ parsed.entries,
1617
+ safeWidth,
1618
+ theme,
1619
+ lineNumberWidth,
1620
+ inlineHighlights,
1621
+ palette,
1622
+ highlightLine,
1623
+ containerBgAnsi,
1624
+ wordWrap,
1625
+ );
1626
+ const bodyWithLimit = applyLineLimit(
1627
+ bodyRows,
1628
+ options.expanded,
1629
+ config.diffCollapsedLines,
1630
+ parsed.stats.hunks,
1631
+ theme,
1632
+ );
1633
+ const frame = renderDiffFrameLine(safeWidth, theme);
1634
+
1635
+ cachedLines = (mode === "split"
1636
+ ? [...headerRows.map((row) => row.text), ...bodyWithLimit]
1637
+ : [...headerRows.map((row) => row.text), frame, ...bodyWithLimit, frame])
1638
+ .map((line) => stabilizeBackgroundResets(line));
1639
+ cachedWidth = safeWidth;
1640
+ cachedExpanded = options.expanded;
1641
+ cachedMode = mode;
1642
+ return cachedLines;
1643
+ },
1644
+ invalidate() {
1645
+ cachedWidth = undefined;
1646
+ cachedExpanded = undefined;
1647
+ cachedMode = undefined;
1648
+ cachedLines = undefined;
1649
+ },
1650
+ };
1651
+ }
1652
+
1653
+ function splitWriteContentLines(content: string): string[] {
1654
+ if (!content) {
1655
+ return [];
1656
+ }
1657
+
1658
+ const normalized = content.replace(/\r/g, "");
1659
+ const lines = normalized.split("\n");
1660
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
1661
+ lines.pop();
1662
+ }
1663
+ return lines;
1664
+ }
1665
+
1666
+ function renderWriteHeader(
1667
+ lineCount: number,
1668
+ sizeBytes: number,
1669
+ wasOverwrite: boolean,
1670
+ width: number,
1671
+ theme: DiffTheme,
1672
+ ): string {
1673
+ const separator = theme.fg("muted", " • ");
1674
+ const actionLabel = wasOverwrite ? "overwritten" : "created";
1675
+ const segments = [
1676
+ theme.fg("toolOutput", `↳ ${emphasis(theme, actionLabel)}`),
1677
+ theme.fg("muted", `${lineCount} ${pluralize(lineCount, "line")}`),
1678
+ theme.fg("muted", formatSize(sizeBytes)),
1679
+ ];
1680
+ return stabilizeBackgroundResets(
1681
+ truncateToWidth(`${segments[0]}${separator}${segments[1]}${separator}${segments[2]}`, width),
1682
+ );
1683
+ }
1684
+
1685
+ type WriteDiffOperationKind = "context" | "remove" | "add";
1686
+
1687
+ interface WriteDiffOperation {
1688
+ kind: WriteDiffOperationKind;
1689
+ content: string;
1690
+ }
1691
+
1692
+ function buildWriteDiffOperations(oldLines: string[], newLines: string[]): WriteDiffOperation[] {
1693
+ const oldLength = oldLines.length;
1694
+ const newLength = newLines.length;
1695
+ const table: number[][] = Array.from({ length: oldLength + 1 }, () => Array<number>(newLength + 1).fill(0));
1696
+
1697
+ for (let oldIndex = 1; oldIndex <= oldLength; oldIndex++) {
1698
+ for (let newIndex = 1; newIndex <= newLength; newIndex++) {
1699
+ if ((oldLines[oldIndex - 1] ?? "") === (newLines[newIndex - 1] ?? "")) {
1700
+ table[oldIndex]![newIndex] = (table[oldIndex - 1]?.[newIndex - 1] ?? 0) + 1;
1701
+ continue;
1702
+ }
1703
+ const top = table[oldIndex - 1]?.[newIndex] ?? 0;
1704
+ const left = table[oldIndex]?.[newIndex - 1] ?? 0;
1705
+ table[oldIndex]![newIndex] = Math.max(top, left);
1706
+ }
1707
+ }
1708
+
1709
+ const operations: WriteDiffOperation[] = [];
1710
+ let oldCursor = oldLength;
1711
+ let newCursor = newLength;
1712
+
1713
+ while (oldCursor > 0 || newCursor > 0) {
1714
+ const oldLine = oldCursor > 0 ? (oldLines[oldCursor - 1] ?? "") : undefined;
1715
+ const newLine = newCursor > 0 ? (newLines[newCursor - 1] ?? "") : undefined;
1716
+
1717
+ if (oldCursor > 0 && newCursor > 0 && oldLine === newLine) {
1718
+ operations.push({ kind: "context", content: oldLine ?? "" });
1719
+ oldCursor--;
1720
+ newCursor--;
1721
+ continue;
1722
+ }
1723
+
1724
+ const top = oldCursor > 0 ? (table[oldCursor - 1]?.[newCursor] ?? 0) : -1;
1725
+ const left = newCursor > 0 ? (table[oldCursor]?.[newCursor - 1] ?? 0) : -1;
1726
+
1727
+ if (newCursor > 0 && left >= top) {
1728
+ operations.push({ kind: "add", content: newLine ?? "" });
1729
+ newCursor--;
1730
+ continue;
1731
+ }
1732
+
1733
+ if (oldCursor > 0) {
1734
+ operations.push({ kind: "remove", content: oldLine ?? "" });
1735
+ oldCursor--;
1736
+ }
1737
+ }
1738
+
1739
+ operations.reverse();
1740
+ return operations;
1741
+ }
1742
+
1743
+ function buildWriteEntries(lines: string[]): ParsedDiffEntry[] {
1744
+ return lines.map((line, index) => ({
1745
+ kind: "line",
1746
+ lineKind: "add",
1747
+ oldLineNumber: null,
1748
+ newLineNumber: index + 1,
1749
+ fallbackLineNumber: `${index + 1}`,
1750
+ content: line,
1751
+ raw: `+${line}`,
1752
+ hunkIndex: 1,
1753
+ }));
1754
+ }
1755
+
1756
+ function buildWriteOverwriteEntries(oldLines: string[], newLines: string[]): ParsedDiffEntry[] {
1757
+ const operations = buildWriteDiffOperations(oldLines, newLines);
1758
+ const entries: ParsedDiffEntry[] = [];
1759
+ let oldLineNumber = 1;
1760
+ let newLineNumber = 1;
1761
+
1762
+ for (const operation of operations) {
1763
+ if (operation.kind === "context") {
1764
+ entries.push({
1765
+ kind: "line",
1766
+ lineKind: "context",
1767
+ oldLineNumber,
1768
+ newLineNumber,
1769
+ fallbackLineNumber: `${newLineNumber}`,
1770
+ content: operation.content,
1771
+ raw: ` ${operation.content}`,
1772
+ hunkIndex: 1,
1773
+ });
1774
+ oldLineNumber++;
1775
+ newLineNumber++;
1776
+ continue;
1777
+ }
1778
+
1779
+ if (operation.kind === "remove") {
1780
+ entries.push({
1781
+ kind: "line",
1782
+ lineKind: "remove",
1783
+ oldLineNumber,
1784
+ newLineNumber: null,
1785
+ fallbackLineNumber: `${oldLineNumber}`,
1786
+ content: operation.content,
1787
+ raw: `-${operation.content}`,
1788
+ hunkIndex: 1,
1789
+ });
1790
+ oldLineNumber++;
1791
+ continue;
1792
+ }
1793
+
1794
+ entries.push({
1795
+ kind: "line",
1796
+ lineKind: "add",
1797
+ oldLineNumber: null,
1798
+ newLineNumber,
1799
+ fallbackLineNumber: `${newLineNumber}`,
1800
+ content: operation.content,
1801
+ raw: `+${operation.content}`,
1802
+ hunkIndex: 1,
1803
+ });
1804
+ newLineNumber++;
1805
+ }
1806
+
1807
+ return entries;
1808
+ }
1809
+
1810
+ export function renderWriteDiffResult(
1811
+ content: string | undefined,
1812
+ options: DiffRenderOptions,
1813
+ config: ToolDisplayConfig,
1814
+ theme: DiffTheme,
1815
+ fallbackText: string,
1816
+ ): Component {
1817
+ if (typeof content !== "string") {
1818
+ if (!fallbackText.trim()) {
1819
+ return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
1820
+ }
1821
+ return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
1822
+ }
1823
+
1824
+ const filePath = options.filePath?.trim() || "(unknown path)";
1825
+ const lines = splitWriteContentLines(content);
1826
+ const previousLines = typeof options.previousContent === "string"
1827
+ ? splitWriteContentLines(options.previousContent)
1828
+ : [];
1829
+ const hasComparablePrevious = options.fileExistedBeforeWrite === true && typeof options.previousContent === "string";
1830
+ const entries = hasComparablePrevious
1831
+ ? buildWriteOverwriteEntries(previousLines, lines)
1832
+ : buildWriteEntries(lines);
1833
+ const splitRows = buildSplitRows(entries);
1834
+ const inlineHighlights = buildInlineHighlightMap(splitRows);
1835
+ const lineNumberWidth = getLineNumberWidth(entries);
1836
+ const palette = resolveDiffPalette(theme);
1837
+ const containerBgAnsi = resolveContainerBackgroundAnsi(theme);
1838
+ const language = resolveLanguageFromPath(filePath);
1839
+ const highlightLine = createCodeLineHighlighter(language);
1840
+ const wordWrap = config.diffWordWrap;
1841
+ const hunkCount = entries.length > 0 ? 1 : 0;
1842
+ const sizeBytes = Buffer.byteLength(content, "utf8");
1843
+
1844
+ let cachedWidth: number | undefined;
1845
+ let cachedExpanded: boolean | undefined;
1846
+ let cachedMode: "split" | "unified" | undefined;
1847
+ let cachedLines: string[] | undefined;
1848
+
1849
+ return {
1850
+ render(width: number): string[] {
1851
+ const safeWidth = resolveRenderWidth(width);
1852
+ const preferredMode: "split" | "unified" = shouldUseSplitMode(config, safeWidth) ? "split" : "unified";
1853
+ const adaptiveMode: "split" | "unified" = preferredMode === "split" && canRenderSplitLayout(safeWidth)
1854
+ ? "split"
1855
+ : "unified";
1856
+ const mode: "split" | "unified" = hasComparablePrevious ? adaptiveMode : "unified";
1857
+ if (
1858
+ cachedLines
1859
+ && cachedWidth === safeWidth
1860
+ && cachedExpanded === options.expanded
1861
+ && cachedMode === mode
1862
+ ) {
1863
+ return cachedLines;
1864
+ }
1865
+
1866
+ const header = renderWriteHeader(
1867
+ lines.length,
1868
+ sizeBytes,
1869
+ options.fileExistedBeforeWrite === true,
1870
+ safeWidth,
1871
+ theme,
1872
+ );
1873
+ const bodyRows: RenderedRow[] = entries.length === 0
1874
+ ? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
1875
+ : mode === "split"
1876
+ ? renderSplit(
1877
+ splitRows,
1878
+ safeWidth,
1879
+ theme,
1880
+ lineNumberWidth,
1881
+ inlineHighlights,
1882
+ palette,
1883
+ highlightLine,
1884
+ containerBgAnsi,
1885
+ wordWrap,
1886
+ )
1887
+ : renderUnified(
1888
+ entries,
1889
+ safeWidth,
1890
+ theme,
1891
+ lineNumberWidth,
1892
+ inlineHighlights,
1893
+ palette,
1894
+ highlightLine,
1895
+ containerBgAnsi,
1896
+ wordWrap,
1897
+ );
1898
+
1899
+ const bodyWithLimit = applyLineLimit(
1900
+ bodyRows,
1901
+ options.expanded,
1902
+ config.diffCollapsedLines,
1903
+ hunkCount,
1904
+ theme,
1905
+ );
1906
+ const frame = renderDiffFrameLine(safeWidth, theme);
1907
+ cachedLines = (mode === "split"
1908
+ ? [header, ...bodyWithLimit]
1909
+ : [header, frame, ...bodyWithLimit, frame])
1910
+ .map((line) => stabilizeBackgroundResets(line));
1911
+ cachedWidth = safeWidth;
1912
+ cachedExpanded = options.expanded;
1913
+ cachedMode = mode;
1914
+ return cachedLines;
1915
+ },
1916
+ invalidate() {
1917
+ cachedWidth = undefined;
1918
+ cachedExpanded = undefined;
1919
+ cachedMode = undefined;
1920
+ cachedLines = undefined;
1921
+ },
1922
+ };
1923
+ }