@hncode/hncode 0.5.1 → 0.5.4
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/tools/edit.js
CHANGED
|
@@ -28,21 +28,24 @@ import { hashRegion as hashLines } from './read.js';
|
|
|
28
28
|
|
|
29
29
|
export const spec = {
|
|
30
30
|
name: 'Edit',
|
|
31
|
-
description: 'Edit a file.
|
|
31
|
+
description: 'Edit a file. THREE shapes:\n- LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string.\nPREFERRED — use it whenever you have Read the file and know the line numbers.\n- EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use when the exact text is known and a line range is not convenient.\n- RANGED SUBSTRING mode: path + start_line + end_line + old_string + new_string. Searches for old_string ONLY inside [start_line, end_line], and replaces only what it finds there. Use this when the same snippet appears several times and you need exactly one of them: replace_all changes every copy, while plain substring mode refuses an ambiguous match. The range says WHERE to look, old_string says WHAT to replace.\nLEADING WHITESPACE — SPELL IT OUT ON EVERY LINE, INCLUDING THE FIRST. new_string is written VERBATIM in both modes: nothing is re-indented, auto-indented, or trimmed, and there is no tolerance for a missing first-line indent. Reproduce the indentation the code SHOULD have; it need not match the surrounding block. Copy it from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).\nWRONG — nothing between the quote and `return`, so the line lands at column 0 inside an indented block. Look at the SHAPE as well: the quote touches the first character, and spaces only turn up after the first newline.\nnew_string: "return total;\n total += 1;"\nRIGHT — four spaces, then `return`. The opening quote is followed by the indent as well, so every line carries its own indentation INCLUDING the first:\nnew_string: " return total;\n total += 1;"\nSame call, same words. The only difference is the spaces at the START of the first line, and that is the whole point — in the RIGHT form the indent sits between the quote and the code, exactly where it sits in the file you copied it from.\nThe same applies to tabs, and to a line whose indentation changes partway through.\nRANGE HYGIENE — COUNT THE LINES BEFORE YOU SEND. Lines [start_line, end_line] are deleted and new_string is inserted in their place. The rule that follows from that is arithmetic, not judgement:\n new_string must have EXACTLY (end_line - start_line + 1) lines.\nCount them. If you wrote three lines for a two-line range, one of them already exists past end_line — it was not deleted, so it is now duplicated. Do not reason about which line "looks related"; count. A range that should grow is a WRONG RANGE, so widen end_line rather than adding the extra line by hand.\nThe mistake looks like this — end_line: 3 for lines 2-3 is a TWO-line range, given three lines:\nnew_string: "const a = 1;\nconst b = 20;\nconst c = 3;" (3 lines for a 2-line range: c is now duplicated)\nAnd correct — two lines for a two-line range:\nnew_string: "const a = 1;\nconst b = 20;"\nTo replace line 4 as well, set end_line: 4 — then three lines are correct and the count agrees.\nIN A DIFF, A DUPLICATE SHOWS AS THE SAME LINE NUMBER TWICE: a "-" row and a "+" row numbered alike, or the same number on two rows. That is the signature of this mistake, not of a tool fault. Read the diff before continuing; it costs one look.\nA DUPLICATED SEAM IS REMOVED FOR YOU. If the last line of new_string is the same text as the line at\nend_line + 1 — the line just past the range, which is never deleted — that last line is dropped, because\nit would otherwise appear twice. The reply says what was dropped. Pass allow_dup: true when two\nidentical adjacent lines are what you actually want.\nIn SUBSTRING and RANGED SUBSTRING modes old_string is matched EXACTLY, including the indentation of its own first line.\nOmitting new_string is an error, not an empty replacement.\nMis-indented lines often still parse, so a green test run is not proof that an edit landed correctly — re-Read the region before editing it again.\',',
|
|
32
32
|
parameters: {
|
|
33
33
|
type: 'object',
|
|
34
34
|
properties: {
|
|
35
35
|
path: { type: 'string', description: 'Path to the file to edit.' },
|
|
36
|
-
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit
|
|
36
|
+
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit for pure line-range mode. COMBINE it with start_line/end_line to replace only the occurrence inside that range.' },
|
|
37
37
|
new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
|
|
38
38
|
|
|
39
39
|
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
allow_dup: { type: 'boolean', default: false, description: 'Permit an edit that leaves two adjacent identical lines. OFF by default, so an edit that restates the line just past end_line is refused — that line is outside the replaced range and was never deleted, so the file would end up holding it twice. Set true only when the duplicate IS the intent.' },
|
|
41
|
+
start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_string). With old_string too, it is the first line to SEARCH for it in.' },
|
|
42
|
+
end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line). With old_string too, it is the last line to search in.' },
|
|
42
43
|
},
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
44
|
+
// Three accepted shapes:
|
|
45
|
+
// line-range start_line + end_line + new_string
|
|
46
|
+
// exact-substring old_string + new_string
|
|
47
|
+
// rangedToWindow-substring start_line + end_line + old_string + new_string
|
|
48
|
+
// Line-range is the preferred route; the third exists for "one of several copies".
|
|
46
49
|
anyOf: [
|
|
47
50
|
{ required: ['path', 'start_line', 'end_line', 'new_string'] },
|
|
48
51
|
{ required: ['path', 'old_string', 'new_string'] },
|
|
@@ -77,28 +80,85 @@ export const spec = {
|
|
|
77
80
|
return `Error reading ${args.path}: ${e.message}`;
|
|
78
81
|
}
|
|
79
82
|
const { start_line, end_line } = args;
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
+
// THREE shapes, not two.
|
|
84
|
+
//
|
|
85
|
+
// line-range start_line + end_line + new_string
|
|
86
|
+
// substring old_string + new_string
|
|
87
|
+
// RANGED SUB start_line + end_line + old_string + new_string
|
|
88
|
+
//
|
|
89
|
+
// The third is what the first two cannot express: replace ONE occurrence of a snippet that
|
|
90
|
+
// appears several times, without `replace_all` (which would hit every copy) and without
|
|
91
|
+
// counting lines by hand. The range narrows WHERE to look; `old_string` says WHAT to replace
|
|
92
|
+
// inside it. Rejecting the combination sent callers to `replace_all` as their only option,
|
|
93
|
+
// which is how the wrong copy gets edited.
|
|
94
|
+
//
|
|
95
|
+
// `old_string` alongside a range is therefore not a mistake — but a range with NEITHER
|
|
96
|
+
// old_string nor new_string still is, and is reported below.
|
|
83
97
|
const hasRangeKeys = args.start_line != null || args.end_line != null;
|
|
84
98
|
const hasSubKeys = args.old_string != null;
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
const hasNewString = (args.new_string != null) || (args.new_content != null);
|
|
100
|
+
if (hasRangeKeys && hasSubKeys && !hasNewString) {
|
|
101
|
+
return 'Error: a rangedToWindow edit needs new_string as well as old_string.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- RANGED SUBSTRING ---------------------------------------------------
|
|
105
|
+
// A range AND an old_string: search for the snippet only INSIDE the range. Handled here,
|
|
106
|
+
// before the pure line-range path, because that path replaces whole lines and would ignore
|
|
107
|
+
// the snippet entirely — the very ambiguity the old mutual-exclusion check was avoiding.
|
|
108
|
+
//
|
|
109
|
+
// The range is validated, then the substring path below does the matching with the search
|
|
110
|
+
// window clamped to it. Splitting the work this way keeps ONE implementation of matching
|
|
111
|
+
// (quote-normalized retry, staleness, whitespace tolerance) instead of a second, weaker copy.
|
|
112
|
+
// A HALF-GIVEN RANGE with an old_string is not a reason to refuse: the snippet locates the
|
|
113
|
+
// edit by itself, and the single bound is a hint that failed to pair. Callers add `start_line`
|
|
114
|
+
// from the schema's own wording ("1-based first line to replace") expecting it to help, so
|
|
115
|
+
// refusing rejected a request that was already complete.
|
|
116
|
+
//
|
|
117
|
+
// `rangedToWindow` is set even though no window is applied. It means "the range question has been
|
|
118
|
+
// settled", which is what the line-range guard below tests — leaving it false made that guard
|
|
119
|
+
// see the same unpaired bound and reject the call one block later.
|
|
120
|
+
let searchFrom = 0;
|
|
121
|
+
let searchTo = 0; // 0,0 = the whole file
|
|
122
|
+
// A real window was validated, so the search below is confined to it. Distinct from
|
|
123
|
+
// `rangeHandled`: this one means "clamp", and setting it for a snippet with no window made
|
|
124
|
+
// every search run inside 0..0 and find nothing.
|
|
125
|
+
let rangedToWindow = false;
|
|
126
|
+
// The range question is answered — either a window was applied, or the caller gave an
|
|
127
|
+
// `old_string` that locates the edit on its own and a half-given bound was ignored.
|
|
128
|
+
let rangeHandled = hasSubKeys;
|
|
129
|
+
if (hasRangeKeys && hasSubKeys && hasNewString) {
|
|
130
|
+
if ((start_line != null) !== (end_line != null)) {
|
|
131
|
+
// Ignore the unpaired bound; `searchFrom`/`searchTo` stay at the whole file and `rangedToWindow`
|
|
132
|
+
// was already set above.
|
|
133
|
+
searchFrom = 0;
|
|
134
|
+
searchTo = 0;
|
|
135
|
+
} else {
|
|
136
|
+
// The range was given in full: validate it, then confine the search to it.
|
|
137
|
+
const start = Number(start_line) | 0, end = Number(end_line) | 0;
|
|
138
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 1 || end < start) {
|
|
139
|
+
return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
|
|
140
|
+
}
|
|
141
|
+
{
|
|
142
|
+
const all = content.split(/\r\n|\r|\n/);
|
|
143
|
+
if (end > all.length) {
|
|
144
|
+
return `Error: end_line ${end_line} exceeds file length (${all.length} lines).`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
rangedToWindow = true;
|
|
148
|
+
rangeHandled = true;
|
|
149
|
+
searchFrom = start;
|
|
150
|
+
searchTo = end;
|
|
151
|
+
}
|
|
87
152
|
}
|
|
88
153
|
|
|
89
154
|
// ---- LINE-RANGE mode ---------------------------------------------------
|
|
90
155
|
// `start_line`..`end_line` replace the lines at those 1-based positions,
|
|
91
156
|
// with NO old_string involved at all. This is the path for "change line 12"
|
|
92
|
-
// where spelling out the exact current text is awkward.
|
|
93
|
-
|
|
94
|
-
// refresh the cache -> return.
|
|
95
|
-
// ONE bound without the other is its own mistake and an easy one to make: the
|
|
96
|
-
// call then falls past this block and reports a missing old_string, which
|
|
97
|
-
// sends the model looking in the wrong place.
|
|
98
|
-
if ((start_line != null) !== (end_line != null)) {
|
|
157
|
+
// where spelling out the exact current text is awkward.
|
|
158
|
+
if (!rangeHandled && (start_line != null) !== (end_line != null)) {
|
|
99
159
|
return 'Error: line-range mode needs BOTH start_line and end_line.';
|
|
100
160
|
}
|
|
101
|
-
if (start_line != null && end_line != null) {
|
|
161
|
+
if (!rangeHandled && start_line != null && end_line != null) {
|
|
102
162
|
// Accept both spellings so old new_content callers keep working.
|
|
103
163
|
const new_string = args.new_string != null ? args.new_string : args.new_content;
|
|
104
164
|
// new_string is required — a missing one would put the literal string
|
|
@@ -129,28 +189,112 @@ export const spec = {
|
|
|
129
189
|
// drew NO diff at all (no +/- rows, no +N/-N counts). Reporting them lets
|
|
130
190
|
// the caller diff the replaced region -> `new_string`.
|
|
131
191
|
const replacedText = lines.slice(start - 1, end).join('\n');
|
|
132
|
-
|
|
192
|
+
|
|
193
|
+
// AUTO-DEDUPE THE SEAM — unless `allow_dup` says the duplicate is wanted.
|
|
194
|
+
//
|
|
195
|
+
// The recurring mistake: `new_string`'s last line is the same text as the line at `end_line + 1`.
|
|
196
|
+
// That line is OUTSIDE the replaced range, so it is never deleted — the copy inside `new_string`
|
|
197
|
+
// lands directly above it and the file holds the text twice. Every line number below then shifts.
|
|
198
|
+
//
|
|
199
|
+
// REFUSING was the first attempt and it was wrong twice over. It rejected legitimate edits — a
|
|
200
|
+
// one-line `new_string` against a six-line range matched the following line by coincidence and the
|
|
201
|
+
// edit was blocked — and it put the burden on the caller to work out whose line it was.
|
|
202
|
+
//
|
|
203
|
+
// DROPPING THE DUPLICATE is what the caller meant either way. If they restated a line they did not
|
|
204
|
+
// intend to replace, removing the restatement produces exactly the file they described; if they DID
|
|
205
|
+
// want it changed, the range was wrong — but silently producing a duplicate helps no one, so the
|
|
206
|
+
// reply says what was dropped and how to keep it.
|
|
207
|
+
//
|
|
208
|
+
// `allow_dup: true` keeps the duplicate, for the real case where two identical lines are the point.
|
|
209
|
+
// DEDUPE REPEATEDLY, not once.
|
|
210
|
+
//
|
|
211
|
+
// One pass was not enough: `new_string` can carry the SAME line twice in a row on its own — a
|
|
212
|
+
// pasted block whose first line got duplicated — and removing one copy leaves the other against
|
|
213
|
+
// the file's own line. Each removal shortens `inserted`, so the scan has to run again on what is
|
|
214
|
+
// left. Bounded by the block's own length, so it always terminates.
|
|
215
|
+
let insertedFinal = inserted;
|
|
216
|
+
const drops = [];
|
|
217
|
+
if (args.allow_dup !== true) {
|
|
218
|
+
for (let guard = 0; guard <= inserted.length; guard++) {
|
|
219
|
+
const hit = findRestatedLine(lines, insertedFinal, start, end);
|
|
220
|
+
if (!hit || hit.index < 0 || hit.index >= insertedFinal.length) break;
|
|
221
|
+
drops.push(hit);
|
|
222
|
+
// REMOVE THE DUPLICATE AND ANY BLANK LINE THAT WAS PART OF THE SAME FRAGMENT.
|
|
223
|
+
//
|
|
224
|
+
// `new_string` often carries a trailing blank line — a copy that ended with one, or a block
|
|
225
|
+
// pasted with its closing newline. When the line above that blank is the duplicate, removing only
|
|
226
|
+
// the line leaves the blank stranded: the file keeps a whitespace-only row the caller never
|
|
227
|
+
// meant to add, and it sits exactly where the duplicate used to be.
|
|
228
|
+
//
|
|
229
|
+
// Blanks DIRECTLY adjacent to the removed line go with it, on both sides. A blank line elsewhere
|
|
230
|
+
// in the block is untouched — it is not this fragment's.
|
|
231
|
+
let lo = hit.index;
|
|
232
|
+
let hi = hit.index + 1;
|
|
233
|
+
while (lo > 0 && String(insertedFinal[lo - 1]).trim() === '') lo--;
|
|
234
|
+
while (hi < insertedFinal.length && String(insertedFinal[hi]).trim() === '') hi++;
|
|
235
|
+
insertedFinal = [...insertedFinal.slice(0, lo), ...insertedFinal.slice(hi)];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const seamDrop = drops.length ? drops[0] : null;
|
|
239
|
+
|
|
240
|
+
// LAST CHANCE: if a duplicate SURVIVED the removal, do not write the file.
|
|
241
|
+
//
|
|
242
|
+
// The dedupe above tries repeatedly, but every rule it applies is a heuristic about which line is
|
|
243
|
+
// the caller's — and a wrong guess leaves the duplicate in place with a confident "dropped" note in
|
|
244
|
+
// the reply. That is worse than refusing: the message says the problem was handled.
|
|
245
|
+
//
|
|
246
|
+
// So the result is checked ONCE MORE with the same test, and if it still finds a pair the write is
|
|
247
|
+
// abandoned. `allow_dup` skips this too — a deliberate duplicate must not be refused at the door.
|
|
248
|
+
const out = [...lines.slice(0, start - 1), ...insertedFinal, ...lines.slice(end)];
|
|
249
|
+
if (args.allow_dup !== true) {
|
|
250
|
+
const leftover = findRestatedLine(lines, insertedFinal, start, end);
|
|
251
|
+
if (leftover) {
|
|
252
|
+
return `Error: the edit would leave "${leftover.text}" twice in a row (result lines `
|
|
253
|
+
+ `${leftover.a} and ${leftover.b}), and automatic removal could not settle it.\n`
|
|
254
|
+
+ 'Nothing was written. Narrow the range so it covers only the lines you mean to replace, '
|
|
255
|
+
+ 'or pass allow_dup: true if the duplicate is intentional.';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
133
258
|
const normUpdated = out.join('\n');
|
|
134
259
|
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
260
|
+
|
|
135
261
|
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
136
262
|
checkpointBeforeWrite(p, ctx);
|
|
137
263
|
try {
|
|
138
264
|
ensureDir(p);
|
|
139
265
|
fs.writeFileSync(p, updated, 'utf8');
|
|
140
266
|
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
141
|
-
refreshReadPool(p, normUpdated, { start, end: start +
|
|
267
|
+
refreshReadPool(p, normUpdated, { start, end: start + insertedFinal.length - 1 }, ctx);
|
|
142
268
|
const removed = end - start + 1;
|
|
143
269
|
// Publish the diff for the UI. The Edit line is drawn from these, so a
|
|
144
270
|
// line-range edit shows the same +/- rows and +N/-N counts as a substring
|
|
145
271
|
// edit instead of nothing. `ctx.lastEditDiff` is consumed (and cleared) by
|
|
146
272
|
// the agent loop right after this tool returns.
|
|
147
|
-
|
|
148
|
-
|
|
273
|
+
//
|
|
274
|
+
// `lines` is the pre-edit content, which is what `start` indexes into.
|
|
275
|
+
// The diff's NEW text is `insertedFinal` — what the file now holds — not the
|
|
276
|
+
// raw `new_string`: when the seam-drop removed a line, the raw text showed a
|
|
277
|
+
// `+` row that is not in the file and shifted every after-context line
|
|
278
|
+
// number by the dropped count.
|
|
279
|
+
if (ctx) ctx.lastEditDiff = buildEditDiff({ fileLines: lines, oldText: replacedText, newText: insertedFinal.join('\n'), startLine: start });
|
|
280
|
+
// Say what the seam did, whichever way it went: a silent drop and a silent keep are both
|
|
281
|
+
// indistinguishable from an edit that never had the ambiguity. The dropped line is named
|
|
282
|
+
// with its ACTUAL pair (the two result lines that would have been identical) — it is not
|
|
283
|
+
// always "the last line of new_string against end_line + 1": a block can carry the repeat
|
|
284
|
+
// inside itself, with blank lines between the copies.
|
|
285
|
+
const seamNote = seamDrop
|
|
286
|
+
? ` Dropped a duplicated line: "${seamDrop.text}" would have appeared twice in a row`
|
|
287
|
+
+ ` (result lines ${seamDrop.a} and ${seamDrop.b}). Pass allow_dup: true to keep it.`
|
|
288
|
+
: (args.allow_dup === true ? ' Duplicate kept (allow_dup: true).' : '');
|
|
289
|
+
return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${insertedFinal.length} line${insertedFinal.length === 1 ? '' : 's'}.${seamNote}`;
|
|
149
290
|
}
|
|
150
291
|
|
|
151
|
-
|
|
152
292
|
// ---- SUBSTRING mode (old_string -> new_string) --------------------------
|
|
293
|
+
// `new_content` is accepted as an alias of `new_string` — the validation above
|
|
294
|
+
// already counts it, so refusing it here sent a confusing "new_string is
|
|
295
|
+
// required" for an argument the mode's own checks accepted.
|
|
153
296
|
let { old_string, new_string, replace_all = false } = args;
|
|
297
|
+
if (new_string == null && args.new_content != null) new_string = args.new_content;
|
|
154
298
|
// Reaching here means neither mode was usable: line-range needs
|
|
155
299
|
// start_line+end_line (handled above) and substring needs old_string. When
|
|
156
300
|
// BOTH are absent, say so — "old_string must not be empty" sends the model
|
|
@@ -171,13 +315,38 @@ export const spec = {
|
|
|
171
315
|
const normOld = normalizeText(old_string);
|
|
172
316
|
const normNew = normalizeText(new_string);
|
|
173
317
|
|
|
318
|
+
// A RANGED edit searches only inside the given lines. `windowStart` is the character offset of
|
|
319
|
+
// the range's first line in `normContent`, and `windowEnd` the end of its last line; the whole
|
|
320
|
+
// file is the window when no range was given. Every match, count and replacement below uses
|
|
321
|
+
// this window, so a snippet that appears elsewhere in the file is neither found nor touched —
|
|
322
|
+
// which is the entire point, and what `replace_all` cannot express.
|
|
323
|
+
let windowStart = 0;
|
|
324
|
+
let windowEnd = normContent.length;
|
|
325
|
+
if (rangedToWindow) {
|
|
326
|
+
const allLines = normContent.split('\n');
|
|
327
|
+
// Offset of line N's first character: the sum of the lengths of lines 1..N-1 plus their
|
|
328
|
+
// newlines. Computed by walking, because a single `indexOf` would find the wrong copy of a
|
|
329
|
+
// line that also occurs earlier in the file.
|
|
330
|
+
const offsetOfLine = (n) => {
|
|
331
|
+
let at = 0;
|
|
332
|
+
for (let i = 0; i < n - 1 && i < allLines.length; i++) at += allLines[i].length + 1;
|
|
333
|
+
return at;
|
|
334
|
+
};
|
|
335
|
+
windowStart = offsetOfLine(searchFrom);
|
|
336
|
+
const lastLineStart = offsetOfLine(searchTo);
|
|
337
|
+
windowEnd = Math.min(normContent.length, lastLineStart + (allLines[searchTo - 1] || '').length);
|
|
338
|
+
}
|
|
339
|
+
/** Occurrences of `needle` inside the window. */
|
|
340
|
+
const countInWindow = (needle) => {
|
|
341
|
+
if (!needle) return 0;
|
|
342
|
+
const seg = normContent.slice(windowStart, windowEnd);
|
|
343
|
+
return seg.split(needle).length - 1;
|
|
344
|
+
};
|
|
345
|
+
|
|
174
346
|
let matchOld = normOld; // the string that will actually be matched/replaced
|
|
175
347
|
let matchNew = normNew;
|
|
176
|
-
let count =
|
|
348
|
+
let count = countInWindow(matchOld);
|
|
177
349
|
if (count === 0) {
|
|
178
|
-
// Quote-normalized retry: the file may use curly quotes while the model sent
|
|
179
|
-
// straight ones. Match on the normalized forms, then replace the ACTUAL
|
|
180
|
-
// substring found in the file so typography is preserved.
|
|
181
350
|
// Quote-normalized retry: the file may use curly quotes while the model sent
|
|
182
351
|
// straight ones. Match on the normalized forms, then replace the ACTUAL
|
|
183
352
|
// substring found in the file so typography is preserved. normalizeQuotes is
|
|
@@ -185,22 +354,35 @@ export const spec = {
|
|
|
185
354
|
const normFileQ = normalizeQuotes(normContent);
|
|
186
355
|
if (normFileQ !== normContent) {
|
|
187
356
|
const normOldQ = normalizeQuotes(normOld);
|
|
188
|
-
|
|
189
|
-
|
|
357
|
+
// Search the NORMALIZED window for the normalized snippet, then take the real text at
|
|
358
|
+
// that place so the replacement keeps the file's own typography.
|
|
359
|
+
const idx = normFileQ.indexOf(normOldQ, windowStart);
|
|
360
|
+
if (idx >= 0 && idx + normOldQ.length <= windowEnd) {
|
|
190
361
|
matchOld = normContent.slice(idx, idx + normOldQ.length);
|
|
191
362
|
matchNew = normNew; // keep the model's new_string as written
|
|
192
|
-
count =
|
|
363
|
+
count = countInWindow(matchOld);
|
|
193
364
|
}
|
|
194
365
|
}
|
|
195
366
|
}
|
|
196
367
|
if (count === 0) {
|
|
368
|
+
if (rangedToWindow) {
|
|
369
|
+
// Say WHERE it was looked for, so a snippet that exists elsewhere is not reported as
|
|
370
|
+
// missing outright — that sends the caller hunting for a typo that is not there.
|
|
371
|
+
const elsewhere = normContent.split(normOld).length - 1;
|
|
372
|
+
const hint = elsewhere
|
|
373
|
+
? ` It appears ${elsewhere} time(s) elsewhere in the file, outside lines ${searchFrom}-${searchTo}. Broaden the range, or correct the range.`
|
|
374
|
+
: '';
|
|
375
|
+
return `Error: old_string not found within lines ${searchFrom}-${searchTo} of ${args.path}.${hint}`;
|
|
376
|
+
}
|
|
197
377
|
const near = nearestLineHint(normContent, normOld);
|
|
198
378
|
return `Error: old_string not found in ${args.path}.${near}`;
|
|
199
379
|
}
|
|
200
380
|
if (count > 1 && !replace_all) {
|
|
381
|
+
if (rangedToWindow) {
|
|
382
|
+
return `Error: old_string matched ${count} occurrences within lines ${searchFrom}-${searchTo} of ${args.path}. Narrow the range, or use replace_all=true to change all of them.`;
|
|
383
|
+
}
|
|
201
384
|
return `Error: old_string matched ${count} occurrences in ${args.path}. Use replace_all=true or make old_string unique.`;
|
|
202
385
|
}
|
|
203
|
-
|
|
204
386
|
// --- Staleness guard: the AI must edit against content it actually Read.
|
|
205
387
|
// We stored per-region + whole-file hashes in ctx.readPool when Read ran.
|
|
206
388
|
// If the file changed on disk since (external edit, stream, Ctrl-C side
|
|
@@ -208,9 +390,28 @@ export const spec = {
|
|
|
208
390
|
const stale = checkStale(p, normContent, matchOld, ctx);
|
|
209
391
|
if (stale) return stale;
|
|
210
392
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
393
|
+
// Replace INSIDE the window only. A rangedToWindow edit must not touch an identical snippet sitting
|
|
394
|
+
// before or after the range — replacing across the whole file is the failure the range exists
|
|
395
|
+
// to prevent. With no range the window IS the whole file, so non-rangedToWindow callers are unchanged.
|
|
396
|
+
//
|
|
397
|
+
// The replacement is passed as a FUNCTION: a string second argument makes JS interpret
|
|
398
|
+
// `$&`, `` $` ``, `$'` and `$$` in the NEW text, so an edit whose new_string contains them
|
|
399
|
+
// (regex examples, Makefile `$$`, shell code) silently wrote the wrong file.
|
|
400
|
+
const normUpdated = (() => {
|
|
401
|
+
const head = normContent.slice(0, windowStart);
|
|
402
|
+
const body = normContent.slice(windowStart, windowEnd);
|
|
403
|
+
const tail = normContent.slice(windowEnd);
|
|
404
|
+
const literal = () => matchNew;
|
|
405
|
+
return head + (replace_all ? body.replaceAll(matchOld, literal) : body.replace(matchOld, literal)) + tail;
|
|
406
|
+
})();
|
|
407
|
+
// The replaced text's actual character offset in normContent (first occurrence
|
|
408
|
+
// inside the window — guaranteed >= 0, the count above found it). Everything
|
|
409
|
+
// before the match is unchanged, so this one offset anchors BOTH the diff and
|
|
410
|
+
// the Read-pool re-arm below to the real edit site. Locating the site by
|
|
411
|
+
// matching its first LINE (the old approach) collapsed to line 1 when
|
|
412
|
+
// old_string starts mid-line, and pointed at an earlier copy when the file
|
|
413
|
+
// repeats that line — the diff then drew context from the top of the file.
|
|
414
|
+
const matchAt = normContent.indexOf(matchOld, windowStart);
|
|
214
415
|
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
215
416
|
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
216
417
|
checkpointBeforeWrite(p, ctx);
|
|
@@ -229,15 +430,16 @@ export const spec = {
|
|
|
229
430
|
const newLines = normUpdated.split('\n');
|
|
230
431
|
entry.fileHash = hashStr(normUpdated);
|
|
231
432
|
entry.lines = newLines.length;
|
|
232
|
-
// Locate the edit site in the NEW content:
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
const
|
|
433
|
+
// Locate the edit site in the NEW content: same character offset as the
|
|
434
|
+
// match in the old one, because the text before the match is untouched.
|
|
435
|
+
// An anchor taken from the NEW text's first line found the first copy of
|
|
436
|
+
// that line ANYWHERE in the file (and `indexOf('')` returned 0 for a
|
|
437
|
+
// deletion), recording the re-armed region at a line the model never edited.
|
|
438
|
+
if (matchAt >= 0) {
|
|
439
|
+
const at = normUpdated.slice(0, matchAt).split('\n').length - 1; // 0-based
|
|
440
|
+
const newSpan = Math.max(1, String(matchNew).split('\n').length);
|
|
239
441
|
const start = at + 1;
|
|
240
|
-
const end = Math.min(newLines.length, at +
|
|
442
|
+
const end = Math.min(newLines.length, at + newSpan);
|
|
241
443
|
// Re-hash every surviving old region against the new content, and add
|
|
242
444
|
// the freshly-edited span. Regions that no longer match are dropped.
|
|
243
445
|
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
@@ -252,17 +454,184 @@ export const spec = {
|
|
|
252
454
|
}
|
|
253
455
|
const occurrences = replace_all ? count : 1;
|
|
254
456
|
// Publish the diff for the UI (same contract as the line-range path above).
|
|
457
|
+
// `startLine` comes from the actual match offset, so a snippet that starts
|
|
458
|
+
// mid-line or a file with repeated lines still anchors the context rows at
|
|
459
|
+
// the place that was really edited.
|
|
255
460
|
if (ctx) {
|
|
256
|
-
const startLine = (()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
461
|
+
const startLine = matchAt >= 0 ? normContent.slice(0, matchAt).split('\n').length : 1;
|
|
462
|
+
ctx.lastEditDiff = buildEditDiff({
|
|
463
|
+
fileLines: normContent.split('\n'),
|
|
464
|
+
oldText: old_string,
|
|
465
|
+
newText: new_string,
|
|
466
|
+
startLine,
|
|
467
|
+
});
|
|
261
468
|
}
|
|
262
469
|
return `Edited ${args.path}: replaced ${occurrences} occurrence(s).`;
|
|
263
470
|
},
|
|
264
471
|
};
|
|
265
472
|
|
|
473
|
+
/** How many unchanged lines to show on each side of a changed region. */
|
|
474
|
+
const DIFF_CONTEXT = 5;
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
/** The line's leading whitespace, for the same-indent closer rule. */
|
|
479
|
+
function leadingWs(s) {
|
|
480
|
+
const m = /^\s*/.exec(String(s == null ? '' : s));
|
|
481
|
+
return m ? m[0] : '';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Did THIS edit restate a line it did not replace?
|
|
486
|
+
*
|
|
487
|
+
* The exact shape of the recurring mistake: `new_string` ends with the same text as the line at
|
|
488
|
+
* `end_line + 1`. That line is outside the replaced range, so it survives — and the copy inside
|
|
489
|
+
* `new_string` lands directly above it, leaving the file holding it twice.
|
|
490
|
+
*
|
|
491
|
+
* Detected DIRECTLY rather than by scanning the result for any adjacent pair. A scan cannot tell "the
|
|
492
|
+
* caller fumbled the range" from "the file always had two of these", so it either misses the case or cries
|
|
493
|
+
* wolf on legitimate code. Comparing the last inserted line against the first untouched line is exact: it
|
|
494
|
+
* fires only when the edit itself created the repetition, and never on one that was already there.
|
|
495
|
+
*
|
|
496
|
+
* `lines` is the content BEFORE the edit; `inserted` is the new lines; `start`/`end` are the range.
|
|
497
|
+
* Returns the 1-based line numbers of the pair, or null.
|
|
498
|
+
*/
|
|
499
|
+
function findRestatedLine(lines, inserted, start, end) {
|
|
500
|
+
// ASK THE RESULT, NOT THE SHAPE.
|
|
501
|
+
//
|
|
502
|
+
// Every earlier version tried to predict the duplicate from the arguments — "the last line of
|
|
503
|
+
// new_string equals the line at end_line + 1", gated on the replacement growing, or on that line
|
|
504
|
+
// differing from the one it replaced. Each rule missed a real case:
|
|
505
|
+
//
|
|
506
|
+
// replacing line N with the same text, when line N+1 already held it — nothing in the arguments
|
|
507
|
+
// distinguishes that from an ordinary rewrite, and the file ended up with the text twice.
|
|
508
|
+
//
|
|
509
|
+
// Building the result and looking at it cannot miss a case, because it is the thing being asked about.
|
|
510
|
+
const result = [...lines.slice(0, start - 1), ...inserted, ...lines.slice(end)];
|
|
511
|
+
// Scan from the start of the replaced region to a few lines PAST the inserted block.
|
|
512
|
+
//
|
|
513
|
+
// The old bound was `start - 1 + inserted.length`, which stops at the first untouched line. That is one
|
|
514
|
+
// line short whenever blank lines sit between the inserted block and the file's own copy — and exactly
|
|
515
|
+
// one line short in the shape that shipped a duplicate: `new_string` ending in `*/` while the file's own
|
|
516
|
+
// `*/` followed a blank. The pair straddled the bound and neither half was compared.
|
|
517
|
+
//
|
|
518
|
+
// A few extra lines is enough and still narrow: a duplicate further away is not this edit's doing.
|
|
519
|
+
const from = Math.max(0, start - 1);
|
|
520
|
+
const to = Math.min(result.length - 1, start - 1 + inserted.length + 4);
|
|
521
|
+
// TWO COMPARISON CHAINS, not one.
|
|
522
|
+
//
|
|
523
|
+
// `prevText` follows the last line with content; `prevPunct` follows the last line that is only
|
|
524
|
+
// punctuation. A closer is compared with the previous closer, a sentence with the previous sentence.
|
|
525
|
+
//
|
|
526
|
+
// One chain was wrong: a block ending `/** * note */` followed by `* note` `*/` repeats the `*/`, but the
|
|
527
|
+
// `* note` line overwrote the comparison base, so the second `*/` was compared against text and the
|
|
528
|
+
// duplicate went through. Measured on the reported file.
|
|
529
|
+
let prevText = '';
|
|
530
|
+
let prevTextAt = 0;
|
|
531
|
+
let prevPunct = '';
|
|
532
|
+
let prevPunctAt = 0;
|
|
533
|
+
// HOW MANY BLANK LINES SEPARATE the previous content line from this one. A blank line between two
|
|
534
|
+
// identical lines used to reset the comparison, so `A / <blank> / A` went through as though it were not
|
|
535
|
+
// a duplicate — and it is one: the caller's `new_string` carried an extra blank line, and the file ended
|
|
536
|
+
// up with the text twice with a gap in it.
|
|
537
|
+
let blanksSince = 0;
|
|
538
|
+
for (let i = from; i <= to; i++) {
|
|
539
|
+
const t = String(result[i] == null ? '' : result[i]).trim();
|
|
540
|
+
// A blank line is not content, but it does not erase the line above it either. A line made
|
|
541
|
+
// ENTIRELY of punctuation (`}`, `)`, `});` — closers) is compared too, but ONLY when the two
|
|
542
|
+
// copies carry the SAME leading whitespace, checked below. Trim-equal closers at DIFFERENT
|
|
543
|
+
// indentation are the nested closers of legitimate code (` )` above `)` in Python, an
|
|
544
|
+
// inner ` }` above the outer `}`) — treating those as a restated duplicate deleted working
|
|
545
|
+
// syntax. Same indentation, though, is the signature of a RESTATED closer: a model rewriting
|
|
546
|
+
// a block's ending copies the closers that already exist just past the range, and two
|
|
547
|
+
// same-indent identical closers in a row close the same scope twice.
|
|
548
|
+
// PUNCTUATION IS COMPARED AGAINST THE PREVIOUS PUNCTUATION, not against whatever line came last.
|
|
549
|
+
//
|
|
550
|
+
// `* xxx` sits between the two `*/` that repeat, and treating the text line as the comparison base
|
|
551
|
+
// meant the second `*/` was compared with the text and never with the first `*/`. Measured on the
|
|
552
|
+
// reported file: `/** * 注释 */` … `* 注释` `*/` went through with the closer duplicated.
|
|
553
|
+
//
|
|
554
|
+
// So two chains are kept: the last CONTENT line, and the last PUNCTUATION line. A closer is compared
|
|
555
|
+
// with the previous closer; a text line with the previous text line.
|
|
556
|
+
if (!t) { blanksSince++; continue; }
|
|
557
|
+
const isPunct = !/[a-zA-Z0-9_\u0080-\uFFFF]/.test(t);
|
|
558
|
+
// A LONE CLOSER IS NOT A DUPLICATE. `}`, `)`, `});`, `]` each close one scope and appear at every
|
|
559
|
+
// nesting level, so two in a row is ordinary code rather than a restatement. `*/` is deliberately
|
|
560
|
+
// NOT in this list: a doc block has exactly one closer, so two means the block was written twice —
|
|
561
|
+
// which is the case this whole check exists for.
|
|
562
|
+
if (t === '{' || t === '}' || t === ')' || t === '});' || t === '};' || t === ']' || t === '];') {
|
|
563
|
+
prevPunct = ''; prevPunctAt = 0; blanksSince = 0; continue;
|
|
564
|
+
}
|
|
565
|
+
const base = isPunct ? prevPunct : prevText;
|
|
566
|
+
const baseAt = isPunct ? prevPunctAt : prevTextAt;
|
|
567
|
+
if (base && t === base) {
|
|
568
|
+
const sameIndent = leadingWs(result[i]) === leadingWs(result[baseAt]);
|
|
569
|
+
if (sameIndent) {
|
|
570
|
+
// WHICH LINE IS OURS — the one INSIDE `new_string`, since the file's own copy must survive.
|
|
571
|
+
//
|
|
572
|
+
// `i` is the later of the pair and `baseAt` the earlier. Preferring `i` is what handles
|
|
573
|
+
// `A / <blank> / A`: both copies came from `new_string`, and deleting the later one leaves the
|
|
574
|
+
// earlier in place with no gap after it.
|
|
575
|
+
//
|
|
576
|
+
// Falls back to `baseAt` when `i` lies past the inserted block: that is the ordinary seam case,
|
|
577
|
+
// where the duplicate's second half is the file's own line and must not be touched.
|
|
578
|
+
let idx = i - (start - 1);
|
|
579
|
+
if (idx < 0 || idx >= inserted.length) idx = baseAt - (start - 1);
|
|
580
|
+
idx = Math.max(0, Math.min(inserted.length - 1, idx));
|
|
581
|
+
return { a: baseAt + 1, b: i + 1, text: t, index: idx, blanksBetween: blanksSince };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (isPunct) { prevPunct = t; prevPunctAt = i; }
|
|
585
|
+
else { prevText = t; prevTextAt = i; }
|
|
586
|
+
blanksSince = 0;
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Build the diff payload, including the unchanged lines around the change.
|
|
593
|
+
*
|
|
594
|
+
* The replaced lines alone do not tell a reader WHERE the edit landed: a four-line block quoted with no
|
|
595
|
+
* surroundings could be anywhere in a 500-line file, so neither the model nor the user could tell a
|
|
596
|
+
* correct edit from one applied at the right text in the wrong place — and with repeated code (several
|
|
597
|
+
* similar blocks, two `}` in a row) the same text appears in many places.
|
|
598
|
+
*
|
|
599
|
+
* `context` carries the file's own lines so the renderer can draw them unmarked, above and below.
|
|
600
|
+
*
|
|
601
|
+
* `fileLines` is the content BEFORE the edit, for the replaced region's START LINE to be meaningful:
|
|
602
|
+
* `startLine` is a line number in that same file.
|
|
603
|
+
*/
|
|
604
|
+
function buildEditDiff({ fileLines, oldText, newText, startLine }) {
|
|
605
|
+
const lines = Array.isArray(fileLines) ? fileLines : [];
|
|
606
|
+
const before = [];
|
|
607
|
+
const after = [];
|
|
608
|
+
const replacedCount = normalizeText(oldText).split('\n').length;
|
|
609
|
+
// `startLine` is 1-based; the replaced block occupies [startLine, startLine + n - 1].
|
|
610
|
+
const lastReplaced = startLine + replacedCount - 1;
|
|
611
|
+
for (let n = startLine - DIFF_CONTEXT; n < startLine; n++) {
|
|
612
|
+
if (n >= 1 && n <= lines.length) before.push({ n, text: lines[n - 1] });
|
|
613
|
+
}
|
|
614
|
+
// The AFTER context is drawn from the file BEFORE the edit, but its line numbers must describe the file
|
|
615
|
+
// AFTER it — that is the file a reader has open when they read the diff.
|
|
616
|
+
//
|
|
617
|
+
// Using the pre-edit numbers looked right for a same-size replacement and drifted by one per added line
|
|
618
|
+
// otherwise: an edit that grew the file by three shifted every context row, so the diff showed line
|
|
619
|
+
// numbers that did not exist where it claimed, or repeated ones that did.
|
|
620
|
+
//
|
|
621
|
+
// `net` is the number of lines the edit added (negative when it shrank the file), so every line below
|
|
622
|
+
// the replaced block moves by exactly that much.
|
|
623
|
+
const net = normalizeText(newText).split('\n').length - replacedCount;
|
|
624
|
+
for (let n = lastReplaced + 1; n <= lastReplaced + DIFF_CONTEXT; n++) {
|
|
625
|
+
if (n >= 1 && n <= lines.length) after.push({ n: n + net, text: lines[n - 1] });
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
old: normalizeText(oldText),
|
|
629
|
+
new: normalizeText(newText),
|
|
630
|
+
startLine,
|
|
631
|
+
context: { before, after },
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
266
635
|
// Suggest a near-miss filename when a path does not exist: same directory, an
|
|
267
636
|
// exact case-insensitive match, same stem with a different extension, or a
|
|
268
637
|
// prefix match. Saves the model a round-trip of guessing (Claude Code's
|
|
@@ -340,8 +709,17 @@ function checkStale(p, normContent, normOld, ctx) {
|
|
|
340
709
|
const curFileHash = hashStr(normContent);
|
|
341
710
|
|
|
342
711
|
// Locate the edit target's first line in the CURRENT file (1-based).
|
|
712
|
+
//
|
|
713
|
+
// An EXACT line match first, then a line that merely CONTAINS it. The exact match alone
|
|
714
|
+
// missed every edit whose `old_string` begins mid-line — `old_string: "return 1;"` against
|
|
715
|
+
// a line ` return 1;` found nothing, so `editLine` was -1 and step (1) below was skipped
|
|
716
|
+
// entirely: the edit was then reported as stale (branch 2) or, with the whole-file hash
|
|
717
|
+
// also differing, rejected as though the file had changed. Two-line `old_string`s are the
|
|
718
|
+
// common case for a range edit, and their first line usually IS whole, which is why this
|
|
719
|
+
// only showed up on single-line and indented-prefix edits.
|
|
343
720
|
const firstLine = String(normOld).split('\n')[0];
|
|
344
|
-
|
|
721
|
+
let idx = curLines.indexOf(firstLine);
|
|
722
|
+
if (idx < 0 && firstLine) idx = curLines.findIndex((l) => l.includes(firstLine));
|
|
345
723
|
const editLine = idx >= 0 ? idx + 1 : -1;
|
|
346
724
|
|
|
347
725
|
// (1) The edit target must sit inside a region the AI actually Read, and that
|