pi-hashline-edit-pro 4.5.2 → 5.0.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
@@ -17,7 +17,7 @@ HDtm│}
17
17
 
18
18
  replace one line by its anchor:
19
19
 
20
- { "remove_from": "Emno", "remove_to": "Emno", "replacement_lines": [" console.log('hi');"] }
20
+ { "remove_from": "Emno", "remove_to": "Emno", "replacement_lines": " console.log('hi');" }
21
21
 
22
22
  the result is the post-edit diff with fresh anchors, so the next edit needs no re-read.
23
23
  ```
@@ -30,6 +30,7 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
30
30
  - [Tools](#tools)
31
31
  - [read](#read)
32
32
  - [replace](#replace)
33
+ - [replace_within](#replace_within)
33
34
  - [insert](#insert)
34
35
  - [copy](#copy)
35
36
  - [move](#move)
@@ -89,6 +90,7 @@ pi install /path/to/pi-hashline-edit-pro
89
90
  | `edit` | disabled |
90
91
  | `grep` | disabled while `anchor_grep` is enabled |
91
92
  | `copy`, `move` | disabled while Copy/move is off |
93
+ | `replace_within` | disabled while Replace within is off |
92
94
  | `write` | kept; an auto-read block with fresh anchors is appended to its result |
93
95
  | `bash` | untouched |
94
96
 
@@ -128,7 +130,7 @@ pi uninstall npm:pi-hashline-edit-pro
128
130
  {
129
131
  "remove_from": "Emno",
130
132
  "remove_to": "Emno",
131
- "replacement_lines": [" console.log('hi');"]
133
+ "replacement_lines": " console.log('hi');"
132
134
  }
133
135
  ```
134
136
 
@@ -153,7 +155,7 @@ Nothing commits until an edit call returns: the extension validates the request
153
155
 
154
156
  ## Tools
155
157
 
156
- The extension registers seven tools: `read`, `replace`, `insert`, `copy`, `move`, `anchor_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`, `insert`, `copy`, and `move` take no `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. Opt in with `/hashline-config` to require `path` in `replace`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
158
+ The extension registers eight tools: `read`, `replace`, `replace_within`, `insert`, `copy`, `move`, `anchor_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_within` is enabled by default; turn Replace within off in `/hashline-config` to remove it. `replace`, `replace_within`, `insert`, `copy`, and `move` take no `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. Opt in with `/hashline-config` to require `path` in `replace`, `replace_within`, `insert`, `copy`, and `move` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
157
159
 
158
160
  ### read
159
161
 
@@ -186,7 +188,7 @@ Edge cases:
186
188
  | --- | --- |
187
189
  | `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
188
190
  | `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
189
- | `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
191
+ | `replacement_lines` | The exact text to write in place of the removed range, as one string: `""` deletes the range, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
190
192
 
191
193
  Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
192
194
 
@@ -194,7 +196,7 @@ Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
194
196
  {
195
197
  "remove_from": "Hasu",
196
198
  "remove_to": "arvm",
197
- "replacement_lines": ["new line 1", "new line 2"]
199
+ "replacement_lines": "new line 1\nnew line 2"
198
200
  }
199
201
  ```
200
202
 
@@ -202,16 +204,24 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
202
204
 
203
205
  The extension checks the request before any file I/O, so a bad request never touches the file.
204
206
 
205
- Auto-fixable slips fall into two groups. Fixed silently: a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines. Fixed with a warning: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
207
+ Auto-fixable slips fall into two groups. Fixed silently: a reversed range, embedded newlines, and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement.
206
208
 
207
209
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
208
210
 
209
211
  Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
210
212
 
211
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
213
+ An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
212
214
 
213
215
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
214
216
 
217
+ ### replace_within
218
+
219
+ `replace_within` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `replace_old` is the exact text to find inside that range, and `replace_new` replaces just that match; every other character stays untouched. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_within` leaves everything the request did not name untouched. It is enabled by default; turn Replace within off in `/hashline-config` to remove the tool.
220
+
221
+ `replace_old` is matched against the range's text (LF line breaks, no final terminator) and must occur exactly once. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows; a repeated match is refused with `[E_SUBSTRING_AMBIGUOUS]` and the matching line numbers. Both refusals carry enough to retry without a `read`.
222
+
223
+ A `replace_within` call is never grouped into a batch; it commits on its own like `copy` and `move`. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
224
+
215
225
  ### insert
216
226
 
217
227
  `insert` adds lines after or before an existing line without removing anything. Like `replace`, there is no `path` parameter.
@@ -220,14 +230,14 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
220
230
  | --- | --- |
221
231
  | `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
