pi-hashline-edit-pro 6.3.0 → 6.3.1
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 +2 -2
- package/package.json +1 -1
- package/src/auto-read-all.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/edit-common.ts +22 -12
- package/src/insert.ts +1 -1
- package/src/payload-contract.ts +2 -2
- package/tool-prompts/copy.md +1 -1
- package/tool-prompts/grep.md +1 -1
- package/tool-prompts/insert-guidelines.md +1 -1
- package/tool-prompts/insert.md +1 -1
- package/tool-prompts/move.md +2 -2
- package/tool-prompts/read-guidelines.md +2 -2
- package/tool-prompts/read.md +2 -2
- package/tool-prompts/transfer-guidelines.md +1 -1
- package/tool-prompts/undo-last-change-guidelines.md +1 -1
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ Edge cases:
|
|
|
188
188
|
| --- | --- |
|
|
189
189
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
190
190
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
191
|
-
| `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range
|
|
191
|
+
| `text` | The exact text to write in place of the removed range, as one string: `""` deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
192
192
|
|
|
193
193
|
`replace_from` and `replace_to` are accepted as aliases for `remove_from` and `remove_to`.
|
|
194
194
|
|
|
@@ -235,7 +235,7 @@ In a same-message batch it joins the other calls on its file: the batch validate
|
|
|
235
235
|
| --- | --- |
|
|
236
236
|
| `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. |
|
|
237
237
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
238
|
-
| `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as
|
|
238
|
+
| `text` | The exact text to insert, as one string: `""` inserts one blank line (the same as a lone newline). A trailing line break sets the newline of the last line written instead of adding a blank line. Never include the anchor line. Embedded `\r\n`/`\r`/`\n` are preserved; JSON decoding happens once, before the tool; the tool writes the string it receives and never decodes — `\uXXXX` is the character, `\\uXXXX` the literal text. Legacy arrays are converted to text (elements joined with LF); prefer the string form. |
|
|
239
239
|
|
|
240
240
|
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. An empty `text` payload inserts one blank line. To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
241
241
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.1",
|
|
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",
|
package/src/auto-read-all.ts
CHANGED
|
@@ -30,7 +30,7 @@ const HEADER =
|
|
|
30
30
|
"[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
|
|
31
31
|
|
|
32
32
|
const OUTLINE_HEADER =
|
|
33
|
-
"[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor and the row's `limit
|
|
33
|
+
"[hashline auto-read-all] Every non-ignored project file is outlined below as `=== path (language) — lines ===` then `anchor│...` rows with live anchors: multi-line symbols show a label with `[limit N]`, the span to request when reading from the anchor, and single-line symbols show the line content.\nEdit a row's anchor with replace or insert, or read from it with `offset` set to that anchor (and the row's `limit` when it shows one); do not call read for attached files.\nFiles listed as not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
|
|
34
34
|
|
|
35
35
|
const IMAGE_EXTENSIONS = new Set([
|
|
36
36
|
".avif",
|
package/src/constants.ts
CHANGED
|
@@ -14,10 +14,10 @@ export const NEW_CONTENT_NOT_ARRAY_MSG =
|
|
|
14
14
|
`[E_BAD_SHAPE] "text" must be an array of strings, one per line (use [] to delete).`;
|
|
15
15
|
|
|
16
16
|
export const NEW_CONTENT_NOT_STRING_MSG =
|
|
17
|
-
`[E_BAD_SHAPE] "text" must be a string holding the exact text to write. Use "" to delete the range and
|
|
17
|
+
`[E_BAD_SHAPE] "text" must be a string holding the exact text to write. Use "" to delete the range and a lone newline for one blank line; line breaks inside the string separate lines.`;
|
|
18
18
|
|
|
19
19
|
export const TEXT_NOT_STRING_MSG =
|
|
20
|
-
`[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use
|
|
20
|
+
`[E_BAD_SHAPE] "text" must be a string holding the exact text to insert. Use a lone newline for one blank line; line breaks inside the string separate lines.`;
|
|
21
21
|
|
|
22
22
|
export const NUL_CONTENT_MSG =
|
|
23
23
|
`[E_BAD_SHAPE] Content contains a NUL byte (U+0000); a text file cannot contain NUL, and writing it would break further reads and edits. Remove the NUL byte and retry.`;
|
package/src/edit-common.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface EditToolFlags {
|
|
|
13
13
|
strictInput: boolean;
|
|
14
14
|
autoRead: boolean;
|
|
15
15
|
autoReadAllActive: boolean;
|
|
16
|
+
autoReadAllOutline: boolean;
|
|
16
17
|
replaceMatchEnabled: boolean;
|
|
17
18
|
copyMoveEnabled: boolean;
|
|
18
19
|
codemode: boolean;
|
|
@@ -23,6 +24,7 @@ export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
|
|
|
23
24
|
strictInput: false,
|
|
24
25
|
autoRead: true,
|
|
25
26
|
autoReadAllActive: false,
|
|
27
|
+
autoReadAllOutline: false,
|
|
26
28
|
replaceMatchEnabled: true,
|
|
27
29
|
copyMoveEnabled: true,
|
|
28
30
|
codemode: false,
|
|
@@ -35,6 +37,7 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
|
|
|
35
37
|
strictInput: config.strictInput === true,
|
|
36
38
|
autoRead: config.autoRead !== false,
|
|
37
39
|
autoReadAllActive: (config.autoReadAll ?? "off") !== "off",
|
|
40
|
+
autoReadAllOutline: (config.autoReadAll ?? "off") === "outline",
|
|
38
41
|
replaceMatchEnabled: config.replaceMatchEnabled !== false,
|
|
39
42
|
copyMoveEnabled: config.copyMoveEnabled !== false,
|
|
40
43
|
codemode
|
|
@@ -66,7 +69,7 @@ function batchGuideline(flags: EditToolFlags): string {
|
|
|
66
69
|
const tools = operationNames(SHARED_EDIT_OPS, flags);
|
|
67
70
|
const outcome = flags.autoRead ? "diff" : "result";
|
|
68
71
|
const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
|
|
69
|
-
return `${tools}: same-file calls in one message are grouped into one batch; earlier calls reply \`In batch N (queued)\` and the last applied call shows the combined ${outcome}, with one undo for the whole batch. A call whose own request is invalid (anchors, shape, or \`old_string\`) fails on its own
|
|
72
|
+
return `${tools}: same-file calls in one message are grouped into one batch; earlier calls reply \`In batch N (queued)\` and the last applied call shows the combined ${outcome}, with one undo for the whole batch. A call whose own request is invalid (anchors, shape, or \`old_string\`) fails on its own; any failure once the batch runs aborts the whole batch.${script}`;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
function diffGuideline(flags: EditToolFlags): string {
|
|
@@ -83,7 +86,7 @@ function pathGuideline(flags: EditToolFlags): string {
|
|
|
83
86
|
|
|
84
87
|
function payloadGuideline(flags: EditToolFlags): string {
|
|
85
88
|
const tools = operationNames(SHARED_PAYLOAD_OPS, flags);
|
|
86
|
-
return `${tools}:
|
|
89
|
+
return `${tools}: write the character itself, not an escape; the tool writes \`text\` verbatim and never decodes escapes — \`\\uXXXX\` is the character, \`\\\\uXXXX\` the literal text.`;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
function strictInputGuideline(flags: EditToolFlags): string {
|
|
@@ -115,27 +118,34 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
115
118
|
return finalizePrompts(description, base.snippet, guidelines, flags);
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
function readGuidelineApplies(guideline: string, flags: EditToolFlags): boolean {
|
|
122
|
+
if (!flags.autoReadAllOutline && guideline.includes("[limit N]")) return false;
|
|
123
|
+
if (!flags.replaceMatchEnabled && guideline.includes("`replace_match`")) return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
export function withReadPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
119
128
|
const preference = preferenceGuideline(flags);
|
|
120
129
|
const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE] : [];
|
|
130
|
+
const baseGuidelines = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
|
|
121
131
|
if (flags.autoReadAllActive) {
|
|
122
|
-
const rewritten =
|
|
132
|
+
const rewritten = baseGuidelines
|
|
123
133
|
.filter((guideline) => !guideline.includes("call again after an edit"))
|
|
124
134
|
return { description: base.description, snippet: base.snippet, guidelines: [preference, ...rewritten, ...script] };
|
|
125
135
|
}
|
|
126
|
-
if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...
|
|
127
|
-
const guidelines = [preference, ...
|
|
136
|
+
if (flags.autoRead) return { description: base.description, snippet: base.snippet, guidelines: [preference, ...baseGuidelines, ...script] };
|
|
137
|
+
const guidelines = [preference, ...baseGuidelines, ...script];
|
|
128
138
|
const mapped = guidelines.map((guideline) => guideline.startsWith("`read`: call again after an edit") ? "`read`: call again after an edit when you need anchors you lack." : guideline);
|
|
129
139
|
return { description: base.description, snippet: base.snippet, guidelines: mapped };
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
export function withInsertPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
133
|
-
const guidelines = [...base.guidelines];
|
|
143
|
+
const guidelines = [preferenceGuideline(flags), ...base.guidelines];
|
|
134
144
|
return finalizePrompts(base.description, base.snippet, guidelines, flags);
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
138
|
-
const guidelines = [...base.guidelines];
|
|
148
|
+
const guidelines = [preferenceGuideline(flags), ...base.guidelines];
|
|
139
149
|
return finalizePrompts(base.description, base.snippet, guidelines, flags);
|
|
140
150
|
}
|
|
141
151
|
|
|
@@ -157,8 +167,8 @@ function joinOps(ops: string[], options?: { backtick?: boolean; separator?: "/"
|
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
export function withGrepPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
160
|
-
|
|
161
|
-
|
|
170
|
+
const guidelines = [preferenceGuideline(flags), ...base.guidelines];
|
|
171
|
+
if (flags.codemode) guidelines.push(RESULT_CONTRACT_GUIDELINE);
|
|
162
172
|
if (flags.copyMoveEnabled) return { ...base, guidelines };
|
|
163
173
|
return { ...base, guidelines, description: base.description.replaceAll("replace, insert, copy, or move", joinOps(gatedEditOps(["replace", "insert", "copy", "move"], flags))) };
|
|
164
174
|
}
|
|
@@ -168,9 +178,9 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
|
|
|
168
178
|
let description = base.description;
|
|
169
179
|
let snippet = base.snippet;
|
|
170
180
|
const script = flags.codemode ? [RESULT_CONTRACT_GUIDELINE, SCRIPT_UNDO_GUIDELINE] : [];
|
|
171
|
-
let guidelines = [...base.guidelines, ...script];
|
|
181
|
+
let guidelines = [preferenceGuideline(flags), ...base.guidelines, ...script];
|
|
172
182
|
if (!flags.autoRead) {
|
|
173
|
-
guidelines = guidelines.map((guideline) => guideline.includes("
|
|
183
|
+
guidelines = guidelines.map((guideline) => guideline.includes("a `write` clears the history") ? "`undo_last_change`: only the last `replace`/`replace_match`/`insert`/`copy`/`move` per file is undoable; a `write` clears it, so undo before any other edit or write." : guideline);
|
|
174
184
|
}
|
|
175
185
|
if (ops.length !== 5) {
|
|
176
186
|
description = description.replaceAll("replace, replace_match, insert, copy, or move", joinOps(ops));
|
|
@@ -184,7 +194,7 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
|
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
export function withTransferPrompts(base: { description: string; snippet: string; guidelines: string[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
187
|
-
const guidelines = flags
|
|
197
|
+
const guidelines = [preferenceGuideline(flags), ...base.guidelines, ...(flags.codemode ? [SCRIPT_TRANSFER_GUIDELINE] : [])];
|
|
188
198
|
return finalizePrompts(base.description, base.snippet, guidelines, flags, { stringPayload: false });
|
|
189
199
|
}
|
|
190
200
|
|
package/src/insert.ts
CHANGED
|
@@ -28,7 +28,7 @@ const insertDirectionSchema = Type.Union(
|
|
|
28
28
|
);
|
|
29
29
|
const insertTextSchema = Type.String({
|
|
30
30
|
description:
|
|
31
|
-
'The exact text to insert; an empty string inserts one blank line.
|
|
31
|
+
'The exact text to insert; an empty string inserts one blank line. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line. Never include the anchor line.',
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
const insertPathRequiredSchema = Type.String({
|
package/src/payload-contract.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { TEXT_NOT_STRING_MSG, NEW_CONTENT_NOT_STRING_MSG } from "./constants";
|
|
|
4
4
|
|
|
5
5
|
const textSchema = Type.String({
|
|
6
6
|
description:
|
|
7
|
-
'The exact text to write in place of the removed range. "" deletes the range
|
|
7
|
+
'The exact text to write in place of the removed range. "" deletes the range. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line.',
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
const removeFromSchema = Type.String({
|
|
@@ -234,7 +234,7 @@ const replaceMatchOldStringSchema = Type.String({
|
|
|
234
234
|
});
|
|
235
235
|
const replaceMatchNewStringSchema = Type.String({
|
|
236
236
|
description:
|
|
237
|
-
'The exact replacement for every matched occurrence. "" deletes the matches
|
|
237
|
+
'The exact replacement for every matched occurrence. "" deletes the matches. A trailing line break sets the newline of the last line written instead of adding a blank line; a lone newline writes one blank line; the rest of the range is kept byte-for-byte.',
|
|
238
238
|
});
|
|
239
239
|
const replaceMatchPathRequiredSchema = Type.String({
|
|
240
240
|
description:
|
package/tool-prompts/copy.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
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. The source lines stay in place, and the copied lines are written exactly as the source file holds them.
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A copy always duplicates the content its source anchors were served from, so a same-message edit to the source file does not change what is copied.
|
package/tool-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, insert, copy, or move without a new read; the `=== path ===` header and line numbers locate the match. Patterns are regex by default; set `literal: true` to search exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows.
|
|
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. Patterns are regex by default; set `literal: true` to search exact text, `ignoreCase` for case-insensitive search, and `context` for surrounding rows. Backreferences and look-around are rejected by ripgrep's engine; nested quantifiers and alternation run in linear time. 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 +1 @@
|
|
|
1
|
-
- `insert`:
|
|
1
|
+
- `insert`: a missing or extra `+anchor│` blank row at either block edge in the diff is the classic insert slip.
|
package/tool-prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert text after or before one existing line in a text file, addressed by the `anchor` field (a bare anchor from any served anchor│content row). `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert
|
|
1
|
+
Insert text after or before one existing line in a text file, addressed by the `anchor` field (a bare anchor from any served anchor│content row). `text` goes after the anchor line with `direction: "after"` or before it with `direction: "before"`. `text` is one string holding the exact text to insert; a trailing line break sets the newline of the last line written instead of adding a blank line, and a leading break is an ordinary blank line.
|
package/tool-prompts/move.md
CHANGED
|
@@ -1,3 +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. `insert_after` must sit outside the source range when both anchors share a file
|
|
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. `insert_after` must sit outside the source range when both anchors share a file.
|
|
2
2
|
|
|
3
|
-
A cross-file `move`
|
|
3
|
+
A cross-file `move` commits on its own when its source file also has batched edits in the message.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- `read`: call again after an edit when you need anchors you lack — post-edit diff `+anchor│`/` anchor│` rows and any served `anchor│content` rows already carry fresh anchors for the changed range.
|
|
2
|
-
- `read`:
|
|
3
|
-
- `read`:
|
|
2
|
+
- `read`: an auto-read-all outline row shows a symbol label, or the line content for a single-line symbol; its `[limit N]` is the span to request.
|
|
3
|
+
- `read`: `replace_match` needs both boundary lines served; copy `old_string` from a served row or read the lines first.
|
package/tool-prompts/read.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target
|
|
1
|
+
Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target edits by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue from the hinted `offset`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
2
2
|
|
|
3
|
-
`offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it. A stale anchor is refused with the current context rows and fresh anchors.
|
|
3
|
+
`offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return after it, so no line-number math is needed. A stale anchor is refused with the current context rows and fresh anchors.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
- `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving;
|
|
1
|
+
- `copy`/`move`: for a block transfer, only the source's two boundary rows and the destination line need serving; to append at the end, use the destination's last served line as `insert_after`.
|
|
2
2
|
- `copy`/`move`: `insert_after` must sit outside the source range; `source_to` places the block right after itself.
|
|
3
3
|
- `copy`/`move`: `source_from` and `source_to` may live in a different file than `insert_after`; an empty destination file is seeded with the copied or moved lines.
|
|
4
4
|
- `move`: a batched cross-file move does not display the source file's diff; its removal commits with the batch, and the source file needs a read for fresh anchors.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- `undo_last_change`: history is one slot per file, not a stack; a second undo returns `E_UNDO_NONE`.
|
|
2
|
-
- `undo_last_change`: a `write` clears the history, so undo
|
|
2
|
+
- `undo_last_change`: a `write` clears the history, so undo before any other edit or write; the undo diff's `+anchor│` rows are the restored lines.
|
|
3
3
|
- `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.
|