pi-hashline-edit-pro 6.1.1 → 6.1.2

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
@@ -190,6 +190,8 @@ Edge cases:
190
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
191
191
  | `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
192
192
 
193
+ `replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
194
+
193
195
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
194
196
 
195
197
  ```json
@@ -219,6 +221,8 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
219
221
 
220
222
  `replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
221
223
 
224
+ `remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
225
+
222
226
  `old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
223
227
 
224
228
  In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` aborts the whole batch unwritten. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
@@ -346,7 +350,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
346
350
 
347
351
  | Command | Description |
348
352
  | --- | --- |
349
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, disable on models, disable read on models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
353
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, disable on models, read on disabled models, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
350
354
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
351
355
 
352
356
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -363,7 +367,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
363
367
  "strictInput": false,
364
368
  "diffContextLines": 1,
365
369
  "disableOnModels": [],
366
- "disableReadOnModels": true
370
+ "readOnDisabledModels": "vanilla"
367
371
  }
368
372
  ```
369
373
 
@@ -378,10 +382,10 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
378
382
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
379
383
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
380
384
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
381
- | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` is only removed while Disable read on models is on. |
382
- | `disableReadOnModels` | Disable read on models | `true` | Also remove `read` from the active tools for a model matched by `disableOnModels`. Turn it off to keep `read` active on those models. |
385
+ | `disableOnModels` | Disable on models | `[]` | Model globs matched case-insensitively against `provider/id`, the bare `id`, and `api`, with `*` and `?` wildcards. A matching model gets no anchored edit tools, no auto-read, and no write-echo refusal; `read` stays available as pi's ordinary read unless Read on disabled models is `remove`. |
386
+ | `readOnDisabledModels` | Read on disabled models | `"vanilla"` | `read` for a model matched by `disableOnModels`: `"vanilla"` keeps pi's ordinary `read` active while the anchored edit tools stay off; `"remove"` disables `read` with the rest of the anchored tools. |
383
387
 
384
- `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, and also `read` while Disable read on models is on (the default), restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all unless another extension provides one, so turn Disable read on models off to keep the anchored `read` active on those models. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
388
+ `disableOnModels` turns off the whole anchored surface for the models you name, so another edit tool (`apply_patch`, a shell read) can own the session without competing instructions. A match removes `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, restoring the built-in `grep` when it was active, and it also skips the auto-read-all injection, the auto-read block after `write`, the post-edit diff substitution, and the `write` hook that refuses a `write` echoing a served anchor. Because the extension's `read` replaces the built-in one, a matched model has no `read` at all under `remove` unless another extension provides one; the default `vanilla` gives those models pi's ordinary `read` while the anchored edit tools stay off. The list is checked on `session_start`, on `model_select`, and again on `before_agent_start`, so a model change inside a session switches the surface immediately and another extension cannot re-add the tools mid-session. The default `[]` leaves every model unchanged.
385
389
 
386
390
  When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows the directory always uses `~/.config`, where `~` is `%USERPROFILE%`. To move the directory explicitly, see [Isolated state](#isolated-state).
387
391
 
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
2
+ import { createReadToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
3
  import { initHasher } from "./src/hashline";
4
4
  import { regReplaceMatch } from "./src/replace-match";
5
5
  import { regReplace } from "./src/replace";
@@ -16,7 +16,7 @@ import type { ReplaceDetails } from "./src/replace";
16
16
  import { extractHints, extractWarnings } from "./src/replace-render";
17
17
  import { MAX_HASH_LINES } from "./src/hashline";
18
18
  import { withStructuredText } from "./src/structured";
19
- import type { AutoReadAllMode } from "./src/config";
19
+ import type { AutoReadAllMode, ReadOnDisabledModels } from "./src/config";
20
20
  import {
21
21
  readConfig,
22
22
  readConfigWithStatus,
@@ -25,7 +25,7 @@ import {
25
25
  toggleAnchorGrep,
26
26
  toggleCopyMove,
27
27
  toggleReplaceMatch,
28
- toggleDisableReadOnModels,
28
+ cycleReadOnDisabledModels,
29
29
  toggleRequirePath,
30
30
  toggleStrictInput,
31
31
  adjustDiffContextLines,
@@ -62,7 +62,9 @@ export default function (pi: ExtensionAPI): void {
62
62
  let autoReadAll: AutoReadAllMode = "off";
63
63
  let autoReadAllIgnore: string[] = [];
64
64
  let disableOnModels: string[] = [];
65
- let disableReadOnModels = true;
65
+ let readOnDisabledModels: ReadOnDisabledModels = "vanilla";
66
+ let readFlavor: "anchored" | "vanilla" = "anchored";
67
+ let readFlavorCwd: string | undefined;
66
68
  let autoReadAllInjected = false;
67
69
  let grepWasActive = false;
68
70
  const baseAnchorTools = new Set<string>();
@@ -71,7 +73,7 @@ export default function (pi: ExtensionAPI): void {
71
73
  async function refreshEditTools(): Promise<void> {
72
74
  try {
73
75
  const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
74
- regRead(pi, flags);
76
+ if (readFlavor !== "vanilla") regRead(pi, flags);
75
77
  regReplace(pi, flags);
76
78
  regReplaceMatch(pi, flags);
77
79
  regInsert(pi, flags);
@@ -84,11 +86,24 @@ export default function (pi: ExtensionAPI): void {
84
86
  }
85
87
  }
86
88
 
87
- async function syncModelGate(model: ModelLike | undefined): Promise<void> {
88
- if (disableOnModels.length > 0 && modelDisabled(model, disableOnModels)) {
89
+ async function syncModelGate(model: ModelLike | undefined, cwd: string): Promise<void> {
90
+ const gated = disableOnModels.length > 0 && modelDisabled(model, disableOnModels);
91
+ if (gated && readOnDisabledModels === "vanilla") {
92
+ if (readFlavor !== "vanilla" || readFlavorCwd !== cwd) {
93
+ pi.registerTool(createReadToolDefinition(cwd));
94
+ readFlavor = "vanilla";
95
+ readFlavorCwd = cwd;
96
+ }
97
+ } else if (readFlavor === "vanilla") {
98
+ readFlavor = "anchored";
99
+ readFlavorCwd = undefined;
100
+ await refreshEditTools();
101
+ }
102
+ if (gated) {
89
103
  const active = pi.getActiveTools();
90
- const gated = disableReadOnModels ? ANCHOR_TOOL_NAMES : ANCHOR_TOOL_NAMES.filter((tool) => tool !== "read");
91
- const next = active.filter((tool) => !gated.includes(tool));
104
+ const gatedTools = readOnDisabledModels === "vanilla" ? ANCHOR_TOOL_NAMES.filter((tool) => tool !== "read") : ANCHOR_TOOL_NAMES;
105
+ const next = active.filter((tool) => !gatedTools.includes(tool));
106
+ if (readOnDisabledModels === "vanilla" && baseAnchorTools.has("read") && !next.includes("read")) next.push("read");
92
107
  if (grepWasActive && !next.includes("grep")) next.push("grep");
93
108
  pi.setActiveTools(next);
94
109
  gateApplied = true;
@@ -136,7 +151,7 @@ export default function (pi: ExtensionAPI): void {
136
151
  autoReadAll = config.autoReadAll ?? "off";
137
152
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
138
153
  disableOnModels = config.disableOnModels ?? [];
139
- disableReadOnModels = config.disableReadOnModels !== false;
154
+ readOnDisabledModels = config.readOnDisabledModels ?? "vanilla";
140
155
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
141
156
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
142
157
  await refreshEditTools();
@@ -148,7 +163,7 @@ export default function (pi: ExtensionAPI): void {
148
163
  return true;
149
164
  }),
150
165
  );
151
- await syncModelGate(ctx.model);
166
+ await syncModelGate(ctx.model, ctx.cwd);
152
167
  const debugValue = process.env.PI_HASHLINE_DEBUG;
153
168
  if (debugValue === "1" || debugValue === "true") {
154
169
  ctx.ui.notify(`Hashline Edit mode active`, "info");
@@ -165,11 +180,11 @@ export default function (pi: ExtensionAPI): void {
165
180
  }
166
181
  });
167
182
  pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
168
- await syncModelGate(event.model);
183
+ await syncModelGate(event.model, ctx.cwd);
169
184
  }));
170
185
 
171
186
  pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
172
- await syncModelGate(ctx.model);
187
+ await syncModelGate(ctx.model, ctx.cwd);
173
188
  if (autoReadAll === "off" || autoReadAllInjected) return;
174
189
  if (modelDisabled(ctx.model, disableOnModels)) return;
175
190
  autoReadAllInjected = true;
@@ -201,7 +216,7 @@ export default function (pi: ExtensionAPI): void {
201
216
  else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
202
217
  else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
203
218
  else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
204
- else if (key === "disableReadOnModels") disableReadOnModels = await toggleDisableReadOnModels();
219
+ else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels();
205
220
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
206
221
  else if (key === "anchorGrepEnabled") {
207
222
  const enabled = await toggleAnchorGrep();
@@ -229,7 +244,7 @@ export default function (pi: ExtensionAPI): void {
229
244
  else if (key === "requirePath") await toggleRequirePath();
230
245
  else if (key === "strictInput") await toggleStrictInput();
231
246
  await refreshEditTools();
232
- await syncModelGate(ctx.model);
247
+ await syncModelGate(ctx.model, ctx.cwd);
233
248
  },
234
249
  });
235
250
  await overlay.load();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.1.1",
3
+ "version": "6.1.2",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,3 +1,3 @@
1
- Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `old_string` is the text to find inside that range, and `new_string` replaces every occurrence of it.
1
+ Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `old_string` is the text to find inside that range, and `new_string` replaces every occurrence of it. `remove_from`/`remove_to` are accepted as aliases for `replace_from`/`replace_to`.
2
2
 
3
3
  The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
@@ -1,2 +1,2 @@
1
- Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. The text is written exactly as given, and nothing else in the file changes.
1
+ Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `text` is one string holding the exact replacement text. The text is written exactly as given, and nothing else in the file changes. `replace_from`/`replace_to` are accepted as aliases for `remove_from`/`remove_to`.
2
2
  To change only part of a line without retyping the rest, use `replace_match` instead; it preserves every character the request does not name. Deleting every line empties the file; the result names the new empty-line anchor, so a follow-up `replace` on it can seed content without a `read`.
package/src/batch.ts CHANGED
@@ -233,7 +233,7 @@ export function resetBatchStateForTests(): void {
233
233
 
234
234
  function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | undefined {
235
235
  if (!isRec(args)) return undefined;
236
- const normalized = normReq(args);
236
+ const normalized = normReq(args, name === "replace_match" ? "replace" : name === "replace" ? "remove" : undefined);
237
237
  if (!isRec(normalized)) return undefined;
238
238
  const path = typeof normalized.path === "string" ? normalized.path : undefined;
239
239
  if (name === "copy" || name === "move") {
@@ -787,7 +787,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
787
787
  if (!planned) continue;
788
788
  if (executedOrders.has(planned.order)) continue;
789
789
  try {
790
- const normalized = normReq(planned.args);
790
+ const normalized = normReq(planned.args, planned.kind === "replace_match" ? "replace" : planned.kind === "replace" ? "remove" : undefined);
791
791
  if (planned.kind === "insert") assertInsertReq(normalized);
792
792
  else if (planned.kind === "replace") assertReq(normalized);
793
793
  else if (planned.kind === "replace_match") assertReplaceMatchReq(normalized);
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "disableReadOnModels";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -28,7 +28,7 @@ export function configRows(config: Config): ConfigRow[] {
28
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
29
  { key: "replaceMatchEnabled", label: "Replace match", hint: "replace_match tool (off while disabled)", enabled: config.replaceMatchEnabled !== false },
30
30
  { key: "disableOnModels", label: "Disable on models", hint: "Model globs (provider/id, model id, or api) that turn off the anchored edit tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
31
- { key: "disableReadOnModels", label: "Disable read on models", hint: "Also turn off read for models matched by Disable on models (off keeps read active)", enabled: config.disableReadOnModels !== false },
31
+ { key: "readOnDisabledModels", label: "Read on disabled models", hint: "Read for models matched by Disable on models: vanilla (plain pi read), or remove it", enabled: (config.readOnDisabledModels ?? "vanilla") !== "remove", mode: config.readOnDisabledModels ?? "vanilla", cycle: ["remove", "vanilla"] },
32
32
  ];
33
33
  }
34
34
 
package/src/config.ts CHANGED
@@ -6,6 +6,9 @@ import { writeAtomic } from "./fs-write";
6
6
  export type AutoReadAllMode = "off" | "on" | "git";
7
7
  const AUTO_READ_ALL_MODES: AutoReadAllMode[] = ["off", "on", "git"];
8
8
 
9
+ export type ReadOnDisabledModels = "remove" | "vanilla";
10
+ const READ_ON_DISABLED_MODELS: ReadOnDisabledModels[] = ["remove", "vanilla"];
11
+
9
12
  export const DEFAULT_DIFF_CONTEXT_LINES = 1;
10
13
  export const MIN_DIFF_CONTEXT_LINES = 0;
11
14
  export const MAX_DIFF_CONTEXT_LINES = 10;
@@ -21,7 +24,7 @@ export interface Config {
21
24
  strictInput?: boolean;
22
25
  diffContextLines?: number;
23
26
  disableOnModels?: string[];
24
- disableReadOnModels?: boolean;
27
+ readOnDisabledModels?: ReadOnDisabledModels;
25
28
  }
26
29
 
27
30
  const DEFAULT_CONFIG: Config = {
@@ -35,7 +38,7 @@ const DEFAULT_CONFIG: Config = {
35
38
  strictInput: false,
36
39
  diffContextLines: DEFAULT_DIFF_CONTEXT_LINES,
37
40
  disableOnModels: [],
38
- disableReadOnModels: true,
41
+ readOnDisabledModels: "vanilla",
39
42
  };
40
43
 
41
44
  function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
@@ -45,6 +48,12 @@ function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
45
48
  return DEFAULT_CONFIG.autoReadAll ?? "off";
46
49
  }
47
50
 
51
+ function parseReadOnDisabledModels(value: unknown, legacy: unknown): ReadOnDisabledModels {
52
+ if (value === "remove" || value === "vanilla") return value;
53
+ if (typeof legacy === "boolean") return legacy ? "remove" : "vanilla";
54
+ return DEFAULT_CONFIG.readOnDisabledModels ?? "vanilla";
55
+ }
56
+
48
57
  export function normalizeAutoReadAllIgnoreEntry(entry: string): string {
49
58
  return entry.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
50
59
  }
@@ -96,7 +105,8 @@ function parseConfig(content: string): Config {
96
105
  const diffContextLines = parsed.diffContextLines;
97
106
  const autoReadAllIgnore = parsed.autoReadAllIgnore;
98
107
  const disableOnModels = parsed.disableOnModels;
99
- const disableReadOnModels = parsed.disableReadOnModels;
108
+ const readOnDisabledModels = parsed.readOnDisabledModels;
109
+ const legacyDisableReadOnModels = parsed.disableReadOnModels;
100
110
  return {
101
111
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
102
112
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
@@ -108,7 +118,7 @@ function parseConfig(content: string): Config {
108
118
  diffContextLines: normalizeDiffContextLines(diffContextLines),
109
119
  autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
110
120
  disableOnModels: parseDisableOnModels(disableOnModels),
111
- disableReadOnModels: typeof disableReadOnModels === "boolean" ? disableReadOnModels : DEFAULT_CONFIG.disableReadOnModels,
121
+ readOnDisabledModels: parseReadOnDisabledModels(readOnDisabledModels, legacyDisableReadOnModels),
112
122
  };
113
123
  }
114
124
 
@@ -216,7 +226,7 @@ export async function writeConfig(config: Config): Promise<void> {
216
226
  }
217
227
 
218
228
 
219
- type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "disableReadOnModels";
229
+ type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput";
220
230
 
221
231
  async function toggleFlag(key: ToggleKey): Promise<boolean> {
222
232
  const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
@@ -226,7 +236,15 @@ export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
226
236
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
227
237
  export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
228
238
  export const toggleReplaceMatch = (): Promise<boolean> => toggleFlag("replaceMatchEnabled");
229
- export const toggleDisableReadOnModels = (): Promise<boolean> => toggleFlag("disableReadOnModels");
239
+ export async function cycleReadOnDisabledModels(): Promise<ReadOnDisabledModels> {
240
+ let next: ReadOnDisabledModels = "vanilla";
241
+ await updateConfig((c) => {
242
+ const current = c.readOnDisabledModels ?? "vanilla";
243
+ next = READ_ON_DISABLED_MODELS[(READ_ON_DISABLED_MODELS.indexOf(current) + 1) % READ_ON_DISABLED_MODELS.length] ?? "remove";
244
+ c.readOnDisabledModels = next;
245
+ });
246
+ return next;
247
+ }
230
248
  export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
231
249
  let next: AutoReadAllMode = "off";
232
250
  await updateConfig((c) => {
@@ -85,7 +85,7 @@ export { normalizeRequest as normReq } from "./utils";
85
85
  export function getPreviewInput(args: unknown): { path?: string; remove_from: string; remove_to: string; text: string } | null {
86
86
  let normalized: unknown;
87
87
  try {
88
- normalized = normalizeRequest(args);
88
+ normalized = normalizeRequest(args, "remove");
89
89
  } catch {
90
90
  return null;
91
91
  }
@@ -196,7 +196,12 @@ export function assertReplaceMatchReq(request: unknown): asserts request is Repl
196
196
  }
197
197
 
198
198
  export function getReplaceMatchInput(args: unknown): { path?: string; replace_from: string; replace_to: string; old_string: string; new_string: string } | null {
199
- const normalized: unknown = args;
199
+ let normalized: unknown;
200
+ try {
201
+ normalized = normalizeRequest(args, "replace");
202
+ } catch {
203
+ return null;
204
+ }
200
205
  if (!isRec(normalized)) return null;
201
206
  if (
202
207
  typeof normalized.replace_from !== "string" ||
@@ -11,9 +11,10 @@ import {
11
11
  assertReplaceMatchReq,
12
12
  buildReplaceMatchToolSchema,
13
13
  getReplaceMatchInput,
14
+ normReq,
14
15
  type ReplaceMatchReq,
15
16
  } from "./payload-contract";
16
- import { literalEscapeHints, splitLines } from "./utils";
17
+ import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
17
18
  import { toLF } from "./normalize";
18
19
  import { MAX_RANGE_STALE_LINES } from "./constants";
19
20
  import {
@@ -110,7 +111,7 @@ export function buildReplaceMatchEdit(
110
111
 
111
112
  export async function replaceMatchPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
112
113
  try {
113
- const normalized: unknown = request;
114
+ const normalized = normReq(request, "replace");
114
115
  assertReplaceMatchReq(normalized);
115
116
  const req = normalized;
116
117
  const { refs, warnings } = parseMatchAnchors(req);
@@ -158,6 +159,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
158
159
  promptSnippet: prompted.snippet,
159
160
  promptGuidelines: prompted.guidelines,
160
161
  ...editToolBase,
162
+ prepareArguments: makePrepareArguments("replace"),
161
163
  parameters: buildReplaceMatchToolSchema(flags.requirePath),
162
164
  outputSchema: editResultSchema,
163
165
  renderCall: makeRenderCall(replaceMatchPreview, {
@@ -168,7 +170,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
168
170
  renderResult: editRenderResultWrapper,
169
171
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
170
172
  return withStructuredErrors(signal, { diff: "" }, () => withAnchorSession(ctx, async () => {
171
- const normalized: unknown = params;
173
+ const normalized = normReq(params, "replace");
172
174
  assertReplaceMatchReq(normalized);
173
175
  const req = normalized;
174
176
  let refs: MatchRefs;
package/src/replace.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "./replace-diff";
11
11
  import { readNormFile, type NormFile } from "./file-reader";
12
12
  import { editToolSchema, buildEditToolSchema, type ReqParams, type RawReqParams, assertReq, normReq } from "./payload-contract";
13
- import { literalEscapeHints, splitLines } from "./utils";
13
+ import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
14
14
  import { editResultSchema, withStructuredErrors } from "./structured";
15
15
  import { loadP, loadGuide } from "./prompts";
16
16
  import { type FileIdentity } from "./fs-write";
@@ -240,7 +240,7 @@ export async function compPreview(
240
240
  signal?: AbortSignal,
241
241
  ): Promise<RPreview> {
242
242
  try {
243
- const normalized = normReq(request);
243
+ const normalized = normReq(request, "remove");
244
244
  assertReq(normalized);
245
245
  const targetPath = await resolveEditTargetWithRequirement({
246
246
  removeFrom: (normalized as ReqParams).remove_from,
@@ -283,11 +283,12 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
283
283
  promptSnippet: prompted.snippet,
284
284
  promptGuidelines: prompted.guidelines,
285
285
  ...editToolBase,
286
+ prepareArguments: makePrepareArguments("remove"),
286
287
  renderCall: editRenderCallWrapper(compPreview),
287
288
  renderResult: editRenderResultWrapper,
288
289
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
289
290
  return withStructuredErrors(signal, { diff: "" }, () => withAnchorSession(ctx, async () => {
290
- const canonical = normReq(params);
291
+ const canonical = normReq(params, "remove");
291
292
  assertReq(canonical);
292
293
  const normalizedParams = canonical;
293
294
  const literalEscapes = literalEscapeHints([normalizedParams.text], "text");
package/src/utils.ts CHANGED
@@ -8,15 +8,31 @@ export function isRec(value: unknown): value is Record<string, unknown> {
8
8
  }
9
9
 
10
10
 
11
- export function normalizeRequest(input: unknown): unknown {
11
+ export type AnchorPairName = "remove" | "replace";
12
+
13
+ const ANCHOR_ALIASES: Record<AnchorPairName, Record<string, string>> = {
14
+ remove: { replace_from: "remove_from", replace_to: "remove_to" },
15
+ replace: { remove_from: "replace_from", remove_to: "replace_to" },
16
+ };
17
+
18
+ function normalizeAnchorAliases(record: Record<string, unknown>, pair: AnchorPairName): void {
19
+ for (const [alias, canonical] of Object.entries(ANCHOR_ALIASES[pair])) {
20
+ if (!(alias in record)) continue;
21
+ if (record[canonical] === undefined) record[canonical] = record[alias];
22
+ delete record[alias];
23
+ }
24
+ }
25
+
26
+ export function normalizeRequest(input: unknown, pair?: AnchorPairName): unknown {
12
27
  if (!isRec(input)) return input;
13
28
  const record: Record<string, unknown> = { ...input };
14
29
  normalizeEditLines(record);
30
+ if (pair !== undefined) normalizeAnchorAliases(record, pair);
15
31
  return record;
16
32
  }
17
33
 
18
- export function makePrepareArguments(): (args: unknown) => any {
19
- return normalizeRequest;
34
+ export function makePrepareArguments(pair?: AnchorPairName): (args: unknown) => any {
35
+ return (args) => normalizeRequest(args, pair);
20
36
  }
21
37
 
22
38
  export function splitLines(text: string): string[] {