@d3ara1n/pi-hashline-edit 0.3.4 → 0.4.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/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.1",
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, type DiffCounts } 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;
@@ -189,6 +190,16 @@ function formatUpdatedAnchors(newText: string, touched: readonly number[], hashL
189
190
  return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
190
191
  }
191
192
 
193
+ /** Call-header line: `edit path — N ops: op`, plus `+N -N` once the result's diff counts are known. */
194
+ function editHeader(args: Static<typeof editSchema>, theme: any, counts?: DiffCounts): string {
195
+ let t = theme.fg("toolTitle", theme.bold("edit "));
196
+ t += theme.fg("accent", args.path);
197
+ const n = args.edits?.length ?? 0;
198
+ if (n) t += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
199
+ if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
200
+ return t;
201
+ }
202
+
192
203
  export function makeEditOverride(cwd: string) {
193
204
  const builtin = createEditTool(cwd);
194
205
 
@@ -209,12 +220,14 @@ export function makeEditOverride(cwd: string) {
209
220
  parameters: editSchema,
210
221
  renderShell: "default" as const,
211
222
 
212
- renderCall(args: Static<typeof editSchema>, theme: any) {
213
- let text = theme.fg("toolTitle", theme.bold("edit "));
214
- text += theme.fg("accent", args.path);
215
- 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);
223
+ renderCall(args: Static<typeof editSchema>, theme: any, context: any) {
224
+ const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
225
+ // Stash the header for renderResult: the diff counts land after
226
+ // execution and are refreshed in place (renderResult's lastComponent
227
+ // is the result component, not this header)
228
+ if (context?.state) context.state.callText = text;
229
+ text.setText(editHeader(args, theme, context?.state?.diffCounts));
230
+ return text;
218
231
  },
219
232
 
220
233
  renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
@@ -225,24 +238,20 @@ export function makeEditOverride(cwd: string) {
225
238
  return new Text(theme.fg("error", t), 0, 0);
226
239
  }
227
240
  const diff: string | undefined = result.details?.diff;
241
+ // refresh the call header's +N -N in place — never invalidate from
242
+ // inside a renderer (re-enters updateDisplay, diff renders twice)
243
+ publishDiffCounts(diff, context, (counts) => {
244
+ context.state?.callText?.setText(editHeader(context.args, theme, counts));
245
+ });
228
246
  if (!diff) {
229
247
  // No net diff (e.g. a successful but non-mutating edit): show only the summary
230
248
  // line — content.text also carries `Updated anchors` (hashline) for the model.
231
249
  const t = content?.type === "text" ? content.text.split("\n")[0] : "Edited";
232
250
  return new Text(theme.fg("success", t), 0, 0);
233
251
  }
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);
252
+ // details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
253
+ // semantic colors plus intra-line change highlighting
254
+ return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
246
255
  },
247
256
 
248
257
  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,48 @@ 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 refreshes the call header in place — no invalidate", 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 args = { path: "f.txt", edits: [{ op: "replace" as const }] };
