pi-hashline-edit-pro 7.0.0 → 7.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
@@ -29,6 +29,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
29
29
  - [Quickstart](#quickstart)
30
30
  - [Tools](#tools)
31
31
  - [read](#read)
32
+ - [outline](#outline)
32
33
  - [replace](#replace)
33
34
  - [replace_match](#replace_match)
34
35
  - [insert](#insert)
@@ -87,6 +88,7 @@ pi install /path/to/pi-hashline-edit-pro
87
88
  | Built-in | Effect |
88
89
  | --- | --- |
89
90
  | `read` | overridden, returns `anchor│content` rows |
91
+ | `outline` | new opt-in tool; returns the structural outline of a file, a directory, or a glob |
90
92
  | `edit` | disabled |
91
93
  | `grep` | replaced by an anchored `grep`; pi's own `grep` returns when Anchor grep is off |
92
94
  | `copy`, `move` | disabled while Copy/move is off |
@@ -155,7 +157,7 @@ Nothing commits until an edit call returns: the extension validates the request
155
157
 
156
158
  ## Tools
157
159
 
158
- The extension registers eight tools: `read`, `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `copy` and `move` are enabled by default; turn Copy/move off in `/hashline-config` to remove both. `replace_match` is enabled by default; turn Replace match off in `/hashline-config` to remove it. `replace`, `replace_match`, `insert`, `copy`, and `move` do not need a `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. A `path` passed anyway is checked against anchor ownership: a matching path is accepted, a mismatch is rejected. Opt in with `/hashline-config` to require `path` in `replace`, `replace_match`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
160
+ The extension registers nine tools: `read`, `outline`, `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `copy` and `move` are enabled by default; turn Copy/move off in `/hashline-config` to remove both. `replace_match` is enabled by default; turn Replace match off in `/hashline-config` to remove it. `outline` is off by default; turn Outline tool on in `/hashline-config` to enable it, which also activates it when the host's default tool list omits it. `replace`, `replace_match`, `insert`, `copy`, and `move` do not need a `path` parameter by default: the file is resolved from the anchors' session ownership alone, so an edit can only land on the file the anchors were served for. A `path` passed anyway is checked against anchor ownership: a matching path is accepted, a mismatch is rejected. Opt in with `/hashline-config` to require `path` in `replace`, `replace_match`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
159
161
 
160
162
  ### read
161
163
 
@@ -167,7 +169,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
167
169
  | `offset` | Line number (1-indexed) or a served anchor to start reading from. |
168
170
  | `limit` | Maximum number of lines to return from `offset`. |
169
171
 
170
- Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=emnox to continue.]`.
172
+ Output is capped at 2000 lines and 50KB. Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=emnox to continue.]`. When the `outline` tool is enabled, a truncated read appends a notice pointing at it.
171
173
 
172
174
  A line whose `anchor│content` row exceeds 50KB is replaced by a marker that keeps the line's anchor: `anchor│[Line N is 2.2MB, exceeds 50.0KB; content not shown. Use bash: sed -n 'Np' <path> | head -c 51200]`. The marker is served like a normal row, so the whole line can still be replaced through it.
173
175
 
@@ -180,6 +182,19 @@ Edge cases:
180
182
  - BOMs are stripped for display. Non-UTF-8 bytes are shown as `U+FFFD`; editing such a file rewrites it as UTF-8, with a warning.
181
183
  - Files over 1,353,139 lines or 100MB are rejected with `[E_FILE_TOO_LARGE]`.
182
184
 
185
+ ### outline
186
+
187
+ `outline` returns the structural outline of a file, a directory, or a glob: a `=== path (language) — N lines ===` header per file, then one `anchor│content` row per symbol whose content is the first line of that symbol's declaration (annotations, decorators, and attributes excluded, and outside the symbol's span). Rows cover top-level declarations and the members of a type or module; a declaration inside a function body is never a row, whatever `depth` you pass. A `[limit N]` line under a multi-line symbol is the span to pass as `read`'s `limit` when reading the body from that anchor, and an `[N nested]` line marks children hidden by `depth`. Hiding children that way is a view choice rather than truncation, so `truncated` stays false and the section still counts as complete. The structured result reports `complete`, which is true only when no row or byte cap cut the outline, every rendered row was served, no line was skipped for a missing anchor, and no outlined file's anchors were reclaimed; a head-and-tail preview that omits lines is incomplete. Files the parser cannot outline, and supported files with no symbols, fall back to the same head-and-tail preview auto-read all uses, followed by a note that names the reason. Another note says when the parser could not read part of a file (a syntax error, or syntax the vendored grammar does not cover), since symbols in that part may be missing. A row whose line is longer than 200 characters is clipped and is not served for editing, even when an `E_RANGE_STALE` or `E_STALE_ANCHOR` error shows it again, so read that line first. The tool is offered only while Outline tool is on in `/hashline-config`.
188
+
189
+ A directory or glob outlines every supported source file it matches, sorted by path, with files separated by a blank line. Listing starts at the named folder (or the glob's literal prefix): inside a Git work tree it uses tracked and untracked-but-not-ignored files, otherwise a filesystem walk, and it skips the folders and files auto-read all excludes plus your `autoReadAllIgnore` list. `*` and `**` both cross folders, as in `grep`'s `glob`. All files share the 400-row cap and the 50KB budget; each file gets at least 20 rows, or an equal share of what is left, so one large file does not hide the rest. Files over 200KB are skipped before reading, and a batch stops once it has read 400,000 source lines so files the output would not show cannot exhaust the session's anchor pool. When the budget runs out, a footer names how many files were not outlined and how to narrow the request. Listed paths that are no longer regular files (deleted, or symlinks) are dropped silently; a file that fails to read (binary, oversized, or unreadable) is skipped with a notice. All notices count against the budget, and each outlined file's own notes are always kept. A path that exists is never read as a glob, so `app/[id].ts` names that file; otherwise `*`, `?`, `[...]`, or `{a,b}` makes it a glob. A pattern that matches no supported file returns `[E_NOT_FOUND]`, and an invalid glob returns `[E_BAD_SHAPE]`.
190
+
191
+ | Parameter | Description |
192
+ | --- | --- |
193
+ | `path` | A file (relative or absolute), a directory, or a glob such as `src/**/*.ts`, relative to the working directory. |
194
+ | `depth` | Nesting depth: `0` lists top-level symbols only, `1` adds their members, and the default is `2` (maximum `10`). |
195
+
196
+ Rows are the file's real lines, so an outline anchor is editable with `replace` or `insert` without a separate `read`, exactly like an auto-read-all outline row. The outline shows only each symbol's declaration line, so an edit inside a body still needs a `read` of that span.
197
+
183
198
  ### replace
184
199
 
185
200
  `replace` removes a range of lines and puts new lines in their place. One edit per call, with the fields at the top level:
@@ -260,7 +275,7 @@ Example: read served `hasux│old` in `a.ts` and `qwerx│top` in `b.ts`; to cop
260
275
  { "source_from": "hasux", "source_to": "hasux", "anchor": "qwerx", "direction": "after" }
261
276
  ```
262
277
 
263
- The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
278
+ The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and carry the source's line endings. The destination's end-of-file state is preserved either way: a destination that ended without a newline still does, and one that ended with a newline still does. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy. In a same-message batch, a same-file copy and a cross-file copy join the destination file's batch; the copy still duplicates the content its source anchors were served from, not the result of a sibling edit. Anchoring the block's last line with `direction: "after"` (or its first line with `direction: "before"`) duplicates the block right beside itself.
264
279
 
265
280
  ### move
266
281
 
@@ -290,6 +305,10 @@ Patterns are evaluated by ripgrep's linear-time regex engine, so shapes that sta
290
305
 
291
306
  Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whichever comes first, with a note naming the caps that cut the results (the exact one is in `details.truncation`). A matched line whose `anchor│content` row exceeds 500 bytes is shown as a fragment around the match, with `...` marking the truncated sides; a context row over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable, and `replace` always replaces the whole line.
292
307
 
308
+ A path ripgrep cannot read does not abort the search. ripgrep reports the error and keeps scanning, so directory searches return every match from the readable files and add a `[grep: some paths could not be searched, so these results are partial: ...]` note naming the failure. `[E_GREP_FAILED]` is reserved for a search that produced nothing: an unusable pattern or glob, ripgrep failing to start, or an exit that left no matches at all.
309
+
310
+ Matching uses the same line model as `read`, so `^` and `$` address the file's own line boundaries: in a CRLF file `TWO$` matches the second line, and a byte order mark does not keep `^` from matching line 1. A file with lone-CR (classic Mac) endings is still read, displayed, and anchored by its real lines, and every match is attributed to the line it is on, but ripgrep only breaks lines at `\n` there, so `^` and `$` see such a file as a single line: unanchored patterns work, `^` matches only at the start of the file, and `$` only at its end. Use `read` when line anchoring matters for a lone-CR file.
311
+
293
312
  ### undo_last_change
294
313
 
295
314
  `undo_last_change` reverts the most recent successful `replace`, `replace_match`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
@@ -302,6 +321,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
302
321
  - A successful `write` clears the history for that file.
303
322
  - If the file was modified since the last edit, the undo is refused with `[E_UNDO_STALE]` rather than overwriting those changes, and the record is kept. Once the file matches the edited state again, `undo_last_change` succeeds.
304
323
  - If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
324
+ - A path with no undo record reports `[E_UNDO_NONE]`, and `[E_NOT_FOUND]` when that path does not exist either. The record is looked up first, so a file deleted since its last edit is still restored.
305
325
  - Missing-file cleanup never touches the undo record. The per-session prune removes snapshots and served records of files that no longer exist (both are recomputed on the next read), but the undo history survives, even when the file is temporarily absent during a branch switch.
306
326
  - Records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent edit, even one made by another conversation.
307
327
 
@@ -338,9 +358,9 @@ Auto-read all is off by default and has three modes, selected in `/hashline-conf
338
358
 
339
359
  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; `resources/views/vendor` is kept so Laravel view overrides stay attached), 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, _ide_helper.php, _ide_helper_models.php, .phpstorm.meta.php, .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; `full` also caps the budget at 2MB, while `outline` follows the context-derived budget because outlines stay compact. Files are ranked before the budget applies: working-tree changes first, then manifest and entry files, then the dominant source tree, then everything else, and no single top-level directory may take more than 40% of the budget in the first pass, so one large subtree cannot crowd out the rest. 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.
340
360
 
341
- Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode, multi-line symbols appear as labels with `[limit N]` and single-line symbols appear as their content.
361
+ Each attached file starts with a header (`=== path ===` in `full` mode, `=== path (language) — lines ===` in `outline` mode) followed by its anchor rows. Edit directly from the attachment with replace and insert, so no `read` is needed. `full` attaches files whole; `outline` attaches each file's structural outline within the budget. In outline mode each symbol appears as the first line of its declaration, a `[limit N]` line under it gives the span to read when the symbol spans several lines, and `[N nested]` marks children hidden by auto-read all's six-level depth cap.
342
362
 
343
- A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget is not counted as complete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
363
+ A coverage line after the header reports how many attached files were served in full; a section truncated by the per-file budget, a preview that omitted lines, or one that includes a clipped row is not counted as complete. Children hidden by the outline depth cap are marked `[N nested]` and do not make a section incomplete. Each attached file begins with a `=== path ===` header, and files that were not attached are named in the footer.
344
364
 
345
365
  The setting lives in `/hashline-config` as Auto-read all and in `config.json` as `autoReadAll` (`"off"`, `"outline"`, or `"full"`; older configs with `"on"` or `true` are read as `"full"`, `"git"` as `"full"`, `"outline(git)"` as `"outline"` — both with Git repos only on — and `false` as `"off"`), and as `autoReadAllRequireGit`, the Git repos only switch (on by default). Extra folders and files are ignored via `/hashline-config` as Ignore folders/files and via `config.json` as `autoReadAllIgnore` (array of folder names, file names, or globs, for example `["docs", "scratch.md", "*.test.ts"]`). A single-segment entry skips any folder or file with that exact name (case-insensitive); an entry containing glob characters (`*`, `?`, `[`, `]`, `{`, `}`) is matched as a glob against the file name, or against the whole relative path when it also contains a slash, with the same syntax as `grep`'s `glob`; any other entry with a slash (for example `"src/tmp"`) skips that path. Custom ignores are counted with the vendor/name/pattern skips in the footer.
346
366
 
@@ -348,7 +368,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
348
368
 
349
369
  | Command | Description |
350
370
  | --- | --- |
351
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, required `path`, strict input, and undo retention. Persists across sessions. |
371
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, git repos only, ignore folders/files, disable on models, read on disabled models, diff context lines, `grep` tool, copy/move tools, replace_match tool, outline tool, required `path`, strict input, and undo retention. Persists across sessions. |
352
372
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
353
373
 
354
374
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -362,6 +382,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
362
382
  "anchorGrepEnabled": true,
363
383
  "copyMoveEnabled": true,
364
384
  "replaceMatchEnabled": true,
385
+ "outlineEnabled": false,
365
386
  "requirePath": false,
366
387
  "strictInput": false,
367
388
  "diffContextLines": 1,
@@ -380,6 +401,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
380
401
  | `anchorGrepEnabled` | Anchor grep | `true` | Register an anchored `grep` in place of pi's own `grep` while it is on. |
381
402
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
382
403
  | `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
404
+ | `outlineEnabled` | Outline tool | `false` | Register the `outline` tool for the structure of a file, directory, or glob on demand; when off, it is removed from the active tools. |
383
405
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
384
406
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`, `[W_ANCHOR_CASE]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
385
407
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
@@ -387,7 +409,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
387
409
  | `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. |
388
410
  | `undoRetentionDays` | Undo retention | `30` | Days to keep undo history; records older than this are deleted when the store opens. `0` keeps them forever (0-3650). |
389
411
 
390
- `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`, `grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, registering pi's plain `grep` for those models, 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.
412
+ `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 `outline`, `replace`, `replace_match`, `insert`, `copy`, `move`, `grep`, and `undo_last_change` from the active tools, and also `read` when Read on disabled models is `remove`, registering pi's plain `grep` for those models, 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.
391
413
 
392
414
  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).
393
415
 
@@ -397,7 +419,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
397
419
  | --- | --- | --- |
398
420
  | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
399
421
  | Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
400
- | Line cap | 1,353,139 lines per file | `read`, `replace`, `replace_match`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
422
+ | Line cap | 1,353,139 lines per file | `read`, `outline`, `replace`, `replace_match`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
401
423
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
402
424
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
403
425
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
@@ -405,6 +427,9 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
405
427
  | Grep output | 2000 rows and 50KB | `grep` |
406
428
  | Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
407
429
  | Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
430
+ | Outline rows | 400 symbol rows and 50KB of content per file, shared across files for a directory or glob (a truncation footer may follow) | `outline`, auto-read all `outline` mode |
431
+ | Outline directory file | 200KB per file | `outline` directory/glob; larger files are skipped with a notice |
432
+ | Outline source lines | 400,000 lines | `outline` directory/glob; stops early to protect the session anchor pool |
408
433
  | Auto-read all budget | 200KB floor; 2MB ceiling in `full` | total injection size derived from the model context window; `outline` is not capped at 2MB |
409
434
  | Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
410
435
  | Session anchors | 1,353,139 anchors in use | all tools; the least recently read or edited files are freed when the quota is exhausted (`[W_ANCHOR_RECLAIMED]`) |
@@ -414,14 +439,16 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
414
439
 
415
440
  ## Tool result details
416
441
 
417
- All eight tools return machine-readable metadata in `details` alongside the model-visible text.
442
+ All nine tools return machine-readable metadata in `details` alongside the model-visible text.
418
443
 
419
- 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. In a codemode session `tools.read()` resolves to the file's text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }`, where `text` is the raw file content of the served window and `lines` carries the anchored rows. 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.
444
+ All nine 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. In a codemode session `tools.read()` resolves to the file's text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }`, where `text` is the raw file content of the served window and `lines` carries the anchored rows. 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.
420
445
 
421
446
  `read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors, notices }`, where `text` is the raw file content of the served window (no anchors, no footers) and each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` there is what was shown, so an oversized line appears as its marker while keeping its anchor). `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`. An image read returns `{ type: "image", data, mimeType, note }`.
422
447
 
423
448
  `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.
424
449
 
450
+ `outline` returns `{ kind: "outline", path, text, totalLines, depth, lines, truncated, complete, notices }`, where `text` is the outline body without the notices, `lines` is the anchored rows it served, `complete` is true only when no cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor; `notices` carries notes such as the preview reason, the UTF-8 rewrite note, and `[W_ANCHOR_RECLAIMED]`; for a directory or glob, `lines` is replaced by `files: [{ path, lines, totalLines, truncated, complete }]`, one entry per outlined file, and `totalLines` is their sum; in a codemode session `tools.outline()` resolves to the served outline text (with notices) unless `anchors: true` is passed.
451
+
425
452
  `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`.
426
453
 
427
454
  | Tool | `details` |
@@ -431,6 +458,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
431
458
  | `replace_match` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), and `metrics` with the same counters. |
432
459
  | `copy`, `move` | Same shape as `replace`: `diff` (post-edit diff with current anchors), `patch`, `patchTruncated`, `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when a move changes nothing), and `metrics` with the same counters. |
433
460
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
461
+ | `outline` | `truncated` (true when the row or byte cap cut the outline), `complete` (true only when no cap cut the outline, every rendered row was served, and no line was skipped for a missing anchor), `snapshotId`, and `metrics` with `rows` (every rendered symbol row, including clipped ones) and `truncated`; structured `lines` holds only the rows that were served for editing. |
434
462
  | `grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
435
463
 
436
464
  `snapshotId`, `firstChangedLine`, and `metrics.warnings` are the three fields external consumers most often read; `snapshotId` is a string fingerprint, `firstChangedLine` is a 1-based line number on the result file, and `metrics.warnings` counts the `[W_*]` notices in `details.warnings`; hint `[H_*]` notices live in `details.hints` and are not counted.
@@ -485,7 +513,7 @@ Full reference:
485
513
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
486
514
  | `[E_BATCH_OVERLAP]` | Batched edit calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
487
515
  | `[E_OP_ABORTED]` | The edit was aborted before writing: the file changed or was deleted after it was read (a single edit or a cross-file move reports the file; a batch reports the batch and the failing call), the base read failed, a cross-file member could not be prepared, or the composed result exceeded a limit. Nothing was written; a call whose own request is invalid is left out of the batch instead. Fix the cause and retry, otherwise call `read` for fresh anchors and retry. The batch abort names the failing call and its error code when one is known. |
488
- | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences, look-around, or invalid regex syntax, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. |
516
+ | `[E_GREP_FAILED]` | `grep` could not start ripgrep or ripgrep exited with an error (for example a pattern with backreferences, look-around, or invalid regex syntax, which ripgrep's regex engine does not support); the message carries ripgrep's output. Retry with `literal: true` or simplify the pattern. A path ripgrep cannot read is not one of these: the search returns the readable matches with a partial-results note. |
489
517
  | `[E_GREP_TIMEOUT]` | `grep` timed out after 10 seconds; narrow `path` or simplify `pattern` and retry. |
490
518
 
491
519
  ## Troubleshooting
package/index.ts CHANGED
@@ -8,6 +8,7 @@ import { regCopy, regMove } from "./src/copy-move";
8
8
  import { regGrep, regVanillaGrep } from "./src/grep";
9
9
  import { regUndo, clearUndo } from "./src/replace-undo";
10
10
  import { regRead, fmtReadPreview } from "./src/read";
11
+ import { regOutline } from "./src/outline-tool";
11
12
  import { ANCHOR_TOOL_NAMES, modelDisabled, type ModelLike } from "./src/model-gate";
12
13
  import { buildAutoReadAllInjection, autoReadAllBudget, isOutlineAutoReadAll } from "./src/auto-read-all";
13
14
  import type { RMetrics } from "./src/replace-response";
@@ -15,7 +16,7 @@ import type { ReplaceDetails } from "./src/replace";
15
16
  import { extractHints, extractWarnings } from "./src/replace-render";
16
17
  import { MAX_HASH_LINES } from "./src/hashline";
17
18
  import { withStructuredText } from "./src/structured";
18
- import type { AutoReadAllMode, ReadOnDisabledModels } from "./src/config";
19
+ import type { AutoReadAllMode, Config, ReadOnDisabledModels } from "./src/config";
19
20
  import {
20
21
  readConfig,
21
22
  readConfigWithStatus,
@@ -25,6 +26,7 @@ import {
25
26
  toggleAnchorGrep,
26
27
  toggleCopyMove,
27
28
  toggleReplaceMatch,
29
+ toggleOutline,
28
30
  cycleReadOnDisabledModels,
29
31
  toggleRequirePath,
30
32
  toggleStrictInput,
@@ -38,7 +40,7 @@ import { loadHashStore, pruneExpiredUndo, pruneMissing } from "./src/hash-store"
38
40
  import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, sessionContextReadable, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
39
41
  import { serveRows } from "./src/served";
40
42
  import { finalizeTurn, planAssistantMessage } from "./src/batch";
41
- import { currentEditFlags } from "./src/edit-common";
43
+ import { currentEditFlags, editToolEnabled } from "./src/edit-common";
42
44
  import { HashlineConfigOverlay } from "./src/config-ui";
43
45
  import { registerWriteHook } from "./src/write-hook";
44
46
  import { readNormFile } from "./src/file-reader";
@@ -53,8 +55,18 @@ interface PendingRegistrySession {
53
55
  alive: () => boolean;
54
56
  }
55
57
 
58
+ function configEnablesTool(tool: string, config: Config): boolean {
59
+ if (tool === "grep") return config.anchorGrepEnabled === true;
60
+ return editToolEnabled(tool, {
61
+ replaceMatchEnabled: config.replaceMatchEnabled !== false,
62
+ copyMoveEnabled: config.copyMoveEnabled !== false,
63
+ outlineEnabled: config.outlineEnabled === true,
64
+ });
65
+ }
66
+
56
67
  export default function (pi: ExtensionAPI): void {
57
68
  regRead(pi);
69
+ regOutline(pi);
58
70
 
59
71
  regReplace(pi);
60
72
  regReplaceMatch(pi);
@@ -82,11 +94,21 @@ export default function (pi: ExtensionAPI): void {
82
94
  let gateApplied = false;
83
95
  const pendingRegistrySessions: PendingRegistrySession[] = [];
84
96
 
97
+ function setAnchorToolEnabled(tools: readonly string[], enabled: boolean): void {
98
+ const active = pi.getActiveTools();
99
+ pi.setActiveTools(enabled ? [...new Set([...active, ...tools])] : active.filter((tool) => !tools.includes(tool)));
100
+ for (const tool of tools) {
101
+ if (enabled) baseAnchorTools.add(tool);
102
+ else baseAnchorTools.delete(tool);
103
+ }
104
+ }
105
+
85
106
  async function refreshEditTools(): Promise<void> {
86
107
  try {
87
108
  codemodeActive = pi.getActiveTools().includes("codemode");
88
109
  const flags = await currentEditFlags(codemodeActive);
89
110
  if (readFlavor !== "vanilla") regRead(pi, flags);
111
+ regOutline(pi, flags);
90
112
  regReplace(pi, flags);
91
113
  regReplaceMatch(pi, flags);
92
114
  regInsert(pi, flags);
@@ -144,10 +166,7 @@ export default function (pi: ExtensionAPI): void {
144
166
  const enabled = ANCHOR_TOOL_NAMES.filter((tool) => {
145
167
  if (tool === "read") return true;
146
168
  if (!baseAnchorTools.has(tool)) return false;
147
- if (tool === "replace_match") return config.replaceMatchEnabled !== false;
148
- if (tool === "copy" || tool === "move") return config.copyMoveEnabled !== false;
149
- if (tool === "grep") return config.anchorGrepEnabled === true;
150
- return true;
169
+ return configEnablesTool(tool, config);
151
170
  });
152
171
  const active = pi.getActiveTools().filter((tool) => tool !== "edit");
153
172
  const base = enabled.includes("grep") ? active.filter((tool) => tool !== "grep") : active;
@@ -216,12 +235,9 @@ export default function (pi: ExtensionAPI): void {
216
235
  autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
217
236
  await refreshEditTools();
218
237
  pi.setActiveTools(
219
- pi.getActiveTools().filter((t) => {
220
- if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
221
- if (config.replaceMatchEnabled === false && t === "replace_match") return false;
222
- return true;
223
- }),
238
+ pi.getActiveTools().filter((tool) => tool === "grep" || configEnablesTool(tool, config)),
224
239
  );
240
+ setAnchorToolEnabled(["outline"], config.outlineEnabled === true);
225
241
  await syncModelGate(ctx.model, ctx.cwd);
226
242
  const debugValue = process.env.PI_HASHLINE_DEBUG;
227
243
  if (debugValue === "1" || debugValue === "true") {
@@ -278,7 +294,7 @@ export default function (pi: ExtensionAPI): void {
278
294
  }));
279
295
 
280
296
  pi.registerCommand("hashline-config", {
281
- description: "Open the hashline settings window (auto-read, diff context, auto-read all, git repos only, ignore folders/files, grep, copy/move, replace_match, path, strict input, disable on models, read on disabled models)",
297
+ description: "Open the hashline settings window (auto-read, diff context, auto-read all, git repos only, ignore folders/files, grep, copy/move, replace_match, outline, path, strict input, disable on models, read on disabled models)",
282
298
  handler: async (_args, ctx) => {
283
299
  if (!ctx.hasUI) {
284
300
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -307,22 +323,9 @@ export default function (pi: ExtensionAPI): void {
307
323
  if (enabled) baseAnchorTools.add("grep");
308
324
  else baseAnchorTools.delete("grep");
309
325
  }
310
- else if (key === "copyMoveEnabled") {
311
- const enabled = await toggleCopyMove();
312
- const active = pi.getActiveTools();
313
- pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
314
- for (const tool of ["copy", "move"]) {
315
- if (enabled) baseAnchorTools.add(tool);
316
- else baseAnchorTools.delete(tool);
317
- }
318
- }
319
- else if (key === "replaceMatchEnabled") {
320
- const enabled = await toggleReplaceMatch();
321
- const active = pi.getActiveTools();
322
- pi.setActiveTools(enabled ? [...new Set([...active, "replace_match"])] : active.filter((t) => t !== "replace_match"));
323
- if (enabled) baseAnchorTools.add("replace_match");
324
- else baseAnchorTools.delete("replace_match");
325
- }
326
+ else if (key === "copyMoveEnabled") setAnchorToolEnabled(["copy", "move"], await toggleCopyMove());
327
+ else if (key === "replaceMatchEnabled") setAnchorToolEnabled(["replace_match"], await toggleReplaceMatch());
328
+ else if (key === "outlineEnabled") setAnchorToolEnabled(["outline"], await toggleOutline());
326
329
  else if (key === "requirePath") await toggleRequirePath();
327
330
  else if (key === "strictInput") await toggleStrictInput();
328
331
  await refreshEditTools();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "7.0.0",
3
+ "version": "7.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 5-character lowercase 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",
@@ -31,7 +31,7 @@ const HEADER =
31
31
  "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
32
32
 
33
33
  const OUTLINE_HEADER =
34
- "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
34
+ "[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│content` rows with live anchors: each symbol row is the first line of that symbol's declaration (annotations and attributes excluded), a `[limit N]` line under a multi-line symbol is the span to request when reading from that anchor, a `[N nested]` line marks children hidden by the six-level depth cap, and a file the parser cannot outline falls back to a head-and-tail preview.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when the next line shows one); the outline shows only symbol declaration lines, so read a span before editing inside a body. A row whose line is too long is clipped with a trailing `...` and is not served for editing; read that line first. Do not call read for attached files except to fetch a symbol span or a clipped row.\nFiles listed as not attached can be read normally.\nAnchors are lowercase and stay valid until their line is edited.";
35
35
 
36
36
  const IMAGE_EXTENSIONS = new Set([
37
37
  ".avif",
@@ -88,7 +88,7 @@ export const AUTO_READ_ALL_EXCLUDED_NAMES = [
88
88
  ];
89
89
  const EXCLUDED_NAME_SET = new Set(AUTO_READ_ALL_EXCLUDED_NAMES);
90
90
  const EXCLUDED_SEGMENT_SET = new Set(AUTO_READ_ALL_EXCLUDED_SEGMENTS);
91
- function isExcludedBySegment(path: string): boolean {
91
+ export function isExcludedBySegment(path: string): boolean {
92
92
  const lower = path.toLowerCase();
93
93
  const vendorViews = lower.startsWith("resources/views/vendor/");
94
94
  for (const segment of lower.split("/")) {
@@ -103,7 +103,7 @@ const EXCLUDED_PATTERN_SUFFIXES = [
103
103
  ];
104
104
  const EXCLUDED_PATTERN_INFIXES = [".bundle.", ".chunk.", ".generated.", ".gen."];
105
105
 
106
- function isExcludedByPattern(baseLower: string): boolean {
106
+ export function isExcludedByPattern(baseLower: string): boolean {
107
107
  return EXCLUDED_PATTERN_SUFFIXES.some((suffix) => baseLower.endsWith(suffix))
108
108
  || EXCLUDED_PATTERN_INFIXES.some((infix) => baseLower.includes(infix))
109
109
  || baseLower.startsWith("coreui-icons.")
@@ -256,7 +256,7 @@ const GLOB_CHARS_RE = /[*?[\]{}]/;
256
256
  const GLOB_CACHE_LIMIT = 256;
257
257
  const globRegexCache = new Map<string, RegExp | null>();
258
258
 
259
- function globRegexFor(entry: string): RegExp | null {
259
+ export function globRegexFor(entry: string): RegExp | null {
260
260
  const cached = globRegexCache.get(entry);
261
261
  if (cached !== undefined) return cached;
262
262
  let compiled: RegExp | null;
@@ -354,7 +354,7 @@ function runCommand(command: string, args: string[], cwd: string): Promise<{ std
354
354
  });
355
355
  }
356
356
 
357
- async function listFromGit(cwd: string): Promise<string[] | undefined> {
357
+ export async function listFromGit(cwd: string): Promise<string[] | undefined> {
358
358
  const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd);
359
359
  if (result.code !== 0) return undefined;
360
360
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
@@ -403,7 +403,7 @@ async function listFromRg(cwd: string): Promise<string[] | undefined> {
403
403
  return result.stdout.split("\0").filter((entry) => entry.length > 0);
404
404
  }
405
405
 
406
- async function walkDir(dir: string, base: string, out: string[], customIgnore: readonly string[] = []): Promise<void> {
406
+ export async function walkDir(dir: string, base: string, out: string[], customIgnore: readonly string[] = []): Promise<void> {
407
407
  let entries;
408
408
  try {
409
409
  entries = await readdir(dir, { withFileTypes: true });
@@ -554,7 +554,7 @@ async function renderFile(file: string, cwd: string, outline: boolean): Promise<
554
554
  if (outline) {
555
555
  const section = await buildFileOutline({ displayPath: file, content: normalized, hashes: fileHashes });
556
556
  serveRows(absolutePath, fileHashes, fileLines, section.servedHashes);
557
- return { file, text: section.text, totalLines: fileHashes.length, absolutePath, complete: !section.truncated };
557
+ return { file, text: section.text, totalLines: fileHashes.length, absolutePath, complete: section.complete };
558
558
  }
559
559
  const preview = await fmtReadPreview(normalized, {}, fileHashes, absolutePath, AUTO_READ_ALL_MAX_BUDGET_BYTES, MAX_HASH_LINES);
560
560
  serveRows(absolutePath, fileHashes, fileLines, preview.servedHashes);
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { DEFAULT_UNDO_RETENTION_DAYS, readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllRequireGit" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels" | "undoRetentionDays";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllRequireGit" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "outlineEnabled" | "requirePath" | "strictInput" | "diffContextLines" | "disableOnModels" | "readOnDisabledModels" | "undoRetentionDays";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -36,6 +36,7 @@ export function configRows(config: Config): ConfigRow[] {
36
36
  { key: "anchorGrepEnabled", group: "Tools", depth: 0, label: "Anchor grep", hint: "Use the anchored grep in place of pi's own grep", enabled: config.anchorGrepEnabled === true },
37
37
  { key: "copyMoveEnabled", group: "Tools", depth: 0, label: "Copy/move", hint: "Enable the copy and move tools", enabled: config.copyMoveEnabled !== false },
38
38
  { key: "replaceMatchEnabled", group: "Tools", depth: 0, label: "Replace match", hint: "Enable the replace_match tool", enabled: config.replaceMatchEnabled !== false },
39
+ { key: "outlineEnabled", group: "Tools", depth: 0, label: "Outline tool", hint: "Offer the outline tool for one file's structure on demand", enabled: config.outlineEnabled === true },
39
40
  { key: "requirePath", group: "Edit behavior", depth: 0, label: "Require path", hint: "Edit tools must also send a matching path", enabled: config.requirePath === true },
40
41
  { key: "strictInput", group: "Edit behavior", depth: 0, label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
41
42
  { key: "disableOnModels", group: "Model gating", depth: 0, label: "Disable on models", hint: "Model globs that turn off anchored tools (comma-separated)", enabled: (config.disableOnModels ?? []).length > 0, entries: config.disableOnModels ?? [] },
@@ -101,6 +102,10 @@ export class HashlineConfigOverlay {
101
102
  this.rows = configRows(await readConfig());
102
103
  }
103
104
 
105
+ rowKeys(): ConfigToggleKey[] {
106
+ return this.rows.map((row) => row.key);
107
+ }
108
+
104
109
  private runToggle(row: ConfigRow, delta?: number, value?: string): void {
105
110
  this.opts.tui.requestRender(true);
106
111
  void this.opts.onToggle(row.key, delta, value).then(async () => {
package/src/config.ts CHANGED
@@ -21,6 +21,7 @@ export interface Config {
21
21
  anchorGrepEnabled: boolean;
22
22
  copyMoveEnabled?: boolean;
23
23
  replaceMatchEnabled?: boolean;
24
+ outlineEnabled?: boolean;
24
25
  autoReadAll?: AutoReadAllMode;
25
26
  autoReadAllRequireGit?: boolean;
26
27
  autoReadAllIgnore?: string[];
@@ -37,6 +38,7 @@ const DEFAULT_CONFIG: Config = {
37
38
  anchorGrepEnabled: true,
38
39
  copyMoveEnabled: true,
39
40
  replaceMatchEnabled: true,
41
+ outlineEnabled: false,
40
42
  autoReadAll: "off",
41
43
  autoReadAllRequireGit: true,
42
44
  autoReadAllIgnore: [],
@@ -123,6 +125,7 @@ function parseConfig(content: string): Config {
123
125
  const anchorGrepEnabled = parsed.anchorGrepEnabled;
124
126
  const copyMoveEnabled = parsed.copyMoveEnabled;
125
127
  const replaceMatchEnabled = parsed.replaceMatchEnabled;
128
+ const outlineEnabled = parsed.outlineEnabled;
126
129
  const autoReadAll = parsed.autoReadAll;
127
130
  const autoReadAllRequireGit = parsed.autoReadAllRequireGit;
128
131
  const requirePath = parsed.requirePath;
@@ -138,6 +141,7 @@ function parseConfig(content: string): Config {
138
141
  anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
139
142
  copyMoveEnabled: typeof copyMoveEnabled === "boolean" ? copyMoveEnabled : DEFAULT_CONFIG.copyMoveEnabled,
140
143
  replaceMatchEnabled: typeof replaceMatchEnabled === "boolean" ? replaceMatchEnabled : DEFAULT_CONFIG.replaceMatchEnabled,
144
+ outlineEnabled: typeof outlineEnabled === "boolean" ? outlineEnabled : DEFAULT_CONFIG.outlineEnabled,
141
145
  autoReadAll: parseAutoReadAllMode(autoReadAll),
142
146
  autoReadAllRequireGit: parseAutoReadAllRequireGit(autoReadAllRequireGit, autoReadAll),
143
147
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
@@ -254,7 +258,7 @@ export async function writeConfig(config: Config): Promise<void> {
254
258
  }
255
259
 
256
260
 
257
- type ToggleKey = "autoRead" | "autoReadAllRequireGit" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput";
261
+ type ToggleKey = "autoRead" | "autoReadAllRequireGit" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "outlineEnabled" | "requirePath" | "strictInput";
258
262
 
259
263
  async function toggleFlag(key: ToggleKey): Promise<boolean> {
260
264
  const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
@@ -265,6 +269,7 @@ export const toggleAutoReadAllRequireGit = (): Promise<boolean> => toggleFlag("a
265
269
  export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
266
270
  export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
267
271
  export const toggleReplaceMatch = (): Promise<boolean> => toggleFlag("replaceMatchEnabled");
272
+ export const toggleOutline = (): Promise<boolean> => toggleFlag("outlineEnabled");
268
273
  function steppedIndex(length: number, index: number, delta: number): number {
269
274
  return ((index + delta) % length + length) % length;
270
275
  }
@@ -17,6 +17,7 @@ export interface EditToolFlags {
17
17
  autoReadAllOutline: boolean;
18
18
  replaceMatchEnabled: boolean;
19
19
  copyMoveEnabled: boolean;
20
+ outlineEnabled: boolean;
20
21
  codemode: boolean;
21
22
  }
22
23
 
@@ -28,9 +29,19 @@ export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
28
29
  autoReadAllOutline: false,
29
30
  replaceMatchEnabled: true,
30
31
  copyMoveEnabled: true,
32
+ outlineEnabled: false,
31
33
  codemode: false,
32
34
  };
33
35
 
36
+ export type EditToolGate = Pick<EditToolFlags, "replaceMatchEnabled" | "copyMoveEnabled" | "outlineEnabled">;
37
+
38
+ export function editToolEnabled(tool: string, flags: EditToolGate): boolean {
39
+ if (tool === "outline") return flags.outlineEnabled;
40
+ if (tool === "replace_match") return flags.replaceMatchEnabled;
41
+ if (tool === "copy" || tool === "move") return flags.copyMoveEnabled;
42
+ return true;
43
+ }
44
+
34
45
  export async function currentEditFlags(codemode = false): Promise<EditToolFlags> {
35
46
  const config = await readConfig();
36
47
  return {
@@ -41,23 +52,24 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
41
52
  autoReadAllOutline: (config.autoReadAll ?? "off") === "outline",
42
53
  replaceMatchEnabled: config.replaceMatchEnabled !== false,
43
54
  copyMoveEnabled: config.copyMoveEnabled !== false,
55
+ outlineEnabled: config.outlineEnabled === true,
44
56
  codemode
45
57
  };
46
58
  }
47
59
 
48
60
  function preferenceGuideline(flags: EditToolFlags): string {
49
- const tools = gatedEditOps(["read", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
61
+ const tools = gatedEditOps(["read", "outline", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
50
62
  return `Remember to use the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
51
63
  }
52
64
 
53
65
  const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
54
66
  const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
55
67
  const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
56
- const SHARED_SCRIPT_OPS = ["read", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
68
+ const SHARED_SCRIPT_OPS = ["read", "outline", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
57
69
 
58
70
  function resultContractGuideline(flags: EditToolFlags): string {
59
71
  const tools = operationNames(SHARED_SCRIPT_OPS, flags);
60
- return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`.`;
72
+ return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`. Arguments that fail the tool's schema still throw before the tool runs.`;
61
73
  }
62
74
  const READ_SCRIPT_GUIDELINE =
63
75
  '`read` in a codemode script resolves to the file\'s text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }` with the raw file content and `lines[].anchor` for chained edits. An image read resolves to an image block.';
@@ -161,11 +173,7 @@ export function withReplaceMatchPrompts(base: { description: string; snippet: st
161
173
  }
162
174
 
163
175
  function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
164
- return ops.filter((op) => {
165
- if (op === "replace_match") return flags.replaceMatchEnabled;
166
- if (op === "copy" || op === "move") return flags.copyMoveEnabled;
167
- return true;
168
- });
176
+ return ops.filter((op) => editToolEnabled(op, flags));
169
177
  }
170
178
 
171
179
  function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/" }): string {