pi-hashline-edit-pro 6.1.0 → 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, 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`:
@@ -362,7 +366,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
362
366
  "requirePath": false,
363
367
  "strictInput": false,
364
368
  "diffContextLines": 1,
365
- "disableOnModels": []
369
+ "disableOnModels": [],
370
+ "readOnDisabledModels": "vanilla"
366
371
  }
367
372
  ```
368
373
 
@@ -377,9 +382,10 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
377
382
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
378
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. |
379
384
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
380
- | `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 hashline tools, no auto-read, and no write-echo refusal. |
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. |
381
387
 
382
- `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 `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `anchor_grep`, and `undo_last_change` from the active tools, 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. 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.
383
389
 
384
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).
385
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,6 +25,7 @@ import {
25
25
  toggleAnchorGrep,
26
26
  toggleCopyMove,
27
27
  toggleReplaceMatch,
28
+ cycleReadOnDisabledModels,
28
29
  toggleRequirePath,
29
30
  toggleStrictInput,
30
31
  adjustDiffContextLines,
@@ -61,6 +62,9 @@ export default function (pi: ExtensionAPI): void {
61
62
  let autoReadAll: AutoReadAllMode = "off";
62
63
  let autoReadAllIgnore: string[] = [];
63
64
  let disableOnModels: string[] = [];
65
+ let readOnDisabledModels: ReadOnDisabledModels = "vanilla";
66
+ let readFlavor: "anchored" | "vanilla" = "anchored";
67
+ let readFlavorCwd: string | undefined;
64
68
  let autoReadAllInjected = false;
65
69
  let grepWasActive = false;
66
70
  const baseAnchorTools = new Set<string>();
@@ -69,7 +73,7 @@ export default function (pi: ExtensionAPI): void {
69
73
  async function refreshEditTools(): Promise<void> {
70
74
  try {
71
75
  const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
72
- regRead(pi, flags);
76
+ if (readFlavor !== "vanilla") regRead(pi, flags);
73
77
  regReplace(pi, flags);
74
78
  regReplaceMatch(pi, flags);
75
79
  regInsert(pi, flags);
@@ -82,10 +86,24 @@ export default function (pi: ExtensionAPI): void {
82
86
  }
83
87
  }
84
88
 
85
- async function syncModelGate(model: ModelLike | undefined): Promise<void> {
86
- 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) {
87
103
  const active = pi.getActiveTools();
88
- const next = active.filter((tool) => !ANCHOR_TOOL_NAMES.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");
89
107
  if (grepWasActive && !next.includes("grep")) next.push("grep");
90
108
  pi.setActiveTools(next);
91
109
  gateApplied = true;
@@ -133,6 +151,7 @@ export default function (pi: ExtensionAPI): void {
133
151
  autoReadAll = config.autoReadAll ?? "off";
134
152
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
135
153
  disableOnModels = config.disableOnModels ?? [];
154
+ readOnDisabledModels = config.readOnDisabledModels ?? "vanilla";
136
155
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
137
156
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
138
157
  await refreshEditTools();
@@ -144,7 +163,7 @@ export default function (pi: ExtensionAPI): void {
144
163
  return true;
145
164
  }),
146
165
  );
147
- await syncModelGate(ctx.model);
166
+ await syncModelGate(ctx.model, ctx.cwd);
148
167
  const debugValue = process.env.PI_HASHLINE_DEBUG;
149
168
  if (debugValue === "1" || debugValue === "true") {
150
169
  ctx.ui.notify(`Hashline Edit mode active`, "info");
@@ -161,11 +180,11 @@ export default function (pi: ExtensionAPI): void {
161
180
  }
162
181
  });
163
182
  pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
164
- await syncModelGate(event.model);
183
+ await syncModelGate(event.model, ctx.cwd);
165
184
  }));
166
185
 
167
186
  pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
168
- await syncModelGate(ctx.model);
187
+ await syncModelGate(ctx.model, ctx.cwd);
169
188
  if (autoReadAll === "off" || autoReadAllInjected) return;
170
189
  if (modelDisabled(ctx.model, disableOnModels)) return;
171
190
  autoReadAllInjected = true;
@@ -197,6 +216,7 @@ export default function (pi: ExtensionAPI): void {
197
216
  else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
198
217
  else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
199
218
  else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
219
+ else if (key === "readOnDisabledModels") readOnDisabledModels = await cycleReadOnDisabledModels();
200
220
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
201
221
  else if (key === "anchorGrepEnabled") {
202
222
  const enabled = await toggleAnchorGrep();
@@ -224,7 +244,7 @@ export default function (pi: ExtensionAPI): void {
224
244
  else if (key === "requirePath") await toggleRequirePath();
225
245
  else if (key === "strictInput") await toggleStrictInput();
226
246
  await refreshEditTools();
227
- await syncModelGate(ctx.model);
247
+ await syncModelGate(ctx.model, ctx.cwd);
228
248
  },
229
249
  });
230
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.0",
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";
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;
@@ -27,7 +27,8 @@ export function configRows(config: Config): ConfigRow[] {
27
27
  { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
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
- { key: "disableOnModels", label: "Disable on models", hint: "Model globs (provider/id, model id, or api) that turn off read and the hashline tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
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: "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"] },
31
32
  ];
32
33
  }
33
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,6 +24,7 @@ export interface Config {
21
24
  strictInput?: boolean;
22
25
  diffContextLines?: number;
23
26
  disableOnModels?: string[];
27
+ readOnDisabledModels?: ReadOnDisabledModels;
24
28
  }
25
29
 
26
30
  const DEFAULT_CONFIG: Config = {
@@ -33,7 +37,8 @@ const DEFAULT_CONFIG: Config = {
33
37
  requirePath: false,
34
38
  strictInput: false,
35
39
  diffContextLines: DEFAULT_DIFF_CONTEXT_LINES,
36
- disableOnModels: []
40
+ disableOnModels: [],
41
+ readOnDisabledModels: "vanilla",
37
42
  };
38
43
 
39
44
  function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
@@ -43,6 +48,12 @@ function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
43
48
  return DEFAULT_CONFIG.autoReadAll ?? "off";
44
49
  }
45
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
+
46
57
  export function normalizeAutoReadAllIgnoreEntry(entry: string): string {
47
58
  return entry.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
48
59
  }
@@ -94,6 +105,8 @@ function parseConfig(content: string): Config {
94
105
  const diffContextLines = parsed.diffContextLines;
95
106
  const autoReadAllIgnore = parsed.autoReadAllIgnore;
96
107
  const disableOnModels = parsed.disableOnModels;
108
+ const readOnDisabledModels = parsed.readOnDisabledModels;
109
+ const legacyDisableReadOnModels = parsed.disableReadOnModels;
97
110
  return {
98
111
  autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
99
112
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
@@ -105,6 +118,7 @@ function parseConfig(content: string): Config {
105
118
  diffContextLines: normalizeDiffContextLines(diffContextLines),
106
119
  autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
107
120
  disableOnModels: parseDisableOnModels(disableOnModels),
121
+ readOnDisabledModels: parseReadOnDisabledModels(readOnDisabledModels, legacyDisableReadOnModels),
108
122
  };
109
123
  }
110
124
 
@@ -222,6 +236,15 @@ export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
222
236
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
223
237
  export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
224
238
  export const toggleReplaceMatch = (): Promise<boolean> => toggleFlag("replaceMatchEnabled");
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
+ }
225
248
  export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
226
249
  let next: AutoReadAllMode = "off";
227
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[] {