pi-hashline-edit-pro 6.0.1 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ What "pro" adds over upstream `pi-hashline-edit`: the anchor table is built from
68
68
 
69
69
  Prerequisites:
70
70
 
71
- - [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.84.0` (`@earendil-works/pi-coding-agent`).
71
+ - [pi-coding-agent](https://github.com/earendil-works/pi) `>= 0.99.0` (`@earendil-works/pi-coding-agent`).
72
72
  - Node.js 22.19 or newer, or a Bun build that ships `bun:sqlite`.
73
73
  - An SQLite runtime. The extension uses `node:sqlite` on Node 22.19+ and falls back to `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`, so run pi under Node or a Bun build that ships SQLite. Without a runtime, every tool fails with `[E_STORE_UNAVAILABLE]`.
74
74
 
@@ -322,7 +322,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
322
322
 
323
323
  ## Auto-read
324
324
 
325
- Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
325
+ Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call. A model matched by `disableOnModels` skips the block, and the post-edit diff substitution below is skipped with it.
326
326
 
327
327
  After `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N (queued)`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
328
328
 
@@ -334,7 +334,7 @@ Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff
334
334
 
335
335
  Auto-read all is off by default and has three modes, selected in `/hashline-config`: `off` injects nothing, `on` discovers every file in the working directory that is not git-ignored (`git ls-files`, falling back to `ripgrep`, then to a directory walk), and `git` uses `git ls-files` only, injecting nothing when the working directory is not a git repository. On the first turn of a session, the extension discovers the files, reads each one, and attaches the resulting `anchor│content` rows to the conversation as one extension message before the model answers. Those anchors are served exactly like `read` output, so the model can `replace` and `insert` immediately without calling `read` first. The message is injected once per session; resumed, forked, and cloned sessions that already contain it skip the injection.
336
336
 
337
- Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand.
337
+ Files are filtered before injection: symlinks, directories, image extensions (including SVG), binary files (a NUL byte in the first 8KB), files over 200KB, any path with a vendored segment (vendor, node_modules, bower_components, third_party, thirdparty, jspm_packages, .venv, venv, site-packages, __pycache__, .tox, .gradle, .terraform, Pods, Carthage, DerivedData, coreui, coreui-icons, case-insensitive), and vendored or generated names and patterns (*.min.js, *.min.css, *.min.mjs, *-min.js, *-min.css, *.bundle.*, *.chunk.*, *.umd.js, *.map, *.lock, package-lock.json, yarn.lock, composer.lock, Gemfile.lock, Cargo.lock, poetry.lock, Pipfile.lock, go.sum, flake.lock, *.generated.*, *.gen.*, *_pb2.py, *.pb.go, *.g.dart, *.freezed.dart, *.designer.cs, *.g.cs, *.snap, .eslintcache, coreui-icons.*, coreui.css) are skipped. The attachment stops at 500 files or at a byte budget derived from the model context window (200KB floor, 2MB ceiling), and it never drops below one file. Skipped and not-attached files are named at the end of the message so the model can `read` them on demand. A model matched by `disableOnModels` skips the injection entirely.
338
338
 
339
339
  Each attached file is shown as `=== path ===` followed by its `anchor│content` rows. Edit directly from the attachment with replace and insert, so no `read` is needed. Files attach whole.
340
340
 
@@ -346,7 +346,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
346
346
 
347
347
  | Command | Description |
348
348
  | --- | --- |
349
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_match tool, required `path`, and strict input. Persists across sessions. |
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. |
350
350
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
351
351
 
352
352
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -361,7 +361,9 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
361
361
  "replaceMatchEnabled": true,
362
362
  "requirePath": false,
363
363
  "strictInput": false,
364
- "diffContextLines": 1
364
+ "diffContextLines": 1,
365
+ "disableOnModels": [],
366
+ "disableReadOnModels": true
365
367
  }
366
368
  ```
367
369
 
@@ -376,6 +378,10 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
376
378
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
377
379
  | `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. |
378
380
  | `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. |
383
+
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.
379
385
 
380
386
  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).
