pi-hashline-edit-pro 6.4.7 → 6.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 +9 -13
- package/package.json +9 -9
- package/src/anchor-registry.ts +33 -9
- package/src/batch.ts +2 -2
- package/src/copy-move.ts +8 -4
- package/src/edit-common.ts +12 -10
- package/src/edit-fidelity.ts +98 -41
- package/src/insert.ts +11 -4
- package/src/payload-contract.ts +41 -22
- package/src/read.ts +71 -35
- package/src/replace-diff.ts +38 -15
- package/src/replace-match.ts +67 -147
- package/src/replace.ts +6 -3
- package/src/utils.ts +22 -2
- package/tool-prompts/read-guidelines.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +3 -3
- package/tool-prompts/replace-match-snippet.md +1 -1
- package/tool-prompts/replace-match.md +2 -2
package/README.md
CHANGED
|
@@ -219,15 +219,13 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
219
219
|
|
|
220
220
|
### replace_match
|
|
221
221
|
|
|
222
|
-
`replace_match` changes part of a line
|
|
222
|
+
`replace_match` changes part of a line — or the same fragment on several lines of a range — without retyping the rest. `replace_from` and `replace_to` are bare anchors from served `anchor│content` rows marking the first and last line of the range searched (the same anchor for a single line). `match` is the exact text to find inside that range, and `replacement` replaces every occurrence of it; every other character stays untouched. Both stay within one line: a single trailing line break is accepted as the line's own terminator and dropped, so the file keeps its separator, while a line break anywhere else is refused with `[E_BAD_SHAPE]` (a `match` that is only that trailing line break leaves nothing to find and is refused; in `replacement` it counts as `""` and deletes the matches). Send `match` as only the fragment that changes — never include text that `replacement` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines; if `match` is an entire single-line range except indentation and trailing punctuation, that is a whole-line change. The anchors bound the search, so `match` only needs to be unique inside the selected range, not file-wide. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the lines, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
|
|
223
223
|
|
|
224
224
|
`remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
|
|
225
225
|
|
|
226
|
-
`
|
|
226
|
+
`match` is matched against each selected line's text (LF line breaks) and every non-overlapping occurrence is replaced, left to right — since `match` stays within one line, so does each replacement. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are searched as they currently stand on disk.
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
|
|
228
|
+
In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `match` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
|
|
231
229
|
|
|
232
230
|
### insert
|
|
233
231
|
|
|
@@ -422,7 +420,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
422
420
|
|
|
423
421
|
All eight tools return machine-readable metadata in `details` alongside the model-visible text.
|
|
424
422
|
|
|
425
|
-
All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text.
|
|
423
|
+
All eight tools also declare an `outputSchema` and return `structuredContent`, so codemode scripts receive anchors, line numbers, diffs, and errors as data instead of parsing the rendered text. In a codemode session `tools.read()` resolves to the file's text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }`, where `text` is the raw file content of the served window and `lines` carries the anchored rows. The model still receives `content`. Every structured result is either `{ ok: true, kind, ... }` or `{ ok: false, kind: "error", error: { code, message } }`; a failed call is still a tool error (`isError: true`) and also carries `structuredContent`, so a script can branch on the code without matching the message.
|
|
426
424
|
|
|
427
425
|
`read` returns `{ kind: "read", path, text, lines, totalLines, startLine, nextOffset, truncated, blockedByLongLine, hadUtf8DecodeErrors, notices }`, where `text` is the raw file content of the served window (no anchors, no footers) and each entry of `lines` is `{ line, text, anchor, rendered }` for a row that was served (`text` there is what was shown, so an oversized line appears as its marker while keeping its anchor). `notices` carries non-file notes such as the UTF-8 rewrite note and `[W_ANCHOR_RECLAIMED]`. An image read returns `{ type: "image", data, mimeType, note }`.
|
|
428
426
|
|
|
@@ -459,20 +457,18 @@ Full reference:
|
|
|
459
457
|
| Code | Meaning |
|
|
460
458
|
| --- | --- |
|
|
461
459
|
| `[E_CONFIG]` | `PI_HASHLINE_DIR` is nonempty but not an absolute path. |
|
|
462
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary,
|
|
460
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `text` must be a string holding the exact text), content contains a NUL byte (`U+0000`), which would make the file binary, a grep `glob` has invalid bracket or brace syntax, or a `replace_match` string spans lines: `match`/`replacement` contains a line break after the optional trailing one. |
|
|
463
461
|
| `[W_BAD_SHAPE]` | Auto-corrected request slip reported as a warning (for example legacy array text that could not be parsed and was kept as one literal line). |
|
|
464
462
|
| `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 4-character anchor (the anchor table is letters only). |
|
|
465
|
-
| `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `
|
|
463
|
+
| `[E_SUBSTRING_NOT_FOUND]` | `replace_match` did not find `match` in the selected range. The current `anchor│content` rows are returned, and when `match` looks like a retyped copy of a single-line range the message names the first mismatching column; copy only the fragment that changes from the served row and retry, or use `replace` for a whole-line rewrite. |
|
|
466
464
|
| `[W_BAD_REF]` | A pasted `anchor│` or diff-preview marker was stripped from an anchor field with a warning. |
|
|
467
465
|
| `[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. |
|
|
468
466
|
| `[W_INVALID_PATCH]` | A `text` line is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
469
467
|
| `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
470
|
-
| `[W_REPLACE_MATCH_WHOLE_RANGE]` | `replace_match` `old_string` is the entire selected range. The anchors already identify it; use `replace` with the same anchors, or send only the changed fragment. Refused in strict-input mode. |
|
|
471
|
-
| `[W_REPLACE_MATCH_RETYPE]` | `replace_match` `old_string` retypes a long run of text that `new_string` leaves unchanged. Send only the fragment that changes; refused in strict-input mode. |
|
|
472
468
|
| `[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. |
|
|
473
|
-
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31)
|
|
474
|
-
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the
|
|
475
|
-
| `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names
|
|
469
|
+
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31).` names the field, the escape, and up to three affected anchors with their columns; when more than three rows carry it, it gives `on 10 rows; undo_last_change if unintended.` instead of the anchor list. |
|
|
470
|
+
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the live anchor of the offending result row, when one can be identified, and the character as its JSON escape, for example `[H_UNICODE_LOST] Zbbb│ missing "\u2060"; Kq3f│ has it at col 31.` The column is attributed to the row that had the character: `; Kq3f│ has it at col N` while that row's anchor is still valid, otherwise `; the replaced row had it at col N`, and a dead anchor is never named. When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
|
|
471
|
+
| `[H_UNICODE_SWAPPED]` | The new text uses a different invisible or look-alike character than the line it matches (for example `U+200D` where that line has `U+200B`, or an ASCII stand-in such as `.` for `。` or a space for `U+00A0`). The edit applied as sent; the one-line hint names the live anchor of the offending result row with both characters as JSON escapes, for example `[H_UNICODE_SWAPPED] Zbbb│ has "\u200d" at col 21; expected "\u200b".` A surviving reference row appears as `; Kq3f│ has "\u200b"`; a replaced row's dead anchor is never named. The substitute may also be `U+FFFD`, for example where a row has `U+00A0`. |
|
|
476
472
|
| `[H_TRAILING_WHITESPACE]` | The new text differs from the replaced line only in trailing whitespace. Anchor checksums trim trailing whitespace, so the change does not invalidate the anchor; the one-line hint names the old and new trailing-whitespace counts and the column, for example `[H_TRAILING_WHITESPACE] 1 trailing whitespace character at col 7; Kq3f│ had 0.` |
|
|
477
473
|
| `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
|
|
478
474
|
| `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.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",
|
|
@@ -61,15 +61,15 @@
|
|
|
61
61
|
"typecheck": "tsc --noEmit"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
-
"@types/node": "^24",
|
|
67
|
-
"@vitest/coverage-v8": "^
|
|
68
|
-
"eslint": "^10.
|
|
69
|
-
"typebox": "^1.
|
|
70
|
-
"typescript": "^
|
|
71
|
-
"typescript-eslint": "^8.
|
|
72
|
-
"vitest": "^
|
|
66
|
+
"@types/node": "^24.19.2",
|
|
67
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
68
|
+
"eslint": "^10.13.0",
|
|
69
|
+
"typebox": "^1.4.1",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"typescript-eslint": "^8.71.2",
|
|
72
|
+
"vitest": "^5.0.5"
|
|
73
73
|
},
|
|
74
74
|
"allowScripts": {
|
|
75
75
|
"@google/genai@1.52.0": true,
|
package/src/anchor-registry.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
6
6
|
import { sessionClaimsDir } from "./paths";
|
|
7
7
|
import { contentChecksum } from "./hashline/hasher";
|
|
8
8
|
import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
|
|
9
|
-
import { HASH_PROBE_STRIDE } from "./hashline/hash";
|
|
10
|
-
import { errCode, isRec, splitLines } from "./utils";
|
|
9
|
+
import { HASH_PROBE_STRIDE, MAX_HASH_LINES } from "./hashline/hash";
|
|
10
|
+
import { assertLineCount, errCode, isRec, splitLines, spliceAll } from "./utils";
|
|
11
11
|
import * as Diff from "diff";
|
|
12
12
|
import { lineChecksum } from "./hashline";
|
|
13
13
|
import { getAllocatedState, persistSnapshot, type HashStore } from "./hash-store";
|
|
@@ -46,6 +46,7 @@ interface OwnedAnchorMap {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
interface AnchorMintedSet {
|
|
49
|
+
readonly size: number;
|
|
49
50
|
has(anchor: string): boolean;
|
|
50
51
|
add(anchor: string): unknown;
|
|
51
52
|
[Symbol.iterator](): IterableIterator<string>;
|
|
@@ -125,9 +126,12 @@ class ShadowOwnedMap implements OwnedAnchorMap {
|
|
|
125
126
|
class ShadowMintedSet implements AnchorMintedSet {
|
|
126
127
|
private readonly parent: AnchorMintedSet;
|
|
127
128
|
private readonly added = new Set<string>();
|
|
129
|
+
private extra = 0;
|
|
130
|
+
private parentSize: number;
|
|
128
131
|
|
|
129
132
|
constructor(parent: AnchorMintedSet) {
|
|
130
133
|
this.parent = parent;
|
|
134
|
+
this.parentSize = parent.size;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
has(anchor: string): boolean {
|
|
@@ -135,10 +139,22 @@ class ShadowMintedSet implements AnchorMintedSet {
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
add(anchor: string): this {
|
|
142
|
+
if (!this.added.has(anchor) && !this.parent.has(anchor)) this.extra += 1;
|
|
138
143
|
this.added.add(anchor);
|
|
139
144
|
return this;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
get size(): number {
|
|
148
|
+
if (this.parent.size !== this.parentSize) {
|
|
149
|
+
this.parentSize = this.parent.size;
|
|
150
|
+
this.extra = 0;
|
|
151
|
+
for (const anchor of this.added) {
|
|
152
|
+
if (!this.parent.has(anchor)) this.extra += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return this.parentSize + this.extra;
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
*[Symbol.iterator](): IterableIterator<string> {
|
|
143
159
|
yield* this.parent;
|
|
144
160
|
for (const anchor of this.added) {
|
|
@@ -555,16 +571,19 @@ export function reclaimAnchorSpace(state: SessionState, protectPath?: string): s
|
|
|
555
571
|
|
|
556
572
|
export function mintAnchor(state: SessionState, protectPath?: string): string {
|
|
557
573
|
for (;;) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
574
|
+
if (state.everMinted.size < ANCHOR_COUNT) {
|
|
575
|
+
for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
|
|
576
|
+
state.probe = (state.probe + HASH_PROBE_STRIDE) % ANCHOR_COUNT;
|
|
577
|
+
const candidate = anchorAt(state.probe);
|
|
578
|
+
if (!state.owned.has(candidate) && !state.everMinted.has(candidate)) {
|
|
579
|
+
return candidate;
|
|
580
|
+
}
|
|
563
581
|
}
|
|
564
582
|
}
|
|
565
583
|
for (let step = 1; step <= ANCHOR_COUNT; step++) {
|
|
566
584
|
const candidate = anchorAt((state.probe + step) % ANCHOR_COUNT);
|
|
567
585
|
if (!state.owned.has(candidate)) {
|
|
586
|
+
state.probe = (state.probe + step) % ANCHOR_COUNT;
|
|
568
587
|
for (const served of state.served.values()) served.delete(candidate);
|
|
569
588
|
return candidate;
|
|
570
589
|
}
|
|
@@ -861,7 +880,7 @@ export function alignOwnershipWithSpans(
|
|
|
861
880
|
state.everMinted.add(settled[k]!);
|
|
862
881
|
state.owned.set(settled[k]!, { path, checksum: newChecksums[start + k]! });
|
|
863
882
|
}
|
|
864
|
-
anchors
|
|
883
|
+
spliceAll(anchors, start, spanLength, settled);
|
|
865
884
|
offset += span.replacementCount - spanLength;
|
|
866
885
|
}
|
|
867
886
|
const rows: [string, string][] = minted.map((m) => [m.anchor, m.checksum]);
|
|
@@ -963,8 +982,9 @@ export async function allocateFileAnchors(
|
|
|
963
982
|
ensureRegistry();
|
|
964
983
|
const registry = current();
|
|
965
984
|
const shadow = options?.shadow === true;
|
|
966
|
-
if (!shadow && registry) touchPath(registry, path);
|
|
967
985
|
const lines = splitLines(content);
|
|
986
|
+
assertLineCount(lines.length, path, MAX_HASH_LINES);
|
|
987
|
+
if (!shadow && registry) touchPath(registry, path);
|
|
968
988
|
const checksums = lines.map(lineChecksum);
|
|
969
989
|
if (options?.previous?.spans) {
|
|
970
990
|
const prevChecksums = splitLines(options.previous.content).map(lineChecksum);
|
|
@@ -1058,6 +1078,10 @@ export function resetRegistryForTests(): void {
|
|
|
1058
1078
|
registries.clear();
|
|
1059
1079
|
}
|
|
1060
1080
|
|
|
1081
|
+
export function lastUsedAtForTests(path: string): number | undefined {
|
|
1082
|
+
return current()?.lastUsedAt.get(path);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1061
1085
|
async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
|
|
1062
1086
|
const handle = await open(sidecar, "r");
|
|
1063
1087
|
const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
|
package/src/batch.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { saveUndo } from "./replace-undo";
|
|
|
28
28
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
29
29
|
import { toEditVerb, withStructuredText, type EditStructured } from "./structured";
|
|
30
30
|
import { serveRows, servedHashesFromDiff } from "./served";
|
|
31
|
-
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines } from "./utils";
|
|
31
|
+
import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, mapIoError, splitLines, spliceAll } from "./utils";
|
|
32
32
|
|
|
33
33
|
export interface PlannedMember {
|
|
34
34
|
batchKey: number;
|
|
@@ -793,7 +793,7 @@ function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
|
|
|
793
793
|
const lines = splitLines(baseContent);
|
|
794
794
|
const baseLineCount = lines.length;
|
|
795
795
|
const descending = [...pieces].sort((a, b) => b.start - a.start);
|
|
796
|
-
for (const piece of descending) lines
|
|
796
|
+
for (const piece of descending) spliceAll(lines, piece.start - 1, piece.end - piece.start + 1, piece.newLines);
|
|
797
797
|
let composed = lines.join("\n");
|
|
798
798
|
if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) {
|
|
799
799
|
composed += "\n";
|
package/src/copy-move.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { formatAnchorReclaimNotice, servedForPath, takeReclaimedPaths, withAncho
|
|
|
23
23
|
import { batchMemberFor, batchServedFor, ensureBatchBase, executeBatchMember, noteBatchFailure, pendingBatchMemberFor, registerBatchPlanner, type BatchBase, type BatchMemberPlanner, type PlannedMember } from "./batch";
|
|
24
24
|
import { loadP, loadGuidelines } from "./prompts";
|
|
25
25
|
import { assertTransferReq, normReq, type TransferReq } from "./payload-contract";
|
|
26
|
-
import { abortIf, assertByteLimit, isRec, splitLines } from "./utils";
|
|
26
|
+
import { abortIf, anchorCarryIndex, assertByteLimit, isRec, splitLines } from "./utils";
|
|
27
27
|
import { toDisplayPath } from "./paths";
|
|
28
28
|
import { getDiffContextLines } from "./config";
|
|
29
29
|
import { resolveInCwd, writeAtomic } from "./fs-write";
|
|
@@ -145,7 +145,7 @@ export function buildTransferEdit(input: {
|
|
|
145
145
|
: [...sourceLines, fileLines[anchorLine - 1]!],
|
|
146
146
|
},
|
|
147
147
|
foldedAnchorLines: 1,
|
|
148
|
-
anchorCarry: refs.direction
|
|
148
|
+
anchorCarry: anchorCarryIndex(refs.direction, sourceLines.length),
|
|
149
149
|
endingOverrides: refs.direction === "after"
|
|
150
150
|
? [undefined, ...sourceEndings]
|
|
151
151
|
: [...sourceEndings, undefined],
|
|
@@ -319,14 +319,14 @@ async function prepareCrossTransfer(input: {
|
|
|
319
319
|
? offsetEndings(1, sourceEndings)
|
|
320
320
|
: sourceEndings;
|
|
321
321
|
if (kind === "copy") {
|
|
322
|
-
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction
|
|
322
|
+
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: anchorCarryIndex(refs.direction, moved.length) } : {}), endingOverrides };
|
|
323
323
|
}
|
|
324
324
|
const sourceEdit: HTEdit = {
|
|
325
325
|
remove_from: sourcePreload.fileHashes[sourceStart - 1]!,
|
|
326
326
|
remove_to: sourcePreload.fileHashes[sourceEnd - 1]!,
|
|
327
327
|
text: [],
|
|
328
328
|
};
|
|
329
|
-
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: refs.direction
|
|
329
|
+
return { sourcePreload, destinationPreload, sourceDisplay, destinationDisplay, destinationEdit, destinationFolded, ...(destinationFolded === 1 ? { anchorCarry: anchorCarryIndex(refs.direction, moved.length) } : {}), sourceEdit, endingOverrides };
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
function pairEntry(pipe: PipelineResult, separators: LineEnding[] | undefined): UndoEntry {
|
|
@@ -584,6 +584,7 @@ async function executeCrossFile(
|
|
|
584
584
|
signal,
|
|
585
585
|
preloadedNorm: prepared.destinationPreload,
|
|
586
586
|
endingOverrides: prepared.endingOverrides,
|
|
587
|
+
...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
|
|
587
588
|
});
|
|
588
589
|
if (kind === "copy") {
|
|
589
590
|
return commitEdit(destinationPipe, {
|
|
@@ -827,6 +828,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
827
828
|
served: plan.servedOverride,
|
|
828
829
|
endingOverrides: plan.endingOverrides,
|
|
829
830
|
signal,
|
|
831
|
+
...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
|
|
830
832
|
});
|
|
831
833
|
return previewFromPipe(pipe);
|
|
832
834
|
}
|
|
@@ -837,6 +839,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
837
839
|
preloadedNorm: prepared.destinationPreload,
|
|
838
840
|
endingOverrides: prepared.endingOverrides,
|
|
839
841
|
signal,
|
|
842
|
+
...(prepared.anchorCarry !== undefined ? { anchorCarry: prepared.anchorCarry } : {}),
|
|
840
843
|
});
|
|
841
844
|
if (kind === "copy") return previewFromPipe(destinationPipe);
|
|
842
845
|
const sourcePipe = await execPipeline(sourcePath, prepared.sourceEdit!, cwd, {
|
|
@@ -1017,6 +1020,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
|
|
|
1017
1020
|
preloadedNorm: preload,
|
|
1018
1021
|
served: plan.servedOverride,
|
|
1019
1022
|
endingOverrides: plan.endingOverrides,
|
|
1023
|
+
...(plan.anchorCarry !== undefined ? { anchorCarry: plan.anchorCarry } : {}),
|
|
1020
1024
|
});
|
|
1021
1025
|
return commitEdit(pipe, {
|
|
1022
1026
|
path: pipe.path,
|
package/src/edit-common.ts
CHANGED
|
@@ -53,10 +53,14 @@ function preferenceGuideline(flags: EditToolFlags): string {
|
|
|
53
53
|
const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
|
|
54
54
|
const SHARED_PAYLOAD_OPS = ["replace", "replace_match", "insert"];
|
|
55
55
|
const SHARED_DIFF_OPS = ["replace", "replace_match", "insert", "copy", "move", "undo_last_change"];
|
|
56
|
-
const
|
|
57
|
-
|
|
56
|
+
const SHARED_SCRIPT_OPS = ["read", "replace", "replace_match", "insert", "copy", "move", "grep", "undo_last_change"];
|
|
57
|
+
|
|
58
|
+
function resultContractGuideline(flags: EditToolFlags): string {
|
|
59
|
+
const tools = operationNames(SHARED_SCRIPT_OPS, flags);
|
|
60
|
+
return `${tools}: when called from a codemode script, failures resolve to \`{ ok: false, kind: "error", error: { code, message } }\`; branch on \`ok\` instead of \`try\`/\`catch\`.`;
|
|
61
|
+
}
|
|
58
62
|
const READ_SCRIPT_GUIDELINE =
|
|
59
|
-
'`read
|
|
63
|
+
'`read` in a codemode script resolves to the file\'s text, like the built-in `read`; pass `anchors: true` to receive `{ text, lines, notices }` with the raw file content and `lines[].anchor` for chained edits. An image read resolves to an image block.';
|
|
60
64
|
const GREP_SCRIPT_GUIDELINE =
|
|
61
65
|
'`grep`: a codemode script receives `{ text, results }`; each `results[].lines[]` entry carries the matching line\'s `anchor` and `text`, so a result is editable without a fresh read.';
|
|
62
66
|
function editScriptGuideline(flags: EditToolFlags): string {
|
|
@@ -88,7 +92,7 @@ function batchGuideline(flags: EditToolFlags): string {
|
|
|
88
92
|
const tools = operationNames(SHARED_EDIT_OPS, flags);
|
|
89
93
|
const outcome = flags.autoRead ? "diff" : "result";
|
|
90
94
|
const script = flags.codemode ? ` ${SCRIPT_BATCH_GUIDELINE}` : "";
|
|
91
|
-
return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`
|
|
95
|
+
return `${tools}: same-file calls in one message commit as one batch; earlier calls reply \`In batch N (queued)\`, the last shows the combined ${outcome}, and one undo reverts all. A request-invalid call (anchors, shape, or \`match\`) fails alone; a failure while the batch runs aborts it.${script}`;
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function diffGuideline(flags: EditToolFlags): string {
|
|
@@ -137,7 +141,7 @@ function readGuidelineApplies(guideline: Guideline, flags: EditToolFlags): boole
|
|
|
137
141
|
|
|
138
142
|
export function withReadPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
139
143
|
const preference = preferenceGuideline(flags);
|
|
140
|
-
const script = flags.codemode ? [READ_SCRIPT_GUIDELINE,
|
|
144
|
+
const script = flags.codemode ? [READ_SCRIPT_GUIDELINE, resultContractGuideline(flags)] : [];
|
|
141
145
|
const applicable = base.guidelines.filter((guideline) => readGuidelineApplies(guideline, flags));
|
|
142
146
|
if (flags.autoReadAllActive) {
|
|
143
147
|
const rewritten = applicable.filter((guideline) => guideline.id !== READ_REREAD_ID).map((guideline) => guideline.text);
|
|
@@ -153,10 +157,8 @@ export function withInsertPrompts(base: { description: string; snippet: string;
|
|
|
153
157
|
return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] },
|
|
157
|
-
|
|
158
|
-
if (flags.strictInput) guidelines.push("`replace_match`: strict-input mode rejects an `old_string` that covers the whole range or retypes unchanged text.");
|
|
159
|
-
return { description: base.description, snippet: base.snippet, guidelines };
|
|
160
|
+
export function withReplaceMatchPrompts(base: { description: string; snippet: string; guidelines: Guideline[] }, _flags: EditToolFlags): { description: string; snippet: string; guidelines: string[] } {
|
|
161
|
+
return { description: base.description, snippet: base.snippet, guidelines: base.guidelines.map((guideline) => guideline.text) };
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
function gatedEditOps(ops: string[], flags: EditToolFlags): string[] {
|
|
@@ -295,7 +297,7 @@ export async function resolveEditTargetWithRequirement(input: PathRequirementInp
|
|
|
295
297
|
return anchorTarget;
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"
|
|
300
|
+
const STRICT_INPUT_REJECT_CODES = ["[W_BAD_SHAPE]", "[W_BAD_REF]", "[W_INVALID_PATCH]", "[W_BARE_HASH_PREFIX]"];
|
|
299
301
|
export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
300
302
|
const fixes = warnings.filter((warning) => STRICT_INPUT_REJECT_CODES.some((code) => warning.startsWith(code)));
|
|
301
303
|
if (fixes.length === 0) return;
|
package/src/edit-fidelity.ts
CHANGED
|
@@ -76,8 +76,12 @@ export function isFidelitySensitiveChar(char: string): boolean {
|
|
|
76
76
|
return char === REPLACEMENT_CHAR || INVISIBLE_RE.test(char) || LOOKALIKE_SPACE_RE.test(char) || LOOKALIKE_DASH_RE.test(char) || LOOKALIKE_QUOTE_RE.test(char) || LOOKALIKE_PUNCT_RE.test(char);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
80
|
-
|
|
79
|
+
export function formatCharEscape(char: string): string {
|
|
80
|
+
const codePoint = char.codePointAt(0);
|
|
81
|
+
if (codePoint === undefined) return "";
|
|
82
|
+
if (codePoint <= 0xffff) return `\\u${codePoint.toString(16).padStart(4, "0")}`;
|
|
83
|
+
const pair = codePoint - 0x10000;
|
|
84
|
+
return `\\u${(0xd800 + (pair >> 10)).toString(16).padStart(4, "0")}\\u${(0xdc00 + (pair & 0x3ff)).toString(16).padStart(4, "0")}`;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
function plural(count: number, noun: string): string {
|
|
@@ -89,11 +93,16 @@ interface ReferenceRow {
|
|
|
89
93
|
index: number;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
function hiddenCharHint(char: string, reference: ReferenceRow,
|
|
96
|
+
function hiddenCharHint(char: string, reference: ReferenceRow, liveAnchor: string | undefined, referenceAnchor: string | undefined): string {
|
|
93
97
|
const column = [...reference.line].indexOf(char) + 1;
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
98
|
+
const escape = formatCharEscape(char);
|
|
99
|
+
const located = referenceAnchor === undefined
|
|
100
|
+
? `the replaced row had it at col ${column}`
|
|
101
|
+
: `${referenceAnchor}${HASH_SEP} has it at col ${column}`;
|
|
102
|
+
if (liveAnchor === undefined) {
|
|
103
|
+
return `[H_UNICODE_LOST] "${escape}" missing; ${located}.`;
|
|
104
|
+
}
|
|
105
|
+
return `[H_UNICODE_LOST] ${liveAnchor}${HASH_SEP} missing "${escape}"; ${located}.`;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
function withCharsRestored(line: string, chars: readonly string[], replacementFor: (char: string) => string): string {
|
|
@@ -122,6 +131,7 @@ interface SwappedChar {
|
|
|
122
131
|
interface SwapCandidate {
|
|
123
132
|
reference: ReferenceRow;
|
|
124
133
|
swapped: SwappedChar;
|
|
134
|
+
liveAnchor?: string;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
function containsSensitiveChar(line: string): boolean {
|
|
@@ -134,6 +144,14 @@ function normalizeSensitiveChars(line: string): string {
|
|
|
134
144
|
return normalized;
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
function sensitiveStripped(line: string): string {
|
|
148
|
+
let visible = "";
|
|
149
|
+
for (const char of line) {
|
|
150
|
+
if (!isFidelitySensitiveChar(char)) visible += char;
|
|
151
|
+
}
|
|
152
|
+
return visible;
|
|
153
|
+
}
|
|
154
|
+
|
|
137
155
|
function isLookalikeSwap(oldChar: string, newChar: string): boolean {
|
|
138
156
|
if (isFidelitySensitiveChar(oldChar) && isFidelitySensitiveChar(newChar)) return true;
|
|
139
157
|
return LOOKALIKE_SUBSTITUTES[newChar] === oldChar;
|
|
@@ -150,21 +168,46 @@ function sharesFidelityClass(left: string, right: string): boolean {
|
|
|
150
168
|
);
|
|
151
169
|
}
|
|
152
170
|
|
|
153
|
-
|
|
171
|
+
interface SubstituteMatch {
|
|
172
|
+
value: string;
|
|
173
|
+
payloadIndex: number;
|
|
174
|
+
column: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface SubstituteRank {
|
|
178
|
+
echo: boolean;
|
|
179
|
+
aligned: boolean;
|
|
180
|
+
distance: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function preferSubstitute(candidate: SubstituteRank, best: SubstituteRank | undefined): boolean {
|
|
184
|
+
if (best === undefined) return true;
|
|
185
|
+
if (candidate.echo !== best.echo) return candidate.echo;
|
|
186
|
+
if (candidate.aligned !== best.aligned) return candidate.aligned;
|
|
187
|
+
return candidate.distance < best.distance;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function findSubstitute(char: string, reference: ReferenceRow, payload: readonly string[]): SubstituteMatch | undefined {
|
|
154
191
|
const referenceChars = [...reference.line];
|
|
155
192
|
const column = referenceChars.indexOf(char);
|
|
156
|
-
|
|
193
|
+
const visibleReference = sensitiveStripped(reference.line);
|
|
194
|
+
let best: SubstituteMatch | undefined;
|
|
195
|
+
let bestRank: SubstituteRank | undefined;
|
|
196
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
197
|
+
const line = payload[index]!;
|
|
157
198
|
const candidateChars = [...line];
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
199
|
+
const echo = sensitiveStripped(line) === visibleReference;
|
|
200
|
+
const sameLength = candidateChars.length === referenceChars.length;
|
|
201
|
+
for (let at = 0; at < candidateChars.length; at += 1) {
|
|
202
|
+
const candidate = candidateChars[at]!;
|
|
203
|
+
if (candidate === char || !sharesFidelityClass(char, candidate)) continue;
|
|
204
|
+
const rank = { echo, aligned: sameLength && at === column, distance: Math.abs(at - column) };
|
|
205
|
+
if (!preferSubstitute(rank, bestRank)) continue;
|
|
206
|
+
bestRank = rank;
|
|
207
|
+
best = { value: candidate, payloadIndex: index, column: at + 1 };
|
|
165
208
|
}
|
|
166
209
|
}
|
|
167
|
-
return
|
|
210
|
+
return best;
|
|
168
211
|
}
|
|
169
212
|
|
|
170
213
|
function swappedCharPair(oldLine: string, newLine: string): SwappedChar | undefined {
|
|
@@ -193,10 +236,15 @@ function swappedCandidate(line: string, matches: IndexedLine[], spanStart: numbe
|
|
|
193
236
|
return candidates.find((candidate) => candidate.reference.index >= spanStart && candidate.reference.index <= spanEnd) ?? candidates[0];
|
|
194
237
|
}
|
|
195
238
|
|
|
196
|
-
function swappedCharHint(swapped: SwappedChar,
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
239
|
+
function swappedCharHint(swapped: SwappedChar, liveAnchor: string | undefined, referenceAnchor: string | undefined): string {
|
|
240
|
+
const newEscape = formatCharEscape(swapped.newChar);
|
|
241
|
+
const oldEscape = formatCharEscape(swapped.oldChar);
|
|
242
|
+
if (liveAnchor === undefined) {
|
|
243
|
+
const label = referenceAnchor === undefined ? "the replaced line" : `${referenceAnchor}${HASH_SEP}`;
|
|
244
|
+
return `[H_UNICODE_SWAPPED] "${newEscape}" at col ${swapped.column} where ${label} has "${oldEscape}".`;
|
|
245
|
+
}
|
|
246
|
+
const referenceNote = referenceAnchor === undefined ? `; expected "${oldEscape}"` : `; ${referenceAnchor}${HASH_SEP} has "${oldEscape}"`;
|
|
247
|
+
return `[H_UNICODE_SWAPPED] ${liveAnchor}${HASH_SEP} has "${newEscape}" at col ${swapped.column}${referenceNote}.`;
|
|
200
248
|
}
|
|
201
249
|
|
|
202
250
|
function trailingWhitespaceHint(oldLine: string, newLine: string, anchor: string | undefined): string {
|
|
@@ -327,18 +375,6 @@ function literalEscapeHintRows(
|
|
|
327
375
|
return rows;
|
|
328
376
|
}
|
|
329
377
|
|
|
330
|
-
function actualEscapeName(escape: string): string {
|
|
331
|
-
const hex = /^\\u([0-9a-fA-F]{4})$/.exec(escape)?.[1];
|
|
332
|
-
if (hex !== undefined) return `U+${hex.toUpperCase()}`;
|
|
333
|
-
if (escape.length === 2) {
|
|
334
|
-
if (escape[1] === "n") return "a line break";
|
|
335
|
-
if (escape[1] === "r") return "a carriage return";
|
|
336
|
-
if (escape[1] === "t") return "a tab";
|
|
337
|
-
if (escape[1] === '"') return "a double quote";
|
|
338
|
-
}
|
|
339
|
-
return "the character";
|
|
340
|
-
}
|
|
341
|
-
|
|
342
378
|
export function annotateLiteralEscapeHints(
|
|
343
379
|
hints: string[],
|
|
344
380
|
resultContent: string,
|
|
@@ -353,10 +389,10 @@ export function annotateLiteralEscapeHints(
|
|
|
353
389
|
const rows = literalEscapeHintRows(resultContent, spans, resultHashes, escape);
|
|
354
390
|
if (rows.length === 0) return hint;
|
|
355
391
|
if (rows.length > MAX_LITERAL_ESCAPE_ROWS) {
|
|
356
|
-
return `${hint} on ${plural(rows.length, "row")}; undo_last_change
|
|
392
|
+
return `${hint} on ${plural(rows.length, "row")}; undo_last_change if unintended.`;
|
|
357
393
|
}
|
|
358
394
|
const locations = rows.map((row) => `${resultHashes[row.index]!}${HASH_SEP} col ${literalEscapeColumn(row.line, escape)}`).join(", ");
|
|
359
|
-
return `${hint} (${locations})
|
|
395
|
+
return `${hint} (${locations}).`;
|
|
360
396
|
});
|
|
361
397
|
}
|
|
362
398
|
|
|
@@ -404,6 +440,8 @@ export function fidelityHints(
|
|
|
404
440
|
return sensitiveIndex;
|
|
405
441
|
};
|
|
406
442
|
let offset = 0;
|
|
443
|
+
const removedByAnySpan = (index: number): boolean =>
|
|
444
|
+
spans.some((candidate) => index >= candidate.start && index <= candidate.end);
|
|
407
445
|
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
|
|
408
446
|
const removedCount = span.end >= span.start ? span.end - span.start + 1 : 0;
|
|
409
447
|
const removed = removedCount > 0 ? oldLines.slice(span.start, span.end + 1) : [];
|
|
@@ -416,10 +454,31 @@ export function fidelityHints(
|
|
|
416
454
|
? removed.map((line, index) => ({ line, index: span.start + index }))
|
|
417
455
|
: referenceRows(oldLines, span.start, span.end);
|
|
418
456
|
const insertGate = carried === undefined ? null : (gramSet(payload, SIMILARITY_RUN, true) ?? new Set<string>());
|
|
457
|
+
const resultIndexForPayload = (payloadIndex: number): number =>
|
|
458
|
+
span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
|
|
459
|
+
const referenceSurvives = (reference: ReferenceRow): boolean =>
|
|
460
|
+
(carried !== undefined && reference.index === span.start && inserted[carried] === oldLines[reference.index]) ||
|
|
461
|
+
!removedByAnySpan(reference.index);
|
|
462
|
+
const liveAnchorForPayload = (payloadIndex: number): string | undefined =>
|
|
463
|
+
resultHashes?.[resultIndexForPayload(payloadIndex)];
|
|
464
|
+
const payloadIndexSharingRun = (line: string): number => {
|
|
465
|
+
const grams = gramSet([sensitiveStripped(line)], SIMILARITY_RUN);
|
|
466
|
+
return grams === undefined ? -1 : payload.findIndex((candidate) => sharesRun(sensitiveStripped(candidate), grams, SIMILARITY_RUN));
|
|
467
|
+
};
|
|
468
|
+
const liveAnchorForReference = (reference: ReferenceRow): string | undefined => {
|
|
469
|
+
const shared = payloadIndexSharingRun(reference.line);
|
|
470
|
+
if (shared >= 0) return liveAnchorForPayload(shared);
|
|
471
|
+
if (carried !== undefined || removed.length !== payload.length) return undefined;
|
|
472
|
+
const payloadIndex = reference.index - span.start;
|
|
473
|
+
return payloadIndex >= 0 && payloadIndex < payload.length ? liveAnchorForPayload(payloadIndex) : undefined;
|
|
474
|
+
};
|
|
475
|
+
const referenceAnchorFor = (reference: ReferenceRow): string | undefined =>
|
|
476
|
+
referenceSurvives(reference) ? originalHashes?.[reference.index] : undefined;
|
|
419
477
|
const swapKeys = new Set<string>();
|
|
420
478
|
const swaps: SwapCandidate[] = [];
|
|
421
479
|
let candidates: Map<string, IndexedLine[]> | undefined;
|
|
422
|
-
for (
|
|
480
|
+
for (let payloadIndex = 0; payloadIndex < payload.length; payloadIndex += 1) {
|
|
481
|
+
const line = payload[payloadIndex]!;
|
|
423
482
|
if (!containsSensitiveChar(line)) continue;
|
|
424
483
|
candidates ??= sensitiveLines();
|
|
425
484
|
const matches = [...(candidates.get(normalizeSensitiveChars(line)) ?? []), ...references];
|
|
@@ -428,7 +487,7 @@ export function fidelityHints(
|
|
|
428
487
|
const key = `${candidate.reference.index}:${candidate.swapped.oldChar}`;
|
|
429
488
|
if (swapKeys.has(key)) continue;
|
|
430
489
|
swapKeys.add(key);
|
|
431
|
-
swaps.push(candidate);
|
|
490
|
+
swaps.push({ ...candidate, liveAnchor: liveAnchorForPayload(payloadIndex) });
|
|
432
491
|
}
|
|
433
492
|
const missing = new Map<string, ReferenceRow>();
|
|
434
493
|
for (const reference of references) {
|
|
@@ -449,17 +508,17 @@ export function fidelityHints(
|
|
|
449
508
|
const substitute = findSubstitute(char, reference, payload);
|
|
450
509
|
if (substitute !== undefined) {
|
|
451
510
|
seen.add(`swap:${reference.index}:${char}`);
|
|
452
|
-
hints.push(swappedCharHint({ oldChar: char, newChar: substitute, column:
|
|
511
|
+
hints.push(swappedCharHint({ oldChar: char, newChar: substitute.value, column: substitute.column }, liveAnchorForPayload(substitute.payloadIndex), referenceAnchorFor(reference)));
|
|
453
512
|
continue;
|
|
454
513
|
}
|
|
455
|
-
hints.push(hiddenCharHint(char, reference,
|
|
514
|
+
hints.push(hiddenCharHint(char, reference, liveAnchorForReference(reference), referenceAnchorFor(reference)));
|
|
456
515
|
}
|
|
457
516
|
for (const candidate of swaps) {
|
|
458
517
|
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
459
518
|
const key = `swap:${candidate.reference.index}:${candidate.swapped.oldChar}`;
|
|
460
519
|
if (seen.has(key)) continue;
|
|
461
520
|
seen.add(key);
|
|
462
|
-
hints.push(swappedCharHint(candidate.swapped,
|
|
521
|
+
hints.push(swappedCharHint(candidate.swapped, candidate.liveAnchor, referenceAnchorFor(candidate.reference)));
|
|
463
522
|
}
|
|
464
523
|
const indentReferenceFor = (payloadIndex: number): ReferenceRow | undefined => {
|
|
465
524
|
if (options?.indentHints === false) return undefined;
|
|
@@ -482,8 +541,6 @@ export function fidelityHints(
|
|
|
482
541
|
}
|
|
483
542
|
return undefined;
|
|
484
543
|
};
|
|
485
|
-
const resultIndexForPayload = (payloadIndex: number): number =>
|
|
486
|
-
span.start + offset + (carried !== undefined && payloadIndex >= carried ? payloadIndex + 1 : payloadIndex);
|
|
487
544
|
for (let index = 0; index < payload.length; index += 1) {
|
|
488
545
|
if (hints.length >= MAX_FIDELITY_HINTS) break;
|
|
489
546
|
const payloadLine = payload[index]!;
|