pi-extensions-tool-display 0.1.1 → 0.2.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,2517 @@
1
+ import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
2
+ import { getLanguageFromPath, highlightCode, type EditToolDetails } from "@earendil-works/pi-coding-agent";
3
+ import { ANSI_SGR_PATTERN, STYLE_RESET_PARAMS, filterSgrSequences, toSgrParams, readSgrColorSequence, isFiniteSgrParam, expandSgrReset } from "./ansi-utils.js";
4
+ import {
5
+ buildCollapsedDiffHintText,
6
+ clampRenderedLineToWidth,
7
+ clampRenderedLinesToWidth,
8
+ } from "./line-width-safety.js";
9
+ import {
10
+ buildDiffSummaryText,
11
+ normalizeDiffRenderWidth,
12
+ resolveDiffPresentationMode,
13
+ type DiffPresentationMode,
14
+ } from "./diff-presentation.js";
15
+ import { pluralize, sanitizeAnsiForThemedOutput } from "./render-utils.js";
16
+ import { splitWriteContentLines } from "./write-display-utils.js";
17
+ import { DEFAULT_TOOL_DISPLAY_CONFIG, type DiffIndicatorMode, type ToolDisplayConfig } from "./types.js";
18
+
19
+ interface DiffTheme {
20
+ fg(color: string, text: string): string;
21
+ bg?(color: string, text: string): string;
22
+ bold?(text: string): string;
23
+ getFgAnsi?(color: string): string;
24
+ getBgAnsi?(color: string): string;
25
+ }
26
+
27
+ type DiffLineKind = "add" | "remove" | "context";
28
+ type DiffEntryKind = "line" | "meta" | "hunk" | "file";
29
+
30
+ interface DiffLineEntry {
31
+ kind: "line";
32
+ lineKind: DiffLineKind;
33
+ oldLineNumber: number | null;
34
+ newLineNumber: number | null;
35
+ fallbackLineNumber: string;
36
+ content: string;
37
+ hashlineAnchorContent?: string;
38
+ raw: string;
39
+ hunkIndex: number;
40
+ }
41
+
42
+ interface DiffMetaEntry {
43
+ kind: Exclude<DiffEntryKind, "line">;
44
+ raw: string;
45
+ hunkIndex: number;
46
+ }
47
+
48
+ type ParsedDiffEntry = DiffLineEntry | DiffMetaEntry;
49
+
50
+ interface ParsedDiff {
51
+ entries: ParsedDiffEntry[];
52
+ stats: DiffStats;
53
+ }
54
+
55
+ interface DiffStats {
56
+ added: number;
57
+ removed: number;
58
+ context: number;
59
+ hunks: number;
60
+ files: number;
61
+ lines: number;
62
+ }
63
+
64
+ interface RenderedRow {
65
+ text: string;
66
+ hunkIndex: number | null;
67
+ }
68
+
69
+ interface SplitDiffRow {
70
+ left?: DiffLineEntry;
71
+ right?: DiffLineEntry;
72
+ meta?: DiffMetaEntry;
73
+ hunkIndex: number | null;
74
+ }
75
+
76
+ interface DiffSpan {
77
+ start: number;
78
+ end: number;
79
+ }
80
+
81
+ interface RgbColor {
82
+ r: number;
83
+ g: number;
84
+ b: number;
85
+ }
86
+
87
+ interface DiffPalette {
88
+ addRowBgAnsi: string;
89
+ removeRowBgAnsi: string;
90
+ addEmphasisBgAnsi: string;
91
+ removeEmphasisBgAnsi: string;
92
+ }
93
+
94
+ interface DiffRenderOptions {
95
+ expanded: boolean;
96
+ filePath?: string;
97
+ previousContent?: string;
98
+ fileExistedBeforeWrite?: boolean;
99
+ headerLabel?: string;
100
+ }
101
+
102
+ type CodeLineHighlighter = (line: string) => string;
103
+
104
+ const CANONICAL_LINE_PATTERN = /^([+\- ])(\s*\d+)\|(.*)$/;
105
+ const HASHLINE_ANCHOR_LINE_PATTERN = /^([+\- ])(\s*\d+)#([A-Za-z0-9]+| {2}):(.*)$/;
106
+ const LEGACY_LINE_PATTERN = /^([+\- ])(\s*\d+)\s(.*)$/;
107
+ const HUNK_HEADER_PATTERN = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/;
108
+ const SPLIT_SEPARATOR = " │ ";
109
+ const MIN_LINE_NUMBER_WIDTH = 2;
110
+ const MIN_SPLIT_COLUMN_WIDTH = 24;
111
+ const MAX_INLINE_DIFF_LINE_LENGTH = 700;
112
+ const ADD_ROW_BACKGROUND_MIX_RATIO = 0.12;
113
+ const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
114
+ const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.26;
115
+ const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
116
+ const ADDITION_TINT_TARGET: RgbColor = { r: 84, g: 190, b: 118 };
117
+ const DELETION_TINT_TARGET: RgbColor = { r: 232, g: 95, b: 122 };
118
+ const ANSI_BG_RESET = "\x1b[49m";
119
+ const DIFF_WIDTH_OPS = {
120
+ measure: visibleWidth,
121
+ truncate: (text: string, maxWidth: number): string => truncateToWidth(text, maxWidth, ""),
122
+ };
123
+
124
+ function clampDiffLineToWidth(text: string, width: number): string {
125
+ return stabilizeBackgroundResets(clampRenderedLineToWidth(text, width, DIFF_WIDTH_OPS));
126
+ }
127
+
128
+ function clampDiffLinesToWidth(lines: string[], width: number): string[] {
129
+ return clampRenderedLinesToWidth(lines, width, DIFF_WIDTH_OPS).map((line) => stabilizeBackgroundResets(line));
130
+ }
131
+
132
+ function normalizeCodeWhitespace(text: string): string {
133
+ return text.replace(/\t/g, " ");
134
+ }
135
+
136
+ function emphasis(theme: DiffTheme, text: string): string {
137
+ return typeof theme.bold === "function" ? theme.bold(text) : text;
138
+ }
139
+
140
+
141
+
142
+ function sequenceResetsBackground(params: number[]): boolean {
143
+ let index = 0;
144
+ while (index < params.length) {
145
+ const param = params[index] ?? 0;
146
+ if (param === 0 || param === 49) {
147
+ return true;
148
+ }
149
+
150
+ const colorSequence = readSgrColorSequence(params, index);
151
+ index += colorSequence ? colorSequence.length : 1;
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ function stripBackgroundResetParams(params: number[]): number[] {
158
+ const filtered: number[] = [];
159
+
160
+ for (let i = 0; i < params.length; i++) {
161
+ const param = params[i] ?? 0;
162
+
163
+ if (param === 0) {
164
+ filtered.push(...expandSgrReset(param)!);
165
+ continue;
166
+ }
167
+
168
+ if (param === 49) {
169
+ continue;
170
+ }
171
+
172
+ const colorSequence = readSgrColorSequence(params, i);
173
+ if (colorSequence) {
174
+ filtered.push(...colorSequence);
175
+ i += colorSequence.length - 1;
176
+ continue;
177
+ }
178
+
179
+ filtered.push(param);
180
+ }
181
+
182
+ return filtered;
183
+ }
184
+
185
+ function stabilizeBackgroundResets(text: string): string {
186
+ if (!text) {
187
+ return text;
188
+ }
189
+ return filterSgrSequences(text, stripBackgroundResetParams);
190
+ }
191
+
192
+ function fitToWidth(text: string, width: number): string {
193
+ const trimmed = truncateToWidth(text, width, "");
194
+ const gap = Math.max(0, width - visibleWidth(trimmed));
195
+ return gap > 0 ? `${trimmed}${" ".repeat(gap)}` : trimmed;
196
+ }
197
+
198
+ function applyBackgroundToVisualRow(
199
+ text: string,
200
+ width: number,
201
+ rowBgAnsi: string,
202
+ restoreBgAnsi: string,
203
+ ): string {
204
+ if (width <= 0) {
205
+ return "";
206
+ }
207
+
208
+ const fitted = fitToWidth(text, width);
209
+ const withStableBackground = keepBackgroundAcrossResets(fitted, rowBgAnsi);
210
+ return stabilizeBackgroundResets(`${rowBgAnsi}${withStableBackground}${restoreBgAnsi}`);
211
+ }
212
+
213
+ function applyLineBackgroundToWrappedRows(
214
+ rows: string[],
215
+ width: number,
216
+ rowBgAnsi: string,
217
+ restoreBgAnsi: string,
218
+ ): string[] {
219
+ if (rows.length === 0) {
220
+ return [applyBackgroundToVisualRow("", width, rowBgAnsi, restoreBgAnsi)];
221
+ }
222
+
223
+ return rows.map((row) => applyBackgroundToVisualRow(row, width, rowBgAnsi, restoreBgAnsi));
224
+ }
225
+
226
+ function wrapToWidth(text: string, width: number, wordWrap: boolean): string[] {
227
+ if (width <= 0) {
228
+ return [""];
229
+ }
230
+
231
+ if (!wordWrap) {
232
+ return [fitToWidth(text, width)];
233
+ }
234
+
235
+ const wrapped = wrapTextWithAnsi(text, width);
236
+ if (wrapped.length === 0) {
237
+ return [fitToWidth("", width)];
238
+ }
239
+
240
+ return wrapped.map((line) => fitToWidth(line, width));
241
+ }
242
+
243
+ function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
244
+ if (!rawPath || !rawPath.trim()) {
245
+ return undefined;
246
+ }
247
+ const normalizedPath = rawPath.replace(/^@/, "").trim();
248
+ if (!normalizedPath) {
249
+ return undefined;
250
+ }
251
+ try {
252
+ return getLanguageFromPath(normalizedPath);
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ function createCodeLineHighlighter(language: string | undefined): CodeLineHighlighter {
259
+ if (!language) {
260
+ return (line) => sanitizeAnsiForThemedOutput(line);
261
+ }
262
+
263
+ const cache = new Map<string, string>();
264
+ return (line) => {
265
+ if (!line) {
266
+ return line;
267
+ }
268
+ const cached = cache.get(line);
269
+ if (cached !== undefined) {
270
+ return cached;
271
+ }
272
+ try {
273
+ const highlighted = highlightCode(line, language)[0] ?? line;
274
+ const sanitized = sanitizeAnsiForThemedOutput(highlighted);
275
+ cache.set(line, sanitized);
276
+ return sanitized;
277
+ } catch {
278
+ const sanitizedFallback = sanitizeAnsiForThemedOutput(line);
279
+ cache.set(line, sanitizedFallback);
280
+ return sanitizedFallback;
281
+ }
282
+ };
283
+ }
284
+
285
+ function toParsedDiffLine(
286
+ prefix: string,
287
+ lineNumber: string,
288
+ content: string,
289
+ ): {
290
+ lineKind: DiffLineKind;
291
+ lineNumber: string;
292
+ content: string;
293
+ } {
294
+ const normalizedLineNumber = lineNumber.trim();
295
+ if (prefix === "+") {
296
+ return { lineKind: "add", lineNumber: normalizedLineNumber, content };
297
+ }
298
+ if (prefix === "-") {
299
+ return { lineKind: "remove", lineNumber: normalizedLineNumber, content };
300
+ }
301
+ return { lineKind: "context", lineNumber: normalizedLineNumber, content };
302
+ }
303
+
304
+ function parseCanonicalDiffLine(line: string): {
305
+ lineKind: DiffLineKind;
306
+ lineNumber: string;
307
+ content: string;
308
+ hashlineAnchorContent?: string;
309
+ } | null {
310
+ const hashlineAnchorMatch = line.match(HASHLINE_ANCHOR_LINE_PATTERN);
311
+ if (hashlineAnchorMatch) {
312
+ const lineNumber = hashlineAnchorMatch[2] ?? "";
313
+ const hash = hashlineAnchorMatch[3] ?? "";
314
+ const content = hashlineAnchorMatch[4] ?? "";
315
+ const parsed = toParsedDiffLine(
316
+ hashlineAnchorMatch[1] ?? " ",
317
+ lineNumber,
318
+ content,
319
+ );
320
+ return {
321
+ ...parsed,
322
+ hashlineAnchorContent: `${lineNumber.trim()}#${hash}:${content}`,
323
+ };
324
+ }
325
+
326
+ const canonicalMatch = line.match(CANONICAL_LINE_PATTERN);
327
+ const legacyMatch = canonicalMatch ? null : line.match(LEGACY_LINE_PATTERN);
328
+ const matched = canonicalMatch ?? legacyMatch;
329
+ if (!matched) {
330
+ return null;
331
+ }
332
+
333
+ return toParsedDiffLine(
334
+ matched[1] ?? " ",
335
+ matched[2] ?? "",
336
+ matched[3] ?? "",
337
+ );
338
+ }
339
+
340
+ function toNumber(value: string | undefined): number | null {
341
+ if (!value) {
342
+ return null;
343
+ }
344
+ const parsed = Number.parseInt(value, 10);
345
+ return Number.isNaN(parsed) ? null : parsed;
346
+ }
347
+
348
+ function anchorCanonicalLineCursors(
349
+ kind: DiffLineKind,
350
+ parsedNumber: number | null,
351
+ oldLineCursor: number | null,
352
+ newLineCursor: number | null,
353
+ lineNumberDelta: number,
354
+ ): { oldLineCursor: number | null; newLineCursor: number | null } {
355
+ if (parsedNumber === null) {
356
+ return { oldLineCursor, newLineCursor };
357
+ }
358
+
359
+ if (kind === "add") {
360
+ return {
361
+ oldLineCursor,
362
+ newLineCursor: newLineCursor ?? parsedNumber,
363
+ };
364
+ }
365
+
366
+ return {
367
+ oldLineCursor: parsedNumber,
368
+ newLineCursor: parsedNumber + lineNumberDelta,
369
+ };
370
+ }
371
+
372
+ function classifyMetaLine(raw: string): DiffMetaEntry["kind"] {
373
+ if (raw.startsWith("@@")) {
374
+ return "hunk";
375
+ }
376
+ if (
377
+ raw.startsWith("diff --git")
378
+ || raw.startsWith("index ")
379
+ || raw.startsWith("--- ")
380
+ || raw.startsWith("+++ ")
381
+ || raw.startsWith("rename from ")
382
+ || raw.startsWith("rename to ")
383
+ || raw.startsWith("new file mode ")
384
+ || raw.startsWith("deleted file mode ")
385
+ ) {
386
+ return "file";
387
+ }
388
+ return "meta";
389
+ }
390
+
391
+ function pushParsedLineEntry(
392
+ entries: ParsedDiffEntry[],
393
+ lineKind: DiffLineKind,
394
+ oldLineNumber: number | null,
395
+ newLineNumber: number | null,
396
+ fallbackLineNumber: string,
397
+ rawLine: string,
398
+ hunkIndex: number,
399
+ ): void {
400
+ entries.push({
401
+ kind: "line",
402
+ lineKind,
403
+ oldLineNumber,
404
+ newLineNumber,
405
+ fallbackLineNumber,
406
+ content: rawLine.slice(1),
407
+ raw: rawLine,
408
+ hunkIndex,
409
+ });
410
+ }
411
+
412
+ function createMetaEntry(raw: string, hunkIndex: number): DiffMetaEntry {
413
+ return {
414
+ kind: classifyMetaLine(raw),
415
+ raw,
416
+ hunkIndex,
417
+ };
418
+ }
419
+
420
+ function ensureImplicitHunk(currentHunk: number): number {
421
+ return currentHunk > 0 ? currentHunk : 1;
422
+ }
423
+
424
+ function parseDiff(diffText: string): ParsedDiff {
425
+ const stats: DiffStats = {
426
+ added: 0,
427
+ removed: 0,
428
+ context: 0,
429
+ hunks: 0,
430
+ files: 0,
431
+ lines: 0,
432
+ };
433
+ const entries: ParsedDiffEntry[] = [];
434
+
435
+ if (!diffText.trim()) {
436
+ return { entries, stats };
437
+ }
438
+
439
+ let hunkIndex = 0;
440
+ let oldLineCursor: number | null = null;
441
+ let newLineCursor: number | null = null;
442
+ let lineNumberDelta = 0;
443
+
444
+ for (const rawLine of diffText.replace(/\r/g, "").split("\n")) {
445
+ stats.lines++;
446
+
447
+ const hunkMatch = rawLine.match(HUNK_HEADER_PATTERN);
448
+ if (hunkMatch) {
449
+ hunkIndex++;
450
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
451
+ oldLineCursor = toNumber(hunkMatch[1]);
452
+ newLineCursor = toNumber(hunkMatch[3]);
453
+ lineNumberDelta = (newLineCursor ?? 0) - (oldLineCursor ?? 0);
454
+ entries.push({ kind: "hunk", raw: rawLine, hunkIndex });
455
+ continue;
456
+ }
457
+
458
+ if (rawLine.startsWith("diff --git ")) {
459
+ stats.files++;
460
+ oldLineCursor = null;
461
+ newLineCursor = null;
462
+ lineNumberDelta = 0;
463
+ entries.push({ kind: "file", raw: rawLine, hunkIndex });
464
+ continue;
465
+ }
466
+
467
+ if (rawLine.startsWith("--- ") || rawLine.startsWith("+++ ")) {
468
+ oldLineCursor = null;
469
+ newLineCursor = null;
470
+ lineNumberDelta = 0;
471
+ }
472
+
473
+ const canonical = parseCanonicalDiffLine(rawLine);
474
+ if (canonical) {
475
+ hunkIndex = ensureImplicitHunk(hunkIndex);
476
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
477
+
478
+ const parsedNumber = toNumber(canonical.lineNumber);
479
+ const anchoredCursors = anchorCanonicalLineCursors(
480
+ canonical.lineKind,
481
+ parsedNumber,
482
+ oldLineCursor,
483
+ newLineCursor,
484
+ lineNumberDelta,
485
+ );
486
+ oldLineCursor = anchoredCursors.oldLineCursor;
487
+ newLineCursor = anchoredCursors.newLineCursor;
488
+
489
+ const oldLineNumber = canonical.lineKind === "add" ? null : oldLineCursor;
490
+ const newLineNumber = canonical.lineKind === "remove" ? null : newLineCursor;
491
+
492
+ if (canonical.lineKind === "add") {
493
+ stats.added++;
494
+ if (newLineCursor !== null) {
495
+ newLineCursor++;
496
+ }
497
+ lineNumberDelta++;
498
+ } else if (canonical.lineKind === "remove") {
499
+ stats.removed++;
500
+ if (oldLineCursor !== null) {
501
+ oldLineCursor++;
502
+ }
503
+ lineNumberDelta--;
504
+ } else {
505
+ stats.context++;
506
+ if (oldLineCursor !== null) {
507
+ oldLineCursor++;
508
+ }
509
+ if (newLineCursor !== null) {
510
+ newLineCursor++;
511
+ }
512
+ }
513
+
514
+ entries.push({
515
+ kind: "line",
516
+ lineKind: canonical.lineKind,
517
+ oldLineNumber,
518
+ newLineNumber,
519
+ fallbackLineNumber: canonical.lineNumber,
520
+ content: canonical.content,
521
+ hashlineAnchorContent: canonical.hashlineAnchorContent,
522
+ raw: rawLine,
523
+ hunkIndex,
524
+ });
525
+ continue;
526
+ }
527
+
528
+ if (rawLine.startsWith("-") && !rawLine.startsWith("---")) {
529
+ hunkIndex = ensureImplicitHunk(hunkIndex);
530
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
531
+ stats.removed++;
532
+ const oldLineNumber = oldLineCursor;
533
+ if (oldLineCursor !== null) {
534
+ oldLineCursor++;
535
+ }
536
+ lineNumberDelta--;
537
+ pushParsedLineEntry(
538
+ entries,
539
+ "remove",
540
+ oldLineNumber,
541
+ null,
542
+ oldLineNumber !== null ? `${oldLineNumber}` : "",
543
+ rawLine,
544
+ hunkIndex,
545
+ );
546
+ continue;
547
+ }
548
+
549
+ if (rawLine.startsWith("+") && !rawLine.startsWith("+++")) {
550
+ hunkIndex = ensureImplicitHunk(hunkIndex);
551
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
552
+ stats.added++;
553
+ const newLineNumber = newLineCursor;
554
+ if (newLineCursor !== null) {
555
+ newLineCursor++;
556
+ }
557
+ lineNumberDelta++;
558
+ pushParsedLineEntry(
559
+ entries,
560
+ "add",
561
+ null,
562
+ newLineNumber,
563
+ newLineNumber !== null ? `${newLineNumber}` : "",
564
+ rawLine,
565
+ hunkIndex,
566
+ );
567
+ continue;
568
+ }
569
+
570
+ if (rawLine.startsWith(" ")) {
571
+ hunkIndex = ensureImplicitHunk(hunkIndex);
572
+ stats.hunks = Math.max(stats.hunks, hunkIndex);
573
+ stats.context++;
574
+ const oldLineNumber = oldLineCursor;
575
+ const newLineNumber = newLineCursor;
576
+ if (oldLineCursor !== null) {
577
+ oldLineCursor++;
578
+ }
579
+ if (newLineCursor !== null) {
580
+ newLineCursor++;
581
+ }
582
+ pushParsedLineEntry(
583
+ entries,
584
+ "context",
585
+ oldLineNumber,
586
+ newLineNumber,
587
+ oldLineNumber !== null ? `${oldLineNumber}` : newLineNumber !== null ? `${newLineNumber}` : "",
588
+ rawLine,
589
+ hunkIndex,
590
+ );
591
+ continue;
592
+ }
593
+
594
+ entries.push(createMetaEntry(rawLine, hunkIndex));
595
+ }
596
+
597
+ if (stats.hunks === 0 && (stats.added > 0 || stats.removed > 0 || stats.context > 0)) {
598
+ stats.hunks = 1;
599
+ }
600
+ if (stats.files === 0) {
601
+ const patchStyleFileHeaders = entries.filter(
602
+ (entry) => entry.kind === "file" && entry.raw.startsWith("+++ "),
603
+ ).length;
604
+ if (patchStyleFileHeaders > 0) {
605
+ stats.files = patchStyleFileHeaders;
606
+ } else if (stats.hunks > 0) {
607
+ stats.files = 1;
608
+ }
609
+ }
610
+
611
+ return { entries, stats };
612
+ }
613
+
614
+ function getHashlineAnchorLabel(entry: DiffLineEntry): string | undefined {
615
+ if (!entry.hashlineAnchorContent) {
616
+ return undefined;
617
+ }
618
+ const separatorIndex = entry.hashlineAnchorContent.indexOf(":");
619
+ return separatorIndex >= 0
620
+ ? entry.hashlineAnchorContent.slice(0, separatorIndex)
621
+ : entry.hashlineAnchorContent;
622
+ }
623
+
624
+ function getLineNumberWidth(entries: ParsedDiffEntry[], showHashlineAnchors = false): number {
625
+ let maxWidth = MIN_LINE_NUMBER_WIDTH;
626
+
627
+ for (const entry of entries) {
628
+ if (entry.kind !== "line") {
629
+ continue;
630
+ }
631
+
632
+ if (showHashlineAnchors) {
633
+ const anchorLabel = getHashlineAnchorLabel(entry);
634
+ if (anchorLabel) {
635
+ maxWidth = Math.max(maxWidth, visibleWidth(anchorLabel));
636
+ continue;
637
+ }
638
+ }
639
+
640
+ const candidates = [
641
+ entry.oldLineNumber,
642
+ entry.newLineNumber,
643
+ toNumber(entry.fallbackLineNumber),
644
+ ].filter((value): value is number => value !== null);
645
+
646
+ for (const candidate of candidates) {
647
+ const digits = `${candidate}`.length;
648
+ if (digits > maxWidth) {
649
+ maxWidth = digits;
650
+ }
651
+ }
652
+ }
653
+
654
+ return maxWidth;
655
+ }
656
+
657
+ function formatLineNumber(value: number | null, fallback: string, width: number): string {
658
+ if (value !== null) {
659
+ return `${value}`.padStart(width, " ");
660
+ }
661
+ if (fallback.trim()) {
662
+ return fallback.trim().slice(-width).padStart(width, " ");
663
+ }
664
+ return " ".repeat(width);
665
+ }
666
+
667
+ function formatLineNumberLabel(
668
+ entry: DiffLineEntry,
669
+ value: number | null,
670
+ fallback: string,
671
+ width: number,
672
+ showHashlineAnchors: boolean,
673
+ ): string {
674
+ const anchorLabel = showHashlineAnchors ? getHashlineAnchorLabel(entry) : undefined;
675
+ if (anchorLabel) {
676
+ return fitToWidth(anchorLabel, width);
677
+ }
678
+ return formatLineNumber(value, fallback, width);
679
+ }
680
+
681
+ function formatMetaEntryRows(entry: DiffMetaEntry, width: number, theme: DiffTheme, wordWrap: boolean): RenderedRow[] {
682
+ const normalized = sanitizeAnsiForThemedOutput(normalizeCodeWhitespace(entry.raw));
683
+ const lines = wordWrap
684
+ ? wrapToWidth(normalized, width, true)
685
+ : [truncateToWidth(normalized, width)];
686
+
687
+ const mapColor = (line: string): string => {
688
+ if (entry.kind === "hunk") {
689
+ return stabilizeBackgroundResets(theme.fg("accent", line));
690
+ }
691
+ if (entry.kind === "file") {
692
+ return stabilizeBackgroundResets(theme.fg("muted", line));
693
+ }
694
+ return stabilizeBackgroundResets(theme.fg("toolDiffContext", line));
695
+ };
696
+
697
+ return lines.map((line) => ({
698
+ text: mapColor(line),
699
+ hunkIndex: entry.kind === "file" ? null : entry.hunkIndex || null,
700
+ }));
701
+ }
702
+
703
+ function collectConsecutiveLineEntries(
704
+ entries: ParsedDiffEntry[],
705
+ startIndex: number,
706
+ lineKind: DiffLineKind,
707
+ ): { collected: DiffLineEntry[]; nextIndex: number } {
708
+ const collected: DiffLineEntry[] = [];
709
+ let index = startIndex;
710
+ while (index < entries.length) {
711
+ const candidate = entries[index];
712
+ if (!candidate || candidate.kind !== "line" || candidate.lineKind !== lineKind) {
713
+ break;
714
+ }
715
+ collected.push(candidate);
716
+ index++;
717
+ }
718
+ return { collected, nextIndex: index };
719
+ }
720
+
721
+ function buildSplitRows(entries: ParsedDiffEntry[]): SplitDiffRow[] {
722
+ const rows: SplitDiffRow[] = [];
723
+ let index = 0;
724
+
725
+ while (index < entries.length) {
726
+ const entry = entries[index];
727
+ if (!entry) {
728
+ break;
729
+ }
730
+
731
+ if (entry.kind !== "line") {
732
+ rows.push({ meta: entry, hunkIndex: entry.hunkIndex || null });
733
+ index++;
734
+ continue;
735
+ }
736
+
737
+ if (entry.lineKind === "remove") {
738
+ const removedResult = collectConsecutiveLineEntries(entries, index, "remove");
739
+ const removed = removedResult.collected;
740
+ const addedResult = collectConsecutiveLineEntries(entries, removedResult.nextIndex, "add");
741
+ const added = addedResult.collected;
742
+ index = addedResult.nextIndex;
743
+
744
+ const pairCount = Math.max(removed.length, added.length);
745
+ for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
746
+ const left = removed[pairIndex];
747
+ const right = added[pairIndex];
748
+ rows.push({
749
+ left,
750
+ right,
751
+ hunkIndex: left?.hunkIndex ?? right?.hunkIndex ?? null,
752
+ });
753
+ }
754
+ continue;
755
+ }
756
+
757
+ if (entry.lineKind === "add") {
758
+ rows.push({ right: entry, hunkIndex: entry.hunkIndex || null });
759
+ index++;
760
+ continue;
761
+ }
762
+
763
+ rows.push({ left: entry, right: entry, hunkIndex: entry.hunkIndex || null });
764
+ index++;
765
+ }
766
+
767
+ return rows;
768
+ }
769
+
770
+ function getCellLineNumber(line: DiffLineEntry, side: "left" | "right"): number | null {
771
+ if (side === "left") {
772
+ return line.oldLineNumber ?? (line.lineKind === "context" ? line.newLineNumber : null);
773
+ }
774
+ return line.newLineNumber ?? (line.lineKind === "context" ? line.oldLineNumber : null);
775
+ }
776
+
777
+ function tokenizeInlineDiff(input: string): Array<{ value: string; start: number; end: number }> {
778
+ if (!input) {
779
+ return [];
780
+ }
781
+
782
+ const tokens: Array<{ value: string; start: number; end: number }> = [];
783
+ const pattern = /(\s+|[A-Za-z0-9_]+|[^A-Za-z0-9_\s])/g;
784
+ let match: RegExpExecArray | null;
785
+
786
+ while ((match = pattern.exec(input)) !== null) {
787
+ const value = match[0] ?? "";
788
+ if (!value) {
789
+ continue;
790
+ }
791
+ tokens.push({
792
+ value,
793
+ start: match.index,
794
+ end: match.index + value.length,
795
+ });
796
+ }
797
+
798
+ if (tokens.length === 0 && input.length > 0) {
799
+ tokens.push({ value: input, start: 0, end: input.length });
800
+ }
801
+
802
+ return tokens;
803
+ }
804
+
805
+ function mergeSpans(spans: DiffSpan[]): DiffSpan[] {
806
+ if (spans.length <= 1) {
807
+ return spans;
808
+ }
809
+
810
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
811
+ const merged: DiffSpan[] = [sorted[0]];
812
+
813
+ for (let index = 1; index < sorted.length; index++) {
814
+ const current = sorted[index];
815
+ const previous = merged[merged.length - 1];
816
+ if (!current || !previous) {
817
+ continue;
818
+ }
819
+
820
+ if (current.start <= previous.end) {
821
+ previous.end = Math.max(previous.end, current.end);
822
+ continue;
823
+ }
824
+
825
+ merged.push({ ...current });
826
+ }
827
+
828
+ return merged;
829
+ }
830
+
831
+ function tokensToDiffSpans(
832
+ text: string,
833
+ tokens: Array<{ value: string; start: number; end: number }>,
834
+ changedIndexes: Set<number>,
835
+ ): DiffSpan[] {
836
+ if (tokens.length === 0 || changedIndexes.size === 0) {
837
+ return [];
838
+ }
839
+
840
+ const spans: DiffSpan[] = [];
841
+ let start: number | null = null;
842
+ let end = -1;
843
+
844
+ for (let index = 0; index < tokens.length; index++) {
845
+ if (!changedIndexes.has(index)) {
846
+ if (start !== null && end > start) {
847
+ spans.push({ start, end });
848
+ start = null;
849
+ end = -1;
850
+ }
851
+ continue;
852
+ }
853
+
854
+ const token = tokens[index];
855
+ if (!token) {
856
+ continue;
857
+ }
858
+
859
+ if (start === null) {
860
+ start = token.start;
861
+ end = token.end;
862
+ } else {
863
+ end = token.end;
864
+ }
865
+ }
866
+
867
+ if (start !== null && end > start) {
868
+ spans.push({ start, end });
869
+ }
870
+
871
+ const trimmed: DiffSpan[] = [];
872
+ for (const span of spans) {
873
+ let spanStart = span.start;
874
+ let spanEnd = span.end;
875
+
876
+ while (spanStart < spanEnd && /\s/.test(text[spanStart] ?? "")) {
877
+ spanStart++;
878
+ }
879
+ while (spanEnd > spanStart && /\s/.test(text[spanEnd - 1] ?? "")) {
880
+ spanEnd--;
881
+ }
882
+ if (spanEnd > spanStart) {
883
+ trimmed.push({ start: spanStart, end: spanEnd });
884
+ }
885
+ }
886
+
887
+ return mergeSpans(trimmed);
888
+ }
889
+
890
+ function computeInlineDiffSpans(leftLine: string, rightLine: string): { left: DiffSpan[]; right: DiffSpan[] } {
891
+ if (leftLine === rightLine) {
892
+ return { left: [], right: [] };
893
+ }
894
+ if (leftLine.length > MAX_INLINE_DIFF_LINE_LENGTH || rightLine.length > MAX_INLINE_DIFF_LINE_LENGTH) {
895
+ return { left: [], right: [] };
896
+ }
897
+
898
+ const leftTokens = tokenizeInlineDiff(leftLine);
899
+ const rightTokens = tokenizeInlineDiff(rightLine);
900
+ const leftCount = leftTokens.length;
901
+ const rightCount = rightTokens.length;
902
+
903
+ if (leftCount === 0 || rightCount === 0) {
904
+ return {
905
+ left: leftLine.trim().length > 0 ? [{ start: 0, end: leftLine.length }] : [],
906
+ right: rightLine.trim().length > 0 ? [{ start: 0, end: rightLine.length }] : [],
907
+ };
908
+ }
909
+
910
+ const table: number[][] = Array.from({ length: leftCount + 1 }, () => Array<number>(rightCount + 1).fill(0));
911
+
912
+ for (let leftIndex = 1; leftIndex <= leftCount; leftIndex++) {
913
+ const leftToken = leftTokens[leftIndex - 1];
914
+ for (let rightIndex = 1; rightIndex <= rightCount; rightIndex++) {
915
+ const rightToken = rightTokens[rightIndex - 1];
916
+ if (leftToken?.value === rightToken?.value) {
917
+ table[leftIndex][rightIndex] = (table[leftIndex - 1]?.[rightIndex - 1] ?? 0) + 1;
918
+ } else {
919
+ const top = table[leftIndex - 1]?.[rightIndex] ?? 0;
920
+ const side = table[leftIndex]?.[rightIndex - 1] ?? 0;
921
+ table[leftIndex][rightIndex] = Math.max(top, side);
922
+ }
923
+ }
924
+ }
925
+
926
+ const changedLeft = new Set<number>();
927
+ const changedRight = new Set<number>();
928
+ let leftCursor = leftCount;
929
+ let rightCursor = rightCount;
930
+
931
+ while (leftCursor > 0 && rightCursor > 0) {
932
+ const leftToken = leftTokens[leftCursor - 1];
933
+ const rightToken = rightTokens[rightCursor - 1];
934
+ if (leftToken?.value === rightToken?.value) {
935
+ leftCursor--;
936
+ rightCursor--;
937
+ continue;
938
+ }
939
+
940
+ const top = table[leftCursor - 1]?.[rightCursor] ?? 0;
941
+ const side = table[leftCursor]?.[rightCursor - 1] ?? 0;
942
+ if (top >= side) {
943
+ changedLeft.add(leftCursor - 1);
944
+ leftCursor--;
945
+ } else {
946
+ changedRight.add(rightCursor - 1);
947
+ rightCursor--;
948
+ }
949
+ }
950
+
951
+ while (leftCursor > 0) {
952
+ changedLeft.add(leftCursor - 1);
953
+ leftCursor--;
954
+ }
955
+ while (rightCursor > 0) {
956
+ changedRight.add(rightCursor - 1);
957
+ rightCursor--;
958
+ }
959
+
960
+ return {
961
+ left: tokensToDiffSpans(leftLine, leftTokens, changedLeft),
962
+ right: tokensToDiffSpans(rightLine, rightTokens, changedRight),
963
+ };
964
+ }
965
+
966
+ function getCompactLineRenderContent(entry: DiffLineEntry, showHashlineAnchors: boolean): string {
967
+ return showHashlineAnchors && entry.hashlineAnchorContent
968
+ ? entry.hashlineAnchorContent
969
+ : entry.content;
970
+ }
971
+
972
+ function buildInlineHighlightMap(rows: SplitDiffRow[]): WeakMap<DiffLineEntry, DiffSpan[]> {
973
+ const highlights = new WeakMap<DiffLineEntry, DiffSpan[]>();
974
+
975
+ for (const row of rows) {
976
+ if (!row.left || !row.right) {
977
+ continue;
978
+ }
979
+ if (row.left.lineKind !== "remove" || row.right.lineKind !== "add") {
980
+ continue;
981
+ }
982
+
983
+ const leftText = normalizeCodeWhitespace(row.left.content);
984
+ const rightText = normalizeCodeWhitespace(row.right.content);
985
+ const inline = computeInlineDiffSpans(leftText, rightText);
986
+ if (inline.left.length > 0) {
987
+ highlights.set(row.left, inline.left);
988
+ }
989
+ if (inline.right.length > 0) {
990
+ highlights.set(row.right, inline.right);
991
+ }
992
+ }
993
+
994
+ return highlights;
995
+ }
996
+
997
+ function ansi256ToRgb(code: number): RgbColor {
998
+ if (code < 0) {
999
+ return { r: 0, g: 0, b: 0 };
1000
+ }
1001
+ if (code <= 15) {
1002
+ const base16: RgbColor[] = [
1003
+ { r: 0, g: 0, b: 0 },
1004
+ { r: 128, g: 0, b: 0 },
1005
+ { r: 0, g: 128, b: 0 },
1006
+ { r: 128, g: 128, b: 0 },
1007
+ { r: 0, g: 0, b: 128 },
1008
+ { r: 128, g: 0, b: 128 },
1009
+ { r: 0, g: 128, b: 128 },
1010
+ { r: 192, g: 192, b: 192 },
1011
+ { r: 128, g: 128, b: 128 },
1012
+ { r: 255, g: 0, b: 0 },
1013
+ { r: 0, g: 255, b: 0 },
1014
+ { r: 255, g: 255, b: 0 },
1015
+ { r: 0, g: 0, b: 255 },
1016
+ { r: 255, g: 0, b: 255 },
1017
+ { r: 0, g: 255, b: 255 },
1018
+ { r: 255, g: 255, b: 255 },
1019
+ ];
1020
+ return base16[code] ?? { r: 255, g: 255, b: 255 };
1021
+ }
1022
+ if (code >= 232) {
1023
+ const value = Math.max(0, Math.min(255, 8 + (code - 232) * 10));
1024
+ return { r: value, g: value, b: value };
1025
+ }
1026
+
1027
+ const cube = code - 16;
1028
+ const levels = [0, 95, 135, 175, 215, 255];
1029
+ const blue = cube % 6;
1030
+ const green = Math.floor(cube / 6) % 6;
1031
+ const red = Math.floor(cube / 36) % 6;
1032
+ return {
1033
+ r: levels[red] ?? 0,
1034
+ g: levels[green] ?? 0,
1035
+ b: levels[blue] ?? 0,
1036
+ };
1037
+ }
1038
+
1039
+ function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
1040
+ if (!ansi) {
1041
+ return null;
1042
+ }
1043
+ const rgbMatch = /\x1b\[(?:3|4)8;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
1044
+ if (rgbMatch) {
1045
+ const r = Number.parseInt(rgbMatch[1] ?? "0", 10);
1046
+ const g = Number.parseInt(rgbMatch[2] ?? "0", 10);
1047
+ const b = Number.parseInt(rgbMatch[3] ?? "0", 10);
1048
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
1049
+ return {
1050
+ r: Math.max(0, Math.min(255, r)),
1051
+ g: Math.max(0, Math.min(255, g)),
1052
+ b: Math.max(0, Math.min(255, b)),
1053
+ };
1054
+ }
1055
+ }
1056
+
1057
+ const bitMatch = /\x1b\[(?:3|4)8;5;(\d{1,3})m/.exec(ansi);
1058
+ if (bitMatch) {
1059
+ const code = Number.parseInt(bitMatch[1] ?? "0", 10);
1060
+ if (Number.isFinite(code)) {
1061
+ return ansi256ToRgb(code);
1062
+ }
1063
+ }
1064
+
1065
+ return null;
1066
+ }
1067
+
1068
+ function rgbToBgAnsi(color: RgbColor): string {
1069
+ const r = Math.max(0, Math.min(255, Math.round(color.r)));
1070
+ const g = Math.max(0, Math.min(255, Math.round(color.g)));
1071
+ const b = Math.max(0, Math.min(255, Math.round(color.b)));
1072
+ return `\x1b[48;2;${r};${g};${b}m`;
1073
+ }
1074
+
1075
+ function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
1076
+ const clamped = Math.max(0, Math.min(1, ratio));
1077
+ return {
1078
+ r: base.r * (1 - clamped) + tint.r * clamped,
1079
+ g: base.g * (1 - clamped) + tint.g * clamped,
1080
+ b: base.b * (1 - clamped) + tint.b * clamped,
1081
+ };
1082
+ }
1083
+
1084
+ function extractThemeBackgroundAnsi(text: string): string | undefined {
1085
+ if (!text || !text.includes("\x1b[")) {
1086
+ return undefined;
1087
+ }
1088
+
1089
+ ANSI_SGR_PATTERN.lastIndex = 0;
1090
+ let match: RegExpExecArray | null;
1091
+ while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
1092
+ const parsed = toSgrParams(match[1] ?? "");
1093
+ for (let index = 0; index < parsed.length; index += 1) {
1094
+ const param = parsed[index] ?? 0;
1095
+ if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
1096
+ return `\x1b[${param}m`;
1097
+ }
1098
+
1099
+ const colorSequence = readSgrColorSequence(parsed, index);
1100
+ if (colorSequence?.[0] === 48) {
1101
+ return `\x1b[${colorSequence.join(";")}m`;
1102
+ }
1103
+ if (colorSequence) {
1104
+ index += colorSequence.length - 1;
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ return undefined;
1110
+ }
1111
+
1112
+ function readThemeAnsi(theme: DiffTheme, kind: "fg" | "bg", slot: string): string | undefined {
1113
+ try {
1114
+ if (kind === "fg" && typeof theme.getFgAnsi === "function") {
1115
+ return theme.getFgAnsi(slot);
1116
+ }
1117
+ if (kind === "bg") {
1118
+ if (typeof theme.getBgAnsi === "function") {
1119
+ return theme.getBgAnsi(slot);
1120
+ }
1121
+ if (typeof theme.bg === "function") {
1122
+ return extractThemeBackgroundAnsi(theme.bg(slot, " "));
1123
+ }
1124
+ }
1125
+ } catch {
1126
+ return undefined;
1127
+ }
1128
+ return undefined;
1129
+ }
1130
+
1131
+ function resolveContainerBackgroundAnsi(theme: DiffTheme): string | undefined {
1132
+ return readThemeAnsi(theme, "bg", "toolSuccessBg")
1133
+ ?? readThemeAnsi(theme, "bg", "toolPendingBg")
1134
+ ?? readThemeAnsi(theme, "bg", "toolErrorBg")
1135
+ ?? readThemeAnsi(theme, "bg", "userMessageBg");
1136
+ }
1137
+
1138
+ function resolveDiffPalette(theme: DiffTheme): DiffPalette {
1139
+ const baseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg"))
1140
+ ?? parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg"))
1141
+ ?? parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg"))
1142
+ ?? { r: 32, g: 35, b: 42 };
1143
+ const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffAdded")) ?? { r: 88, g: 173, b: 88 };
1144
+ const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffRemoved")) ?? { r: 196, g: 98, b: 98 };
1145
+ const addTint = mixRgb(addFg, ADDITION_TINT_TARGET, 0.35);
1146
+ const removeTint = mixRgb(removeFg, DELETION_TINT_TARGET, 0.65);
1147
+
1148
+ const addRowBg = mixRgb(baseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO);
1149
+ const removeRowBg = mixRgb(baseBg, removeTint, REMOVE_ROW_BACKGROUND_MIX_RATIO);
1150
+ const addEmphasisBg = mixRgb(baseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO);
1151
+ const removeEmphasisBg = mixRgb(baseBg, removeTint, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
1152
+
1153
+ return {
1154
+ addRowBgAnsi: rgbToBgAnsi(addRowBg),
1155
+ removeRowBgAnsi: rgbToBgAnsi(removeRowBg),
1156
+ addEmphasisBgAnsi: rgbToBgAnsi(addEmphasisBg),
1157
+ removeEmphasisBgAnsi: rgbToBgAnsi(removeEmphasisBg),
1158
+ };
1159
+ }
1160
+
1161
+ function getLineBackground(kind: DiffLineKind, palette: DiffPalette, emphasis: boolean): string | undefined {
1162
+ if (kind === "add") {
1163
+ return emphasis ? palette.addEmphasisBgAnsi : palette.addRowBgAnsi;
1164
+ }
1165
+ if (kind === "remove") {
1166
+ return emphasis ? palette.removeEmphasisBgAnsi : palette.removeRowBgAnsi;
1167
+ }
1168
+ return undefined;
1169
+ }
1170
+
1171
+ function applyBackgroundToVisibleRange(
1172
+ ansiText: string,
1173
+ start: number,
1174
+ end: number,
1175
+ backgroundAnsi: string,
1176
+ restoreBackgroundAnsi: string,
1177
+ ): string {
1178
+ if (!ansiText || start >= end || end <= 0) {
1179
+ return ansiText;
1180
+ }
1181
+
1182
+ const rangeStart = Math.max(0, start);
1183
+ const rangeEnd = Math.max(rangeStart, end);
1184
+ let output = "";
1185
+ let visibleIndex = 0;
1186
+ let index = 0;
1187
+ let inRange = false;
1188
+
1189
+ while (index < ansiText.length) {
1190
+ if (ansiText[index] === "\x1b") {
1191
+ const sequenceEnd = ansiText.indexOf("m", index);
1192
+ if (sequenceEnd !== -1) {
1193
+ output += ansiText.slice(index, sequenceEnd + 1);
1194
+ index = sequenceEnd + 1;
1195
+ continue;
1196
+ }
1197
+ }
1198
+
1199
+ if (visibleIndex === rangeStart && !inRange) {
1200
+ output += backgroundAnsi;
1201
+ inRange = true;
1202
+ }
1203
+ if (visibleIndex === rangeEnd && inRange) {
1204
+ output += restoreBackgroundAnsi;
1205
+ inRange = false;
1206
+ }
1207
+
1208
+ output += ansiText[index] ?? "";
1209
+ visibleIndex++;
1210
+ index++;
1211
+ }
1212
+
1213
+ if (inRange) {
1214
+ output += restoreBackgroundAnsi;
1215
+ }
1216
+
1217
+ return output;
1218
+ }
1219
+
1220
+ function applyInlineSpanHighlight(
1221
+ plainText: string,
1222
+ renderedText: string,
1223
+ spans: DiffSpan[],
1224
+ emphasisBgAnsi: string | undefined,
1225
+ rowBgAnsi: string | undefined,
1226
+ fallbackBgAnsi: string | undefined,
1227
+ ): string {
1228
+ if (!renderedText || !plainText || spans.length === 0 || !emphasisBgAnsi) {
1229
+ return renderedText;
1230
+ }
1231
+
1232
+ const sorted = mergeSpans(
1233
+ spans
1234
+ .map((span) => ({
1235
+ start: Math.max(0, Math.min(plainText.length, span.start)),
1236
+ end: Math.max(0, Math.min(plainText.length, span.end)),
1237
+ }))
1238
+ .filter((span) => span.end > span.start),
1239
+ );
1240
+ if (sorted.length === 0) {
1241
+ return renderedText;
1242
+ }
1243
+
1244
+ const restoreBackgroundAnsi = rowBgAnsi ?? fallbackBgAnsi ?? ANSI_BG_RESET;
1245
+ let highlighted = renderedText;
1246
+ for (let index = sorted.length - 1; index >= 0; index--) {
1247
+ const span = sorted[index];
1248
+ if (!span) {
1249
+ continue;
1250
+ }
1251
+ highlighted = applyBackgroundToVisibleRange(
1252
+ highlighted,
1253
+ span.start,
1254
+ span.end,
1255
+ emphasisBgAnsi,
1256
+ restoreBackgroundAnsi,
1257
+ );
1258
+ }
1259
+
1260
+ return highlighted;
1261
+ }
1262
+
1263
+ function resolveDiffIndicatorMode(config: Partial<Pick<ToolDisplayConfig, "diffIndicatorMode">>): DiffIndicatorMode {
1264
+ return config.diffIndicatorMode ?? DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode;
1265
+ }
1266
+
1267
+ function resolveIndicatorGlyph(kind: DiffLineKind, indicatorMode: DiffIndicatorMode, continuation: boolean): string {
1268
+ if (kind === "context") {
1269
+ return " ";
1270
+ }
1271
+
1272
+ switch (indicatorMode) {
1273
+ case "bars":
1274
+ return "▌";
1275
+ case "classic":
1276
+ if (continuation) {
1277
+ return " ";
1278
+ }
1279
+ return kind === "add" ? "+" : "-";
1280
+ case "none":
1281
+ default:
1282
+ return " ";
1283
+ }
1284
+ }
1285
+
1286
+ function colorizeSegment(
1287
+ theme: DiffTheme,
1288
+ color: "dim" | "toolDiffAdded" | "toolDiffRemoved",
1289
+ text: string,
1290
+ rowBg: string | undefined,
1291
+ ): string {
1292
+ let themedText: string;
1293
+ try {
1294
+ themedText = theme.fg(color, text);
1295
+ } catch {
1296
+ themedText = text;
1297
+ }
1298
+
1299
+ if (!rowBg) {
1300
+ return themedText;
1301
+ }
1302
+
1303
+ const stableText = keepBackgroundAcrossResets(themedText, rowBg);
1304
+ return `${rowBg}${stableText}${rowBg}`;
1305
+ }
1306
+
1307
+ function keepBackgroundAcrossResets(text: string, rowBg: string): string {
1308
+ if (!text) {
1309
+ return text;
1310
+ }
1311
+
1312
+ return text.replace(ANSI_SGR_PATTERN, (sequence, rawParams: string) => {
1313
+ const params = toSgrParams(rawParams);
1314
+ if (params.length === 0 || !sequenceResetsBackground(params)) {
1315
+ return sequence;
1316
+ }
1317
+ return `${sequence}${rowBg}`;
1318
+ });
1319
+ }
1320
+
1321
+ function renderChangeMarker(
1322
+ kind: DiffLineKind,
1323
+ theme: DiffTheme,
1324
+ rowBg: string | undefined,
1325
+ indicatorMode: DiffIndicatorMode,
1326
+ continuation = false,
1327
+ ): string {
1328
+ const glyph = resolveIndicatorGlyph(kind, indicatorMode, continuation);
1329
+ if (glyph === " ") {
1330
+ return rowBg ? `${rowBg} ${rowBg}` : " ";
1331
+ }
1332
+ if (kind === "add") {
1333
+ return colorizeSegment(theme, "toolDiffAdded", glyph, rowBg);
1334
+ }
1335
+ if (kind === "remove") {
1336
+ return colorizeSegment(theme, "toolDiffRemoved", glyph, rowBg);
1337
+ }
1338
+ return colorizeSegment(theme, "dim", glyph, rowBg);
1339
+ }
1340
+
1341
+ function usesHashlineGutter(showHashlineAnchors: boolean): boolean {
1342
+ return showHashlineAnchors;
1343
+ }
1344
+
1345
+ function getHashlineGutterMarkerWidth(_indicatorMode: DiffIndicatorMode): number {
1346
+ return 0;
1347
+ }
1348
+
1349
+ function getLineDividerPlainWidth(indicatorMode: DiffIndicatorMode, hashlineGutter = false): number {
1350
+ if (hashlineGutter) {
1351
+ return 2;
1352
+ }
1353
+ return indicatorMode === "classic" ? 1 : 2;
1354
+ }
1355
+
1356
+ function renderCodeDivider(
1357
+ theme: DiffTheme,
1358
+ rowBg: string | undefined,
1359
+ indicatorMode: DiffIndicatorMode,
1360
+ hashlineGutter = false,
1361
+ ): string {
1362
+ return colorizeSegment(theme, "dim", hashlineGutter || indicatorMode !== "classic" ? "│ " : "│", rowBg);
1363
+ }
1364
+
1365
+ function getLineNumberColor(kind: DiffLineKind): "dim" | "toolDiffAdded" | "toolDiffRemoved" {
1366
+ if (kind === "add") {
1367
+ return "toolDiffAdded";
1368
+ }
1369
+ if (kind === "remove") {
1370
+ return "toolDiffRemoved";
1371
+ }
1372
+ return "dim";
1373
+ }
1374
+
1375
+ function renderLineNumberSegment(
1376
+ kind: DiffLineKind,
1377
+ lineNumber: string,
1378
+ theme: DiffTheme,
1379
+ rowBg: string | undefined,
1380
+ ): string {
1381
+ return colorizeSegment(theme, getLineNumberColor(kind), lineNumber, rowBg);
1382
+ }
1383
+
1384
+ function getLinePrefixPlainWidth(lineNumberWidth: number, indicatorMode: DiffIndicatorMode, hashlineGutter = false): number {
1385
+ if (hashlineGutter) {
1386
+ return getHashlineGutterMarkerWidth(indicatorMode) + lineNumberWidth;
1387
+ }
1388
+ return indicatorMode === "bars"
1389
+ ? visibleWidth(`▌ ${" ".repeat(lineNumberWidth)} `)
1390
+ : visibleWidth(`${" ".repeat(lineNumberWidth)} `);
1391
+ }
1392
+
1393
+ function getLineContentIndicatorPrefixPlainWidth(indicatorMode: DiffIndicatorMode): number {
1394
+ return indicatorMode === "classic" ? 2 : 0;
1395
+ }
1396
+
1397
+ function renderClassicContentPrefix(
1398
+ kind: DiffLineKind,
1399
+ theme: DiffTheme,
1400
+ rowBg: string | undefined,
1401
+ continuation = false,
1402
+ ): string {
1403
+ if (kind === "context" || continuation) {
1404
+ return rowBg ? `${rowBg} ${rowBg}` : " ";
1405
+ }
1406
+
1407
+ const glyph = kind === "add" ? "+" : "-";
1408
+ const glyphColor = kind === "add" ? "toolDiffAdded" : "toolDiffRemoved";
1409
+ const spacer = rowBg ? `${rowBg} ` : " ";
1410
+ return `${colorizeSegment(theme, glyphColor, glyph, rowBg)}${spacer}`;
1411
+ }
1412
+
1413
+ function renderLinePrefix(
1414
+ kind: DiffLineKind,
1415
+ lineNumber: string,
1416
+ theme: DiffTheme,
1417
+ rowBg: string | undefined,
1418
+ indicatorMode: DiffIndicatorMode,
1419
+ continuation = false,
1420
+ hashlineGutter = false,
1421
+ ): string {
1422
+ const number = renderLineNumberSegment(kind, lineNumber, theme, rowBg);
1423
+ if (hashlineGutter) {
1424
+ return number;
1425
+ }
1426
+ const spacer = rowBg ? `${rowBg} ` : " ";
1427
+ if (indicatorMode !== "bars") {
1428
+ return `${number}${spacer}`;
1429
+ }
1430
+ const marker = renderChangeMarker(kind, theme, rowBg, indicatorMode, continuation);
1431
+ return `${marker}${spacer}${number}${spacer}`;
1432
+ }
1433
+
1434
+ function renderLineContinuationPrefix(
1435
+ kind: DiffLineKind,
1436
+ lineNumberWidth: number,
1437
+ rowBg: string | undefined,
1438
+ theme: DiffTheme,
1439
+ indicatorMode: DiffIndicatorMode,
1440
+ hashlineGutter = false,
1441
+ ): string {
1442
+ const blankLineNumber = " ".repeat(lineNumberWidth);
1443
+ return renderLinePrefix(kind, blankLineNumber, theme, rowBg, indicatorMode, true, hashlineGutter);
1444
+ }
1445
+
1446
+ function renderLineContentIndicatorPrefix(
1447
+ kind: DiffLineKind,
1448
+ theme: DiffTheme,
1449
+ rowBg: string | undefined,
1450
+ indicatorMode: DiffIndicatorMode,
1451
+ continuation = false,
1452
+ ): string {
1453
+ return indicatorMode === "classic"
1454
+ ? renderClassicContentPrefix(kind, theme, rowBg, continuation)
1455
+ : "";
1456
+ }
1457
+
1458
+ function renderCompactLinePrefix(
1459
+ kind: DiffLineKind,
1460
+ theme: DiffTheme,
1461
+ rowBg: string | undefined,
1462
+ indicatorMode: DiffIndicatorMode,
1463
+ continuation = false,
1464
+ ): string {
1465
+ const marker = renderChangeMarker(kind, theme, rowBg, indicatorMode, continuation);
1466
+ const spacer = rowBg ? `${rowBg} ` : " ";
1467
+ return `${marker}${spacer}`;
1468
+ }
1469
+
1470
+ interface LineCellRenderParams {
1471
+ kind: DiffLineKind;
1472
+ code: string;
1473
+ width: number;
1474
+ rowBg: string | undefined;
1475
+ restoreBgAnsi: string | undefined;
1476
+ theme: DiffTheme;
1477
+ wordWrap: boolean;
1478
+ indicatorMode: DiffIndicatorMode;
1479
+ }
1480
+
1481
+ function renderWrappedRowsWithOptionalBackground(
1482
+ wrappedCodeLines: string[],
1483
+ buildRow: (index: number, wrappedCodeLine: string) => string,
1484
+ width: number,
1485
+ rowBg: string | undefined,
1486
+ restoreBgAnsi: string | undefined,
1487
+ ): string[] {
1488
+ if (!rowBg) {
1489
+ return wrappedCodeLines.map((wrappedCodeLine, index) =>
1490
+ stabilizeBackgroundResets(buildRow(index, wrappedCodeLine)),
1491
+ );
1492
+ }
1493
+ const safeRestoreBgAnsi = restoreBgAnsi ?? rowBg ?? ANSI_BG_RESET;
1494
+ const visualRows = wrappedCodeLines.map((wrappedCodeLine, index) => buildRow(index, wrappedCodeLine));
1495
+ return applyLineBackgroundToWrappedRows(visualRows, width, rowBg, safeRestoreBgAnsi);
1496
+ }
1497
+
1498
+ function computeLineCellCodeWidth(
1499
+ width: number,
1500
+ lineNumberWidth: number,
1501
+ indicatorMode: DiffIndicatorMode,
1502
+ hashlineGutter: boolean,
1503
+ ): number {
1504
+ const prefixPlainWidth = getLinePrefixPlainWidth(lineNumberWidth, indicatorMode, hashlineGutter);
1505
+ const dividerPlainWidth = getLineDividerPlainWidth(indicatorMode, hashlineGutter);
1506
+ const contentIndicatorWidth = hashlineGutter ? 0 : getLineContentIndicatorPrefixPlainWidth(indicatorMode);
1507
+ return Math.max(0, width - prefixPlainWidth - dividerPlainWidth - contentIndicatorWidth);
1508
+ }
1509
+
1510
+ function buildLineCellParams(
1511
+ kind: DiffLineKind,
1512
+ code: string,
1513
+ width: number,
1514
+ rowBg: string | undefined,
1515
+ restoreBgAnsi: string | undefined,
1516
+ theme: DiffTheme,
1517
+ wordWrap: boolean,
1518
+ indicatorMode: DiffIndicatorMode,
1519
+ ): LineCellRenderParams {
1520
+ return { kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode };
1521
+ }
1522
+
1523
+ function renderCompactLineCell({ kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode }: LineCellRenderParams): string[] {
1524
+ if (width <= 0) {
1525
+ return [""];
1526
+ }
1527
+
1528
+ const prefix = renderCompactLinePrefix(kind, theme, undefined, indicatorMode);
1529
+ const continuationPrefix = renderCompactLinePrefix(kind, theme, undefined, indicatorMode, true);
1530
+ const prefixPlainWidth = 2;
1531
+ const codeWidth = Math.max(0, width - prefixPlainWidth);
1532
+ const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
1533
+ return renderWrappedRowsWithOptionalBackground(
1534
+ wrappedCodeLines,
1535
+ (index, line) => `${index === 0 ? prefix : continuationPrefix}${line}`,
1536
+ width,
1537
+ rowBg,
1538
+ restoreBgAnsi,
1539
+ );
1540
+ }
1541
+
1542
+ function renderLineCell(
1543
+ { kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode }: LineCellRenderParams,
1544
+ lineNumber: string,
1545
+ hashlineGutter = false,
1546
+ ): string[] {
1547
+ if (width <= 0) {
1548
+ return [""];
1549
+ }
1550
+
1551
+ const codeWidth = computeLineCellCodeWidth(width, lineNumber.length, indicatorMode, hashlineGutter);
1552
+ const prefix = renderLinePrefix(kind, lineNumber, theme, undefined, indicatorMode, false, hashlineGutter);
1553
+ const continuationPrefix = renderLineContinuationPrefix(kind, lineNumber.length, undefined, theme, indicatorMode, hashlineGutter);
1554
+ const divider = renderCodeDivider(theme, undefined, indicatorMode, hashlineGutter);
1555
+ const firstContentPrefix = hashlineGutter ? "" : renderLineContentIndicatorPrefix(kind, theme, undefined, indicatorMode);
1556
+ const continuationContentPrefix = hashlineGutter ? "" : renderLineContentIndicatorPrefix(kind, theme, undefined, indicatorMode, true);
1557
+ const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
1558
+ return renderWrappedRowsWithOptionalBackground(
1559
+ wrappedCodeLines,
1560
+ (index, line) => {
1561
+ const linePrefix = index === 0 ? prefix : continuationPrefix;
1562
+ const contentPrefix = index === 0 ? firstContentPrefix : continuationContentPrefix;
1563
+ return `${linePrefix}${divider}${contentPrefix}${line}`;
1564
+ },
1565
+ width,
1566
+ rowBg,
1567
+ restoreBgAnsi,
1568
+ );
1569
+ }
1570
+
1571
+ function highlightDiffLine(
1572
+ codeText: string,
1573
+ entry: DiffLineEntry,
1574
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1575
+ palette: DiffPalette,
1576
+ highlightLine: CodeLineHighlighter,
1577
+ containerBgAnsi: string | undefined,
1578
+ ): { highlighted: string; rowBg: string | undefined } {
1579
+ const syntaxHighlighted = highlightLine(codeText);
1580
+ const rowBg = getLineBackground(entry.lineKind, palette, false);
1581
+ const emphasisBg = getLineBackground(entry.lineKind, palette, true);
1582
+ const inlineSpans = inlineHighlights.get(entry) ?? [];
1583
+ const highlighted = applyInlineSpanHighlight(codeText, syntaxHighlighted, inlineSpans, emphasisBg, rowBg, containerBgAnsi);
1584
+ return { highlighted, rowBg };
1585
+ }
1586
+
1587
+ function pushDiffLineRows(rows: RenderedRow[], lines: string[], entry: DiffLineEntry): void {
1588
+ rows.push(
1589
+ ...lines.map((text) => ({
1590
+ text,
1591
+ hunkIndex: entry.hunkIndex || null,
1592
+ })),
1593
+ );
1594
+ }
1595
+
1596
+ interface DiffRenderContext {
1597
+ width: number;
1598
+ theme: DiffTheme;
1599
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
1600
+ palette: DiffPalette;
1601
+ highlightLine: CodeLineHighlighter;
1602
+ containerBgAnsi: string | undefined;
1603
+ wordWrap: boolean;
1604
+ indicatorMode: DiffIndicatorMode;
1605
+ showHashlineAnchors: boolean;
1606
+ }
1607
+
1608
+ function processDiffEntries(
1609
+ entries: ParsedDiffEntry[],
1610
+ ctx: DiffRenderContext,
1611
+ processLine: (entry: DiffLineEntry) => string[],
1612
+ ): RenderedRow[] {
1613
+ const { width, theme, wordWrap } = ctx;
1614
+ const rows: RenderedRow[] = [];
1615
+ for (const entry of entries) {
1616
+ if (entry.kind !== "line") {
1617
+ rows.push(...formatMetaEntryRows(entry, width, theme, wordWrap));
1618
+ continue;
1619
+ }
1620
+ pushDiffLineRows(rows, processLine(entry), entry);
1621
+ }
1622
+ return rows;
1623
+ }
1624
+
1625
+ function renderUnified(
1626
+ entries: ParsedDiffEntry[],
1627
+ ctx: DiffRenderContext,
1628
+ lineNumberWidth: number,
1629
+ ): RenderedRow[] {
1630
+ return processDiffEntries(entries, ctx, (entry) => {
1631
+ const lineNumber = entry.lineKind === "add"
1632
+ ? formatLineNumberLabel(entry, entry.newLineNumber, entry.fallbackLineNumber, lineNumberWidth, ctx.showHashlineAnchors)
1633
+ : formatLineNumberLabel(entry, entry.oldLineNumber, entry.fallbackLineNumber, lineNumberWidth, ctx.showHashlineAnchors);
1634
+ const codeText = normalizeCodeWhitespace(entry.content);
1635
+ const { highlighted, rowBg } = highlightDiffLine(codeText, entry, ctx.inlineHighlights, ctx.palette, ctx.highlightLine, ctx.containerBgAnsi);
1636
+ return renderLineCell(
1637
+ buildLineCellParams(entry.lineKind, highlighted, ctx.width, rowBg, ctx.containerBgAnsi, ctx.theme, ctx.wordWrap, ctx.indicatorMode),
1638
+ lineNumber,
1639
+ usesHashlineGutter(ctx.showHashlineAnchors),
1640
+ );
1641
+ });
1642
+ }
1643
+
1644
+ function toUnifiedFallbackRows(
1645
+ rows: SplitDiffRow[],
1646
+ ctx: DiffRenderContext,
1647
+ lineNumberWidth: number,
1648
+ ): RenderedRow[] {
1649
+ const flattened: ParsedDiffEntry[] = [];
1650
+ for (const row of rows) {
1651
+ if (row.meta) {
1652
+ flattened.push(row.meta);
1653
+ continue;
1654
+ }
1655
+ if (row.left) {
1656
+ flattened.push(row.left);
1657
+ }
1658
+ if (row.right && row.right !== row.left) {
1659
+ flattened.push(row.right);
1660
+ }
1661
+ }
1662
+ return renderUnified(flattened, ctx, lineNumberWidth);
1663
+ }
1664
+
1665
+ function renderCompact(
1666
+ entries: ParsedDiffEntry[],
1667
+ ctx: DiffRenderContext,
1668
+ ): RenderedRow[] {
1669
+ return processDiffEntries(entries, ctx, (entry) => {
1670
+ const codeText = normalizeCodeWhitespace(getCompactLineRenderContent(entry, ctx.showHashlineAnchors));
1671
+ const { highlighted, rowBg } = highlightDiffLine(codeText, entry, ctx.inlineHighlights, ctx.palette, ctx.highlightLine, ctx.containerBgAnsi);
1672
+ return renderCompactLineCell(buildLineCellParams(entry.lineKind, highlighted, ctx.width, rowBg, ctx.containerBgAnsi, ctx.theme, ctx.wordWrap, ctx.indicatorMode));
1673
+ });
1674
+ }
1675
+
1676
+ function renderSplitBlankCell(
1677
+ columnWidth: number,
1678
+ lineNumberWidth: number,
1679
+ theme: DiffTheme,
1680
+ indicatorMode: DiffIndicatorMode,
1681
+ hashlineGutter = false,
1682
+ ): string {
1683
+ const codeWidth = computeLineCellCodeWidth(columnWidth, lineNumberWidth, indicatorMode, hashlineGutter);
1684
+ const prefix = renderLinePrefix("context", " ".repeat(lineNumberWidth), theme, undefined, indicatorMode, true, hashlineGutter);
1685
+ const divider = renderCodeDivider(theme, undefined, indicatorMode, hashlineGutter);
1686
+ const contentPrefix = hashlineGutter ? "" : renderLineContentIndicatorPrefix("context", theme, undefined, indicatorMode, true);
1687
+ return stabilizeBackgroundResets(`${prefix}${divider}${contentPrefix}${" ".repeat(codeWidth)}`);
1688
+ }
1689
+
1690
+ function renderSplitCell(
1691
+ line: DiffLineEntry | undefined,
1692
+ side: "left" | "right",
1693
+ columnWidth: number,
1694
+ lineNumberWidth: number,
1695
+ theme: DiffTheme,
1696
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1697
+ palette: DiffPalette,
1698
+ highlightLine: CodeLineHighlighter,
1699
+ containerBgAnsi: string | undefined,
1700
+ wordWrap: boolean,
1701
+ indicatorMode: DiffIndicatorMode,
1702
+ showHashlineAnchors: boolean,
1703
+ ): string[] {
1704
+ const hashlineGutter = usesHashlineGutter(showHashlineAnchors);
1705
+ if (!line) {
1706
+ return [renderSplitBlankCell(columnWidth, lineNumberWidth, theme, indicatorMode, hashlineGutter)];
1707
+ }
1708
+
1709
+ const lineNumber = formatLineNumberLabel(line, getCellLineNumber(line, side), line.fallbackLineNumber, lineNumberWidth, showHashlineAnchors);
1710
+ const codeText = normalizeCodeWhitespace(line.content);
1711
+ const { highlighted, rowBg } = highlightDiffLine(codeText, line, inlineHighlights, palette, highlightLine, containerBgAnsi);
1712
+ return renderLineCell(
1713
+ buildLineCellParams(line.lineKind, highlighted, columnWidth, rowBg, containerBgAnsi, theme, wordWrap, indicatorMode),
1714
+ lineNumber,
1715
+ hashlineGutter,
1716
+ );
1717
+ }
1718
+
1719
+ function renderSplitDivider(
1720
+ theme: DiffTheme,
1721
+ containerBgAnsi: string | undefined,
1722
+ separatorText: string = SPLIT_SEPARATOR,
1723
+ ): string {
1724
+ const dimAnsi = readThemeAnsi(theme, "fg", "dim");
1725
+ if (!containerBgAnsi) {
1726
+ return stabilizeBackgroundResets(theme.fg("dim", separatorText));
1727
+ }
1728
+ if (!dimAnsi) {
1729
+ return stabilizeBackgroundResets(`${containerBgAnsi}${theme.fg("dim", separatorText)}${containerBgAnsi}`);
1730
+ }
1731
+ return stabilizeBackgroundResets(`${containerBgAnsi}${dimAnsi}${separatorText}\x1b[39m${containerBgAnsi}`);
1732
+ }
1733
+
1734
+ function renderSplitTopBorderCell(
1735
+ columnWidth: number,
1736
+ lineNumberWidth: number,
1737
+ theme: DiffTheme,
1738
+ indicatorMode: DiffIndicatorMode,
1739
+ hashlineGutter = false,
1740
+ ): string {
1741
+ const safeColumnWidth = Math.max(1, columnWidth);
1742
+ const chars = "─".repeat(safeColumnWidth).split("");
1743
+ const dividerIndex = getLinePrefixPlainWidth(lineNumberWidth, indicatorMode, hashlineGutter);
1744
+ if (dividerIndex >= 0 && dividerIndex < chars.length) {
1745
+ chars[dividerIndex] = "┬";
1746
+ }
1747
+ return stabilizeBackgroundResets(theme.fg("dim", chars.join("")));
1748
+ }
1749
+
1750
+ function renderSplitHeaderCell(
1751
+ label: string,
1752
+ columnWidth: number,
1753
+ lineNumberWidth: number,
1754
+ theme: DiffTheme,
1755
+ indicatorMode: DiffIndicatorMode,
1756
+ hashlineGutter = false,
1757
+ ): string {
1758
+ const markerPad = hashlineGutter
1759
+ ? " ".repeat(getHashlineGutterMarkerWidth(indicatorMode))
1760
+ : indicatorMode === "bars" ? " " : "";
1761
+ const lineNumberLabel = fitToWidth(label, lineNumberWidth);
1762
+ const lineNumberSpacer = hashlineGutter ? "" : " ";
1763
+ const divider = hashlineGutter || indicatorMode !== "classic" ? "│ " : "│";
1764
+ const prefix = `${theme.fg("dim", markerPad)}${theme.fg("muted", lineNumberLabel)}${theme.fg("dim", lineNumberSpacer)}${theme.fg("dim", divider)}`;
1765
+ const prefixWidth = visibleWidth(`${markerPad}${lineNumberLabel}${lineNumberSpacer}${divider}`);
1766
+ const contentIndicatorWidth = hashlineGutter ? 0 : getLineContentIndicatorPrefixPlainWidth(indicatorMode);
1767
+ const codeWidth = Math.max(0, columnWidth - prefixWidth - contentIndicatorWidth);
1768
+ const contentPad = !hashlineGutter && indicatorMode === "classic" ? " " : "";
1769
+ return stabilizeBackgroundResets(`${prefix}${contentPad}${" ".repeat(codeWidth)}`);
1770
+ }
1771
+
1772
+ function canRenderSplitLayout(width: number): boolean {
1773
+ const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
1774
+ const minimumSplitWidth = MIN_SPLIT_COLUMN_WIDTH * 2 + separatorWidth;
1775
+ return width >= minimumSplitWidth;
1776
+ }
1777
+
1778
+ function renderSplit(
1779
+ rows: SplitDiffRow[],
1780
+ ctx: DiffRenderContext,
1781
+ lineNumberWidth: number,
1782
+ ): RenderedRow[] {
1783
+ const { width, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors } = ctx;
1784
+ if (!canRenderSplitLayout(width)) {
1785
+ return toUnifiedFallbackRows(rows, ctx, lineNumberWidth);
1786
+ }
1787
+
1788
+ const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
1789
+ const leftWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, Math.floor((width - separatorWidth) / 2));
1790
+ const rightWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, width - separatorWidth - leftWidth);
1791
+ const splitLineNumberWidth = Math.max(3, lineNumberWidth);
1792
+ const hashlineGutter = usesHashlineGutter(showHashlineAnchors);
1793
+ const separator = renderSplitDivider(theme, containerBgAnsi);
1794
+ const topSeparator = renderSplitDivider(theme, containerBgAnsi, "─┬─");
1795
+ const output: RenderedRow[] = [];
1796
+ output.push({
1797
+ text: `${renderSplitTopBorderCell(leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}${topSeparator}${renderSplitTopBorderCell(rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}`,
1798
+ hunkIndex: null,
1799
+ });
1800
+ output.push({
1801
+ text: `${renderSplitHeaderCell("old", leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}${separator}${renderSplitHeaderCell("new", rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}`,
1802
+ hunkIndex: null,
1803
+ });
1804
+
1805
+ for (const row of rows) {
1806
+ if (row.meta) {
1807
+ output.push(...formatMetaEntryRows(row.meta, width, theme, wordWrap));
1808
+ continue;
1809
+ }
1810
+
1811
+ const leftCells = renderSplitCell(
1812
+ row.left,
1813
+ "left",
1814
+ leftWidth,
1815
+ splitLineNumberWidth,
1816
+ theme,
1817
+ inlineHighlights,
1818
+ palette,
1819
+ highlightLine,
1820
+ containerBgAnsi,
1821
+ wordWrap,
1822
+ indicatorMode,
1823
+ showHashlineAnchors,
1824
+ );
1825
+ const rightCells = renderSplitCell(
1826
+ row.right,
1827
+ "right",
1828
+ rightWidth,
1829
+ splitLineNumberWidth,
1830
+ theme,
1831
+ inlineHighlights,
1832
+ palette,
1833
+ highlightLine,
1834
+ containerBgAnsi,
1835
+ wordWrap,
1836
+ indicatorMode,
1837
+ showHashlineAnchors,
1838
+ );
1839
+
1840
+ const rowCount = Math.max(leftCells.length, rightCells.length);
1841
+ for (let index = 0; index < rowCount; index++) {
1842
+ const leftCell = leftCells[index] ?? renderSplitBlankCell(leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter);
1843
+ const rightCell = rightCells[index] ?? renderSplitBlankCell(rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter);
1844
+ output.push({ text: `${leftCell}${separator}${rightCell}`, hunkIndex: row.hunkIndex });
1845
+ }
1846
+ }
1847
+
1848
+ return output;
1849
+ }
1850
+
1851
+ function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): string | null {
1852
+ const totalChanges = stats.added + stats.removed;
1853
+ if (totalChanges === 0 || width < 20) {
1854
+ return null;
1855
+ }
1856
+
1857
+ const barSlots = Math.max(8, Math.min(24, Math.floor(width / 12)));
1858
+ let addedSlots = Math.max(0, Math.min(barSlots, Math.round((stats.added / totalChanges) * barSlots)));
1859
+ if (stats.added > 0 && addedSlots === 0) {
1860
+ addedSlots = 1;
1861
+ }
1862
+ if (stats.removed > 0 && addedSlots >= barSlots) {
1863
+ addedSlots = barSlots - 1;
1864
+ }
1865
+ const removedSlots = Math.max(0, barSlots - addedSlots);
1866
+
1867
+ const addedBar = addedSlots > 0 ? theme.fg("toolDiffAdded", "━".repeat(addedSlots)) : "";
1868
+ const removedBar = removedSlots > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots)) : "";
1869
+ return stabilizeBackgroundResets(`${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`);
1870
+ }
1871
+
1872
+ function buildDiffSummaryBasePieces(stats: DiffStats, theme: DiffTheme): string[] {
1873
+ return [
1874
+ theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
1875
+ theme.fg("toolDiffAdded", `+${stats.added}`),
1876
+ theme.fg("toolDiffRemoved", `-${stats.removed}`),
1877
+ ];
1878
+ }
1879
+
1880
+ function renderHeaderRows(stats: DiffStats, mode: Exclude<DiffPresentationMode, "summary">, width: number, theme: DiffTheme): RenderedRow[] {
1881
+ if (mode === "compact") {
1882
+ const summary = buildDiffSummaryBasePieces(stats, theme).join(" ");
1883
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
1884
+ }
1885
+
1886
+ const summaryPieces = mode === "split"
1887
+ ? [
1888
+ ...buildDiffSummaryBasePieces(stats, theme),
1889
+ theme.fg("muted", mode),
1890
+ ]
1891
+ : [
1892
+ ...buildDiffSummaryBasePieces(stats, theme),
1893
+ theme.fg("muted", `${stats.hunks} ${pluralize(stats.hunks, "hunk")}`),
1894
+ theme.fg("muted", `${stats.files} ${pluralize(stats.files, "file")}`),
1895
+ theme.fg("muted", mode),
1896
+ ];
1897
+
1898
+ const summary = summaryPieces.join(mode === "split" ? " " : theme.fg("muted", " • "));
1899
+ const meter = renderDiffStatBar(stats, width, theme);
1900
+ if (!meter) {
1901
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
1902
+ }
1903
+
1904
+ const meterSeparator = " ";
1905
+ const meterWidth = visibleWidth(meterSeparator) + visibleWidth(meter);
1906
+ if (meterWidth >= width) {
1907
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
1908
+ }
1909
+
1910
+ const summaryWidth = Math.max(0, width - meterWidth);
1911
+ const fittedSummary = truncateToWidth(summary, summaryWidth);
1912
+ return [{ text: stabilizeBackgroundResets(`${fittedSummary}${meterSeparator}${meter}`), hunkIndex: null }];
1913
+ }
1914
+
1915
+ function renderDiffFrameLine(width: number, theme: DiffTheme): string {
1916
+ const frameWidth = Math.max(0, width);
1917
+ if (frameWidth === 0) {
1918
+ return "";
1919
+ }
1920
+ return stabilizeBackgroundResets(theme.fg("dim", "─".repeat(frameWidth)));
1921
+ }
1922
+
1923
+ function renderDiffSpacerLine(width: number): string {
1924
+ const safeWidth = Math.max(0, width);
1925
+ return safeWidth > 0 ? " ".repeat(safeWidth) : "";
1926
+ }
1927
+
1928
+ function applyLineLimit(
1929
+ rows: RenderedRow[],
1930
+ width: number,
1931
+ expanded: boolean,
1932
+ maxCollapsedLines: number,
1933
+ maxExpandedLines: number,
1934
+ totalHunks: number,
1935
+ theme: DiffTheme,
1936
+ ): string[] {
1937
+ const expandedLimit = Number.isFinite(maxExpandedLines)
1938
+ ? maxExpandedLines
1939
+ : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
1940
+ const collapsedLimit = Number.isFinite(maxCollapsedLines)
1941
+ ? maxCollapsedLines
1942
+ : DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines;
1943
+ const limit = expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
1944
+ if (limit === 0 || rows.length <= limit) {
1945
+ return rows.map((row) => clampDiffLineToWidth(row.text, width));
1946
+ }
1947
+
1948
+ const shown = rows.slice(0, limit);
1949
+ const remaining = rows.length - shown.length;
1950
+ const visibleHunks = new Set(
1951
+ shown
1952
+ .map((row) => row.hunkIndex)
1953
+ .filter((hunkIndex): hunkIndex is number => typeof hunkIndex === "number" && hunkIndex > 0),
1954
+ );
1955
+ const hiddenHunks = Math.max(0, totalHunks - visibleHunks.size);
1956
+ const hintText = buildCollapsedDiffHintText(
1957
+ {
1958
+ remainingLines: remaining,
1959
+ hiddenHunks,
1960
+ },
1961
+ width,
1962
+ DIFF_WIDTH_OPS,
1963
+ );
1964
+
1965
+ return [
1966
+ ...shown.map((row) => clampDiffLineToWidth(row.text, width)),
1967
+ renderDiffSpacerLine(width),
1968
+ clampDiffLineToWidth(theme.fg(expanded ? "warning" : "muted", hintText), width),
1969
+ ];
1970
+ }
1971
+
1972
+ function collectDiffStats(entries: ParsedDiffEntry[], fallbackHunks = 0, fallbackFiles = 0): DiffStats {
1973
+ const stats: DiffStats = {
1974
+ added: 0,
1975
+ removed: 0,
1976
+ context: 0,
1977
+ hunks: fallbackHunks,
1978
+ files: fallbackFiles,
1979
+ lines: entries.length,
1980
+ };
1981
+
1982
+ const hunkIndexes = new Set<number>();
1983
+ let explicitFileCount = 0;
1984
+
1985
+ for (const entry of entries) {
1986
+ if (entry.kind === "line") {
1987
+ if (entry.lineKind === "add") {
1988
+ stats.added++;
1989
+ } else if (entry.lineKind === "remove") {
1990
+ stats.removed++;
1991
+ } else {
1992
+ stats.context++;
1993
+ }
1994
+ if (entry.hunkIndex > 0) {
1995
+ hunkIndexes.add(entry.hunkIndex);
1996
+ }
1997
+ continue;
1998
+ }
1999
+
2000
+ if (entry.kind === "hunk" && entry.hunkIndex > 0) {
2001
+ hunkIndexes.add(entry.hunkIndex);
2002
+ }
2003
+ if (entry.kind === "file") {
2004
+ explicitFileCount++;
2005
+ }
2006
+ }
2007
+
2008
+ if (hunkIndexes.size > 0) {
2009
+ stats.hunks = Math.max(stats.hunks, hunkIndexes.size);
2010
+ }
2011
+ if (explicitFileCount > 0) {
2012
+ stats.files = Math.max(stats.files, explicitFileCount);
2013
+ } else if (entries.length > 0) {
2014
+ stats.files = Math.max(stats.files, 1);
2015
+ }
2016
+ if (stats.hunks === 0 && entries.some((entry) => entry.kind === "line")) {
2017
+ stats.hunks = 1;
2018
+ }
2019
+
2020
+ return stats;
2021
+ }
2022
+
2023
+ function renderSingleDiffRow(text: string, color: string, width: number, theme: DiffTheme): string[] {
2024
+ if (width <= 0) {
2025
+ return [""];
2026
+ }
2027
+ return [clampDiffLineToWidth(stabilizeBackgroundResets(theme.fg(color, text)), width)];
2028
+ }
2029
+
2030
+ function safeGetDiff(details: unknown): string {
2031
+ if (!details || typeof details !== "object") {
2032
+ return "";
2033
+ }
2034
+ const typed = details as Partial<EditToolDetails>;
2035
+ return typeof typed.diff === "string" ? typed.diff : "";
2036
+ }
2037
+
2038
+ function createDiffRenderCache() {
2039
+ let cachedWidth: number | undefined;
2040
+ let cachedExpanded: boolean | undefined;
2041
+ let cachedMode: DiffPresentationMode | undefined;
2042
+ let cachedLines: string[] | undefined;
2043
+
2044
+ return {
2045
+ get(width: number, expanded: boolean, mode: DiffPresentationMode): string[] | undefined {
2046
+ if (cachedLines && cachedWidth === width && cachedExpanded === expanded && cachedMode === mode) {
2047
+ return cachedLines;
2048
+ }
2049
+ return undefined;
2050
+ },
2051
+ set(width: number, expanded: boolean, mode: DiffPresentationMode, lines: string[]): string[] {
2052
+ cachedWidth = width;
2053
+ cachedExpanded = expanded;
2054
+ cachedMode = mode;
2055
+ cachedLines = lines;
2056
+ return lines;
2057
+ },
2058
+ invalidate(): void {
2059
+ cachedWidth = undefined;
2060
+ cachedExpanded = undefined;
2061
+ cachedMode = undefined;
2062
+ cachedLines = undefined;
2063
+ },
2064
+ };
2065
+ }
2066
+
2067
+ export function renderEditDiffResult(
2068
+ details: unknown,
2069
+ options: DiffRenderOptions,
2070
+ config: ToolDisplayConfig,
2071
+ theme: DiffTheme,
2072
+ fallbackText: string,
2073
+ ): Component {
2074
+ const diffText = safeGetDiff(details);
2075
+ if (!diffText.trim()) {
2076
+ if (!fallbackText.trim()) {
2077
+ return new Text(theme.fg("muted", "↳ edit completed (no diff payload)"), 0, 0);
2078
+ }
2079
+ return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
2080
+ }
2081
+
2082
+ let parsed: ParsedDiff;
2083
+ try {
2084
+ parsed = parseDiff(diffText);
2085
+ } catch (error) {
2086
+ const message = error instanceof Error ? error.message : String(error);
2087
+ return new Text(theme.fg("warning", `↳ unable to render diff: ${message}`), 0, 0);
2088
+ }
2089
+
2090
+ if (parsed.entries.length === 0) {
2091
+ return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
2092
+ }
2093
+
2094
+ const splitRows = buildSplitRows(parsed.entries);
2095
+ const showHashlineAnchors = options.expanded === true
2096
+ && parsed.entries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
2097
+ const lineNumberWidth = getLineNumberWidth(parsed.entries, showHashlineAnchors);
2098
+ const palette = resolveDiffPalette(theme);
2099
+ const containerBgAnsi = resolveContainerBackgroundAnsi(theme);
2100
+ const language = resolveLanguageFromPath(options.filePath);
2101
+ const highlightLine = createCodeLineHighlighter(language);
2102
+ const wordWrap = config.diffWordWrap;
2103
+ const indicatorMode = resolveDiffIndicatorMode(config);
2104
+
2105
+ const cache = createDiffRenderCache();
2106
+
2107
+ return {
2108
+ render(width: number): string[] {
2109
+ const safeWidth = normalizeDiffRenderWidth(width);
2110
+ const mode = resolveDiffPresentationMode(config, safeWidth, canRenderSplitLayout(safeWidth));
2111
+ const cached = cache.get(safeWidth, options.expanded, mode);
2112
+ if (cached) {
2113
+ return cached;
2114
+ }
2115
+
2116
+ if (mode === "summary") {
2117
+ return cache.set(safeWidth, options.expanded, mode, clampDiffLinesToWidth(renderSingleDiffRow(buildDiffSummaryText(parsed.stats, safeWidth), "toolOutput", safeWidth, theme), safeWidth));
2118
+ }
2119
+
2120
+ const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
2121
+ const inlineHighlights = buildInlineHighlightMap(splitRows);
2122
+ const bodyRows = mode === "split"
2123
+ ? renderSplit(
2124
+ splitRows,
2125
+ { width: safeWidth, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors },
2126
+ lineNumberWidth,
2127
+ )
2128
+ : mode === "compact"
2129
+ ? renderCompact(
2130
+ parsed.entries,
2131
+ { width: safeWidth, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors },
2132
+ )
2133
+ : renderUnified(
2134
+ parsed.entries,
2135
+ { width: safeWidth, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors },
2136
+ lineNumberWidth,
2137
+ );
2138
+ const bodyWithLimit = applyLineLimit(
2139
+ bodyRows,
2140
+ safeWidth,
2141
+ options.expanded,
2142
+ config.diffCollapsedLines,
2143
+ config.expandedPreviewMaxLines,
2144
+ parsed.stats.hunks,
2145
+ theme,
2146
+ );
2147
+ const frame = renderDiffFrameLine(safeWidth, theme);
2148
+ const renderedLines = mode === "unified"
2149
+ ? [...headerRows.map((row) => row.text), frame, ...bodyWithLimit, frame]
2150
+ : [...headerRows.map((row) => row.text), ...bodyWithLimit];
2151
+
2152
+ const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
2153
+ return cache.set(safeWidth, options.expanded, mode, clampedLines);
2154
+ },
2155
+ invalidate: cache.invalidate,
2156
+ };
2157
+ }
2158
+
2159
+ function renderWriteHeader(
2160
+ wasOverwrite: boolean,
2161
+ width: number,
2162
+ theme: DiffTheme,
2163
+ headerLabel?: string,
2164
+ ): string {
2165
+ const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
2166
+ return stabilizeBackgroundResets(
2167
+ truncateToWidth(theme.fg("toolOutput", `↳ ${emphasis(theme, actionLabel)}`), width),
2168
+ );
2169
+ }
2170
+
2171
+ type WriteDiffOperationKind = "context" | "remove" | "add";
2172
+
2173
+ interface WriteDiffOperation {
2174
+ kind: WriteDiffOperationKind;
2175
+ content: string;
2176
+ }
2177
+
2178
+ function buildWriteDiffOperations(oldLines: string[], newLines: string[]): WriteDiffOperation[] {
2179
+ const oldLength = oldLines.length;
2180
+ const newLength = newLines.length;
2181
+ const table: number[][] = Array.from({ length: oldLength + 1 }, () => Array<number>(newLength + 1).fill(0));
2182
+
2183
+ for (let oldIndex = 1; oldIndex <= oldLength; oldIndex++) {
2184
+ for (let newIndex = 1; newIndex <= newLength; newIndex++) {
2185
+ if ((oldLines[oldIndex - 1] ?? "") === (newLines[newIndex - 1] ?? "")) {
2186
+ table[oldIndex]![newIndex] = (table[oldIndex - 1]?.[newIndex - 1] ?? 0) + 1;
2187
+ continue;
2188
+ }
2189
+ const top = table[oldIndex - 1]?.[newIndex] ?? 0;
2190
+ const left = table[oldIndex]?.[newIndex - 1] ?? 0;
2191
+ table[oldIndex]![newIndex] = Math.max(top, left);
2192
+ }
2193
+ }
2194
+
2195
+ const operations: WriteDiffOperation[] = [];
2196
+ let oldCursor = oldLength;
2197
+ let newCursor = newLength;
2198
+
2199
+ while (oldCursor > 0 || newCursor > 0) {
2200
+ const oldLine = oldCursor > 0 ? (oldLines[oldCursor - 1] ?? "") : undefined;
2201
+ const newLine = newCursor > 0 ? (newLines[newCursor - 1] ?? "") : undefined;
2202
+
2203
+ if (oldCursor > 0 && newCursor > 0 && oldLine === newLine) {
2204
+ operations.push({ kind: "context", content: oldLine ?? "" });
2205
+ oldCursor--;
2206
+ newCursor--;
2207
+ continue;
2208
+ }
2209
+
2210
+ const top = oldCursor > 0 ? (table[oldCursor - 1]?.[newCursor] ?? 0) : -1;
2211
+ const left = newCursor > 0 ? (table[oldCursor]?.[newCursor - 1] ?? 0) : -1;
2212
+
2213
+ if (newCursor > 0 && left >= top) {
2214
+ operations.push({ kind: "add", content: newLine ?? "" });
2215
+ newCursor--;
2216
+ continue;
2217
+ }
2218
+
2219
+ if (oldCursor > 0) {
2220
+ operations.push({ kind: "remove", content: oldLine ?? "" });
2221
+ oldCursor--;
2222
+ }
2223
+ }
2224
+
2225
+ operations.reverse();
2226
+ return operations;
2227
+ }
2228
+
2229
+ function buildWriteEntries(lines: string[]): ParsedDiffEntry[] {
2230
+ return lines.map((line, index) => ({
2231
+ kind: "line",
2232
+ lineKind: "add",
2233
+ oldLineNumber: null,
2234
+ newLineNumber: index + 1,
2235
+ fallbackLineNumber: `${index + 1}`,
2236
+ content: line,
2237
+ raw: `+${line}`,
2238
+ hunkIndex: 1,
2239
+ }));
2240
+ }
2241
+
2242
+ function buildWriteOverwriteEntries(oldLines: string[], newLines: string[]): ParsedDiffEntry[] {
2243
+ const operations = buildWriteDiffOperations(oldLines, newLines);
2244
+ const entries: ParsedDiffEntry[] = [];
2245
+ let oldLineNumber = 1;
2246
+ let newLineNumber = 1;
2247
+
2248
+ for (const operation of operations) {
2249
+ if (operation.kind === "context") {
2250
+ entries.push({
2251
+ kind: "line",
2252
+ lineKind: "context",
2253
+ oldLineNumber,
2254
+ newLineNumber,
2255
+ fallbackLineNumber: `${newLineNumber}`,
2256
+ content: operation.content,
2257
+ raw: ` ${operation.content}`,
2258
+ hunkIndex: 1,
2259
+ });
2260
+ oldLineNumber++;
2261
+ newLineNumber++;
2262
+ continue;
2263
+ }
2264
+
2265
+ if (operation.kind === "remove") {
2266
+ entries.push({
2267
+ kind: "line",
2268
+ lineKind: "remove",
2269
+ oldLineNumber,
2270
+ newLineNumber: null,
2271
+ fallbackLineNumber: `${oldLineNumber}`,
2272
+ content: operation.content,
2273
+ raw: `-${operation.content}`,
2274
+ hunkIndex: 1,
2275
+ });
2276
+ oldLineNumber++;
2277
+ continue;
2278
+ }
2279
+
2280
+ entries.push({
2281
+ kind: "line",
2282
+ lineKind: "add",
2283
+ oldLineNumber: null,
2284
+ newLineNumber,
2285
+ fallbackLineNumber: `${newLineNumber}`,
2286
+ content: operation.content,
2287
+ raw: `+${operation.content}`,
2288
+ hunkIndex: 1,
2289
+ });
2290
+ newLineNumber++;
2291
+ }
2292
+
2293
+ return entries;
2294
+ }
2295
+
2296
+ interface WriteDiffData {
2297
+ entries: ParsedDiffEntry[];
2298
+ splitRows: SplitDiffRow[];
2299
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
2300
+ lineNumberWidth: number;
2301
+ stats: DiffStats;
2302
+ hunkCount: number;
2303
+ }
2304
+
2305
+ interface WriteOverwriteGuard {
2306
+ previousLineCount: number;
2307
+ nextLineCount: number;
2308
+ }
2309
+
2310
+ const MAX_WRITE_OVERWRITE_DIFF_LINES = 4000;
2311
+ const MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 1_000_000;
2312
+
2313
+ function buildApproximateWriteStats(
2314
+ lineCount: number,
2315
+ previousLineCount: number,
2316
+ hasComparablePrevious: boolean,
2317
+ ): DiffStats {
2318
+ const removed = hasComparablePrevious ? previousLineCount : 0;
2319
+ const added = lineCount;
2320
+ const hasContent = lineCount > 0 || removed > 0;
2321
+ return {
2322
+ added,
2323
+ removed,
2324
+ context: 0,
2325
+ hunks: hasContent ? 1 : 0,
2326
+ files: 1,
2327
+ lines: added + removed,
2328
+ };
2329
+ }
2330
+
2331
+ function buildWriteDiffData(entries: ParsedDiffEntry[]): WriteDiffData {
2332
+ const splitRows = buildSplitRows(entries);
2333
+ const inlineHighlights = buildInlineHighlightMap(splitRows);
2334
+ const lineNumberWidth = getLineNumberWidth(entries);
2335
+ const hunkCount = entries.length > 0 ? 1 : 0;
2336
+ const stats = collectDiffStats(entries, hunkCount, 1);
2337
+ return {
2338
+ entries,
2339
+ splitRows,
2340
+ inlineHighlights,
2341
+ lineNumberWidth,
2342
+ stats,
2343
+ hunkCount,
2344
+ };
2345
+ }
2346
+
2347
+ function resolveWriteOverwriteGuard(
2348
+ previousLines: string[],
2349
+ nextLines: string[],
2350
+ ): WriteOverwriteGuard | undefined {
2351
+ const previousLineCount = previousLines.length;
2352
+ const nextLineCount = nextLines.length;
2353
+ if (previousLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES || nextLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES) {
2354
+ return { previousLineCount, nextLineCount };
2355
+ }
2356
+ if (previousLineCount === 0 || nextLineCount === 0) {
2357
+ return undefined;
2358
+ }
2359
+ return previousLineCount * nextLineCount > MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS
2360
+ ? { previousLineCount, nextLineCount }
2361
+ : undefined;
2362
+ }
2363
+
2364
+ function buildWriteOverwriteGuardText(guard: WriteOverwriteGuard, width: number): string {
2365
+ const safeWidth = normalizeDiffRenderWidth(width);
2366
+ if (safeWidth === 0) {
2367
+ return "";
2368
+ }
2369
+
2370
+ const candidates = [
2371
+ `↳ overwrite diff omitted (${guard.previousLineCount} → ${guard.nextLineCount} lines)`,
2372
+ `↳ overwrite diff omitted (${guard.previousLineCount}→${guard.nextLineCount})`,
2373
+ "↳ overwrite diff omitted",
2374
+ "diff omitted",
2375
+ "…",
2376
+ ];
2377
+ for (const candidate of candidates) {
2378
+ if (visibleWidth(candidate) <= safeWidth) {
2379
+ return candidate;
2380
+ }
2381
+ }
2382
+ return truncateToWidth(candidates[candidates.length - 1] ?? "", safeWidth, "");
2383
+ }
2384
+
2385
+ function renderWriteOverwriteGuardRows(
2386
+ guard: WriteOverwriteGuard,
2387
+ width: number,
2388
+ theme: DiffTheme,
2389
+ ): string[] {
2390
+ return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
2391
+ }
2392
+
2393
+ export function renderWriteDiffResult(
2394
+ content: string | undefined,
2395
+ options: DiffRenderOptions,
2396
+ config: ToolDisplayConfig,
2397
+ theme: DiffTheme,
2398
+ fallbackText: string,
2399
+ ): Component {
2400
+ if (typeof content !== "string") {
2401
+ if (!fallbackText.trim()) {
2402
+ return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
2403
+ }
2404
+ return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
2405
+ }
2406
+
2407
+ const filePath = options.filePath?.trim() || "(unknown path)";
2408
+ const lines = splitWriteContentLines(content);
2409
+ const previousLines = typeof options.previousContent === "string"
2410
+ ? splitWriteContentLines(options.previousContent)
2411
+ : [];
2412
+ const hasComparablePrevious = options.fileExistedBeforeWrite === true && typeof options.previousContent === "string";
2413
+ const approximateStats = buildApproximateWriteStats(
2414
+ lines.length,
2415
+ previousLines.length,
2416
+ hasComparablePrevious,
2417
+ );
2418
+ const overwriteGuard = hasComparablePrevious
2419
+ ? resolveWriteOverwriteGuard(previousLines, lines)
2420
+ : undefined;
2421
+ const palette = resolveDiffPalette(theme);
2422
+ const containerBgAnsi = resolveContainerBackgroundAnsi(theme);
2423
+ const language = resolveLanguageFromPath(filePath);
2424
+ const highlightLine = createCodeLineHighlighter(language);
2425
+ const wordWrap = config.diffWordWrap;
2426
+ const indicatorMode = resolveDiffIndicatorMode(config);
2427
+
2428
+ let detailedData: WriteDiffData | undefined;
2429
+ const cache = createDiffRenderCache();
2430
+
2431
+ function getDetailedData(): WriteDiffData {
2432
+ if (detailedData) {
2433
+ return detailedData;
2434
+ }
2435
+ const entries = hasComparablePrevious
2436
+ ? buildWriteOverwriteEntries(previousLines, lines)
2437
+ : buildWriteEntries(lines);
2438
+ detailedData = buildWriteDiffData(entries);
2439
+ return detailedData;
2440
+ }
2441
+
2442
+ return {
2443
+ render(width: number): string[] {
2444
+ const safeWidth = normalizeDiffRenderWidth(width);
2445
+ const resolvedMode = resolveDiffPresentationMode(config, safeWidth, canRenderSplitLayout(safeWidth));
2446
+ const mode: DiffPresentationMode = hasComparablePrevious
2447
+ ? resolvedMode
2448
+ : resolvedMode === "split"
2449
+ ? "unified"
2450
+ : resolvedMode;
2451
+ const cached = cache.get(safeWidth, options.expanded, mode);
2452
+ if (cached) {
2453
+ return cached;
2454
+ }
2455
+
2456
+ const header = renderWriteHeader(
2457
+ options.fileExistedBeforeWrite === true,
2458
+ safeWidth,
2459
+ theme,
2460
+ options.headerLabel,
2461
+ );
2462
+ if (overwriteGuard) {
2463
+ return cache.set(safeWidth, options.expanded, mode, clampDiffLinesToWidth(
2464
+ [header, ...renderWriteOverwriteGuardRows(overwriteGuard, safeWidth, theme)],
2465
+ safeWidth,
2466
+ ));
2467
+ }
2468
+
2469
+ if (mode === "summary") {
2470
+ const summaryRows = approximateStats.lines === 0
2471
+ ? [header]
2472
+ : [header, ...renderSingleDiffRow(buildDiffSummaryText(approximateStats, safeWidth), "toolOutput", safeWidth, theme)];
2473
+ return cache.set(safeWidth, options.expanded, mode, clampDiffLinesToWidth(summaryRows, safeWidth));
2474
+ }
2475
+
2476
+ const data = getDetailedData();
2477
+ const renderCtx: DiffRenderContext = {
2478
+ width: safeWidth, theme, inlineHighlights: data.inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors: false,
2479
+ };
2480
+ const bodyRows: RenderedRow[] = data.entries.length === 0
2481
+ ? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
2482
+ : mode === "split"
2483
+ ? renderSplit(
2484
+ data.splitRows,
2485
+ renderCtx,
2486
+ data.lineNumberWidth,
2487
+ )
2488
+ : mode === "compact"
2489
+ ? renderCompact(
2490
+ data.entries,
2491
+ renderCtx,
2492
+ )
2493
+ : renderUnified(
2494
+ data.entries,
2495
+ renderCtx,
2496
+ data.lineNumberWidth,
2497
+ );
2498
+
2499
+ const bodyWithLimit = applyLineLimit(
2500
+ bodyRows,
2501
+ safeWidth,
2502
+ options.expanded,
2503
+ config.diffCollapsedLines,
2504
+ config.expandedPreviewMaxLines,
2505
+ data.hunkCount,
2506
+ theme,
2507
+ );
2508
+ const frame = renderDiffFrameLine(safeWidth, theme);
2509
+ const renderedLines = mode === "unified"
2510
+ ? [header, frame, ...bodyWithLimit, frame]
2511
+ : [header, ...bodyWithLimit];
2512
+ const finalLines = clampDiffLinesToWidth(renderedLines, safeWidth);
2513
+ return cache.set(safeWidth, options.expanded, mode, finalLines);
2514
+ },
2515
+ invalidate: cache.invalidate,
2516
+ };
2517
+ }