222
232
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
223
- | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A lone string is split on newlines, and stringified array text is unwrapped. |
233
+ | `lines` | The exact text to insert, as one string: `""` inserts nothing, `"\n"` is one blank line, and a trailing line break sets the last line's ending instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
224
234
 
225
- Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
235
+ Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: ""`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
226
236
 
227
237
  Example: add a line after `Emno│`:
228
238
 
229
239
  ```json
230
- { "anchor": "Emno", "direction": "after", "lines": [" // log the greeting"] }
240
+ { "anchor": "Emno", "direction": "after", "lines": " // log the greeting" }
231
241
  ```
232
242
 
233
243
  The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
@@ -280,11 +290,11 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
280
290
 
281
291
  ### undo_last_change
282
292
 
283
- `undo_last_change` reverts the most recent successful `replace`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
293
+ `undo_last_change` reverts the most recent successful `replace`, `replace_within`, `insert`, `copy`, or `move` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
284
294
 
285
- - History is per-file and single-level: only the most recent `replace`, `insert`, `copy`, or `move` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
295
+ - History is per-file and single-level: only the most recent `replace`, `replace_within`, `insert`, `copy`, or `move` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
286
296
  - History is persisted and survives session restarts. A failed `write` does not clear it.
287
- - Every applied `replace`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
297
+ - Every applied `replace`, `replace_within`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
288
298
  - A cross-file `move` stores one undo entry per file; `undo_last_change` reverts the file you name, so revert both sides to undo the whole move.
289
299
  - A successful `write` clears the history for that file.
290
300
  - 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.
@@ -297,7 +307,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
297
307
  Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run on their own, one after another.
298
308
 
299
309
  - A call outside a batch commits before its result returns.
300
- - A `copy` or `move` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
310
+ - A `copy`, `move`, or `replace_within` call is never grouped into a batch: it commits on its own, and a pending same-file batch aborts safely with `[E_OP_ABORTED]` if the file changed under it.
301
311
  - A batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N`, and the batch's last call shows the combined diff, with one undo reverting the whole batch.
302
312
  - If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
303
313
  - A batch member accepts the same request shapes and auto-fixes as a standalone call.
@@ -312,7 +322,9 @@ The hashline tools are sequential in pi, so a message that contains one runs all
312
322
 
313
323
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
314
324
 
315
- After `replace`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
325
+ After `replace`, `replace_within`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
326
+
327
+ An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
316
328
 
317
329
  Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff context live in `/hashline-config` and persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
318
330
 
@@ -332,7 +344,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
332
344
 
333
345
  | Command | Description |
334
346
  | --- | --- |
335
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, required `path`, and strict input. Persists across sessions. |
347
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, copy/move tools, replace_within tool, required `path`, and strict input. Persists across sessions. |
336
348
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
337
349
 
338
350
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -344,6 +356,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
344
356
  "autoReadAllIgnore": [],
345
357
  "anchorGrepEnabled": true,
346
358
  "copyMoveEnabled": true,
359
+ "replaceWithinEnabled": true,
347
360
  "requirePath": false,
348
361
  "strictInput": false,
349
362
  "diffContextLines": 1
@@ -357,7 +370,8 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
357
370
  | `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
358
371
  | `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
359
372
  | `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
360
- | `requirePath` | Require path | `false` | `replace`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
373
+ | `replaceWithinEnabled` | Replace within | `true` | Offer the `replace_within` tool; when off, it is removed from the active tools. |
374
+ | `requirePath` | Require path | `false` | `replace`, `replace_within`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
361
375
  | `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
362
376
  | `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
363
377
 
@@ -369,7 +383,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
369
383
  | --- | --- | --- |
370
384
  | Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
371
385
  | Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
372
- | Line cap | 1,353,139 lines per file | `read`, `replace`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
386
+ | Line cap | 1,353,139 lines per file | `read`, `replace`, `replace_within`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
373
387
  | File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
374
388
  | Hash window | first 500 bytes of a line | anchor identity for long lines |
375
389
  | Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
@@ -385,12 +399,13 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
385
399
 
386
400
  ## Tool result details
387
401
 
388
- All seven tools return machine-readable metadata in `details` alongside the model-visible text.
402
+ All eight tools return machine-readable metadata in `details` alongside the model-visible text.
389
403
 
390
404
  | Tool | `details` |
391
405
  | --- | --- |
392
406
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
393
407
  | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+anchor│` and ` anchor│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), `hints` (informative `[H_*]` notices, for example literal escaped text written as sent), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
408
+ | `replace_within` | 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. |
394
409
  | `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. |
