pi-hashline-edit-pro 4.4.4 → 4.5.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 +44 -11
- package/index.ts +19 -4
- package/package.json +4 -3
- package/prompts/copy-guidelines.md +1 -0
- package/prompts/copy-snippet.md +1 -0
- package/prompts/copy.md +3 -0
- package/prompts/grep.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +1 -0
- package/prompts/move-snippet.md +1 -0
- package/prompts/move.md +3 -0
- package/prompts/replace-guidelines.md +0 -2
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +28 -6
- package/src/commit.ts +17 -4
- package/src/config-ui.ts +2 -1
- package/src/config.ts +6 -1
- package/src/constants.ts +1 -1
- package/src/copy-move.ts +793 -0
- package/src/edit-common.ts +19 -0
- package/src/file-reader.ts +4 -0
- package/src/hash-store/validation.ts +22 -0
- package/src/hash-store.ts +32 -4
- package/src/hashline/apply.ts +8 -3
- package/src/hashline/index.ts +3 -0
- package/src/hashline/parse.ts +22 -10
- package/src/hashline/resolve.ts +23 -8
- package/src/insert.ts +28 -10
- package/src/line-endings.ts +151 -0
- package/src/paths.ts +18 -2
- package/src/payload-contract.ts +29 -1
- package/src/replace-render.ts +4 -4
- package/src/replace-undo.ts +9 -2
- package/src/replace.ts +17 -5
- package/src/utils.ts +40 -5
package/README.md
CHANGED
|
@@ -31,6 +31,8 @@ the result is the post-edit diff with fresh anchors, so the next edit needs no r
|
|
|
31
31
|
- [read](#read)
|
|
32
32
|
- [replace](#replace)
|
|
33
33
|
- [insert](#insert)
|
|
34
|
+
- [copy](#copy)
|
|
35
|
+
- [move](#move)
|
|
34
36
|
- [anchor_grep](#anchor_grep)
|
|
35
37
|
- [undo_last_change](#undo_last_change)
|
|
36
38
|
- [Batching](#batching)
|
|
@@ -86,6 +88,7 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
86
88
|
| `read` | overridden, returns `anchor│content` rows |
|
|
87
89
|
| `edit` | disabled |
|
|
88
90
|
| `grep` | disabled while `anchor_grep` is enabled |
|
|
91
|
+
| `copy`, `move` | disabled while Copy/move is off |
|
|
89
92
|
| `write` | kept; an auto-read block with fresh anchors is appended to its result |
|
|
90
93
|
| `bash` | untouched |
|
|
91
94
|
|
|
@@ -150,7 +153,7 @@ Nothing commits until an edit call returns: the extension validates the request
|
|
|
150
153
|
|
|
151
154
|
## Tools
|
|
152
155
|
|
|
153
|
-
The extension registers
|
|
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.
|
|
154
157
|
|
|
155
158
|
### read
|
|
156
159
|
|
|
@@ -183,7 +186,7 @@ Edge cases:
|
|
|
183
186
|
| --- | --- |
|
|
184
187
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
185
188
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
186
|
-
| `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.
|
|
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; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
|
|
187
190
|
|
|
188
191
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
189
192
|
|
|
@@ -217,7 +220,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
217
220
|
| --- | --- |
|
|
218
221
|
| `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. |
|
|
219
222
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
220
|
-
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line
|
|
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; escape sequences such as `\n` or `\u200b` are not decoded. A lone string is split on newlines, and stringified array text is unwrapped. |
|
|
221
224
|
|
|
222
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.
|
|
223
226
|
|
|
@@ -229,6 +232,30 @@ Example: add a line after `Emno│`:
|
|
|
229
232
|
|
|
230
233
|
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.
|
|
231
234
|
|
|
235
|
+
### copy
|
|
236
|
+
|
|
237
|
+
`copy` duplicates a range of lines to another position without removing the source. `source_from` and `source_to` select lines in the source file; `insert_after` selects the destination line, and it may live in a different file. An empty destination file is seeded with the copied lines. It is a served-anchor edit like `replace` and `insert`: the source lines and the destination anchor line must come from rows you were shown, and the request is refused if they changed on disk or were never served. With `requirePath` on, `path` must match the source or the destination file.
|
|
238
|
+
|
|
239
|
+
| Field | Description |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `source_from` | 4-char anchor marking the FIRST source line to copy (inclusive). |
|
|
242
|
+
| `source_to` | 4-char anchor marking the LAST source line to copy (inclusive). |
|
|
243
|
+
| `insert_after` | 4-char anchor of the destination line after which the copy goes. Within one file it must sit outside the source range; passing `source_to` duplicates the range right after itself. |
|
|
244
|
+
|
|
245
|
+
Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`; to copy `old` into `b.ts` below `top`:
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{ "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The source lines stay in place and keep their anchors; the copied lines are minted fresh anchors in the destination's post-edit diff and keep their source line endings. A cross-file copy writes only the destination, so one `undo_last_change` on it reverts the copy.
|
|
252
|
+
|
|
253
|
+
### move
|
|
254
|
+
|
|
255
|
+
`move` relocates a range of lines in one call: the range is removed from the source file and written after `insert_after`, which may live in a different file. It takes the same fields as `copy`, and an empty destination file is seeded with the moved lines. Within one file, `insert_after` must sit outside the source range, and moving a range to where it already sits reports `No changes made` and leaves the anchors alone. Lines between the source and the target keep their content but may be re-anchored; the moved lines keep their source line endings, and a cross-file move that removes every source line leaves the source file empty.
|
|
256
|
+
|
|
257
|
+
The same safety machinery as `replace` applies to both tools: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive. A cross-file `move` writes two files and records one undo entry per file; undo both sides to revert the whole move, because undoing one side alone leaves the moved lines duplicated or missing.
|
|
258
|
+
|
|
232
259
|
### anchor_grep
|
|
233
260
|
|
|
234
261
|
`anchor_grep` is an anchored search backed by ripgrep. It is enabled by default; disable it in `/hashline-config` (or set `anchorGrepEnabled` to `false` in the config file). While it is enabled, the built-in grep is disabled. Disabling it removes the tool and restores the built-in grep only if that was active before the extension loaded.
|
|
@@ -253,11 +280,12 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
253
280
|
|
|
254
281
|
### undo_last_change
|
|
255
282
|
|
|
256
|
-
`undo_last_change` reverts the most recent successful `replace` or `
|
|
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.
|
|
257
284
|
|
|
258
|
-
- History is per-file and single-level: only the most recent `replace` or `
|
|
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.
|
|
259
286
|
- History is persisted and survives session restarts. A failed `write` does not clear it.
|
|
260
|
-
- Every applied `replace` or `
|
|
287
|
+
- Every applied `replace`, `insert`, `copy`, or `move` is undoable; the undo record is saved before the edit is written.
|
|
288
|
+
- 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.
|
|
261
289
|
- A successful `write` clears the history for that file.
|
|
262
290
|
- 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.
|
|
263
291
|
- If the file was deleted since the last edit, `undo_last_change` restores it from the recorded pre-edit content.
|
|
@@ -269,6 +297,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
269
297
|
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.
|
|
270
298
|
|
|
271
299
|
- 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.
|
|
272
301
|
- 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.
|
|
273
302
|
- If a batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits until the last call succeeds.
|
|
274
303
|
- A batch member accepts the same request shapes and auto-fixes as a standalone call.
|
|
@@ -283,7 +312,7 @@ The hashline tools are sequential in pi, so a message that contains one runs all
|
|
|
283
312
|
|
|
284
313
|
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.
|
|
285
314
|
|
|
286
|
-
After `replace`, `insert`, 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.
|
|
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.
|
|
287
316
|
|
|
288
317
|
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.
|
|
289
318
|
|
|
@@ -303,7 +332,7 @@ The setting lives in `/hashline-config` as Auto-read all and in `config.json` as
|
|
|
303
332
|
|
|
304
333
|
| Command | Description |
|
|
305
334
|
| --- | --- |
|
|
306
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, and strict input. Persists across sessions. |
|
|
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. |
|
|
307
336
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
308
337
|
|
|
309
338
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -314,6 +343,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
314
343
|
"autoReadAll": "off",
|
|
315
344
|
"autoReadAllIgnore": [],
|
|
316
345
|
"anchorGrepEnabled": true,
|
|
346
|
+
"copyMoveEnabled": true,
|
|
317
347
|
"requirePath": false,
|
|
318
348
|
"strictInput": false,
|
|
319
349
|
"diffContextLines": 1
|
|
@@ -326,6 +356,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
326
356
|
| `autoReadAll` | Auto-read all | `"off"` | Attachment mode: `"off"`, `"on"`, or `"git"`. |
|
|
327
357
|
| `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
|
|
328
358
|
| `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
|
|
359
|
+
| `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
|
|
329
360
|
| `requirePath` | Require path | `false` | `replace` and `insert` require a `path` argument that must match anchor ownership. |
|
|
330
361
|
| `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. |
|
|
331
362
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
@@ -354,12 +385,13 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
354
385
|
|
|
355
386
|
## Tool result details
|
|
356
387
|
|
|
357
|
-
All
|
|
388
|
+
All seven tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
358
389
|
|
|
359
390
|
| Tool | `details` |
|
|
360
391
|
| --- | --- |
|
|
361
392
|
| `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`. |
|
|
362
393
|
| `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), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
|
|
394
|
+
| `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. |
|
|
363
395
|
| `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
|
|
364
396
|
| `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). |
|
|
365
397
|
|
|
@@ -390,8 +422,9 @@ Full reference:
|
|
|
390
422
|
| `[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. |
|
|
391
423
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
392
424
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
425
|
+
| `[W_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escape text such as `\u200b` or `\n`; the file receives those characters as written. Decode the escapes first if the text came from a quoted prompt. |
|
|
393
426
|
| `[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. |
|
|
394
|
-
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
427
|
+
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
|
|
395
428
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
396
429
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
397
430
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
@@ -473,7 +506,7 @@ Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline
|
|
|
473
506
|
|
|
474
507
|
## Benchmark
|
|
475
508
|
|
|
476
|
-
pi-hashline-edit-pro is measured on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party benchmark maintained by [alexshpunt](https://github.com/alexshpunt). The suite has 226 deterministic, byte-exact editing tasks
|
|
509
|
+
pi-hashline-edit-pro is measured on the [Explicit Edit Benchmark](https://github.com/alexshpunt/explicit-edit-benchmark), an open third-party benchmark maintained by [alexshpunt](https://github.com/alexshpunt). The suite has 226 deterministic, byte-exact editing tasks across many edit shapes and file types. A verifier compares the result file tree byte by byte, so nothing is graded on compilation or behavioral equivalence. Many harnesses are scored on it.
|
|
477
510
|
|
|
478
511
|
The current release scores 98.7% in the explorer's harness-family view for `pi-hashline-edit-pro`, with 99.6% or better final exactness on every model route.
|
|
479
512
|
|
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-
|
|
|
3
3
|
import { initHasher } from "./src/hashline";
|
|
4
4
|
import { regReplace } from "./src/replace";
|
|
5
5
|
import { regInsert } from "./src/insert";
|
|
6
|
+
import { regCopy, regMove } from "./src/copy-move";
|
|
6
7
|
import { regGrep } from "./src/grep";
|
|
7
8
|
import { regUndo, clearUndo } from "./src/replace-undo";
|
|
8
9
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
toggleAutoRead,
|
|
19
20
|
cycleAutoReadAllMode,
|
|
20
21
|
toggleAnchorGrep,
|
|
22
|
+
toggleCopyMove,
|
|
21
23
|
toggleRequirePath,
|
|
22
24
|
toggleStrictInput,
|
|
23
25
|
adjustDiffContextLines,
|
|
@@ -42,6 +44,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
42
44
|
|
|
43
45
|
regReplace(pi);
|
|
44
46
|
regInsert(pi);
|
|
47
|
+
regCopy(pi);
|
|
48
|
+
regMove(pi);
|
|
45
49
|
regGrep(pi);
|
|
46
50
|
regUndo(pi);
|
|
47
51
|
registerWriteHook(pi);
|
|
@@ -58,6 +62,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
58
62
|
regRead(pi, flags);
|
|
59
63
|
regReplace(pi, flags);
|
|
60
64
|
regInsert(pi, flags);
|
|
65
|
+
regCopy(pi, flags);
|
|
66
|
+
regMove(pi, flags);
|
|
61
67
|
regUndo(pi, flags);
|
|
62
68
|
} catch (error) {
|
|
63
69
|
console.error("Failed to refresh edit tools:", error);
|
|
@@ -90,9 +96,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
90
96
|
autoReadAllInjected = sessionBranch.some((entry) => entry.type === "custom_message" && entry.customType === AUTO_READ_ALL_CUSTOM_TYPE);
|
|
91
97
|
await refreshEditTools();
|
|
92
98
|
pi.setActiveTools(
|
|
93
|
-
pi.getActiveTools().filter((t) =>
|
|
94
|
-
config.anchorGrepEnabled ? t
|
|
95
|
-
|
|
99
|
+
pi.getActiveTools().filter((t) => {
|
|
100
|
+
if (config.anchorGrepEnabled ? t === "grep" : t === "anchor_grep") return false;
|
|
101
|
+
if (config.copyMoveEnabled === false && (t === "copy" || t === "move")) return false;
|
|
102
|
+
return true;
|
|
103
|
+
}),
|
|
96
104
|
);
|
|
97
105
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
98
106
|
if (debugValue === "1" || debugValue === "true") {
|
|
@@ -125,7 +133,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
125
133
|
}));
|
|
126
134
|
|
|
127
135
|
pi.registerCommand("hashline-config", {
|
|
128
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input)",
|
|
136
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, copy/move, path, strict input)",
|
|
129
137
|
handler: async (_args, ctx) => {
|
|
130
138
|
if (!ctx.hasUI) {
|
|
131
139
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -146,6 +154,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
146
154
|
const active = pi.getActiveTools();
|
|
147
155
|
pi.setActiveTools(enabled ? [...new Set([...active.filter((t) => t !== "grep"), "anchor_grep"])] : [...new Set([...active.filter((t) => t !== "anchor_grep"), ...(grepWasActive ? ["grep"] : [])])]);
|
|
148
156
|
}
|
|
157
|
+
else if (key === "copyMoveEnabled") {
|
|
158
|
+
const enabled = await toggleCopyMove();
|
|
159
|
+
const active = pi.getActiveTools();
|
|
160
|
+
pi.setActiveTools(enabled ? [...new Set([...active, "copy", "move"])] : active.filter((t) => t !== "copy" && t !== "move"));
|
|
161
|
+
}
|
|
149
162
|
else if (key === "requirePath") await toggleRequirePath();
|
|
150
163
|
else if (key === "strictInput") await toggleStrictInput();
|
|
151
164
|
await refreshEditTools();
|
|
@@ -239,6 +252,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
239
252
|
if (
|
|
240
253
|
event.toolName !== "replace" &&
|
|
241
254
|
event.toolName !== "insert" &&
|
|
255
|
+
event.toolName !== "copy" &&
|
|
256
|
+
event.toolName !== "move" &&
|
|
242
257
|
event.toolName !== "undo_last_change"
|
|
243
258
|
) return;
|
|
244
259
|
if (!autoRead) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.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",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"diff": "^9.0.0",
|
|
43
43
|
"file-type": "^22.0.2",
|
|
44
|
-
"typebox": "^1.3.7",
|
|
45
44
|
"xxhash-wasm": "^1.1.0"
|
|
46
45
|
},
|
|
47
46
|
"peerDependencies": {
|
|
48
47
|
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
49
|
-
"@earendil-works/pi-tui": "*"
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": ">=22.19.0"
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@types/node": "^24",
|
|
66
66
|
"@vitest/coverage-v8": "^4.1.11",
|
|
67
67
|
"eslint": "^10.9.1",
|
|
68
|
+
"typebox": "^1.3.7",
|
|
68
69
|
"typescript": "^5.9.3",
|
|
69
70
|
"typescript-eslint": "^8.68.0",
|
|
70
71
|
"vitest": "^4.1.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- `copy`: the same anchor in `source_from` and `source_to` copies one line; copied lines get fresh anchors in the post-edit diff and the source rows keep theirs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copy lines to another position by anchor: `source_from`/`source_to` mark the range, the copy lands after `insert_after`; the anchors resolve both files
|
package/prompts/copy.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
Copy a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to copy in the source file, and `insert_after` as the bare anchor of the destination line after which the copy goes. `source_from` and `source_to` may belong to a different file than `insert_after`: the lines are copied from the source file and inserted into the destination file, and an empty destination file is seeded with the copied lines. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
|
|
2
|
+
|
|
3
|
+
Example: read served `Hasu│old` in `a.ts` and `Qwer│top` in `b.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } copies `old` from `a.ts` into `b.ts` below `top`; the source `Hasu│old` row stays live.
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace or
|
|
1
|
+
Search text files with ripgrep. Hits and context lines come back as `lineNumber │ anchor│content` rows, editable with replace, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Searches respect `.gitignore`, always skip `.git`, and skip binary and image files silently. A match over 500 bytes is shown as a `...` fragment around the hit, but its anchor still covers the whole line. When cut by match limit, raise `limit` (default 100); when cut by rows/bytes, refine `pattern` / narrow path.
|
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
|
|
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; escape sequences such as `\n` or `\u200b` are not decoded. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
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.
|
|
@@ -0,0 +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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Move lines to another position by anchor: `source_from`/`source_to` mark the range, it lands after `insert_after`; the anchors resolve both files
|
package/prompts/move.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
Move a range of lines to another position, targeted by 4-character anchors from any served anchor│content row. Give `source_from` and `source_to` as bare anchors marking the first and last line to move in the source file, and `insert_after` as the bare anchor of the destination line after which the range goes. `source_from` and `source_to` may belong to a different file than `insert_after`: the range is removed from the source file and inserted into the destination file in one call, and an empty destination file is seeded with the moved lines. `insert_after` must sit outside the source range when both anchors share a file; moving a range in one file to where it already sits reports no change.
|
|
2
|
+
|
|
3
|
+
Example: read served `Hasu│a` in `lib.ts` and `Qwer│top` in `app.ts`. Call { "source_from": "Hasu", "source_to": "Hasu", "insert_after": "Qwer" } moves `a` from `lib.ts` into `app.ts` below `top`.
|
|
@@ -1,6 +1,4 @@
|
|
|
1
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
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`: `[E_STALE_ANCHOR]` means the anchor is not owned in this session: call `read` on the file for fresh anchors. `[E_RANGE_STALE]` means a line in the range changed since it was shown, or was never shown: retry with the fresh anchors the error returns.
|
|
4
|
-
- `replace`: `[E_BATCH_OVERLAP]` means two batch calls share lines: retry with disjoint ranges. `[E_OP_ABORTED]` means another batch call failed or the file changed: fix that call, then retry, or call `read` for fresh anchors.
|
|
5
3
|
- `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
|
|
6
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`.
|
package/prompts/replace.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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 and
|
|
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; escape sequences such as `\n` or `\u200b` are not decoded. `[]` deletes the range.
|
|
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.
|
|
4
4
|
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
- `undo_last_change`: only the last `replace`/`insert` 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`/`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
|
+
- `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` or `
|
|
1
|
+
Single-level undo: reverts a file's last `replace`, `insert`, `copy`, or `move`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo the last replace or
|
|
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.
|
package/src/batch.ts
CHANGED
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
MAX_HASH_LINES,
|
|
16
16
|
type HEdit,
|
|
17
17
|
type PlannedEdit,
|
|
18
|
+
type StripWarningLocation,
|
|
18
19
|
} from "./hashline";
|
|
19
20
|
import { adoptAnchors, servedForPath, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
20
|
-
import {
|
|
21
|
+
import { stripBOM, toLF, type LineEnding } from "./normalize";
|
|
22
|
+
import { applySpanEndings, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
21
23
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
22
24
|
import { saveUndo } from "./replace-undo";
|
|
23
25
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
@@ -44,6 +46,7 @@ interface BatchBase {
|
|
|
44
46
|
hashes: string[];
|
|
45
47
|
bom: string;
|
|
46
48
|
ending: LineEnding;
|
|
49
|
+
separators: LineEnding[];
|
|
47
50
|
identity: FileIdentity;
|
|
48
51
|
hadUtf8DecodeErrors: boolean;
|
|
49
52
|
absolutePath: string;
|
|
@@ -64,6 +67,7 @@ export interface BatchPiece {
|
|
|
64
67
|
noop: boolean;
|
|
65
68
|
foldedLines: number;
|
|
66
69
|
carryIndex?: number;
|
|
70
|
+
separators?: (LineEnding | undefined)[];
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
export interface BatchMemberInput {
|
|
@@ -77,6 +81,8 @@ export interface BatchMemberInput {
|
|
|
77
81
|
hedit: HEdit;
|
|
78
82
|
extraWarnings: string[];
|
|
79
83
|
foldedLines?: number;
|
|
84
|
+
stripWarning?: StripWarningLocation;
|
|
85
|
+
contentSeparators?: (LineEnding | undefined)[];
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
interface BatchFailure {
|
|
@@ -434,6 +440,7 @@ export async function ensureBatchBase(input: {
|
|
|
434
440
|
hashes: file.fileHashes.slice(),
|
|
435
441
|
bom: file.bom,
|
|
436
442
|
ending: file.originalEnding,
|
|
443
|
+
separators: file.endingSeparators,
|
|
437
444
|
identity: file.identity,
|
|
438
445
|
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors,
|
|
439
446
|
absolutePath: file.absolutePath,
|
|
@@ -482,6 +489,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
482
489
|
servedHashes: runtime.served,
|
|
483
490
|
signal: input.signal,
|
|
484
491
|
baseFileLines: base.baseLines,
|
|
492
|
+
stripWarning: input.stripWarning,
|
|
485
493
|
});
|
|
486
494
|
} catch (error) {
|
|
487
495
|
if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
|
|
@@ -497,6 +505,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
497
505
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
498
506
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
499
507
|
const foldedLines = input.foldedLines ?? 0;
|
|
508
|
+
const separators = input.contentSeparators ?? input.hedit.content_separators;
|
|
500
509
|
const carryIndex =
|
|
501
510
|
input.kind === "insert" && foldedLines > 0
|
|
502
511
|
? input.direction === "after"
|
|
@@ -513,6 +522,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
513
522
|
fromHash: input.hedit.hash_bounds[0].hash,
|
|
514
523
|
toHash: input.hedit.hash_bounds[1].hash,
|
|
515
524
|
newLines: [...newLines],
|
|
525
|
+
...(separators !== undefined ? { separators } : {}),
|
|
516
526
|
warnings: [...input.extraWarnings, ...planned.warnings],
|
|
517
527
|
noop,
|
|
518
528
|
foldedLines,
|
|
@@ -569,12 +579,16 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
|
|
|
569
579
|
consumed.add(partner);
|
|
570
580
|
const before = piece.direction === "before" ? piece : partner;
|
|
571
581
|
const after = piece.direction === "before" ? partner : piece;
|
|
582
|
+
const beforeSeparators = before.separators ?? before.newLines.map(() => undefined);
|
|
583
|
+
const afterSeparators = after.separators ?? after.newLines.map(() => undefined);
|
|
584
|
+
const mergedSeparators = [...beforeSeparators.slice(0, before.newLines.length), ...afterSeparators.slice(1)];
|
|
572
585
|
merged.push({
|
|
573
586
|
...before,
|
|
574
587
|
order: Math.min(before.order, after.order),
|
|
575
588
|
newLines: [...before.newLines, ...after.newLines.slice(1)],
|
|
576
589
|
warnings: [...before.warnings, ...after.warnings],
|
|
577
590
|
foldedLines: before.foldedLines + after.foldedLines - 1,
|
|
591
|
+
...(before.separators !== undefined || after.separators !== undefined ? { separators: mergedSeparators } : {}),
|
|
578
592
|
});
|
|
579
593
|
}
|
|
580
594
|
return merged;
|
|
@@ -625,13 +639,21 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
625
639
|
}
|
|
626
640
|
}
|
|
627
641
|
const composed = composeBatchLines(base.content, effectivePieces);
|
|
642
|
+
const spans = pieceMappingSpans(effectivePieces);
|
|
643
|
+
const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending);
|
|
644
|
+
applySpanEndings(resultSeparators, effectivePieces.map((piece) => ({
|
|
645
|
+
start: piece.start - 1,
|
|
646
|
+
end: piece.end - 1,
|
|
647
|
+
replacementCount: piece.newLines.length,
|
|
648
|
+
...(piece.separators !== undefined ? { endings: piece.separators } : {}),
|
|
649
|
+
})));
|
|
628
650
|
const warnings = [...runtime.warnings];
|
|
629
651
|
if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
630
652
|
try {
|
|
631
653
|
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
632
654
|
assertNotEmpty(base.content, composed);
|
|
633
655
|
assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
|
|
634
|
-
const finalBytes = base.bom +
|
|
656
|
+
const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
|
|
635
657
|
assertByteLimit(finalBytes, paths.displayPath);
|
|
636
658
|
} catch (error) {
|
|
637
659
|
discardBatchState(runtime);
|
|
@@ -657,12 +679,11 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
657
679
|
discardBatchState(runtime);
|
|
658
680
|
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
|
|
659
681
|
}
|
|
660
|
-
const preflightSpans = pieceMappingSpans(effectivePieces);
|
|
661
682
|
try {
|
|
662
683
|
await lineHashes(composed, runtime.target, {
|
|
663
684
|
content: base.content,
|
|
664
685
|
hashes: base.hashes,
|
|
665
|
-
spans
|
|
686
|
+
spans,
|
|
666
687
|
}, undefined, false, true);
|
|
667
688
|
} catch (error) {
|
|
668
689
|
discardBatchState(runtime);
|
|
@@ -673,8 +694,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
673
694
|
content: base.content,
|
|
674
695
|
bom: base.bom,
|
|
675
696
|
originalEnding: base.ending,
|
|
697
|
+
separators: base.separators,
|
|
676
698
|
hashes: base.hashes,
|
|
677
699
|
resultContent: composed,
|
|
700
|
+
resultSeparators,
|
|
678
701
|
});
|
|
679
702
|
if (!undo.persisted) {
|
|
680
703
|
discardBatchState(runtime);
|
|
@@ -682,7 +705,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
682
705
|
}
|
|
683
706
|
try {
|
|
684
707
|
abortIf(signal);
|
|
685
|
-
await writeAtomic(paths.absolutePath, base.bom +
|
|
708
|
+
await writeAtomic(paths.absolutePath, base.bom + joinSeparators(composed, resultSeparators), base.identity);
|
|
686
709
|
} catch (error) {
|
|
687
710
|
await undo.restore();
|
|
688
711
|
discardBatchState(runtime);
|
|
@@ -690,7 +713,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
690
713
|
throw error;
|
|
691
714
|
}
|
|
692
715
|
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
693
|
-
const spans = pieceMappingSpans(effectivePieces);
|
|
694
716
|
let resultHashes: string[];
|
|
695
717
|
try {
|
|
696
718
|
resultHashes = await lineHashes(composed, runtime.target, {
|
package/src/commit.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry
|
|
|
10
10
|
import { servedHashesFromDiff, serveRows } from "./served";
|
|
11
11
|
import { lineHashes } from "./hashline";
|
|
12
12
|
import { spanForEdit } from "./replace";
|
|
13
|
-
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
13
|
+
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
14
|
+
import { applyEndingOverrides, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
14
15
|
export interface CommitMeta {
|
|
15
16
|
editAnchors?: [string, string];
|
|
16
17
|
anchorCarry?: number;
|
|
@@ -22,6 +23,7 @@ export interface CommitMeta {
|
|
|
22
23
|
noopNoun?: string;
|
|
23
24
|
prefixWarnings?: string[];
|
|
24
25
|
foldedAnchorLines?: number;
|
|
26
|
+
endingOverrides?: (LineEnding | undefined)[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
|
|
@@ -50,7 +52,18 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
50
52
|
);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
const
|
|
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));
|
|
54
67
|
assertByteLimit(finalFileBytes, path);
|
|
55
68
|
|
|
56
69
|
if (pipe.hadUtf8DecodeErrors) {
|
|
@@ -80,8 +93,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
80
93
|
content: pipe.originalNormalized,
|
|
81
94
|
bom: pipe.bom,
|
|
82
95
|
originalEnding: pipe.originalEnding,
|
|
96
|
+
separators: pipe.originalSeparators,
|
|
83
97
|
hashes: pipe.originalHashes,
|
|
84
98
|
resultContent: pipe.result,
|
|
99
|
+
...(resultSeparators !== undefined ? { resultSeparators } : {}),
|
|
85
100
|
});
|
|
86
101
|
if (!undo.persisted) {
|
|
87
102
|
throw new Error(
|
|
@@ -110,8 +125,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
110
125
|
removedLines: pipe.totalRemovedLines,
|
|
111
126
|
};
|
|
112
127
|
|
|
113
|
-
const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
|
|
114
|
-
if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
|
|
115
128
|
let resultHashes: string[];
|
|
116
129
|
try {
|
|
117
130
|
resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
|
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" | "requirePath" | "strictInput" | "diffContextLines";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -23,6 +23,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
23
23
|
{ key: "autoReadAllIgnore", label: "Ignore folders/files", hint: "Extra folders, files, or globs skipped by auto-read all (comma-separated)", enabled: (config.autoReadAllIgnore ?? []).length > 0, folders: config.autoReadAllIgnore ?? [] },
|
|
24
24
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
25
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
26
|
+
{ key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
|
|
26
27
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
|
27
28
|
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
28
29
|
];
|
package/src/config.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const MAX_DIFF_CONTEXT_LINES = 10;
|
|
|
13
13
|
export interface Config {
|
|
14
14
|
autoRead: boolean;
|
|
15
15
|
anchorGrepEnabled: boolean;
|
|
16
|
+
copyMoveEnabled?: boolean;
|
|
16
17
|
autoReadAll?: AutoReadAllMode;
|
|
17
18
|
autoReadAllIgnore?: string[];
|
|
18
19
|
requirePath?: boolean;
|
|
@@ -23,6 +24,7 @@ export interface Config {
|
|
|
23
24
|
const DEFAULT_CONFIG: Config = {
|
|
24
25
|
autoRead: true,
|
|
25
26
|
anchorGrepEnabled: true,
|
|
27
|
+
copyMoveEnabled: true,
|
|
26
28
|
autoReadAll: "off",
|
|
27
29
|
autoReadAllIgnore: [],
|
|
28
30
|
requirePath: false,
|
|
@@ -72,6 +74,7 @@ function parseConfig(content: string): Config {
|
|
|
72
74
|
}
|
|
73
75
|
const autoRead = parsed.autoRead;
|
|
74
76
|
const anchorGrepEnabled = parsed.anchorGrepEnabled;
|
|
77
|
+
const copyMoveEnabled = parsed.copyMoveEnabled;
|
|
75
78
|
const autoReadAll = parsed.autoReadAll;
|
|
76
79
|
const requirePath = parsed.requirePath;
|
|
77
80
|
const strictInput = parsed.strictInput;
|
|
@@ -80,6 +83,7 @@ function parseConfig(content: string): Config {
|
|
|
80
83
|
return {
|
|
81
84
|
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
82
85
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
86
|
+
copyMoveEnabled: typeof copyMoveEnabled === "boolean" ? copyMoveEnabled : DEFAULT_CONFIG.copyMoveEnabled,
|
|
83
87
|
autoReadAll: parseAutoReadAllMode(autoReadAll),
|
|
84
88
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
85
89
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
@@ -192,7 +196,7 @@ export async function writeConfig(config: Config): Promise<void> {
|
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
|
|
195
|
-
type ToggleKey = "autoRead" | "anchorGrepEnabled" | "requirePath" | "strictInput";
|
|
199
|
+
type ToggleKey = "autoRead" | "anchorGrepEnabled" | "copyMoveEnabled" | "requirePath" | "strictInput";
|
|
196
200
|
|
|
197
201
|
async function toggleFlag(key: ToggleKey): Promise<boolean> {
|
|
198
202
|
const config = await updateConfig((c) => { c[key] = !(c[key] === true); });
|
|
@@ -200,6 +204,7 @@ async function toggleFlag(key: ToggleKey): Promise<boolean> {
|
|
|
200
204
|
}
|
|
201
205
|
export const toggleAutoRead = (): Promise<boolean> => toggleFlag("autoRead");
|
|
202
206
|
export const toggleAnchorGrep = (): Promise<boolean> => toggleFlag("anchorGrepEnabled");
|
|
207
|
+
export const toggleCopyMove = (): Promise<boolean> => toggleFlag("copyMoveEnabled");
|
|
203
208
|
export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
|
|
204
209
|
let next: AutoReadAllMode = "off";
|
|
205
210
|
await updateConfig((c) => {
|