271
+ let invalidated = false;
272
+ const context: any = { args, isError: false, state: {}, invalidate: () => { invalidated = true; } };
273
+ // first pass: renderCall builds the header; no counts exist yet
274
+ const header: any = edit.renderCall(args, stubTheme, context);
275
+ assert.ok(!header.text.includes("+3"), "pre-execution header must not show counts");
276
+ // result lands: renderResult refreshes the SAME component in place
277
+ edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
278
+ assert.deepEqual(context.state.diffCounts, { added: 3, removed: 2 });
279
+ assert.ok(header.text.includes("+3"), "header should show added count");
280
+ assert.ok(header.text.includes("-2"), "header should show removed count");
281
+ // refreshing via context.invalidate() re-enters updateDisplay and renders
282
+ // the diff twice — the renderer must never call it
283
+ assert.ok(!invalidated, "renderResult must not call invalidate");
284
+ // later full passes (expand/collapse) re-run renderCall; counts survive in state
285
+ const header2: any = edit.renderCall(args, stubTheme, { args, state: context.state, lastComponent: header });
286
+ assert.equal(header2, header, "renderCall reuses the stashed component");
287
+ assert.ok(header2.text.includes("+3"), "re-render keeps the counts");
288
+ });
289
+ });
290
+
251
291
  test("edit error: renderResult renders the error line without throwing", async () => {
252
292
  await withDir(async (dir) => {
253
293
  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,74 @@ 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 refresh the
79
+ * call header component in place — pi's own edit-tool pattern (renderResult
80
+ * mutates the component stashed by renderCall; it never re-runs the renderer).
81
+ *
82
+ * `updateDisplay` runs renderCall before renderResult in every pass, so later
83
+ * passes (expand/collapse, result updates) rebuild the header from
84
+ * `state.diffCounts`; the in-place refresh covers the first result render,
85
+ * where renderCall ran before the counts existed. renderResult cannot find the
86
+ * header via `lastComponent` — there it is the *result* component — so
87
+ * renderCall stashes it (e.g. `state.callText`).
88
+ *
89
+ * MUST NOT call `context.invalidate()`: it re-enters `updateDisplay`
90
+ * synchronously (not re-entrant) and the outer pass then re-adds the result
91
+ * component after the nested one — the diff renders twice.
92
+ */
93
+ export function publishDiffCounts(
94
+ diff: string | undefined,
95
+ context: any,
96
+ refreshHeader: (counts: DiffCounts) => void,
97
+ ): void {
98
+ if (!diff || !context?.state) return;
99
+ const counts = countDiffLines(diff);
100
+ const prev: DiffCounts | undefined = context.state.diffCounts;
101
+ context.state.diffCounts = counts;
102
+ if (!prev || prev.added !== counts.added || prev.removed !== counts.removed) refreshHeader(counts);
103
+ }
@@ -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, type DiffCounts } 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;
@@ -144,6 +145,17 @@ function show(s: string, n = 30): string {
144
145
  return folded.length > n ? folded.slice(0, n) + "…" : folded;
145
146
  }
146
147
 
148
+ /** Call-header line: `replace path — mode "find" → "replace"`, plus `+N -N` once the result's diff counts are known. */
149
+ function replaceHeader(args: ReplaceParams, theme: any, counts?: DiffCounts): string {
150
+ let t = theme.fg("toolTitle", theme.bold("replace "));
151
+ t += theme.fg("accent", args.path);
152
+ const mode = args.regex ? "regex" : "lit";
153
+ const f = args.flags ? `/${args.flags}` : "";
154
+ t += theme.fg("dim", ` — ${mode}${f} "${show(args.find)}" → "${show(args.replace)}"`);
155
+ if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme);
156
+ return t;
157
+ }
158
+
147
159
  export function makeReplaceTool(cwd: string) {
148
160
  return {
149
161
  name: "replace" as const,
@@ -162,13 +174,14 @@ export function makeReplaceTool(cwd: string) {
162
174
  parameters: replaceSchema,
163
175
  renderShell: "default" as const,
164
176
 
165
- renderCall(args: ReplaceParams, theme: any) {
166
- let text = theme.fg("toolTitle", theme.bold("replace "));
167
- text += theme.fg("accent", args.path);
168
- const mode = args.regex ? "regex" : "lit";
169
- 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);
177
+ renderCall(args: ReplaceParams, theme: any, context: any) {
178
+ const text = (context?.lastComponent as Text | undefined) ?? new Text("", 0, 0);
179
+ // Stash the header for renderResult: the diff counts land after
180
+ // execution and are refreshed in place (renderResult's lastComponent
181
+ // is the result component, not this header)
182
+ if (context?.state) context.state.callText = text;
183
+ text.setText(replaceHeader(args, theme, context?.state?.diffCounts));
184
+ return text;
172
185
  },
173
186
 
174
187
  renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
@@ -179,24 +192,20 @@ export function makeReplaceTool(cwd: string) {
179
192
  return new Text(theme.fg("error", t), 0, 0);
180
193
  }
181
194
  const diff: string | undefined = result.details?.diff;
195
+ // refresh the call header's +N -N in place — never invalidate from
196
+ // inside a renderer (re-enters updateDisplay, diff renders twice)
197
+ publishDiffCounts(diff, context, (counts) => {
198
+ context.state?.callText?.setText(replaceHeader(context.args, theme, counts));
199
+ });
182
200
  if (!diff) {
183
201
  // No net diff: show only the summary line — content.text also carries
184
202
  // `Updated anchors` (hashline) for the model.
185
203
  const t = content?.type === "text" ? content.text.split("\n")[0] : "Replaced";
186
204
  return new Text(theme.fg("success", t), 0, 0);
187
205
  }
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);
206
+ // details.diff is pi-format (+N/-N/<space>N content); renderDiff handles
207
+ // semantic colors plus intra-line change highlighting
208
+ return new Text(renderDiffPreview(diff, expanded, theme), 0, 0);
200
209
  },
201
210
 
202
211
  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");
@@ -263,6 +268,25 @@ test("replace renderResult: renders the error line without throwing", async () =
263
268
  });
264
269
  });
265
270
 
271
+ test("replace header: renderResult refreshes the call header in place — no invalidate", async () => {
272
+ await withDir(async (dir) => {
273
+ const f = join(dir, "f.txt");
274
+ await writeFile(f, "a\nb\nc\n");
275
+ const tool = makeReplaceTool(dir);
276
+ const args = { path: "f.txt", find: "b", replace: "B1\nB2" };
277
+ const r: any = await call(tool, args);
278
+ let invalidated = false;
279
+ const context: any = { args, isError: false, state: {}, invalidate: () => { invalidated = true; } };
280
+ const header: any = tool.renderCall(args, stubTheme, context);
281
+ assert.ok(!header.text.includes("+2"), "pre-execution header must not show counts");
282
+ tool.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, context);
283
+ assert.deepEqual(context.state.diffCounts, { added: 2, removed: 1 });
284
+ assert.ok(header.text.includes("+2"), "header should show added count");
285
+ assert.ok(header.text.includes("-1"), "header should show removed count");
286
+ assert.ok(!invalidated, "renderResult must not call invalidate");
287
+ });
288
+ });
289
+
266
290
  test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
267
291
  await withDir(async (dir) => {
268
292
  const f = join(dir, "f.txt");