@d3ara1n/pi-hashline-edit 0.5.3 → 0.5.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/README.md CHANGED
@@ -24,7 +24,7 @@ Routine local code editing in pi — the common case. If you spend turns fightin
24
24
 
25
25
  ## When to turn it off
26
26
 
27
- 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`/`edit` and plain `grep` calls delegate to the built-ins (`grep` calls using the extended params below still run locally, formatted without anchors) and `replace` refuses (it has no built-in counterpart).
27
+ 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 the extension registers none of them and pi behaves as if it were not installed (reload pi after changing the setting).
28
28
 
29
29
  ## Model Compatibility
30
30
 
@@ -212,7 +212,7 @@ Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/setti
212
212
  ```jsonc
213
213
  {
214
214
  "hashlineEdit": {
215
- "enabled": true, // set false to fall back to the built-in read/edit
215
+ "enabled": true, // set false to disable the extension entirely (built-ins remain; reload pi)
216
216
  "hashLen": 4, // hash length, 2–8 (default 4)
217
217
  "shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
218
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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
  "homepage": "https://github.com/d3ara1n/pi-extensions/tree/main/packages/pi-hashline-edit#readme",
@@ -171,6 +171,32 @@ test("CRLF line endings preserved", () => {
171
171
  if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
172
172
  });
173
173
 
174
+ test("editing a mixed-ending file preserves untouched separators", () => {
175
+ const before = "first\r\nsecond\nthird\r\n";
176
+ const result = applyEdits(before, [{ op: "replace", start: at(before, 2), body: ["SECOND"] }]);
177
+ assert.equal(result.ok, true);
178
+ if (result.ok) assert.equal(result.text, "first\r\nSECOND\nthird\r\n");
179
+ });
180
+
181
+ test("inserting and deleting in mixed-ending files preserves surviving separators", () => {
182
+ const before = "a\r\nb\nc\r\nlast";
183
+ const inserted = applyEdits(before, [{ op: "insert_after", anchor: at(before, 2), body: ["x"] }]);
184
+ assert.equal(inserted.ok, true);
185
+ if (inserted.ok) assert.equal(inserted.text, "a\r\nb\nx\r\nc\r\nlast");
186
+ const deleted = applyEdits(before, [{ op: "delete", start: at(before, 2) }]);
187
+ assert.equal(deleted.ok, true);
188
+ if (deleted.ok) assert.equal(deleted.text, "a\r\nc\r\nlast");
189
+ });
190
+
191
+ test("replacing one line with several keeps the block's trailing gap", () => {
192
+ const before = "a\r\nb\nc\r\nd";
193
+ const result = applyEdits(before, [{ op: "replace", start: at(before, 2), body: ["X", "Y", "Z"] }]);
194
+ assert.equal(result.ok, true);
195
+ // b's gap (\n) moves to the last new line, so the boundary to c is unchanged;
196
+ // the two new interior gaps borrow positionally, then fall back to crlf.
197
+ if (result.ok) assert.equal(result.text, "a\r\nX\nY\r\nZ\nc\r\nd");
198
+ });
199
+
174
200
  test("a file without a final newline keeps not having one", () => {
175
201
  const text = "a\nb";
176
202
  const r = applyEdits(text, [{ op: "replace", start: at(text, 2), body: ["B"] }]);
@@ -193,9 +219,6 @@ test("appending to a file without a final newline keeps it absent", () => {
193
219
  });
194
220
 
195
221
  test("noop is still detected when the file lacks a final newline", () => {
196
- // Before the final-newline fix, joinLines appended a terminator, so a
197
- // byte-identical body looked like a change and the edit silently rewrote the
198
- // file — the noop guard never fired.
199
222
  const text = "a\nb";
200
223
  const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
201
224
  assert.equal(r.ok, false);
package/src/core/apply.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import { computeLineHash } from "./hash.ts";
33
- import { detectLineEnding, hasFinalNewline, joinLines, splitLines } from "./lines.ts";
33
+ import { detectLineEnding, hasFinalNewline, splitLines } from "./lines.ts";
34
34
  import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
35
35
 
36
36
  /** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
@@ -212,13 +212,29 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius
212
212
  }
213
213
  }
214
214
 
215
- // Apply back-to-front (lo descending) so original lo/hi stay valid
216
- let result = [...lines];
215
+ // Mixed line endings: each line carries the separator that FOLLOWED it in
216
+ // the original (its gap), so surviving lines keep theirs byte for byte. New
217
+ // gaps borrow the removed block's separators positionally — the last new
218
+ // line inherits the block's trailing gap, leaving the boundary to the next
219
+ // surviving line unchanged; gaps past the removed block's length (or in a
220
+ // pure insertion) fall back to the file's customary ending.
221
+ const separators = text.match(/\r?\n/g) ?? [];
222
+ const separator = ending === "crlf" ? "\r\n" : "\n";
223
+ let result = lines.map((content, i) => ({ content, separator: separators[i] ?? "" }));
217
224
  for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
218
- result = [...result.slice(0, op.lo), ...op.newLines, ...result.slice(op.hi)];
225
+ const removed = result.slice(op.lo, op.hi);
226
+ const inserted = op.newLines.map((content, i) => ({
227
+ content,
228
+ separator: i === op.newLines.length - 1 && removed.length > 0
229
+ ? removed[removed.length - 1].separator
230
+ : removed[i]?.separator || separator,
231
+ }));
232
+ result.splice(op.lo, op.hi - op.lo, ...inserted);
219
233
  }
220
-
221
- const newText = joinLines(result, ending, hasFinalNewline(text));
234
+ const finalNewline = hasFinalNewline(text);
235
+ const newText = result.map(({ content, separator: current }, i) =>
236
+ content + (i < result.length - 1 || finalNewline ? current || separator : ""),
237
+ ).join("");
222
238
  if (newText === text) {
223
239
  return {
224
240
  ok: false,
package/src/core/index.ts CHANGED
@@ -9,5 +9,5 @@
9
9
 
10
10
  export * from "./types.ts";
11
11
  export { computeLineHash, hashFileLines } from "./hash.ts";
12
- export { splitLines, joinLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
12
+ export { splitLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
13
13
  export { applyEdits } from "./apply.ts";
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { splitLines, joinLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
3
+ import { splitLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
4
4
 
5
5
  test("splitLines edge cases", () => {
6
6
  assert.deepEqual(splitLines(""), []);
@@ -16,20 +16,6 @@ test("splitLines strips CRLF \\r", () => {
16
16
  assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
17
17
  });
18
18
 
19
- test("joinLines restores line endings", () => {
20
- assert.equal(joinLines(["a", "b"]), "a\nb\n");
21
- assert.equal(joinLines([]), "");
22
- assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
23
- assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
24
- });
25
-
26
- test("joinLines preserves the final-newline state when told to", () => {
27
- assert.equal(joinLines(["a", "b"], "lf", false), "a\nb");
28
- assert.equal(joinLines(["a", "b"], "crlf", false), "a\r\nb");
29
- assert.equal(joinLines(["a"], "lf", false), "a");
30
- assert.equal(joinLines([], "lf", false), "");
31
- });
32
-
33
19
  test("hasFinalNewline", () => {
34
20
  assert.equal(hasFinalNewline("a\nb"), false);
35
21
  assert.equal(hasFinalNewline("a\nb\n"), true);
@@ -40,12 +26,6 @@ test("hasFinalNewline", () => {
40
26
  assert.equal(hasFinalNewline(""), true);
41
27
  });
42
28
 
43
- test("split/join round-trips a document byte for byte", () => {
44
- for (const text of ["a", "a\n", "a\nb", "a\nb\n", "a\n\n", "\n", "a\r\nb", "a\r\nb\r\n", ""]) {
45
- assert.equal(joinLines(splitLines(text), detectLineEnding(text), hasFinalNewline(text)), text);
46
- }
47
- });
48
-
49
29
  test("detectLineEnding", () => {
50
30
  assert.equal(detectLineEnding("a\nb\n"), "lf");
51
31
  assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
package/src/core/lines.ts CHANGED
@@ -4,13 +4,15 @@
4
4
  *
5
5
  * CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
6
6
  * clean lines, matching the `\r`-free content the model copies from the
7
- * display); detectLineEnding records the original ending so joinLines can
8
- * restore it guaranteeing a CRLF file keeps its endings after edit.
7
+ * display); detectLineEnding records whether the file uses CRLF at all (any
8
+ * `\r\n` counts) so new boundaries added by an edit can use the file's
9
+ * customary ending.
9
10
  *
10
11
  * Final newline: splitLines discards whether the input ended with a terminator
11
12
  * (a trailing newline terminates the last line, it does not create one).
12
- * joinLines therefore takes that state as an argument rather than assuming it
13
- * a file that lacked a final newline must not silently gain one.
13
+ * hasFinalNewline recovers that state so a file that lacked a final newline
14
+ * does not silently gain one editing reassembles per-line separators and
15
+ * must suppress the last line's terminator accordingly.
14
16
  *
15
17
  * @module pi-hashline-edit/core
16
18
  */
@@ -34,14 +36,14 @@ export function splitLines(text: string): string[] {
34
36
  return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
35
37
  }
36
38
 
37
- /** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
39
+ /** Whether the text uses CRLF at all (any `\r\n` counts; mixed files report "crlf"). */
38
40
  export function detectLineEnding(text: string): LineEnding {
39
41
  return text.includes("\r\n") ? "crlf" : "lf";
40
42
  }
41
43
 
42
44
  /**
43
45
  * Whether the text ends with a line terminator — the state splitLines discards
44
- * and joinLines needs in order to reproduce a document byte for byte.
46
+ * and edit application needs in order to reproduce a document byte for byte.
45
47
  *
46
48
  * The empty string has no lines and no terminator; it reports `true` so that
47
49
  * rejoining its (also empty) line array — which yields `""` either way —
@@ -50,18 +52,3 @@ export function detectLineEnding(text: string): LineEnding {
50
52
  export function hasFinalNewline(text: string): boolean {
51
53
  return text === "" || text.endsWith("\n");
52
54
  }
53
-
54
- /**
55
- * Join a line array back into text, restoring the given line ending (default LF)
56
- * and final-newline state (default: terminates with a newline, the convention
57
- * for freshly created content).
58
- *
59
- * Reconstructing an *existing* file must pass `hasFinalNewline(originalText)` so
60
- * a missing terminator stays missing.
61
- */
62
- export function joinLines(lines: readonly string[], ending: LineEnding = "lf", finalNewline = true): string {
63
- if (lines.length === 0) return "";
64
- const sep = ending === "crlf" ? "\r\n" : "\n";
65
- const body = lines.join(sep);
66
- return finalNewline ? body + sep : body;
67
- }
package/src/index.ts CHANGED
@@ -23,14 +23,15 @@ import { makeReplaceTool } from "./pi/replace-tool.ts";
23
23
  export default function (pi: ExtensionAPI) {
24
24
  const cwd = process.cwd();
25
25
 
26
- // refresh config on session start / reload
27
- pi.on("session_start", async () => {
28
- const state = getState();
29
- state.config = loadConfig(cwd);
30
- });
26
+ const state = getState();
27
+ state.config = loadConfig(cwd);
31
28
 
32
- pi.registerTool(makeReadOverride(cwd));
33
- pi.registerTool(makeEditOverride(cwd));
34
- pi.registerTool(makeGrepOverride(cwd));
35
- pi.registerTool(makeReplaceTool(cwd));
29
+ // `enabled: false` leaves the extension fully inert — pi's built-in
30
+ // read/edit/grep stay in place, as if this package were not installed.
31
+ if (state.config.enabled) {
32
+ pi.registerTool(makeReadOverride(cwd));
33
+ pi.registerTool(makeEditOverride(cwd));
34
+ pi.registerTool(makeGrepOverride(cwd));
35
+ pi.registerTool(makeReplaceTool(cwd));
36
+ }
36
37
  }
package/src/pi/config.ts CHANGED
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
 
13
13
  export interface HashlineEditConfig {
14
- /** Whether hashline is enabled (when false, delegate to the built-in read/edit). */
14
+ /** Master switch: when false the extension registers no tools — pi's built-ins remain. */
15
15
  enabled: boolean;
16
16
  /** Line hash length (default 4). */
17
17
  hashLen: number;
@@ -19,7 +19,7 @@
19
19
  * @module pi-hashline-edit/pi
20
20
  */
21
21
 
22
- import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, type EditToolDetails } from "@earendil-works/pi-coding-agent";
22
+ import { generateDiffString, generateUnifiedPatch, withFileMutationQueue, type EditToolDetails } from "@earendil-works/pi-coding-agent";
23
23
  import { Type, type Static } from "typebox";
24
24
  import { Text } from "@earendil-works/pi-tui";
25
25
  import { readFile, writeFile } from "node:fs/promises";
@@ -201,7 +201,6 @@ function editHeader(args: Static<typeof editSchema>, theme: any, counts?: DiffCo
201
201
  }
202
202
 
203
203
  export function makeEditOverride(cwd: string) {
204
- const builtin = createEditTool(cwd);
205
204
 
206
205
  return {
207
206
  name: "edit" as const,
@@ -255,10 +254,6 @@ export function makeEditOverride(cwd: string) {
255
254
  },
256
255
 
257
256
  async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
258
- const state = getState();
259
- // hashline disabled by the user (config.enabled=false) → delegate to the built-in
260
- if (!state.config.enabled) return builtin.execute(toolCallId, params as any, signal, onUpdate);
261
-
262
257
  const path = params.path;
263
258
  const absPath = canonicalPath(cwd, path);
264
259
 
@@ -5,12 +5,15 @@
5
5
  */
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
8
- import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
8
+ import { mkdir, 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
+ import { createEditTool, initTheme } from "@earendil-works/pi-coding-agent";
12
+ import { validateToolArguments } from "@earendil-works/pi-ai";
13
+ import registerHashline from "../index.ts";
12
14
  import { makeEditOverride } from "./edit-tool.ts";
13
15
  import { makeReadOverride } from "./read-tool.ts";
16
+ import { getState } from "./state.ts";
14
17
  import { computeLineHash } from "../core/hash.ts";
15
18
  import { splitLines } from "../core/lines.ts";
16
19
 
@@ -242,6 +245,32 @@ test("edit execute: delete op", async () => {
242
245
  });
243
246
  });
244
247
 
248
+ test("disabled config registers no tools — built-ins remain", async () => {
249
+ await withDir(async (dir) => {
250
+ const oldCwd = process.cwd();
251
+ const state = getState();
252
+ const previous = state.config;
253
+ try {
254
+ await mkdir(join(dir, ".pi"));
255
+ await writeFile(join(dir, ".pi", "settings.json"), JSON.stringify({ hashlineEdit: { enabled: false } }));
256
+ await writeFile(join(dir, "f.txt"), "old value\n");
257
+ process.chdir(dir);
258
+ const registered: string[] = [];
259
+ registerHashline({ on() {}, registerTool(tool: { name: string }) { registered.push(tool.name); } } as any);
260
+ assert.deepEqual(registered, []);
261
+ const builtin = createEditTool(dir);
262
+ const params = validateToolArguments(builtin, {
263
+ name: "edit", arguments: { path: "f.txt", edits: [{ oldText: "old value", newText: "new value" }] },
264
+ } as any);
265
+ await call(builtin, params);
266
+ assert.equal(await readFile(join(dir, "f.txt"), "utf-8"), "new value\n");
267
+ } finally {
268
+ process.chdir(oldCwd);
269
+ state.config = previous;
270
+ }
271
+ });
272
+ });
273
+
245
274
  // --- renderer regression guards (details.diff must be a string, renderResult must not throw) ---
246
275
 
247
276
  const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
@@ -22,9 +22,9 @@
22
22
  * pre-filter candidates. Context windows are likewise rebuilt client-side from
23
23
  * the surviving matches — context lines of a filtered-out match never leak.
24
24
  *
25
- * Falls back to the built-in grep when: hashline disabled with plain params,
26
- * aborted, or ripgrep cannot be located (the built-in can auto-download rg).
27
- * Extended params never delegate — the built-in would misread them.
25
+ * Falls back to the built-in grep when aborted or when ripgrep cannot be
26
+ * located (the built-in can auto-download rg). Extended params never
27
+ * delegate — the built-in would misread them.
28
28
  *
29
29
  * @module pi-hashline-edit/pi
30
30
  */
@@ -382,10 +382,6 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
382
382
  params.wordMatch === undefined &&
383
383
  !Array.isArray(params.path);
384
384
 
385
- // disabled + plain params → built-in grep, exactly as before
386
- if (!state.config.enabled && legacyShaped)
387
- return backend.delegate(toolCallId, params, signal, onUpdate);
388
-
389
385
  const rgPath = await backend.findRg();
390
386
  // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
391
387
  if (!rgPath) {
@@ -395,9 +391,6 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
395
391
  );
396
392
  }
397
393
 
398
- // disabled + extended params still run locally, formatted without anchors
399
- const anchored = state.config.enabled;
400
-
401
394
  const patterns = toArray(params.pattern);
402
395
  const excludes = toArray(params.excludePattern);
403
396
  if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
@@ -411,6 +404,8 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
411
404
  })();
412
405
  const hashLen = state.config.hashLen;
413
406
 
407
+ // Verify search paths upfront so a typo fails fast with a clear error
408
+ // (rg's own diagnostics are less actionable).
414
409
  for (const sp of searchPaths) {
415
410
  try {
416
411
  await stat(sp);
@@ -537,7 +532,6 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
537
532
  if (outputMode === "content") {
538
533
  for (const [fp, matchLines] of byFile) {
539
534
  const { lines, hashes } = await getFile(fp);
540
- const matchSet = new Set(matchLines);
541
535
  // Context windows are rebuilt from surviving matches so context
542
536
  // lines of a filtered-out match never leak.
543
537
  const windowSet = new Set<number>();
@@ -545,18 +539,14 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
545
539
  for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++)
546
540
  windowSet.add(n);
547
541
  }
548
- const header = anchored
549
- ? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
550
- : "";
542
+ const header = `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`;
551
543
  const rows: string[] = [];
552
544
  for (const n of [...windowSet].sort((a, b) => a - b)) {
553
545
  const content = lines[n - 1] ?? "";
554
546
  const hash = hashes[n - 1] ?? "";
555
547
  const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
556
548
  if (wasTruncated) linesTruncated = true;
557
- if (anchored) rows.push(`${n}#${hash}│${disp}`);
558
- else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
559
- else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
549
+ rows.push(`${n}#${hash}│${disp}`);
560
550
  }
561
551
  blocks.push(`${header}${rows.join("\n")}`);
562
552
  }
@@ -131,11 +131,13 @@ test("grep in a subdirectory returns a path that edits the matching file", async
131
131
  const edit: any = makeEditOverride(dir);
132
132
  await call(edit, {
133
133
  path: displayPath,
134
- edits: [{
135
- op: "replace",
136
- anchor: { line: 1, hash: computeLineHash(1, original.trimEnd()) },
137
- body: ["export const status = 2;"],
138
- }],
134
+ edits: [
135
+ {
136
+ op: "replace",
137
+ anchor: { line: 1, hash: computeLineHash(1, original.trimEnd()) },
138
+ body: ["export const status = 2;"],
139
+ },
140
+ ],
139
141
  });
140
142
  assert.equal(await readFile(matchedFile, "utf-8"), "export const status = 2;\n");
141
143
  assert.equal(await readFile(rootFile, "utf-8"), original);
@@ -293,24 +295,6 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
293
295
  /ripgrep \(rg\) not found/,
294
296
  );
295
297
  });
296
-
297
- const file = join(dir, "a.ts");
298
- await writeFile(file, "x y\n");
299
- const disabled = fakeBackend({ lines: [rgMatch(file, 1, "x y\n")] });
300
- await withEnabled(false, async () => {
301
- assert.equal(
302
- text(await call(makeGrepOverrideWithBackend(dir, disabled.backend), { pattern: "x" })),
303
- "delegated",
304
- );
305
- assert.equal(disabled.calls.length, 0);
306
- const extended = await call(makeGrepOverrideWithBackend(dir, disabled.backend), {
307
- pattern: ["x", "y"],
308
- matchMode: "all",
309
- });
310
- assert.match(text(extended), /a\.ts:1: x y/);
311
- assert.doesNotMatch(text(extended), /#[0-9A-Z]+│/);
312
- assert.equal(disabled.calls.length, 1);
313
- });
314
298
  });
315
299
  });