395
410
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
396
411
  | `anchor_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). |
@@ -415,15 +430,18 @@ Full reference:
415
430
  | Code | Meaning |
416
431
  | --- | --- |
417
432
  | `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
418
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
419
- | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example stringified array text that could not be parsed and was kept as one literal line). |
433
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, or a grep `glob` has invalid bracket or brace syntax. |
434
+ | `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example legacy array text that could not be parsed and was kept as one literal line). |
420
435
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
436
+ | `[E_SUBSTRING_NOT_FOUND]` | `replace_within` did not find `replace_old` in the selected range. The current `anchor│content` rows are returned; copy `replace_old` exactly from the served row and retry. |
437
+ | `[E_SUBSTRING_AMBIGUOUS]` | `replace_within` found `replace_old` more than once in the selected range. Narrow `replace_from`/`replace_to` or extend `replace_old` so it matches exactly once. |
421
438
  | `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
422
439
  | `[E_STALE_ANCHOR]` | An anchor is not owned in this session (it was never shown to you, or its line was edited or the file was rewritten); call `read` for fresh anchors. |
423
- | `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
424
- | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
440
+ | `[W_INVALID_PATCH]` | A `replacement_lines` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
441
+ | `[W_BARE_HASH_PREFIX]` | A `replacement_lines` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
425
442
  | `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
