@jerryan/pi-hashline-edit 0.7.1 → 0.7.2

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/src/edit.ts CHANGED
@@ -1,642 +1,519 @@
1
- import { Markdown, 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
- } from "./hashline";
21
- import { loadFileKindAndText } from "./file-kind";
22
- import { resolveToCwd } from "./path-utils";
23
-
24
- import { throwIfAborted } from "./runtime";
25
- import { getFileSnapshot } from "./snapshot";
26
- import { buildChangedResponse, buildNoopResponse } from "./edit-response";
27
-
28
- const editEntrySchema = Type.Object(
29
- {
30
- range: Type.Tuple([Type.String(), Type.String()], {
31
- description:
32
- 'LINE#HASH anchor pair [start, end] copied from a recent `read` or `--- Anchors ---` block. Use the same anchor twice for single-line: ["42#A4", "42#A4"].',
33
- }),
34
- lines: Type.Array(Type.String(), {
35
- description: "New content lines. Use [] to delete.",
36
- }),
37
- },
38
- { additionalProperties: false },
39
- );
40
- export const hashlineEditToolSchema = Type.Object(
41
- {
42
- path: Type.String({ description: "path" }),
43
- edits: Type.Array(editEntrySchema, {
44
- 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.",
45
- }),
46
- },
47
- { additionalProperties: false },
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
- changed_lines?: { first: number; last: number };
63
- added_lines?: number;
64
- removed_lines?: number;
65
- };
66
-
67
- type HashlineEditToolDetails = {
68
- diff: string;
69
- firstChangedLine?: number;
70
- /**
71
- * Post-edit snapshot fingerprint. Surfaced in details only — the LLM no
72
- * longer receives or echoes it. Hosts may use this for UI hints (e.g.
73
- * "file changed since last view"). See plan W2.
74
- */
75
- snapshotId?: string;
76
- classification?: "noop";
77
- /**
78
- * Phase 2 C — opt-in observability surface for hosts. Never echoed in text.
79
- * Hosts can use it for adoption/regression dashboards.
80
- */
81
- metrics?: EditMetrics;
82
- };
83
-
84
- const EDIT_DESC = readFileSync(
85
- new URL("../prompts/edit.md", import.meta.url),
86
- "utf-8",
87
- ).trim();
88
-
89
- const EDIT_PROMPT_SNIPPET = readFileSync(
90
- new URL("../prompts/edit-snippet.md", import.meta.url),
91
- "utf-8",
92
- ).trim();
93
-
94
- function isRecord(value: unknown): value is Record<string, unknown> {
95
- return typeof value === "object" && value !== null && !Array.isArray(value);
96
- }
97
-
98
- function getVisibleLines(text: string): string[] {
99
- if (text.length === 0) {
100
- return [];
101
- }
102
- const lines = text.split("\n");
103
- return text.endsWith("\n") ? lines.slice(0, -1) : lines;
104
- }
105
-
106
- // Safety net for environments where AJV validation is disabled.
107
- // Field-type and schema validation are AJV's responsibility;
108
- // only prevent crashes from missing required top-level fields.
109
- // Path existence is checked in execute() once CWD is available.
110
- export function assertEditRequest(request: unknown): asserts request is EditRequestParams {
111
- if (!isRecord(request)) {
112
- throw new Error("Edit request must be an object.");
113
- }
114
- if (typeof request.path !== "string" || request.path.length === 0) {
115
- throw new Error('Edit request requires a non-empty "path" string.');
116
- }
117
- if (!Array.isArray(request.edits) || request.edits.length === 0) {
118
- throw new Error('Edit request requires a non-empty "edits" array.');
119
- }
120
- }
121
-
122
- export function normalizeEditItems(edits: Record<string, unknown>[]): HashlineToolEdit[] {
123
- return edits.map((edit) => {
124
- const [pos, end] = (edit.range as [string, string]) || ["", ""];
125
- return { op: "replace", pos, end, lines: (edit.lines as string[]) || [] };
126
- });
127
- }
128
-
129
- type EditPreview = { diff: string } | { error: string };
130
- type EditRenderState = {
131
- argsKey?: string;
132
- preview?: EditPreview;
133
- previewGeneration?: number;
134
- };
135
-
136
- function getRenderablePreviewInput(args: unknown): EditRequestParams | null {
137
- if (!isRecord(args) || typeof args.path !== "string") {
138
- return null;
139
- }
140
-
141
- const request: EditRequestParams = {
142
- path: args.path,
143
- edits: Array.isArray(args.edits) ? args.edits : [],
144
- };
145
- return request.edits.length > 0 ? request : null;
146
- }
147
-
148
- function colorDiffLines(
149
- lines: string[],
150
- theme: { fg: (token: string, text: string) => string },
151
- ): string[] {
152
- return lines.map((line) => {
153
- if (line.startsWith("+") && !line.startsWith("+++")) {
154
- return theme.fg("success", line);
155
- }
156
- if (line.startsWith("-") && !line.startsWith("---")) {
157
- return theme.fg("error", line);
158
- }
159
- return theme.fg("dim", line);
160
- });
161
- }
162
-
163
- function formatPreviewDiff(
164
- diff: string,
165
- expanded: boolean,
166
- theme: { fg: (token: string, text: string) => string },
167
- ): string {
168
- const lines = diff.split("\n");
169
- const maxLines = expanded ? 40 : 16;
170
- const shown = colorDiffLines(lines.slice(0, maxLines), theme);
171
-
172
- if (lines.length > maxLines) {
173
- shown.push(theme.fg("muted", `... ${lines.length - maxLines} more diff lines`));
174
- }
175
- return shown.join("\n");
176
- }
177
-
178
- function formatResultDiff(
179
- diff: string,
180
- theme: { fg: (token: string, text: string) => string },
181
- ): string {
182
- return colorDiffLines(diff.split("\n"), theme).join("\n");
183
- }
184
-
185
- function getRenderedEditTextContent(
186
- result: { content?: Array<{ type: string; text?: string }> },
187
- ): string | undefined {
188
- const textContent = result.content?.find(
189
- (entry): entry is { type: "text"; text: string } =>
190
- entry.type === "text" && typeof entry.text === "string",
191
- );
192
- return textContent?.text;
193
- }
194
-
195
- function extractRenderedWarnings(text: string | undefined): string | undefined {
196
- return text?.match(/(?:^|\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
197
- }
198
-
199
- function isAppliedChangedResult(
200
- details: HashlineEditToolDetails | undefined,
201
- ): boolean {
202
- const metrics = details?.metrics;
203
- return (
204
- metrics?.classification === "applied" &&
205
- metrics.added_lines !== undefined &&
206
- metrics.removed_lines !== undefined
207
- );
208
- }
209
-
210
- function buildAppliedChangedResultText(
211
- text: string | undefined,
212
- details: HashlineEditToolDetails | undefined,
213
- preview: EditPreview | undefined,
214
- theme: { fg: (token: string, text: string) => string },
215
- ): string | undefined {
216
- const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
217
- const sections: string[] = [];
218
-
219
- if (details?.diff && details.diff !== previewDiff) {
220
- sections.push(formatResultDiff(details.diff, theme));
221
- }
222
-
223
- const warnings = extractRenderedWarnings(text);
224
- if (warnings) sections.push(warnings);
225
-
226
- return sections.length > 0 ? sections.join("\n\n") : undefined;
227
- }
228
-
229
-
230
- function trimEdgeEmptyLines(lines: string[]): string[] {
231
- let start = 0;
232
- let end = lines.length;
233
-
234
- while (start < end && lines[start] === "") {
235
- start++;
236
- }
237
- while (end > start && lines[end - 1] === "") {
238
- end--;
239
- }
240
-
241
- return lines.slice(start, end);
242
- }
243
-
244
- function isRenderedEditSectionBoundary(line: string): boolean {
245
- return (
246
- line.startsWith("--- Anchors ") ||
247
- line === "Warnings:"
248
- );
249
- }
250
-
251
- function formatRenderedEditResultMarkdown(text: string): string {
252
- const lines = text.split("\n");
253
- const sections: string[] = [];
254
- let plainLines: string[] = [];
255
-
256
- const flushPlainLines = () => {
257
- const trimmed = trimEdgeEmptyLines(plainLines);
258
- if (trimmed.length > 0) {
259
- sections.push(trimmed.join("\n"));
260
- }
261
- plainLines = [];
262
- };
263
-
264
- let index = 0;
265
- while (index < lines.length) {
266
- const line = lines[index]!;
267
-
268
- if (line.startsWith("--- Anchors ")) {
269
- flushPlainLines();
270
- const title = line.replace(/^---\s*/, "").replace(/\s*---$/, "");
271
- index++;
272
- const bodyLines: string[] = [];
273
- while (index < lines.length && !isRenderedEditSectionBoundary(lines[index]!)) {
274
- bodyLines.push(lines[index]!);
275
- index++;
276
- }
277
- sections.push([`#### ${title}`, "```text", ...trimEdgeEmptyLines(bodyLines), "```"].join("\n"));
278
- continue;
279
- }
280
-
281
- plainLines.push(line);
282
- index++;
283
- }
284
-
285
- flushPlainLines();
286
-
287
- return sections.join("\n\n");
288
- }
289
-
290
- function createRenderedEditMarkdownTheme(theme: {
291
- fg: (token: string, text: string) => string;
292
- bold: (text: string) => string;
293
- italic?: (text: string) => string;
294
- underline?: (text: string) => string;
295
- strikethrough?: (text: string) => string;
296
- }) {
297
- return {
298
- heading: (text: string) => theme.fg("mdHeading", text),
299
- link: (text: string) => theme.fg("mdLink", text),
300
- linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
301
- code: (text: string) => theme.fg("mdCode", text),
302
- codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
303
- codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
304
- quote: (text: string) => theme.fg("mdQuote", text),
305
- quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
306
- hr: (text: string) => theme.fg("mdHr", text),
307
- listBullet: (text: string) => theme.fg("mdListBullet", text),
308
- bold: (text: string) => theme.bold(text),
309
- italic: (text: string) => theme.italic ? theme.italic(text) : text,
310
- underline: (text: string) => theme.underline ? theme.underline(text) : text,
311
- strikethrough: (text: string) => theme.strikethrough ? theme.strikethrough(text) : text,
312
- highlightCode: (code: string, lang?: string) =>
313
- code.split("\n").map((line) => {
314
- if (lang === "diff") {
315
- if (line.startsWith("+") && !line.startsWith("+++")) {
316
- return theme.fg("toolDiffAdded", line);
317
- }
318
- if (line.startsWith("-") && !line.startsWith("---")) {
319
- return theme.fg("toolDiffRemoved", line);
320
- }
321
- return theme.fg("toolDiffContext", line);
322
- }
323
-
324
- return theme.fg("mdCodeBlock", line);
325
- }),
326
- };
327
- }
328
-
329
-
330
-
331
- function formatEditCall(
332
- args: EditRequestParams | undefined,
333
- state: EditRenderState,
334
- expanded: boolean,
335
- theme: {
336
- bold: (text: string) => string;
337
- fg: (token: string, text: string) => string;
338
- },
339
- ): string {
340
- const path = args?.path;
341
- const pathDisplay =
342
- typeof path === "string" && path.length > 0
343
- ? theme.fg("accent", path)
344
- : theme.fg("toolOutput", "...");
345
- let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
346
-
347
- if (!state.preview) {
348
- return text;
349
- }
350
-
351
- if ("error" in state.preview) {
352
- text += `\n\n${theme.fg("error", state.preview.error)}`;
353
- return text;
354
- }
355
-
356
- if (state.preview.diff) {
357
- text += `\n\n${formatPreviewDiff(state.preview.diff, expanded, theme)}`;
358
- }
359
- return text;
360
- }
361
-
362
- export async function computeEditPreview(
363
- request: unknown,
364
- cwd: string,
365
- ): Promise<EditPreview> {
366
- try {
367
- assertEditRequest(request);
368
- } catch (error: unknown) {
369
- return { error: error instanceof Error ? error.message : String(error) };
370
- }
371
-
372
- const params = request as EditRequestParams;
373
- const path = params.path;
374
- const absolutePath = resolveToCwd(path, cwd);
375
- const toolEdits = normalizeEditItems(params.edits);
376
-
377
- try {
378
- await fsAccess(absolutePath, constants.R_OK);
379
- } catch (error: unknown) {
380
- const code = (error as NodeJS.ErrnoException).code;
381
- if (code === "ENOENT") {
382
- return { error: `File not found: ${path}` };
383
- }
384
- if (code === "EACCES" || code === "EPERM") {
385
- return { error: `File is not readable: ${path}` };
386
- }
387
- return { error: `Cannot access file: ${path}` };
388
- }
389
-
390
- try {
391
- const file = await loadFileKindAndText(absolutePath);
392
- if (file.kind === "directory") {
393
- return { error: `Path is a directory: ${path}. Use ls to inspect directories.` };
394
- }
395
- if (file.kind === "image") {
396
- return {
397
- error: `Path is an image file: ${path}. Hashline edit only supports UTF-8 text files.`,
398
- };
399
- }
400
- if (file.kind === "binary") {
401
- return {
402
- error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports UTF-8 text files.`,
403
- };
404
- }
405
-
406
- const originalNormalized = normalizeToLF(stripBom(file.text).text);
407
- const resolved = resolveEditAnchors(toolEdits);
408
- const result = applyHashlineEdits(originalNormalized, resolved).content;
409
-
410
- if (originalNormalized === result) {
411
- return {
412
- error: `No changes made to ${path}. The edits produced identical content.`,
413
- };
414
- }
415
-
416
- return { diff: generateDiffString(originalNormalized, result).diff };
417
- } catch (error: unknown) {
418
- return { error: error instanceof Error ? error.message : String(error) };
419
- }
420
- }
421
-
422
- type EditToolDefinition = ToolDefinition<
423
- typeof hashlineEditToolSchema,
424
- HashlineEditToolDetails,
425
- EditRenderState
426
- > & { renderShell?: "default" | "self" };
427
-
428
- const editToolDefinition: EditToolDefinition = {
429
- name: "edit",
430
- label: "Edit",
431
- description: EDIT_DESC,
432
- parameters: hashlineEditToolSchema,
433
- promptSnippet: EDIT_PROMPT_SNIPPET,
434
- // Force the default tool shell (Box with pending/success/error background) so
435
- // we don't inherit renderShell: "self" from the built-in edit tool of the
436
- // same name, which would drop the shared background color block.
437
- renderShell: "default",
438
- renderCall(args, theme, context) {
439
- const previewInput = getRenderablePreviewInput(args);
440
- if (context.executionStarted) {
441
- context.state.argsKey = undefined;
442
- context.state.preview = undefined;
443
- context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
444
- } else if (!context.argsComplete || !previewInput) {
445
- context.state.argsKey = undefined;
446
- context.state.preview = undefined;
447
- context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
448
- } else {
449
- const argsKey = JSON.stringify(previewInput);
450
- if (context.state.argsKey !== argsKey) {
451
- context.state.argsKey = argsKey;
452
- context.state.preview = undefined;
453
- const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
454
- context.state.previewGeneration = previewGeneration;
455
- computeEditPreview(previewInput, context.cwd)
456
- .then((preview) => {
457
- if (
458
- context.state.argsKey === argsKey &&
459
- context.state.previewGeneration === previewGeneration
460
- ) {
461
- context.state.preview = preview;
462
- context.invalidate();
463
- }
464
- })
465
- .catch((err: unknown) => {
466
- if (
467
- context.state.argsKey === argsKey &&
468
- context.state.previewGeneration === previewGeneration
469
- ) {
470
- context.state.preview = {
471
- error: err instanceof Error ? err.message : String(err),
472
- };
473
- context.invalidate();
474
- }
475
- });
476
- }
477
- }
478
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
479
- text.setText(
480
- formatEditCall(
481
- getRenderablePreviewInput(args) ?? undefined,
482
- context.state as EditRenderState,
483
- context.expanded,
484
- theme,
485
- ),
486
- );
487
- return text;
488
- },
489
-
490
- renderResult(result, { isPartial }, theme, context) {
491
- if (isPartial) {
492
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
493
- text.setText(theme.fg("warning", "Editing..."));
494
- return text;
495
- }
496
-
497
- const typedResult = result as {
498
- content?: Array<{ type: string; text?: string }>;
499
- details?: HashlineEditToolDetails;
500
- };
501
- const renderedText = getRenderedEditTextContent(typedResult);
502
-
503
- const renderState = context.state as EditRenderState | undefined;
504
- const previewBeforeResult = renderState?.preview;
505
- if (renderState) {
506
- renderState.preview = undefined;
507
- renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
508
- }
509
-
510
- if (context.isError) {
511
- if (!renderedText) {
512
- return new Text("", 0, 0);
513
- }
514
- const text = context.lastComponent instanceof Text
515
- ? context.lastComponent
516
- : new Text("", 0, 0);
517
- text.setText(`\n${theme.fg("error", renderedText)}`);
518
- return text;
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
+ } from "./hashline";
21
+ import { loadFileKindAndText } from "./file-kind";
22
+ import { resolveToCwd } from "./path-utils";
23
+
24
+ import { throwIfAborted } from "./runtime";
25
+ import { getFileSnapshot } from "./snapshot";
26
+ import { buildChangedResponse, buildNoopResponse } from "./edit-response";
27
+
28
+ const editEntrySchema = Type.Object(
29
+ {
30
+ range: Type.Tuple([Type.String(), Type.String()], {
31
+ description:
32
+ 'LINE#HASH anchor pair [start, end] copied from a recent `read` or `--- Anchors ---` block. Use the same anchor twice for single-line: ["42#A4", "42#A4"].',
33
+ }),
34
+ lines: Type.Array(Type.String(), {
35
+ description: "New content lines. Use [] to delete.",
36
+ }),
37
+ },
38
+ { additionalProperties: false },
39
+ );
40
+ export const hashlineEditToolSchema = Type.Object(
41
+ {
42
+ path: Type.String({ description: "path" }),
43
+ edits: Type.Array(editEntrySchema, {
44
+ 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.",
45
+ }),
46
+ },
47
+ { additionalProperties: false },
48
+ );
49
+
50
+
51
+ type EditRequestParams = {
52
+ path: string;
53
+ edits: Record<string, unknown>[];
54
+ };
55
+
56
+ type EditMetrics = {
57
+ edits_attempted: number;
58
+ edits_noop: number;
59
+ warnings: number;
60
+ classification: "applied" | "noop";
61
+ added_lines?: number;
62
+ removed_lines?: number;
63
+ };
64
+
65
+ type HashlineEditToolDetails = {
66
+ diff: string;
67
+ snapshotId?: string;
68
+ classification?: "noop";
69
+ metrics?: EditMetrics;
70
+ };
71
+
72
+ const EDIT_DESC = readFileSync(
73
+ new URL("../prompts/edit.md", import.meta.url),
74
+ "utf-8",
75
+ ).trim();
76
+
77
+ const EDIT_PROMPT_SNIPPET = readFileSync(
78
+ new URL("../prompts/edit-snippet.md", import.meta.url),
79
+ "utf-8",
80
+ ).trim();
81
+
82
+ function isRecord(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value);
84
+ }
85
+
86
+ // Safety net for environments where AJV validation is disabled.
87
+ // Field-type and schema validation are AJV's responsibility;
88
+ // only prevent crashes from missing required top-level fields.
89
+ // Path existence is checked in execute() once CWD is available.
90
+ export function assertEditRequest(request: unknown): asserts request is EditRequestParams {
91
+ if (!isRecord(request)) {
92
+ throw new Error("Edit request must be an object.");
93
+ }
94
+ if (typeof request.path !== "string" || request.path.length === 0) {
95
+ throw new Error('Edit request requires a non-empty "path" string.');
96
+ }
97
+ if (!Array.isArray(request.edits) || request.edits.length === 0) {
98
+ throw new Error('Edit request requires a non-empty "edits" array.');
99
+ }
100
+ }
101
+
102
+ export function normalizeEditItems(edits: Record<string, unknown>[]): HashlineToolEdit[] {
103
+ return edits.map((edit) => {
104
+ const [pos, end] = (edit.range as [string, string]) || ["", ""];
105
+ return { op: "replace", pos, end, lines: (edit.lines as string[]) || [] };
106
+ });
107
+ }
108
+
109
+ type EditPreview = { diff: string } | { error: string };
110
+ type EditRenderState = {
111
+ argsKey?: string;
112
+ preview?: EditPreview;
113
+ previewGeneration?: number;
114
+ };
115
+
116
+ function getRenderablePreviewInput(args: unknown): EditRequestParams | null {
117
+ if (!isRecord(args) || typeof args.path !== "string") {
118
+ return null;
119
+ }
120
+
121
+ const request: EditRequestParams = {
122
+ path: args.path,
123
+ edits: Array.isArray(args.edits) ? args.edits : [],
124
+ };
125
+ return request.edits.length > 0 ? request : null;
126
+ }
127
+
128
+ function colorDiffLines(
129
+ lines: string[],
130
+ theme: { fg: (token: string, text: string) => string },
131
+ ): string[] {
132
+ return lines.map((line) => {
133
+ if (line.startsWith("+") && !line.startsWith("+++")) {
134
+ return theme.fg("success", line);
135
+ }
136
+ if (line.startsWith("-") && !line.startsWith("---")) {
137
+ return theme.fg("error", line);
138
+ }
139
+ return theme.fg("dim", line);
140
+ });
141
+ }
142
+
143
+ function formatPreviewDiff(
144
+ diff: string,
145
+ expanded: boolean,
146
+ theme: { fg: (token: string, text: string) => string },
147
+ ): string {
148
+ const lines = diff.split("\n");
149
+ const maxLines = expanded ? 40 : 16;
150
+ const shown = colorDiffLines(lines.slice(0, maxLines), theme);
151
+
152
+ if (lines.length > maxLines) {
153
+ shown.push(theme.fg("muted", `... ${lines.length - maxLines} more diff lines`));
154
+ }
155
+ return shown.join("\n");
156
+ }
157
+
158
+ function formatResultDiff(
159
+ diff: string,
160
+ theme: { fg: (token: string, text: string) => string },
161
+ ): string {
162
+ return colorDiffLines(diff.split("\n"), theme).join("\n");
163
+ }
164
+
165
+ function getRenderedEditTextContent(
166
+ result: { content?: Array<{ type: string; text?: string }> },
167
+ ): string | undefined {
168
+ const textContent = result.content?.find(
169
+ (entry): entry is { type: "text"; text: string } =>
170
+ entry.type === "text" && typeof entry.text === "string",
171
+ );
172
+ return textContent?.text;
173
+ }
174
+
175
+ function isAppliedChangedResult(
176
+ details: HashlineEditToolDetails | undefined,
177
+ ): boolean {
178
+ const metrics = details?.metrics;
179
+ return (
180
+ metrics?.classification === "applied" &&
181
+ metrics.added_lines !== undefined &&
182
+ metrics.removed_lines !== undefined
183
+ );
184
+ }
185
+
186
+ function buildAppliedChangedResultText(
187
+ text: string | undefined,
188
+ details: HashlineEditToolDetails | undefined,
189
+ preview: EditPreview | undefined,
190
+ theme: { fg: (token: string, text: string) => string },
191
+ ): string | undefined {
192
+ const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
193
+ const sections: string[] = [];
194
+
195
+ if (details?.diff && details.diff !== previewDiff) {
196
+ sections.push(formatResultDiff(details.diff, theme));
197
+ }
198
+
199
+ const warnings = text?.match(/(?:^|\n\n)Warnings:\n[\s\S]*$/)?.[0]?.trimStart();
200
+ if (warnings) sections.push(warnings);
201
+
202
+ return sections.length > 0 ? sections.join("\n\n") : undefined;
203
+ }
204
+
205
+ function formatEditCall(
206
+ args: EditRequestParams | undefined,
207
+ state: EditRenderState,
208
+ expanded: boolean,
209
+ theme: {
210
+ bold: (text: string) => string;
211
+ fg: (token: string, text: string) => string;
212
+ },
213
+ ): string {
214
+ const path = args?.path;
215
+ const pathDisplay =
216
+ typeof path === "string" && path.length > 0
217
+ ? theme.fg("accent", path)
218
+ : theme.fg("toolOutput", "...");
219
+ let text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
220
+
221
+ if (!state.preview) {
222
+ return text;
223
+ }
224
+
225
+ if ("error" in state.preview) {
226
+ text += `\n\n${theme.fg("error", state.preview.error)}`;
227
+ return text;
228
+ }
229
+
230
+ if (state.preview.diff) {
231
+ text += `\n\n${formatPreviewDiff(state.preview.diff, expanded, theme)}`;
232
+ }
233
+ return text;
234
+ }
235
+
236
+ export async function computeEditPreview(
237
+ request: unknown,
238
+ cwd: string,
239
+ ): Promise<EditPreview> {
240
+ try {
241
+ assertEditRequest(request);
242
+ } catch (error: unknown) {
243
+ return { error: error instanceof Error ? error.message : String(error) };
244
+ }
245
+
246
+ const params = request as EditRequestParams;
247
+ const path = params.path;
248
+ const absolutePath = resolveToCwd(path, cwd);
249
+ const toolEdits = normalizeEditItems(params.edits);
250
+
251
+ try {
252
+ await fsAccess(absolutePath, constants.R_OK);
253
+ } catch (error: unknown) {
254
+ const code = (error as NodeJS.ErrnoException).code;
255
+ if (code === "ENOENT") {
256
+ return { error: `File not found: ${path}` };
257
+ }
258
+ if (code === "EACCES" || code === "EPERM") {
259
+ return { error: `File is not readable: ${path}` };
260
+ }
261
+ return { error: `Cannot access file: ${path}` };
262
+ }
263
+
264
+ try {
265
+ const file = await loadFileKindAndText(absolutePath);
266
+ if (file.kind === "directory") {
267
+ return { error: `Path is a directory: ${path}. Use ls to inspect directories.` };
268
+ }
269
+ if (file.kind === "image") {
270
+ return {
271
+ error: `Path is an image file: ${path}. Hashline edit only supports UTF-8 text files.`,
272
+ };
273
+ }
274
+ if (file.kind === "binary") {
275
+ return {
276
+ error: `Path is a binary file: ${path} (${file.description}). Hashline edit only supports UTF-8 text files.`,
277
+ };
278
+ }
279
+
280
+ const originalNormalized = normalizeToLF(stripBom(file.text).text);
281
+ const resolved = resolveEditAnchors(toolEdits);
282
+ const result = applyHashlineEdits(originalNormalized, resolved).content;
283
+
284
+ if (originalNormalized === result) {
285
+ return {
286
+ error: `No changes made to ${path}. The edits produced identical content.`,
287
+ };
288
+ }
289
+
290
+ return { diff: generateDiffString(originalNormalized, result).diff };
291
+ } catch (error: unknown) {
292
+ return { error: error instanceof Error ? error.message : String(error) };
293
+ }
294
+ }
295
+
296
+ type EditToolDefinition = ToolDefinition<
297
+ typeof hashlineEditToolSchema,
298
+ HashlineEditToolDetails,
299
+ EditRenderState
300
+ > & { renderShell?: "default" | "self" };
301
+
302
+ const editToolDefinition: EditToolDefinition = {
303
+ name: "edit",
304
+ label: "Edit",
305
+ description: EDIT_DESC,
306
+ parameters: hashlineEditToolSchema,
307
+ promptSnippet: EDIT_PROMPT_SNIPPET,
308
+ // Force the default tool shell (Box with pending/success/error background) so
309
+ // we don't inherit renderShell: "self" from the built-in edit tool of the
310
+ // same name, which would drop the shared background color block.
311
+ renderShell: "default",
312
+ renderCall(args, theme, context) {
313
+ const previewInput = getRenderablePreviewInput(args);
314
+ if (context.executionStarted) {
315
+ context.state.argsKey = undefined;
316
+ context.state.preview = undefined;
317
+ context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
318
+ } else if (!context.argsComplete || !previewInput) {
319
+ context.state.argsKey = undefined;
320
+ context.state.preview = undefined;
321
+ context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
322
+ } else {
323
+ const argsKey = JSON.stringify(previewInput);
324
+ if (context.state.argsKey !== argsKey) {
325
+ context.state.argsKey = argsKey;
326
+ context.state.preview = undefined;
327
+ const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
328
+ context.state.previewGeneration = previewGeneration;
329
+ computeEditPreview(previewInput, context.cwd)
330
+ .then((preview) => {
331
+ if (
332
+ context.state.argsKey === argsKey &&
333
+ context.state.previewGeneration === previewGeneration
334
+ ) {
335
+ context.state.preview = preview;
336
+ context.invalidate();
337
+ }
338
+ })
339
+ .catch((err: unknown) => {
340
+ if (
341
+ context.state.argsKey === argsKey &&
342
+ context.state.previewGeneration === previewGeneration
343
+ ) {
344
+ context.state.preview = {
345
+ error: err instanceof Error ? err.message : String(err),
346
+ };
347
+ context.invalidate();
348
+ }
349
+ });
350
+ }
351
+ }
352
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
353
+ text.setText(
354
+ formatEditCall(
355
+ getRenderablePreviewInput(args) ?? undefined,
356
+ context.state as EditRenderState,
357
+ context.expanded,
358
+ theme,
359
+ ),
360
+ );
361
+ return text;
362
+ },
363
+
364
+ renderResult(result, { isPartial }, theme, context) {
365
+ if (isPartial) {
366
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
367
+ text.setText(theme.fg("warning", "Editing..."));
368
+ return text;
369
+ }
370
+
371
+ const typedResult = result as {
372
+ content?: Array<{ type: string; text?: string }>;
373
+ details?: HashlineEditToolDetails;
374
+ };
375
+ const renderedText = getRenderedEditTextContent(typedResult);
376
+
377
+ const renderState = context.state as EditRenderState | undefined;
378
+ const previewBeforeResult = renderState?.preview;
379
+ if (renderState) {
380
+ renderState.preview = undefined;
381
+ renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
382
+ }
383
+
384
+ if (context.isError) {
385
+ if (!renderedText) {
386
+ return new Text("", 0, 0);
387
+ }
388
+ const text = context.lastComponent instanceof Text
389
+ ? context.lastComponent
390
+ : new Text("", 0, 0);
391
+ text.setText(`\n${theme.fg("error", renderedText)}`);
392
+ return text;
393
+ }
394
+
395
+ if (isAppliedChangedResult(typedResult.details)) {
396
+ const appliedChangedText = buildAppliedChangedResultText(
397
+ renderedText,
398
+ typedResult.details,
399
+ previewBeforeResult,
400
+ theme,
401
+ );
402
+ if (!appliedChangedText) {
403
+ return new Text("", 0, 0);
404
+ }
405
+ const text = context.lastComponent instanceof Text
406
+ ? context.lastComponent
407
+ : new Text("", 0, 0);
408
+ text.setText(appliedChangedText);
409
+ return text;
410
+ }
411
+
412
+ if (!renderedText) {
413
+ return new Text("", 0, 0);
414
+ }
415
+
416
+ const text = context.lastComponent instanceof Text
417
+ ? context.lastComponent
418
+ : new Text("", 0, 0);
419
+ text.setText(renderedText);
420
+ return text;
421
+ },
422
+
423
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
424
+ assertEditRequest(params);
425
+
426
+ const path = (params as EditRequestParams).path;
427
+ const absolutePath = resolveToCwd(path, ctx.cwd);
428
+ const toolEdits = normalizeEditItems(
429
+ (params as EditRequestParams).edits,
430
+ );
431
+
432
+ const mutationTargetPath = await resolveMutationTargetPath(absolutePath);
433
+ return withFileMutationQueue(mutationTargetPath, async () => {
434
+ throwIfAborted(signal);
435
+ try {
436
+ await fsAccess(absolutePath, constants.R_OK | constants.W_OK);
437
+ } catch (error: unknown) {
438
+ const code = (error as NodeJS.ErrnoException).code;
439
+ if (code === "ENOENT") {
440
+ throw new Error(`File not found: ${path}`);
441
+ }
442
+ if (code === "EACCES" || code === "EPERM") {
443
+ throw new Error(`File is not writable: ${path}`);
444
+ }
445
+ throw new Error(`Cannot access file: ${path}`);
446
+ }
447
+
448
+ throwIfAborted(signal);
449
+ const file = await loadFileKindAndText(absolutePath);
450
+ if (file.kind === "directory") {
451
+ throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
452
+ }
453
+ if (file.kind === "image") {
454
+ throw new Error(
455
+ `Path is an image file: ${path}. Hashline edit only supports UTF-8 text files.`,
456
+ );
457
+ }
458
+ if (file.kind === "binary") {
459
+ throw new Error(
460
+ `Path is a binary file: ${path} (${file.description}). Hashline edit only supports UTF-8 text files.`,
461
+ );
462
+ }
463
+
464
+ throwIfAborted(signal);
465
+ const { bom, text: content } = stripBom(file.text);
466
+ const originalEnding = detectLineEnding(content);
467
+ const originalNormalized = normalizeToLF(content);
468
+
469
+ const resolved = resolveEditAnchors(toolEdits);
470
+
471
+ const anchorResult = applyHashlineEdits(originalNormalized, resolved, signal);
472
+ const result = anchorResult.content;
473
+ const warnings = anchorResult.warnings;
474
+ const originalLineCount = originalNormalized.length === 0
475
+ ? 0
476
+ : originalNormalized.split("\n").length - (originalNormalized.endsWith("\n") ? 1 : 0);
477
+ if (result.length === 0 && originalLineCount > 50) {
478
+ throw new Error(
479
+ "[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.",
480
+ );
481
+ }
482
+ const noopEdits = anchorResult.noopEdits;
483
+ const editsAttempted = toolEdits.length;
484
+
485
+ if (originalNormalized === result) {
486
+ const noopSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
487
+ return buildNoopResponse({
488
+ path,
489
+ noopEdits,
490
+ originalNormalized,
491
+ snapshotId: noopSnapshotId,
492
+ editsAttempted,
493
+ warnings,
494
+ });
495
+ }
496
+
497
+ throwIfAborted(signal);
498
+ await writeFileAtomically(
499
+ absolutePath,
500
+ bom + restoreLineEndings(result, originalEnding),
501
+ );
502
+ const updatedSnapshotId = (await getFileSnapshot(absolutePath)).snapshotId;
503
+
504
+ return buildChangedResponse({
505
+ path,
506
+ originalNormalized,
507
+ result,
508
+ warnings,
509
+ snapshotId: updatedSnapshotId,
510
+ editsAttempted,
511
+ noopEditsCount: noopEdits?.length ?? 0,
512
+ });
513
+ });
514
+ },
515
+ };
516
+
517
+ export function registerEditTool(pi: ExtensionAPI): void {
518
+ pi.registerTool(editToolDefinition);
519
+ }