@heyhuynhgiabuu/pi-diff 0.6.10 → 0.7.1

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,13 +21,16 @@
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 } from "node:path";
24
+ import { extname, relative, resolve } from "node:path";
25
25
  import { codeToANSI } from "@shikijs/cli";
26
26
  import * as Diff from "diff";
27
27
  import { configIndicatorStyle } from "./core/config.js";
28
28
  import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified, } from "./core/diff.js";
29
29
  import { replace } from "./core/replace.js";
30
30
  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";
31
34
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
32
35
  const DIFF_PRESETS = {
33
36
  default: {
@@ -930,7 +933,7 @@ async function renderUnified(diff, language, max = MAX_RENDER_LINES, dc = DEFAUL
930
933
  /** Emit a single stacked row with compact gutter + left border bar. */
931
934
  function emitRow(num, sign, gutterBg, signFg, body, bodyBg = "") {
932
935
  const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
933
- const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : `${BG_BASE} `;
936
+ const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : `${BG_BASE}`;
934
937
  const numFg = borderFg || FG_LNUM;
935
938
  const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${gutterBg} ${signFg}${sign}${gutterBg} ${RST}`;
936
939
  const contGutter = `${border}${gutterBg}${" ".repeat(nw + 3)}${RST}`;
@@ -1083,7 +1086,7 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1083
1086
  const label = sepLabelSplit(getSepStyle(), line.hunkMeta, line.newNum, line.content);
1084
1087
  if (!label)
1085
1088
  return { gutter: "", contGutter: "", bodyRows: [""] };
1086
- const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 3)}${RST}`;
1089
+ const g = `${BG_BASE}${FG_DIM}${fit("", nw + 3)}${RST}`;
1087
1090
  return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] };
1088
1091
  }
1089
1092
  const isDel = line.type === "del", isAdd = line.type === "add";
@@ -1100,7 +1103,7 @@ async function renderSplit(diff, language, max = MAX_PREVIEW_LINES, dc = DEFAULT
1100
1103
  : line.newNum;
1101
1104
  // Border bar + colored line numbers for changed lines
1102
1105
  const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
1103
- const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : ` ${BG_BASE}`;
1106
+ const border = borderFg ? `${borderFg}${getBorderBar()}${RST}` : `${BG_BASE}`;
1104
1107
  const numFg = borderFg || FG_LNUM;
1105
1108
  let body;
1106
1109
  if (ranges && ranges.length > 0) {
@@ -1214,14 +1217,31 @@ export default async function diffRendererExtension(pi) {
1214
1217
  return;
1215
1218
  writeHeaderStatsByCallId.set(toolCallId, { added, removed });
1216
1219
  }
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
+ }
1217
1235
  const cwd = process.cwd();
1218
1236
  const home = process.env.HOME ?? "";
1219
1237
  const sp = (p) => shortPath(cwd, home, p);
1220
1238
  const TOOL_RESULT_INDENT = " ";
1221
- const TOOL_HEADER_LEFT_PAD = 1;
1222
- const TOOL_HEADER_TOP_PAD = 1;
1223
- const TOOL_PREVIEW_BOTTOM_PAD = 1;
1239
+ const TOOL_HEADER_LEFT_PAD = 0;
1224
1240
  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 };
1225
1245
  function resolvePreviewDiffColors(theme) {
1226
1246
  resolveDiffColors(theme);
1227
1247
  return resolveSharedDiffColors(theme);
@@ -1231,25 +1251,19 @@ export default async function diffRendererExtension(pi) {
1231
1251
  const padding = " ".repeat(Math.max(0, renderWidth - strip(content).length));
1232
1252
  return injectBg(`${content}${padding}`, [], BG_BASE, BG_BASE);
1233
1253
  }
1234
- function formatToolHeader(summary, width) {
1235
- const leftPad = " ".repeat(TOOL_HEADER_LEFT_PAD);
1236
- const meta = `${leftPad}${summary}`;
1237
- return bgLine(meta, width);
1238
- }
1239
- function formatToolTitle(label, filePath, theme, width, suffix = "") {
1240
- const leftPad = " ".repeat(TOOL_HEADER_LEFT_PAD);
1241
- const displayPath = filePath ? ` ${theme.fg("accent", sp(filePath))}` : "";
1242
- const top = Array.from({ length: TOOL_HEADER_TOP_PAD }, () => bgLine("", width));
1243
- return [
1244
- ...top,
1245
- bgLine(`${leftPad}${theme.fg("toolTitle", theme.bold(label))}${displayPath}${suffix}`, width),
1246
- ].join("\n");
1247
- }
1248
- function formatBottomPadding(width) {
1249
- return Array.from({ length: TOOL_PREVIEW_BOTTOM_PAD }, () => bgLine("", width)).join("\n");
1250
- }
1251
- function formatToolCallHeader(label, filePath, theme, width, suffix = "") {
1252
- return [formatToolTitle(label, filePath, theme, width, suffix), formatBottomPadding(width)].join("\n");
1254
+ function formatToolFrameHeader(opts) {
1255
+ const { width, topPad = 0, bottomPad = 0, headerLeftPad, suffix = "", label, filePath, theme, meta } = opts;
1256
+ 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");
1253
1267
  }
1254
1268
  function editEditsCountLabel(edits, diffLines, theme) {
1255
1269
  const n = edits === 1 ? "1 edit" : `${edits} edits`;
@@ -1262,6 +1276,24 @@ export default async function diffRendererExtension(pi) {
1262
1276
  const count = editEditsCountLabel(raw.edits, raw.diffLines, theme);
1263
1277
  return `${TOOL_RESULT_INDENT}${theme.fg("muted", count)} ${summarizeThemed(raw.added, raw.removed, theme)}`;
1264
1278
  }
1279
+ function formatEditDiffResultTitle(d, theme, width, frame) {
1280
+ const fp = d.filePath ?? d.summary ?? "";
1281
+ const edits = d.edits ?? d.editCount ?? 1;
1282
+ const diffLines = typeof d.diffLineCount === "number"
1283
+ ? d.diffLineCount
1284
+ : (d.linesAdded ?? 0) + (d.linesRemoved ?? 0);
1285
+ const suffix = `${TOOL_RESULT_INDENT}${theme.fg("muted", editEditsCountLabel(edits, diffLines, theme))} ${summarizeThemed(d.linesAdded ?? 0, d.linesRemoved ?? 0, theme)}`;
1286
+ return formatToolFrameHeader({
1287
+ width,
1288
+ label: "edit",
1289
+ filePath: fp,
1290
+ suffix,
1291
+ theme,
1292
+ topPad: frame.topPad ?? 0,
1293
+ bottomPad: frame.bottomPad ?? 0,
1294
+ headerLeftPad: frame.headerLeftPad,
1295
+ });
1296
+ }
1265
1297
  function writeCallStatsSuffix(toolCallId, theme) {
1266
1298
  const raw = toolCallId ? writeHeaderStatsByCallId.get(toolCallId) : undefined;
1267
1299
  if (!raw)
@@ -1288,18 +1320,36 @@ export default async function diffRendererExtension(pi) {
1288
1320
  resolvePreviewDiffColors(theme);
1289
1321
  text.__piDiffTask = undefined;
1290
1322
  const width = termW();
1291
- text.setText(`${formatToolHeader(meta, width)}\n${formatBottomPadding(width)}`);
1323
+ text.setText(formatToolFrameHeader({ meta, width, bottomPad: 0 }));
1292
1324
  }
1293
- function setDiffPreviewTask(text, keyPrefix, meta, diff, language, maxLines, theme, ctx) {
1325
+ function setDiffPreviewTask(text, keyPrefix, metaOrHeader, diff, language, maxLines, theme, ctx, frame) {
1294
1326
  const themeKey = sharedThemeCacheKey(theme);
1295
1327
  const colors = resolvePreviewDiffColors(theme);
1296
- const header = (width) => formatToolHeader(meta, width);
1328
+ const headerKey = typeof metaOrHeader === "function" ? "fn:" + keyPrefix : metaOrHeader;
1329
+ const header = frame?.omitHeader
1330
+ ? (_width) => ""
1331
+ : typeof metaOrHeader === "function"
1332
+ ? metaOrHeader
1333
+ : (width) => formatToolFrameHeader({
1334
+ meta: metaOrHeader,
1335
+ width,
1336
+ topPad: frame?.topPad ?? 0,
1337
+ bottomPad: frame?.bottomPad ?? 0,
1338
+ headerLeftPad: frame?.headerLeftPad,
1339
+ });
1340
+ const joinHeaderBody = (width, body) => {
1341
+ const h = header(width);
1342
+ const bottomPad = Math.max(0, frame?.previewBottomPad ?? 0);
1343
+ const bottom = Array.from({ length: bottomPad }, () => bgLine("", width)).join("\n");
1344
+ const main = h ? `${h}\n${body}` : body;
1345
+ return bottom ? `${main}\n${bottom}` : main;
1346
+ };
1297
1347
  text.__piDiffTask = {
1298
- placeholder: `${header(termW())}\n${padDiffBody(theme.fg("muted", "rendering diff…"))}\n${formatBottomPadding(termW())}`,
1299
- fallback: `${header(termW())}\n${formatBottomPadding(termW())}`,
1348
+ placeholder: joinHeaderBody(termW(), padDiffBody(theme.fg("muted", " rendering diff…"))),
1349
+ fallback: header(termW()),
1300
1350
  invalidate: ctx.invalidate,
1301
- key: (width) => `${keyPrefix}:${themeKey}:${width}:${meta}:${diff.lines.length}:${language ?? ""}`,
1302
- render: async (width) => `${header(width)}\n${await renderPaddedDiff(diff, language, maxLines, colors, width)}\n${formatBottomPadding(width)}`,
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)),
1303
1353
  };
1304
1354
  }
1305
1355
  /** Wrap a Text component so its render(width) kicks off async diff rendering
@@ -1400,11 +1450,11 @@ export default async function diffRendererExtension(pi) {
1400
1450
  if (args?.content && !ctx.argsComplete) {
1401
1451
  const n = String(args.content).split("\n").length;
1402
1452
  const suffix = `${TOOL_RESULT_INDENT}${theme.fg("muted", `(${n} lines…)`)}${stats ? ` ${stats.trimStart()}` : ""}`;
1403
- text.setText(formatToolCallHeader(label, fp, theme, w, suffix));
1453
+ text.setText(formatToolFrameHeader({ label, filePath: fp, theme, width: w, suffix, topPad: 0, bottomPad: 0 }));
1404
1454
  return text;
1405
1455
  }
1406
1456
  if (args?.content && ctx.argsComplete && isNew) {
1407
- const title = formatToolCallHeader(label, fp, theme, w);
1457
+ const title = formatToolFrameHeader({ label, filePath: fp, theme, width: w, topPad: 0, bottomPad: 0 });
1408
1458
  const previewKey = `create:${sharedThemeCacheKey(theme)}:${fp}:${String(args.content).length}`;
1409
1459
  if (ctx.state._previewKey !== previewKey) {
1410
1460
  ctx.state._previewKey = previewKey;
@@ -1422,7 +1472,7 @@ export default async function diffRendererExtension(pi) {
1422
1472
  text.setText(ctx.state._previewText ?? title);
1423
1473
  return text;
1424
1474
  }
1425
- text.setText(formatToolCallHeader(label, fp, theme, w, stats));
1475
+ text.setText(formatToolFrameHeader({ label, filePath: fp, theme, width: w, suffix: stats, topPad: 0, bottomPad: 0 }));
1426
1476
  return text;
1427
1477
  },
1428
1478
  renderResult(result, _opt, theme, ctx) {
@@ -1438,7 +1488,7 @@ export default async function diffRendererExtension(pi) {
1438
1488
  }
1439
1489
  const d = result.details;
1440
1490
  if (d?._type === "diff") {
1441
- setDiffPreviewTask(text, "wd", "", d.diff, d.language, MAX_RENDER_LINES, theme, ctx);
1491
+ setDiffPreviewTask(text, "wd", "", d.diff, d.language, MAX_RENDER_LINES, theme, ctx, { omitHeader: true, previewBottomPad: 1 });
1442
1492
  return text;
1443
1493
  }
1444
1494
  if (d?._type === "noChange") {
@@ -1448,31 +1498,32 @@ export default async function diffRendererExtension(pi) {
1448
1498
  }
1449
1499
  if (d?._type === "new") {
1450
1500
  const { lines: lineCount, content: rawContent, filePath: fp } = d;
1501
+ resolvePreviewDiffColors(theme);
1451
1502
  const w = termW();
1452
1503
  const newHdr = bgLine(`${theme.fg("success", `✓ new file (${lineCount} lines)`)}`, w);
1453
1504
  const pk = `nf:${sharedThemeCacheKey(theme)}:${fp}:${lineCount}`;
1454
1505
  if (ctx.state._nfk !== pk) {
1455
1506
  ctx.state._nfk = pk;
1456
- ctx.state._nft = newHdr;
1457
1507
  const lg = detectDiffLanguage(fp);
1458
- if (rawContent) {
1459
- hlBlock(rawContent, lg)
1460
- .then((hlLines) => {
1461
- if (ctx.state._nfk !== pk)
1462
- return;
1508
+ text.__piDiffTask = {
1509
+ placeholder: `${newHdr}\n${padDiffBody(theme.fg("muted", "rendering file…"))}`,
1510
+ fallback: `${newHdr}`,
1511
+ invalidate: ctx.invalidate,
1512
+ key: (width) => `nf:${sharedThemeCacheKey(theme)}:${fp}:${lineCount}:${width}`,
1513
+ render: async (width) => {
1514
+ if (!rawContent)
1515
+ return `${newHdr}`;
1516
+ const hlLines = await hlBlock(rawContent, lg);
1463
1517
  const maxShow = hlLines.length;
1464
- const preview = hlLines.slice(0, maxShow).join("\n");
1518
+ const preview = hlLines.slice(0, maxShow).join("\n").replace(/\n+$/, "");
1465
1519
  const rem = hlLines.length - maxShow;
1466
- let out = `${newHdr}\n${padDiffBody(preview)}`;
1467
- if (rem > 0)
1468
- out += `\n${bgLine(`${TOOL_RESULT_INDENT}${theme.fg("muted", `… ${rem} more lines`)}`, w)}`;
1469
- ctx.state._nft = out;
1470
- ctx.invalidate();
1471
- })
1472
- .catch(() => { });
1473
- }
1520
+ const moreLine = rem > 0
1521
+ ? `\n${bgLine(`${TOOL_RESULT_INDENT}${theme.fg("muted", `… ${rem} more lines`)}`, width)}`
1522
+ : "";
1523
+ return `${newHdr}\n${padDiffBody(preview)}${moreLine}`;
1524
+ },
1525
+ };
1474
1526
  }
1475
- text.setText(ctx.state._nft ?? newHdr);
1476
1527
  return text;
1477
1528
  }
1478
1529
  text.setText(`${TOOL_RESULT_INDENT}${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
@@ -1526,8 +1577,60 @@ export default async function diffRendererExtension(pi) {
1526
1577
  pi.registerTool({
1527
1578
  ...origEdit,
1528
1579
  name: "edit",
1580
+ parameters: {
1581
+ ...(origEdit.parameters || {}),
1582
+ 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).",
1587
+ },
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
+ },
1610
+ },
1611
+ },
1612
+ },
1529
1613
  async execute(tid, params, sig, upd, ctx) {
1530
1614
  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
+ }
1531
1634
  const operations = getEditOperations(params);
1532
1635
  // Try cascading replace() first — smarter matching than SDK's exact-only edit
1533
1636
  if (fp && operations.length > 0 && existsSync(fp)) {
@@ -1709,12 +1812,8 @@ export default async function diffRendererExtension(pi) {
1709
1812
  const operations = getEditOperations(args);
1710
1813
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1711
1814
  resolvePreviewDiffColors(theme);
1815
+ const stats = editCallStatsSuffix(ctx.toolCallId, theme);
1712
1816
  if (ctx.argsComplete && operations.length > 0) {
1713
- const { totalAdded, totalRemoved } = summarizeEditOperations(operations);
1714
- // Compute the line number for the call preview by reading the
1715
- // file and finding the first oldText. This lets the title show
1716
- // "at line N" even before the edit executes, matching what the
1717
- // result title will show.
1718
1817
  let previewLine = 0;
1719
1818
  try {
1720
1819
  if (fp && existsSync(fp)) {
@@ -1728,10 +1827,28 @@ export default async function diffRendererExtension(pi) {
1728
1827
  previewLine = 0;
1729
1828
  }
1730
1829
  const loc = previewLine > 0 ? ` ${theme.fg("muted", `at line ${previewLine}`)}` : "";
1731
- text.setText(formatToolTitle("edit", fp, theme, termW(), `${TOOL_RESULT_INDENT}${theme.fg("muted", editEditsCountLabel(operations.length, totalAdded + totalRemoved, theme))} ${summarizeThemed(totalAdded, totalRemoved, theme)}${loc}`));
1830
+ text.setText(formatToolFrameHeader({
1831
+ label: "edit",
1832
+ filePath: fp,
1833
+ theme,
1834
+ width: termW(),
1835
+ suffix: `${stats}${loc}`,
1836
+ topPad: EDIT_DIFF_RESULT_FRAME.topPad,
1837
+ bottomPad: EDIT_DIFF_RESULT_FRAME.bottomPad,
1838
+ headerLeftPad: EDIT_DIFF_RESULT_FRAME.headerLeftPad,
1839
+ }));
1732
1840
  }
1733
1841
  else {
1734
- text.setText(formatToolTitle("edit", fp, theme, termW()));
1842
+ text.setText(formatToolFrameHeader({
1843
+ label: "edit",
1844
+ filePath: fp,
1845
+ theme,
1846
+ width: termW(),
1847
+ suffix: stats,
1848
+ topPad: EDIT_DIFF_RESULT_FRAME.topPad,
1849
+ bottomPad: EDIT_DIFF_RESULT_FRAME.bottomPad,
1850
+ headerLeftPad: EDIT_DIFF_RESULT_FRAME.headerLeftPad,
1851
+ }));
1735
1852
  }
1736
1853
  return text;
1737
1854
  },
@@ -1748,20 +1865,20 @@ export default async function diffRendererExtension(pi) {
1748
1865
  }
1749
1866
  const d = result.details;
1750
1867
  if (d?._type === "editInfo" && d.diff) {
1751
- setDiffPreviewTask(text, "ed", "", d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx);
1868
+ setDiffPreviewTask(text, "ed", "", d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx, { omitHeader: true, previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad });
1752
1869
  return text;
1753
1870
  }
1754
- if (d?._type === "editInfo") {
1755
- setToolHeaderText(text, "", theme);
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 });
1756
1873
  return text;
1757
1874
  }
1758
1875
  if (d?._type === "multiEditInfo") {
1759
1876
  const { editCount, diffLineCount, diff, language } = d;
1760
- const meta = `${editCount} edits${diffLineCountLabel(diffLineCount, theme)}`;
1761
1877
  if (diff) {
1762
- setDiffPreviewTask(text, "me", meta, diff, language, MAX_PREVIEW_LINES, theme, ctx);
1878
+ setDiffPreviewTask(text, "me", "", diff, language, MAX_PREVIEW_LINES, theme, ctx, { omitHeader: true, previewBottomPad: EDIT_DIFF_RESULT_FRAME.previewBottomPad });
1763
1879
  return text;
1764
1880
  }
1881
+ const meta = `${editCount} edits${diffLineCountLabel(diffLineCount, theme)}`;
1765
1882
  setToolHeaderText(text, meta, theme);
1766
1883
  return text;
1767
1884
  }
@@ -1770,6 +1887,171 @@ export default async function diffRendererExtension(pi) {
1770
1887
  return text;
1771
1888
  },
1772
1889
  });
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
+ pi.registerTool({
1900
+ name: "hashline_read",
1901
+ label: "hashline_read",
1902
+ description: HASHLINE_READ_DESC,
1903
+ parameters: {
1904
+ type: "object",
1905
+ 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: {
1982
+ type: "array",
1983
+ description: "Atomic list of range replacements (empty content_lines deletes the range).",
1984
+ items: {
1985
+ type: "object",
1986
+ 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" },
1998
+ },
1999
+ },
2000
+ required: ["hash_range_inclusive", "content_lines"],
2001
+ additionalProperties: false,
2002
+ },
2003
+ },
2004
+ },
2005
+ required: ["path", "hashlineChanges"],
2006
+ additionalProperties: false,
2007
+ },
2008
+ 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,
2019
+ topPad: 0,
2020
+ bottomPad: HASHLINE_TOOL_RESULT_FRAME.callTitleBottomPad,
2021
+ }));
2022
+ return text;
2023
+ },
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
+ renderResult(result, _opt, theme, ctx) {
2042
+ 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 });
2046
+ return text;
2047
+ }
2048
+ // Fallback for errors / no-diff
2049
+ text.__piDiffTask = undefined;
2050
+ const out = (result.content || []).map((c) => c.text).join("\n");
2051
+ text.setText(theme.fg(ctx.isError ? "error" : "muted", out));
2052
+ return text;
2053
+ },
2054
+ });
1773
2055
  registerEditGuard(pi);
1774
2056
  }
1775
2057
  //# sourceMappingURL=index.js.map