381
387
 
@@ -403,6 +409,14 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
403
409
 
404
410
  All eight tools return machine-readable metadata in `details` alongside the model-visible text.
405
411
 
412
+ All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
413
+
414
+ `read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors }`, where each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` is what was shown, so an oversized line appears as its marker while keeping its anchor). An image read returns `{ kind: "image", path, mimeType }`.
415
+
416
+ `replace`, `replace_match`, `insert`, `copy`, `move`, and `undo_last_change` return `{ kind: "edit", verb, classification, path, text, diff, warnings, hints, firstChangedLine, anchors, anchorsOmitted }`, where `verb` is `"replaced"`, `"inserted"`, `"copied"`, `"moved"`, or `"undone"`, `anchors` are the live rows of the post-edit diff, and `anchorsOmitted` is true when the diff was truncated or carried no rows, meaning a fresh read is needed. A cross-file move reports the destination file and its anchors.
417
+
418
+ `anchor_grep` returns `{ kind: "grep", text, matches, files, truncated, results }`, where each entry of `results` is `{ path, matchLines, lines, hadUtf8DecodeErrors }` and `lines` uses the same `{ line, text, anchor, rendered }` shape as `read`.
419
+
406
420
  | Tool | `details` |
407
421
  | --- | --- |
408
422
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
@@ -453,6 +467,7 @@ Full reference:
453
467
  | `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
454
468
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
455
469
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
470
+ | `[E_UNDO_NONE]` | `undo_last_change` found no recorded edit for the file. Nothing was changed; make an edit first. |
456
471
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown; a pure deletion checks only its first and last line). The edit was refused; the current range is returned with fresh anchors. |
457
472
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
458
473
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
package/index.ts CHANGED
@@ -8,24 +8,29 @@ import { regCopy, regMove } from "./src/copy-move";
8
8
  import { regGrep } from "./src/grep";
9
9
  import { regUndo, clearUndo } from "./src/replace-undo";
10
10
  import { regRead, fmtReadPreview } from "./src/read";
11
+ import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
11
12
  import { buildAutoReadAllInjection, autoReadAllBudget } from "./src/auto-read-all";
12
13
  import { clearAutoReadAllComplete } from "./src/auto-read-all-state";
13
14
  import type { RMetrics } from "./src/replace-response";
14
15
  import type { ReplaceDetails } from "./src/replace";
15
16
  import { extractHints, extractWarnings } from "./src/replace-render";
16
17
  import { MAX_HASH_LINES } from "./src/hashline";
18
+ import { withStructuredText } from "./src/structured";
17
19
  import type { AutoReadAllMode } from "./src/config";
18
20
  import {
21
+ readConfig,
19
22
  readConfigWithStatus,
20
23
  toggleAutoRead,
21
24
  cycleAutoReadAllMode,
22
25
  toggleAnchorGrep,
23
26
  toggleCopyMove,
24
27
  toggleReplaceMatch,
28
+ toggleDisableReadOnModels,
25
29
  toggleRequirePath,
26
30
  toggleStrictInput,
27
31
  adjustDiffContextLines,
28
32
  setAutoReadAllIgnoreFromText,
33
+ setDisableOnModelsFromText,
29
34
  } from "./src/config";
30
35
  import { loadHashStore, pruneMissing } from "./src/hash-store";
31
36
  import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
