@heyhuynhgiabuu/pi-diff 0.6.0 → 0.6.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/dist/index.js CHANGED
@@ -24,22 +24,22 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { extname, relative } from "node:path";
25
25
  import { codeToANSI } from "@shikijs/cli";
26
26
  import * as Diff from "diff";
27
- import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified } from "./core/diff.js";
28
27
  import { configIndicatorStyle } from "./core/config.js";
28
+ import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified, } from "./core/diff.js";
29
+ import { replace } from "./core/replace.js";
29
30
  import { resolveLinesFromPatch } from "./core/resolve-lines.js";
30
31
  import { registerReviewDiffCommand } from "./review/command.js";
31
- import { replace } from "./core/replace.js";
32
32
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
33
33
  const DIFF_PRESETS = {
34
34
  default: {
35
35
  name: "default",
36
36
  description: "Original pi-diff colors — tuned for dark theme bases (~#1e1e2e)",
37
- bgAdd: "#162620",
38
- bgDel: "#2d1919",
39
- bgAddHighlight: "#234b32",
40
- bgDelHighlight: "#502323",
41
- bgGutterAdd: "#12201a",
42
- bgGutterDel: "#261616",
37
+ bgAdd: "#1a3324",
38
+ bgDel: "#3d2020",
39
+ bgAddHighlight: "#2d5c3a",
40
+ bgDelHighlight: "#5c2d2d",
41
+ bgGutterAdd: "#16281e",
42
+ bgGutterDel: "#301c1c",
43
43
  bgEmpty: "#121212",
44
44
  fgDim: "#505050",
45
45
  fgLnum: "#646464",
@@ -50,12 +50,12 @@ const DIFF_PRESETS = {
50
50
  midnight: {
51
51
  name: "midnight",
52
52
  description: "Subtle tints for pure black (#000000) terminal backgrounds",
53
- bgAdd: "#0d1a12",
54
- bgDel: "#1a0d0d",
53
+ bgAdd: "#101e15",
54
+ bgDel: "#1e1010",
55
55
  bgAddHighlight: "#1a3825",
56
56
  bgDelHighlight: "#381a1a",
57
- bgGutterAdd: "#091208",
58
- bgGutterDel: "#120908",
57
+ bgGutterAdd: "#0c180c",
58
+ bgGutterDel: "#180c0c",
59
59
  bgEmpty: "#080808",
60
60
  fgDim: "#404040",
61
61
  fgLnum: "#505050",
@@ -66,12 +66,12 @@ const DIFF_PRESETS = {
66
66
  subtle: {
67
67
  name: "subtle",
68
68
  description: "Minimal backgrounds — barely-there tints for a clean look",
69
- bgAdd: "#081008",
70
- bgDel: "#100808",
69
+ bgAdd: "#0c160c",
70
+ bgDel: "#160c0c",
71
71
  bgAddHighlight: "#122818",
72
72
  bgDelHighlight: "#281212",
73
- bgGutterAdd: "#060c06",
74
- bgGutterDel: "#0c0606",
73
+ bgGutterAdd: "#080e08",
74
+ bgGutterDel: "#0e0808",
75
75
  bgEmpty: "#060606",
76
76
  fgDim: "#383838",
77
77
  fgLnum: "#484848",
@@ -82,12 +82,12 @@ const DIFF_PRESETS = {
82
82
  neon: {
83
83
  name: "neon",
84
84
  description: "Higher contrast backgrounds for better visibility",
85
- bgAdd: "#1a3320",
86
- bgDel: "#331a16",
85
+ bgAdd: "#1e3a28",
86
+ bgDel: "#3a1c18",
87
87
  bgAddHighlight: "#2d5c3a",
88
88
  bgDelHighlight: "#5c2d2d",
89
- bgGutterAdd: "#142818",
90
- bgGutterDel: "#28120e",
89
+ bgGutterAdd: "#182c1c",
90
+ bgGutterDel: "#2c1410",
91
91
  bgEmpty: "#141414",
92
92
  fgDim: "#606060",
93
93
  fgLnum: "#787878",
@@ -182,22 +182,22 @@ function autoDeriveBgFromTheme(theme) {
182
182
  /* no toolErrorBg — use toolSuccessBg/black */
183
183
  }
184
184
  }
185
- // Line backgrounds — subtle accent mixed into the matching tool-state base (810%)
186
- BG_ADD = mixBg(addBase, addRgb, 0.08);
187
- BG_DEL = mixBg(delBase, delRgb, 0.1);
188
- // Word-level highlights — more visible (2022%)
189
- BG_ADD_W = mixBg(addBase, addRgb, 0.2);
190
- BG_DEL_W = mixBg(delBase, delRgb, 0.22);
191
- // Gutters — subtler than lines (56%)
192
- BG_GUTTER_ADD = mixBg(addBase, addRgb, 0.05);
193
- BG_GUTTER_DEL = mixBg(delBase, delRgb, 0.06);
185
+ // Line backgrounds — visible accent mixed into the matching tool-state base (1518%)
186
+ BG_ADD = mixBg(addBase, addRgb, 0.15);
187
+ BG_DEL = mixBg(delBase, delRgb, 0.18);
188
+ // Word-level highlights — more prominent (3035%)
189
+ BG_ADD_W = mixBg(addBase, addRgb, 0.3);
190
+ BG_DEL_W = mixBg(delBase, delRgb, 0.35);
191
+ // Gutters — slightly subtler than lines (1012%)
192
+ BG_GUTTER_ADD = mixBg(addBase, addRgb, 0.1);
193
+ BG_GUTTER_DEL = mixBg(delBase, delRgb, 0.12);
194
194
  // Empty filler and context — match the success/context base
195
195
  BG_EMPTY = BG_BASE;
196
196
  // Update RST to re-apply base bg after every reset — prevents black
197
197
  // flashes between styled segments when toolSuccessBg is non-black
198
198
  RST = `\x1b[0m${BG_BASE}`;
199
199
  // Rebuild derived constants
200
- DIVIDER = `${FG_RULE}│${RST}`;
200
+ DIVIDER = `${FG_RULE}${RST}`;
201
201
  }
202
202
  catch {
203
203
  // Fall back to defaults silently
@@ -205,7 +205,10 @@ function autoDeriveBgFromTheme(theme) {
205
205
  }
206
206
  /** Load diff theme config from .pi/settings.json (project-level, then global). */
207
207
  function loadDiffConfig() {
208
- const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
208
+ const paths = [
209
+ `${process.cwd()}/.pi/settings.json`,
210
+ `${process.env.HOME ?? ""}/.pi/settings.json`,
211
+ ];
209
212
  for (const p of paths) {
210
213
  try {
211
214
  if (existsSync(p)) {
@@ -304,7 +307,7 @@ function applyDiffPalette() {
304
307
  if (shiki)
305
308
  THEME = shiki;
306
309
  // --- Rebuild derived constants ---
307
- DIVIDER = `${FG_RULE}│${RST}`;
310
+ DIVIDER = `${FG_RULE}${RST}`;
308
311
  DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
309
312
  // If no explicit bg config, auto-derive will run on first render
310
313
  _autoDerivePending = !_hasExplicitBgConfig;
@@ -339,10 +342,10 @@ function envBg(name, fallback) {
339
342
  // --- Split-view thresholds ---
340
343
  // Split is preferred when there's real room. At narrow widths, a clean stacked
341
344
  // (unified) view is better than a cramped split with wrapping.
342
- const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
343
- const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); // ≥60 code cols per side
344
- const SPLIT_MAX_WRAP_RATIO = 0.2; // if >20% lines wrap in split, fall back to stacked
345
- const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
345
+ const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 80); // allow split in normal terminals
346
+ const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 24); // short balanced hunks can split
347
+ const SPLIT_MAX_WRAP_RATIO = 0.35; // wrap-heavy hunks fall back to unified
348
+ const SPLIT_MAX_WRAP_LINES = 10; // absolute cap before unified fallback
346
349
  // --- Terminal bounds ---
347
350
  const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
348
351
  const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
@@ -363,16 +366,15 @@ const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
363
366
  // ANSI
364
367
  // ---------------------------------------------------------------------------
365
368
  let RST = "\x1b[0m";
366
- const BOLD = "\x1b[1m";
367
369
  const DIM = "\x1b[2m";
368
370
  // Subtle diff backgrounds — muted tones to let syntax fg shine through
369
371
  // Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
370
- let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
371
- let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
372
- let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
373
- let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
374
- let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
375
- let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
372
+ let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;30;52;40m"); // visible teal-green
373
+ let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;60;30;30m"); // visible brown-red
374
+ let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;45;90;60m"); // word-level emphasis
375
+ let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;100;45;45m");
376
+ let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;24;42;32m");
377
+ let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;48;28;28m");
376
378
  const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
377
379
  let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
378
380
  // Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
@@ -392,7 +394,7 @@ function getBorderBar() {
392
394
  function stripes(w, _rowOffset) {
393
395
  return BG_BASE + FG_STRIPE + "╱".repeat(w) + RST;
394
396
  }
395
- let DIVIDER = `${FG_RULE}│${RST}`;
397
+ let DIVIDER = `${FG_RULE}${RST}`;
396
398
  const ESC_RE = "\u001b";
397
399
  const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
398
400
  const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
@@ -675,13 +677,20 @@ function shouldUseSplit(diff, tw, maxRows = MAX_PREVIEW_LINES) {
675
677
  if (tw < SPLIT_MIN_WIDTH)
676
678
  return false;
677
679
  const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
678
- const half = Math.floor((tw - 1) / 2); // -1 for center divider
679
- const gw = nw + 5; // border + num + sign + sp + │ + sp
680
+ const half = Math.floor(tw / 2);
681
+ const gw = nw + 4; // border + num + spaces around sign
680
682
  const cw = Math.max(12, half - gw);
681
683
  if (cw < SPLIT_MIN_CODE_WIDTH)
682
684
  return false;
683
- // Estimate how many lines would need wrapping at this code width
685
+ // Split view only helps balanced replacements. One-sided or heavily
686
+ // imbalanced hunks waste a column and should use unified rendering.
684
687
  const vis = diff.lines.slice(0, maxRows);
688
+ const visibleAdd = vis.filter((line) => line.type === "add").length;
689
+ const visibleDel = vis.filter((line) => line.type === "del").length;
690
+ if (visibleAdd === 0 || visibleDel === 0)
691
+ return false;
692
+ if (Math.max(visibleAdd, visibleDel) > Math.min(visibleAdd, visibleDel) * 2)
693
+ return false;
685
694
  let contentLines = 0;
686
695
  let wrapCandidates = 0;
687
696
  for (const l of vis) {
@@ -828,8 +837,6 @@ function wordDiffAnalysis(a, b) {
828
837
  * Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
829
838
  */
830
839
  function injectBg(ansiLine, ranges, baseBg, hlBg) {
831
- if (!ranges.length)
832
- return baseBg + ansiLine + RST;
833
840
  let out = baseBg;
834
841
  let vis = 0;
835
842
  let inHL = false;
@@ -898,7 +905,7 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
898
905
  const vis = diff.lines.slice(0, max);
899
906
  const tw = termW();
900
907
  const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
901
- const gw = nw + 5; // border + num + sign + sp + │ + sp
908
+ const gw = nw + 4; // border + num + spaces around sign
902
909
  const cw = Math.max(20, tw - gw);
903
910
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
904
911
  // Build separate old/new code blocks for highlighting
@@ -910,18 +917,20 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
910
917
  newSrc.push(l.content);
911
918
  }
912
919
  const [oldHL, newHL] = canHL
913
- ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
920
+ ? await Promise.all([
921
+ hlBlock(oldSrc.join("\n"), language),
922
+ hlBlock(newSrc.join("\n"), language),
923
+ ])
914
924
  : [oldSrc, newSrc];
915
925
  let oI = 0, nI = 0, idx = 0;
916
926
  const out = [];
917
- out.push(rule(tw));
918
927
  /** Emit a single stacked row with compact gutter + left border bar. */
919
928
  function emitRow(num, sign, gutterBg, signFg, body, bodyBg = "") {
920
929
  const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
921
930
  const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : `${BG_BASE} `;
922
931
  const numFg = borderFg || FG_LNUM;
923
- const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `;
924
- const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `;
932
+ const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${gutterBg} ${signFg}${sign}${gutterBg} ${RST}`;
933
+ const contGutter = `${border}${gutterBg}${" ".repeat(nw + 3)}${RST}`;
925
934
  const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg);
926
935
  out.push(`${gutter}${rows[0]}${RST}`);
927
936
  for (let r = 1; r < rows.length; r++)
@@ -932,6 +941,10 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
932
941
  // Hunk separator — collapsed context with optional function context
933
942
  if (l.type === "sep") {
934
943
  const label = sepLabelUnified(getSepStyle(), l.hunkMeta, l.newNum, l.content);
944
+ if (!label) {
945
+ idx++;
946
+ continue;
947
+ }
935
948
  const totalW = Math.min(tw, 72);
936
949
  const pad = Math.max(0, totalW - label.length - 2);
937
950
  const half1 = Math.floor(pad / 2), half2 = pad - half1;
@@ -972,27 +985,26 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
972
985
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
973
986
  const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W);
974
987
  const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W);
975
- emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL);
976
- emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD);
988
+ emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, dc.fgDel, delBody, BG_DEL);
989
+ emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, dc.fgAdd, addBody, BG_ADD);
977
990
  continue;
978
991
  }
979
992
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
980
993
  const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
981
- emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL);
982
- emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD);
994
+ emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, dc.fgDel, `${BG_DEL}${pwd.old}`, BG_DEL);
995
+ emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, dc.fgAdd, `${BG_ADD}${pwd.new}`, BG_ADD);
983
996
  continue;
984
997
  }
985
998
  // Multi-line blocks — syntax highlighted with diff bg
986
999
  for (const d of dels) {
987
- const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`;
988
- emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL);
1000
+ const body = canHL ? injectBg(d.hl, [], BG_DEL, BG_DEL) : `${BG_DEL}${d.l.content}`;
1001
+ emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, dc.fgDel, body, BG_DEL);
989
1002
  }
990
1003
  for (const a of adds) {
991
- const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`;
992
- emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD);
1004
+ const body = canHL ? injectBg(a.hl, [], BG_ADD, BG_ADD) : `${BG_ADD}${a.l.content}`;
1005
+ emitRow(a.l.newNum, "+", BG_GUTTER_ADD, dc.fgAdd, body, BG_ADD);
993
1006
  }
994
1007
  }
995
- out.push(rule(tw));
996
1008
  if (diff.lines.length > vis.length) {
997
1009
  out.push(`${BG_BASE}${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`);
998
1010
  }
@@ -1037,9 +1049,9 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1037
1049
  rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
1038
1050
  }
1039
1051
  const vis = rows.slice(0, max);
1040
- const half = Math.floor((tw - 1) / 2); // -1 for center divider
1041
1052
  const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
1042
- const gw = nw + 5; // border + num + sign + sp + │ + sp
1053
+ const gw = nw + 4; // border + num + spaces around sign
1054
+ const half = Math.floor(tw / 2);
1043
1055
  const cw = Math.max(12, half - gw);
1044
1056
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
1045
1057
  // Build separate code blocks per side
@@ -1051,22 +1063,24 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1051
1063
  rightSrc.push(r.right.content);
1052
1064
  }
1053
1065
  const [leftHL, rightHL] = canHL
1054
- ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
1066
+ ? await Promise.all([
1067
+ hlBlock(leftSrc.join("\n"), language),
1068
+ hlBlock(rightSrc.join("\n"), language),
1069
+ ])
1055
1070
  : [leftSrc, rightSrc];
1056
1071
  let lI = 0, rI = 0;
1057
1072
  let stripeRow = 0; // tracks row index for diagonal stripe offset
1058
1073
  function half_build(line, hl, ranges, side) {
1059
- // Empty filler — diagonal stripes
1074
+ // Empty filler — render nothing to avoid dead-column gaps.
1060
1075
  if (!line) {
1061
- const gw2 = nw + 2; // number + sign + space before │
1062
- const gPat = FG_STRIPE + "╱".repeat(gw2) + RST;
1063
- const g = ` ${gPat}${FG_RULE}│${RST} `;
1064
- return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
1076
+ return { gutter: "", contGutter: "", bodyRows: [""] };
1065
1077
  }
1066
1078
  // Hunk separator with optional function context
1067
1079
  if (line.type === "sep") {
1068
1080
  const label = sepLabelSplit(getSepStyle(), line.hunkMeta, line.newNum, line.content);
1069
- const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
1081
+ if (!label)
1082
+ return { gutter: "", contGutter: "", bodyRows: [""] };
1083
+ const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 3)}${RST}`;
1070
1084
  return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
1071
1085
  }
1072
1086
  const isDel = line.type === "del", isAdd = line.type === "add";
@@ -1074,7 +1088,13 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1074
1088
  const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
1075
1089
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
1076
1090
  const sign = isDel ? "-" : isAdd ? "+" : " ";
1077
- const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
1091
+ const num = isDel
1092
+ ? line.oldNum
1093
+ : isAdd
1094
+ ? line.newNum
1095
+ : side === "left"
1096
+ ? line.oldNum
1097
+ : line.newNum;
1078
1098
  // Border bar + colored line numbers for changed lines
1079
1099
  const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
1080
1100
  const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : ` ${BG_BASE}`;
@@ -1084,13 +1104,13 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1084
1104
  body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
1085
1105
  }
1086
1106
  else if (isDel || isAdd) {
1087
- body = `${cBg}${hl}`;
1107
+ body = injectBg(hl, [], cBg, cBg);
1088
1108
  }
1089
1109
  else {
1090
1110
  body = `${BG_BASE}${DIM}${hl}`;
1091
1111
  }
1092
- const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `;
1093
- const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${RST} ${FG_RULE}│${RST} `;
1112
+ const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${gBg} ${sFg}${sign}${gBg} ${RST}`;
1113
+ const contGutter = `${border}${gBg}${" ".repeat(nw + 3)}${RST}`;
1094
1114
  const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg);
1095
1115
  return { gutter, contGutter, bodyRows };
1096
1116
  }
@@ -1098,8 +1118,7 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1098
1118
  // Column headers — "old" / "new" positioned above line numbers
1099
1119
  const hdrOld = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}`;
1100
1120
  const hdrNew = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}`;
1101
- out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`);
1102
- out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
1121
+ out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw))}${hdrNew}`);
1103
1122
  for (const r of vis) {
1104
1123
  const leftLine = r.left, rightLine = r.right;
1105
1124
  const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
@@ -1124,20 +1143,19 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1124
1143
  lResult = half_build(leftLine, lhl, null, "left");
1125
1144
  rResult = half_build(rightLine, rhl, null, "right");
1126
1145
  }
1127
- // Compose wrapped rows pad shorter side with striped continuation rows
1146
+ // Compose wrapped rows; missing sides render empty to avoid dead-column gaps.
1128
1147
  const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
1129
- const leftIsEmpty = !r.left;
1130
- const rightIsEmpty = !r.right;
1131
1148
  for (let row = 0; row < maxRows; row++) {
1132
1149
  const lg = row === 0 ? lResult.gutter : lResult.contGutter;
1133
1150
  const rg = row === 0 ? rResult.gutter : rResult.contGutter;
1134
- const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1135
- const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1136
- out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
1151
+ const lb = lResult.bodyRows[row] ?? "";
1152
+ const rb = rResult.bodyRows[row] ?? "";
1153
+ if (!lg && !rg && !lb && !rb)
1154
+ continue;
1155
+ out.push(`${lg}${lb.trimEnd()}${rg}${rb.trimEnd()}`);
1137
1156
  stripeRow++;
1138
1157
  }
1139
1158
  }
1140
- out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
1141
1159
  if (rows.length > vis.length) {
1142
1160
  out.push(`${BG_BASE}${FG_DIM} … ${rows.length - vis.length} more lines${RST}`);
1143
1161
  }
@@ -1180,6 +1198,70 @@ export default async function diffRendererExtension(pi) {
1180
1198
  const cwd = process.cwd();
1181
1199
  const home = process.env.HOME ?? "";
1182
1200
  const sp = (p) => shortPath(cwd, home, p);
1201
+ const TOOL_HEADER_LEFT_PAD = 2;
1202
+ const TOOL_HEADER_TOP_PAD = 1;
1203
+ const TOOL_PREVIEW_BOTTOM_PAD = 1;
1204
+ const DIFF_BODY_LEFT_PAD = 1;
1205
+ function resolvePreviewDiffColors(theme) {
1206
+ resolveDiffColors(theme);
1207
+ return resolveSharedDiffColors(theme);
1208
+ }
1209
+ function bgLine(content, width) {
1210
+ const renderWidth = Math.max(1, width);
1211
+ const padding = " ".repeat(Math.max(0, renderWidth - strip(content).length));
1212
+ return injectBg(`${content}${padding}`, [], BG_BASE, BG_BASE);
1213
+ }
1214
+ function formatToolHeader(summary, width) {
1215
+ const leftPad = " ".repeat(TOOL_HEADER_LEFT_PAD);
1216
+ const meta = `${leftPad}${summary}`;
1217
+ return bgLine(meta, width);
1218
+ }
1219
+ function formatToolTitle(label, filePath, theme, width, suffix = "") {
1220
+ const leftPad = " ".repeat(TOOL_HEADER_LEFT_PAD);
1221
+ const displayPath = filePath ? ` ${theme.fg("accent", sp(filePath))}` : "";
1222
+ const top = Array.from({ length: TOOL_HEADER_TOP_PAD }, () => bgLine("", width));
1223
+ return [
1224
+ ...top,
1225
+ bgLine(`${leftPad}${theme.fg("toolTitle", theme.bold(label))}${displayPath}${suffix}`, width),
1226
+ ].join("\n");
1227
+ }
1228
+ function formatBottomPadding(width) {
1229
+ return Array.from({ length: TOOL_PREVIEW_BOTTOM_PAD }, () => bgLine("", width)).join("\n");
1230
+ }
1231
+ function padDiffBody(rendered) {
1232
+ const leftPad = `${BG_BASE}${" ".repeat(DIFF_BODY_LEFT_PAD)}${RST}`;
1233
+ return rendered
1234
+ .split("\n")
1235
+ .map((line) => `${leftPad}${line}`)
1236
+ .join("\n");
1237
+ }
1238
+ async function renderPaddedDiff(diff, language, maxLines, colors, width) {
1239
+ const bodyWidth = Math.max(1, width - DIFF_BODY_LEFT_PAD);
1240
+ return padDiffBody(await renderSharedSplit(diff, language, maxLines, colors, bodyWidth));
1241
+ }
1242
+ function diffLineCountLabel(diffLineCount, theme) {
1243
+ if (typeof diffLineCount !== "number")
1244
+ return "";
1245
+ return ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}`;
1246
+ }
1247
+ function setToolHeaderText(text, meta, theme) {
1248
+ resolvePreviewDiffColors(theme);
1249
+ text.__piDiffTask = undefined;
1250
+ const width = termW();
1251
+ text.setText(`${formatToolHeader(meta, width)}\n${formatBottomPadding(width)}`);
1252
+ }
1253
+ function setDiffPreviewTask(text, keyPrefix, meta, diff, language, maxLines, theme, ctx) {
1254
+ const themeKey = sharedThemeCacheKey(theme);
1255
+ const colors = resolvePreviewDiffColors(theme);
1256
+ const header = (width) => formatToolHeader(meta, width);
1257
+ text.__piDiffTask = {
1258
+ placeholder: `${header(termW())}\n${padDiffBody(theme.fg("muted", "rendering diff…"))}\n${formatBottomPadding(termW())}`,
1259
+ fallback: `${header(termW())}\n${formatBottomPadding(termW())}`,
1260
+ invalidate: ctx.invalidate,
1261
+ key: (width) => `${keyPrefix}:${themeKey}:${width}:${meta}:${diff.lines.length}:${language ?? ""}`,
1262
+ render: async (width) => `${header(width)}\n${await renderPaddedDiff(diff, language, maxLines, colors, width)}\n${formatBottomPadding(width)}`,
1263
+ };
1264
+ }
1183
1265
  /** Wrap a Text component so its render(width) kicks off async diff rendering
1184
1266
  * using the real TUI width (which accounts for the sidebar). */
1185
1267
  function getWidthAwareText(lastComponent) {
@@ -1214,7 +1296,7 @@ export default async function diffRendererExtension(pi) {
1214
1296
  });
1215
1297
  }
1216
1298
  }
1217
- return text.__piDiffRender(width);
1299
+ return text.__piDiffRender?.(width) ?? [];
1218
1300
  };
1219
1301
  return text;
1220
1302
  }
@@ -1263,7 +1345,11 @@ Examples:
1263
1345
  try {
1264
1346
  const { existingCode, filePath, patchText, fileContent } = params ?? {};
1265
1347
  if (!existingCode || !filePath) {
1266
- return { content: [{ type: "text", text: "Error: existingCode and filePath are required" }] };
1348
+ return {
1349
+ content: [
1350
+ { type: "text", text: "Error: existingCode and filePath are required" },
1351
+ ],
1352
+ };
1267
1353
  }
1268
1354
  // Fetch git diff if patchText not provided
1269
1355
  let patch = patchText;
@@ -1289,25 +1375,42 @@ Examples:
1289
1375
  }
1290
1376
  catch {
1291
1377
  return {
1292
- content: [{ type: "text", text: `Error: could not read git diff for ${filePath}` }],
1378
+ content: [
1379
+ { type: "text", text: `Error: could not read git diff for ${filePath}` },
1380
+ ],
1293
1381
  };
1294
1382
  }
1295
1383
  }
1296
1384
  if (!patch) {
1297
1385
  return {
1298
- content: [{ type: "text", text: `No diff found for ${filePath} — file may not have changes` }],
1386
+ content: [
1387
+ {
1388
+ type: "text",
1389
+ text: `No diff found for ${filePath} — file may not have changes`,
1390
+ },
1391
+ ],
1299
1392
  details: { unresolved: true },
1300
1393
  };
1301
1394
  }
1302
1395
  const result = resolveLinesFromPatch(existingCode, patch, fileContent);
1303
1396
  if ("unresolved" in result) {
1304
1397
  return {
1305
- content: [{ type: "text", text: `Could not resolve line numbers for existing_code in ${filePath}` }],
1398
+ content: [
1399
+ {
1400
+ type: "text",
1401
+ text: `Could not resolve line numbers for existing_code in ${filePath}`,
1402
+ },
1403
+ ],
1306
1404
  details: { unresolved: true },
1307
1405
  };
1308
1406
  }
1309
1407
  return {
1310
- content: [{ type: "text", text: `Resolved ${filePath}:${result.startLine}-${result.endLine}` }],
1408
+ content: [
1409
+ {
1410
+ type: "text",
1411
+ text: `Resolved ${filePath}:${result.startLine}-${result.endLine}`,
1412
+ },
1413
+ ],
1311
1414
  details: { startLine: result.startLine, endLine: result.endLine },
1312
1415
  };
1313
1416
  }
