@heyhuynhgiabuu/pi-diff 0.7.2 → 0.7.4

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,19 +1647,7 @@ export default async function diffRendererExtension(pi) {
1580
1647
  parameters: {
1581
1648
  ...(origEdit.parameters || {}),
1582
1649
  properties: {
1583
- ...(((origEdit.parameters || {}).properties) || {}),
1584
- start_hash: {
1585
- type: "string",
1586
- description: "Hashline anchor for the first line to replace (copied from hashline_read HASH column).",
1587
- },
1588
- end_hash: {
1589
- type: "string",
1590
- description: "Hashline anchor for the last line to replace. Use same hash as start_hash for single-line edit.",
1591
- },
1592
- replacement: {
1593
- type: "string",
1594
- description: "Replacement text with literal newlines replacing the anchor range. Empty string deletes the range. Only used with start_hash/end_hash.",
1595
- },
1650
+ ...(origEdit.parameters?.properties || {}),
1596
1651
  oldText: {
1597
1652
  type: "string",
1598
1653
  description: "Exact existing text to replace.",
@@ -1601,35 +1656,12 @@ export default async function diffRendererExtension(pi) {
1601
1656
  type: "string",
1602
1657
  description: "Replacement text for oldText.",
1603
1658
  },
1604
- dryRun: {
1605
- type: "boolean",
1606
- description: "When true: validate anchors and return diff without writing (for hashline path).",
1607
- },
1608
1659
  },
1609
1660
  required: ["path"],
1610
1661
  additionalProperties: true,
1611
1662
  },
1612
1663
  async execute(tid, params, sig, upd, ctx) {
1613
1664
  const fp = params.path ?? params.file_path ?? "";
1614
- // Hashline path: strict, no fuzzy fallback, atomic write.
1615
- if (params.start_hash && params.end_hash) {
1616
- if (!fp) {
1617
- return {
1618
- content: [{ type: "text", text: "[E_BAD_INPUT] path required for hashline" }],
1619
- isError: true,
1620
- };
1621
- }
1622
- const resolvedFp = resolve(cwd, fp);
1623
- const content_lines = params.replacement === "" ? [] : (params.replacement?.split("\n") ?? []);
1624
- const changes = [{ hash_range_inclusive: [params.start_hash, params.end_hash], content_lines }];
1625
- return runHashlineEdit({
1626
- resolvedPath: resolvedFp,
1627
- changes,
1628
- dryRun: params.dryRun === true,
1629
- toolCallId: tid,
1630
- onDiffStats: (id, diff) => stashHashlineEditHeaderStats(id, diff.lines.length, diff.added, diff.removed),
1631
- });
1632
- }
1633
1665
  const operations = getEditOperations(params);
1634
1666
  // Try cascading replace() first — smarter matching than SDK's exact-only edit
1635
1667
  if (fp && operations.length > 0 && existsSync(fp)) {
@@ -1664,9 +1696,7 @@ export default async function diffRendererExtension(pi) {
1664
1696
  editLine = 0;
1665
1697
  }
1666
1698
  const useFull = !!params._expandGaps;
1667
- const diffData = useFull
1668
- ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1669
- : diffs[0];
1699
+ const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1670
1700
  return {
1671
1701
  content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1672
1702
  details: {
@@ -1748,9 +1778,7 @@ export default async function diffRendererExtension(pi) {
1748
1778
  editLine = 0;
1749
1779
  }
1750
1780
  const useFull = !!params._expandGaps;
1751
- const diffData = useFull
1752
- ? parseDiff(operations[0].oldText, operations[0].newText, undefined)
1753
- : diffs[0];
1781
+ const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1754
1782
  result.details = {
1755
1783
  _type: "editInfo",
1756
1784
  summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
@@ -1825,13 +1853,13 @@ export default async function diffRendererExtension(pi) {
1825
1853
  catch {
1826
1854
  previewLine = 0;
1827
1855
  }
1828
- const loc = previewLine > 0 ? ` ${theme.fg("muted", `at line ${previewLine}`)}` : "";
1829
- text.setText(formatToolFrameHeader({
1856
+ const loc = previewLine > 0 ? `${TOOL_RESULT_INDENT}${theme.fg("muted", `at line ${previewLine}`)}` : "";
1857
+ setToolHeaderBg(text);
1858
+ text.setText(formatToolFrameHeaderText({
1830
1859
  label: "edit",
1831
1860
  filePath: fp,
1832
1861
  theme,
1833
- width: termW(),
1834
- suffix: `${stats}${loc}`,
1862
+ suffix: loc,
1835
1863
  topPad: EDIT_DIFF_RESULT_FRAME.topPad,
1836
1864
  bottomPad: EDIT_DIFF_RESULT_FRAME.bottomPad,
1837
1865
  headerLeftPad: EDIT_DIFF_RESULT_FRAME.headerLeftPad,
@@ -1864,17 +1892,23 @@ export default async function diffRendererExtension(pi) {
1864
1892
  }
1865
1893
  const d = result.details;
1866
1894
  if (d?._type === "editInfo" && d.diff) {
1867
- setDiffPreviewTask(text, "ed", "", d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx, { omitHeader: true, previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad });
1868
- return text;
1869
- }
1870
- if ((d?._type === "hashlineEditInfo" || d?._type === "hashlineEditDryRun") && d.diff) {
1871
- 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
+ });
1872
1901
  return text;
1873
1902
  }
1874
1903
  if (d?._type === "multiEditInfo") {
1875
1904
  const { editCount, diffLineCount, diff, language } = d;
1876
1905
  if (diff) {
1877
- 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
+ });
1878
1912
  return text;
1879
1913
  }
1880
1914
  const meta = `${editCount} edits${diffLineCountLabel(diffLineCount, theme)}`;
@@ -1882,163 +1916,94 @@ export default async function diffRendererExtension(pi) {
1882
1916
  return text;
1883
1917
  }
1884
1918
  text.__piDiffTask = undefined;
1919
+ clearToolHeaderBg(text);
1885
1920
  text.setText(`${TOOL_RESULT_INDENT}${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
1886
1921
  return text;
1887
1922
  },
1888
1923
  });
1889
- // Eager-init the hashline engine on session start so first edit has no WASM latency.
1890
- pi.on("session_start", async () => {
1891
- try {
1892
- await initHashline();
1893
- }
1894
- catch {
1895
- // best-effort; lazy init still works on first edit
1896
- }
1897
- });
1898
1924
  pi.registerTool({
1899
- name: "hashline_read",
1900
- label: "hashline_read",
1901
- 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.",
1902
1928
  parameters: {
1903
1929
  type: "object",
1904
1930
  properties: {
1905
- path: { type: "string", description: "Absolute or relative file path" },
1906
- startLine: { type: "number", description: "Optional 1-indexed start line" },
1907
- endLine: { type: "number", description: "Optional 1-indexed end line (inclusive)" },
1931
+ changes: {
1932
+ type: "array",
1933
+ description: "Array of file changes to apply atomically.",
1934
+ items: {
1935
+ type: "object",
1936
+ properties: {
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.",
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)." },
1947
+ },
1948
+ required: ["path", "action"],
1949
+ },
1950
+ },
1908
1951
  },
1909
- required: ["path"],
1910
- additionalProperties: false,
1911
- },
1912
- renderCall(args, theme, ctx) {
1913
- const fp = args?.path ?? "";
1914
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1915
- resolvePreviewDiffColors(theme);
1916
- const range = typeof args?.startLine === "number" || typeof args?.endLine === "number"
1917
- ? ` ${theme.fg("muted", `lines ${args.startLine ?? 1}-${args.endLine ?? "…"}`)}`
1918
- : "";
1919
- text.setText(formatToolFrameHeader({
1920
- label: "hashline_read",
1921
- filePath: fp,
1922
- theme,
1923
- width: termW(),
1924
- suffix: range,
1925
- topPad: 0,
1926
- bottomPad: HASHLINE_TOOL_RESULT_FRAME.callTitleBottomPad,
1927
- }));
1928
- return text;
1952
+ required: ["changes"],
1929
1953
  },
1930
1954
  async execute(_tid, params) {
1931
- await initHashline();
1932
- const fp = resolve(cwd, params.path);
1933
- if (!fp) {
1934
- return { content: [{ type: "text", text: "[E_BAD_INPUT] path required" }], isError: true, details: {} };
1935
- }
1936
- let content;
1937
- try {
1938
- content = await fs.promises.readFile(fp, "utf8");
1939
- }
1940
- catch (err) {
1941
- const msg = err instanceof Error ? err.message : String(err);
1942
- return {
1943
- content: [{ type: "text", text: `[E_READ_FAILED] cannot read ${fp}: ${msg}` }],
1944
- isError: true,
1945
- details: {},
1946
- };
1947
- }
1948
- const startLine = typeof params.startLine === "number" ? Math.max(1, params.startLine) : 1;
1949
- const endLine = typeof params.endLine === "number" ? Math.max(startLine, params.endLine) : Infinity;
1950
- return executeHashlineRead(fp, content, startLine, endLine);
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
+ };
1951
1970
  },
1952
- renderResult(result, _opt, theme, ctx) {
1971
+ renderCall(args, theme, ctx) {
1953
1972
  const text = getWidthAwareText(ctx.lastComponent);
1954
- const d = result.details;
1955
- const lineCount = d?.lineCount ?? 0;
1956
- if (ctx.expanded === false) {
1957
- text.__piDiffTask = undefined;
1958
- const hint = theme.fg("dim", `(${lineCount} lines) ctrl+o to expand`);
1959
- text.setText(hint);
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("");
1960
1978
  return text;
1961
1979
  }
1962
- text.__piDiffTask = undefined;
1963
- const out = (result.content || []).map((c) => c.text).join("\n");
1964
- text.setText(out);
1965
- return text;
1966
- },
1967
- });
1968
- pi.registerTool({
1969
- name: "hashline_edit",
1970
- label: "hashline_edit",
1971
- description: HASHLINE_EDIT_DESC,
1972
- parameters: {
1973
- type: "object",
1974
- properties: {
1975
- path: { type: "string", description: "Absolute or relative file path" },
1976
- start_hash: {
1977
- type: "string",
1978
- description: "Hashline anchor for the first line to replace (copied from hashline_read HASH column).",
1979
- },
1980
- end_hash: {
1981
- type: "string",
1982
- description: "Hashline anchor for the last line to replace. Use same hash as start_hash for single-line edit (copied from hashline_read HASH column).",
1983
- },
1984
- replacement: {
1985
- type: "string",
1986
- description: "Replacement text with literal newlines replacing the anchor range. Empty string deletes the range.",
1987
- },
1988
- dryRun: {
1989
- type: "boolean",
1990
- description: "If true, validate anchors and return diff without writing the file.",
1991
- },
1992
- },
1993
- required: ["path", "start_hash", "end_hash"],
1994
- additionalProperties: false,
1995
- },
1996
- renderCall(args, theme, ctx) {
1997
- const fp = args?.path ?? "";
1998
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1999
- resolvePreviewDiffColors(theme);
2000
- const stats = hashlineEditCallStatsSuffix(ctx.toolCallId, theme);
2001
- text.setText(formatToolFrameHeader({
2002
- label: "hashline_edit",
2003
- filePath: fp,
2004
- theme,
2005
- width: termW(),
2006
- suffix: stats,
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}`,
2007
1986
  topPad: 0,
2008
- bottomPad: HASHLINE_TOOL_RESULT_FRAME.callTitleBottomPad,
1987
+ bottomPad: 0,
2009
1988
  }));
2010
1989
  return text;
2011
1990
  },
2012
- async execute(tid, params) {
2013
- await initHashline();
2014
- const fp = resolve(cwd, params.path);
2015
- if (!fp) {
2016
- return { content: [{ type: "text", text: "[E_BAD_INPUT] path required" }], isError: true, details: {} };
2017
- }
2018
- if (!params.start_hash || !params.end_hash) {
2019
- return { content: [{ type: "text", text: "[E_BAD_INPUT] start_hash and end_hash required" }], isError: true, details: {} };
2020
- }
2021
- const content_lines = params.replacement === "" ? [] : (params.replacement?.split("\n") ?? []);
2022
- const changes = [{ hash_range_inclusive: [params.start_hash, params.end_hash], content_lines }];
2023
- return runHashlineEdit({
2024
- resolvedPath: fp,
2025
- changes,
2026
- dryRun: params.dryRun === true,
2027
- toolCallId: tid,
2028
- onDiffStats: (id, diff) => stashHashlineEditHeaderStats(id, diff.lines.length, diff.added, diff.removed),
2029
- });
2030
- },
2031
1991
  renderResult(result, _opt, theme, ctx) {
2032
1992
  const text = getWidthAwareText(ctx.lastComponent);
2033
- const d = result.details;
2034
- if ((d?._type === "hashlineEditInfo" || d?._type === "hashlineEditDryRun") && d.diff) {
2035
- 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));
2036
1998
  return text;
2037
1999
  }
2038
- // Fallback for errors / no-diff
2039
- text.__piDiffTask = undefined;
2000
+ const patchResult = result.details?.result;
2001
+ if (renderApplyPatchPreview(text, patchResult, theme, ctx))
2002
+ return text;
2040
2003
  const out = (result.content || []).map((c) => c.text).join("\n");
2041
- text.setText(theme.fg(ctx.isError ? "error" : "muted", out));
2004
+ text.__piDiffTask = undefined;
2005
+ clearToolHeaderBg(text);
2006
+ text.setText(theme.fg("muted", out));
2042
2007
  return text;
2043
2008
  },
2044
2009
  });