@heyhuynhgiabuu/pi-diff 0.5.5 → 0.6.1

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
@@ -20,12 +20,13 @@
20
20
  * • Large-diff fallback (skip highlighting, still show diff)
21
21
  * • Async rendering with invalidate() for non-blocking preview
22
22
  */
23
- import { existsSync, readFileSync } from "node:fs";
23
+ 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
32
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
@@ -33,12 +34,12 @@ const DIFF_PRESETS = {
33
34
  default: {
34
35
  name: "default",
35
36
  description: "Original pi-diff colors — tuned for dark theme bases (~#1e1e2e)",
36
- bgAdd: "#162620",
37
- bgDel: "#2d1919",
38
- bgAddHighlight: "#234b32",
39
- bgDelHighlight: "#502323",
40
- bgGutterAdd: "#12201a",
41
- bgGutterDel: "#261616",
37
+ bgAdd: "#1a3324",
38
+ bgDel: "#3d2020",
39
+ bgAddHighlight: "#2d5c3a",
40
+ bgDelHighlight: "#5c2d2d",
41
+ bgGutterAdd: "#16281e",
42
+ bgGutterDel: "#301c1c",
42
43
  bgEmpty: "#121212",
43
44
  fgDim: "#505050",
44
45
  fgLnum: "#646464",
@@ -49,12 +50,12 @@ const DIFF_PRESETS = {
49
50
  midnight: {
50
51
  name: "midnight",
51
52
  description: "Subtle tints for pure black (#000000) terminal backgrounds",
52
- bgAdd: "#0d1a12",
53
- bgDel: "#1a0d0d",
53
+ bgAdd: "#101e15",
54
+ bgDel: "#1e1010",
54
55
  bgAddHighlight: "#1a3825",
55
56
  bgDelHighlight: "#381a1a",
56
- bgGutterAdd: "#091208",
57
- bgGutterDel: "#120908",
57
+ bgGutterAdd: "#0c180c",
58
+ bgGutterDel: "#180c0c",
58
59
  bgEmpty: "#080808",
59
60
  fgDim: "#404040",
60
61
  fgLnum: "#505050",
@@ -65,12 +66,12 @@ const DIFF_PRESETS = {
65
66
  subtle: {
66
67
  name: "subtle",
67
68
  description: "Minimal backgrounds — barely-there tints for a clean look",
68
- bgAdd: "#081008",
69
- bgDel: "#100808",
69
+ bgAdd: "#0c160c",
70
+ bgDel: "#160c0c",
70
71
  bgAddHighlight: "#122818",
71
72
  bgDelHighlight: "#281212",
72
- bgGutterAdd: "#060c06",
73
- bgGutterDel: "#0c0606",
73
+ bgGutterAdd: "#080e08",
74
+ bgGutterDel: "#0e0808",
74
75
  bgEmpty: "#060606",
75
76
  fgDim: "#383838",
76
77
  fgLnum: "#484848",
@@ -81,12 +82,12 @@ const DIFF_PRESETS = {
81
82
  neon: {
82
83
  name: "neon",
83
84
  description: "Higher contrast backgrounds for better visibility",
84
- bgAdd: "#1a3320",
85
- bgDel: "#331a16",
85
+ bgAdd: "#1e3a28",
86
+ bgDel: "#3a1c18",
86
87
  bgAddHighlight: "#2d5c3a",
87
88
  bgDelHighlight: "#5c2d2d",
88
- bgGutterAdd: "#142818",
89
- bgGutterDel: "#28120e",
89
+ bgGutterAdd: "#182c1c",
90
+ bgGutterDel: "#2c1410",
90
91
  bgEmpty: "#141414",
91
92
  fgDim: "#606060",
92
93
  fgLnum: "#787878",
@@ -181,15 +182,15 @@ function autoDeriveBgFromTheme(theme) {
181
182
  /* no toolErrorBg — use toolSuccessBg/black */
182
183
  }
183
184
  }
184
- // Line backgrounds — subtle accent mixed into the matching tool-state base (810%)
185
- BG_ADD = mixBg(addBase, addRgb, 0.08);
186
- BG_DEL = mixBg(delBase, delRgb, 0.1);
187
- // Word-level highlights — more visible (2022%)
188
- BG_ADD_W = mixBg(addBase, addRgb, 0.2);
189
- BG_DEL_W = mixBg(delBase, delRgb, 0.22);
190
- // Gutters — subtler than lines (56%)
191
- BG_GUTTER_ADD = mixBg(addBase, addRgb, 0.05);
192
- 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);
193
194
  // Empty filler and context — match the success/context base
194
195
  BG_EMPTY = BG_BASE;
195
196
  // Update RST to re-apply base bg after every reset — prevents black
@@ -204,7 +205,10 @@ function autoDeriveBgFromTheme(theme) {
204
205
  }
205
206
  /** Load diff theme config from .pi/settings.json (project-level, then global). */
206
207
  function loadDiffConfig() {
207
- 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
+ ];
208
212
  for (const p of paths) {
209
213
  try {
210
214
  if (existsSync(p)) {
@@ -362,16 +366,15 @@ const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
362
366
  // ANSI
363
367
  // ---------------------------------------------------------------------------
364
368
  let RST = "\x1b[0m";
365
- const BOLD = "\x1b[1m";
366
369
  const DIM = "\x1b[2m";
367
370
  // Subtle diff backgrounds — muted tones to let syntax fg shine through
368
371
  // Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
369
- let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
370
- let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
371
- let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
372
- let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
373
- let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
374
- 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");
375
378
  const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
376
379
  let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
377
380
  // Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
@@ -675,7 +678,7 @@ function shouldUseSplit(diff, tw, maxRows = MAX_PREVIEW_LINES) {
675
678
  return false;
676
679
  const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
677
680
  const half = Math.floor((tw - 1) / 2); // -1 for center divider
678
- const gw = nw + 5; // border + num + sign + sp + │ + sp
681
+ const gw = nw + 4; // border + num + spaces around sign
679
682
  const cw = Math.max(12, half - gw);
680
683
  if (cw < SPLIT_MIN_CODE_WIDTH)
681
684
  return false;
@@ -827,8 +830,6 @@ function wordDiffAnalysis(a, b) {
827
830
  * Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
828
831
  */
829
832
  function injectBg(ansiLine, ranges, baseBg, hlBg) {
830
- if (!ranges.length)
831
- return baseBg + ansiLine + RST;
832
833
  let out = baseBg;
833
834
  let vis = 0;
834
835
  let inHL = false;
@@ -897,7 +898,7 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
897
898
  const vis = diff.lines.slice(0, max);
898
899
  const tw = termW();
899
900
  const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
900
- const gw = nw + 5; // border + num + sign + sp + │ + sp
901
+ const gw = nw + 4; // border + num + spaces around sign
901
902
  const cw = Math.max(20, tw - gw);
902
903
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
903
904
  // Build separate old/new code blocks for highlighting
@@ -909,18 +910,20 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
909
910
  newSrc.push(l.content);
910
911
  }
911
912
  const [oldHL, newHL] = canHL
912
- ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
913
+ ? await Promise.all([
914
+ hlBlock(oldSrc.join("\n"), language),
915
+ hlBlock(newSrc.join("\n"), language),
916
+ ])
913
917
  : [oldSrc, newSrc];
914
918
  let oI = 0, nI = 0, idx = 0;
915
919
  const out = [];
916
- out.push(rule(tw));
917
920
  /** Emit a single stacked row with compact gutter + left border bar. */
918
921
  function emitRow(num, sign, gutterBg, signFg, body, bodyBg = "") {
919
922
  const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
920
923
  const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : `${BG_BASE} `;
921
924
  const numFg = borderFg || FG_LNUM;
922
- const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `;
923
- const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `;
925
+ const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${gutterBg} ${signFg}${sign}${gutterBg} ${RST}`;
926
+ const contGutter = `${border}${gutterBg}${" ".repeat(nw + 3)}${RST}`;
924
927
  const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg);
925
928
  out.push(`${gutter}${rows[0]}${RST}`);
926
929
  for (let r = 1; r < rows.length; r++)
@@ -971,27 +974,26 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
971
974
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
972
975
  const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W);
973
976
  const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W);
974
- emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL);
975
- emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD);
977
+ emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, dc.fgDel, delBody, BG_DEL);
978
+ emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, dc.fgAdd, addBody, BG_ADD);
976
979
  continue;
977
980
  }
978
981
  if (isPaired && wdBalanced && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
979
982
  const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
980
- emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL);
981
- emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD);
983
+ emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, dc.fgDel, `${BG_DEL}${pwd.old}`, BG_DEL);
984
+ emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, dc.fgAdd, `${BG_ADD}${pwd.new}`, BG_ADD);
982
985
  continue;
983
986
  }
984
987
  // Multi-line blocks — syntax highlighted with diff bg
985
988
  for (const d of dels) {
986
- const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`;
987
- emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL);
989
+ const body = canHL ? injectBg(d.hl, [], BG_DEL, BG_DEL) : `${BG_DEL}${d.l.content}`;
990
+ emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, dc.fgDel, body, BG_DEL);
988
991
  }
989
992
  for (const a of adds) {
990
- const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`;
991
- emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD);
993
+ const body = canHL ? injectBg(a.hl, [], BG_ADD, BG_ADD) : `${BG_ADD}${a.l.content}`;
994
+ emitRow(a.l.newNum, "+", BG_GUTTER_ADD, dc.fgAdd, body, BG_ADD);
992
995
  }
993
996
  }
994
- out.push(rule(tw));
995
997
  if (diff.lines.length > vis.length) {
996
998
  out.push(`${BG_BASE}${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`);
997
999
  }
@@ -1038,7 +1040,7 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1038
1040
  const vis = rows.slice(0, max);
1039
1041
  const half = Math.floor((tw - 1) / 2); // -1 for center divider
1040
1042
  const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
1041
- const gw = nw + 5; // border + num + sign + sp + │ + sp
1043
+ const gw = nw + 4; // border + num + spaces around sign
1042
1044
  const cw = Math.max(12, half - gw);
1043
1045
  const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
1044
1046
  // Build separate code blocks per side
@@ -1050,22 +1052,25 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1050
1052
  rightSrc.push(r.right.content);
1051
1053
  }
1052
1054
  const [leftHL, rightHL] = canHL
1053
- ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
1055
+ ? await Promise.all([
1056
+ hlBlock(leftSrc.join("\n"), language),
1057
+ hlBlock(rightSrc.join("\n"), language),
1058
+ ])
1054
1059
  : [leftSrc, rightSrc];
1055
1060
  let lI = 0, rI = 0;
1056
1061
  let stripeRow = 0; // tracks row index for diagonal stripe offset
1057
1062
  function half_build(line, hl, ranges, side) {
1058
1063
  // Empty filler — diagonal stripes
1059
1064
  if (!line) {
1060
- const gw2 = nw + 2; // number + sign + space before
1065
+ const gw2 = nw + 4; // border + number + spaces around sign
1061
1066
  const gPat = FG_STRIPE + "╱".repeat(gw2) + RST;
1062
- const g = ` ${gPat}${FG_RULE}│${RST} `;
1067
+ const g = gPat;
1063
1068
  return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
1064
1069
  }
1065
1070
  // Hunk separator with optional function context
1066
1071
  if (line.type === "sep") {
1067
1072
  const label = sepLabelSplit(getSepStyle(), line.hunkMeta, line.newNum, line.content);
1068
- const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
1073
+ const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 3)}${RST}`;
1069
1074
  return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
1070
1075
  }
1071
1076
  const isDel = line.type === "del", isAdd = line.type === "add";
@@ -1073,7 +1078,13 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1073
1078
  const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
1074
1079
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
1075
1080
  const sign = isDel ? "-" : isAdd ? "+" : " ";
1076
- const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
1081
+ const num = isDel
1082
+ ? line.oldNum
1083
+ : isAdd
1084
+ ? line.newNum
1085
+ : side === "left"
1086
+ ? line.oldNum
1087
+ : line.newNum;
1077
1088
  // Border bar + colored line numbers for changed lines
1078
1089
  const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
1079
1090
  const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : ` ${BG_BASE}`;
@@ -1083,13 +1094,13 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1083
1094
  body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
1084
1095
  }
1085
1096
  else if (isDel || isAdd) {
1086
- body = `${cBg}${hl}`;
1097
+ body = injectBg(hl, [], cBg, cBg);
1087
1098
  }
1088
1099
  else {
1089
1100
  body = `${BG_BASE}${DIM}${hl}`;
1090
1101
  }
1091
- const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `;
1092
- const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${RST} ${FG_RULE}│${RST} `;
1102
+ const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${gBg} ${sFg}${sign}${gBg} ${RST}`;
1103
+ const contGutter = `${border}${gBg}${" ".repeat(nw + 3)}${RST}`;
1093
1104
  const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg);
1094
1105
  return { gutter, contGutter, bodyRows };
1095
1106
  }
@@ -1098,7 +1109,6 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1098
1109
  const hdrOld = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}`;
1099
1110
  const hdrNew = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}`;
1100
1111
  out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`);
1101
- out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
1102
1112
  for (const r of vis) {
1103
1113
  const leftLine = r.left, rightLine = r.right;
1104
1114
  const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
@@ -1130,13 +1140,14 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1130
1140
  for (let row = 0; row < maxRows; row++) {
1131
1141
  const lg = row === 0 ? lResult.gutter : lResult.contGutter;
1132
1142
  const rg = row === 0 ? rResult.gutter : rResult.contGutter;
1133
- const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1134
- const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1143
+ const lb = lResult.bodyRows[row] ??
1144
+ (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1145
+ const rb = rResult.bodyRows[row] ??
1146
+ (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
1135
1147
  out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
1136
1148
  stripeRow++;
1137
1149
  }
1138
1150
  }
1139
- out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
1140
1151
  if (rows.length > vis.length) {
1141
1152
  out.push(`${BG_BASE}${FG_DIM} … ${rows.length - vis.length} more lines${RST}`);
1142
1153
  }
@@ -1179,6 +1190,70 @@ export default async function diffRendererExtension(pi) {
1179
1190
  const cwd = process.cwd();
1180
1191
  const home = process.env.HOME ?? "";
1181
1192
  const sp = (p) => shortPath(cwd, home, p);
1193
+ const TOOL_HEADER_LEFT_PAD = 2;
1194
+ const TOOL_HEADER_TOP_PAD = 1;
1195
+ const TOOL_PREVIEW_BOTTOM_PAD = 1;
1196
+ const DIFF_BODY_LEFT_PAD = 1;
1197
+ function resolvePreviewDiffColors(theme) {
1198
+ resolveDiffColors(theme);
1199
+ return resolveSharedDiffColors(theme);
1200
+ }
1201
+ function bgLine(content, width) {
1202
+ const renderWidth = Math.max(1, width);
1203
+ const padding = " ".repeat(Math.max(0, renderWidth - strip(content).length));
1204
+ return injectBg(`${content}${padding}`, [], BG_BASE, BG_BASE);
1205
+ }
1206
+ function formatToolHeader(summary, width) {
1207
+ const leftPad = " ".repeat(TOOL_HEADER_LEFT_PAD);
1208
+ const meta = `${leftPad}${summary}`;
1209
+ return bgLine(meta, width);
1210
+ }
1211
+ function formatToolTitle(label, filePath, theme, width, suffix = "") {
1212
+ const leftPad = " ".repeat(TOOL_HEADER_LEFT_PAD);
1213
+ const displayPath = filePath ? ` ${theme.fg("accent", sp(filePath))}` : "";
1214
+ const top = Array.from({ length: TOOL_HEADER_TOP_PAD }, () => bgLine("", width));
1215
+ return [
1216
+ ...top,
1217
+ bgLine(`${leftPad}${theme.fg("toolTitle", theme.bold(label))}${displayPath}${suffix}`, width),
1218
+ ].join("\n");
1219
+ }
1220
+ function formatBottomPadding(width) {
1221
+ return Array.from({ length: TOOL_PREVIEW_BOTTOM_PAD }, () => bgLine("", width)).join("\n");
1222
+ }
1223
+ function padDiffBody(rendered) {
1224
+ const leftPad = `${BG_BASE}${" ".repeat(DIFF_BODY_LEFT_PAD)}${RST}`;
1225
+ return rendered
1226
+ .split("\n")
1227
+ .map((line) => `${leftPad}${line}`)
1228
+ .join("\n");
1229
+ }
1230
+ async function renderPaddedDiff(diff, language, maxLines, colors, width) {
1231
+ const bodyWidth = Math.max(1, width - DIFF_BODY_LEFT_PAD);
1232
+ return padDiffBody(await renderSharedSplit(diff, language, maxLines, colors, bodyWidth));
1233
+ }
1234
+ function diffLineCountLabel(diffLineCount, theme) {
1235
+ if (typeof diffLineCount !== "number")
1236
+ return "";
1237
+ return ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}`;
1238
+ }
1239
+ function setToolHeaderText(text, meta, theme) {
1240
+ resolvePreviewDiffColors(theme);
1241
+ text.__piDiffTask = undefined;
1242
+ const width = termW();
1243
+ text.setText(`${formatToolHeader(meta, width)}\n${formatBottomPadding(width)}`);
1244
+ }
1245
+ function setDiffPreviewTask(text, keyPrefix, meta, diff, language, maxLines, theme, ctx) {
1246
+ const themeKey = sharedThemeCacheKey(theme);
1247
+ const colors = resolvePreviewDiffColors(theme);
1248
+ const header = (width) => formatToolHeader(meta, width);
1249
+ text.__piDiffTask = {
1250
+ placeholder: `${header(termW())}\n${padDiffBody(theme.fg("muted", "rendering diff…"))}\n${formatBottomPadding(termW())}`,
1251
+ fallback: `${header(termW())}\n${formatBottomPadding(termW())}`,
1252
+ invalidate: ctx.invalidate,
1253
+ key: (width) => `${keyPrefix}:${themeKey}:${width}:${meta}:${diff.lines.length}:${language ?? ""}`,
1254
+ render: async (width) => `${header(width)}\n${await renderPaddedDiff(diff, language, maxLines, colors, width)}\n${formatBottomPadding(width)}`,
1255
+ };
1256
+ }
1182
1257
  /** Wrap a Text component so its render(width) kicks off async diff rendering
1183
1258
  * using the real TUI width (which accounts for the sidebar). */
1184
1259
  function getWidthAwareText(lastComponent) {
@@ -1213,7 +1288,7 @@ export default async function diffRendererExtension(pi) {
1213
1288
  });
1214
1289
  }
1215
1290
  }
1216
- return text.__piDiffRender(width);
1291
+ return text.__piDiffRender?.(width) ?? [];
1217
1292
  };
1218
1293
  return text;
1219
1294
  }
@@ -1262,7 +1337,11 @@ Examples:
1262
1337
  try {
1263
1338
  const { existingCode, filePath, patchText, fileContent } = params ?? {};
1264
1339
  if (!existingCode || !filePath) {
1265
- return { content: [{ type: "text", text: "Error: existingCode and filePath are required" }] };
1340
+ return {
1341
+ content: [
1342
+ { type: "text", text: "Error: existingCode and filePath are required" },
1343
+ ],
1344
+ };
1266
1345
  }
1267
1346
  // Fetch git diff if patchText not provided
1268
1347
  let patch = patchText;
@@ -1288,25 +1367,42 @@ Examples:
1288
1367
  }
1289
1368
  catch {
1290
1369
  return {
1291
- content: [{ type: "text", text: `Error: could not read git diff for ${filePath}` }],
1370
+ content: [
1371
+ { type: "text", text: `Error: could not read git diff for ${filePath}` },
1372
+ ],
1292
1373
  };
1293
1374
  }
1294
1375
  }
1295
1376
  if (!patch) {
1296
1377
  return {
1297
- content: [{ type: "text", text: `No diff found for ${filePath} — file may not have changes` }],
1378
+ content: [
1379
+ {
1380
+ type: "text",
1381
+ text: `No diff found for ${filePath} — file may not have changes`,
1382
+ },
1383
+ ],
1298
1384
  details: { unresolved: true },
1299
1385
  };
1300
1386
  }
1301
1387
  const result = resolveLinesFromPatch(existingCode, patch, fileContent);
1302
1388
  if ("unresolved" in result) {
1303
1389
  return {
1304
- content: [{ type: "text", text: `Could not resolve line numbers for existing_code in ${filePath}` }],
1390
+ content: [
1391
+ {
1392
+ type: "text",
1393
+ text: `Could not resolve line numbers for existing_code in ${filePath}`,
1394
+ },
1395
+ ],
1305
1396
  details: { unresolved: true },
1306
1397
  };
1307
1398
  }
1308
1399
  return {
1309
- content: [{ type: "text", text: `Resolved ${filePath}:${result.startLine}-${result.endLine}` }],
1400
+ content: [
1401
+ {
1402
+ type: "text",
1403
+ text: `Resolved ${filePath}:${result.startLine}-${result.endLine}`,
1404
+ },
1405
+ ],
1310
1406
  details: { startLine: result.startLine, endLine: result.endLine },
1311
1407
  };
1312
1408
  }
@@ -1366,11 +1462,24 @@ Examples:
1366
1462
  const useFull = !!params._expandGaps;
1367
1463
  const diff = parseDiff(old, content, useFull ? undefined : 3);
1368
1464
  const lg = detectDiffLanguage(fp);
1369
- result.details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg, oldContent: old, newContent: content };
1465
+ result.details = {
1466
+ _type: "diff",
1467
+ summary: summarize(diff.added, diff.removed),
1468
+ filePath: fp,
1469
+ diff,
1470
+ language: lg,
1471
+ oldContent: old,
1472
+ newContent: content,
1473
+ };
1370
1474
  }
1371
1475
  else if (old === null) {
1372
1476
  const lineCount = content ? content.split("\n").length : 0;
1373
- result.details = { _type: "new", lines: lineCount, content: content ?? "", filePath: fp };
1477
+ result.details = {
1478
+ _type: "new",
1479
+ lines: lineCount,
1480
+ content: content ?? "",
1481
+ filePath: fp,
1482
+ };
1374
1483
  }
1375
1484
  else if (old === content) {
1376
1485
  result.details = { _type: "noChange" };
@@ -1430,15 +1539,7 @@ Examples:
1430
1539
  }
1431
1540
  const d = result.details;
1432
1541
  if (d?._type === "diff") {
1433
- const themeKey = sharedThemeCacheKey(theme);
1434
- const dc = resolveSharedDiffColors(theme);
1435
- text.__piDiffTask = {
1436
- placeholder: ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`,
1437
- fallback: ` ${d.summary}`,
1438
- invalidate: ctx.invalidate,
1439
- key: (width) => `wd:${themeKey}:${width}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`,
1440
- render: async (width) => ` ${d.summary}\n${await renderSharedSplit(d.diff, d.language, MAX_RENDER_LINES, dc, width)}`,
1441
- };
1542
+ setDiffPreviewTask(text, "wd", d.summary, d.diff, d.language, MAX_RENDER_LINES, theme, ctx);
1442
1543
  return text;
1443
1544
  }
1444
1545
  if (d?._type === "noChange") {
@@ -1485,13 +1586,29 @@ Examples:
1485
1586
  if (Array.isArray(input?.edits)) {
1486
1587
  return input.edits
1487
1588
  .map((edit) => ({
1488
- oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
1489
- newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
1589
+ oldText: typeof edit?.oldText === "string"
1590
+ ? edit.oldText
1591
+ : typeof edit?.old_text === "string"
1592
+ ? edit.old_text
1593
+ : "",
1594
+ newText: typeof edit?.newText === "string"
1595
+ ? edit.newText
1596
+ : typeof edit?.new_text === "string"
1597
+ ? edit.new_text
1598
+ : "",
1490
1599
  }))
1491
1600
  .filter((edit) => edit.oldText && edit.oldText !== edit.newText);
1492
1601
  }
1493
- const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
1494
- const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1602
+ const oldText = typeof input?.oldText === "string"
1603
+ ? input.oldText
1604
+ : typeof input?.old_text === "string"
1605
+ ? input.old_text
1606
+ : "";
1607
+ const newText = typeof input?.newText === "string"
1608
+ ? input.newText
1609
+ : typeof input?.new_text === "string"
1610
+ ? input.new_text
1611
+ : "";
1495
1612
  return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1496
1613
  }
1497
1614
  function summarizeEditOperations(operations) {
@@ -1511,6 +1628,95 @@ Examples:
1511
1628
  async execute(tid, params, sig, upd, ctx) {
1512
1629
  const fp = params.path ?? params.file_path ?? "";
1513
1630
  const operations = getEditOperations(params);
1631
+ // Try cascading replace() first — smarter matching than SDK's exact-only edit
1632
+ if (fp && operations.length > 0 && existsSync(fp)) {
1633
+ try {
1634
+ let content = readFileSync(fp, "utf-8");
1635
+ let firstStrategy = "";
1636
+ let replaceOk = true;
1637
+ for (const op of operations) {
1638
+ const r = replace(content, op.oldText, op.newText);
1639
+ if (r.changed) {
1640
+ content = r.content;
1641
+ if (!firstStrategy)
1642
+ firstStrategy = r.strategy;
1643
+ }
1644
+ else {
1645
+ replaceOk = false;
1646
+ break;
1647
+ }
1648
+ }
1649
+ if (replaceOk) {
1650
+ writeFileSync(fp, content, "utf-8");
1651
+ const { diffs, summary } = summarizeEditOperations(operations);
1652
+ const lg = detectDiffLanguage(fp);
1653
+ if (operations.length === 1) {
1654
+ let editLine = 0;
1655
+ try {
1656
+ const idx = content.indexOf(operations[0].newText);
1657
+ if (idx >= 0)
1658
+ editLine = content.slice(0, idx).split("\n").length;
1659
+ }
1660
+ catch {
1661
+ editLine = 0;
1662
+ }
1663
+ const useFull = !!params._expandGaps;
1664
+ const diffData = useFull
1665
+ ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1666
+ : diffs[0];
1667
+ return {
1668
+ content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1669
+ details: {
1670
+ _type: "editInfo",
1671
+ summary,
1672
+ filePath: fp,
1673
+ editLine,
1674
+ diff: diffData,
1675
+ language: lg,
1676
+ oldContent: operations[0].oldText,
1677
+ newContent: operations[0].newText,
1678
+ _replaceStrategy: firstStrategy,
1679
+ },
1680
+ };
1681
+ }
1682
+ // Merge all diffs into one combined view for rendering
1683
+ const merged = {
1684
+ lines: diffs.flatMap((diff, i) => [
1685
+ ...(i > 0
1686
+ ? [
1687
+ {
1688
+ type: "sep",
1689
+ oldNum: null,
1690
+ newNum: null,
1691
+ content: `───── Edit ${i + 1} ─────`,
1692
+ },
1693
+ ]
1694
+ : []),
1695
+ ...diff.lines,
1696
+ ]),
1697
+ added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1698
+ removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1699
+ chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1700
+ };
1701
+ return {
1702
+ content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1703
+ details: {
1704
+ _type: "multiEditInfo",
1705
+ summary,
1706
+ filePath: fp,
1707
+ editCount: operations.length,
1708
+ diffLineCount: merged.lines.length,
1709
+ diff: merged,
1710
+ language: lg,
1711
+ },
1712
+ };
1713
+ }
1714
+ }
1715
+ catch (replaceError) {
1716
+ // replace() failed; fall through to SDK edit tool
1717
+ console.warn(`[pi-diff] replace() failed, falling back to SDK: ${replaceError instanceof Error ? replaceError.message : String(replaceError)}`);
1718
+ }
1719
+ }
1514
1720
  const result = await origEdit.execute(tid, params, sig, upd, ctx);
1515
1721
  if (operations.length === 0)
1516
1722
  return result;
@@ -1536,6 +1742,7 @@ Examples:
1536
1742
  result.details = {
1537
1743
  _type: "editInfo",
1538
1744
  summary,
1745
+ filePath: fp,
1539
1746
  editLine,
1540
1747
  diff: diffData,
1541
1748
  language: lg,
@@ -1548,7 +1755,16 @@ Examples:
1548
1755
  const merged = {
1549
1756
  lines: diffs.flatMap((diff, i) => [
1550
1757
  // Add separator between multiple edits
1551
- ...(i > 0 ? [{ type: "sep", oldNum: null, newNum: null, content: `───── Edit ${i + 1} ─────` }] : []),
1758
+ ...(i > 0
1759
+ ? [
1760
+ {
1761
+ type: "sep",
1762
+ oldNum: null,
1763
+ newNum: null,
1764
+ content: `───── Edit ${i + 1} ─────`,
1765
+ },
1766
+ ]
1767
+ : []),
1552
1768
  ...diff.lines,
1553
1769
  ]),
1554
1770
  added: diffs.reduce((sum, diff) => sum + diff.added, 0),
@@ -1558,6 +1774,7 @@ Examples:
1558
1774
  result.details = {
1559
1775
  _type: "multiEditInfo",
1560
1776
  summary,
1777
+ filePath: fp,
1561
1778
  editCount: operations.length,
1562
1779
  diffLineCount: merged.lines.length,
1563
1780
  diff: merged,
@@ -1569,13 +1786,13 @@ Examples:
1569
1786
  const fp = args?.path ?? args?.file_path ?? "";
1570
1787
  const operations = getEditOperations(args);
1571
1788
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1572
- const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1789
+ resolvePreviewDiffColors(theme);
1573
1790
  if (ctx.argsComplete && operations.length > 0) {
1574
1791
  const { totalAdded, totalRemoved } = summarizeEditOperations(operations);
1575
- text.setText(`${hdr} ${theme.fg("muted", summarize(totalAdded, totalRemoved))}`);
1792
+ text.setText(formatToolTitle("edit", fp, theme, termW(), ` ${theme.fg("muted", summarize(totalAdded, totalRemoved))}`));
1576
1793
  }
1577
1794
  else {
1578
- text.setText(hdr);
1795
+ text.setText(formatToolTitle("edit", fp, theme, termW()));
1579
1796
  }
1580
1797
  return text;
1581
1798
  },
@@ -1592,42 +1809,24 @@ Examples:
1592
1809
  }
1593
1810
  const d = result.details;
1594
1811
  if (d?._type === "editInfo" && d.diff) {
1595
- const themeKey = sharedThemeCacheKey(theme);
1596
- const dc = resolveSharedDiffColors(theme);
1597
1812
  const loc = d.editLine > 0 ? ` ${theme.fg("muted", `at line ${d.editLine}`)}` : "";
1598
- text.__piDiffTask = {
1599
- placeholder: ` ${d.summary}${loc}\n${theme.fg("muted", " rendering diff…")}`,
1600
- fallback: ` ${d.summary}${loc}`,
1601
- invalidate: ctx.invalidate,
1602
- key: (width) => `ed:${themeKey}:${width}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`,
1603
- render: async (width) => ` ${d.summary}${loc}\n${await renderSharedSplit(d.diff, d.language, MAX_PREVIEW_LINES, dc, width)}`,
1604
- };
1813
+ setDiffPreviewTask(text, "ed", `${d.summary}${loc}`, d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx);
1605
1814
  return text;
1606
1815
  }
1607
1816
  if (d?._type === "editInfo") {
1608
1817
  const { summary: s, editLine } = d;
1609
1818
  const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
1610
- text.__piDiffTask = undefined;
1611
- text.setText(` ${s}${loc}`);
1819
+ setToolHeaderText(text, `${s}${loc}`, theme);
1612
1820
  return text;
1613
1821
  }
1614
1822
  if (d?._type === "multiEditInfo") {
1615
1823
  const { summary: s, editCount, diffLineCount, diff, language } = d;
1824
+ const meta = `${editCount} edits ${s}${diffLineCountLabel(diffLineCount, theme)}`;
1616
1825
  if (diff) {
1617
- const themeKey = sharedThemeCacheKey(theme);
1618
- const dc = resolveSharedDiffColors(theme);
1619
- text.__piDiffTask = {
1620
- placeholder: ` ${editCount} edits ${s}
1621
- ${theme.fg("muted", " rendering diff…")}`,
1622
- fallback: ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`,
1623
- invalidate: ctx.invalidate,
1624
- key: (width) => `me:${themeKey}:${width}:${s}:${diff?.lines?.length ?? 0}:${language ?? ""}`,
1625
- 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)}`,
1626
- };
1826
+ setDiffPreviewTask(text, "me", meta, diff, language, MAX_PREVIEW_LINES, theme, ctx);
1627
1827
  return text;
1628
1828
  }
1629
- text.__piDiffTask = undefined;
1630
- text.setText(` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`);
1829
+ setToolHeaderText(text, meta, theme);
1631
1830
  return text;
1632
1831
  }
1633
1832
  text.__piDiffTask = undefined;