@@ -1367,11 +1470,24 @@ Examples:
1367
1470
  const useFull = !!params._expandGaps;
1368
1471
  const diff = parseDiff(old, content, useFull ? undefined : 3);
1369
1472
  const lg = detectDiffLanguage(fp);
1370
- result.details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg, oldContent: old, newContent: content };
1473
+ result.details = {
1474
+ _type: "diff",
1475
+ summary: summarize(diff.added, diff.removed),
1476
+ filePath: fp,
1477
+ diff,
1478
+ language: lg,
1479
+ oldContent: old,
1480
+ newContent: content,
1481
+ };
1371
1482
  }
1372
1483
  else if (old === null) {
1373
1484
  const lineCount = content ? content.split("\n").length : 0;
1374
- result.details = { _type: "new", lines: lineCount, content: content ?? "", filePath: fp };
1485
+ result.details = {
1486
+ _type: "new",
1487
+ lines: lineCount,
1488
+ content: content ?? "",
1489
+ filePath: fp,
1490
+ };
1375
1491
  }
1376
1492
  else if (old === content) {
1377
1493
  result.details = { _type: "noChange" };
@@ -1431,15 +1547,7 @@ Examples:
1431
1547
  }
1432
1548
  const d = result.details;
1433
1549
  if (d?._type === "diff") {
1434
- const themeKey = sharedThemeCacheKey(theme);
1435
- const dc = resolveSharedDiffColors(theme);
1436
- text.__piDiffTask = {
1437
- placeholder: ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`,
1438
- fallback: ` ${d.summary}`,
1439
- invalidate: ctx.invalidate,
1440
- key: (width) => `wd:${themeKey}:${width}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`,
1441
- render: async (width) => ` ${d.summary}\n${await renderSharedSplit(d.diff, d.language, MAX_RENDER_LINES, dc, width)}`,
1442
- };
1550
+ setDiffPreviewTask(text, "wd", d.summary, d.diff, d.language, MAX_RENDER_LINES, theme, ctx);
1443
1551
  return text;
1444
1552
  }
1445
1553
  if (d?._type === "noChange") {
@@ -1486,13 +1594,29 @@ Examples:
1486
1594
  if (Array.isArray(input?.edits)) {
1487
1595
  return input.edits
1488
1596
  .map((edit) => ({
1489
- oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
1490
- newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
1597
+ oldText: typeof edit?.oldText === "string"
1598
+ ? edit.oldText
1599
+ : typeof edit?.old_text === "string"
1600
+ ? edit.old_text
1601
+ : "",
1602
+ newText: typeof edit?.newText === "string"
1603
+ ? edit.newText
1604
+ : typeof edit?.new_text === "string"
1605
+ ? edit.new_text
1606
+ : "",
1491
1607
  }))
1492
1608
  .filter((edit) => edit.oldText && edit.oldText !== edit.newText);
1493
1609
  }
1494
- const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
1495
- const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1610
+ const oldText = typeof input?.oldText === "string"
1611
+ ? input.oldText
1612
+ : typeof input?.old_text === "string"
1613
+ ? input.old_text
1614
+ : "";
1615
+ const newText = typeof input?.newText === "string"
1616
+ ? input.newText
1617
+ : typeof input?.new_text === "string"
1618
+ ? input.new_text
1619
+ : "";
1496
1620
  return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1497
1621
  }
1498
1622
  function summarizeEditOperations(operations) {
@@ -1553,6 +1677,7 @@ Examples:
1553
1677
  details: {
1554
1678
  _type: "editInfo",
1555
1679
  summary,
1680
+ filePath: fp,
1556
1681
  editLine,
1557
1682
  diff: diffData,
1558
1683
  language: lg,
@@ -1565,7 +1690,16 @@ Examples:
1565
1690
  // Merge all diffs into one combined view for rendering
1566
1691
  const merged = {
1567
1692
  lines: diffs.flatMap((diff, i) => [
1568
- ...(i > 0 ? [{ type: "sep", oldNum: null, newNum: null, content: `───── Edit ${i + 1} ─────` }] : []),
1693
+ ...(i > 0
1694
+ ? [
1695
+ {
1696
+ type: "sep",
1697
+ oldNum: null,
1698
+ newNum: null,
1699
+ content: `───── Edit ${i + 1} ─────`,
1700
+ },
1701
+ ]
1702
+ : []),
1569
1703
  ...diff.lines,
1570
1704
  ]),
1571
1705
  added: diffs.reduce((sum, diff) => sum + diff.added, 0),
@@ -1577,6 +1711,7 @@ Examples:
1577
1711
  details: {
1578
1712
  _type: "multiEditInfo",
1579
1713
  summary,
1714
+ filePath: fp,
1580
1715
  editCount: operations.length,
1581
1716
  diffLineCount: merged.lines.length,
1582
1717
  diff: merged,
@@ -1615,6 +1750,7 @@ Examples:
1615
1750
  result.details = {
1616
1751
  _type: "editInfo",
1617
1752
  summary,
1753
+ filePath: fp,
1618
1754
  editLine,
1619
1755
  diff: diffData,
1620
1756
  language: lg,
@@ -1627,7 +1763,16 @@ Examples:
1627
1763
  const merged = {
1628
1764
  lines: diffs.flatMap((diff, i) => [
1629
1765
  // Add separator between multiple edits
1630
- ...(i > 0 ? [{ type: "sep", oldNum: null, newNum: null, content: `───── Edit ${i + 1} ─────` }] : []),
1766
+ ...(i > 0
1767
+ ? [
1768
+ {
1769
+ type: "sep",
1770
+ oldNum: null,
1771
+ newNum: null,
1772
+ content: `───── Edit ${i + 1} ─────`,
1773
+ },
1774
+ ]
1775
+ : []),
1631
1776
  ...diff.lines,
1632
1777
  ]),
1633
1778
  added: diffs.reduce((sum, diff) => sum + diff.added, 0),
@@ -1637,6 +1782,7 @@ Examples:
1637
1782
  result.details = {
1638
1783
  _type: "multiEditInfo",
1639
1784
  summary,
1785
+ filePath: fp,
1640
1786
  editCount: operations.length,
1641
1787
  diffLineCount: merged.lines.length,
1642
1788
  diff: merged,
@@ -1648,13 +1794,13 @@ Examples:
1648
1794
  const fp = args?.path ?? args?.file_path ?? "";
1649
1795
  const operations = getEditOperations(args);
1650
1796
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1651
- const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1797
+ resolvePreviewDiffColors(theme);
1652
1798
  if (ctx.argsComplete && operations.length > 0) {
1653
1799
  const { totalAdded, totalRemoved } = summarizeEditOperations(operations);
1654
- text.setText(`${hdr} ${theme.fg("muted", summarize(totalAdded, totalRemoved))}`);
1800
+ text.setText(formatToolTitle("edit", fp, theme, termW(), ` ${theme.fg("muted", summarize(totalAdded, totalRemoved))}`));
1655
1801
  }
1656
1802
  else {
1657
- text.setText(hdr);
1803
+ text.setText(formatToolTitle("edit", fp, theme, termW()));
1658
1804
  }
1659
1805
  return text;
1660
1806
  },
@@ -1671,42 +1817,24 @@ Examples:
1671
1817
  }
1672
1818
  const d = result.details;
1673
1819
  if (d?._type === "editInfo" && d.diff) {
1674
- const themeKey = sharedThemeCacheKey(theme);
1675
- const dc = resolveSharedDiffColors(theme);
1676
1820
  const loc = d.editLine > 0 ? ` ${theme.fg("muted", `at line ${d.editLine}`)}` : "";
1677
- text.__piDiffTask = {
1678
- placeholder: ` ${d.summary}${loc}\n${theme.fg("muted", " rendering diff…")}`,
1679
- fallback: ` ${d.summary}${loc}`,
1680
- invalidate: ctx.invalidate,
1681
- key: (width) => `ed:${themeKey}:${width}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`,
1682
- render: async (width) => ` ${d.summary}${loc}\n${await renderSharedSplit(d.diff, d.language, MAX_PREVIEW_LINES, dc, width)}`,
1683
- };
1821
+ setDiffPreviewTask(text, "ed", `${d.summary}${loc}`, d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx);
1684
1822
  return text;
1685
1823
  }
1686
1824
  if (d?._type === "editInfo") {
1687
1825
  const { summary: s, editLine } = d;
1688
1826
  const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
1689
- text.__piDiffTask = undefined;
1690
- text.setText(` ${s}${loc}`);
1827
+ setToolHeaderText(text, `${s}${loc}`, theme);
1691
1828
  return text;
1692
1829
  }
1693
1830
  if (d?._type === "multiEditInfo") {
1694
1831
  const { summary: s, editCount, diffLineCount, diff, language } = d;
1832
+ const meta = `${editCount} edits ${s}${diffLineCountLabel(diffLineCount, theme)}`;
1695
1833
  if (diff) {
1696
- const themeKey = sharedThemeCacheKey(theme);
1697
- const dc = resolveSharedDiffColors(theme);
1698
- text.__piDiffTask = {
1699
- placeholder: ` ${editCount} edits ${s}
1700
- ${theme.fg("muted", " rendering diff…")}`,
1701
- fallback: ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`,
1702
- invalidate: ctx.invalidate,
1703
- key: (width) => `me:${themeKey}:${width}:${s}:${diff?.lines?.length ?? 0}:${language ?? ""}`,
1704
- render: async (width) => ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}\n${await renderSharedSplit(diff, language, MAX_PREVIEW_LINES, dc, width)}`,
1705
- };
1834
+ setDiffPreviewTask(text, "me", meta, diff, language, MAX_PREVIEW_LINES, theme, ctx);
1706
1835
  return text;
1707
1836
  }
1708
- text.__piDiffTask = undefined;
1709
- text.setText(` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`);
1837
+ setToolHeaderText(text, meta, theme);
1710
1838
  return text;
1711
1839
  }
1712
1840
  text.__piDiffTask = undefined;