316
300
 
@@ -144,8 +144,8 @@ export function makeReadOverride(cwd: string) {
144
144
  },
145
145
 
146
146
  async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
147
- // Not enabled OR user cancelled → delegate to the built-in (builtin handles abort itself)
148
- if (!getState().config.enabled || signal?.aborted) return builtin.execute(toolCallId, params, signal, onUpdate);
147
+ // User cancelled → delegate to the built-in (builtin handles abort itself)
148
+ if (signal?.aborted) return builtin.execute(toolCallId, params, signal, onUpdate);
149
149
 
150
150
  const absPath = canonicalPath(cwd, params.path as string);
151
151
  let buf: Buffer;
@@ -210,12 +210,6 @@ export function makeReplaceTool(cwd: string) {
210
210
 
211
211
  async execute(toolCallId: string, params: ReplaceParams, signal: AbortSignal | undefined, onUpdate: any) {
212
212
  const state = getState();
213
- // Gated by the same `enabled` switch as read/grep/edit — they delegate to the
214
- // built-ins when disabled; replace has no built-in counterpart, so it refuses.
215
- if (!state.config.enabled)
216
- throw new Error(
217
- "Replace is unavailable: hashlineEdit is disabled. Set hashlineEdit.enabled = true to use it.",
218
- );
219
213
  const path = params.path;
220
214
  const absPath = canonicalPath(cwd, path);
221
215
 
@@ -14,7 +14,6 @@ import { join } from "node:path";
14
14
  import { initTheme } from "@earendil-works/pi-coding-agent";
15
15
  import { makeReplaceTool } from "./replace-tool.ts";
16
16
  import { makeEditOverride } from "./edit-tool.ts";
17
- import { getState } from "./state.ts";
18
17
 
19
18
  async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
20
19
  const dir = await mkdtemp(join(tmpdir(), "hl-replace-"));
@@ -286,22 +285,3 @@ test("replace header: renderResult refreshes the call header in place — no inv
286
285
  assert.ok(!invalidated, "renderResult must not call invalidate");
287
286
  });
288
287
  });
289
-
290
- test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
291
- await withDir(async (dir) => {
292
- const f = join(dir, "f.txt");
293
- await writeFile(f, "a\nb\na\n");
294
- const state = getState();
295
- const saved = state.config;
296
- state.config = { ...saved, enabled: false };
297
- try {
298
- await assert.rejects(
299
- call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "b" }),
300
- /disabled/,
301
- );
302
- assert.equal(await readFile(f, "utf-8"), "a\nb\na\n", "file untouched when disabled");
303
- } finally {
304
- state.config = saved;
305
- }
306
- });
307
- });
package/src/pi/state.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Session-level config holder.
3
3
  *
4
4
  * globalThis singleton (consistent with the repo's module-identity guidance).
5
- * Config is loaded on session_start and read by the read/edit overrides.
5
+ * Config is loaded when the extension registers and read by the tool
6
+ * overrides.
6
7
  *
7
8
  * @module pi-hashline-edit/pi
8
9
  */