@heyhuynhgiabuu/pi-diff 0.5.0 → 0.5.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,7 +24,7 @@ import { existsSync, readFileSync } 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 { parseDiff } from "./core/diff.js";
27
+ import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified } from "./core/diff.js";
28
28
  import { registerReviewDiffCommand } from "./review/command.js";
29
29
  import { formatReviewMarkdown } from "./review/export.js";
30
30
  import { countReviewDiffLines, readGitDiff } from "./review/git.js";
@@ -926,10 +926,9 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
926
926
  }
927
927
  while (idx < vis.length) {
928
928
  const l = vis[idx];
929
- // Hunk separator — collapsed context
929
+ // Hunk separator — collapsed context with optional function context
930
930
  if (l.type === "sep") {
931
- const gap = l.newNum;
932
- const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
931
+ const label = sepLabelUnified(getSepStyle(), l.hunkMeta, l.newNum, l.content);
933
932
  const totalW = Math.min(tw, 72);
934
933
  const pad = Math.max(0, totalW - label.length - 2);
935
934
  const half1 = Math.floor(pad / 2), half2 = pad - half1;
@@ -1009,16 +1008,23 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1009
1008
  let i = 0;
1010
1009
  while (i < diff.lines.length) {
1011
1010
  const l = diff.lines[i];
1012
- if (l.type === "sep" || l.type === "ctx") {
1011
+ if (l.type === "ctx") {
1013
1012
  rows.push({ left: l, right: l });
1014
1013
  i++;
1015
1014
  continue;
1016
1015
  }
1017
- const dels = [], adds = [];
1016
+ if (l.type === "sep") {
1017
+ rows.push({ left: l, right: l });
1018
+ i++;
1019
+ continue;
1020
+ }
1021
+ // Collect del/add block
1022
+ const dels = [];
1018
1023
  while (i < diff.lines.length && diff.lines[i].type === "del") {
1019
1024
  dels.push(diff.lines[i]);
1020
1025
  i++;
1021
1026
  }
1027
+ const adds = [];
1022
1028
  while (i < diff.lines.length && diff.lines[i].type === "add") {
1023
1029
  adds.push(diff.lines[i]);
1024
1030
  i++;
@@ -1054,10 +1060,9 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1054
1060
  const g = ` ${gPat}${FG_RULE}│${RST} `;
1055
1061
  return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
1056
1062
  }
1057
- // Hunk separator
1063
+ // Hunk separator with optional function context
1058
1064
  if (line.type === "sep") {
1059
- const gap = line.newNum;
1060
- const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
1065
+ const label = sepLabelSplit(getSepStyle(), line.hunkMeta, line.newNum, line.content);
1061
1066
  const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
1062
1067
  return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
1063
1068
  }
@@ -1139,8 +1144,12 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1139
1144
  // Extension
1140
1145
  // ---------------------------------------------------------------------------
1141
1146
  export const __testing = {
1147
+ computeHunkBlocks,
1142
1148
  normalizeShikiContrast,
1149
+ getSepStyle,
1143
1150
  parseDiff,
1151
+ parsePatchFiles,
1152
+ resolveSepStyle,
1144
1153
  renderSplit,
1145
1154
  renderUnified,
1146
1155
  };
@@ -1168,6 +1177,8 @@ function normalizeOptionalPositiveInteger(value, name) {
1168
1177
  export default async function diffRendererExtension(pi) {
1169
1178
  // Apply diff theme palette from settings/presets before rendering
1170
1179
  applySharedDiffPalette();
1180
+ // Resolve hunk separator style from env var
1181
+ resolveSepStyle();
1171
1182
  let createWriteTool, createEditTool, getMarkdownTheme, TextComponent, MarkdownComponent;
1172
1183
  try {
1173
1184
  const sdk = await import("@earendil-works/pi-coding-agent");
@@ -1187,6 +1198,44 @@ export default async function diffRendererExtension(pi) {
1187
1198
  const cwd = process.cwd();
1188
1199
  const home = process.env.HOME ?? "";
1189
1200
  const sp = (p) => shortPath(cwd, home, p);
1201
+ /** Wrap a Text component so its render(width) kicks off async diff rendering
1202
+ * using the real TUI width (which accounts for the sidebar). */
1203
+ function getWidthAwareText(lastComponent) {
1204
+ const text = (lastComponent ?? new TextComponent("", 0, 0));
1205
+ if (text.__piDiffWidthAware)
1206
+ return text;
1207
+ const baseRender = typeof text.render === "function" ? text.render.bind(text) : null;
1208
+ if (!baseRender)
1209
+ return text;
1210
+ text.__piDiffWidthAware = true;
1211
+ text.__piDiffRender = baseRender;
1212
+ text.render = (width) => {
1213
+ const task = text.__piDiffTask;
1214
+ if (task) {
1215
+ const renderWidth = Math.max(1, Math.floor(width || termW()));
1216
+ const key = task.key(renderWidth);
1217
+ if (text.__piDiffRenderedKey !== key) {
1218
+ text.__piDiffRenderedKey = key;
1219
+ text.setText(task.placeholder);
1220
+ Promise.resolve(task.render(renderWidth))
1221
+ .then((rendered) => {
1222
+ if (text.__piDiffRenderedKey !== key)
1223
+ return;
1224
+ text.setText(rendered);
1225
+ task.invalidate?.();
1226
+ })
1227
+ .catch(() => {
1228
+ if (text.__piDiffRenderedKey !== key)
1229
+ return;
1230
+ text.setText(task.fallback);
1231
+ task.invalidate?.();
1232
+ });
1233
+ }
1234
+ }
1235
+ return text.__piDiffRender(width);
1236
+ };
1237
+ return text;
1238
+ }
1190
1239
  registerReviewDiffCommand(pi, cwd);
1191
1240
  pi.registerTool({
1192
1241
  name: "review_git_diff",
@@ -1241,7 +1290,7 @@ Examples:
1241
1290
  },
1242
1291
  async execute(_tid, params = {}) {
1243
1292
  try {
1244
- const safeParams = params ?? {};
1293
+ const safeParams = (params ?? {});
1245
1294
  const diff = await readGitDiff(cwd, reviewGitDiffMode(safeParams));
1246
1295
  const maxLinesPerHunk = reviewGitDiffMaxLines(safeParams);
1247
1296
  const markdown = safeParams.includeRawDiff || safeParams.raw
@@ -1259,13 +1308,14 @@ Examples:
1259
1308
  details: {
1260
1309
  _type: "reviewGitDiff",
1261
1310
  markdown,
1262
- mode: diff.mode,
1311
+ mode: String(diff.mode),
1263
1312
  fileCount: diff.files.length,
1264
1313
  insertions: counts.insertions,
1265
1314
  deletions: counts.deletions,
1266
1315
  commentCount: 0,
1267
1316
  focusedFile: safeParams.file,
1268
1317
  focusedHunk: safeParams.hunkId,
1318
+ error: undefined,
1269
1319
  },
1270
1320
  };
1271
1321
  }
@@ -1273,14 +1323,15 @@ Examples:
1273
1323
  const message = error instanceof Error ? error.message : String(error);
1274
1324
  return {
1275
1325
  content: [{ type: "text", text: `Error: ${message}` }],
1276
- details: { _type: "reviewGitDiff", error: message },
1326
+ details: { _type: "reviewGitDiff", markdown: "", mode: "error", fileCount: 0, insertions: 0, deletions: 0, commentCount: 0, focusedFile: undefined, focusedHunk: undefined, error: message },
1277
1327
  };
1278
1328
  }
1279
1329
  },
1280
1330
  renderCall(args, theme, ctx) {
1281
1331
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1282
- const base = typeof args?.base === "string" && args.base.trim() ? ` vs ${args.base.trim()}` : " working tree";
1283
- const focus = typeof args?.file === "string" && args.file.trim() ? ` ${args.file.trim()}` : "";
1332
+ const safeArgs = args;
1333
+ const base = typeof safeArgs?.base === "string" && safeArgs.base.trim() ? ` vs ${safeArgs.base.trim()}` : " working tree";
1334
+ const focus = typeof safeArgs?.file === "string" && safeArgs.file.trim() ? ` • ${safeArgs.file.trim()}` : "";
1284
1335
  text.setText(`${theme.fg("toolTitle", theme.bold("review_git_diff"))}${theme.fg("muted", `${base}${focus}`)}`);
1285
1336
  return text;
1286
1337
  },
@@ -1325,9 +1376,10 @@ Examples:
1325
1376
  const content = params.content ?? "";
1326
1377
  // Store in details — the only custom field TUI preserves in renderResult
1327
1378
  if (old !== null && old !== content) {
1328
- const diff = parseDiff(old, content);
1379
+ const useFull = !!params._expandGaps;
1380
+ const diff = parseDiff(old, content, useFull ? undefined : 3);
1329
1381
  const lg = detectDiffLanguage(fp);
1330
- result.details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg };
1382
+ result.details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg, oldContent: old, newContent: content };
1331
1383
  }
1332
1384
  else if (old === null) {
1333
1385
  const lineCount = content ? content.split("\n").length : 0;
@@ -1379,41 +1431,31 @@ Examples:
1379
1431
  return text;
1380
1432
  },
1381
1433
  renderResult(result, _opt, theme, ctx) {
1382
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1434
+ const text = getWidthAwareText(ctx.lastComponent);
1383
1435
  if (ctx.isError) {
1384
1436
  const e = result.content
1385
1437
  ?.filter((c) => c.type === "text")
1386
1438
  .map((c) => c.text || "")
1387
1439
  .join("\n") ?? "Error";
1440
+ text.__piDiffTask = undefined;
1388
1441
  text.setText(`\n${theme.fg("error", e)}`);
1389
1442
  return text;
1390
1443
  }
1391
1444
  const d = result.details;
1392
1445
  if (d?._type === "diff") {
1393
- const w = termW();
1394
- const key = `wd:${sharedThemeCacheKey(theme)}:${w}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`;
1395
- if (ctx.state._wdk !== key) {
1396
- ctx.state._wdk = key;
1397
- ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
1398
- const dc = resolveSharedDiffColors(theme);
1399
- renderSharedSplit(d.diff, d.language, MAX_RENDER_LINES, dc, w)
1400
- .then((rendered) => {
1401
- if (ctx.state._wdk !== key)
1402
- return;
1403
- ctx.state._wdt = ` ${d.summary}\n${rendered}`;
1404
- ctx.invalidate();
1405
- })
1406
- .catch(() => {
1407
- if (ctx.state._wdk !== key)
1408
- return;
1409
- ctx.state._wdt = ` ${d.summary}`;
1410
- ctx.invalidate();
1411
- });
1412
- }
1413
- text.setText(ctx.state._wdt ?? ` ${d.summary}`);
1446
+ const themeKey = sharedThemeCacheKey(theme);
1447
+ const dc = resolveSharedDiffColors(theme);
1448
+ text.__piDiffTask = {
1449
+ placeholder: ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`,
1450
+ fallback: ` ${d.summary}`,
1451
+ invalidate: ctx.invalidate,
1452
+ key: (width) => `wd:${themeKey}:${width}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`,
1453
+ render: async (width) => ` ${d.summary}\n${await renderSharedSplit(d.diff, d.language, MAX_RENDER_LINES, dc, width)}`,
1454
+ };
1414
1455
  return text;
1415
1456
  }
1416
1457
  if (d?._type === "noChange") {
1458
+ text.__piDiffTask = undefined;
1417
1459
  text.setText(` ${theme.fg("muted", "✓ no changes")}`);
1418
1460
  return text;
1419
1461
  }
@@ -1486,6 +1528,7 @@ Examples:
1486
1528
  if (operations.length === 0)
1487
1529
  return result;
1488
1530
  const { diffs, summary } = summarizeEditOperations(operations);
1531
+ const lg = detectDiffLanguage(fp);
1489
1532
  if (operations.length === 1) {
1490
1533
  let editLine = 0;
1491
1534
  try {
@@ -1499,14 +1542,39 @@ Examples:
1499
1542
  catch {
1500
1543
  editLine = 0;
1501
1544
  }
1502
- result.details = { _type: "editInfo", summary, editLine };
1545
+ const useFull = !!params._expandGaps;
1546
+ const diffData = useFull
1547
+ ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1548
+ : diffs[0];
1549
+ result.details = {
1550
+ _type: "editInfo",
1551
+ summary,
1552
+ editLine,
1553
+ diff: diffData,
1554
+ language: lg,
1555
+ oldContent: operations[0].oldText,
1556
+ newContent: operations[0].newText,
1557
+ };
1503
1558
  return result;
1504
1559
  }
1560
+ // Merge all diffs into one combined view for rendering
1561
+ const merged = {
1562
+ lines: diffs.flatMap((diff, i) => [
1563
+ // Add separator between multiple edits
1564
+ ...(i > 0 ? [{ type: "sep", oldNum: null, newNum: null, content: `───── Edit ${i + 1} ─────` }] : []),
1565
+ ...diff.lines,
1566
+ ]),
1567
+ added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1568
+ removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1569
+ chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1570
+ };
1505
1571
  result.details = {
1506
1572
  _type: "multiEditInfo",
1507
1573
  summary,
1508
1574
  editCount: operations.length,
1509
- diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
1575
+ diffLineCount: merged.lines.length,
1576
+ diff: merged,
1577
+ language: lg,
1510
1578
  };
1511
1579
  return result;
1512
1580
  },
@@ -1515,85 +1583,67 @@ Examples:
1515
1583
  const operations = getEditOperations(args);
1516
1584
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1517
1585
  const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
1518
- if (!(ctx.argsComplete && operations.length > 0)) {
1519
- text.setText(hdr);
1520
- return text;
1586
+ if (ctx.argsComplete && operations.length > 0) {
1587
+ const { totalAdded, totalRemoved } = summarizeEditOperations(operations);
1588
+ text.setText(`${hdr} ${theme.fg("muted", summarize(totalAdded, totalRemoved))}`);
1521
1589
  }
1522
- const pk = JSON.stringify({ fp, operations, theme: sharedThemeCacheKey(theme), w: termW() });
1523
- if (ctx.state._pk !== pk) {
1524
- ctx.state._pk = pk;
1525
- ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
1526
- const lg = detectDiffLanguage(fp);
1527
- const dc = resolveSharedDiffColors(theme);
1528
- if (operations.length === 1) {
1529
- const diff = parseDiff(operations[0].oldText, operations[0].newText);
1530
- renderSharedSplit(diff, lg, MAX_PREVIEW_LINES, dc, termW())
1531
- .then((rendered) => {
1532
- if (ctx.state._pk !== pk)
1533
- return;
1534
- ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
1535
- ctx.invalidate();
1536
- })
1537
- .catch(() => {
1538
- if (ctx.state._pk !== pk)
1539
- return;
1540
- ctx.state._pt = `${hdr} ${summarize(diff.added, diff.removed)}`;
1541
- ctx.invalidate();
1542
- });
1543
- }
1544
- else {
1545
- const { diffs, summary } = summarizeEditOperations(operations);
1546
- const maxShown = Math.min(operations.length, 3);
1547
- const previewLines = Math.max(8, Math.floor(MAX_PREVIEW_LINES / maxShown));
1548
- Promise.all(diffs.slice(0, maxShown).map((diff, index) => renderSharedSplit(diff, lg, previewLines, dc, termW())
1549
- .then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`)
1550
- .catch(() => `Edit ${index + 1}/${operations.length} ${summarize(diff.added, diff.removed)}`)))
1551
- .then((sections) => {
1552
- if (ctx.state._pk !== pk)
1553
- return;
1554
- const remainder = operations.length - maxShown;
1555
- const suffix = remainder > 0 ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` : "";
1556
- ctx.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
1557
- ctx.invalidate();
1558
- })
1559
- .catch(() => {
1560
- if (ctx.state._pk !== pk)
1561
- return;
1562
- ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`;
1563
- ctx.invalidate();
1564
- });
1565
- }
1590
+ else {
1591
+ text.setText(hdr);
1566
1592
  }
1567
- text.setText(ctx.state._pt ?? hdr);
1568
1593
  return text;
1569
1594
  },
1570
1595
  renderResult(result, _opt, theme, ctx) {
1571
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1596
+ const text = getWidthAwareText(ctx.lastComponent);
1572
1597
  if (ctx.isError) {
1573
1598
  const e = result.content
1574
1599
  ?.filter((c) => c.type === "text")
1575
1600
  .map((c) => c.text || "")
1576
1601
  .join("\n") ?? "Error";
1602
+ text.__piDiffTask = undefined;
1577
1603
  text.setText(`\n${theme.fg("error", e)}`);
1578
1604
  return text;
1579
1605
  }
1580
- if (result.details?._type === "editInfo") {
1581
- const { summary: s, editLine } = result.details;
1606
+ const d = result.details;
1607
+ if (d?._type === "editInfo" && d.diff) {
1608
+ const themeKey = sharedThemeCacheKey(theme);
1609
+ const dc = resolveSharedDiffColors(theme);
1610
+ const loc = d.editLine > 0 ? ` ${theme.fg("muted", `at line ${d.editLine}`)}` : "";
1611
+ text.__piDiffTask = {
1612
+ placeholder: ` ${d.summary}${loc}\n${theme.fg("muted", " rendering diff…")}`,
1613
+ fallback: ` ${d.summary}${loc}`,
1614
+ invalidate: ctx.invalidate,
1615
+ key: (width) => `ed:${themeKey}:${width}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`,
1616
+ render: async (width) => ` ${d.summary}${loc}\n${await renderSharedSplit(d.diff, d.language, MAX_PREVIEW_LINES, dc, width)}`,
1617
+ };
1618
+ return text;
1619
+ }
1620
+ if (d?._type === "editInfo") {
1621
+ const { summary: s, editLine } = d;
1582
1622
  const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
1583
- const content = ` ${s}${loc}`;
1584
- const vis = content.replace(ANSI_RE, "").length;
1585
- const pad = Math.max(0, termW() - vis);
1586
- text.setText(`${content}${" ".repeat(pad)}`);
1623
+ text.__piDiffTask = undefined;
1624
+ text.setText(` ${s}${loc}`);
1587
1625
  return text;
1588
1626
  }
1589
- if (result.details?._type === "multiEditInfo") {
1590
- const { summary: s, editCount, diffLineCount } = result.details;
1591
- const content = ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`;
1592
- const vis = content.replace(ANSI_RE, "").length;
1593
- const pad = Math.max(0, termW() - vis);
1594
- text.setText(`${content}${" ".repeat(pad)}`);
1627
+ if (d?._type === "multiEditInfo") {
1628
+ const { summary: s, editCount, diffLineCount, diff, language } = d;
1629
+ if (diff) {
1630
+ const themeKey = sharedThemeCacheKey(theme);
1631
+ const dc = resolveSharedDiffColors(theme);
1632
+ text.__piDiffTask = {
1633
+ placeholder: ` ${editCount} edits ${s}
1634
+ ${theme.fg("muted", " rendering diff…")}`,
1635
+ fallback: ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`,
1636
+ invalidate: ctx.invalidate,
1637
+ key: (width) => `me:${themeKey}:${width}:${s}:${diff?.lines?.length ?? 0}:${language ?? ""}`,
1638
+ 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)}`,
1639
+ };
1640
+ return text;
1641
+ }
1642
+ text.__piDiffTask = undefined;
1643
+ text.setText(` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`);
1595
1644
  return text;
1596
1645
  }
1646
+ text.__piDiffTask = undefined;
1597
1647
  text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
1598
1648
  return text;
1599
1649
  },