@heyhuynhgiabuu/pi-diff 0.7.5 → 0.8.0

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,20 +20,22 @@
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, writeFileSync } from "node:fs";
23
+ import { constants, existsSync, readFileSync } from "node:fs";
24
+ import { access as accessFile, readFile, writeFile } from "node:fs/promises";
24
25
  import { extname, relative } from "node:path";
25
26
  import { codeToANSI } from "@shikijs/cli";
26
27
  import * as Diff from "diff";
27
28
  import { executeApplyPatch, formatApplyPatchResult } from "./core/apply-patch.js";
28
- import { configIndicatorStyle } from "./core/config.js";
29
+ import { configIndicatorStyle, loadPiDiffConfig } from "./core/config.js";
29
30
  import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified, } from "./core/diff.js";
30
- import { replace } from "./core/replace.js";
31
- import { registerEditGuard } from "./edit-guard.js";
32
31
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
33
32
  const ARROW_PREFIXED_TOOL_HEADERS = new Set(["write", "create", "edit", "apply_patch"]);
34
33
  function formatToolHeaderName(name) {
35
34
  return ARROW_PREFIXED_TOOL_HEADERS.has(name) ? `← ${name}` : name;
36
35
  }
36
+ function isToolResultError(result, context) {
37
+ return result.isError === true || context.isError === true;
38
+ }
37
39
  function formatToolHeaderPath(theme, filePath) {
38
40
  return theme.fg("toolTitle", filePath);
39
41
  }
