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 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 five tools: `read`, `replace`, `insert`, `anchor_grep`, and `undo_last_change`. The built-in `edit` tool is disabled. `replace` and `insert` 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` and `insert` for RPC visibility (for example pimacs.el); anchors still resolve the target and `path` must match.
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. Never embed `\n` inside an element. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
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, and never embed `\n` inside an element. A lone string is split on newlines, and stringified array text is unwrapped. |
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 `insert` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
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 `insert` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
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 `insert` is undoable; the undo record is saved before the edit is written.
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 five tools return machine-readable metadata in `details` alongside the model-visible text.
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: replacements, insertions, deletions, copies, moves, large files, several file types, and Unicode edge cases. 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.
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 !== "grep" : t !== "anchor_grep",
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.4.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
@@ -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 insert 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.
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, no embedded newlines. Inserted lines are written exactly as given; nothing else in the file changes.
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
@@ -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`.
@@ -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 no embedded newlines; `[]` deletes the range.
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 `insert`
1
+ Single-level undo: reverts a file's last `replace`, `insert`, `copy`, or `move`
@@ -1 +1 @@
1
- Undo the last replace or insert 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, 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 { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
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 + restoreEndings(composed, base.ending);
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: preflightSpans,
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 + restoreEndings(composed, base.ending), base.identity);
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 finalFileBytes = pipe.bom + restoreEndings(pipe.result, pipe.originalEnding);
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) => {