@heyhuynhgiabuu/pi-diff 0.5.1 → 0.5.3

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,12 +24,10 @@ 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
+ import { resolveLinesFromPatch } from "./core/resolve-lines.js";
28
29
  import { registerReviewDiffCommand } from "./review/command.js";
29
- import { formatReviewMarkdown } from "./review/export.js";
30
- import { countReviewDiffLines, readGitDiff } from "./review/git.js";
31
30
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
32
- import { formatInteractiveReviewPanel } from "./review/interactive.js";
33
31
  const DIFF_PRESETS = {
34
32
  default: {
35
33
  name: "default",
@@ -926,10 +924,9 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
926
924
  }
927
925
  while (idx < vis.length) {
928
926
  const l = vis[idx];
929
- // Hunk separator — collapsed context
927
+ // Hunk separator — collapsed context with optional function context
930
928
  if (l.type === "sep") {
931
- const gap = l.newNum;
932
- const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
929
+ const label = sepLabelUnified(getSepStyle(), l.hunkMeta, l.newNum, l.content);
933
930
  const totalW = Math.min(tw, 72);
934
931
  const pad = Math.max(0, totalW - label.length - 2);
935
932
  const half1 = Math.floor(pad / 2), half2 = pad - half1;
@@ -1009,16 +1006,23 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1009
1006
  let i = 0;
1010
1007
  while (i < diff.lines.length) {
1011
1008
  const l = diff.lines[i];
1012
- if (l.type === "sep" || l.type === "ctx") {
1009
+ if (l.type === "ctx") {
1010
+ rows.push({ left: l, right: l });
1011
+ i++;
1012
+ continue;
1013
+ }
1014
+ if (l.type === "sep") {
1013
1015
  rows.push({ left: l, right: l });
1014
1016
  i++;
1015
1017
  continue;
1016
1018
  }
1017
- const dels = [], adds = [];
1019
+ // Collect del/add block
1020
+ const dels = [];
1018
1021
  while (i < diff.lines.length && diff.lines[i].type === "del") {
1019
1022
  dels.push(diff.lines[i]);
1020
1023
  i++;
1021
1024
  }
1025
+ const adds = [];
1022
1026
  while (i < diff.lines.length && diff.lines[i].type === "add") {
1023
1027
  adds.push(diff.lines[i]);
1024
1028
  i++;
@@ -1054,10 +1058,9 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1054
1058
  const g = ` ${gPat}${FG_RULE}│${RST} `;
1055
1059
  return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
1056
1060
  }
1057
- // Hunk separator
1061
+ // Hunk separator with optional function context
1058
1062
  if (line.type === "sep") {
1059
- const gap = line.newNum;
1060
- const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
1063
+ const label = sepLabelSplit(getSepStyle(), line.hunkMeta, line.newNum, line.content);
1061
1064
  const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
1062
1065
  return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
1063
1066
  }
@@ -1139,35 +1142,20 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1139
1142
  // Extension
1140
1143
  // ---------------------------------------------------------------------------
1141
1144
  export const __testing = {
1145
+ computeHunkBlocks,
1142
1146
  normalizeShikiContrast,
1147
+ getSepStyle,
1143
1148
  parseDiff,
1149
+ parsePatchFiles,
1150
+ resolveSepStyle,
1144
1151
  renderSplit,
1145
1152
  renderUnified,
1146
1153
  };
1147
- function reviewGitDiffMode(params) {
1148
- const base = typeof params.base === "string" ? params.base.trim() : "";
1149
- return base ? { type: "branch", base } : { type: "working-tree" };
1150
- }
1151
- function reviewGitDiffMaxLines(params) {
1152
- if (params.maxLinesPerHunk === undefined)
1153
- return undefined;
1154
- const maxLines = Number(params.maxLinesPerHunk);
1155
- if (!Number.isInteger(maxLines) || maxLines < 1) {
1156
- throw new Error("maxLinesPerHunk must be a positive integer");
1157
- }
1158
- return maxLines;
1159
- }
1160
- function normalizeOptionalPositiveInteger(value, name) {
1161
- if (value === undefined || value === null)
1162
- return undefined;
1163
- const number = Number(value);
1164
- if (!Number.isInteger(number) || number < 1)
1165
- throw new Error(`${name} must be a positive integer`);
1166
- return number;
1167
- }
1168
1154
  export default async function diffRendererExtension(pi) {
1169
1155
  // Apply diff theme palette from settings/presets before rendering
1170
1156
  applySharedDiffPalette();
1157
+ // Resolve hunk separator style from env var
1158
+ resolveSepStyle();
1171
1159
  let createWriteTool, createEditTool, getMarkdownTheme, TextComponent, MarkdownComponent;
1172
1160
  try {
1173
1161
  const sdk = await import("@earendil-works/pi-coding-agent");
@@ -1226,121 +1214,127 @@ export default async function diffRendererExtension(pi) {
1226
1214
  return text;
1227
1215
  }
1228
1216
  registerReviewDiffCommand(pi, cwd);
1217
+ // ── resolve_lines tool ────────────────────────────────────────────────
1229
1218
  pi.registerTool({
1230
- name: "review_git_diff",
1231
- label: "Review Git Diff",
1232
- description: `Open a read-only Git diff review panel as markdown inside Pi TUI.
1219
+ name: "resolve_lines",
1220
+ label: "Resolve Lines",
1221
+ description: `Resolve line numbers for an LLM code-review comment by matching its existing_code snippet against the git diff hunks. Uses a three-tier algorithm: new-side hunk match, old-side hunk match, then full file-content scan. Returns \`startLine\` and \`endLine\` when found, or \`unresolved: true\` when the snippet cannot be located.
1233
1222
 
1234
- Use this when the agent needs structured Git review context or a non-destructive markdown view of local changes. For the real keyboard-driven local review UI, use the /review-diff command instead. This tool never stages, commits, reverts, discards, or modifies files.
1223
+ Three-tier resolution:
1224
+ 1. Match against diff hunks (new-file side) — context + added lines
1225
+ 2. Match against diff hunks (old-file side) — context + deleted lines
1226
+ 3. Scan full file content for a consecutive line match
1227
+
1228
+ Use after receiving a review comment with \`existing_code\` but zero or drifted line numbers. The tool fetches the current git diff for the file automatically when \`patchText\` is omitted.
1235
1229
 
1236
1230
  Examples:
1237
- review_git_diff({})
1238
- review_git_diff({ base: "main" })
1239
- review_git_diff({ file: "src/index.ts" })
1240
- review_git_diff({ file: "src/index.ts", hunkId: "src/index.ts:10:12" })`,
1241
- promptSnippet: "Open a read-only Git review markdown panel for local changes. Prefer /review-diff for the interactive TUI workflow.",
1231
+ resolve_lines({ existingCode: "const x = 1;", filePath: "src/index.ts" })
1232
+ resolve_lines({ existingCode: "function foo() {", filePath: "src/utils.ts", patchText: "@@ -1,3 +1,4 @@..." })`,
1233
+ promptSnippet: "Resolve line numbers for an LLM review comment by matching existing_code against git diff hunks.",
1242
1234
  parameters: {
1243
1235
  type: "object",
1244
1236
  properties: {
1245
- base: {
1237
+ existingCode: {
1246
1238
  type: "string",
1247
- description: "Optional base branch/ref. Omit for uncommitted working-tree changes; set to main/master/etc. for base...HEAD branch review.",
1239
+ description: "The code snippet from the LLM review comment that shows the problematic code. Required.",
1248
1240
  },
1249
- file: {
1241
+ filePath: {
1250
1242
  type: "string",
1251
- description: "Optional changed file path to focus in the interactive review panel.",
1243
+ description: "Path to the file this comment is about, relative to repo root. Required.",
1252
1244
  },
1253
- hunkId: {
1245
+ patchText: {
1254
1246
  type: "string",
1255
- description: "Optional hunk id to focus, shown by review_git_diff output.",
1256
- },
1257
- includeRawDiff: {
1258
- type: "boolean",
1259
- description: "Return legacy raw review markdown instead of the interactive panel when true.",
1260
- },
1261
- raw: {
1262
- type: "boolean",
1263
- description: "Alias for includeRawDiff.",
1264
- },
1265
- maxLinesPerHunk: {
1266
- type: "number",
1267
- description: "Optional positive integer limit for lines shown per hunk.",
1247
+ description: "Optional raw unified diff text for the file. When omitted, pi-diff fetches the git diff automatically.",
1268
1248
  },
1269
- maxFiles: {
1270
- type: "number",
1271
- description: "Optional positive integer limit for files shown in the changed-file sidebar.",
1272
- },
1273
- maxHunks: {
1274
- type: "number",
1275
- description: "Optional positive integer limit for hunks shown in the focused file view.",
1249
+ fileContent: {
1250
+ type: "string",
1251
+ description: "Optional full new-file content for fallback line scan after hunk matching fails.",
1276
1252
  },
1277
1253
  },
1254
+ required: ["existingCode", "filePath"],
1278
1255
  additionalProperties: false,
1279
1256
  },
1280
1257
  async execute(_tid, params = {}) {
1281
1258
  try {
1282
- const safeParams = (params ?? {});
1283
- const diff = await readGitDiff(cwd, reviewGitDiffMode(safeParams));
1284
- const maxLinesPerHunk = reviewGitDiffMaxLines(safeParams);
1285
- const markdown = safeParams.includeRawDiff || safeParams.raw
1286
- ? formatReviewMarkdown(diff, { includeRawDiff: true, maxLinesPerHunk })
1287
- : formatInteractiveReviewPanel(diff, [], {
1288
- file: safeParams.file,
1289
- hunkId: safeParams.hunkId,
1290
- maxFiles: normalizeOptionalPositiveInteger(safeParams.maxFiles, "maxFiles"),
1291
- maxHunks: normalizeOptionalPositiveInteger(safeParams.maxHunks, "maxHunks"),
1292
- maxLinesPerHunk,
1293
- });
1294
- const counts = countReviewDiffLines(diff);
1259
+ const { existingCode, filePath, patchText, fileContent } = params ?? {};
1260
+ if (!existingCode || !filePath) {
1261
+ return { content: [{ type: "text", text: "Error: existingCode and filePath are required" }] };
1262
+ }
1263
+ // Fetch git diff if patchText not provided
1264
+ let patch = patchText;
1265
+ if (!patch) {
1266
+ const { execFileSync } = await import("node:child_process");
1267
+ try {
1268
+ patch = execFileSync("git", ["diff", "--no-ext-diff", "HEAD", "--", filePath], {
1269
+ cwd,
1270
+ encoding: "utf8",
1271
+ maxBuffer: 1024 * 1024,
1272
+ stdio: ["ignore", "pipe", "pipe"],
1273
+ }).trim();
1274
+ // Also try unstaged diff
1275
+ const unstaged = execFileSync("git", ["diff", "--no-ext-diff", "--", filePath], {
1276
+ cwd,
1277
+ encoding: "utf8",
1278
+ maxBuffer: 1024 * 1024,
1279
+ stdio: ["ignore", "pipe", "pipe"],
1280
+ }).trim();
1281
+ if (unstaged) {
1282
+ patch = patch ? [patch, unstaged].filter(Boolean).join("\n") : unstaged;
1283
+ }
1284
+ }
1285
+ catch {
1286
+ return {
1287
+ content: [{ type: "text", text: `Error: could not read git diff for ${filePath}` }],
1288
+ };
1289
+ }
1290
+ }
1291
+ if (!patch) {
1292
+ return {
1293
+ content: [{ type: "text", text: `No diff found for ${filePath} — file may not have changes` }],
1294
+ details: { unresolved: true },
1295
+ };
1296
+ }
1297
+ const result = resolveLinesFromPatch(existingCode, patch, fileContent);
1298
+ if ("unresolved" in result) {
1299
+ return {
1300
+ content: [{ type: "text", text: `Could not resolve line numbers for existing_code in ${filePath}` }],
1301
+ details: { unresolved: true },
1302
+ };
1303
+ }
1295
1304
  return {
1296
- content: [{ type: "text", text: markdown }],
1297
- details: {
1298
- _type: "reviewGitDiff",
1299
- markdown,
1300
- mode: String(diff.mode),
1301
- fileCount: diff.files.length,
1302
- insertions: counts.insertions,
1303
- deletions: counts.deletions,
1304
- commentCount: 0,
1305
- focusedFile: safeParams.file,
1306
- focusedHunk: safeParams.hunkId,
1307
- error: undefined,
1308
- },
1305
+ content: [{ type: "text", text: `Resolved ${filePath}:${result.startLine}-${result.endLine}` }],
1306
+ details: { startLine: result.startLine, endLine: result.endLine },
1309
1307
  };
1310
1308
  }
1311
1309
  catch (error) {
1312
1310
  const message = error instanceof Error ? error.message : String(error);
1313
1311
  return {
1314
1312
  content: [{ type: "text", text: `Error: ${message}` }],
1315
- details: { _type: "reviewGitDiff", markdown: "", mode: "error", fileCount: 0, insertions: 0, deletions: 0, commentCount: 0, focusedFile: undefined, focusedHunk: undefined, error: message },
1313
+ details: { unresolved: true, error: message },
1316
1314
  };
1317
1315
  }
1318
1316
  },
1319
1317
  renderCall(args, theme, ctx) {
1320
1318
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1321
- const safeArgs = args;
1322
- const base = typeof safeArgs?.base === "string" && safeArgs.base.trim() ? ` vs ${safeArgs.base.trim()}` : " working tree";
1323
- const focus = typeof safeArgs?.file === "string" && safeArgs.file.trim() ? ` • ${safeArgs.file.trim()}` : "";
1324
- text.setText(`${theme.fg("toolTitle", theme.bold("review_git_diff"))}${theme.fg("muted", `${base}${focus}`)}`);
1319
+ const fp = typeof args?.filePath === "string" ? args.filePath : "";
1320
+ text.setText(`${theme.fg("toolTitle", theme.bold("resolve_lines"))} ${theme.fg("accent", fp)}`);
1325
1321
  return text;
1326
1322
  },
1327
1323
  renderResult(result, _opt, theme, ctx) {
1328
1324
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1329
1325
  if (ctx.isError || result.details?.error) {
1330
- text.setText(`\n${theme.fg("error", result.details?.error ?? "review_git_diff failed")}`);
1326
+ text.setText(`\n${theme.fg("error", result.details?.error ?? "resolve_lines failed")}`);
1331
1327
  return text;
1332
1328
  }
1333
- const details = result.details;
1334
- if (details?._type === "reviewGitDiff") {
1335
- if (MarkdownComponent && getMarkdownTheme && typeof details.markdown === "string") {
1336
- return new MarkdownComponent(details.markdown, 0, 0, getMarkdownTheme());
1337
- }
1338
- const summary = `${details.fileCount} files ${summarize(details.insertions ?? 0, details.deletions ?? 0)}`;
1339
- const comments = details.commentCount ? ` • ${details.commentCount} comments` : "";
1340
- text.setText(` ${summary}${theme.fg("muted", comments)}\n${theme.fg("muted", " Interactive review panel generated in the tool result.")}`);
1329
+ if (result.details?.unresolved) {
1330
+ text.setText(` ${theme.fg("muted", "unresolved")}`);
1331
+ return text;
1332
+ }
1333
+ if (result.details?.startLine != null) {
1334
+ text.setText(` ${theme.fg("success", `lines ${result.details.startLine}-${result.details.endLine}`)}`);
1341
1335
  return text;
1342
1336
  }
1343
- text.setText(` ${theme.fg("muted", "interactive review generated")}`);
1337
+ text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "").slice(0, 120))}`);
1344
1338
  return text;
1345
1339
  },
1346
1340
  });
@@ -1365,9 +1359,10 @@ Examples:
1365
1359
  const content = params.content ?? "";
1366
1360
  // Store in details — the only custom field TUI preserves in renderResult
1367
1361
  if (old !== null && old !== content) {
1368
- const diff = parseDiff(old, content);
1362
+ const useFull = !!params._expandGaps;
1363
+ const diff = parseDiff(old, content, useFull ? undefined : 3);
1369
1364
  const lg = detectDiffLanguage(fp);
1370
- result.details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg };
1365
+ result.details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg, oldContent: old, newContent: content };
1371
1366
  }
1372
1367
  else if (old === null) {
1373
1368
  const lineCount = content ? content.split("\n").length : 0;
@@ -1530,14 +1525,39 @@ Examples:
1530
1525
  catch {
1531
1526
  editLine = 0;
1532
1527
  }
1533
- result.details = { _type: "editInfo", summary, editLine, diff: diffs[0], language: lg };
1528
+ const useFull = !!params._expandGaps;
1529
+ const diffData = useFull
1530
+ ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1531
+ : diffs[0];
1532
+ result.details = {
1533
+ _type: "editInfo",
1534
+ summary,
1535
+ editLine,
1536
+ diff: diffData,
1537
+ language: lg,
1538
+ oldContent: operations[0].oldText,
1539
+ newContent: operations[0].newText,
1540
+ };
1534
1541
  return result;
1535
1542
  }
1543
+ // Merge all diffs into one combined view for rendering
1544
+ const merged = {
1545
+ lines: diffs.flatMap((diff, i) => [
1546
+ // Add separator between multiple edits
1547
+ ...(i > 0 ? [{ type: "sep", oldNum: null, newNum: null, content: `───── Edit ${i + 1} ─────` }] : []),
1548
+ ...diff.lines,
1549
+ ]),
1550
+ added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1551
+ removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1552
+ chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1553
+ };
1536
1554
  result.details = {
1537
1555
  _type: "multiEditInfo",
1538
1556
  summary,
1539
1557
  editCount: operations.length,
1540
- diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
1558
+ diffLineCount: merged.lines.length,
1559
+ diff: merged,
1560
+ language: lg,
1541
1561
  };
1542
1562
  return result;
1543
1563
  },
@@ -1588,7 +1608,20 @@ Examples:
1588
1608
  return text;
1589
1609
  }
1590
1610
  if (d?._type === "multiEditInfo") {
1591
- const { summary: s, editCount, diffLineCount } = d;
1611
+ const { summary: s, editCount, diffLineCount, diff, language } = d;
1612
+ if (diff) {
1613
+ const themeKey = sharedThemeCacheKey(theme);
1614
+ const dc = resolveSharedDiffColors(theme);
1615
+ text.__piDiffTask = {
1616
+ placeholder: ` ${editCount} edits ${s}
1617
+ ${theme.fg("muted", " rendering diff…")}`,
1618
+ fallback: ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`,
1619
+ invalidate: ctx.invalidate,
1620
+ key: (width) => `me:${themeKey}:${width}:${s}:${diff?.lines?.length ?? 0}:${language ?? ""}`,
1621
+ 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)}`,
1622
+ };
1623
+ return text;
1624
+ }
1592
1625
  text.__piDiffTask = undefined;
1593
1626
  text.setText(` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`);
1594
1627
  return text;