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 +43 -25
- package/index.ts +15 -2
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +1 -1
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -1
- package/prompts/replace-guidelines.md +2 -4
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace-within-guidelines.md +3 -0
- package/prompts/replace-within-snippet.md +1 -0
- package/prompts/replace-within.md +5 -0
- package/prompts/replace.md +3 -2
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +29 -7
- package/src/commit.ts +16 -13
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +6 -0
- package/src/copy-move.ts +15 -11
- package/src/edit-common.ts +66 -15
- package/src/edit-fidelity.ts +108 -0
- package/src/grep.ts +6 -5
- package/src/hashline/apply.ts +7 -5
- package/src/hashline/index.ts +2 -0
- package/src/hashline/parse.ts +15 -4
- package/src/hashline/resolve.ts +67 -38
- package/src/insert.ts +21 -34
- package/src/payload-contract.ts +155 -28
- package/src/replace-render.ts +4 -4
- package/src/replace-response.ts +3 -1
- package/src/replace-within.ts +227 -0
- package/src/replace.ts +26 -15
- package/src/utils.ts +28 -14
- package/prompts/grep-guidelines.md +0 -2
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":
|
|
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":
|
|
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
|
|
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` |
|
|
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":
|
|
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
|
|
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
|
|
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` |
|
|
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:
|
|
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":
|
|
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 `
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
419
|
-
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example
|
|
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`
|
|
424
|
-
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines`
|
|
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
|
|
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": "
|
|
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
|
|
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
|
|
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
|
|
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`:
|
|
2
|
-
- `replace`:
|
|
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`,
|
|
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.
|
package/prompts/replace.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file
|
|
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":
|
|
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] === ""))
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
760
|
-
|
|
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
|
-
|
|
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
|
|