@jerryan/pi-hashline-edit 0.7.1 → 0.7.3
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 +127 -114
- package/package.json +53 -53
- package/prompts/edit.md +4 -4
- package/prompts/read.md +1 -1
- package/src/edit-diff.ts +39 -77
- package/src/edit-response.ts +5 -67
- package/src/edit.ts +520 -642
- package/src/hashline.ts +1071 -1058
package/src/edit.ts
CHANGED
|
@@ -1,642 +1,520 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Type } from "@sinclair/typebox";
|
|
5
|
-
import { constants } from "fs";
|
|
6
|
-
import { readFileSync } from "fs";
|
|
7
|
-
import { access as fsAccess } from "fs/promises";
|
|
8
|
-
import {
|
|
9
|
-
detectLineEnding,
|
|
10
|
-
generateDiffString,
|
|
11
|
-
normalizeToLF,
|
|
12
|
-
restoreLineEndings,
|
|
13
|
-
stripBom,
|
|
14
|
-
} from "./edit-diff";
|
|
15
|
-
import { resolveMutationTargetPath, writeFileAtomically } from "./fs-write";
|
|
16
|
-
import {
|
|
17
|
-
applyHashlineEdits,
|
|
18
|
-
resolveEditAnchors,
|
|
19
|
-
type HashlineToolEdit,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
type EditRequestParams = {
|
|
53
|
-
path: string;
|
|
54
|
-
edits: Record<string, unknown>[];
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
type EditMetrics = {
|
|
58
|
-
edits_attempted: number;
|
|
59
|
-
edits_noop: number;
|
|
60
|
-
warnings: number;
|
|
61
|
-
classification: "applied" | "noop";
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
lines
|
|
150
|
-
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
return (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
return
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (isAppliedChangedResult(typedResult.details)) {
|
|
522
|
-
const appliedChangedText = buildAppliedChangedResultText(
|
|
523
|
-
renderedText,
|
|
524
|
-
typedResult.details,
|
|
525
|
-
previewBeforeResult,
|
|
526
|
-
theme,
|
|
527
|
-
);
|
|
528
|
-
if (!appliedChangedText) {
|
|
529
|
-
return new Text("", 0, 0);
|
|
530
|
-
}
|
|
531
|
-
const text = context.lastComponent instanceof Text
|
|
532
|
-
? context.lastComponent
|
|
533
|
-
: new Text("", 0, 0);
|
|
534
|
-
text.setText(appliedChangedText);
|
|
535
|
-
return text;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
if (!renderedText) {
|
|
539
|
-
return new Text("", 0, 0);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const markdown = context.lastComponent instanceof Markdown
|
|
543
|
-
? context.lastComponent
|
|
544
|
-
: new Markdown("", 0, 0, createRenderedEditMarkdownTheme(theme));
|
|
545
|
-
markdown.setText(formatRenderedEditResultMarkdown(renderedText));
|
|
546
|
-
return markdown;
|
|
547
|
-
},
|
|
548
|
-
|
|
549
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
550
|
-
assertEditRequest(params);
|
|
551
|
-
|
|
552
|
-
const path = (params as EditRequestParams).path;
|
|
553
|
-
const absolutePath = resolveToCwd(path, ctx.cwd);
|
|
554
|
-
const toolEdits = normalizeEditItems(
|
|
555
|
-
(params as EditRequestParams).edits,
|
|
556
|
-
);
|
|
557
|
-
|
|
558
|
-
const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
|
|
559
|
-
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
560
|
-
throwIfAborted(signal);
|
|
561
|
-
try {
|
|
562
|
-
await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
|
|
563
|
-
} catch (error: unknown) {
|
|
564
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
565
|
-
if (code === "ENOENT") {
|
|
566
|
-
throw new Error(`File not found: ${path}`);
|
|
567
|
-
}
|
|
568
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
569
|
-
throw new Error(`File is not writable: ${path}`);
|
|
570
|
-
}
|
|
571
|
-
throw new Error(`Cannot access file: ${path}`);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
throwIfAborted(signal);
|
|
575
|
-
const file = await loadFileKindAndText(absolutePath);
|
|
576
|
-
if (file.kind === "directory") {
|
|
577
|
-
throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
578
|
-
}
|
|
579
|
-
if (file.kind === "image") {
|
|
580
|
-
throw new Error(
|
|
581
|
-
`Path is an image file: ${path}. Hashline edit only supports UTF-8 text files.`,
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
if (file.kind === "binary") {
|
|
585
|
-
throw new Error(
|
|
586
|
-
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports UTF-8 text files.`,
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
throwIfAborted(signal);
|
|
591
|
-
const { bom, text: content } = stripBom(file.text);
|
|
592
|
-
const originalEnding = detectLineEnding(content);
|
|
593
|
-
const originalNormalized = normalizeToLF(content);
|
|
594
|
-
|
|
595
|
-
const resolved = resolveEditAnchors(toolEdits);
|
|
596
|
-
|
|
597
|
-
const anchorResult = applyHashlineEdits(originalNormalized, resolved, signal);
|
|
598
|
-
const result = anchorResult.content;
|
|
599
|
-
const warnings = anchorResult.warnings;
|
|
600
|
-
const noopEdits = anchorResult.noopEdits;
|
|
601
|
-
const firstChangedLine = anchorResult.firstChangedLine;
|
|
602
|
-
const lastChangedLine = anchorResult.lastChangedLine;
|
|
603
|
-
|
|
604
|
-
const editsAttempted = toolEdits.length;
|
|
605
|
-
|
|
606
|
-
if (originalNormalized === result) {
|
|
607
|
-
const noopSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
|
|
608
|
-
return buildNoopResponse({
|
|
609
|
-
path,
|
|
610
|
-
noopEdits,
|
|
611
|
-
originalNormalized,
|
|
612
|
-
snapshotId: noopSnapshotId,
|
|
613
|
-
editsAttempted,
|
|
614
|
-
warnings,
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
throwIfAborted(signal);
|
|
619
|
-
await writeFileAtomically(
|
|
620
|
-
absolutePath,
|
|
621
|
-
bom + restoreLineEndings(result, originalEnding),
|
|
622
|
-
);
|
|
623
|
-
const updatedSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
|
|
624
|
-
|
|
625
|
-
return buildChangedResponse({
|
|
626
|
-
path,
|
|
627
|
-
originalNormalized,
|
|
628
|
-
result,
|
|
629
|
-
warnings,
|
|
630
|
-
firstChangedLine,
|
|
631
|
-
lastChangedLine,
|
|
632
|
-
snapshotId: updatedSnapshotId,
|
|
633
|
-
editsAttempted,
|
|
634
|
-
noopEditsCount: noopEdits?.length ?? 0,
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
},
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
export function registerEditTool(pi: ExtensionAPI): void {
|
|
641
|
-
pi.registerTool(editToolDefinition);
|
|
642
|
-
}
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { constants } from "fs";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { access as fsAccess } from "fs/promises";
|
|
8
|
+
import {
|
|
9
|
+
detectLineEnding,
|
|
10
|
+
generateDiffString,
|
|
11
|
+
normalizeToLF,
|
|
12
|
+
restoreLineEndings,
|
|
13
|
+
stripBom,
|
|
14
|
+
} from "./edit-diff";
|
|
15
|
+
import { resolveMutationTargetPath, writeFileAtomically } from "./fs-write";
|
|
16
|
+
import {
|
|
17
|
+
applyHashlineEdits,
|
|
18
|
+
resolveEditAnchors,
|
|
19
|
+
type HashlineToolEdit,
|
|
20
|
+
ANCHOR_SEP,
|
|
21
|
+
} from "./hashline";
|
|
22
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
23
|
+
import { resolveToCwd } from "./path-utils";
|
|
24
|
+
|
|
25
|
+
import { throwIfAborted } from "./runtime";
|
|
26
|
+
import { getFileSnapshot } from "./snapshot";
|
|
27
|
+
import { buildChangedResponse, buildNoopResponse } from "./edit-response";
|
|
28
|
+
|
|
29
|
+
const editEntrySchema = Type.Object(
|
|
30
|
+
{
|
|
31
|
+
range: Type.Tuple([Type.String(), Type.String()], {
|
|
32
|
+
description:
|
|
33
|
+
`LINE${ANCHOR_SEP}HASH anchor pair [start, end] copied from a recent \`read\` or diff output. Use the same anchor twice for single-line: ["42${ANCHOR_SEP}A4", "42${ANCHOR_SEP}A4"].`,
|
|
34
|
+
}),
|
|
35
|
+
lines: Type.Array(Type.String(), {
|
|
36
|
+
description: "New content lines. Use [] to delete.",
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
{ additionalProperties: false },
|
|
40
|
+
);
|
|
41
|
+
export const hashlineEditToolSchema = Type.Object(
|
|
42
|
+
{
|
|
43
|
+
path: Type.String({ description: "path" }),
|
|
44
|
+
edits: Type.Array(editEntrySchema, {
|
|
45
|
+
description: `Edits to apply to $path. Each edit replaces the range [start, end] with lines. Use the same anchor twice for single-line; use [] to delete.`,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
{ additionalProperties: false },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type EditRequestParams = {
|
|
53
|
+
path: string;
|
|
54
|
+
edits: Record<string, unknown>[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type EditMetrics = {
|
|
58
|
+
edits_attempted: number;
|
|
59
|
+
edits_noop: number;
|
|
60
|
+
warnings: number;
|
|
61
|
+
classification: "applied" | "noop";
|
|
62
|
+
added_lines?: number;
|
|
63
|
+
removed_lines?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type HashlineEditToolDetails = {
|
|
67
|
+
diff: string;
|
|
68
|
+
snapshotId?: string;
|
|
69
|
+
classification?: "noop";
|
|
70
|
+
metrics?: EditMetrics;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const EDIT_DESC = readFileSync(
|
|
74
|
+
new URL("../prompts/edit.md", import.meta.url),
|
|
75
|
+
"utf-8",
|
|
76
|
+
).trim();
|
|
77
|
+
|
|
78
|
+
const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
79
|
+
new URL("../prompts/edit-snippet.md", import.meta.url),
|
|
80
|
+
"utf-8",
|
|
81
|
+
).trim();
|
|
82
|
+
|
|
83
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Safety net for environments where AJV validation is disabled.
|
|
88
|
+
// Field-type and schema validation are AJV's responsibility;
|
|
89
|
+
// only prevent crashes from missing required top-level fields.
|
|
90
|
+
// Path existence is checked in execute() once CWD is available.
|
|
91
|
+
export function assertEditRequest(request: unknown): asserts request is EditRequestParams {
|
|
92
|
+
if (!isRecord(request)) {
|
|
93
|
+
throw new Error("Edit request must be an object.");
|
|
94
|
+
}
|
|
95
|
+
if (typeof request.path !== "string" || request.path.length === 0) {
|
|
96
|
+
throw new Error('Edit request requires a non-empty "path" string.');
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(request.edits) || request.edits.length === 0) {
|
|
99
|
+
throw new Error('Edit request requires a non-empty "edits" array.');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeEditItems(edits: Record<string, unknown>[]): HashlineToolEdit[] {
|
|
104
|
+
return edits.map((edit) => {
|
|
105
|
+
const [pos, end] = (edit.range as [string, string]) || ["", ""];
|
|
106
|
+
return { op: "replace", pos, end, lines: (edit.lines as string[]) || [] };
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type EditPreview = { diff: string } | { error: string };
|
|
111
|
+
type EditRenderState = {
|
|
112
|
+
argsKey?: string;
|
|
113
|
+
preview?: EditPreview;
|
|
114
|
+
previewGeneration?: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function getRenderablePreviewInput(args: unknown): EditRequestParams | null {
|
|
118
|
+
if (!isRecord(args) || typeof args.path !== "string") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const request: EditRequestParams = {
|
|
123
|
+
path: args.path,
|
|
124
|
+
edits: Array.isArray(args.edits) ? args.edits : [],
|
|
125
|
+
};
|
|
126
|
+
return request.edits.length > 0 ? request : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function colorDiffLines(
|
|
130
|
+
lines: string[],
|
|
131
|
+
theme: { fg: (token: string, text: string) => string },
|
|
132
|
+
): string[] {
|
|
133
|
+
return lines.map((line) => {
|
|
134
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
135
|
+
return theme.fg("success", line);
|
|
136
|
+
}
|
|
137
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
138
|
+
return theme.fg("error", line);
|
|
139
|
+
}
|
|
140
|
+
return theme.fg("dim", line);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatPreviewDiff(
|
|
145
|
+
diff: string,
|
|
146
|
+
expanded: boolean,
|
|
147
|
+
theme: { fg: (token: string, text: string) => string },
|
|
148
|
+
): string {
|
|
149
|
+
const lines = diff.split("\n");
|
|
150
|
+
const maxLines = expanded ? 40 : 16;
|
|
151
|
+
const shown = colorDiffLines(lines.slice(0, maxLines), theme);
|
|
152
|
+
|
|
153
|
+
if (lines.length > maxLines) {
|
|
154
|
+
shown.push(theme.fg("muted", `... ${lines.length - maxLines} more diff lines`));
|
|
155
|
+
}
|
|
156
|
+
return shown.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatResultDiff(
|
|
160
|
+
diff: string,
|
|
161
|
+
theme: { fg: (token: string, text: string) => string },
|
|
162
|
+
): string {
|
|
163
|
+
return colorDiffLines(diff.split("\n"), theme).join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getRenderedEditTextContent(
|
|
167
|
+
result: { content?: Array<{ type: string; text?: string }> },
|
|
168
|
+
): string | undefined {
|
|
169
|
+
const textContent = result.content?.find(
|
|
170
|
+
(entry): entry is { type: "text"; text: string } =>
|
|
171
|
+
entry.type === "text" && typeof entry.text === "string",
|
|
172
|
+
);
|
|
173
|
+
return textContent?.text;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isAppliedChangedResult(
|
|
177
|
+
details: HashlineEditToolDetails | undefined,
|
|
178
|
+
): boolean {
|
|
179
|
+
const metrics = details?.metrics;
|
|
180
|
+
return (
|
|
181
|
+
metrics?.classification === "applied" &&
|
|
182
|
+
metrics.added_lines !== undefined &&
|
|
183
|
+
metrics.removed_lines !== undefined
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildAppliedChangedResultText(
|
|
188
|
+
text: string | undefined,
|
|
189
|
+
details: HashlineEditToolDetails | undefined,
|
|
190
|
+
preview: EditPreview | undefined,
|
|
191
|
+
theme: { fg: (token: string, text: string) => string },
|
|
192
|
+
): string | undefined {
|
|
193
|
+
const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
|
|
194
|
+
const sections: string[] = [];
|
|
195
|
+
|
|
196
|
+
if (details?.diff && details.diff !== previewDiff) {
|
|
197
|
+
sections.push(formatResultDiff(details.diff, theme));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const warnings = text?.match(/(?:^|\n\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
|
|
201
|
+
if (warnings) sections.push(warnings);
|
|
202
|
+
|
|
203
|
+
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatEditCall(
|
|
207
|
+
args: EditRequestParams | undefined,
|
|
208
|
+
state: EditRenderState,
|
|
209
|
+
expanded: boolean,
|
|
210
|
+
theme: {
|
|
211
|
+
bold: (text: string) => string;
|
|
212
|
+
fg: (token: string, text: string) => string;
|
|
213
|
+
},
|
|
214
|
+
): string {
|
|
215
|
+
const path = args?.path;
|
|
216
|
+
const pathDisplay =
|
|
217
|
+
typeof path === "string" && path.length > 0
|
|
218
|
+
? theme.fg("accent", path)
|
|
219
|
+
: theme.fg("toolOutput", "...");
|
|
220
|
+
let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
|
|
221
|
+
|
|
222
|
+
if (!state.preview) {
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if ("error" in state.preview) {
|
|
227
|
+
text += `\n\n${theme.fg("error", state.preview.error)}`;
|
|
228
|
+
return text;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (state.preview.diff) {
|
|
232
|
+
text += `\n\n${formatPreviewDiff(state.preview.diff, expanded, theme)}`;
|
|
233
|
+
}
|
|
234
|
+
return text;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function computeEditPreview(
|
|
238
|
+
request: unknown,
|
|
239
|
+
cwd: string,
|
|
240
|
+
): Promise<EditPreview> {
|
|
241
|
+
try {
|
|
242
|
+
assertEditRequest(request);
|
|
243
|
+
} catch (error: unknown) {
|
|
244
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const params = request as EditRequestParams;
|
|
248
|
+
const path = params.path;
|
|
249
|
+
const absolutePath = resolveToCwd(path, cwd);
|
|
250
|
+
const toolEdits = normalizeEditItems(params.edits);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await fsAccess(absolutePath, constants.R_OK);
|
|
254
|
+
} catch (error: unknown) {
|
|
255
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
256
|
+
if (code === "ENOENT") {
|
|
257
|
+
return { error: `File not found: ${path}` };
|
|
258
|
+
}
|
|
259
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
260
|
+
return { error: `File is not readable: ${path}` };
|
|
261
|
+
}
|
|
262
|
+
return { error: `Cannot access file: ${path}` };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const file = await loadFileKindAndText(absolutePath);
|
|
267
|
+
if (file.kind === "directory") {
|
|
268
|
+
return { error: `Path is a directory: ${path}. Use ls to inspect directories.` };
|
|
269
|
+
}
|
|
270
|
+
if (file.kind === "image") {
|
|
271
|
+
return {
|
|
272
|
+
error: `Path is an image file: ${path}. Hashline edit only supports UTF-8 text files.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (file.kind === "binary") {
|
|
276
|
+
return {
|
|
277
|
+
error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports UTF-8 text files.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const originalNormalized = normalizeToLF(stripBom(file.text).text);
|
|
282
|
+
const resolved = resolveEditAnchors(toolEdits);
|
|
283
|
+
const result = applyHashlineEdits(originalNormalized, resolved).content;
|
|
284
|
+
|
|
285
|
+
if (originalNormalized === result) {
|
|
286
|
+
return {
|
|
287
|
+
error: `No changes made to ${path}. The edits produced identical content.`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { diff: generateDiffString(originalNormalized, result).diff };
|
|
292
|
+
} catch (error: unknown) {
|
|
293
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
type EditToolDefinition = ToolDefinition<
|
|
298
|
+
typeof hashlineEditToolSchema,
|
|
299
|
+
HashlineEditToolDetails,
|
|
300
|
+
EditRenderState
|
|
301
|
+
> & { renderShell?: "default" | "self" };
|
|
302
|
+
|
|
303
|
+
const editToolDefinition: EditToolDefinition = {
|
|
304
|
+
name: "edit",
|
|
305
|
+
label: "Edit",
|
|
306
|
+
description: EDIT_DESC,
|
|
307
|
+
parameters: hashlineEditToolSchema,
|
|
308
|
+
promptSnippet: EDIT_PROMPT_SNIPPET,
|
|
309
|
+
// Force the default tool shell (Box with pending/success/error background) so
|
|
310
|
+
// we don't inherit renderShell: "self" from the built-in edit tool of the
|
|
311
|
+
// same name, which would drop the shared background color block.
|
|
312
|
+
renderShell: "default",
|
|
313
|
+
renderCall(args, theme, context) {
|
|
314
|
+
const previewInput = getRenderablePreviewInput(args);
|
|
315
|
+
if (context.executionStarted) {
|
|
316
|
+
context.state.argsKey = undefined;
|
|
317
|
+
context.state.preview = undefined;
|
|
318
|
+
context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
319
|
+
} else if (!context.argsComplete || !previewInput) {
|
|
320
|
+
context.state.argsKey = undefined;
|
|
321
|
+
context.state.preview = undefined;
|
|
322
|
+
context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
323
|
+
} else {
|
|
324
|
+
const argsKey = JSON.stringify(previewInput);
|
|
325
|
+
if (context.state.argsKey !== argsKey) {
|
|
326
|
+
context.state.argsKey = argsKey;
|
|
327
|
+
context.state.preview = undefined;
|
|
328
|
+
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
329
|
+
context.state.previewGeneration = previewGeneration;
|
|
330
|
+
computeEditPreview(previewInput, context.cwd)
|
|
331
|
+
.then((preview) => {
|
|
332
|
+
if (
|
|
333
|
+
context.state.argsKey === argsKey &&
|
|
334
|
+
context.state.previewGeneration === previewGeneration
|
|
335
|
+
) {
|
|
336
|
+
context.state.preview = preview;
|
|
337
|
+
context.invalidate();
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
.catch((err: unknown) => {
|
|
341
|
+
if (
|
|
342
|
+
context.state.argsKey === argsKey &&
|
|
343
|
+
context.state.previewGeneration === previewGeneration
|
|
344
|
+
) {
|
|
345
|
+
context.state.preview = {
|
|
346
|
+
error: err instanceof Error ? err.message : String(err),
|
|
347
|
+
};
|
|
348
|
+
context.invalidate();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
354
|
+
text.setText(
|
|
355
|
+
formatEditCall(
|
|
356
|
+
getRenderablePreviewInput(args) ?? undefined,
|
|
357
|
+
context.state as EditRenderState,
|
|
358
|
+
context.expanded,
|
|
359
|
+
theme,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
return text;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
366
|
+
if (isPartial) {
|
|
367
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
368
|
+
text.setText(theme.fg("warning", "Editing..."));
|
|
369
|
+
return text;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const typedResult = result as {
|
|
373
|
+
content?: Array<{ type: string; text?: string }>;
|
|
374
|
+
details?: HashlineEditToolDetails;
|
|
375
|
+
};
|
|
376
|
+
const renderedText = getRenderedEditTextContent(typedResult);
|
|
377
|
+
|
|
378
|
+
const renderState = context.state as EditRenderState | undefined;
|
|
379
|
+
const previewBeforeResult = renderState?.preview;
|
|
380
|
+
if (renderState) {
|
|
381
|
+
renderState.preview = undefined;
|
|
382
|
+
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (context.isError) {
|
|
386
|
+
if (!renderedText) {
|
|
387
|
+
return new Text("", 0, 0);
|
|
388
|
+
}
|
|
389
|
+
const text = context.lastComponent instanceof Text
|
|
390
|
+
? context.lastComponent
|
|
391
|
+
: new Text("", 0, 0);
|
|
392
|
+
text.setText(`\n${theme.fg("error", renderedText)}`);
|
|
393
|
+
return text;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (isAppliedChangedResult(typedResult.details)) {
|
|
397
|
+
const appliedChangedText = buildAppliedChangedResultText(
|
|
398
|
+
renderedText,
|
|
399
|
+
typedResult.details,
|
|
400
|
+
previewBeforeResult,
|
|
401
|
+
theme,
|
|
402
|
+
);
|
|
403
|
+
if (!appliedChangedText) {
|
|
404
|
+
return new Text("", 0, 0);
|
|
405
|
+
}
|
|
406
|
+
const text = context.lastComponent instanceof Text
|
|
407
|
+
? context.lastComponent
|
|
408
|
+
: new Text("", 0, 0);
|
|
409
|
+
text.setText(appliedChangedText);
|
|
410
|
+
return text;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!renderedText) {
|
|
414
|
+
return new Text("", 0, 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const text = context.lastComponent instanceof Text
|
|
418
|
+
? context.lastComponent
|
|
419
|
+
: new Text("", 0, 0);
|
|
420
|
+
text.setText(renderedText);
|
|
421
|
+
return text;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
425
|
+
assertEditRequest(params);
|
|
426
|
+
|
|
427
|
+
const path = (params as EditRequestParams).path;
|
|
428
|
+
const absolutePath = resolveToCwd(path, ctx.cwd);
|
|
429
|
+
const toolEdits = normalizeEditItems(
|
|
430
|
+
(params as EditRequestParams).edits,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
|
|
434
|
+
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
435
|
+
throwIfAborted(signal);
|
|
436
|
+
try {
|
|
437
|
+
await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
|
|
438
|
+
} catch (error: unknown) {
|
|
439
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
440
|
+
if (code === "ENOENT") {
|
|
441
|
+
throw new Error(`File not found: ${path}`);
|
|
442
|
+
}
|
|
443
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
444
|
+
throw new Error(`File is not writable: ${path}`);
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`Cannot access file: ${path}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
throwIfAborted(signal);
|
|
450
|
+
const file = await loadFileKindAndText(absolutePath);
|
|
451
|
+
if (file.kind === "directory") {
|
|
452
|
+
throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
453
|
+
}
|
|
454
|
+
if (file.kind === "image") {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`Path is an image file: ${path}. Hashline edit only supports UTF-8 text files.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (file.kind === "binary") {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`Path is a binary file: ${path} (${file.description}). Hashline edit only supports UTF-8 text files.`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
throwIfAborted(signal);
|
|
466
|
+
const { bom, text: content } = stripBom(file.text);
|
|
467
|
+
const originalEnding = detectLineEnding(content);
|
|
468
|
+
const originalNormalized = normalizeToLF(content);
|
|
469
|
+
|
|
470
|
+
const resolved = resolveEditAnchors(toolEdits);
|
|
471
|
+
|
|
472
|
+
const anchorResult = applyHashlineEdits(originalNormalized, resolved, signal);
|
|
473
|
+
const result = anchorResult.content;
|
|
474
|
+
const warnings = anchorResult.warnings;
|
|
475
|
+
const originalLineCount = originalNormalized.length === 0
|
|
476
|
+
? 0
|
|
477
|
+
: originalNormalized.split("\n").length - (originalNormalized.endsWith("\n") ? 1 : 0);
|
|
478
|
+
if (result.length === 0 && originalLineCount > 50) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
"[E_WOULD_EMPTY] This edit would delete the entire file. The edit tool does not allow full-file deletion for files with more than 50 lines. If you truly intend to clear the file, use the write tool to overwrite it with an empty string.",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const noopEdits = anchorResult.noopEdits;
|
|
484
|
+
const editsAttempted = toolEdits.length;
|
|
485
|
+
|
|
486
|
+
if (originalNormalized === result) {
|
|
487
|
+
const noopSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
|
|
488
|
+
return buildNoopResponse({
|
|
489
|
+
path,
|
|
490
|
+
noopEdits,
|
|
491
|
+
originalNormalized,
|
|
492
|
+
snapshotId: noopSnapshotId,
|
|
493
|
+
editsAttempted,
|
|
494
|
+
warnings,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
throwIfAborted(signal);
|
|
499
|
+
await writeFileAtomically(
|
|
500
|
+
absolutePath,
|
|
501
|
+
bom + restoreLineEndings(result, originalEnding),
|
|
502
|
+
);
|
|
503
|
+
const updatedSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
|
|
504
|
+
|
|
505
|
+
return buildChangedResponse({
|
|
506
|
+
path,
|
|
507
|
+
originalNormalized,
|
|
508
|
+
result,
|
|
509
|
+
warnings,
|
|
510
|
+
snapshotId: updatedSnapshotId,
|
|
511
|
+
editsAttempted,
|
|
512
|
+
noopEditsCount: noopEdits?.length ?? 0,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
export function registerEditTool(pi: ExtensionAPI): void {
|
|
519
|
+
pi.registerTool(editToolDefinition);
|
|
520
|
+
}
|