@heyhuynhgiabuu/pi-diff 0.7.1 → 0.7.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
@@ -21,16 +21,14 @@
21
21
  * • Async rendering with invalidate() for non-blocking preview
22
22
  */
23
23
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
24
- import { extname, relative, resolve } from "node:path";
24
+ import { extname, relative } from "node:path";
25
25
  import { codeToANSI } from "@shikijs/cli";
26
26
  import * as Diff from "diff";
27
+ import { executeApplyPatch, formatApplyPatchResult } from "./core/apply-patch.js";
27
28
  import { configIndicatorStyle } from "./core/config.js";
28
29
  import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified, } from "./core/diff.js";
29
30
  import { replace } from "./core/replace.js";
30
31
  import { registerEditGuard } from "./edit-guard.js";
31
- import { initHashline } from "./hashline.js";
32
- import { executeHashlineRead, HASHLINE_EDIT_DESC, HASHLINE_READ_DESC, runHashlineEdit, } from "./core/hashline-execute.js";
33
- import * as fs from "node:fs";
34
32
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
35
33
  const DIFF_PRESETS = {
36
34
  default: {
@@ -207,10 +205,7 @@ function autoDeriveBgFromTheme(theme) {
207
205
  }
208
206
  /** Load diff theme config from .pi/settings.json (project-level, then global). */
209
207
  function loadDiffConfig() {
210
- const paths = [
211
- `${process.cwd()}/.pi/settings.json`,
212
- `${process.env.HOME ?? ""}/.pi/settings.json`,
213
- ];
208
+ const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
214
209
  for (const p of paths) {
215
210
  try {
216
211
  if (existsSync(p)) {
@@ -923,10 +918,7 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
923
918
  newSrc.push(l.content);
924
919
  }
925
920
  const [oldHL, newHL] = canHL
926
- ? await Promise.all([
927
- hlBlock(oldSrc.join("\n"), language),
928
- hlBlock(newSrc.join("\n"), language),
929
- ])
921
+ ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
930
922
  : [oldSrc, newSrc];
931
923
  let oI = 0, nI = 0, idx = 0;
932
924
  const out = [];
@@ -1069,10 +1061,7 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1069
1061
  rightSrc.push(r.right.content);
1070
1062
  }
1071
1063
  const [leftHL, rightHL] = canHL
1072
- ? await Promise.all([
1073
- hlBlock(leftSrc.join("\n"), language),
1074
- hlBlock(rightSrc.join("\n"), language),
1075
- ])
1064
+ ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
1076
1065
  : [leftSrc, rightSrc];
1077
1066
  let lI = 0, rI = 0;
1078
1067
  let stripeRow = 0; // tracks row index for diagonal stripe offset
@@ -1094,13 +1083,7 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1094
1083
  const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE;
1095
1084
  const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
1096
1085
  const sign = isDel ? "-" : isAdd ? "+" : " ";
1097
- const num = isDel
1098
- ? line.oldNum
1099
- : isAdd
1100
- ? line.newNum
1101
- : side === "left"
1102
- ? line.oldNum
1103
- : line.newNum;
1086
+ const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
1104
1087
  // Border bar + colored line numbers for changed lines
1105
1088
  const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
1106
1089
  const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : `${BG_BASE}`;
@@ -1217,31 +1200,20 @@ export default async function diffRendererExtension(pi) {
1217
1200
  return;
1218
1201
  writeHeaderStatsByCallId.set(toolCallId, { added, removed });
1219
1202
  }
1220
- const hashlineEditHeaderStatsByCallId = new Map();
1221
- function stashHashlineEditHeaderStats(toolCallId, diffLines, added, removed) {
1222
- if (!toolCallId)
1223
- return;
1224
- hashlineEditHeaderStatsByCallId.set(toolCallId, { diffLines, added, removed });
1225
- }
1226
- function hashlineEditCallStatsSuffix(toolCallId, theme) {
1227
- const raw = toolCallId ? hashlineEditHeaderStatsByCallId.get(toolCallId) : undefined;
1228
- if (!raw)
1229
- return "";
1230
- const loc = raw.diffLines > 0
1231
- ? `${theme.fg("muted", `${raw.diffLines} diff line${raw.diffLines === 1 ? "" : "s"}`)} `
1232
- : "";
1233
- return `${TOOL_RESULT_INDENT}${loc}${summarizeThemed(raw.added, raw.removed, theme)}`;
1234
- }
1235
1203
  const cwd = process.cwd();
1236
1204
  const home = process.env.HOME ?? "";
1237
1205
  const sp = (p) => shortPath(cwd, home, p);
1238
1206
  const TOOL_RESULT_INDENT = " ";
1239
1207
  const TOOL_HEADER_LEFT_PAD = 0;
1240
1208
  const DIFF_BODY_LEFT_PAD = 0;
1241
- /** Extra frame padding for built-in `edit` tool diff results only. */
1242
- const EDIT_DIFF_RESULT_FRAME = { headerLeftPad: 1, topPad: 1, bottomPad: 1, previewBottomPad: 1 };
1243
- /** hashline_read / hashline_edit: one gap under call title (Pi shell only; no formatBottomPadding on call). */
1244
- const HASHLINE_TOOL_RESULT_FRAME = { previewBottomPad: 0, callTitleBottomPad: 0 };
1209
+ /** Built-in `edit` tool diff result frame; intentionally offset from read/write/apply_patch previews. */
1210
+ const EDIT_DIFF_RESULT_FRAME = {
1211
+ headerLeftPad: 1,
1212
+ bodyLeftPad: 1,
1213
+ topPad: 1,
1214
+ bottomPad: 0,
1215
+ previewBottomPad: 1,
1216
+ };
1245
1217
  function resolvePreviewDiffColors(theme) {
1246
1218
  resolveDiffColors(theme);
1247
1219
  return resolveSharedDiffColors(theme);
@@ -1251,19 +1223,114 @@ export default async function diffRendererExtension(pi) {
1251
1223
  const padding = " ".repeat(Math.max(0, renderWidth - strip(content).length));
1252
1224
  return injectBg(`${content}${padding}`, [], BG_BASE, BG_BASE);
1253
1225
  }
1254
- function formatToolFrameHeader(opts) {
1255
- const { width, topPad = 0, bottomPad = 0, headerLeftPad, suffix = "", label, filePath, theme, meta } = opts;
1226
+ function formatToolFrameHeaderText(opts) {
1227
+ const { topPad = 0, bottomPad = 0, headerLeftPad, suffix = "", label, filePath, theme, meta } = opts;
1256
1228
  const leftPad = " ".repeat(headerLeftPad ?? TOOL_HEADER_LEFT_PAD);
1257
- const content = label !== undefined
1258
- ? `${leftPad}${theme.fg("toolTitle", theme.bold(label))}${filePath ? ` ${theme.fg("accent", sp(filePath))}` : ""}${suffix}`
1259
- : `${leftPad}${meta ?? ""}`;
1260
- const parts = [];
1261
- for (let i = 0; i < topPad; i++)
1262
- parts.push(bgLine("", width));
1263
- parts.push(bgLine(content, width));
1264
- for (let i = 0; i < bottomPad; i++)
1265
- parts.push(bgLine("", width));
1266
- return parts.join("\n");
1229
+ const content = meta !== undefined && meta !== null
1230
+ ? `${leftPad}${meta}${suffix}`
1231
+ : `${leftPad}${theme.fg("toolTitle", theme.bold(label ?? ""))} ${theme.fg("accent", sp(filePath ?? ""))}${suffix}`;
1232
+ return `${"\n".repeat(topPad)}${content}${"\n".repeat(bottomPad)}`;
1233
+ }
1234
+ function formatToolFrameHeader(opts) {
1235
+ const { width, ...rest } = opts;
1236
+ return bgLine(formatToolFrameHeaderText(rest), width);
1237
+ }
1238
+ function setToolHeaderBg(text) {
1239
+ text.customBgFn = (line) => injectBg(line, [], BG_BASE, BG_BASE);
1240
+ }
1241
+ function clearToolHeaderBg(text) {
1242
+ text.customBgFn = undefined;
1243
+ }
1244
+ function summarizeApplyPatchChanges(changes, theme) {
1245
+ const labels = changes.map((change) => theme.fg("accent", sp(change.path)));
1246
+ if (labels.length <= 1)
1247
+ return labels.join("");
1248
+ return `${labels[0]}${theme.fg("muted", `, +${labels.length - 1} more`)}`;
1249
+ }
1250
+ function renderApplyPatchPreview(text, result, theme, ctx) {
1251
+ const applied = Array.isArray(result?.applied) ? result.applied : [];
1252
+ if (!applied.length)
1253
+ return false;
1254
+ const w = termW();
1255
+ const previewable = applied.filter((change) => {
1256
+ if (typeof change?.path !== "string")
1257
+ return false;
1258
+ if (change.action === "add")
1259
+ return typeof change.newContent === "string";
1260
+ if (change.action === "update" || change.action === "delete")
1261
+ return typeof change.oldContent === "string";
1262
+ return false;
1263
+ });
1264
+ if (previewable.length !== applied.length)
1265
+ return false;
1266
+ if (previewable.length === 1) {
1267
+ const change = previewable[0];
1268
+ if (change.action === "add" && typeof change.newContent === "string") {
1269
+ clearToolHeaderBg(text);
1270
+ resolvePreviewDiffColors(theme);
1271
+ const lineCount = change.newContent.split("\n").length;
1272
+ const newHdr = bgLine(`${theme.fg("success", `✓ new file (${lineCount} lines)`)}`, w);
1273
+ const fp = change.path;
1274
+ const pk = `ap:nf:${sharedThemeCacheKey(theme)}:${fp}:${lineCount}`;
1275
+ if (ctx.state._nfk !== pk) {
1276
+ ctx.state._nfk = pk;
1277
+ const lg = detectDiffLanguage(fp);
1278
+ text.__piDiffTask = {
1279
+ placeholder: `${newHdr}\n${padDiffBody(theme.fg("muted", "rendering file…"))}`,
1280
+ fallback: `${newHdr}`,
1281
+ invalidate: ctx.invalidate,
1282
+ key: (width) => `ap:nf:${sharedThemeCacheKey(theme)}:${fp}:${lineCount}:${width}`,
1283
+ render: async (_width) => {
1284
+ const hlLines = await hlBlock(change.newContent, lg);
1285
+ const preview = hlLines.join("\n").replace(/\n+$/, "");
1286
+ return `${newHdr}\n${padDiffBody(preview)}`;
1287
+ },
1288
+ };
1289
+ }
1290
+ return true;
1291
+ }
1292
+ if ((change.action === "update" || change.action === "delete") &&
1293
+ typeof change.oldContent === "string" &&
1294
+ typeof change.path === "string") {
1295
+ const parsed = parseDiff(change.oldContent, change.newContent ?? "");
1296
+ setDiffPreviewTask(text, "ap", (width) => formatToolFrameHeader({
1297
+ meta: `${theme.fg("toolTitle", theme.bold("apply_patch"))}${TOOL_RESULT_INDENT}${theme.fg("muted", `(1 change)`)}${TOOL_RESULT_INDENT}${theme.fg("accent", sp(change.path))}`,
1298
+ theme,
1299
+ width,
1300
+ topPad: 0,
1301
+ bottomPad: 0,
1302
+ }), parsed, detectDiffLanguage(change.path), MAX_PREVIEW_LINES, theme, ctx, { previewBottomPad: 1, compactGutter: true });
1303
+ return true;
1304
+ }
1305
+ return false;
1306
+ }
1307
+ clearToolHeaderBg(text);
1308
+ let added = 0;
1309
+ let removed = 0;
1310
+ let chars = 0;
1311
+ let language;
1312
+ let mixedLanguage = false;
1313
+ const lines = [];
1314
+ for (const change of previewable) {
1315
+ const parsed = parseDiff(change.oldContent ?? "", change.newContent ?? "");
1316
+ const nextLanguage = detectDiffLanguage(change.path);
1317
+ if (!language)
1318
+ language = nextLanguage;
1319
+ else if (language !== nextLanguage)
1320
+ mixedLanguage = true;
1321
+ added += parsed.added;
1322
+ removed += parsed.removed;
1323
+ chars += parsed.chars;
1324
+ lines.push(...parsed.lines);
1325
+ }
1326
+ setDiffPreviewTask(text, "ap", (width) => formatToolFrameHeader({
1327
+ meta: `${theme.fg("toolTitle", theme.bold("apply_patch"))}${TOOL_RESULT_INDENT}${theme.fg("muted", `(${previewable.length} changes)`)} ${summarizeThemed(added, removed, theme)}${TOOL_RESULT_INDENT}${theme.fg("muted", summarizeApplyPatchChanges(previewable, theme))}`,
1328
+ theme,
1329
+ width,
1330
+ topPad: 0,
1331
+ bottomPad: 0,
1332
+ }), { lines, added, removed, chars }, mixedLanguage ? undefined : language, MAX_PREVIEW_LINES, theme, ctx, { previewBottomPad: 1, compactGutter: true });
1333
+ return true;
1267
1334
  }
1268
1335
  function editEditsCountLabel(edits, diffLines, theme) {
1269
1336
  const n = edits === 1 ? "1 edit" : `${edits} edits`;
@@ -1279,9 +1346,7 @@ export default async function diffRendererExtension(pi) {
1279
1346
  function formatEditDiffResultTitle(d, theme, width, frame) {
1280
1347
  const fp = d.filePath ?? d.summary ?? "";
1281
1348
  const edits = d.edits ?? d.editCount ?? 1;
1282
- const diffLines = typeof d.diffLineCount === "number"
1283
- ? d.diffLineCount
1284
- : (d.linesAdded ?? 0) + (d.linesRemoved ?? 0);
1349
+ const diffLines = typeof d.diffLineCount === "number" ? d.diffLineCount : (d.linesAdded ?? 0) + (d.linesRemoved ?? 0);
1285
1350
  const suffix = `${TOOL_RESULT_INDENT}${theme.fg("muted", editEditsCountLabel(edits, diffLines, theme))} ${summarizeThemed(d.linesAdded ?? 0, d.linesRemoved ?? 0, theme)}`;
1286
1351
  return formatToolFrameHeader({
1287
1352
  width,
@@ -1300,16 +1365,20 @@ export default async function diffRendererExtension(pi) {
1300
1365
  return "";
1301
1366
  return `${TOOL_RESULT_INDENT}${summarizeThemed(raw.added, raw.removed, theme)}`;
1302
1367
  }
1303
- function padDiffBody(rendered) {
1304
- const leftPad = `${BG_BASE}${" ".repeat(DIFF_BODY_LEFT_PAD)}${RST}`;
1368
+ function padDiffBody(rendered, bodyLeftPad = DIFF_BODY_LEFT_PAD) {
1369
+ const leftPad = `${BG_BASE}${" ".repeat(bodyLeftPad)}${RST}`;
1305
1370
  return rendered
1306
1371
  .split("\n")
1307
1372
  .map((line) => `${leftPad}${line}`)
1308
1373
  .join("\n");
1309
1374
  }
1310
- async function renderPaddedDiff(diff, language, maxLines, colors, width) {
1311
- const bodyWidth = Math.max(1, width - DIFF_BODY_LEFT_PAD);
1312
- return padDiffBody(await renderSharedSplit(diff, language, maxLines, colors, bodyWidth));
1375
+ async function renderPaddedDiff(diff, language, maxLines, colors, width, bodyLeftPad = DIFF_BODY_LEFT_PAD) {
1376
+ const bodyWidth = Math.max(1, width - bodyLeftPad);
1377
+ return padDiffBody(await renderSharedSplit(diff, language, maxLines, colors, bodyWidth), bodyLeftPad);
1378
+ }
1379
+ async function renderPaddedCompactDiff(diff, language, maxLines, colors, width, bodyLeftPad = DIFF_BODY_LEFT_PAD) {
1380
+ const bodyWidth = Math.max(1, width - bodyLeftPad);
1381
+ return padDiffBody(await renderSharedSplit(diff, language, maxLines, colors, bodyWidth, { compactGutter: true }), bodyLeftPad);
1313
1382
  }
1314
1383
  function diffLineCountLabel(diffLineCount, theme) {
1315
1384
  if (typeof diffLineCount !== "number")
@@ -1319,13 +1388,13 @@ export default async function diffRendererExtension(pi) {
1319
1388
  function setToolHeaderText(text, meta, theme) {
1320
1389
  resolvePreviewDiffColors(theme);
1321
1390
  text.__piDiffTask = undefined;
1322
- const width = termW();
1323
- text.setText(formatToolFrameHeader({ meta, width, bottomPad: 0 }));
1391
+ setToolHeaderBg(text);
1392
+ text.setText(formatToolFrameHeaderText({ meta, bottomPad: 0 }));
1324
1393
  }
1325
1394
  function setDiffPreviewTask(text, keyPrefix, metaOrHeader, diff, language, maxLines, theme, ctx, frame) {
1395
+ clearToolHeaderBg(text);
1326
1396
  const themeKey = sharedThemeCacheKey(theme);
1327
1397
  const colors = resolvePreviewDiffColors(theme);
1328
- const headerKey = typeof metaOrHeader === "function" ? "fn:" + keyPrefix : metaOrHeader;
1329
1398
  const header = frame?.omitHeader
1330
1399
  ? (_width) => ""
1331
1400
  : typeof metaOrHeader === "function"
@@ -1345,11 +1414,16 @@ export default async function diffRendererExtension(pi) {
1345
1414
  return bottom ? `${main}\n${bottom}` : main;
1346
1415
  };
1347
1416
  text.__piDiffTask = {
1348
- placeholder: joinHeaderBody(termW(), padDiffBody(theme.fg("muted", " rendering diff…"))),
1417
+ placeholder: joinHeaderBody(termW(), padDiffBody(theme.fg("muted", " rendering diff…"), frame?.bodyLeftPad)),
1349
1418
  fallback: header(termW()),
1350
1419
  invalidate: ctx.invalidate,
1351
- key: (width) => `${keyPrefix}:${themeKey}:${width}:${headerKey}:${diff.lines.length}:${language ?? ""}:${frame?.omitHeader ? "oh" : "h"}:${frame?.previewBottomPad ?? 0}`,
1352
- render: async (width) => joinHeaderBody(width, await renderPaddedDiff(diff, language, maxLines, colors, width)),
1420
+ key: (width) => {
1421
+ const headerKey = frame?.omitHeader ? "" : header(width);
1422
+ return `${keyPrefix}:${themeKey}:${width}:${headerKey}:${diff.lines.length}:${language ?? ""}:${frame?.omitHeader ? "oh" : "h"}:${frame?.headerLeftPad ?? 0}:${frame?.topPad ?? 0}:${frame?.bottomPad ?? 0}:${frame?.previewBottomPad ?? 0}:${frame?.compactGutter ? "cg" : "rg"}:${frame?.bodyLeftPad ?? 0}`;
1423
+ },
1424
+ render: async (width) => joinHeaderBody(width, await (frame?.compactGutter
1425
+ ? renderPaddedCompactDiff(diff, language, maxLines, colors, width, frame?.bodyLeftPad)
1426
+ : renderPaddedDiff(diff, language, maxLines, colors, width, frame?.bodyLeftPad))),
1353
1427
  };
1354
1428
  }
1355
1429
  /** Wrap a Text component so its render(width) kicks off async diff rendering
@@ -1450,7 +1524,8 @@ export default async function diffRendererExtension(pi) {
1450
1524
  if (args?.content && !ctx.argsComplete) {
1451
1525
  const n = String(args.content).split("\n").length;
1452
1526
  const suffix = `${TOOL_RESULT_INDENT}${theme.fg("muted", `(${n} lines…)`)}${stats ? ` ${stats.trimStart()}` : ""}`;
1453
- text.setText(formatToolFrameHeader({ label, filePath: fp, theme, width: w, suffix, topPad: 0, bottomPad: 0 }));
1527
+ setToolHeaderBg(text);
1528
+ text.setText(formatToolFrameHeaderText({ label, filePath: fp, theme, suffix, topPad: 0, bottomPad: 0 }));
1454
1529
  return text;
1455
1530
  }
1456
1531
  if (args?.content && ctx.argsComplete && isNew) {
@@ -1469,10 +1544,12 @@ export default async function diffRendererExtension(pi) {
1469
1544
  })
1470
1545
  .catch(() => { });
1471
1546
  }
1547
+ clearToolHeaderBg(text);
1472
1548
  text.setText(ctx.state._previewText ?? title);
1473
1549
  return text;
1474
1550
  }
1475
- text.setText(formatToolFrameHeader({ label, filePath: fp, theme, width: w, suffix: stats, topPad: 0, bottomPad: 0 }));
1551
+ setToolHeaderBg(text);
1552
+ text.setText(formatToolFrameHeaderText({ label, filePath: fp, theme, suffix: stats, topPad: 0, bottomPad: 0 }));
1476
1553
  return text;
1477
1554
  },
1478
1555
  renderResult(result, _opt, theme, ctx) {
@@ -1483,21 +1560,28 @@ export default async function diffRendererExtension(pi) {
1483
1560
  .map((c) => c.text || "")
1484
1561
  .join("\n") ?? "Error";
1485
1562
  text.__piDiffTask = undefined;
1563
+ clearToolHeaderBg(text);
1486
1564
  text.setText(`\n${theme.fg("error", e)}`);
1487
1565
  return text;
1488
1566
  }
1489
1567
  const d = result.details;
1490
1568
  if (d?._type === "diff") {
1491
- setDiffPreviewTask(text, "wd", "", d.diff, d.language, MAX_RENDER_LINES, theme, ctx, { omitHeader: true, previewBottomPad: 1 });
1569
+ setDiffPreviewTask(text, "wd", "", d.diff, d.language, MAX_RENDER_LINES, theme, ctx, {
1570
+ omitHeader: true,
1571
+ previewBottomPad: 1,
1572
+ compactGutter: true,
1573
+ });
1492
1574
  return text;
1493
1575
  }
1494
1576
  if (d?._type === "noChange") {
1495
1577
  text.__piDiffTask = undefined;
1578
+ clearToolHeaderBg(text);
1496
1579
  text.setText(`${TOOL_RESULT_INDENT}${theme.fg("muted", "✓ no changes")}`);
1497
1580
  return text;
1498
1581
  }
1499
1582
  if (d?._type === "new") {
1500
1583
  const { lines: lineCount, content: rawContent, filePath: fp } = d;
1584
+ clearToolHeaderBg(text);
1501
1585
  resolvePreviewDiffColors(theme);
1502
1586
  const w = termW();
1503
1587
  const newHdr = bgLine(`${theme.fg("success", `✓ new file (${lineCount} lines)`)}`, w);
@@ -1517,15 +1601,14 @@ export default async function diffRendererExtension(pi) {
1517
1601
  const maxShow = hlLines.length;
1518
1602
  const preview = hlLines.slice(0, maxShow).join("\n").replace(/\n+$/, "");
1519
1603
  const rem = hlLines.length - maxShow;
1520
- const moreLine = rem > 0
1521
- ? `\n${bgLine(`${TOOL_RESULT_INDENT}${theme.fg("muted", `… ${rem} more lines`)}`, width)}`
1522
- : "";
1604
+ const moreLine = rem > 0 ? `\n${bgLine(`${TOOL_RESULT_INDENT}${theme.fg("muted", `… ${rem} more lines`)}`, width)}` : "";
1523
1605
  return `${newHdr}\n${padDiffBody(preview)}${moreLine}`;
1524
1606
  },
1525
1607
  };
1526
1608
  }
1527
1609
  return text;
1528
1610
  }
1611
+ clearToolHeaderBg(text);
1529
1612
  text.setText(`${TOOL_RESULT_INDENT}${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
1530
1613
  return text;
1531
1614
  },
@@ -1538,29 +1621,13 @@ export default async function diffRendererExtension(pi) {
1538
1621
  if (Array.isArray(input?.edits)) {
1539
1622
  return input.edits
1540
1623
  .map((edit) => ({
1541
- oldText: typeof edit?.oldText === "string"
1542
- ? edit.oldText
1543
- : typeof edit?.old_text === "string"
1544
- ? edit.old_text
1545
- : "",
1546
- newText: typeof edit?.newText === "string"
1547
- ? edit.newText
1548
- : typeof edit?.new_text === "string"
1549
- ? edit.new_text
1550
- : "",
1624
+ oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
1625
+ newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
1551
1626
  }))
1552
1627
  .filter((edit) => edit.oldText && edit.oldText !== edit.newText);
1553
1628
  }
1554
- const oldText = typeof input?.oldText === "string"
1555
- ? input.oldText
1556
- : typeof input?.old_text === "string"
1557
- ? input.old_text
1558
- : "";
1559
- const newText = typeof input?.newText === "string"
1560
- ? input.newText
1561
- : typeof input?.new_text === "string"
1562
- ? input.new_text
1563
- : "";
1629
+ const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
1630
+ const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1564
1631
  return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1565
1632
  }
1566
1633
  function summarizeEditOperations(operations) {
@@ -1580,57 +1647,21 @@ export default async function diffRendererExtension(pi) {
1580
1647
  parameters: {
1581
1648
  ...(origEdit.parameters || {}),
1582
1649
  properties: {
1583
- ...(((origEdit.parameters || {}).properties) || {}),
1584
- dryRun: {
1585
- type: "boolean",
1586
- description: "With hashlineChanges: validate anchors and return diff without writing (same as hashline_edit dryRun).",
1650
+ ...(origEdit.parameters?.properties || {}),
1651
+ oldText: {
1652
+ type: "string",
1653
+ description: "Exact existing text to replace.",
1587
1654
  },
1588
- hashlineChanges: {
1589
- type: "array",
1590
- description: "Hashline-anchored edits. Each entry: { hash_range_inclusive: [start, end], content_lines: string[] }. Preferred over oldString/newString — no fuzzy matching, no stale-view risk.",
1591
- items: {
1592
- type: "object",
1593
- properties: {
1594
- hash_range_inclusive: {
1595
- type: "array",
1596
- items: { type: "string" },
1597
- minItems: 2,
1598
- maxItems: 2,
1599
- description: "[start_anchor, end_anchor] from a prior hashline_read. Use identical anchor for a single-line edit.",
1600
- },
1601
- content_lines: {
1602
- type: "array",
1603
- items: { type: "string" },
1604
- description: "Replacement lines. Pass an empty array to delete the range.",
1605
- },
1606
- },
1607
- required: ["hash_range_inclusive", "content_lines"],
1608
- additionalProperties: false,
1609
- },
1655
+ newText: {
1656
+ type: "string",
1657
+ description: "Replacement text for oldText.",
1610
1658
  },
1611
1659
  },
1660
+ required: ["path"],
1661
+ additionalProperties: true,
1612
1662
  },
1613
1663
  async execute(tid, params, sig, upd, ctx) {
1614
1664
  const fp = params.path ?? params.file_path ?? "";
1615
- // Hashline path: strict, no fuzzy fallback, atomic write.
1616
- // If the model uses hashlineChanges, route exclusively here. Otherwise fall through
1617
- // to the legacy 6-strategy oldString/newString path below.
1618
- if (Array.isArray(params.hashlineChanges) && params.hashlineChanges.length > 0) {
1619
- if (!fp) {
1620
- return {
1621
- content: [{ type: "text", text: "[E_BAD_INPUT] path required for hashlineChanges" }],
1622
- isError: true,
1623
- };
1624
- }
1625
- const resolvedFp = resolve(cwd, fp);
1626
- return runHashlineEdit({
1627
- resolvedPath: resolvedFp,
1628
- changes: params.hashlineChanges,
1629
- dryRun: params.dryRun === true,
1630
- toolCallId: tid,
1631
- onDiffStats: (id, diff) => stashHashlineEditHeaderStats(id, diff.lines.length, diff.added, diff.removed),
1632
- });
1633
- }
1634
1665
  const operations = getEditOperations(params);
1635
1666
  // Try cascading replace() first — smarter matching than SDK's exact-only edit
1636
1667
  if (fp && operations.length > 0 && existsSync(fp)) {
@@ -1665,9 +1696,7 @@ export default async function diffRendererExtension(pi) {
1665
1696
  editLine = 0;
1666
1697
  }
1667
1698
  const useFull = !!params._expandGaps;
1668
- const diffData = useFull
1669
- ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1670
- : diffs[0];
1699
+ const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1671
1700
  return {
1672
1701
  content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1673
1702
  details: {
@@ -1749,9 +1778,7 @@ export default async function diffRendererExtension(pi) {
1749
1778
  editLine = 0;
1750
1779
  }
1751
1780
  const useFull = !!params._expandGaps;
1752
- const diffData = useFull
1753
- ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1754
- : diffs[0];
1781
+ const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1755
1782
  result.details = {
1756
1783
  _type: "editInfo",
1757
1784
  summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
@@ -1826,13 +1853,13 @@ export default async function diffRendererExtension(pi) {
1826
1853
  catch {
1827
1854
  previewLine = 0;
1828
1855
  }
1829
- const loc = previewLine > 0 ? ` ${theme.fg("muted", `at line ${previewLine}`)}` : "";
1830
- text.setText(formatToolFrameHeader({
1856
+ const loc = previewLine > 0 ? `${TOOL_RESULT_INDENT}${theme.fg("muted", `at line ${previewLine}`)}` : "";
1857
+ setToolHeaderBg(text);
1858
+ text.setText(formatToolFrameHeaderText({
1831
1859
  label: "edit",
1832
1860
  filePath: fp,
1833
1861
  theme,
1834
- width: termW(),
1835
- suffix: `${stats}${loc}`,
1862
+ suffix: loc,
1836
1863
  topPad: EDIT_DIFF_RESULT_FRAME.topPad,
1837
1864
  bottomPad: EDIT_DIFF_RESULT_FRAME.bottomPad,
1838
1865
  headerLeftPad: EDIT_DIFF_RESULT_FRAME.headerLeftPad,
@@ -1865,17 +1892,23 @@ export default async function diffRendererExtension(pi) {
1865
1892
  }
1866
1893
  const d = result.details;
1867
1894
  if (d?._type === "editInfo" && d.diff) {
1868
- setDiffPreviewTask(text, "ed", "", d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx, { omitHeader: true, previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad });
1869
- return text;
1870
- }
1871
- if ((d?._type === "hashlineEditInfo" || d?._type === "hashlineEditDryRun") && d.diff) {
1872
- setDiffPreviewTask(text, d.path, "", d.diff, undefined, MAX_PREVIEW_LINES, theme, ctx, { omitHeader: true, previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad });
1895
+ setDiffPreviewTask(text, "ed", "", d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx, {
1896
+ omitHeader: true,
1897
+ previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad,
1898
+ compactGutter: true,
1899
+ bodyLeftPad: EDIT_DIFF_RESULT_FRAME.bodyLeftPad,
1900
+ });
1873
1901
  return text;
1874
1902
  }
1875
1903
  if (d?._type === "multiEditInfo") {
1876
1904
  const { editCount, diffLineCount, diff, language } = d;
1877
1905
  if (diff) {
1878
- setDiffPreviewTask(text, "me", "", diff, language, MAX_PREVIEW_LINES, theme, ctx, { omitHeader: true, previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad });
1906
+ setDiffPreviewTask(text, "me", "", diff, language, MAX_PREVIEW_LINES, theme, ctx, {
1907
+ omitHeader: true,
1908
+ previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad,
1909
+ compactGutter: true,
1910
+ bodyLeftPad: EDIT_DIFF_RESULT_FRAME.bodyLeftPad,
1911
+ });
1879
1912
  return text;
1880
1913
  }
1881
1914
  const meta = `${editCount} edits${diffLineCountLabel(diffLineCount, theme)}`;
@@ -1883,172 +1916,94 @@ export default async function diffRendererExtension(pi) {
1883
1916
  return text;
1884
1917
  }
1885
1918
  text.__piDiffTask = undefined;
1919
+ clearToolHeaderBg(text);
1886
1920
  text.setText(`${TOOL_RESULT_INDENT}${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
1887
1921
  return text;
1888
1922
  },
1889
1923
  });
1890
- // Eager-init the hashline engine on session start so first edit has no WASM latency.
1891
- pi.on("session_start", async () => {
1892
- try {
1893
- await initHashline();
1894
- }
1895
- catch {
1896
- // best-effort; lazy init still works on first edit
1897
- }
1898
- });
1899
1924
  pi.registerTool({
1900
- name: "hashline_read",
1901
- label: "hashline_read",
1902
- description: HASHLINE_READ_DESC,
1925
+ name: "apply_patch",
1926
+ label: "apply_patch",
1927
+ description: "Multi-file patch engine. One call can add, update, delete, or move multiple files. Uses structured JSON changes array.",
1903
1928
  parameters: {
1904
1929
  type: "object",
1905
1930
  properties: {
1906
- path: { type: "string", description: "Absolute or relative file path" },
1907
- startLine: { type: "number", description: "Optional 1-indexed start line" },
1908
- endLine: { type: "number", description: "Optional 1-indexed end line (inclusive)" },
1909
- },
1910
- required: ["path"],
1911
- additionalProperties: false,
1912
- },
1913
- renderCall(args, theme, ctx) {
1914
- const fp = args?.path ?? "";
1915
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1916
- resolvePreviewDiffColors(theme);
1917
- const range = typeof args?.startLine === "number" || typeof args?.endLine === "number"
1918
- ? ` ${theme.fg("muted", `lines ${args.startLine ?? 1}-${args.endLine ?? "…"}`)}`
1919
- : "";
1920
- text.setText(formatToolFrameHeader({
1921
- label: "hashline_read",
1922
- filePath: fp,
1923
- theme,
1924
- width: termW(),
1925
- suffix: range,
1926
- topPad: 0,
1927
- bottomPad: HASHLINE_TOOL_RESULT_FRAME.callTitleBottomPad,
1928
- }));
1929
- return text;
1930
- },
1931
- async execute(_tid, params) {
1932
- await initHashline();
1933
- const fp = resolve(cwd, params.path);
1934
- if (!fp) {
1935
- return { content: [{ type: "text", text: "[E_BAD_INPUT] path required" }], isError: true, details: {} };
1936
- }
1937
- let content;
1938
- try {
1939
- content = await fs.promises.readFile(fp, "utf8");
1940
- }
1941
- catch (err) {
1942
- const msg = err instanceof Error ? err.message : String(err);
1943
- return {
1944
- content: [{ type: "text", text: `[E_READ_FAILED] cannot read ${fp}: ${msg}` }],
1945
- isError: true,
1946
- details: {},
1947
- };
1948
- }
1949
- const startLine = typeof params.startLine === "number" ? Math.max(1, params.startLine) : 1;
1950
- const endLine = typeof params.endLine === "number" ? Math.max(startLine, params.endLine) : Infinity;
1951
- return executeHashlineRead(fp, content, startLine, endLine);
1952
- },
1953
- renderResult(result, _opt, theme, ctx) {
1954
- const text = getWidthAwareText(ctx.lastComponent);
1955
- const d = result.details;
1956
- const lineCount = d?.lineCount ?? 0;
1957
- if (ctx.expanded === false) {
1958
- text.__piDiffTask = undefined;
1959
- const hint = theme.fg("dim", `(${lineCount} lines) ctrl+o to expand`);
1960
- text.setText(hint);
1961
- return text;
1962
- }
1963
- text.__piDiffTask = undefined;
1964
- const out = (result.content || []).map((c) => c.text).join("\n");
1965
- text.setText(out);
1966
- return text;
1967
- },
1968
- });
1969
- pi.registerTool({
1970
- name: "hashline_edit",
1971
- label: "hashline_edit",
1972
- description: HASHLINE_EDIT_DESC,
1973
- parameters: {
1974
- type: "object",
1975
- properties: {
1976
- path: { type: "string", description: "Absolute or relative file path" },
1977
- dryRun: {
1978
- type: "boolean",
1979
- description: "If true, validate anchors and return diff without writing the file.",
1980
- },
1981
- hashlineChanges: {
1931
+ changes: {
1982
1932
  type: "array",
1983
- description: "Atomic list of range replacements (empty content_lines deletes the range).",
1933
+ description: "Array of file changes to apply atomically.",
1984
1934
  items: {
1985
1935
  type: "object",
1986
1936
  properties: {
1987
- hash_range_inclusive: {
1988
- type: "array",
1989
- description: "[start_anchor, end_anchor] from hashline_read (LINE│HASH│content).",
1990
- items: { type: "string" },
1991
- minItems: 2,
1992
- maxItems: 2,
1993
- },
1994
- content_lines: {
1995
- type: "array",
1996
- description: "Replacement lines; use [] to delete the anchored range.",
1997
- items: { type: "string" },
1937
+ path: { type: "string", description: "Absolute path to the file." },
1938
+ action: {
1939
+ type: "string",
1940
+ enum: ["add", "update", "delete", "move"],
1941
+ description: "The operation to perform.",
1998
1942
  },
1943
+ content: { type: "string", description: "Content for new files (action=add)." },
1944
+ oldText: { type: "string", description: "Text to find for updates (action=update)." },
1945
+ newText: { type: "string", description: "Replacement text for updates (action=update)." },
1946
+ movePath: { type: "string", description: "Destination path for moves (action=move)." },
1999
1947
  },
2000
- required: ["hash_range_inclusive", "content_lines"],
2001
- additionalProperties: false,
1948
+ required: ["path", "action"],
2002
1949
  },
2003
1950
  },
2004
1951
  },
2005
- required: ["path", "hashlineChanges"],
2006
- additionalProperties: false,
1952
+ required: ["changes"],
1953
+ },
1954
+ async execute(_tid, params) {
1955
+ const changes = (params.changes ?? []).map((c) => ({
1956
+ path: c.path,
1957
+ action: c.action,
1958
+ content: c.content,
1959
+ oldText: c.oldText,
1960
+ newText: c.newText,
1961
+ movePath: c.movePath,
1962
+ }));
1963
+ const result = await executeApplyPatch(changes);
1964
+ const output = formatApplyPatchResult(result);
1965
+ return {
1966
+ content: [{ type: "text", text: output }],
1967
+ isError: !result.ok,
1968
+ details: { _type: "applyPatchInfo", result },
1969
+ };
2007
1970
  },
2008
1971
  renderCall(args, theme, ctx) {
2009
- const fp = args?.path ?? "";
2010
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
2011
- resolvePreviewDiffColors(theme);
2012
- const stats = hashlineEditCallStatsSuffix(ctx.toolCallId, theme);
2013
- text.setText(formatToolFrameHeader({
2014
- label: "hashline_edit",
2015
- filePath: fp,
2016
- theme,
2017
- width: termW(),
2018
- suffix: stats,
1972
+ const text = getWidthAwareText(ctx.lastComponent);
1973
+ const changes = Array.isArray(args?.changes) ? args.changes : [];
1974
+ const count = changes.length;
1975
+ if (ctx.argsComplete && count > 0) {
1976
+ clearToolHeaderBg(text);
1977
+ text.setText("");
1978
+ return text;
1979
+ }
1980
+ const suffix = count
1981
+ ? `${TOOL_RESULT_INDENT}${theme.fg("muted", `(${count} change${count === 1 ? "" : "s"})`)}${TOOL_RESULT_INDENT}${summarizeApplyPatchChanges(changes, theme)}`
1982
+ : `${TOOL_RESULT_INDENT}${theme.fg("muted", "(waiting for changes)")}`;
1983
+ setToolHeaderBg(text);
1984
+ text.setText(formatToolFrameHeaderText({
1985
+ meta: `${theme.fg("toolTitle", theme.bold("apply_patch"))}${suffix}`,
2019
1986
  topPad: 0,
2020
- bottomPad: HASHLINE_TOOL_RESULT_FRAME.callTitleBottomPad,
1987
+ bottomPad: 0,
2021
1988
  }));
2022
1989
  return text;
2023
1990
  },
2024
- async execute(tid, params) {
2025
- await initHashline();
2026
- const fp = resolve(cwd, params.path);
2027
- if (!fp) {
2028
- return { content: [{ type: "text", text: "[E_BAD_INPUT] path required" }], isError: true, details: {} };
2029
- }
2030
- if (!Array.isArray(params.hashlineChanges) || params.hashlineChanges.length === 0) {
2031
- return { content: [{ type: "text", text: "[E_EMPTY] hashlineChanges must be a non-empty array" }], isError: true, details: {} };
2032
- }
2033
- return runHashlineEdit({
2034
- resolvedPath: fp,
2035
- changes: params.hashlineChanges,
2036
- dryRun: params.dryRun === true,
2037
- toolCallId: tid,
2038
- onDiffStats: (id, diff) => stashHashlineEditHeaderStats(id, diff.lines.length, diff.added, diff.removed),
2039
- });
2040
- },
2041
1991
  renderResult(result, _opt, theme, ctx) {
2042
1992
  const text = getWidthAwareText(ctx.lastComponent);
2043
- const d = result.details;
2044
- if ((d?._type === "hashlineEditInfo" || d?._type === "hashlineEditDryRun") && d.diff) {
2045
- setDiffPreviewTask(text, "he", "", d.diff, undefined, MAX_RENDER_LINES, theme, ctx, { omitHeader: true, previewBottomPad: HASHLINE_TOOL_RESULT_FRAME.previewBottomPad });
1993
+ if (ctx.isError) {
1994
+ const out = (result.content || []).map((c) => c.text).join("\n") || "Error";
1995
+ text.__piDiffTask = undefined;
1996
+ clearToolHeaderBg(text);
1997
+ text.setText(theme.fg("error", out));
2046
1998
  return text;
2047
1999
  }
2048
- // Fallback for errors / no-diff
2049
- text.__piDiffTask = undefined;
2000
+ const patchResult = result.details?.result;
2001
+ if (renderApplyPatchPreview(text, patchResult, theme, ctx))
2002
+ return text;
2050
2003
  const out = (result.content || []).map((c) => c.text).join("\n");
2051
- text.setText(theme.fg(ctx.isError ? "error" : "muted", out));
2004
+ text.__piDiffTask = undefined;
2005
+ clearToolHeaderBg(text);
2006
+ text.setText(theme.fg("muted", out));
2052
2007
  return text;
2053
2008
  },
2054
2009
  });