pi-hashline-edit-pro 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 one file's structural outline |
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,17 @@ 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 one file: a `=== path (language) — N lines ===` header, then one `anchor│content` row per symbol whose content 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 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, and no line was skipped for a missing anchor; a head-and-tail preview that omits lines is incomplete. Files the parser cannot outline fall back to the same head-and-tail preview auto-read all uses. A row whose line is longer than 200 characters is clipped and is not served for editing, so read that line first. The tool is offered only while Outline tool is on in `/hashline-config`.
188
+
189
+ | Parameter | Description |
190
+ | --- | --- |
191
+ | `path` | Path to the file to outline (relative or absolute). |
192
+ | `depth` | Nesting depth: `0` lists top-level symbols only, `1` adds their members, and the default is `2` (maximum `10`). |
193
+
194
+ 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.
195
+
183
196
  ### replace
184
197
 
185
198
  `replace` removes a range of lines and puts new lines in their place. One edit per call, with the fields at the top level:
@@ -338,9 +351,9 @@ Auto-read all is off by default and has three modes, selected in `/hashline-conf
338
351
 
339
352
  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
353
 
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.
354
+ 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
355
 
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.
356
+ 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
357
 
345
358
  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
359
 
@@ -348,7 +361,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
348
361
 
349
362
  | Command | Description |
350
363
  | --- | --- |
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. |
364
+ | `/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
365
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
353
366
 
354
367
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -362,6 +375,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
362
375
  "anchorGrepEnabled": true,
363
376
  "copyMoveEnabled": true,
364
377
  "replaceMatchEnabled": true,
378
+ "outlineEnabled": false,
365
379
  "requirePath": false,
366
380
  "strictInput": false,
367
381
  "diffContextLines": 1,
@@ -380,6 +394,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
380
394
  | `anchorGrepEnabled` | Anchor grep | `true` | Register an anchored `grep` in place of pi's own `grep` while it is on. |
381
395
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
382
396
  | `replaceMatchEnabled` | Replace match | `true` | Offer the `replace_match` tool; when off, it is removed from the active tools. |
397
+ | `outlineEnabled` | Outline tool | `false` | Register the `outline` tool for one file's structure on demand; when off, it is removed from the active tools. |
383
398
  | `requirePath` | Require path | `false` | `replace`, `replace_match`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
384
399
  | `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
400
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
@@ -387,7 +402,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
387
402
  | `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
403
  | `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
404
 
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.
405
+ `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
406
 
392
407
  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
408
 
@@ -397,7 +412,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
397
412
  | --- | --- | --- |
398
413
  | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
399
414
  | 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]`) |
415
+ | Line cap | 1,353,139 lines per file | `read`, `outline`, `replace`, `replace_match`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
401
416
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
402
417
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
403
418
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
@@ -405,6 +420,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
405
420
  | Grep output | 2000 rows and 50KB | `grep` |
406
421
  | Grep row fragment | 500 bytes | long match and context rows shown as `...` fragments |
407
422
  | Auto-read all files | 500 files, 200KB per file | files larger than 200KB and files past the cap are omitted |
423
+ | Outline rows | 400 symbol rows and 50KB of content per file (a truncation footer may follow) | `outline`, auto-read all `outline` mode |
408
424
  | 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
425
  | Stale-range feedback | first 100 lines | rows returned with `[E_RANGE_STALE]` |
410
426
  | 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 +430,16 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
414
430
 
415
431
  ## Tool result details
416
432
 
417
- All eight tools return machine-readable metadata in `details` alongside the model-visible text.
433
+ All nine tools return machine-readable metadata in `details` alongside the model-visible text.
418
434
 
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.
435
+ 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
436
 
421
437
  `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
438
 
423
439
  `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
440
 
441
+ `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 non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`; in a codemode session `tools.outline()` resolves to the served outline text (with notices) unless `anchors: true` is passed.
442
+
425
443
  `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
444
 
427
445
  | Tool | `details` |
@@ -431,6 +449,7 @@ All eight tools also declare an `outputSchema` and return `structuredContent`, s
431
449
  | `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
450
  | `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
451
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
452
+ | `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
453
  | `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
454
 
436
455
  `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.
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.0",
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",
@@ -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,19 +52,20 @@ 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);
@@ -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 {
package/src/model-gate.ts CHANGED
@@ -6,6 +6,7 @@ export interface ModelLike {
6
6
 
7
7
  export const ANCHOR_TOOL_NAMES: readonly string[] = [
8
8
  "read",
9
+ "outline",
9
10
  "replace",
10
11
  "replace_match",
11
12
  "insert",
@@ -4,7 +4,6 @@ export interface OutlineSymbol {
4
4
  startLine: number;
5
5
  endLine: number;
6
6
  children?: OutlineSymbol[];
7
- detail?: string;
8
7
  }
9
8
 
10
9
  export interface OutlineParse {