@d3ara1n/pi-hashline-edit 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,22 @@ Routine local code editing in pi — the common case. If you spend turns fightin
24
24
 
25
25
  Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`grep`/`edit` delegate to the built-ins and `replace` refuses (it has no built-in counterpart).
26
26
 
27
+ ## Model compatibility — field notes
28
+
29
+ Real-world reliability depends on the model more than on anything else. Field observations from real sessions (June 2026, single environment — directional, not benchmarks; vendors iterate fast, re-test on new releases):
30
+
31
+ | Model profile | Tested example | Built-in string-replace | hashline-edit | Recommendation |
32
+ |---|---|---|---|---|
33
+ | Weak tool-call construction | DeepSeek V4 Flash | ~50% of edits fail; each fix takes several more `edit` rounds | ~80% of edits fail, but each failure converges in **one** retry | Keep hashline on — fewer total round-trips despite the higher failure rate |
34
+ | Strong, but not trained on hashline | Kimi K3 | Excellent | Frequent anchor mistakes | Turn the plugin off — the built-in `edit` serves this profile better |
35
+ | Strong, follows the schema as given | GLM 5.2 | Occasional not-found / whitespace friction | 100% — the friction disappears | The intended pairing |
36
+
37
+ What these numbers actually say:
38
+
39
+ - **A high failure rate on weak models is not a hashline problem.** DeepSeek V4 Flash fails at ~50% even on plain string-replace — the root cause is misremembered file content, and no edit protocol fixes that. What changes is the *shape* of a failure: string-replace failures are divergent (the model retries from the same wrong memory, so fixes take multiple rounds), while hashline failures are convergent (a mismatched anchor returns the live content plus a ready-to-resend `LINE#HASH`, so one retry closes the loop without trusting the model's memory).
40
+ - **Strong ≠ automatic win.** Hashline assumes anchor discipline — copy hashes verbatim from read output, never invent one. A model that hasn't internalized that will fabricate anchors no matter how capable it is. If a strong model keeps hitting anchor errors, the fastest fix is disabling the plugin, not more retries.
41
+ - **Some errors are invisible to any edit protocol.** On weak models `insert_after` is sometimes misread as string-replace-style: the model copies the anchor line into `body` (observed on DeepSeek V4 Flash), the toolcall verifies and succeeds, and the line ends up duplicated in the file. Nothing at the tool layer can catch this — the anchor is valid; the model's *intent* was wrong, and no amount of prompt wording cures it (the schema description already forbids the copy). Capable models never needed the wording in the first place: GLM used `insert_after` correctly back when the tool description didn't explain the op at all. The mismatch lives in the model, not the tool.
42
+
27
43
  ## Gotchas (vs. the built-in `read`/`edit`)
28
44
 
29
45
  Once hashline overrides the built-ins, a few things behave differently:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -28,6 +28,7 @@ import { splitLines } from "../core/lines.ts";
28
28
  import type { ApplyFailure, Edit } from "../core/types.ts";
29
29
  import { canonicalPath } from "./read-tool.ts";
30
30
  import { getState } from "./state.ts";
31
+ import { formatDiffCounts, publishDiffCounts, renderDiffPreview } from "./render.ts";
31
32
 
32
33
  /** Cap on the number of updated anchors returned inline (bounds token cost for large inserts). */
33
34
  const MAX_ANCHOR_LINES = 40;
@@ -209,12 +210,18 @@ export function makeEditOverride(cwd: string) {
209
210
  parameters: editSchema,
210
211
  renderShell: "default" as const,
211
212
 
212
- renderCall(args: Static<typeof editSchema>, theme: any) {
213
- let text = theme.fg("toolTitle", theme.bold("edit "));
214
- text += theme.fg("accent", args.path);
213
+ renderCall(args: Static<typeof editSchema>, theme: any, context: any) {
214
+ const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
215
+ let t = theme.fg("toolTitle", theme.bold("edit "));
216
+ t += theme.fg("accent", args.path);
215
217
  const n = args.edits?.length ?? 0;
216
- if (n) text += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
217
- return new Text(text, 0, 0);
218
+ if (n) t += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
219
+ // diff counts land after execution: renderResult publishes them into
220
+ // context.state and invalidates the row, re-running this renderer
221
+ const counts = context?.state?.diffCounts;
222
+ if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
223
+ text.setText(t);
224
+ return text;
218
225
  },
219
226
 
220
227
  renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
@@ -225,24 +232,16 @@ export function makeEditOverride(cwd: string) {
225
232
  return new Text(theme.fg("error", t), 0, 0);
226
233
  }
227
234
  const diff: string | undefined = result.details?.diff;
235
+ publishDiffCounts(diff, context);
228
236
  if (!diff) {
229
237
  // No net diff (e.g. a successful but non-mutating edit): show only the summary
230
238
  // line — content.text also carries `Updated anchors` (hashline) for the model.
231
239
  const t = content?.type === "text" ? content.text.split("\n")[0] : "Edited";
232
240
  return new Text(theme.fg("success", t), 0, 0);
233
241
  }
234
- // details.diff is pi-format (+N/-N/<space>N content); color by leading char
235
- const allLines = diff.split("\n");
236
- const shown = expanded ? allLines : allLines.slice(0, 24);
237
- const body = shown
238
- .map((line: string) => {
239
- if (line.startsWith("+")) return theme.fg("success", line);
240
- if (line.startsWith("-")) return theme.fg("error", line);
241
- return theme.fg("dim", line);
242
- })
243
- .join("\n");
244
- const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
245
- return new Text(body + more, 0, 0);
242
+ // details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
243
+ // semantic colors plus intra-line change highlighting
244
+ return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
246
245
  },
247
246
 
248
247
  async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
@@ -8,6 +8,7 @@ import assert from "node:assert/strict";
8
8
  import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
+ import { initTheme } from "@earendil-works/pi-coding-agent";
11
12
  import { makeEditOverride } from "./edit-tool.ts";
12
13
  import { makeReadOverride } from "./read-tool.ts";
13
14
  import { computeLineHash } from "../core/hash.ts";
@@ -215,6 +216,10 @@ test("edit execute: delete op", async () => {
215
216
 
216
217
  const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
217
218
 
219
+ // renderResult delegates to pi's renderDiff, which reads the global TUI theme
220
+ // singleton — initialize it once for this test process (watcher off by default).
221
+ initTheme();
222
+
218
223
  test("edit success: details.diff is a string (not the generateDiffString object)", async () => {
219
224
  await withDir(async (dir) => {
220
225
  const f = join(dir, "f.txt");
@@ -241,13 +246,43 @@ test("edit success: renderResult renders the diff without throwing", async () =>
241
246
  path: "f.txt",
242
247
  edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
243
248
  });
244
- // @ts-ignore — drive the renderer with a stub theme
245
- const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false });
249
+ // @ts-ignore — drive the renderer with a stub theme
250
+ const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false, state: {}, invalidate: () => {} });
246
251
  assert.ok(typeof comp?.text === "string");
247
252
  assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
248
253
  });
249
254
  });