426
443
  | `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
444
+ | `[H_UNICODE_LOST]` | The removed line contained an invisible or look-alike character (for example `U+200B` zero-width space, `U+00A0` no-break space, or a smart quote) that the replacement does not. The edit applied as sent; copy the character from the served row if the request did not ask to remove it. |
427
445
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
428
446
  | `[E_NOT_FOUND]` | The path does not exist. |
429
447
  | `[E_ACCESS]` | The file is not readable or writable. |
@@ -452,7 +470,7 @@ Full reference:
452
470
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
453
471
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
454
472
  - Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
455
- - Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `insert`, and `undo_last_change` still write the edit.
473
+ - Windows drives in WSL. Editing a file under a Windows mount (`/mnt/c`, drvfs/9p) can fail with `EPERM` from `fchmod` because those filesystems do not store POSIX modes. Mode preservation is best-effort there, so `replace`, `replace_within`, `insert`, and `undo_last_change` still write the edit.
456
474
  - Not sure what the extension changed. `read` returns anchored rows and the built-in `edit` is gone; that is expected. See [What changes in your session](#what-changes-in-your-session).
457
475
 
458
476
  ## Privacy and on-disk state
@@ -479,7 +497,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
479
497
 
480
498
  ### Allocation
481
499
 
482
- Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
500
+ Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `replace_within`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
483
501
 
484
502
  ### Ownership and mapping across edits
485
503
 
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
3
3
  import { initHasher } from "./src/hashline";
4
+ import { regReplaceWithin } from "./src/replace-within";
4
5
  import { regReplace } from "./src/replace";
5
6
  import { regInsert } from "./src/insert";
6
7
  import { regCopy, regMove } from "./src/copy-move";
@@ -20,6 +21,7 @@ import {
20
21
  cycleAutoReadAllMode,
21
22
  toggleAnchorGrep,
22
23
  toggleCopyMove,
24
+ toggleReplaceWithin,
23
25
  toggleRequirePath,
24
26
  toggleStrictInput,
25
27
  adjustDiffContextLines,
@@ -43,6 +45,7 @@ export default function (pi: ExtensionAPI): void {
43
45
  regRead(pi);
44
46
 
45
47
  regReplace(pi);
48
+ regReplaceWithin(pi);
46
49
  regInsert(pi);
47
50
  regCopy(pi);
48
51
  regMove(pi);
@@ -61,9 +64,11 @@ export default function (pi: ExtensionAPI): void {
61
64
  const flags = await currentEditFlags();
62
65
  regRead(pi, flags);
63
66
  regReplace(pi, flags);
67
+ regReplaceWithin(pi, flags);
64
68
  regInsert(pi, flags);
65
69
  regCopy(pi, flags);
66
70
  regMove(pi, flags);
71
+ regGrep(pi, flags);
67
72
  regUndo(pi, flags);
68
73
  } catch (error) {
69
74
  console.error("Failed to refresh edit tools:", error);
@@ -99,6 +104,7 @@ export default function (pi: ExtensionAPI): void {
99
104
  pi.getActiveTools().filter((t) => {
100
105
  if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
101
106
  if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
107
+ if (config.replaceWithinEnabled === false && t === "replace_within") return false;
102
108
  return true;
103
109
  }),
104
110
  );
@@ -133,7 +139,7 @@ export default function (pi: ExtensionAPI): void {
133
139
  }));
134
140
 
135
141
  pi.registerCommand("hashline-config", {
136
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, path, strict input)",
142
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, replace_within, path, strict input)",
137
143
  handler: async (_args, ctx) => {
138
144
  if (!ctx.hasUI) {
139
145
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -159,6 +165,11 @@ export default function (pi: ExtensionAPI): void {
159
165
  const active = pi.getActiveTools();
160
166
  pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
161
167
  }
168
+ else if (key === "replaceWithinEnabled") {
169
+ const enabled = await toggleReplaceWithin();
170
+ const active = pi.getActiveTools();
171
+ pi.setActiveTools(enabled ? [...new Set([...active, "replace_within"])] : active.filter((t) => t !== "replace_within"));
172
+ }
162
173
  else if (key === "requirePath") await toggleRequirePath();
163
174
  else if (key === "strictInput") await toggleStrictInput();
164
175
  await refreshEditTools();
@@ -251,6 +262,7 @@ export default function (pi: ExtensionAPI): void {
251
262
 
252
263
  if (
253
264
  event.toolName !== "replace" &&
265
+ event.toolName !== "replace_within" &&
254
266
  event.toolName !== "insert" &&
255
267
  event.toolName !== "copy" &&
256
268
  event.toolName !== "move" &&
@@ -280,7 +292,8 @@ export default function (pi: ExtensionAPI): void {
280
292
  const warnings = detailWarnings.length ? `Warnings:\n${detailWarnings.join("\n")}` : extractWarnings(rendered);
281
293
  const hints = detailHints.length ? `Hints:\n${detailHints.join("\n")}` : extractHints(rendered);
282
294
  const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
283
- const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${notices}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
295
+ const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
296
+ const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
284
297
  return {
285
298
  content: [
286
299
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.5.2",
3
+ "version": "5.0.0",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1 +1 @@
1
-
1
+ - `insert`: after inserting a quoted payload, check the post-edit diff for an extra `+anchor│` blank row before the next line.
@@ -1 +1 @@
1
- Insert `lines` after or before an anchor line: the anchor line stays, `lines` are bare without `│`, one per element
1
+ Insert a text block after or before an anchor line: the anchor line stays; `lines` is the exact text without anchor prefixes
package/prompts/insert.md CHANGED
@@ -1,3 +1,3 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Inserted lines are written exactly as given; nothing else in the file changes.
1
+ Insert text after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`. `lines` is one string holding the exact text to insert; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text.
2
2
 
