pi-hashline-edit-pro 0.5.1 → 0.6.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 +15 -15
- package/index.ts +9 -3
- package/package.json +2 -2
- package/prompts/read-guidelines.md +2 -3
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +7 -21
- package/prompts/replace-guidelines.md +3 -0
- package/prompts/replace-snippet.md +1 -0
- package/prompts/replace.md +70 -0
- package/src/hashline/apply.ts +60 -225
- package/src/hashline/resolve.ts +81 -215
- package/src/read.ts +5 -6
- package/src/{edit-normalize.ts → replace-normalize.ts} +1 -1
- package/src/{edit-render.ts → replace-render.ts} +16 -16
- package/src/{edit-response.ts → replace-response.ts} +8 -8
- package/src/{edit.ts → replace.ts} +36 -50
- package/prompts/edit-guidelines.md +0 -3
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -59
- /package/src/{edit-diff.ts → replace-diff.ts} +0 -0
package/src/hashline/resolve.ts
CHANGED
|
@@ -10,21 +10,12 @@ export type ResolvedAnchor = {
|
|
|
10
10
|
hashMatched: boolean;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export type HashlineEdit =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
| {
|
|
20
|
-
op: "replace";
|
|
21
|
-
start: ResolvedAnchor;
|
|
22
|
-
end: ResolvedAnchor;
|
|
23
|
-
lines: string[];
|
|
24
|
-
}
|
|
25
|
-
| { op: "append"; pos?: ResolvedAnchor; lines: string[] }
|
|
26
|
-
| { op: "prepend"; pos?: ResolvedAnchor; lines: string[] };
|
|
27
|
-
|
|
13
|
+
export type HashlineEdit = { start: Anchor; end: Anchor; lines: string[] };
|
|
14
|
+
export type ResolvedHashlineEdit = {
|
|
15
|
+
start: ResolvedAnchor;
|
|
16
|
+
end: ResolvedAnchor;
|
|
17
|
+
lines: string[];
|
|
18
|
+
};
|
|
28
19
|
interface HashMismatch {
|
|
29
20
|
ref: Anchor;
|
|
30
21
|
kind: "not_found" | "ambiguous";
|
|
@@ -38,8 +29,6 @@ export interface NoopEdit {
|
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
export type HashlineToolEdit = {
|
|
41
|
-
op: string;
|
|
42
|
-
pos?: string;
|
|
43
32
|
start?: string;
|
|
44
33
|
end?: string;
|
|
45
34
|
lines?: string[];
|
|
@@ -89,15 +78,15 @@ export function formatMismatchError(
|
|
|
89
78
|
|
|
90
79
|
if (notFound.length > 0) {
|
|
91
80
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
out.push(
|
|
82
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}: ${refList}. Call read() to get fresh anchors, then copy the 4-character HASH from each line into your next replace call.`
|
|
83
|
+
);
|
|
95
84
|
}
|
|
96
85
|
if (ambiguous.length > 0) {
|
|
97
86
|
if (out.length > 0) out.push("");
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
out.push(
|
|
88
|
+
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}. Call read() to get fresh anchors, then copy the 4-character HASH from each line into your next replace call.`
|
|
89
|
+
);
|
|
101
90
|
for (const m of ambiguous) {
|
|
102
91
|
const sample = (m.candidates ?? []).slice(0, 5);
|
|
103
92
|
const more =
|
|
@@ -116,21 +105,12 @@ export function formatMismatchError(
|
|
|
116
105
|
}
|
|
117
106
|
}
|
|
118
107
|
|
|
119
|
-
out.push("");
|
|
120
|
-
out.push("Current state (first lines):");
|
|
121
|
-
const sampleSize = Math.min(fileLines.length, 5);
|
|
122
|
-
for (let i = 0; i < sampleSize; i++) {
|
|
123
|
-
out.push(`>>> ${fileHashes[i]}│${fileLines[i]}`);
|
|
124
|
-
}
|
|
125
|
-
if (fileLines.length > sampleSize) {
|
|
126
|
-
out.push(`... ${fileLines.length - sampleSize} more.`);
|
|
127
|
-
}
|
|
128
108
|
|
|
129
109
|
return out.join("\n");
|
|
130
110
|
}
|
|
131
111
|
|
|
132
112
|
|
|
133
|
-
const ITEM_KEYS = new Set(["
|
|
113
|
+
const ITEM_KEYS = new Set(["start", "end", "lines"]);
|
|
134
114
|
function isStringArray(value: unknown): value is string[] {
|
|
135
115
|
return (
|
|
136
116
|
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
@@ -145,23 +125,6 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
|
|
|
145
125
|
);
|
|
146
126
|
}
|
|
147
127
|
|
|
148
|
-
if (typeof edit.op !== "string") {
|
|
149
|
-
throw new Error(`[E_BAD_SHAPE] Edit ${index} requires an "op" string.`);
|
|
150
|
-
}
|
|
151
|
-
if (
|
|
152
|
-
edit.op !== "replace" &&
|
|
153
|
-
edit.op !== "append" &&
|
|
154
|
-
edit.op !== "prepend"
|
|
155
|
-
) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
`[E_BAD_OP] Edit ${index} uses unknown op "${edit.op}". Expected "replace", "append", or "prepend".`,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
if ("pos" in edit && typeof edit.pos !== "string" && edit.op !== "replace") {
|
|
161
|
-
throw new Error(
|
|
162
|
-
`[E_BAD_SHAPE] Edit ${index} field "pos" must be a string when provided.`,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
128
|
if ("start" in edit && typeof edit.start !== "string") {
|
|
166
129
|
throw new Error(
|
|
167
130
|
`[E_BAD_SHAPE] Edit ${index} field "start" must be a string when provided.`,
|
|
@@ -176,29 +139,17 @@ function assertEditItem(edit: Record<string, unknown>, index: number): void {
|
|
|
176
139
|
if ("lines" in edit && !isStringArray(edit.lines)) {
|
|
177
140
|
throw new Error(`[E_BAD_SHAPE] Edit ${index} field "lines" must be a string array.`);
|
|
178
141
|
}
|
|
179
|
-
if (edit.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
if (typeof edit.start !== "string") {
|
|
186
|
-
throw new Error(
|
|
187
|
-
`[E_BAD_OP] Edit ${index} with op "replace" requires a "start" anchor string.`,
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
if (typeof edit.end !== "string") {
|
|
191
|
-
throw new Error(
|
|
192
|
-
`[E_BAD_OP] Edit ${index} with op "replace" requires an "end" anchor string.`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
142
|
+
if (typeof edit.start !== "string") {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[E_BAD_OP] Edit ${index} requires a "start" anchor string.`,
|
|
145
|
+
);
|
|
195
146
|
}
|
|
196
|
-
|
|
197
|
-
if ((edit.op === "append" || edit.op === "prepend") && "end" in edit) {
|
|
147
|
+
if (typeof edit.end !== "string") {
|
|
198
148
|
throw new Error(
|
|
199
|
-
`[E_BAD_OP] Edit ${index}
|
|
149
|
+
`[E_BAD_OP] Edit ${index} requires an "end" anchor string.`,
|
|
200
150
|
);
|
|
201
151
|
}
|
|
152
|
+
|
|
202
153
|
}
|
|
203
154
|
|
|
204
155
|
export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
@@ -206,45 +157,21 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
|
206
157
|
for (const [index, edit] of edits.entries()) {
|
|
207
158
|
assertEditItem(edit as Record<string, unknown>, index);
|
|
208
159
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
start: parseHashRef(edit.start!),
|
|
221
|
-
end: parseHashRef(edit.end!),
|
|
222
|
-
lines: normalizedLines,
|
|
223
|
-
});
|
|
224
|
-
break;
|
|
225
|
-
}
|
|
226
|
-
case "append": {
|
|
227
|
-
result.push({
|
|
228
|
-
op: "append",
|
|
229
|
-
...(edit.pos ? { pos: parseHashRef(edit.pos) } : {}),
|
|
230
|
-
lines: hashlineParseText(edit.lines ?? null),
|
|
231
|
-
});
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
case "prepend": {
|
|
235
|
-
result.push({
|
|
236
|
-
op: "prepend",
|
|
237
|
-
...(edit.pos ? { pos: parseHashRef(edit.pos) } : {}),
|
|
238
|
-
lines: hashlineParseText(edit.lines ?? null),
|
|
239
|
-
});
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
160
|
+
// Normalize lines: [""] to lines: [] for deletion.
|
|
161
|
+
const replaceLines = hashlineParseText(edit.lines ?? null);
|
|
162
|
+
const normalizedLines =
|
|
163
|
+
replaceLines.length === 1 && replaceLines[0] === ""
|
|
164
|
+
? []
|
|
165
|
+
: replaceLines;
|
|
166
|
+
result.push({
|
|
167
|
+
start: parseHashRef(edit.start!),
|
|
168
|
+
end: parseHashRef(edit.end!),
|
|
169
|
+
lines: normalizedLines,
|
|
170
|
+
});
|
|
243
171
|
}
|
|
244
172
|
return result;
|
|
245
173
|
}
|
|
246
174
|
|
|
247
|
-
|
|
248
175
|
function maybeWarnSuspiciousUnicodeEscapePlaceholder(
|
|
249
176
|
edits: HashlineEdit[],
|
|
250
177
|
warnings: string[],
|
|
@@ -262,7 +189,6 @@ export function assertNoBareHashPrefixLines(
|
|
|
262
189
|
edits: HashlineEdit[],
|
|
263
190
|
fileLines: string[],
|
|
264
191
|
fileHashes: string[],
|
|
265
|
-
filePath?: string,
|
|
266
192
|
): string[] {
|
|
267
193
|
if (fileHashes.length !== fileLines.length) {
|
|
268
194
|
throw new Error(
|
|
@@ -280,18 +206,11 @@ export function assertNoBareHashPrefixLines(
|
|
|
280
206
|
}
|
|
281
207
|
if (suspects.length === 0) return [];
|
|
282
208
|
|
|
283
|
-
const isPython = filePath?.endsWith('.py');
|
|
284
209
|
const fileHashSet = new Set(fileHashes);
|
|
285
210
|
const matched = suspects.filter((s) => fileHashSet.has(s.hash));
|
|
286
211
|
const matchedCount = matched.length;
|
|
287
212
|
const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
|
|
288
213
|
|
|
289
|
-
if (isPython) {
|
|
290
|
-
const hint = matchedCount > 0
|
|
291
|
-
? `${matchedCount} prefix(es) match file line hashes.`
|
|
292
|
-
: `None match file line hashes — likely Python syntax.`;
|
|
293
|
-
return [`[W_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (e.g. ${JSON.stringify(exampleLine)}). ${hint}`];
|
|
294
|
-
}
|
|
295
214
|
|
|
296
215
|
const linesHint =
|
|
297
216
|
matchedCount === 0
|
|
@@ -308,14 +227,7 @@ export function assertNoBareHashPrefixLines(
|
|
|
308
227
|
* Human-readable label for a resolved edit (used in warnings and conflict errors).
|
|
309
228
|
*/
|
|
310
229
|
export function describeEdit(edit: ResolvedHashlineEdit): string {
|
|
311
|
-
|
|
312
|
-
case "replace":
|
|
313
|
-
return `replace ${edit.start.hash}-${edit.end.hash}`;
|
|
314
|
-
case "append":
|
|
315
|
-
return edit.pos ? `append after ${edit.pos.hash}` : "append at EOF";
|
|
316
|
-
case "prepend":
|
|
317
|
-
return edit.pos ? `prepend before ${edit.pos.hash}` : "prepend at BOF";
|
|
318
|
-
}
|
|
230
|
+
return `replace ${edit.start.hash}-${edit.end.hash}`;
|
|
319
231
|
}
|
|
320
232
|
|
|
321
233
|
export function validateAnchorEdits(
|
|
@@ -345,102 +257,56 @@ export function validateAnchorEdits(
|
|
|
345
257
|
|
|
346
258
|
for (const edit of edits) {
|
|
347
259
|
throwIfAborted(signal);
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
};
|
|
393
|
-
warnings.push(
|
|
394
|
-
`Potential boundary duplication before ${describeEdit(resolvedEdit)}: the replacement starts with a line that matches the preceding surviving line after trim.`,
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
|
-
resolved.push({
|
|
398
|
-
op: "replace",
|
|
399
|
-
start: startResolved,
|
|
400
|
-
end: endResolved,
|
|
401
|
-
lines: edit.lines,
|
|
402
|
-
});
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
case "append": {
|
|
406
|
-
let posResolved: ResolvedAnchor | undefined;
|
|
407
|
-
if (edit.pos) {
|
|
408
|
-
const r = tryResolve(edit.pos);
|
|
409
|
-
if (!r) continue;
|
|
410
|
-
posResolved = r;
|
|
411
|
-
}
|
|
412
|
-
if (edit.lines.length === 0) {
|
|
413
|
-
throw new Error(
|
|
414
|
-
"[E_BAD_OP] Append with empty lines payload. Provide content to insert or remove the edit.",
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
resolved.push({
|
|
418
|
-
op: "append",
|
|
419
|
-
...(posResolved ? { pos: posResolved } : {}),
|
|
420
|
-
lines: edit.lines,
|
|
421
|
-
});
|
|
422
|
-
break;
|
|
423
|
-
}
|
|
424
|
-
case "prepend": {
|
|
425
|
-
let posResolved: ResolvedAnchor | undefined;
|
|
426
|
-
if (edit.pos) {
|
|
427
|
-
const r = tryResolve(edit.pos);
|
|
428
|
-
if (!r) continue;
|
|
429
|
-
posResolved = r;
|
|
430
|
-
}
|
|
431
|
-
if (edit.lines.length === 0) {
|
|
432
|
-
throw new Error(
|
|
433
|
-
"[E_BAD_OP] Prepend with empty lines payload. Provide content to insert or remove the edit.",
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
resolved.push({
|
|
437
|
-
op: "prepend",
|
|
438
|
-
...(posResolved ? { pos: posResolved } : {}),
|
|
439
|
-
lines: edit.lines,
|
|
440
|
-
});
|
|
441
|
-
break;
|
|
442
|
-
}
|
|
260
|
+
const startResolved = tryResolve(edit.start);
|
|
261
|
+
const endResolved = tryResolve(edit.end);
|
|
262
|
+
if (!startResolved || !endResolved) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (startResolved.line > endResolved.line) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.start.hash} and ${edit.end.hash}).`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const endLine = endResolved.line;
|
|
271
|
+
const nextLine = fileLines[endLine];
|
|
272
|
+
const replacementLastLine = edit.lines.at(-1)?.trim();
|
|
273
|
+
if (
|
|
274
|
+
nextLine !== undefined &&
|
|
275
|
+
replacementLastLine &&
|
|
276
|
+
/[\p{L}\p{N}]/u.test(replacementLastLine) &&
|
|
277
|
+
replacementLastLine === nextLine.trim()
|
|
278
|
+
) {
|
|
279
|
+
const resolvedEdit: ResolvedHashlineEdit = {
|
|
280
|
+
start: startResolved,
|
|
281
|
+
end: endResolved,
|
|
282
|
+
lines: edit.lines,
|
|
283
|
+
};
|
|
284
|
+
warnings.push(
|
|
285
|
+
`Potential boundary duplication after ${describeEdit(resolvedEdit)}: the replacement ends with a line that matches the next surviving line after trim.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const prevLine = fileLines[startResolved.line - 2];
|
|
289
|
+
const replacementFirstLine = edit.lines[0]?.trim();
|
|
290
|
+
if (
|
|
291
|
+
prevLine !== undefined &&
|
|
292
|
+
replacementFirstLine &&
|
|
293
|
+
/[\p{L}\p{N}]/u.test(replacementFirstLine) &&
|
|
294
|
+
replacementFirstLine === prevLine.trim()
|
|
295
|
+
) {
|
|
296
|
+
const resolvedEdit: ResolvedHashlineEdit = {
|
|
297
|
+
start: startResolved,
|
|
298
|
+
end: endResolved,
|
|
299
|
+
lines: edit.lines,
|
|
300
|
+
};
|
|
301
|
+
warnings.push(
|
|
302
|
+
`Potential boundary duplication before ${describeEdit(resolvedEdit)}: the replacement starts with a line that matches the preceding surviving line after trim.`,
|
|
303
|
+
);
|
|
443
304
|
}
|
|
305
|
+
resolved.push({
|
|
306
|
+
start: startResolved,
|
|
307
|
+
end: endResolved,
|
|
308
|
+
lines: edit.lines,
|
|
309
|
+
});
|
|
444
310
|
}
|
|
445
311
|
|
|
446
312
|
return { resolved, mismatches };
|
package/src/read.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Type } from "typebox";
|
|
|
11
11
|
import { readFileSync } from "fs";
|
|
12
12
|
import { access as fsAccess } from "fs/promises";
|
|
13
13
|
import { constants } from "fs";
|
|
14
|
-
import { normalizeToLF, stripBom } from "./
|
|
14
|
+
import { normalizeToLF, stripBom } from "./replace-diff";
|
|
15
15
|
import { loadFileKindAndText } from "./file-kind";
|
|
16
16
|
import { computeLineHashes, formatHashlineRegion } from "./hashline";
|
|
17
17
|
import { resolveToCwd } from "./path-utils";
|
|
@@ -68,15 +68,14 @@ export function formatHashlineReadPreview(
|
|
|
68
68
|
if (totalLines === 0) {
|
|
69
69
|
if (startLine === 1) {
|
|
70
70
|
return {
|
|
71
|
-
text: "File is empty. Use edit
|
|
71
|
+
text: "File is empty. Use edit to insert content.",
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
-
|
|
75
74
|
return {
|
|
76
|
-
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use edit
|
|
75
|
+
text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use edit to insert content.`,
|
|
77
76
|
};
|
|
78
|
-
}
|
|
79
77
|
|
|
78
|
+
}
|
|
80
79
|
if (startLine > totalLines) {
|
|
81
80
|
return {
|
|
82
81
|
text: `Offset ${startLine} is beyond end of file (${totalLines} lines total). Use offset=1 to read from the start, or offset=${totalLines} to read the last line.`,
|
|
@@ -197,7 +196,7 @@ export function registerReadTool(pi: ExtensionAPI): void {
|
|
|
197
196
|
throwIfAborted(signal);
|
|
198
197
|
const normalized = normalizeToLF(stripBom(file.text).text);
|
|
199
198
|
// Compute hashes once for the whole file so the per-line anchors the
|
|
200
|
-
// model sees here are byte-identical to what the
|
|
199
|
+
// model sees here are byte-identical to what the replace tool will
|
|
201
200
|
// compute when it later validates against this file.
|
|
202
201
|
const fileHashes = computeLineHashes(normalized);
|
|
203
202
|
const preview = formatHashlineReadPreview(
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI rendering helpers for the
|
|
2
|
+
* TUI rendering helpers for the replace tool.
|
|
3
3
|
*
|
|
4
|
-
* Extracted from `src/
|
|
4
|
+
* Extracted from `src/replace.ts` to separate presentation (color themes, diff
|
|
5
5
|
* formatting, Markdown rendering) from tool execution logic.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
9
|
+
import { normalizeReplaceRequest } from "./replace-normalize";
|
|
10
|
+
import type { ReplaceRequestParams, HashlineReplaceToolDetails } from "./replace";
|
|
11
11
|
import { isRecord } from "./utils";
|
|
12
12
|
|
|
13
13
|
// ─── Theme type aliases ─────────────────────────────────────────────────
|
|
@@ -21,11 +21,11 @@ export type RenderedMarkdownTheme = Pick<
|
|
|
21
21
|
|
|
22
22
|
// ─── Render state ───────────────────────────────────────────────────────
|
|
23
23
|
|
|
24
|
-
export type
|
|
24
|
+
export type ReplacePreview = { diff: string } | { error: string };
|
|
25
25
|
|
|
26
|
-
export type
|
|
26
|
+
export type ReplaceRenderState = {
|
|
27
27
|
argsKey?: string;
|
|
28
|
-
preview?:
|
|
28
|
+
preview?: ReplacePreview;
|
|
29
29
|
previewGeneration?: number;
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -34,10 +34,10 @@ export type EditRenderState = {
|
|
|
34
34
|
|
|
35
35
|
export function getRenderablePreviewInput(
|
|
36
36
|
args: unknown,
|
|
37
|
-
):
|
|
37
|
+
): ReplaceRequestParams | null {
|
|
38
38
|
let normalized: unknown;
|
|
39
39
|
try {
|
|
40
|
-
normalized =
|
|
40
|
+
normalized = normalizeReplaceRequest(args);
|
|
41
41
|
} catch {
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
@@ -45,7 +45,7 @@ export function getRenderablePreviewInput(
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const request:
|
|
48
|
+
const request: ReplaceRequestParams = { path: normalized.path };
|
|
49
49
|
if (Array.isArray(normalized.edits)) {
|
|
50
50
|
request.edits = normalized.edits as any;
|
|
51
51
|
}
|
|
@@ -91,8 +91,8 @@ export function formatResultDiff(diff: string, theme: FgTheme): string {
|
|
|
91
91
|
// ─── Edit call formatting ───────────────────────────────────────────────
|
|
92
92
|
|
|
93
93
|
export function formatEditCall(
|
|
94
|
-
args:
|
|
95
|
-
state:
|
|
94
|
+
args: ReplaceRequestParams | undefined,
|
|
95
|
+
state: ReplaceRenderState,
|
|
96
96
|
expanded: boolean,
|
|
97
97
|
theme: CallTheme,
|
|
98
98
|
): string {
|
|
@@ -101,7 +101,7 @@ export function formatEditCall(
|
|
|
101
101
|
typeof path === "string" && path.length > 0
|
|
102
102
|
? theme.fg("accent", path)
|
|
103
103
|
: theme.fg("toolOutput", "...");
|
|
104
|
-
let text = `${theme.fg("toolTitle", theme.bold("
|
|
104
|
+
let text = `${theme.fg("toolTitle", theme.bold("replace"))} ${pathDisplay}`;
|
|
105
105
|
|
|
106
106
|
if (!state.preview) {
|
|
107
107
|
return text;
|
|
@@ -139,7 +139,7 @@ export function extractRenderedWarnings(
|
|
|
139
139
|
// ─── Result classification ──────────────────────────────────────────────
|
|
140
140
|
|
|
141
141
|
export function isAppliedChangedResult(
|
|
142
|
-
details:
|
|
142
|
+
details: HashlineReplaceToolDetails | undefined,
|
|
143
143
|
): boolean {
|
|
144
144
|
const metrics = details?.metrics;
|
|
145
145
|
return (
|
|
@@ -152,8 +152,8 @@ export function isAppliedChangedResult(
|
|
|
152
152
|
|
|
153
153
|
export function buildAppliedChangedResultText(
|
|
154
154
|
text: string | undefined,
|
|
155
|
-
details:
|
|
156
|
-
preview:
|
|
155
|
+
details: HashlineReplaceToolDetails | undefined,
|
|
156
|
+
preview: ReplacePreview | undefined,
|
|
157
157
|
theme: FgTheme,
|
|
158
158
|
): string | undefined {
|
|
159
159
|
const previewDiff =
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { generateDiffString } from "./
|
|
2
|
+
import { generateDiffString } from "./replace-diff";
|
|
3
3
|
import {
|
|
4
4
|
computeAffectedLineRange,
|
|
5
5
|
computeLineHashes,
|
|
@@ -37,7 +37,7 @@ export type FullContentPreview = {
|
|
|
37
37
|
nextOffset?: number;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
export type
|
|
40
|
+
export type ReplaceMetrics = {
|
|
41
41
|
edits_attempted: number;
|
|
42
42
|
edits_noop: number;
|
|
43
43
|
warnings: number;
|
|
@@ -53,7 +53,7 @@ export type ReadMetrics = {
|
|
|
53
53
|
next_offset?: number;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
export type
|
|
56
|
+
export type ReplaceMeta = {
|
|
57
57
|
editsAttempted: number;
|
|
58
58
|
noopEditsCount: number;
|
|
59
59
|
firstChangedLine?: number;
|
|
@@ -75,7 +75,7 @@ export interface NoopResponseInput {
|
|
|
75
75
|
noopEdits: NoopEditEntry[] | undefined;
|
|
76
76
|
originalNormalized: string;
|
|
77
77
|
snapshotId: string;
|
|
78
|
-
editMeta:
|
|
78
|
+
editMeta: ReplaceMeta;
|
|
79
79
|
warnings: string[] | undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -92,7 +92,7 @@ export interface SuccessResponseInput {
|
|
|
92
92
|
resultHashes?: string[];
|
|
93
93
|
warnings: string[] | undefined;
|
|
94
94
|
snapshotId: string;
|
|
95
|
-
editMeta:
|
|
95
|
+
editMeta: ReplaceMeta;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
@@ -122,8 +122,8 @@ function buildMetrics(args: {
|
|
|
122
122
|
lastChangedLine?: number;
|
|
123
123
|
addedLines?: number;
|
|
124
124
|
removedLines?: number;
|
|
125
|
-
}):
|
|
126
|
-
const metrics:
|
|
125
|
+
}): ReplaceMetrics {
|
|
126
|
+
const metrics: ReplaceMetrics = {
|
|
127
127
|
edits_attempted: args.editsAttempted,
|
|
128
128
|
edits_noop: args.noopEditsCount,
|
|
129
129
|
warnings: args.warningsCount,
|
|
@@ -471,7 +471,7 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
471
471
|
: "Anchors omitted; use read for subsequent edits.";
|
|
472
472
|
})()
|
|
473
473
|
: resultLines.length === 0
|
|
474
|
-
? "File is empty. Use edit
|
|
474
|
+
? "File is empty. Use edit to insert content."
|
|
475
475
|
: "Anchors omitted; use read for subsequent edits.";
|
|
476
476
|
|
|
477
477
|
const text = [anchorsBlock, warningsBlock.trimStart()]
|