@quandev104/pi-style 0.1.1 → 0.1.2
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.
- package/CHANGELOG.md +10 -0
- package/dist/extensions/pi-style.js +568 -261
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
- package/extension-src/pi-style/features/messages/index.ts +95 -32
- package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
- package/extension-src/pi-style/features/startup/index.ts +4 -4
- package/extension-src/pi-style/features/tools/boxed/bash.ts +11 -19
- package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
- package/extension-src/pi-style/pi/session-coordinator.ts +9 -1
- package/extension-src/pi-style/shared/ansi.ts +3 -0
- package/extension-src/pi-style/shared/box.ts +175 -68
- package/extension-src/pi-style/shared/split-diff.ts +395 -86
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Adaptive diff renderer — side-by-side (split) for short corresponding
|
|
2
|
+
// changes, unified for additions/removals-only diffs and narrow terminals,
|
|
3
|
+
// with long runs of unchanged context collapsed into a single
|
|
4
|
+
// "⋯ N unchanged lines hidden" row instead of arbitrary output truncation.
|
|
3
5
|
|
|
4
6
|
import { highlightCode } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import type { Component } from "@earendil-works/pi-tui";
|
|
@@ -27,13 +29,15 @@ type DiffSpan = { start: number; end: number };
|
|
|
27
29
|
|
|
28
30
|
type RgbColor = { r: number; g: number; b: number };
|
|
29
31
|
|
|
30
|
-
/** Structural view of the theme as used by the
|
|
32
|
+
/** Structural view of the theme as used by the diff renderers. */
|
|
31
33
|
export interface SplitDiffTheme {
|
|
32
34
|
fg(color: string, text: string): string;
|
|
33
35
|
getBgAnsi?(color: string): string;
|
|
34
36
|
getFgAnsi?(color: string): string;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export type DiffMode = "unified" | "split";
|
|
40
|
+
|
|
37
41
|
type DiffPalette = {
|
|
38
42
|
addRowBgAnsi: string;
|
|
39
43
|
removeRowBgAnsi: string;
|
|
@@ -41,6 +45,12 @@ type DiffPalette = {
|
|
|
41
45
|
removeEmphasisBgAnsi: string;
|
|
42
46
|
};
|
|
43
47
|
|
|
48
|
+
/** A planned render entry: a diff row, a collapsed context gap, or a budget omission. */
|
|
49
|
+
type DiffEntry =
|
|
50
|
+
| { kind: "row"; row: SplitDiffRow }
|
|
51
|
+
| { kind: "gap"; hidden: number }
|
|
52
|
+
| { kind: "omitted"; count: number };
|
|
53
|
+
|
|
44
54
|
// ── Constants ──────────────────────────────────────────────────────
|
|
45
55
|
|
|
46
56
|
const ESC = "\x1b";
|
|
@@ -52,6 +62,19 @@ const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
|
|
|
52
62
|
const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.44;
|
|
53
63
|
const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
|
|
54
64
|
|
|
65
|
+
/** Context lines kept on each side of a collapsed run of unchanged lines. */
|
|
66
|
+
const CONTEXT_KEEP_DEFAULT = 2;
|
|
67
|
+
/** Context runs of this length (or less) are shown in full. */
|
|
68
|
+
const CONTEXT_RUN_SHOW_MAX = 4;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Minimum diff content width before split mode is even considered. Box chrome
|
|
72
|
+
* (2 borders + 4 side padding) costs ~6 columns, so this corresponds to a
|
|
73
|
+
* ~120-column terminal — below that the two panes wrap too aggressively and
|
|
74
|
+
* unified is always used.
|
|
75
|
+
*/
|
|
76
|
+
const SPLIT_DIFF_MIN_WIDTH = 114;
|
|
77
|
+
|
|
55
78
|
// ── ANSI color utilities (diff-specific) ───────────────────────────
|
|
56
79
|
|
|
57
80
|
function ansi256ToRgb(code: number): RgbColor {
|
|
@@ -384,17 +407,6 @@ export function countDiffStats(diff: string): { additions: number; removals: num
|
|
|
384
407
|
return { additions, removals };
|
|
385
408
|
}
|
|
386
409
|
|
|
387
|
-
export function renderDiffMeter(theme: SplitDiffTheme, additions: number, removals: number, width = 20): string {
|
|
388
|
-
const total = additions + removals;
|
|
389
|
-
if (total <= 0) return "";
|
|
390
|
-
|
|
391
|
-
const addBlocks = Math.round((additions / total) * width);
|
|
392
|
-
const removeBlocks = Math.max(0, width - addBlocks);
|
|
393
|
-
const addBar = addBlocks > 0 ? theme.fg("toolDiffAdded", "━".repeat(addBlocks)) : "";
|
|
394
|
-
const removeBar = removeBlocks > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removeBlocks)) : "";
|
|
395
|
-
return `${theme.fg("dim", "[")}${addBar}${removeBar}${theme.fg("dim", "]")}`;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
410
|
export function extractEditedPath(message: string): string | undefined {
|
|
399
411
|
const m = message.match(/Successfully replaced (?:text|\d+ block\(s\)|lines L\d+-\d+) in (.+)\.$/);
|
|
400
412
|
return m?.[1];
|
|
@@ -409,21 +421,127 @@ export function firstText(content: Array<{ type: string; text?: string }>): stri
|
|
|
409
421
|
return "";
|
|
410
422
|
}
|
|
411
423
|
|
|
412
|
-
|
|
424
|
+
function longestChangedLineWidth(rows: SplitDiffRow[]): number {
|
|
425
|
+
let longest = 0;
|
|
426
|
+
for (const row of rows) {
|
|
427
|
+
const candidates: string[] = [];
|
|
428
|
+
if (row.kind === "changed") {
|
|
429
|
+
if (row.left) candidates.push(row.left.line);
|
|
430
|
+
if (row.right) candidates.push(row.right.line);
|
|
431
|
+
} else if (row.kind === "added" && row.right) {
|
|
432
|
+
candidates.push(row.right.line);
|
|
433
|
+
} else if (row.kind === "removed" && row.left) {
|
|
434
|
+
candidates.push(row.left.line);
|
|
435
|
+
}
|
|
436
|
+
for (const candidate of candidates) {
|
|
437
|
+
longest = Math.max(longest, safeVisibleWidth(candidate));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return longest;
|
|
441
|
+
}
|
|
413
442
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
443
|
+
/**
|
|
444
|
+
* Adaptive layout rule: split side-by-side only when the change has both
|
|
445
|
+
* additions and removals (so both panes carry content), the terminal is wide
|
|
446
|
+
* enough, and no changed line is so long that it would wrap badly in a half
|
|
447
|
+
* pane. Everything else renders as a unified diff.
|
|
448
|
+
*/
|
|
449
|
+
export function pickDiffMode(
|
|
450
|
+
stats: { additions: number; removals: number },
|
|
451
|
+
rows: SplitDiffRow[],
|
|
452
|
+
width: number,
|
|
453
|
+
): DiffMode {
|
|
454
|
+
if (stats.additions <= 0 || stats.removals <= 0) return "unified";
|
|
455
|
+
if (width < SPLIT_DIFF_MIN_WIDTH) return "unified";
|
|
456
|
+
if (longestChangedLineWidth(rows) > width / 2) return "unified";
|
|
457
|
+
return "split";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function collapseContextRows(rows: SplitDiffRow[], options: { keep: number; runShowMax?: number }): DiffEntry[] {
|
|
461
|
+
const keep = options.keep;
|
|
462
|
+
const runShowMax = options.runShowMax ?? CONTEXT_RUN_SHOW_MAX;
|
|
463
|
+
const out: DiffEntry[] = [];
|
|
464
|
+
let i = 0;
|
|
465
|
+
|
|
466
|
+
while (i < rows.length) {
|
|
467
|
+
const row = rows[i];
|
|
468
|
+
if (row?.kind !== "context") {
|
|
469
|
+
if (row) out.push({ kind: "row", row });
|
|
470
|
+
i++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let j = i;
|
|
475
|
+
while (j < rows.length && rows[j]?.kind === "context") j++;
|
|
476
|
+
const run = j - i;
|
|
477
|
+
|
|
478
|
+
if (run <= runShowMax) {
|
|
479
|
+
for (let k = i; k < j; k++) out.push({ kind: "row", row: rows[k] as SplitDiffRow });
|
|
480
|
+
} else {
|
|
481
|
+
const leading = i === 0;
|
|
482
|
+
const trailing = j >= rows.length;
|
|
483
|
+
const keepHead = leading ? 0 : Math.min(keep, run);
|
|
484
|
+
const keepTail = trailing ? 0 : Math.min(keep, Math.max(0, run - keepHead));
|
|
485
|
+
const hidden = Math.max(0, run - keepHead - keepTail);
|
|
486
|
+
|
|
487
|
+
if (keepHead > 0) {
|
|
488
|
+
for (let k = i; k < i + keepHead; k++) out.push({ kind: "row", row: rows[k] as SplitDiffRow });
|
|
489
|
+
}
|
|
490
|
+
if (hidden > 0) out.push({ kind: "gap", hidden });
|
|
491
|
+
if (keepTail > 0) {
|
|
492
|
+
for (let k = j - keepTail; k < j; k++) out.push({ kind: "row", row: rows[k] as SplitDiffRow });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
i = j;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Plan the render entries under a row budget: collapse long context runs with
|
|
504
|
+
* the default padding first, then drop all context if the diff is still too
|
|
505
|
+
* tall, then finally trim the head and append an omission marker. The budget
|
|
506
|
+
* applies to entries (each renders at least one line, gaps included).
|
|
507
|
+
*/
|
|
508
|
+
function planEntries(rows: SplitDiffRow[], maxRows: number): DiffEntry[] {
|
|
509
|
+
const budget = Math.max(1, maxRows);
|
|
510
|
+
|
|
511
|
+
let entries = collapseContextRows(rows, { keep: CONTEXT_KEEP_DEFAULT });
|
|
512
|
+
if (entries.length <= budget) return entries;
|
|
513
|
+
|
|
514
|
+
entries = collapseContextRows(rows, { keep: 0, runShowMax: 0 });
|
|
515
|
+
if (entries.length <= budget) return entries;
|
|
516
|
+
|
|
517
|
+
const kept = entries.slice(0, Math.max(1, budget - 1));
|
|
518
|
+
const omitted = Math.max(0, entries.length - kept.length);
|
|
519
|
+
if (omitted <= 0) return kept;
|
|
520
|
+
return [...kept, { kind: "omitted", count: omitted }];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function formatGapLabel(hidden: number): string {
|
|
524
|
+
return `⋯ ${hidden} unchanged ${hidden === 1 ? "line" : "lines"} hidden`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatOmittedLabel(count: number): string {
|
|
528
|
+
return `⋯ ${count} ${count === 1 ? "line" : "lines"} omitted · Ctrl+O to show full diff`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── DiffRenderContext ──────────────────────────────────────────────
|
|
532
|
+
// Shared per-instance state: palette, gutter width, syntax-highlight cache,
|
|
533
|
+
// and inline-emphasis spans for paired changed rows.
|
|
534
|
+
|
|
535
|
+
class DiffRenderContext {
|
|
536
|
+
readonly lineNumberWidth: number;
|
|
537
|
+
readonly palette: DiffPalette;
|
|
538
|
+
readonly containerBgAnsi: string;
|
|
418
539
|
private readonly highlightCache = new Map<string, string>();
|
|
419
540
|
private readonly inlineHighlights = new WeakMap<DiffLine, DiffSpan[]>();
|
|
420
|
-
private readonly palette: DiffPalette;
|
|
421
|
-
private readonly containerBgAnsi: string;
|
|
422
541
|
|
|
423
542
|
constructor(
|
|
424
543
|
private readonly theme: SplitDiffTheme,
|
|
425
|
-
|
|
426
|
-
private readonly maxRows: number,
|
|
544
|
+
rows: SplitDiffRow[],
|
|
427
545
|
private readonly language?: string,
|
|
428
546
|
) {
|
|
429
547
|
let maxDigits = 3;
|
|
@@ -443,6 +561,153 @@ export class SplitDiffComponent implements Component {
|
|
|
443
561
|
this.containerBgAnsi = theme.getBgAnsi?.("toolSuccessBg") ?? "";
|
|
444
562
|
}
|
|
445
563
|
|
|
564
|
+
fg(color: string, text: string): string {
|
|
565
|
+
return this.theme.fg(color, text);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
inlineSpans(line: DiffLine): DiffSpan[] {
|
|
569
|
+
return this.inlineHighlights.get(line) ?? [];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
syntaxHighlight(line: string): string {
|
|
573
|
+
if (!this.language) return stripInlineBreaksPreserveAnsi(line);
|
|
574
|
+
const safeLine = sanitizeSingleLineText(line);
|
|
575
|
+
const key = `${this.language}\n${safeLine}`;
|
|
576
|
+
const cached = this.highlightCache.get(key);
|
|
577
|
+
if (cached) return cached;
|
|
578
|
+
|
|
579
|
+
let highlighted = safeLine;
|
|
580
|
+
try {
|
|
581
|
+
highlighted = highlightCode(safeLine, this.language)[0] ?? safeLine;
|
|
582
|
+
highlighted = stripInlineBreaksPreserveAnsi(highlighted).replace(BG_ANSI_PATTERN, "");
|
|
583
|
+
} catch {
|
|
584
|
+
highlighted = safeLine;
|
|
585
|
+
}
|
|
586
|
+
this.highlightCache.set(key, highlighted);
|
|
587
|
+
return highlighted;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Unified diff renderer ──────────────────────────────────────────
|
|
592
|
+
// One column: marker + gutter + content, with added/removed lines carrying
|
|
593
|
+
// the same row backgrounds as the split view. Changed rows expand back into
|
|
594
|
+
// their removed-then-added pair (like `git diff`).
|
|
595
|
+
|
|
596
|
+
class UnifiedDiffRenderer {
|
|
597
|
+
constructor(
|
|
598
|
+
private readonly ctx: DiffRenderContext,
|
|
599
|
+
private readonly entries: DiffEntry[],
|
|
600
|
+
) {}
|
|
601
|
+
|
|
602
|
+
private gapLine(entry: { hidden: number }, width: number): string {
|
|
603
|
+
return padRenderedLineWidth(this.ctx.fg("muted", formatGapLabel(entry.hidden)), width);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private omittedLine(entry: { count: number }, width: number): string {
|
|
607
|
+
return padRenderedLineWidth(this.ctx.fg("muted", formatOmittedLabel(entry.count)), width);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
render(width: number): string[] {
|
|
611
|
+
const safeWidth = Math.max(20, width);
|
|
612
|
+
const prefixWidth = 1 + 1 + this.ctx.lineNumberWidth + 2; // marker + space + gutter + 2 spaces
|
|
613
|
+
const codeWidth = Math.max(1, safeWidth - prefixWidth);
|
|
614
|
+
const lines: string[] = [];
|
|
615
|
+
|
|
616
|
+
for (const entry of this.entries) {
|
|
617
|
+
if (entry.kind === "gap") {
|
|
618
|
+
lines.push(this.gapLine(entry, safeWidth));
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (entry.kind === "omitted") {
|
|
622
|
+
lines.push(this.omittedLine(entry, safeWidth));
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
lines.push(...this.rowLines(entry.row, codeWidth, safeWidth));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return lines;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private rowLines(row: SplitDiffRow, codeWidth: number, width: number): string[] {
|
|
632
|
+
const segments: Array<{ kind: CellLineKind; line: DiffLine }> = [];
|
|
633
|
+
if (row.kind === "changed") {
|
|
634
|
+
if (row.left) segments.push({ kind: "remove", line: row.left });
|
|
635
|
+
if (row.right) segments.push({ kind: "add", line: row.right });
|
|
636
|
+
} else if (row.kind === "added" && row.right) {
|
|
637
|
+
segments.push({ kind: "add", line: row.right });
|
|
638
|
+
} else if (row.kind === "removed" && row.left) {
|
|
639
|
+
segments.push({ kind: "remove", line: row.left });
|
|
640
|
+
} else {
|
|
641
|
+
const line = row.left ?? row.right;
|
|
642
|
+
if (line) segments.push({ kind: "context", line });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const out: string[] = [];
|
|
646
|
+
for (const segment of segments) {
|
|
647
|
+
out.push(...this.segmentLines(segment.kind, segment.line, codeWidth, width));
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private segmentLines(kind: CellLineKind, line: DiffLine, codeWidth: number, width: number): string[] {
|
|
653
|
+
const isAdd = kind === "add";
|
|
654
|
+
const isRemove = kind === "remove";
|
|
655
|
+
const blank = line.line === "";
|
|
656
|
+
// Blank added/removed lines render like context (no tinted band), but the
|
|
657
|
+
// +/- marker still shows that a line was inserted/removed.
|
|
658
|
+
const visualKind: CellLineKind = kind === "context" || blank ? "context" : kind;
|
|
659
|
+
const markerChar = kind === "context" ? " " : isAdd ? "+" : "-";
|
|
660
|
+
const markerColor = isAdd ? "toolDiffAdded" : isRemove ? "toolDiffRemoved" : "dim";
|
|
661
|
+
const gutterColor = visualKind === "context" ? "dim" : isAdd ? "toolDiffAdded" : "toolDiffRemoved";
|
|
662
|
+
const gutter = line.lineNumber.trim().padStart(this.ctx.lineNumberWidth, " ");
|
|
663
|
+
|
|
664
|
+
const firstPrefixAnsi = `${this.ctx.fg(markerColor, markerChar)} ${this.ctx.fg(gutterColor, gutter)} `;
|
|
665
|
+
const firstPrefixPlain = `${markerChar} ${gutter} `;
|
|
666
|
+
const contPrefixAnsi = `${this.ctx.fg("dim", " ")} ${this.ctx.fg("dim", " ".repeat(this.ctx.lineNumberWidth))} `;
|
|
667
|
+
const contPrefixPlain = ` ${" ".repeat(1 + this.ctx.lineNumberWidth)} `;
|
|
668
|
+
|
|
669
|
+
const rowBg =
|
|
670
|
+
visualKind === "add"
|
|
671
|
+
? this.ctx.palette.addRowBgAnsi
|
|
672
|
+
: visualKind === "remove"
|
|
673
|
+
? this.ctx.palette.removeRowBgAnsi
|
|
674
|
+
: undefined;
|
|
675
|
+
|
|
676
|
+
const plainSegments = wrapPlainText(line.line, codeWidth);
|
|
677
|
+
const out: string[] = [];
|
|
678
|
+
|
|
679
|
+
for (let i = 0; i < plainSegments.length; i++) {
|
|
680
|
+
const prefixAnsi = i === 0 ? firstPrefixAnsi : contPrefixAnsi;
|
|
681
|
+
const prefixPlain = i === 0 ? firstPrefixPlain : contPrefixPlain;
|
|
682
|
+
const plainSegment = plainSegments[i] ?? "";
|
|
683
|
+
let segment = this.ctx.syntaxHighlight(plainSegment);
|
|
684
|
+
segment = fitToWidth(segment, codeWidth);
|
|
685
|
+
|
|
686
|
+
let rendered = prefixAnsi + segment;
|
|
687
|
+
const expectedWidth = safeVisibleWidth(prefixPlain) + codeWidth;
|
|
688
|
+
const currentWidth = safeVisibleWidth(stripAnsi(rendered));
|
|
689
|
+
if (currentWidth < expectedWidth) {
|
|
690
|
+
rendered += " ".repeat(expectedWidth - currentWidth);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (rowBg) {
|
|
694
|
+
rendered = `${rowBg}${keepBackgroundAcrossResets(rendered, rowBg)}${this.ctx.containerBgAnsi}`;
|
|
695
|
+
}
|
|
696
|
+
out.push(padRenderedLineWidth(rendered, width));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Split (side-by-side) diff renderer ─────────────────────────────
|
|
704
|
+
|
|
705
|
+
class SplitDiffRenderer {
|
|
706
|
+
constructor(
|
|
707
|
+
private readonly ctx: DiffRenderContext,
|
|
708
|
+
private readonly entries: DiffEntry[],
|
|
709
|
+
) {}
|
|
710
|
+
|
|
446
711
|
private getCellLineKind(kind: SplitDiffRow["kind"], side: "left" | "right"): CellLineKind {
|
|
447
712
|
if (kind === "changed") return side === "left" ? "remove" : "add";
|
|
448
713
|
if (kind === "removed" && side === "left") return "remove";
|
|
@@ -465,25 +730,25 @@ export class SplitDiffComponent implements Component {
|
|
|
465
730
|
}
|
|
466
731
|
|
|
467
732
|
private getRowBackground(lineKind: CellLineKind): string | undefined {
|
|
468
|
-
if (lineKind === "add") return this.palette.addRowBgAnsi;
|
|
469
|
-
if (lineKind === "remove") return this.palette.removeRowBgAnsi;
|
|
733
|
+
if (lineKind === "add") return this.ctx.palette.addRowBgAnsi;
|
|
734
|
+
if (lineKind === "remove") return this.ctx.palette.removeRowBgAnsi;
|
|
470
735
|
return undefined;
|
|
471
736
|
}
|
|
472
737
|
|
|
473
738
|
private getEmphasisBackground(lineKind: CellLineKind): string | undefined {
|
|
474
|
-
if (lineKind === "add") return this.palette.addEmphasisBgAnsi;
|
|
475
|
-
if (lineKind === "remove") return this.palette.removeEmphasisBgAnsi;
|
|
739
|
+
if (lineKind === "add") return this.ctx.palette.addEmphasisBgAnsi;
|
|
740
|
+
if (lineKind === "remove") return this.ctx.palette.removeEmphasisBgAnsi;
|
|
476
741
|
return undefined;
|
|
477
742
|
}
|
|
478
743
|
|
|
479
744
|
private getCellFillBackground(kind: SplitDiffRow["kind"], side: "left" | "right"): string | undefined {
|
|
480
745
|
switch (kind) {
|
|
481
746
|
case "changed":
|
|
482
|
-
return side === "left" ? this.palette.removeRowBgAnsi : this.palette.addRowBgAnsi;
|
|
747
|
+
return side === "left" ? this.ctx.palette.removeRowBgAnsi : this.ctx.palette.addRowBgAnsi;
|
|
483
748
|
case "removed":
|
|
484
|
-
return side === "left" ? this.palette.removeRowBgAnsi : undefined;
|
|
749
|
+
return side === "left" ? this.ctx.palette.removeRowBgAnsi : undefined;
|
|
485
750
|
case "added":
|
|
486
|
-
return side === "right" ? this.palette.addRowBgAnsi : undefined;
|
|
751
|
+
return side === "right" ? this.ctx.palette.addRowBgAnsi : undefined;
|
|
487
752
|
default:
|
|
488
753
|
return undefined;
|
|
489
754
|
}
|
|
@@ -494,38 +759,20 @@ export class SplitDiffComponent implements Component {
|
|
|
494
759
|
const markerChar = lineKind === "add" || lineKind === "remove" ? "▌" : " ";
|
|
495
760
|
const markerColor =
|
|
496
761
|
lineKind === "add" ? "toolDiffAdded" : lineKind === "remove" ? "toolDiffRemoved" : "borderMuted";
|
|
497
|
-
const marker = this.
|
|
498
|
-
const lineNumber = this.
|
|
499
|
-
const divider = this.
|
|
762
|
+
const marker = this.ctx.fg(markerColor, markerChar);
|
|
763
|
+
const lineNumber = this.ctx.fg("dim", " ".repeat(this.ctx.lineNumberWidth));
|
|
764
|
+
const divider = this.ctx.fg("borderMuted", " │ ");
|
|
500
765
|
const prefix = `${marker} ${lineNumber}${divider}`;
|
|
501
|
-
const prefixPlain = `${markerChar} ${" ".repeat(this.lineNumberWidth)} │ `;
|
|
766
|
+
const prefixPlain = `${markerChar} ${" ".repeat(this.ctx.lineNumberWidth)} │ `;
|
|
502
767
|
const tailWidth = Math.max(0, columnWidth - safeVisibleWidth(prefixPlain));
|
|
503
768
|
let rendered = prefix + " ".repeat(tailWidth);
|
|
504
769
|
|
|
505
770
|
const bg = this.getCellFillBackground(kind, side);
|
|
506
771
|
if (!bg) return padRenderedLineWidth(rendered, columnWidth);
|
|
507
|
-
rendered = `${bg}${keepBackgroundAcrossResets(rendered, bg)}${this.containerBgAnsi}`;
|
|
772
|
+
rendered = `${bg}${keepBackgroundAcrossResets(rendered, bg)}${this.ctx.containerBgAnsi}`;
|
|
508
773
|
return padRenderedLineWidth(rendered, columnWidth);
|
|
509
774
|
}
|
|
510
775
|
|
|
511
|
-
private syntaxHighlight(line: string): string {
|
|
512
|
-
if (!this.language) return stripInlineBreaksPreserveAnsi(line);
|
|
513
|
-
const safeLine = sanitizeSingleLineText(line);
|
|
514
|
-
const key = `${this.language}\n${safeLine}`;
|
|
515
|
-
const cached = this.highlightCache.get(key);
|
|
516
|
-
if (cached) return cached;
|
|
517
|
-
|
|
518
|
-
let highlighted = safeLine;
|
|
519
|
-
try {
|
|
520
|
-
highlighted = highlightCode(safeLine, this.language)[0] ?? safeLine;
|
|
521
|
-
highlighted = stripInlineBreaksPreserveAnsi(highlighted).replace(BG_ANSI_PATTERN, "");
|
|
522
|
-
} catch {
|
|
523
|
-
highlighted = safeLine;
|
|
524
|
-
}
|
|
525
|
-
this.highlightCache.set(key, highlighted);
|
|
526
|
-
return highlighted;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
776
|
private formatCellLines(
|
|
530
777
|
kind: SplitDiffRow["kind"],
|
|
531
778
|
side: "left" | "right",
|
|
@@ -538,21 +785,21 @@ export class SplitDiffComponent implements Component {
|
|
|
538
785
|
const markerChar = lineKind === "add" || lineKind === "remove" ? "▌" : " ";
|
|
539
786
|
const markerColor =
|
|
540
787
|
lineKind === "add" ? "toolDiffAdded" : lineKind === "remove" ? "toolDiffRemoved" : "borderMuted";
|
|
541
|
-
const lineNumber = line.lineNumber.trim().padStart(this.lineNumberWidth, " ");
|
|
788
|
+
const lineNumber = line.lineNumber.trim().padStart(this.ctx.lineNumberWidth, " ");
|
|
542
789
|
|
|
543
790
|
const firstPrefixAnsi =
|
|
544
|
-
this.
|
|
791
|
+
this.ctx.fg(markerColor, markerChar) +
|
|
545
792
|
" " +
|
|
546
|
-
this.
|
|
547
|
-
this.
|
|
793
|
+
this.ctx.fg(this.getNumberColor(lineKind), lineNumber) +
|
|
794
|
+
this.ctx.fg("borderMuted", " │ ");
|
|
548
795
|
const firstPrefixPlain = `${markerChar} ${lineNumber} │ `;
|
|
549
796
|
|
|
550
797
|
const contPrefixAnsi =
|
|
551
|
-
this.
|
|
798
|
+
this.ctx.fg(markerColor, markerChar) +
|
|
552
799
|
" " +
|
|
553
|
-
this.
|
|
554
|
-
this.
|
|
555
|
-
const contPrefixPlain = `${markerChar} ${" ".repeat(this.lineNumberWidth)} │ `;
|
|
800
|
+
this.ctx.fg("dim", " ".repeat(this.ctx.lineNumberWidth)) +
|
|
801
|
+
this.ctx.fg("borderMuted", " │ ");
|
|
802
|
+
const contPrefixPlain = `${markerChar} ${" ".repeat(this.ctx.lineNumberWidth)} │ `;
|
|
556
803
|
|
|
557
804
|
const codeWidth = Math.max(1, columnWidth - safeVisibleWidth(firstPrefixPlain));
|
|
558
805
|
const rowBg = this.getRowBackground(lineKind);
|
|
@@ -560,14 +807,14 @@ export class SplitDiffComponent implements Component {
|
|
|
560
807
|
|
|
561
808
|
const plainSegments = wrapPlainText(line.line, codeWidth);
|
|
562
809
|
const lines: string[] = [];
|
|
563
|
-
const spans = this.
|
|
810
|
+
const spans = this.ctx.inlineSpans(line);
|
|
564
811
|
|
|
565
812
|
let consumed = 0;
|
|
566
813
|
for (let i = 0; i < plainSegments.length; i++) {
|
|
567
814
|
const prefixAnsi = i === 0 ? firstPrefixAnsi : contPrefixAnsi;
|
|
568
815
|
const prefixPlain = i === 0 ? firstPrefixPlain : contPrefixPlain;
|
|
569
816
|
const plainSegment = plainSegments[i] ?? "";
|
|
570
|
-
let segment = this.syntaxHighlight(plainSegment);
|
|
817
|
+
let segment = this.ctx.syntaxHighlight(plainSegment);
|
|
571
818
|
|
|
572
819
|
if (spans.length > 0 && emphasisBg) {
|
|
573
820
|
const segmentStart = consumed;
|
|
@@ -582,7 +829,7 @@ export class SplitDiffComponent implements Component {
|
|
|
582
829
|
localStart,
|
|
583
830
|
localEnd,
|
|
584
831
|
emphasisBg,
|
|
585
|
-
rowBg ?? this.containerBgAnsi,
|
|
832
|
+
rowBg ?? this.ctx.containerBgAnsi,
|
|
586
833
|
);
|
|
587
834
|
}
|
|
588
835
|
}
|
|
@@ -599,7 +846,7 @@ export class SplitDiffComponent implements Component {
|
|
|
599
846
|
}
|
|
600
847
|
|
|
601
848
|
if (rowBg) {
|
|
602
|
-
rendered = `${rowBg}${keepBackgroundAcrossResets(rendered, rowBg)}${this.containerBgAnsi}`;
|
|
849
|
+
rendered = `${rowBg}${keepBackgroundAcrossResets(rendered, rowBg)}${this.ctx.containerBgAnsi}`;
|
|
603
850
|
}
|
|
604
851
|
lines.push(padRenderedLineWidth(rendered, columnWidth));
|
|
605
852
|
consumed += plainSegment.length;
|
|
@@ -608,11 +855,17 @@ export class SplitDiffComponent implements Component {
|
|
|
608
855
|
return lines;
|
|
609
856
|
}
|
|
610
857
|
|
|
611
|
-
|
|
612
|
-
|
|
858
|
+
private gapLine(entry: { hidden: number }, width: number): string {
|
|
859
|
+
return padRenderedLineWidth(this.ctx.fg("muted", formatGapLabel(entry.hidden)), width);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private omittedLine(entry: { count: number }, width: number): string {
|
|
863
|
+
return padRenderedLineWidth(this.ctx.fg("muted", formatOmittedLabel(entry.count)), width);
|
|
864
|
+
}
|
|
613
865
|
|
|
866
|
+
render(width: number): string[] {
|
|
614
867
|
const safeWidth = Math.max(20, width);
|
|
615
|
-
const columnSeparator = this.
|
|
868
|
+
const columnSeparator = this.ctx.fg("borderMuted", " │ ");
|
|
616
869
|
const separatorWidth = safeVisibleWidth(stripAnsi(columnSeparator));
|
|
617
870
|
const leftWidth = Math.max(20, Math.floor((safeWidth - separatorWidth) / 2));
|
|
618
871
|
const rightWidth = Math.max(20, safeWidth - separatorWidth - leftWidth);
|
|
@@ -620,21 +873,19 @@ export class SplitDiffComponent implements Component {
|
|
|
620
873
|
const formatBorderCell = (columnWidth: number, junction: string): string => {
|
|
621
874
|
const safeColumnWidth = Math.max(1, columnWidth);
|
|
622
875
|
const chars = "─".repeat(safeColumnWidth).split("");
|
|
623
|
-
const dividerIndex = this.lineNumberWidth + 3;
|
|
876
|
+
const dividerIndex = this.ctx.lineNumberWidth + 3;
|
|
624
877
|
if (dividerIndex >= 0 && dividerIndex < chars.length) {
|
|
625
878
|
chars[dividerIndex] = junction;
|
|
626
879
|
}
|
|
627
|
-
return this.
|
|
880
|
+
return this.ctx.fg("borderMuted", chars.join(""));
|
|
628
881
|
};
|
|
629
882
|
|
|
630
883
|
const formatHeaderCell = (label: string, columnWidth: number): string => {
|
|
631
884
|
// Keep marker+space columns, then place label inside the line-number column.
|
|
632
885
|
const markerPad = " ";
|
|
633
|
-
const lineNumberLabel = fitToWidth(label, this.lineNumberWidth);
|
|
886
|
+
const lineNumberLabel = fitToWidth(label, this.ctx.lineNumberWidth);
|
|
634
887
|
const prefixAnsi =
|
|
635
|
-
this.
|
|
636
|
-
this.theme.fg("dim", lineNumberLabel) +
|
|
637
|
-
this.theme.fg("borderMuted", " │ ");
|
|
888
|
+
this.ctx.fg("borderMuted", markerPad) + this.ctx.fg("dim", lineNumberLabel) + this.ctx.fg("borderMuted", " │ ");
|
|
638
889
|
const prefixPlain = `${markerPad}${stripAnsi(lineNumberLabel)} │ `;
|
|
639
890
|
const codeWidth = Math.max(0, columnWidth - safeVisibleWidth(prefixPlain));
|
|
640
891
|
return padRenderedLineWidth(prefixAnsi + " ".repeat(codeWidth), columnWidth);
|
|
@@ -643,7 +894,7 @@ export class SplitDiffComponent implements Component {
|
|
|
643
894
|
const lines: string[] = [];
|
|
644
895
|
lines.push(
|
|
645
896
|
padRenderedLineWidth(
|
|
646
|
-
formatBorderCell(leftWidth, "┬") + this.
|
|
897
|
+
formatBorderCell(leftWidth, "┬") + this.ctx.fg("borderMuted", "─┬─") + formatBorderCell(rightWidth, "┬"),
|
|
647
898
|
safeWidth,
|
|
648
899
|
),
|
|
649
900
|
);
|
|
@@ -654,31 +905,90 @@ export class SplitDiffComponent implements Component {
|
|
|
654
905
|
),
|
|
655
906
|
);
|
|
656
907
|
|
|
657
|
-
for (const
|
|
908
|
+
for (const entry of this.entries) {
|
|
909
|
+
if (entry.kind === "gap") {
|
|
910
|
+
lines.push(this.gapLine(entry, safeWidth));
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (entry.kind === "omitted") {
|
|
914
|
+
lines.push(this.omittedLine(entry, safeWidth));
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const row = entry.row;
|
|
658
919
|
const leftCellLines = this.formatCellLines(row.kind, "left", row.left, leftWidth);
|
|
659
920
|
const rightCellLines = this.formatCellLines(row.kind, "right", row.right, rightWidth);
|
|
660
921
|
const rowHeight = Math.max(leftCellLines.length, rightCellLines.length);
|
|
661
922
|
|
|
662
923
|
for (let i = 0; i < rowHeight; i++) {
|
|
663
|
-
const
|
|
664
|
-
const
|
|
665
|
-
const
|
|
666
|
-
const rightCell = rightCellLines[i] ?? this.blankCell(rightFallbackKind, "right", rightWidth);
|
|
924
|
+
const fallbackKind: SplitDiffRow["kind"] = row.kind === "changed" ? "context" : row.kind;
|
|
925
|
+
const leftCell = leftCellLines[i] ?? this.blankCell(fallbackKind, "left", leftWidth);
|
|
926
|
+
const rightCell = rightCellLines[i] ?? this.blankCell(fallbackKind, "right", rightWidth);
|
|
667
927
|
const joined = padRenderedLineWidth(leftCell + columnSeparator + rightCell, safeWidth);
|
|
668
928
|
lines.push(joined);
|
|
669
929
|
}
|
|
670
930
|
}
|
|
671
931
|
|
|
672
|
-
if (this.rows.length > this.maxRows) {
|
|
673
|
-
lines.push(this.theme.fg("muted", `... ${this.rows.length - this.maxRows} more rows`));
|
|
674
|
-
}
|
|
675
|
-
|
|
676
932
|
lines.push(
|
|
677
933
|
padRenderedLineWidth(
|
|
678
|
-
formatBorderCell(leftWidth, "┴") + this.
|
|
934
|
+
formatBorderCell(leftWidth, "┴") + this.ctx.fg("borderMuted", "─┴─") + formatBorderCell(rightWidth, "┴"),
|
|
679
935
|
safeWidth,
|
|
680
936
|
),
|
|
681
937
|
);
|
|
938
|
+
return lines;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ── AdaptiveDiffComponent ──────────────────────────────────────────
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Boxed diff component that picks unified vs split layout per render width
|
|
946
|
+
* (see `pickDiffMode`) and collapses long unchanged context instead of
|
|
947
|
+
* truncating arbitrarily.
|
|
948
|
+
*/
|
|
949
|
+
export class AdaptiveDiffComponent implements Component {
|
|
950
|
+
private cacheWidth: number | undefined;
|
|
951
|
+
private cacheLines: string[] | undefined;
|
|
952
|
+
private readonly ctx: DiffRenderContext;
|
|
953
|
+
private readonly stats: { additions: number; removals: number };
|
|
954
|
+
private readonly unified: UnifiedDiffRenderer;
|
|
955
|
+
private readonly split: SplitDiffRenderer;
|
|
956
|
+
private readonly collapsed: boolean;
|
|
957
|
+
|
|
958
|
+
constructor(
|
|
959
|
+
theme: SplitDiffTheme,
|
|
960
|
+
private readonly rows: SplitDiffRow[],
|
|
961
|
+
maxRows: number,
|
|
962
|
+
language?: string,
|
|
963
|
+
) {
|
|
964
|
+
this.ctx = new DiffRenderContext(theme, rows, language);
|
|
965
|
+
this.stats = { additions: 0, removals: 0 };
|
|
966
|
+
for (const row of rows) {
|
|
967
|
+
if (row.kind === "added" || row.kind === "changed") this.stats.additions++;
|
|
968
|
+
if (row.kind === "removed" || row.kind === "changed") this.stats.removals++;
|
|
969
|
+
}
|
|
970
|
+
const entries = planEntries(rows, maxRows);
|
|
971
|
+
this.collapsed = entries.some((entry) => entry.kind !== "row");
|
|
972
|
+
this.unified = new UnifiedDiffRenderer(this.ctx, entries);
|
|
973
|
+
this.split = new SplitDiffRenderer(this.ctx, entries);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** True when any unchanged context was collapsed or rows were omitted. */
|
|
977
|
+
hasCollapsed(): boolean {
|
|
978
|
+
return this.collapsed;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
modeForWidth(width: number): DiffMode {
|
|
982
|
+
return pickDiffMode(this.stats, this.rows, Math.max(20, width));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
render(width: number): string[] {
|
|
986
|
+
if (this.cacheWidth === width && this.cacheLines) return this.cacheLines;
|
|
987
|
+
|
|
988
|
+
const safeWidth = Math.max(20, width);
|
|
989
|
+
const mode = this.modeForWidth(safeWidth);
|
|
990
|
+
const lines = mode === "split" ? this.split.render(safeWidth) : this.unified.render(safeWidth);
|
|
991
|
+
|
|
682
992
|
this.cacheWidth = width;
|
|
683
993
|
this.cacheLines = lines;
|
|
684
994
|
return lines;
|
|
@@ -687,6 +997,5 @@ export class SplitDiffComponent implements Component {
|
|
|
687
997
|
invalidate(): void {
|
|
688
998
|
this.cacheWidth = undefined;
|
|
689
999
|
this.cacheLines = undefined;
|
|
690
|
-
this.highlightCache.clear();
|
|
691
1000
|
}
|
|
692
1001
|
}
|