@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.
@@ -1,5 +1,7 @@
1
- // Split-diff renderer — side-by-side diff view with syntax highlighting and
2
- // inline emphasis.
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 split-diff renderer. */
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
- // ── SplitDiffComponent ─────────────────────────────────────────────
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
- export class SplitDiffComponent implements Component {
415
- private cacheWidth: number | undefined;
416
- private cacheLines: string[] | undefined;
417
- private readonly lineNumberWidth: number;
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
- private readonly rows: SplitDiffRow[],
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.theme.fg(markerColor, markerChar);
498
- const lineNumber = this.theme.fg("dim", " ".repeat(this.lineNumberWidth));
499
- const divider = this.theme.fg("borderMuted", " │ ");
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.theme.fg(markerColor, markerChar) +
791
+ this.ctx.fg(markerColor, markerChar) +
545
792
  " " +
546
- this.theme.fg(this.getNumberColor(lineKind), lineNumber) +
547
- this.theme.fg("borderMuted", " │ ");
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.theme.fg(markerColor, markerChar) +
798
+ this.ctx.fg(markerColor, markerChar) +
552
799
  " " +
553
- this.theme.fg("dim", " ".repeat(this.lineNumberWidth)) +
554
- this.theme.fg("borderMuted", " │ ");
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.inlineHighlights.get(line) ?? [];
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
- render(width: number): string[] {
612
- if (this.cacheWidth === width && this.cacheLines) return this.cacheLines;
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.theme.fg("borderMuted", " │ ");
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.theme.fg("borderMuted", chars.join(""));
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.theme.fg("borderMuted", markerPad) +
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.theme.fg("borderMuted", "─┬─") + formatBorderCell(rightWidth, "┬"),
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 row of this.rows.slice(0, this.maxRows)) {
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 leftFallbackKind: SplitDiffRow["kind"] = row.kind === "changed" ? "context" : row.kind;
664
- const rightFallbackKind: SplitDiffRow["kind"] = row.kind === "changed" ? "context" : row.kind;
665
- const leftCell = leftCellLines[i] ?? this.blankCell(leftFallbackKind, "left", leftWidth);
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.theme.fg("borderMuted", "─┴─") + formatBorderCell(rightWidth, "┴"),
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",