@d3ara1n/pi-hashline-edit 0.1.2 → 0.3.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.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * `replace`: powerful bulk text replacement — literal substring (replaceAll) or
3
+ * full JavaScript regex with capture-group substitution.
4
+ *
5
+ * Distinct from the anchor-verified `edit`. `replace` is **location-blind**:
6
+ * it matches `find` everywhere across the whole file and substitutes every
7
+ * occurrence. Use it for renames and pattern-based transforms that would
8
+ * otherwise need many individual edits. For a single surgical, verified change,
9
+ * prefer `edit`.
10
+ *
11
+ * - Literal mode (`regex` false): `find` is a substring, matched verbatim;
12
+ * `replace` is inserted as-is (no `$` expansion).
13
+ * - Regex mode (`regex` true): `find` is a JS pattern source; `replace` supports
14
+ * `$1`, `$2`, `$&`, etc.
15
+ * - `flags` adds regex flags in either mode; `g` is always forced so every
16
+ * occurrence is replaced. `i` (case-insensitive), `m` (per-line ^/$),
17
+ * `s` (dotall), `u` (unicode) all work.
18
+ *
19
+ * Concurrency: read-modify-write is wrapped in {@link withFileMutationQueue}
20
+ * (shared with `edit`), so a `replace` and an `edit` on the same file never
21
+ * interleave. AbortSignal is honored after read / before write.
22
+ *
23
+ * @module pi-hashline-edit/pi
24
+ */
25
+
26
+ import {
27
+ generateDiffString,
28
+ generateUnifiedPatch,
29
+ withFileMutationQueue,
30
+ type EditToolDetails,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { Type, type Static } from "typebox";
33
+ import { Text } from "@earendil-works/pi-tui";
34
+ import { readFile, writeFile } from "node:fs/promises";
35
+ import { hashFileLines, splitLines } from "../core/index.ts";
36
+ import { getState } from "./state.ts";
37
+ import { canonicalPath } from "./read-tool.ts";
38
+
39
+ /** Cap on updated-anchor lines returned inline (bounds token cost for large spans). */
40
+ const MAX_ANCHOR_LINES = 40;
41
+ /** Default safety cap on match count (errors before writing if exceeded). */
42
+ const DEFAULT_MAX_MATCHES = 2000;
43
+ /** Valid JavaScript regular-expression flag characters (ES2023+, incl. hasIndices `d`). */
44
+ const VALID_FLAGS = new Set(["g", "i", "m", "s", "u", "y", "d"]);
45
+
46
+ const replaceSchema = Type.Object({
47
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
48
+ find: Type.String({
49
+ description:
50
+ "Text to find. Literal substring when `regex` is false/omitted; a JavaScript regex pattern source when `regex` is true.",
51
+ }),
52
+ replace: Type.String({
53
+ description:
54
+ "Replacement text. Literal mode: inserted verbatim (no $ expansion). Regex mode: supports $1, $2, $&, $`, $' etc.",
55
+ }),
56
+ regex: Type.Optional(
57
+ Type.Boolean({
58
+ description:
59
+ "Treat `find` as a JavaScript regex pattern source (default false = literal substring, all occurrences replaced).",
60
+ }),
61
+ ),
62
+ flags: Type.Optional(
63
+ Type.String({
64
+ description:
65
+ "Regex flags appended in BOTH modes ('g' is always forced so every occurrence is replaced). Default ''. Common: 'i' (case-insensitive), 'm' (^/$ per line), 's' (dotall, . matches \\n), 'u' (unicode).",
66
+ }),
67
+ ),
68
+ maxMatches: Type.Optional(
69
+ Type.Number({
70
+ description: `Safety cap: errors before writing if more matches than this (default ${DEFAULT_MAX_MATCHES}). Raise for deliberate bulk transforms.`,
71
+ }),
72
+ ),
73
+ });
74
+
75
+ type ReplaceParams = Static<typeof replaceSchema>;
76
+
77
+ /** Escape regex metacharacters so a literal string is matched verbatim. */
78
+ function escapeRegex(s: string): string {
79
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ }
81
+
82
+ /**
83
+ * Build the matcher. `find` is escaped in literal mode; `flags` (validated) get
84
+ * `g` forced so all occurrences replace. Construction errors (bad pattern /
85
+ * conflicting flags such as `g`+`y`) surface as a friendly message rather than
86
+ * a raw `SyntaxError`.
87
+ */
88
+ function buildRegex(find: string, isRegex: boolean, flagsRaw: string | undefined): RegExp {
89
+ for (const c of flagsRaw ?? "") {
90
+ if (!VALID_FLAGS.has(c)) throw new Error(`invalid regex flag '${c}' (valid: g i m s u y d)`);
91
+ }
92
+ const set = new Set((flagsRaw ?? "").split(""));
93
+ set.add("g");
94
+ const flagStr = [...set].join("");
95
+ const source = isRegex ? find : escapeRegex(find);
96
+ try {
97
+ return new RegExp(source, flagStr);
98
+ } catch (e) {
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ throw new Error(`invalid regex /${source}/${flagStr}: ${msg}`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * First-to-last differing line span (0-based, inclusive) in the NEW line array —
106
+ * a contiguous superset that contains every changed line. Computed by stripping
107
+ * the common prefix and suffix, so it is O(n) regardless of file size (no LCS
108
+ * DP). `null` when the text is unchanged. Used only to bound the anchor report.
109
+ */
110
+ function changedSpan(oldLines: readonly string[], newLines: readonly string[]): { start: number; end: number } | null {
111
+ const n = Math.min(oldLines.length, newLines.length);
112
+ let prefix = 0;
113
+ while (prefix < n && oldLines[prefix] === newLines[prefix]) prefix++;
114
+ // same length and fully equal → no change
115
+ if (oldLines.length === newLines.length && prefix === oldLines.length) return null;
116
+ let oldSuffix = 0;
117
+ let newSuffix = 0;
118
+ while (
119
+ oldSuffix < oldLines.length - prefix &&
120
+ newSuffix < newLines.length - prefix &&
121
+ oldLines[oldLines.length - 1 - oldSuffix] === newLines[newLines.length - 1 - newSuffix]
122
+ ) {
123
+ oldSuffix++;
124
+ newSuffix++;
125
+ }
126
+ const start = prefix;
127
+ const end = newLines.length - 1 - newSuffix; // inclusive, 0-based, in new
128
+ return end < start ? null : { start, end };
129
+ }
130
+
131
+ /** Format fresh `LINE#HASH│content` anchors for a contiguous span of the new file, capped. */
132
+ function formatSpanAnchors(newLines: readonly string[], span: { start: number; end: number }, hashLen: number): string {
133
+ const hashes = hashFileLines(newLines, hashLen);
134
+ const rows: string[] = [];
135
+ for (let i = span.start; i <= span.end; i++) rows.push(`${i + 1}#${hashes[i]}│${newLines[i]}`);
136
+ const shown = rows.length > MAX_ANCHOR_LINES ? rows.slice(0, MAX_ANCHOR_LINES) : rows;
137
+ const more = rows.length > MAX_ANCHOR_LINES ? `\n… (${rows.length - MAX_ANCHOR_LINES} more; re-read for full anchors)` : "";
138
+ return `\nUpdated anchors (changed region):\n${shown.join("\n")}${more}`;
139
+ }
140
+
141
+ /** Truncate a string for one-line display, folding newlines into a marker. */
142
+ function show(s: string, n = 30): string {
143
+ const folded = s.replace(/\n/g, "⏎");
144
+ return folded.length > n ? folded.slice(0, n) + "…" : folded;
145
+ }
146
+
147
+ export function makeReplaceTool(cwd: string) {
148
+ return {
149
+ name: "replace" as const,
150
+ label: "replace",
151
+ description:
152
+ "Bulk text replacement with regex support. Replaces ALL occurrences of `find` with `replace` across the whole file — for renames and pattern-based transforms that would need many individual edits. Location-blind (unverified): prefer `edit` for surgical, anchor-verified changes.",
153
+ promptSnippet: "Replace all occurrences of a string/regex across a file (bulk + regex; returns diff and fresh anchors)",
154
+ promptGuidelines: [
155
+ "Pass `path`, `find`, `replace`. ALL occurrences are replaced (not just the first).",
156
+ "`regex: true` treats `find` as a JS regex pattern; capture groups are usable in `replace` via $1, $2, $&. Default false = literal substring (replace text inserted verbatim, no $ expansion).",
157
+ "`flags` adds regex flags ('g' is always forced so every occurrence is replaced). Common: 'i' (case-insensitive), 'm' (^/$ per line), 's' (dotall, . matches \\n), 'u' (unicode). Applies in both modes.",
158
+ "`maxMatches` caps the match count (default 2000) — errors before writing if exceeded; raise it for deliberate bulk transforms.",
159
+ "Returns a diff plus fresh anchors for the changed region; chain edits, or re-read if you need the whole file's anchors.",
160
+ "0 matches is an error. This is a location-blind bulk tool — for one verified change use `edit` instead.",
161
+ ],
162
+ parameters: replaceSchema,
163
+ renderShell: "default" as const,
164
+
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);
172
+ },
173
+
174
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
175
+ if (isPartial) return new Text(theme.fg("warning", "Replacing…"), 0, 0);
176
+ const content = result.content?.[0];
177
+ if (context.isError) {
178
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
179
+ return new Text(theme.fg("error", t), 0, 0);
180
+ }
181
+ const diff: string | undefined = result.details?.diff;
182
+ if (!diff) {
183
+ const t = content?.type === "text" ? content.text : "Replaced";
184
+ return new Text(theme.fg("success", t), 0, 0);
185
+ }
186
+ // details.diff is pi-format (+N/-N/<space>N content); color by leading char
187
+ const allLines = diff.split("\n");
188
+ const shown = expanded ? allLines : allLines.slice(0, 24);
189
+ const body = shown
190
+ .map((line: string) => {
191
+ if (line.startsWith("+")) return theme.fg("success", line);
192
+ if (line.startsWith("-")) return theme.fg("error", line);
193
+ return theme.fg("dim", line);
194
+ })
195
+ .join("\n");
196
+ const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
197
+ return new Text(body + more, 0, 0);
198
+ },
199
+
200
+ async execute(toolCallId: string, params: ReplaceParams, signal: AbortSignal | undefined, onUpdate: any) {
201
+ const state = getState();
202
+ // Gated by the same `enabled` switch as read/grep/edit — they delegate to the
203
+ // built-ins when disabled; replace has no built-in counterpart, so it refuses.
204
+ if (!state.config.enabled)
205
+ throw new Error(
206
+ "Replace is unavailable: hashlineEdit is disabled. Set hashlineEdit.enabled = true to use it.",
207
+ );
208
+ const path = params.path;
209
+ const absPath = canonicalPath(cwd, path);
210
+
211
+ // withFileMutationQueue serializes read-modify-write for the same file,
212
+ // shared with `edit` — a replace and an edit on the same file never interleave.
213
+ return await withFileMutationQueue(absPath, () => runReplace(absPath, path, params, state.config.hashLen, signal));
214
+ },
215
+ };
216
+ }
217
+
218
+ async function runReplace(
219
+ absPath: string,
220
+ displayPath: string,
221
+ params: ReplaceParams,
222
+ hashLen: number,
223
+ signal: AbortSignal | undefined,
224
+ ) {
225
+ const { find, replace } = params;
226
+ const isRegex = params.regex === true;
227
+ const maxMatches = params.maxMatches ?? DEFAULT_MAX_MATCHES;
228
+
229
+ if (find === "") throw new Error(`Replace ${displayPath}: \`find\` is empty.`);
230
+
231
+ let currentText: string;
232
+ try {
233
+ currentText = (await readFile(absPath)).toString("utf-8");
234
+ } catch (e) {
235
+ const msg = e instanceof Error ? e.message : String(e);
236
+ throw new Error(`Error reading ${displayPath}: ${msg}`);
237
+ }
238
+ // honor cancel after read: if aborted, don't proceed to match/replace; the file stays untouched
239
+ if (signal?.aborted) throw new Error(`Replace ${displayPath} aborted before apply.`);
240
+
241
+ let regex: RegExp;
242
+ try {
243
+ regex = buildRegex(find, isRegex, params.flags);
244
+ } catch (e) {
245
+ throw new Error(`Replace ${displayPath}: ${e instanceof Error ? e.message : String(e)}`);
246
+ }
247
+
248
+ // Count matches with an early guard so a runaway pattern (e.g. an empty-match
249
+ // regex) can't produce a catastrophic write. matchAll does not mutate the
250
+ // regex's lastIndex (it clones internally), so the subsequent `replace` is safe.
251
+ let count = 0;
252
+ for (const _ of currentText.matchAll(regex)) {
253
+ count++;
254
+ if (count > maxMatches) {
255
+ throw new Error(
256
+ `Replace ${displayPath}: ${count}+ matches exceed \`maxMatches\` (${maxMatches}). Raise \`maxMatches\` if intentional, or narrow \`find\`.`,
257
+ );
258
+ }
259
+ }
260
+ if (count === 0) {
261
+ const shown = isRegex ? `/${find}/` : JSON.stringify(find);
262
+ throw new Error(`Replace ${displayPath}: no matches for ${shown}.`);
263
+ }
264
+
265
+ // Literal mode uses a function replacement so `$` in `replace` stays literal;
266
+ // regex mode passes the string so $1/$& etc. expand.
267
+ // Literal mode uses a function replacement so `$` in `replace` stays literal;
268
+ // regex mode passes the string so $1/$& etc. expand.
269
+ const newText = isRegex ? currentText.replace(regex, replace) : currentText.replace(regex, () => replace);
270
+ const changed = newText !== currentText;
271
+
272
+ // honor cancel before write: if aborted, don't touch the disk
273
+ if (signal?.aborted) throw new Error(`Replace ${displayPath} aborted before write.`);
274
+
275
+ if (changed) {
276
+ try {
277
+ await writeFile(absPath, newText);
278
+ } catch (e) {
279
+ const msg = e instanceof Error ? e.message : String(e);
280
+ throw new Error(`Error writing ${displayPath}: ${msg}`);
281
+ }
282
+ }
283
+
284
+ const { diff, firstChangedLine } = generateDiffString(currentText, newText);
285
+ const details: EditToolDetails = {
286
+ diff,
287
+ patch: generateUnifiedPatch(displayPath, currentText, newText),
288
+ firstChangedLine,
289
+ };
290
+
291
+ const oldLines = splitLines(currentText);
292
+ const newLines = splitLines(newText);
293
+ const span = changed ? changedSpan(oldLines, newLines) : null;
294
+ const anchors = span ? formatSpanAnchors(newLines, span, hashLen) : "";
295
+
296
+ const matchWord = `match${count !== 1 ? "es" : ""}`;
297
+ const note = changed ? `${count} ${matchWord}` : `${count} ${matchWord}, no net change`;
298
+ return {
299
+ content: [{ type: "text" as const, text: `Replaced ${displayPath} (${note}).${anchors}` }],
300
+ details,
301
+ };
302
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * pi integration tests for the `replace` tool: literal replaceAll, regex with
3
+ * capture groups, flags, the maxMatches guard, 0-match / invalid-pattern
4
+ * failures (signaled by throwing — pi's failure contract), diff + fresh-anchor
5
+ * return, and chaining a hashline `edit` on an anchor returned by `replace`.
6
+ *
7
+ * Failures are signaled by throwing (see edit-tool.ts); tests use assert.rejects.
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { makeReplaceTool } from "./replace-tool.ts";
15
+ import { makeEditOverride } from "./edit-tool.ts";
16
+ import { getState } from "./state.ts";
17
+
18
+ async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
19
+ const dir = await mkdtemp(join(tmpdir(), "hl-replace-"));
20
+ try {
21
+ return await fn(dir);
22
+ } finally {
23
+ await rm(dir, { recursive: true, force: true });
24
+ }
25
+ }
26
+
27
+ const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
28
+
29
+ /** Extract a `LINE#HASH` anchor for `line` from a result text block. */
30
+ function anchorLine(block: string, line: number) {
31
+ const m = new RegExp(`^${line}#([0-9A-Z]+)│`, "m").exec(block);
32
+ if (!m) throw new Error(`line ${line} anchor not found in block`);
33
+ return { line, hash: m[1] };
34
+ }
35
+
36
+ const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
37
+
38
+ test("replace literal: replaces all occurrences", async () => {
39
+ await withDir(async (dir) => {
40
+ const f = join(dir, "f.txt");
41
+ await writeFile(f, "foo bar foo baz foo\n");
42
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "foo", replace: "qux" });
43
+ assert.equal(r.isError, undefined);
44
+ assert.equal(await readFile(f, "utf-8"), "qux bar qux baz qux\n");
45
+ assert.match(r.content[0].text, /3 matches/);
46
+ });
47
+ });
48
+
49
+ test("replace literal: $ in replacement stays literal (no expansion)", async () => {
50
+ await withDir(async (dir) => {
51
+ const f = join(dir, "f.txt");
52
+ await writeFile(f, "cost: $5 here\n");
53
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "$5", replace: "$10" });
54
+ assert.equal(r.isError, undefined);
55
+ // literal mode: "$10" inserted verbatim, NOT interpreted as group-10 + "0"
56
+ assert.equal(await readFile(f, "utf-8"), "cost: $10 here\n");
57
+ });
58
+ });
59
+
60
+ test("replace literal: case-insensitive via flags", async () => {
61
+ await withDir(async (dir) => {
62
+ const f = join(dir, "f.txt");
63
+ await writeFile(f, "Foo fOo FOO\n");
64
+ await call(makeReplaceTool(dir), { path: "f.txt", find: "foo", replace: "x", flags: "i" });
65
+ assert.equal(await readFile(f, "utf-8"), "x x x\n");
66
+ });
67
+ });
68
+
69
+ test("replace regex: capture groups in replacement", async () => {
70
+ await withDir(async (dir) => {
71
+ const f = join(dir, "f.txt");
72
+ await writeFile(f, "name: alice\nname: bob\n");
73
+ await call(makeReplaceTool(dir), {
74
+ path: "f.txt",
75
+ find: "name: (\\w+)",
76
+ replace: "user: $1",
77
+ regex: true,
78
+ });
79
+ assert.equal(await readFile(f, "utf-8"), "user: alice\nuser: bob\n");
80
+ });
81
+ });
82
+
83
+ test("replace regex: multiline flag matches line-anchored pattern", async () => {
84
+ await withDir(async (dir) => {
85
+ const f = join(dir, "f.txt");
86
+ await writeFile(f, "a\nb\nc\n");
87
+ // without `m`, ^a$ wouldn't match (a isn't at end of string); with `m` it does
88
+ await call(makeReplaceTool(dir), { path: "f.txt", find: "^a$", replace: "A", regex: true, flags: "m" });
89
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nc\n");
90
+ });
91
+ });
92
+
93
+ test("replace regex: dotall flag makes . match newlines", async () => {
94
+ await withDir(async (dir) => {
95
+ const f = join(dir, "f.txt");
96
+ await writeFile(f, "a\nb\n");
97
+ // a.b only matches across the newline with the `s` (dotall) flag
98
+ await call(makeReplaceTool(dir), { path: "f.txt", find: "a.b", replace: "X", regex: true, flags: "s" });
99
+ assert.equal(await readFile(f, "utf-8"), "X\n");
100
+ });
101
+ });
102
+
103
+ test("replace: 0 matches throws", async () => {
104
+ await withDir(async (dir) => {
105
+ const f = join(dir, "f.txt");
106
+ await writeFile(f, "a\nb\n");
107
+ await assert.rejects(
108
+ call(makeReplaceTool(dir), { path: "f.txt", find: "zzz", replace: "y" }),
109
+ /no matches/,
110
+ );
111
+ assert.equal(await readFile(f, "utf-8"), "a\nb\n", "file untouched on 0-match failure");
112
+ });
113
+ });
114
+
115
+ test("replace: empty find throws", async () => {
116
+ await withDir(async (dir) => {
117
+ await writeFile(join(dir, "f.txt"), "a\n");
118
+ await assert.rejects(
119
+ call(makeReplaceTool(dir), { path: "f.txt", find: "", replace: "x" }),
120
+ /`find` is empty/,
121
+ );
122
+ });
123
+ });
124
+
125
+ test("replace: maxMatches guard throws before writing", async () => {
126
+ await withDir(async (dir) => {
127
+ const f = join(dir, "f.txt");
128
+ await writeFile(f, "a".repeat(20) + "\n");
129
+ await assert.rejects(
130
+ call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "b", maxMatches: 5 }),
131
+ /exceed `maxMatches`/,
132
+ );
133
+ assert.equal(await readFile(f, "utf-8"), "a".repeat(20) + "\n", "file untouched when guard trips");
134
+ });
135
+ });
136
+
137
+ test("replace: invalid regex throws a friendly message", async () => {
138
+ await withDir(async (dir) => {
139
+ await writeFile(join(dir, "f.txt"), "a\n");
140
+ await assert.rejects(
141
+ call(makeReplaceTool(dir), { path: "f.txt", find: "(unclosed", replace: "x", regex: true }),
142
+ /invalid regex/,
143
+ );
144
+ });
145
+ });
146
+
147
+ test("replace: invalid flag char throws", async () => {
148
+ await withDir(async (dir) => {
149
+ await writeFile(join(dir, "f.txt"), "a\n");
150
+ await assert.rejects(
151
+ call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "x", flags: "z" }),
152
+ /invalid regex flag/,
153
+ );
154
+ });
155
+ });
156
+
157
+ test("replace: count-but-no-net-change does not rewrite and reports it", async () => {
158
+ await withDir(async (dir) => {
159
+ const f = join(dir, "f.txt");
160
+ await writeFile(f, "xx\n");
161
+ // $& = whole match = "x", so the text is unchanged despite 2 matches
162
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "x", replace: "$&", regex: true });
163
+ assert.equal(r.isError, undefined);
164
+ assert.equal(await readFile(f, "utf-8"), "xx\n");
165
+ assert.match(r.content[0].text, /no net change/);
166
+ });
167
+ });
168
+
169
+ test("replace: returns details.diff (string) + patch + firstChangedLine", async () => {
170
+ await withDir(async (dir) => {
171
+ const f = join(dir, "f.txt");
172
+ await writeFile(f, "a\nb\nc\n");
173
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "b", replace: "B" });
174
+ assert.equal(typeof r.details.diff, "string");
175
+ assert.equal(typeof r.details.patch, "string");
176
+ assert.equal(typeof r.details.firstChangedLine, "number");
177
+ });
178
+ });
179
+
180
+ test("replace: returns fresh anchors for the changed region", async () => {
181
+ await withDir(async (dir) => {
182
+ const f = join(dir, "f.txt");
183
+ await writeFile(f, "a\nb\nc\n");
184
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "b", replace: "B" });
185
+ const out: string = r.content[0].text;
186
+ assert.match(out, /Updated anchors/);
187
+ // line 2 now holds "B"; its anchor must be present and correct
188
+ const a2 = anchorLine(out, 2);
189
+ assert.ok(a2.hash.length >= 2);
190
+ });
191
+ });
192
+
193
+ test("replace: changed-region anchor chains a subsequent hashline edit without a re-read", async () => {
194
+ await withDir(async (dir) => {
195
+ const f = join(dir, "f.txt");
196
+ await writeFile(f, "old old old\n");
197
+ const replace = makeReplaceTool(dir);
198
+ const r1: any = await call(replace, { path: "f.txt", find: "old", replace: "new" });
199
+ const out: string = r1.content[0].text;
200
+ // line 1 is the changed line; chain an edit on its returned anchor
201
+ const a1 = anchorLine(out, 1);
202
+ const edit = makeEditOverride(dir);
203
+ const r2: any = await call(edit, {
204
+ path: "f.txt",
205
+ edits: [{ op: "replace", anchor: a1, body: ["NEW NEW NEW"] }],
206
+ });
207
+ assert.equal(r2.isError, undefined);
208
+ assert.equal(await readFile(f, "utf-8"), "NEW NEW NEW\n");
209
+ });
210
+ });
211
+
212
+ test("replace: multiline replacement (changes line count) still reports a correct span", async () => {
213
+ await withDir(async (dir) => {
214
+ const f = join(dir, "f.txt");
215
+ await writeFile(f, "a\nb\nc\n");
216
+ // replace "b" with two lines → line count grows; changed region must cover the insertion
217
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "b", replace: "B1\nB2" });
218
+ assert.equal(await readFile(f, "utf-8"), "a\nB1\nB2\nc\n");
219
+ const out: string = r.content[0].text;
220
+ // the two inserted/changed lines (2 and 3) should both be anchored
221
+ assert.doesNotThrow(() => anchorLine(out, 2));
222
+ assert.doesNotThrow(() => anchorLine(out, 3));
223
+ });
224
+ });
225
+
226
+ test("replace renderResult: renders the diff without throwing", async () => {
227
+ await withDir(async (dir) => {
228
+ const f = join(dir, "f.txt");
229
+ await writeFile(f, "a\nb\nc\n");
230
+ const tool = makeReplaceTool(dir);
231
+ const r: any = await call(tool, { path: "f.txt", find: "b", replace: "B" });
232
+ // @ts-ignore — drive the renderer with a stub theme
233
+ const comp: any = tool.renderResult(
234
+ { content: r.content, details: r.details },
235
+ { isPartial: false, expanded: true },
236
+ stubTheme,
237
+ { isError: r.isError ?? false },
238
+ );
239
+ assert.ok(typeof comp?.text === "string");
240
+ assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
241
+ });
242
+ });
243
+
244
+ test("replace renderResult: renders the error line without throwing", async () => {
245
+ await withDir(async (dir) => {
246
+ const f = join(dir, "f.txt");
247
+ await writeFile(f, "a\n");
248
+ const tool = makeReplaceTool(dir);
249
+ let thrown: any;
250
+ const r: any = await call(tool, { path: "f.txt", find: "zzz", replace: "y" }).catch((e: any) => {
251
+ thrown = e;
252
+ return null;
253
+ });
254
+ assert.ok(thrown, "expected the call to throw");
255
+ // @ts-ignore — simulate how the framework hands the thrown message to renderResult
256
+ const comp: any = tool.renderResult(
257
+ { content: [{ type: "text", text: thrown.message }] },
258
+ { isPartial: false, expanded: false },
259
+ stubTheme,
260
+ { isError: true },
261
+ );
262
+ assert.ok(typeof comp?.text === "string");
263
+ });
264
+ });
265
+
266
+ test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
267
+ await withDir(async (dir) => {
268
+ const f = join(dir, "f.txt");
269
+ await writeFile(f, "a\nb\na\n");
270
+ const state = getState();
271
+ const saved = state.config;
272
+ state.config = { ...saved, enabled: false };
273
+ try {
274
+ await assert.rejects(
275
+ call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "b" }),
276
+ /disabled/,
277
+ );
278
+ assert.equal(await readFile(f, "utf-8"), "a\nb\na\n", "file untouched when disabled");
279
+ } finally {
280
+ state.config = saved;
281
+ }
282
+ });
283
+ });
package/src/pi/state.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import type { HashlineEditConfig } from "./config.ts";
11
11
 
12
12
  const GLOBAL_KEY = "__piHashlineEdit";
13
- const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
13
+ const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
14
14
 
15
15
  export interface HashlineEditState {
16
16
  config: HashlineEditConfig;