@@ -1164,6 +1166,7 @@ export const __testing = {
1164
1166
  computeHunkBlocks,
1165
1167
  formatToolHeaderName,
1166
1168
  formatToolHeaderPath,
1169
+ isToolResultError,
1167
1170
  normalizeShikiContrast,
1168
1171
  getSepStyle,
1169
1172
  parseDiff,
@@ -1173,10 +1176,6 @@ export const __testing = {
1173
1176
  renderUnified,
1174
1177
  };
1175
1178
  export default async function diffRendererExtension(pi) {
1176
- // pi-pretty sets toolOutputExpanded:false on session_start; write/edit default expanded.
1177
- pi.on("session_start", async (_event, ctx) => {
1178
- ctx.ui.setToolsExpanded(true);
1179
- });
1180
1179
  // Apply diff theme palette from settings/presets before rendering
1181
1180
  applySharedDiffPalette();
1182
1181
  // Resolve hunk separator style from env var
@@ -1210,6 +1209,11 @@ export default async function diffRendererExtension(pi) {
1210
1209
  writeHeaderStatsByCallId.set(toolCallId, { added, removed });
1211
1210
  }
1212
1211
  const cwd = process.cwd();
1212
+ const disabledTools = new Set(loadPiDiffConfig().disabledTools ?? []);
1213
+ const registerToolIfEnabled = (toolName, tool) => {
1214
+ if (!disabledTools.has(toolName))
1215
+ pi.registerTool(tool);
1216
+ };
1213
1217
  const home = process.env.HOME ?? "";
1214
1218
  const sp = (p) => shortPath(cwd, home, p);
1215
1219
  const TOOL_RESULT_INDENT = " ";
@@ -1492,7 +1496,7 @@ export default async function diffRendererExtension(pi) {
1492
1496
  // write
1493
1497
  // =======================================================================
1494
1498
  const origWrite = createWriteTool(cwd);
1495
- pi.registerTool({
1499
+ registerToolIfEnabled("write", {
1496
1500
  ...origWrite,
1497
1501
  name: "write",
1498
1502
  async execute(tid, params, sig, upd, ctx) {
@@ -1578,7 +1582,7 @@ export default async function diffRendererExtension(pi) {
1578
1582
  },
1579
1583
  renderResult(result, _opt, theme, ctx) {
1580
1584
  const text = getWidthAwareText(ctx.lastComponent);
1581
- if (ctx.isError) {
1585
+ if (isToolResultError(result, ctx)) {
1582
1586
  const e = result.content
1583
1587
  ?.filter((c) => c.type === "text")
1584
1588
  .map((c) => c.text || "")
@@ -1654,18 +1658,44 @@ export default async function diffRendererExtension(pi) {
1654
1658
  const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1655
1659
  return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1656
1660
  }
1657
- function summarizeEditOperations(operations) {
1658
- const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
1659
- const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
1660
- const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
1661
- return {
1662
- diffs,
1663
- totalAdded,
1664
- totalRemoved,
1665
- summary: summarize(totalAdded, totalRemoved),
1666
- };
1661
+ function normalizeEditMatchText(text) {
1662
+ return text
1663
+ .replace(/\r\n/g, "\n")
1664
+ .replace(/\r/g, "\n")
1665
+ .normalize("NFKC")
1666
+ .split("\n")
1667
+ .map((line) => line.trimEnd())
1668
+ .join("\n")
1669
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
1670
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
1671
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
1672
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
1667
1673
  }
1668
- pi.registerTool({
1674
+ function rejectOverlappingEditMatches(content, filePath, operations) {
1675
+ if (!filePath || operations.length === 0)
1676
+ return;
1677
+ if (content.startsWith("\uFEFF"))
1678
+ content = content.slice(1);
1679
+ const normalizedContent = normalizeEditMatchText(content);
1680
+ for (const [index, operation] of operations.entries()) {
1681
+ const oldText = normalizeEditMatchText(operation.oldText);
1682
+ if (!oldText)
1683
+ continue;
1684
+ let occurrences = 0;
1685
+ let offset = 0;
1686
+ while (true) {
1687
+ const match = normalizedContent.indexOf(oldText, offset);
1688
+ if (match === -1)
1689
+ break;
1690
+ occurrences++;
1691
+ if (occurrences > 1) {
1692
+ throw new Error(`Found ambiguous overlapping occurrences for edits[${index}] in ${filePath}. Provide more context to make oldText unique.`);
1693
+ }
1694
+ offset = match + 1;
1695
+ }
1696
+ }
1697
+ }
1698
+ registerToolIfEnabled("edit", {
1669
1699
  ...origEdit,
1670
1700
  name: "edit",
1671
1701
  parameters: {
@@ -1687,175 +1717,37 @@ export default async function diffRendererExtension(pi) {
1687
1717
  async execute(tid, params, sig, upd, ctx) {
1688
1718
  const fp = params.path ?? params.file_path ?? "";
1689
1719
  const operations = getEditOperations(params);
1690
- // Try cascading replace() first — smarter matching than SDK's exact-only edit
1691
- if (fp && operations.length > 0 && existsSync(fp)) {
1692
- try {
1693
- let content = readFileSync(fp, "utf-8");
1694
- let firstStrategy = "";
1695
- let replaceOk = true;
1696
- for (const op of operations) {
1697
- const r = replace(content, op.oldText, op.newText);
1698
- if (r.changed) {
1699
- content = r.content;
1700
- if (!firstStrategy)
1701
- firstStrategy = r.strategy;
1702
- }
1703
- else {
1704
- replaceOk = false;
1705
- break;
1706
- }
1707
- }
1708
- if (replaceOk) {
1709
- writeFileSync(fp, content, "utf-8");
1710
- const { diffs, summary } = summarizeEditOperations(operations);
1711
- const lg = detectDiffLanguage(fp);
1712
- if (operations.length === 1) {
1713
- let editLine = 0;
1714
- try {
1715
- const idx = content.indexOf(operations[0].newText);
1716
- if (idx >= 0)
1717
- editLine = content.slice(0, idx).split("\n").length;
1718
- }
1719
- catch {
1720
- editLine = 0;
1721
- }
1722
- const useFull = !!params._expandGaps;
1723
- const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1724
- return {
1725
- content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1726
- details: {
1727
- _type: "editInfo",
1728
- summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
1729
- filePath: fp,
1730
- editLine,
1731
- diff: diffData,
1732
- language: lg,
1733
- oldContent: operations[0].oldText,
1734
- newContent: operations[0].newText,
1735
- _replaceStrategy: firstStrategy,
1736
- },
1737
- };
1738
- }
1739
- // Compute the line of the first edit for the title summary
1740
- const firstEditLine = (() => {
1741
- if (!operations[0]?.newText)
1742
- return 0;
1743
- const idx = content.indexOf(operations[0].newText);
1744
- if (idx < 0)
1745
- return 0;
1746
- return content.slice(0, idx).split("\n").length;
1747
- })();
1748
- // Merge all diffs into one combined view for rendering
1749
- const merged = {
1750
- lines: diffs.flatMap((diff, i) => [
1751
- ...(i > 0
1752
- ? [
1753
- {
1754
- type: "sep",
1755
- oldNum: null,
1756
- newNum: null,
1757
- content: `───── Edit ${i + 1} ─────`,
1758
- },
1759
- ]
1760
- : []),
1761
- ...diff.lines,
1762
- ]),
1763
- added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1764
- removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1765
- chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1766
- };
1767
- return {
1768
- content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1769
- details: {
1770
- _type: "multiEditInfo",
1771
- summary: firstEditLine > 0 ? `${summary} at line ${firstEditLine}` : summary,
1772
- filePath: fp,
1773
- editCount: operations.length,
1774
- diffLineCount: merged.lines.length,
1775
- diff: merged,
1776
- language: lg,
1777
- },
1778
- };
1779
- }
1780
- }
1781
- catch (replaceError) {
1782
- // replace() failed; fall through to SDK edit tool
1783
- console.warn(`[pi-diff] replace() failed, falling back to SDK: ${replaceError instanceof Error ? replaceError.message : String(replaceError)}`);
1784
- }
1785
- }
1786
- const result = await origEdit.execute(tid, params, sig, upd, ctx);
1787
- if (operations.length === 0)
1788
- return result;
1789
- const { diffs, summary } = summarizeEditOperations(operations);
1790
- const lg = detectDiffLanguage(fp);
1791
- if (operations.length === 1) {
1792
- let editLine = 0;
1793
- try {
1794
- if (fp && existsSync(fp)) {
1795
- const f = readFileSync(fp, "utf-8");
1796
- const idx = f.indexOf(operations[0].newText);
1797
- if (idx >= 0)
1798
- editLine = f.slice(0, idx).split("\n").length;
1799
- }
1800
- }
1801
- catch {
1802
- editLine = 0;
1803
- }
1804
- const useFull = !!params._expandGaps;
1805
- const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1806
- result.details = {
1807
- _type: "editInfo",
1808
- summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
1809
- filePath: fp,
1810
- editLine,
1811
- diff: diffData,
1812
- language: lg,
1813
- oldContent: operations[0].oldText,
1814
- newContent: operations[0].newText,
1815
- };
1720
+ const guardedEdit = createEditTool(cwd, {
1721
+ operations: {
1722
+ access: (filePath) => accessFile(filePath, constants.R_OK | constants.W_OK),
1723
+ readFile: async (filePath) => {
1724
+ const content = await readFile(filePath);
1725
+ rejectOverlappingEditMatches(content.toString("utf8"), fp, operations);
1726
+ return content;
1727
+ },
1728
+ writeFile: (filePath, content) => writeFile(filePath, content, "utf8"),
1729
+ },
1730
+ });
1731
+ const result = await guardedEdit.execute(tid, params, sig, upd, ctx);
1732
+ const patch = result?.details?.patch;
1733
+ const diff = typeof patch === "string" ? parsePatchFiles(patch)[0] : undefined;
1734
+ if (!diff || (diff.added === 0 && diff.removed === 0))
1816
1735
  return result;
1817
- }
1818
- // Merge all diffs into one combined view for rendering
1819
- const merged = {
1820
- lines: diffs.flatMap((diff, i) => [
1821
- // Add separator between multiple edits
1822
- ...(i > 0
1823
- ? [
1824
- {
1825
- type: "sep",
1826
- oldNum: null,
1827
- newNum: null,
1828
- content: `───── Edit ${i + 1} ─────`,
1829
- },
1830
- ]
1831
- : []),
1832
- ...diff.lines,
1833
- ]),
1834
- added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1835
- removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1836
- chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1837
- };
1838
- let firstEditLine = 0;
1839
- try {
1840
- if (fp) {
1841
- const f = readFileSync(fp, "utf8");
1842
- const idx = f.indexOf(operations[0].newText);
1843
- if (idx >= 0)
1844
- firstEditLine = f.slice(0, idx).split("\n").length;
1845
- }
1846
- }
1847
- catch {
1848
- firstEditLine = 0;
1849
- }
1850
- result.details = {
1851
- _type: "multiEditInfo",
1852
- summary: firstEditLine > 0 ? `${summary} at line ${firstEditLine}` : summary,
1736
+ const editCount = operations.length || (Array.isArray(params?.edits) ? params.edits.length : 1);
1737
+ stashEditHeaderStats(tid, editCount, diff.lines.length, diff.added, diff.removed);
1738
+ const details = {
1739
+ ...result.details,
1740
+ _type: editCount > 1 ? "multiEditInfo" : "editInfo",
1853
1741
  filePath: fp,
1854
- editCount: operations.length,
1855
- diffLineCount: merged.lines.length,
1856
- diff: merged,
1857
- language: lg,
1742
+ diff,
1743
+ diffLineCount: diff.lines.length,
1744
+ editCount,
1745
+ linesAdded: diff.added,
1746
+ linesRemoved: diff.removed,
1747
+ language: detectDiffLanguage(fp),
1748
+ summary: summarize(diff.added, diff.removed),
1858
1749
  };
1750
+ result.details = details;
1859
1751
  return result;
1860
1752
  },
1861
1753
  renderCall(args, theme, ctx) {
@@ -1865,25 +1757,12 @@ export default async function diffRendererExtension(pi) {
1865
1757
  resolvePreviewDiffColors(theme);
1866
1758
  const stats = editCallStatsSuffix(ctx.toolCallId, theme);
1867
1759
  if (ctx.argsComplete && operations.length > 0) {
1868
- let previewLine = 0;
1869
- try {
1870
- if (fp && existsSync(fp)) {
1871
- const cur = readFileSync(fp, "utf-8");
1872
- const idx = cur.indexOf(operations[0].oldText);
1873
- if (idx >= 0)
1874
- previewLine = cur.slice(0, idx).split("\n").length;
1875
- }
1876
- }
1877
- catch {
1878
- previewLine = 0;
1879
- }
1880
- const loc = previewLine > 0 ? `${TOOL_RESULT_INDENT}${theme.fg("muted", `at line ${previewLine}`)}` : "";
1881
1760
  setToolHeaderBg(text);
1882
1761
  text.setText(formatToolFrameHeaderText({
1883
1762
  label: "edit",
1884
1763
  filePath: fp,
1885
1764
  theme,
1886
- suffix: loc,
1765
+ suffix: stats,
1887
1766
  topPad: EDIT_DIFF_RESULT_FRAME.topPad,
1888
1767
  bottomPad: EDIT_DIFF_RESULT_FRAME.bottomPad,
1889
1768
  headerLeftPad: EDIT_DIFF_RESULT_FRAME.headerLeftPad,
@@ -1946,7 +1825,7 @@ export default async function diffRendererExtension(pi) {
1946
1825
  return text;
1947
1826
  },
1948
1827
  });
1949
- pi.registerTool({
1828
+ registerToolIfEnabled("apply_patch", {
1950
1829
  name: "apply_patch",
1951
1830
  label: "apply_patch",
1952
1831
  description: "Multi-file patch engine. One call can add, update, delete, or move multiple files. Uses structured JSON changes array.",
@@ -2032,6 +1911,5 @@ export default async function diffRendererExtension(pi) {
2032
1911
  return text;
2033
1912
  },
2034
1913
  });
2035
- registerEditGuard(pi);
2036
1914
  }
2037
1915
  //# sourceMappingURL=index.js.map