3
3
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
@@ -1 +1 @@
1
- - `move`: a cross-file move records one undo entry per file — undo both sides or the moved lines stay duplicated or missing. Lines between source and target may be re-anchored.
1
+ - `move`: a cross-file move records one undo entry per file — undo both sides. Lines between source and target may be re-anchored.
@@ -1,4 +1,2 @@
1
- - `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
2
- - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
3
- - `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
4
- - `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
1
+ - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone.
2
+ - `replace`: `replacement_lines` is one string; a pasted `anchor│` prefix is stripped; single line: same anchor for `remove_from` and `remove_to`.
@@ -1 +1 @@
1
- Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, bare lines in `replacement_lines` without `│`, one edit per call
1
+ Replace lines by anchor: bare anchors in `remove_from`/`remove_to`, the exact replacement text in `replacement_lines` (one edit per call)
@@ -0,0 +1,3 @@
1
+ - `replace_within`: use it instead of `replace` to change part of a line, so every character the request does not name is preserved as served.
2
+ - `replace_within`: `replace_old` must be copied exactly from the served row and occur once in the range; a missing or repeated match is refused and returns the current rows.
3
+ - `replace_within`: a batch never groups it; it commits on its own, like `copy` and `move`.
@@ -0,0 +1 @@
1
+ Replace part of a line or range by anchor: `replace_old` is matched exactly once inside `replace_from`/`replace_to` and replaced by `replace_new`, leaving the rest of the text untouched
@@ -0,0 +1,5 @@
1
+ Replace part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range; use the same anchor for one line. `replace_old` is the exact text to find inside that range, copied from the served row; it must occur exactly once. `replace_new` replaces just that match, and every other character stays untouched.
2
+
3
+ Example: read served `Hasu│ {"id": "checkout-5", "feature": "legacyCheckout", "retries": 3},`. Call { "replace_from": "Hasu", "replace_to": "Hasu", "replace_old": "legacyCheckout", "replace_new": "stableCheckout" }. The line becomes ` {"id": "checkout-5", "feature": "stableCheckout", "retries": 3},` and the post-edit diff carries fresh anchors.
4
+
5
+ Both strings are exact text; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Matching uses LF line breaks and excludes the last line's terminator. A missing match is refused with the current rows, a repeated match with the matching line numbers, so the retry needs no read. Nothing but the matched text changes.
@@ -1,5 +1,6 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. `[]` deletes the range.
1
+ Replace a range of lines (or a single line) in a text file by anchor. `remove_from` and `remove_to` are the 4-character anchors of the first and last line to remove, and `replacement_lines` is one string with the exact replacement text: `""` deletes the range, and escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. The text is written exactly as given, and nothing else in the file changes.
2
+ To change only part of a line without retyping the rest, use `replace_within` instead; it preserves every character the request does not name.
2
3
 
3
4
  Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
5
 
5
- Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": ["new"] }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
6
+ Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": "new line 1\nnew line 2" }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new line 1`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
@@ -1,2 +1,2 @@
1
- - `undo_last_change`: only the last `replace`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
1
+ - `undo_last_change`: only the last `replace`/`replace_within`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo right after a bad diff — review the diff's `-anchor│` rows first to confirm what you're restoring.
2
2
  - `undo_last_change`: a cross-file `move` records one undo entry per file; undo both the source and the destination to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
@@ -1 +1 @@
1
- Single-level undo: reverts a file's last `replace`, `insert`, `copy`, or `move`
1
+ Single-level undo: reverts a file's last `replace`, `replace_within`, `insert`, `copy`, or `move`
@@ -1 +1 @@
1
- Undo the last replace, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
1
+ Undo the last replace, replace_within, insert, copy, or move on a file, restoring the previous content, BOM, and line endings. Use it when an edit removed or changed the wrong lines. If the file changed since that edit, the undo is refused with `[E_UNDO_STALE]` and nothing is modified: the record is kept, so verify the current state with read and stop instead of forcing the undo. A file deleted since the edit is restored. If the output says truncated, use read to see the full file.
package/src/batch.ts CHANGED
@@ -543,13 +543,31 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
543
543
 
544
544
  function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
545
545
  const lines = splitLines(baseContent);
546
+ const baseLineCount = lines.length;
546
547
  const descending = [...pieces].sort((a, b) => b.start - a.start);
547
548
  for (const piece of descending) lines.splice(piece.start - 1, piece.end - piece.start + 1, ...piece.newLines);
548
549
  let composed = lines.join("\n");
549
- if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) composed += "\n";
550
+ if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) {
551
+ composed += "\n";
552
+ } else if (lines.length > 0) {
553
+ const trailing = pieces.reduce<BatchPiece | undefined>((best, piece) => (best === undefined || piece.end > best.end ? piece : best), undefined);
554
+ const lastEnding = trailing?.separators?.[trailing.newLines.length - 1];
555
+ if (trailing !== undefined && trailing.end === baseLineCount && lastEnding !== undefined) composed += "\n";
556
+ }
550
557
  return composed;
551
558
  }
552
559
 
560
+ function changesEnding(piece: BatchPiece, baseSeparators: LineEnding[]): boolean {
561
+ if (piece.separators === undefined) return false;
562
+ for (let index = 0; index < piece.separators.length; index++) {
563
+ const ending = piece.separators[index];
564
+ if (ending === undefined) continue;
565
+ const baseIndex = piece.start - 1 + index;
566
+ if (baseIndex >= baseSeparators.length || baseSeparators[baseIndex] !== ending) return true;
567
+ }
568
+ return false;
569
+ }
570
+
553
571
  function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