@@ -51,17 +56,21 @@ export default function (pi: ExtensionAPI): void {
51
56
  regMove(pi);
52
57
  regGrep(pi);
53
58
  regUndo(pi);
54
- registerWriteHook(pi);
59
+ registerWriteHook(pi, (model) => modelDisabled(model, disableOnModels));
55
60
 
56
61
  let autoRead = true;
57
62
  let autoReadAll: AutoReadAllMode = "off";
58
63
  let autoReadAllIgnore: string[] = [];
64
+ let disableOnModels: string[] = [];
65
+ let disableReadOnModels = true;
59
66
  let autoReadAllInjected = false;
60
67
  let grepWasActive = false;
68
+ const baseAnchorTools = new Set<string>();
69
+ let gateApplied = false;
61
70
 
62
71
  async function refreshEditTools(): Promise<void> {
63
72
  try {
64
- const flags = await currentEditFlags();
73
+ const flags = await currentEditFlags(pi.getActiveTools().includes("codemode"));
65
74
  regRead(pi, flags);
66
75
  regReplace(pi, flags);
67
76
  regReplaceMatch(pi, flags);
@@ -75,9 +84,38 @@ export default function (pi: ExtensionAPI): void {
75
84
  }
76
85
  }
77
86
 
87
+ async function syncModelGate(model: ModelLike | undefined): Promise<void> {
88
+ if (disableOnModels.length > 0 && modelDisabled(model, disableOnModels)) {
89
+ 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));
92
+ if (grepWasActive && !next.includes("grep")) next.push("grep");
93
+ pi.setActiveTools(next);
94
+ gateApplied = true;
95
+ return;
96
+ }
97
+ if (!gateApplied) return;
98
+ gateApplied = false;
99
+ const config = await readConfig();
100
+ const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
101
+ if (!baseAnchorTools.has(tool)) return false;
102
+ if (tool === "replace_match") return config.replaceMatchEnabled !== false;
103
+ if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
104
+ if (tool === "anchor_grep") return config.anchorGrepEnabled === true;
105
+ return true;
106
+ });
107
+ let next = [...new Set([...pi.getActiveTools(), ...enabled])];
108
+ if (enabled.includes("anchor_grep") && grepWasActive) next = next.filter((tool) => tool !== "grep");
109
+ pi.setActiveTools(next);
110
+ }
111
+
78
112
  pi.on("session_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
79
113
  const active = pi.getActiveTools();
80
114
  grepWasActive = active.includes("grep");
115
+ baseAnchorTools.clear();
116
+ for (const tool of ANCHOR_TOOL_NAMES) {
117
+ if (active.includes(tool)) baseAnchorTools.add(tool);
118
+ }
81
119
  pi.setActiveTools(active.filter((t) => t !== "edit"));
82
120
  await initHasher();
83
121
  loadHashStore()
@@ -97,6 +135,8 @@ export default function (pi: ExtensionAPI): void {
97
135
  autoRead = config.autoRead;
98
136
  autoReadAll = config.autoReadAll ?? "off";
99
137
  autoReadAllIgnore = config.autoReadAllIgnore ?? [];
138
+ disableOnModels = config.disableOnModels ?? [];
139
+ disableReadOnModels = config.disableReadOnModels !== false;
100
140
  const sessionBranch = (ctx as { sessionManager?: { getBranch?: () => Array<{ type?: string; customType?: string }> } }).sessionManager?.getBranch?.() ?? [];
101
141
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
102
142
  await refreshEditTools();
@@ -108,6 +148,7 @@ export default function (pi: ExtensionAPI): void {
108
148
  return true;
109
149
  }),
110
150
  );
151
+ await syncModelGate(ctx.model);
111
152
  const debugValue = process.env.PI_HASHLINE_DEBUG;
112
153
  if (debugValue === "1" || debugValue === "true") {
113
154
  ctx.ui.notify(`Hashline Edit mode active`, "info");
@@ -123,9 +164,14 @@ export default function (pi: ExtensionAPI): void {
123
164
  console.error("Failed to release anchor registry session:", error);
124
165
  }
125
166
  });
167
+ pi.on("model_select", async (event, ctx) => withAnchorSession(ctx, async () => {
168
+ await syncModelGate(event.model);
169
+ }));
126
170
 