250
255
 
256
+ test("edit header: renderResult publishes diff counts, renderCall shows +N -N", async () => {
257
+ await withDir(async (dir) => {
258
+ const f = join(dir, "f.txt");
259
+ const text = "a\nb\nc\nd\ne\n";
260
+ await writeFile(f, text);
261
+ await call(makeReadOverride(dir), { path: "f.txt" });
262
+ const edit = makeEditOverride(dir);
263
+ const r: any = await call(edit, {
264
+ path: "f.txt",
265
+ edits: [
266
+ { op: "replace", anchor: h(text, 2), end: h(text, 3), body: ["B"] },
267
+ { op: "insert_after", anchor: h(text, 5), body: ["f", "g"] },
268
+ ],
269
+ });
270
+ const context: any = { isError: false, state: {}, invalidate: () => {} };
271
+ edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
272
+ assert.deepEqual(context.state.diffCounts, { added: 3, removed: 2 });
273
+ const header: any = edit.renderCall(
274
+ { path: "f.txt", edits: [{ op: "replace" }] },
275
+ stubTheme,
276
+ { state: context.state },
277
+ );
278
+ assert.ok(header.text.includes("+3"), "header should show added count");
279
+ assert.ok(header.text.includes("-2"), "header should show removed count");
280
+ // without counts in state (streaming, pre-execution), no +N -N in the header
281
+ const plain: any = edit.renderCall({ path: "f.txt", edits: [{ op: "replace" }] }, stubTheme, { state: {} });
282
+ assert.ok(!plain.text.includes("+3"), "pre-execution header must not show counts");
283
+ });
284
+ });
285
+
251
286
  test("edit error: renderResult renders the error line without throwing", async () => {
252
287
  await withDir(async (dir) => {
253
288
  await writeFile(join(dir, "f.txt"), "a\n");
package/src/pi/render.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared anchor-parsing helper for the hashline-aware tool renderers.
2
+ * Shared helpers for the hashline-aware tool renderers.
3
3
  *
4
4
  * The model-facing `content` text uses the `LINE#HASH│content` anchor format so
5
5
  * an anchor can be copied straight into an edit op. The user-facing TUI
@@ -9,6 +9,8 @@
9
9
  * @module pi-hashline-edit/pi
10
10
  */
11
11
 
12
+ import { renderDiff } from "@earendil-works/pi-coding-agent";
13
+
12
14
  const HASHLINE_RE = /^(\d+)#[A-Za-z0-9]+│(.*)$/;
13
15
 
14
16
  export interface HashlineRow {
@@ -28,3 +30,60 @@ export function parseHashline(line: string): HashlineRow | null {
28
30
  const m = line.match(HASHLINE_RE);
29
31
  return m ? { lineNo: m[1], content: m[2] } : null;
30
32
  }
33
+
34
+ /** Max diff lines shown when a result is rendered collapsed. */
35
+ const MAX_COLLAPSED_DIFF_LINES = 24;
36
+
37
+ /**
38
+ * Render a pi-format diff (`+N`/`-N`/` N` content) for the TUI, reusing pi's
39
+ * built-in renderer: semantic diff colors plus intra-line (word-level) change
40
+ * highlighting on single-line modifications. When not expanded, collapse to the
41
+ * first 24 rendered lines with an overflow marker — truncating after rendering
42
+ * keeps `-`/`+` pairs intact so the intra-line highlight never dangles.
43
+ */
44
+ export function renderDiffPreview(diff: string, expanded: boolean, theme: any): string {
45
+ const rendered = renderDiff(diff);
46
+ if (expanded) return rendered;
47
+ const allLines = rendered.split("\n");
48
+ const more =
49
+ allLines.length > MAX_COLLAPSED_DIFF_LINES
50
+ ? `\n${theme.fg("dim", `… (${allLines.length - MAX_COLLAPSED_DIFF_LINES} more)`)}`
51
+ : "";
52
+ return allLines.slice(0, MAX_COLLAPSED_DIFF_LINES).join("\n") + more;
53
+ }
54
+
55
+ /** Added/removed line counts of a pi-format diff string (`+N`/`-N` leading char). */
56
+ export interface DiffCounts {
57
+ added: number;
58
+ removed: number;
59
+ }
60
+
61
+ /** Count added/removed lines in a pi-format diff (`+N content` / `-N content` / ` N content`). */
62
+ export function countDiffLines(diff: string): DiffCounts {
63
+ let added = 0;
64
+ let removed = 0;
65
+ for (const line of diff.split("\n")) {
66
+ if (line.startsWith("+")) added++;
67
+ else if (line.startsWith("-")) removed++;
68
+ }
69
+ return { added, removed };
70
+ }
71
+
72
+ /** Format `+N -N` with the theme's diff colors for the tool call header. */
73
+ export function formatDiffCounts(counts: DiffCounts, theme: any): string {
74
+ return ` ${theme.fg("toolDiffAdded", `+${counts.added}`)} ${theme.fg("toolDiffRemoved", `-${counts.removed}`)}`;
75
+ }
76
+
77
+ /**
78
+ * Stash per-call diff counts into the row-local render state and invalidate
79
+ * once so the call header re-renders with `+N -N`. Idempotent: skips the
80
+ * invalidate when the counts are unchanged (prevents a render loop).
81
+ */
82
+ export function publishDiffCounts(diff: string | undefined, context: any): void {
83
+ if (!diff || !context?.state) return;
84
+ const counts = countDiffLines(diff);
85
+ const prev: DiffCounts | undefined = context.state.diffCounts;
86
+ if (prev && prev.added === counts.added && prev.removed === counts.removed) return;
87
+ context.state.diffCounts = counts;
88
+ context.invalidate?.();
89
+ }
@@ -35,6 +35,7 @@ import { readFile, writeFile } from "node:fs/promises";
35
35
  import { hashFileLines, splitLines } from "../core/index.ts";
36
36
  import { getState } from "./state.ts";
37
37
  import { canonicalPath } from "./read-tool.ts";
38
+ import { formatDiffCounts, publishDiffCounts, renderDiffPreview } from "./render.ts";
38
39
 
39
40
  /** Cap on updated-anchor lines returned inline (bounds token cost for large spans). */
40
41
  const MAX_ANCHOR_LINES = 40;
@@ -162,13 +163,19 @@ export function makeReplaceTool(cwd: string) {
162
163
  parameters: replaceSchema,
163
164
  renderShell: "default" as const,
164
165
 
165
- renderCall(args: ReplaceParams, theme: any) {
166
- let text = theme.fg("toolTitle", theme.bold("replace "));
167
- text += theme.fg("accent", args.path);
166
+ renderCall(args: ReplaceParams, theme: any, context: any) {
167
+ const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
168
+ let t = theme.fg("toolTitle", theme.bold("replace "));
169
+ t += theme.fg("accent", args.path);
168
170
  const mode = args.regex ? "regex" : "lit";
169
171
  const f = args.flags ? `/${args.flags}` : "";
170
- text += theme.fg("dim", ` — ${mode}${f} "${show(args.find)}" → "${show(args.replace)}"`);
171
- return new Text(text, 0, 0);
172
+ t += theme.fg("dim", ` — ${mode}${f} "${show(args.find)}" → "${show(args.replace)}"`);
173
+ // diff counts land after execution: renderResult publishes them into
174
+ // context.state and invalidates the row, re-running this renderer
175
+ const counts = context?.state?.diffCounts;
176
+ if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
177
+ text.setText(t);
178
+ return text;
172
179
  },
173
180
 
174
181
  renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
@@ -179,24 +186,16 @@ export function makeReplaceTool(cwd: string) {
179
186
  return new Text(theme.fg("error", t), 0, 0);
180
187
  }
181
188
  const diff: string | undefined = result.details?.diff;
189
+ publishDiffCounts(diff, context);
182
190
  if (!diff) {
183
191
  // No net diff: show only the summary line — content.text also carries
184
192
  // `Updated anchors` (hashline) for the model.
185
193
  const t = content?.type === "text" ? content.text.split("\n")[0] : "Replaced";
186
194
  return new Text(theme.fg("success", t), 0, 0);
187
195
  }
188
- // details.diff is pi-format (+N/-N/<space>N content); color by leading char
189
- const allLines = diff.split("\n");
190
- const shown = expanded ? allLines : allLines.slice(0, 24);
191
- const body = shown
192
- .map((line: string) => {
193
- if (line.startsWith("+")) return theme.fg("success", line);
194
- if (line.startsWith("-")) return theme.fg("error", line);
195
- return theme.fg("dim", line);
196
- })
197
- .join("\n");
198
- const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
199
- return new Text(body + more, 0, 0);
196
+ // details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
197
+ // semantic colors plus intra-line change highlighting
198
+ return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
200
199
  },
201
200
 
202
201
  async execute(toolCallId: string, params: ReplaceParams, signal: AbortSignal | undefined, onUpdate: any) {
@@ -11,6 +11,7 @@ import assert from "node:assert/strict";
11
11
  import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
+ import { initTheme } from "@earendil-works/pi-coding-agent";
14
15
  import { makeReplaceTool } from "./replace-tool.ts";
15
16
  import { makeEditOverride } from "./edit-tool.ts";
16
17
  import { getState } from "./state.ts";
@@ -35,6 +36,10 @@ function anchorLine(block: string, line: number) {
35
36
 
36
37
  const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
37
38
 
39
+ // renderResult delegates to pi's renderDiff, which reads the global TUI theme
40
+ // singleton — initialize it once for this test process (watcher off by default).
41
+ initTheme();
42
+
38
43
  test("replace literal: replaces all occurrences", async () => {
39
44
  await withDir(async (dir) => {
40
45
  const f = join(dir, "f.txt");
@@ -234,7 +239,7 @@ test("replace renderResult: renders the diff without throwing", async () => {
234
239
  { content: r.content, details: r.details },
235
240
  { isPartial: false, expanded: true },
236
241
  stubTheme,
237
- { isError: r.isError ?? false },
242
+ { isError: r.isError ?? false, state: {}, invalidate: () => {} },
238
243
  );
239
244
  assert.ok(typeof comp?.text === "string");
240
245
  assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");