554
572
  const byAnchor = new Map<number, BatchPiece[]>();
555
573
  for (const piece of pieces) {
@@ -624,11 +642,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
624
642
  }
625
643
  }
626
644
  const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
627
- if (appliedPieces.length === 0) {
645
+ const candidatePieces = runtime.pieces.filter((piece) => !piece.noop || changesEnding(piece, base.separators));
646
+ if (candidatePieces.length === 0) {
628
647
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
629
648
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
630
649
  }
631
- const effectivePieces = mergeInsertPairs(appliedPieces);
650
+ const effectivePieces = mergeInsertPairs(candidatePieces);
632
651
  const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
633
652
  for (let i = 1; i < ordered.length; i++) {
634
653
  const prev = ordered[i - 1]!;
@@ -649,11 +668,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
649
668
  })));
650
669
  const warnings = [...runtime.warnings];
651
670
  if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
671
+ const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
672
+ const originalBytes = base.bom + joinSeparators(base.content, base.separators);
652
673
  try {
653
674
  await throwIfStrictInput(dedupeWarnings(warnings));
654
675
  assertNotEmpty(base.content, composed);
655
676
  assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
656
- const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
657
677
  assertByteLimit(finalBytes, paths.displayPath);
658
678
  } catch (error) {
659
679
  discardBatchState(runtime);
@@ -662,7 +682,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
662
682
  }
663
683
  const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
664
684
  if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
665
- if (composed === base.content) {
685
+ if (finalBytes === originalBytes) {
666
686
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
667
687
  return combinedNoop(paths.displayPath, member, runtime, snapshotId);
668
688
  }
@@ -756,8 +776,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
756
776
  batchVerb(runtime),
757
777
  await getDiffContextLines(),
758
778
  );
759
- changed.details.diff = `${header}\n${changed.details.diff}`;
760
- changed.details.diffLineNumbers?.unshift(null);
779
+ if (changed.details.diff.length > 0) {
780
+ changed.details.diff = `${header}\n${changed.details.diff}`;
781
+ changed.details.diffLineNumbers?.unshift(null);
782
+ }
761
783
  try {
762
784
  serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
763
785
  } catch (error) {
package/src/commit.ts CHANGED
@@ -33,7 +33,22 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
33
33
  const readReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
34
34
  if (readReclaim !== undefined) warnings.push(readReclaim);
35
35
 
36
- if (pipe.result === pipe.originalNormalized) {
36
+ const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
37
+ if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
38
+ const resultSeparators = span
39
+ ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
40
+ : undefined;
41
+ if (resultSeparators !== undefined && span !== undefined) {
42
+ applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
43
+ applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
44
+ }
45
+ const finalFileBytes = pipe.bom + (resultSeparators !== undefined
46
+ ? joinSeparators(pipe.result, resultSeparators)
47
+ : restoreEndings(pipe.result, pipe.originalEnding));
48
+ const contentUnchanged = pipe.result === pipe.originalNormalized;
49
+ const bytesUnchanged = contentUnchanged && (span === undefined || finalFileBytes === pipe.bom + joinSeparators(pipe.originalNormalized, pipe.originalSeparators));
50
+
51
+ if (bytesUnchanged) {
37
52
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
38
53
  return buildNoop(
39
54
  {
@@ -52,18 +67,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
52
67
  );
53
68
  }
54
69
 
55
- const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
56
- if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
57
- const resultSeparators = span
58
- ? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
59
- : undefined;
60
- if (resultSeparators !== undefined && span !== undefined) {
61
- applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
62
- applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
63
- }
64
- const finalFileBytes = pipe.bom + (resultSeparators !== undefined
65
- ? joinSeparators(pipe.result, resultSeparators)
66
- : restoreEndings(pipe.result, pipe.originalEnding));
67
70
  assertByteLimit(finalFileBytes, path);
68
71
 
69
72
  if (pipe.hadUtf8DecodeErrors) {
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceWithinEnabled" | "requirePath" | "strictInput" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -26,6 +26,7 @@ export function configRows(config: Config): ConfigRow[] {
26
26
  { key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
27
27
  { key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
28
28
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
29
+ { key: "replaceWithinEnabled", label: "Replace within", hint: "replace_within tool (off while disabled)", enabled: config.replaceWithinEnabled !== false },
29
30
  ];
30
31
  }
31
32