127
171
  pi.on("before_agent_start", async (_event, ctx) => withAnchorSession(ctx, async () => {
172
+ await syncModelGate(ctx.model);
128
173
  if (autoReadAll === "off" || autoReadAllInjected) return;
174
+ if (modelDisabled(ctx.model, disableOnModels)) return;
129
175
  autoReadAllInjected = true;
130
176
  try {
131
177
  const injection = await buildAutoReadAllInjection(ctx.cwd, autoReadAllBudget(ctx.model), autoReadAll, autoReadAllIgnore, sessionKeyFor(ctx));
@@ -139,7 +185,7 @@ export default function (pi: ExtensionAPI): void {
139
185
  }));
140
186
 
141
187
  pi.registerCommand("hashline-config", {
142
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_match, path, strict input)",
188
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, disable on models, diff context, grep, copy/move, replace_match, path, strict input)",
143
189
  handler: async (_args, ctx) => {
144
190
  if (!ctx.hasUI) {
145
191
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -154,25 +200,36 @@ export default function (pi: ExtensionAPI): void {
154
200
  if (key === "autoRead") autoRead = await toggleAutoRead();
155
201
  else if (key === "autoReadAll") { autoReadAll = await cycleAutoReadAllMode(); autoReadAllInjected = false; }
156
202
  else if (key === "autoReadAllIgnore") autoReadAllIgnore = await setAutoReadAllIgnoreFromText(value ?? "");
203
+ else if (key === "disableOnModels") disableOnModels = await setDisableOnModelsFromText(value ?? "");
204
+ else if (key === "disableReadOnModels") disableReadOnModels = await toggleDisableReadOnModels();
157
205
  else if (key === "diffContextLines") await adjustDiffContextLines(delta ?? 1);
158
206
  else if (key === "anchorGrepEnabled") {
159
207
  const enabled = await toggleAnchorGrep();
160
208
  const active = pi.getActiveTools();
161
209
  pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
210
+ if (enabled) baseAnchorTools.add("anchor_grep");
211
+ else baseAnchorTools.delete("anchor_grep");
162
212
  }
163
213
  else if (key === "copyMoveEnabled") {
164
214
  const enabled = await toggleCopyMove();
165
215
  const active = pi.getActiveTools();
166
216
  pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
217
+ for (const tool of ["copy", "move"]) {
218
+ if (enabled) baseAnchorTools.add(tool);
219
+ else baseAnchorTools.delete(tool);
220
+ }
167
221
  }
168
222
  else if (key === "replaceMatchEnabled") {
169
223
  const enabled = await toggleReplaceMatch();
170
224
  const active = pi.getActiveTools();
171
225
  pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
226
+ if (enabled) baseAnchorTools.add("replace_match");
227
+ else baseAnchorTools.delete("replace_match");
172
228
  }
173
229
  else if (key === "requirePath") await toggleRequirePath();
174
230
  else if (key === "strictInput") await toggleStrictInput();
175
231
  await refreshEditTools();
232
+ await syncModelGate(ctx.model);
176
233
  },
177
234
  });
178
235
  await overlay.load();
@@ -208,6 +265,7 @@ export default function (pi: ExtensionAPI): void {
208
265
  });
209
266
  pi.on("tool_result", async (event, ctx) => withAnchorSession(ctx, async () => {
210
267
  if (event.isError) return;
268
+ const gated = modelDisabled(ctx.model, disableOnModels);
211
269
 
212
270
  if (event.toolName === "write") {
213
271
  const writtenPath = (event.input as Record<string, unknown>)?.path;
@@ -221,7 +279,7 @@ export default function (pi: ExtensionAPI): void {
221
279
  console.error("Failed to clear undo after write:", error);
222
280
  }
223
281
  }
224
- if (!autoRead) return;
282
+ if (!autoRead || gated) return;
225
283
  if (typeof writtenPath !== "string") return;
226
284
  try {
227
285
  resolvedPath ??= (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
@@ -268,7 +326,7 @@ export default function (pi: ExtensionAPI): void {
268
326
  event.toolName !== "move" &&
269
327
  event.toolName !== "undo_last_change"
270
328
  ) return;
271
- if (!autoRead) return;
329
+ if (!autoRead || gated) return;
272
330
 
273
331
  const metrics = (event.details as { metrics?: RMetrics } | undefined)?.metrics;
274
332
  if (metrics?.classification === "noop") return;
@@ -294,6 +352,7 @@ export default function (pi: ExtensionAPI): void {
294
352
  const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
295
353
  const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
296
354
  const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
355
+ const structured = (event as { structuredContent?: unknown }).structuredContent;
297
356
  return {
298
357
  content: [
299
358
  {
@@ -301,6 +360,7 @@ export default function (pi: ExtensionAPI): void {
301
360
  text: noticeText,
302
361
  },
303
362
  ],
363
+ ...(structured !== undefined ? { structuredContent: withStructuredText(structured, noticeText) } : {}),
304
364
  };
305
365
  }));
306
366
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "6.0.1",
3
+ "version": "6.1.1",
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",
@@ -44,7 +44,7 @@
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": ">=0.84.0",
47
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
48
48
  "@earendil-works/pi-tui": "*",
49
49
  "typebox": "*"
50
50
  },
@@ -60,7 +60,7 @@
60
60
  "typecheck": "tsc --noEmit"
61
61
  },
62
62
  "devDependencies": {
63
- "@earendil-works/pi-coding-agent": "^0.87.0",
63
+ "@earendil-works/pi-coding-agent": "^1.0.3",
64
64
  "@eslint/js": "^10.0.1",
65
65
  "@types/node": "^24",
66
66
  "@vitest/coverage-v8": "^4.1.11",
@@ -1,2 +1,3 @@
1
+ - `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
1
2
  - `undo_last_change`: a `write` clears the history, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
2
3
  - `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
package/src/batch.ts CHANGED
@@ -25,6 +25,7 @@ import type { PipelineResult } from "./replace";
25
25
  import { genPatch } from "./replace-diff";
26
26
  import { saveUndo } from "./replace-undo";
27
27
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
28
+ import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
28
29
  import { serveRows, servedHashesFromDiff } from "./served";
29
30
  import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
30
31
 
@@ -472,6 +473,20 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
472
473
  classification: piece.noop ? "noop" : "applied",
473
474
  ...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
474
475
  };
476
+ const structuredContent: EditStructured = {
477
+ ok: true,
478
+ kind: "edit",
479
+ verb: toEditVerb(piece.kind),
480
+ classification: piece.noop ? "noop" : "applied",
481
+ path: member.target,
482
+ text: `In batch ${member.display} (queued)`,
483
+ diff: "",
484
+ warnings: [],
485
+ hints: [],
486
+ firstChangedLine: piece.noop ? null : piece.start,
487
+ anchors: [],
488
+ anchorsOmitted: false,
489
+ };
475
490
  return {
476
491
  content: [
477
492
  {
@@ -488,6 +503,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
488
503
  metrics,
489
504
  batch: { id: member.display, size: member.size, last: false, total: member.total },
490
505
  },
506
+ structuredContent,
491
507
  };
492
508
  }
493
509
 
@@ -1044,6 +1060,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
1044
1060
  : "";
1045
1061
  const undoNote = sourceFiles.length > 0 ? "one undo reverts the destination edits" : "one undo reverts them";
1046
1062
  changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; ${undoNote}.${sourceNote}`;
1063
+ changed.structuredContent = withStructuredText(changed.structuredContent, changed.content[0]!.text);
1047
1064
  changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
1048
1065
  return changed;
1049
1066
  }
@@ -1065,10 +1082,12 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
1065
1082
  removedLines: 0,
1066
1083
  },
1067
1084
  warnings: dedupeWarnings(warnings),
1085
+ verb: "edited",
1068
1086
  },
1069
1087
  "Batch",
1070
1088
  );
1071
1089
  noop.content[0]!.text += `\nBatch ${member.display}: ${executed} edits produced no net change; undo history preserved.`;
1090
+ noop.structuredContent = withStructuredText(noop.structuredContent, noop.content[0]!.text);
1072
1091
  noop.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
1073
1092
  return noop;
1074
1093
  }
package/src/commit.ts CHANGED
@@ -12,6 +12,7 @@ import { lineHashes } from "./hashline";
12
12
  import { spanForEdit } from "./replace";
13
13
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
14
14
  import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
15
+ import { toEditVerb } from "./structured";
15
16
  export interface CommitMeta {
16
17
  editAnchors?: [string, string];
17
18
  anchorCarry?: number;
@@ -62,6 +63,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
62
63
  removedLines: 0,
63
64
  },
64
65
  warnings,
66
+ verb: toEditVerb(meta.verb),
65
67
  },
66
68
  meta.noopNoun,
67
69
  );
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";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "disableReadOnModels";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -12,7 +12,7 @@ export interface ConfigRow {
12
12
  mode?: string;
13
13
  cycle?: string[];
14
14
  value?: number;
15
- folders?: string[];
15
+ entries?: string[];
16
16
  disabled?: boolean;
17
17
  }
18
18
 
@@ -20,13 +20,15 @@ export function configRows(config: Config): ConfigRow[] {
20
20
  return [
21
21
  { key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
22
22
  { key: "autoReadAll", label: "Auto-read all", hint: "Attach files on the first turn: off, on, git (git repos only)", enabled: (config.autoReadAll ?? "off") !== "off", mode: config.autoReadAll ?? "off", cycle: ["off", "on", "git"] },
23
- { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
23
+ { key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, entries: config.autoReadAllIgnore ?? [] },
24
24
  { key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
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 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 },
30
32
  ];
31
33
  }
32
34
 
@@ -45,19 +47,19 @@ function numberBox(theme: Theme, value: number, disabled?: boolean): string {
45
47
  if (disabled) return theme.fg("dim", `[${value}]`);
46
48
  return theme.fg("accent", `[${value}]`);
47
49
  }
48
- function ignoreBox(theme: Theme, folders: string[]): string {
49
- return theme.fg("accent", `[${folders.length}]`);
50
+ function listBox(theme: Theme, entries: string[]): string {
51
+ return theme.fg("accent", `[${entries.length}]`);
50
52
  }
51
- function formatIgnoreFolders(folders: string[]): string {
52
- if (folders.length === 0) return "(empty)";
53
- const joined = folders.join(", ");
53
+ function formatList(entries: string[]): string {
54
+ if (entries.length === 0) return "(empty)";
55
+ const joined = entries.join(", ");
54
56
  return joined.length > 40 ? `${joined.slice(0, 37)}...` : joined;
55
57
  }
56
58
 
57
59
  export class HashlineConfigOverlay {
58
60
  private rows: ConfigRow[];
59
61
  private selected = 0;
60
- private editingIgnore = false;
62
+ private editingList = false;
61
63
  private editBuffer = "";
62
64
 
63
65
  constructor(private readonly opts: { tui: { requestRender(force?: boolean): void }; theme: Theme; done: () => void; onToggle: (key: ConfigToggleKey, delta?: number, value?: string) => Promise<void> }) {
@@ -72,37 +74,37 @@ export class HashlineConfigOverlay {
72
74
  this.opts.tui.requestRender(true);
73
75
  void this.opts.onToggle(row.key, delta, value).then(async () => {
74
76
  this.rows = configRows(await readConfig());
75
- this.editingIgnore = false;
77
+ this.editingList = false;
76
78
  this.opts.tui.requestRender(true);
77
79
  }).catch((error: unknown) => {
78
80
  console.error("Failed to toggle hashline setting:", error);
79
81
  });
80
82
  }
81
- private startIgnoreEdit(row: ConfigRow): void {
82
- this.editingIgnore = true;
83
- this.editBuffer = (row.folders ?? []).join(", ");
83
+ private startListEdit(row: ConfigRow): void {
84
+ this.editingList = true;
85
+ this.editBuffer = (row.entries ?? []).join(", ");
84
86
  this.opts.tui.requestRender(true);
85
87
  }
86
- private commitIgnoreEdit(): void {
88
+ private commitListEdit(): void {
87
89
  const row = this.rows[this.selected];
88
- if (!row || row.key !== "autoReadAllIgnore") {
89
- this.editingIgnore = false;
90
+ if (!row || row.entries === undefined) {
91
+ this.editingList = false;
90
92
  return;
91
93
  }
92
94
  const value = this.editBuffer;
93
- this.editingIgnore = false;
95
+ this.editingList = false;
94
96
  this.runToggle(row, undefined, value);
95
97
  }
96
- private cancelIgnoreEdit(): void {
97
- this.editingIgnore = false;
98
+ private cancelListEdit(): void {
99
+ this.editingList = false;
98
100
  this.opts.tui.requestRender(true);
99
101
  }
100
102
 
101
103
  private toggleSelected(): void {
102
104
  const row = this.rows[this.selected];
103
105
  if (!row || row.disabled) return;
104
- if (row.key === "autoReadAllIgnore") {
105
- this.startIgnoreEdit(row);
106
+ if (row.entries !== undefined) {
107
+ this.startListEdit(row);
106
108
  return;
107
109
  }
108
110
  if (row.value !== undefined) {
@@ -129,13 +131,13 @@ export class HashlineConfigOverlay {
129
131
  }
130
132
 
131
133
  handleInput(data: string): void {
132
- if (this.editingIgnore) {
134
+ if (this.editingList) {
133
135
  if (matchesKey(data, Key.escape) || data === "\x1b") {
134
- this.cancelIgnoreEdit();
136
+ this.cancelListEdit();
135
137
  return;
136
138
  }
137
139
  if (matchesKey(data, Key.enter) || data === "\r" || data === "\n") {
138
- this.commitIgnoreEdit();
140
+ this.commitListEdit();
139
141
  return;
140
142
  }
141
143
  if (data === "\x7f" || data === "\b" || data === "\x08") {
@@ -176,8 +178,8 @@ export class HashlineConfigOverlay {
176
178
  return;
177
179
  }
178
180
  const row = this.rows[this.selected];
179
- if ((data === "e" || data === "E") && row && row.key === "autoReadAllIgnore" && !row.disabled) {
180
- this.startIgnoreEdit(row);
181
+ if ((data === "e" || data === "E") && row && row.entries !== undefined && !row.disabled) {
182
+ this.startListEdit(row);
181
183
  return;
182
184
  }
183
185
  if (matchesKey(data, Key.escape) || data === "q") {
@@ -197,16 +199,16 @@ export class HashlineConfigOverlay {
197
199
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
198
200
  this.rows.forEach((row, index) => {
199
201
  const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
200
- const box = row.folders !== undefined ? ignoreBox(theme, row.folders) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
202
+ const box = row.entries !== undefined ? listBox(theme, row.entries) : row.value !== undefined ? numberBox(theme, row.value, row.disabled) : row.mode !== undefined ? modeBox(theme, row.mode) : row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
201
203
  const label = row.disabled ? theme.fg("dim", row.label) : index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
202
- const suffix = row.folders !== undefined ? ` — ${row.hint}: ${formatIgnoreFolders(row.folders)}` : ` — ${row.hint}`;
204
+ const suffix = row.entries !== undefined ? ` — ${row.hint}: ${formatList(row.entries)}` : ` — ${row.hint}`;
203
205
  lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", suffix)}`));
204
- if (row.key === "autoReadAllIgnore" && index === this.selected && this.editingIgnore) {
206
+ if (row.entries !== undefined && index === this.selected && this.editingList) {
205
207
  lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", "edit:")} ${this.editBuffer}█ ${theme.fg("dim", "(Enter save · Esc cancel · Ctrl-U clear)")}`));
206
208
  }
207
209
  });
208
210
  lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
209
- const footer = this.editingIgnore ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
211
+ const footer = this.editingList ? " type to edit · Enter save · Esc cancel" : " ↑↓ navigate · space toggle · ←/→ or -/+ adjust · e edit list · q close";
210
212
  lines.push(padRow(theme, innerWidth, theme.fg("dim", footer)));
211
213
  lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
212
214
  return lines;