@xynogen/pix-edit 0.2.15 → 0.2.17

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/edit.ts +30 -493
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-edit",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Pi tool — precise text replacement edit with diff rendering",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -39,8 +39,8 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@xynogen/pix-pretty": "^1.37.0",
43
- "@xynogen/pix-runtime": "^0.17.0"
42
+ "@xynogen/pix-pretty": "^2.0.0",
43
+ "@xynogen/pix-runtime": "^0.19.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "typebox": "*",
package/src/edit.ts CHANGED
@@ -1,15 +1,9 @@
1
- import { constants } from "node:fs";
2
- import { access, readFile, writeFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { validateToolArguments } from "@earendil-works/pi-ai";
6
1
  import type {
7
2
  AgentToolUpdateCallback,
8
3
  EditToolInput,
9
4
  ExtensionContext,
10
5
  ToolRenderResultOptions,
11
6
  } from "@earendil-works/pi-coding-agent";
12
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
7
  import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
14
8
  import { MAX_RENDER_LINES } from "@xynogen/pix-pretty/config";
15
9
  import type { ToolContext } from "@xynogen/pix-pretty/context";
@@ -36,13 +30,13 @@ import {
36
30
  BODY_PAD,
37
31
  dotJoin,
38
32
  fillToolBackground,
39
- formatToolCallTitle,
40
33
  frameToolResult,
41
34
  getErrorMessage,
42
35
  getTextContent,
43
36
  hideCollapsedToolCall,
44
37
  isTextContent,
45
- renderCollapsedToolRow,
38
+ renderCollapsed,
39
+ renderTitle,
46
40
  renderToolError,
47
41
  setResultDetails,
48
42
  termW,
@@ -50,14 +44,20 @@ import {
50
44
  } from "@xynogen/pix-pretty/utils";
51
45
  import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
52
46
  import {
53
- anchor,
54
- decodeLines,
55
- hashLine,
56
- parseAnchor,
57
- type SourceLine,
58
- } from "@xynogen/pix-runtime/hashline";
59
- import { expandHome } from "@xynogen/pix-runtime/paths";
60
- import { Type } from "typebox";
47
+ type EditInput,
48
+ editPath,
49
+ editSchema,
50
+ editText,
51
+ type HashParams,
52
+ prepareEditArguments,
53
+ } from "@xynogen/pix-runtime/files";
54
+
55
+ export {
56
+ editPath,
57
+ freshAnchors,
58
+ prepareEditArguments,
59
+ prepareHashArguments,
60
+ } from "@xynogen/pix-runtime/files";
61
61
 
62
62
  // ── Helpers ────────────────────────────────────────────────────────────
63
63
 
@@ -95,447 +95,6 @@ export function getEditOperations(input: EditParams): EditOperation[] {
95
95
  return oldText && oldText !== newText ? [{ oldText, newText }] : [];
96
96
  }
97
97
 
98
- type HashEdit = {
99
- op: "replace" | "insert_before" | "insert_after";
100
- pos: string;
101
- end?: string;
102
- lines: string[];
103
- };
104
- type HashParams = { path: string; edits: HashEdit[] };
105
- type ExactParams = { path: string; edits: EditOperation[] };
106
- type EditInput = HashParams | ExactParams;
107
-
108
- const hashFields = {
109
- pos: Type.String({
110
- pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
111
- description: "Original LINE#HASH anchor from read",
112
- }),
113
- lines: Type.Array(
114
- Type.String({
115
- description:
116
- "One literal source line per item. No newline or LINE#HASH| prefix. Empty array deletes.",
117
- }),
118
- ),
119
- };
120
- const hashEdits = Type.Array(
121
- Type.Union([
122
- Type.Object(
123
- {
124
- op: Type.Literal("replace"),
125
- ...hashFields,
126
- end: Type.Optional(
127
- Type.String({
128
- pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
129
- description: "Inclusive end anchor. Omit for one line.",
130
- }),
131
- ),
132
- },
133
- { additionalProperties: false },
134
- ),
135
- Type.Object(
136
- {
137
- op: Type.Union([Type.Literal("insert_before"), Type.Literal("insert_after")]),
138
- ...hashFields,
139
- },
140
- { additionalProperties: false },
141
- ),
142
- ]),
143
- { minItems: 1 },
144
- );
145
- const exactEdits = Type.Array(
146
- Type.Object(
147
- {
148
- oldText: Type.String({
149
- minLength: 1,
150
- description:
151
- "Fallback only after a hash-anchor edit fails. Exact current source text; must match once.",
152
- }),
153
- newText: Type.String({ description: "Replacement text. Empty string deletes." }),
154
- },
155
- { additionalProperties: false },
156
- ),
157
- { minItems: 1 },
158
- );
159
- const path = Type.String({ minLength: 1 });
160
- const editSchema = Type.Object(
161
- {
162
- path,
163
- // ponytail: union whole arrays so the schema rejects mixed formats before execution.
164
- edits: Type.Union([hashEdits, exactEdits], {
165
- description:
166
- "Try hash-anchor edits first. Use exact-text edits only after a hash-anchor edit fails. Never mix formats.",
167
- }),
168
- },
169
- { additionalProperties: false },
170
- );
171
- // One branch each, so a bad call reports only the errors of the format it meant.
172
- const hashSchema = Type.Object({ path, edits: hashEdits }, { additionalProperties: false });
173
- const exactSchema = Type.Object({ path, edits: exactEdits }, { additionalProperties: false });
174
-
175
- const parseJson = (value: string): unknown => {
176
- try {
177
- return JSON.parse(value);
178
- } catch {
179
- return value;
180
- }
181
- };
182
-
183
- export function prepareHashArguments(input: unknown): unknown {
184
- if (!input || typeof input !== "object" || Array.isArray(input)) return input;
185
- const args = { ...input } as Record<string, unknown>;
186
- if (typeof args.edits === "string") args.edits = parseJson(args.edits);
187
- if (args.edits && typeof args.edits === "object" && !Array.isArray(args.edits))
188
- args.edits = [args.edits];
189
- // Top recorded failure: one edit item sent as a JSON string.
190
- if (Array.isArray(args.edits))
191
- args.edits = args.edits.map((item) => {
192
- if (typeof item !== "string") return item;
193
- const parsed = parseJson(item);
194
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : item;
195
- });
196
- return args;
197
- }
198
-
199
- const EDIT_FIX = "Fix: resend only the broken field; keep one format per call.";
200
-
201
- function editFix(format: "hash" | "exact", path: string, problem: string): string {
202
- if (/\.(pos|end)$/.test(path) && problem.startsWith("expected to match"))
203
- return "LINE#HASH from read, hash is uppercase hex (12#A3F)";
204
- if (/\.op$/.test(path)) return "op is replace, insert_before or insert_after";
205
- if (/\.lines$/.test(path) && problem === "missing")
206
- return "missing; add lines: [] of literal source lines ([] deletes)";
207
- if (/\.lines(\.\d+)?$/.test(path)) return "lines is an array of strings, one per line";
208
- if (/\.oldText$/.test(path) && problem !== "missing")
209
- return "oldText is non-empty exact current source text";
210
- if (problem === "unknown field") {
211
- if (!path.startsWith("edits.")) return "unknown field; send only path and edits";
212
- return format === "hash"
213
- ? "unknown field; hash items take op, pos, end?, lines"
214
- : "unknown field; exact items take oldText, newText";
215
- }
216
- if (/^edits\.\d+$/.test(path)) return `${problem}; each edit is one object`;
217
- if (path === "edits") return `${problem}; edits is a non-empty array of objects`;
218
- return problem;
219
- }
220
-
221
- /**
222
- * Normalize, then check with the host validator. A bad call fails with a few
223
- * lines that name the field and the fix, for the format the call meant.
224
- */
225
- export function prepareEditArguments(input: unknown): unknown {
226
- const args = prepareHashArguments(input);
227
- const edits = (args as { edits?: unknown } | undefined)?.edits;
228
- const items = Array.isArray(edits) ? edits : [];
229
- const kind = (item: unknown) =>
230
- item && typeof item === "object"
231
- ? ["op", "pos", "end", "lines"].some((k) => k in item)
232
- ? "hash"
233
- : ["oldText", "newText"].some((k) => k in item)
234
- ? "exact"
235
- : undefined
236
- : undefined;
237
- const kinds = items.map(kind);
238
- const at = (k: string) => kinds.flatMap((x, i) => (x === k ? [i] : [])).join(",");
239
- if (kinds.includes("hash") && kinds.includes("exact"))
240
- throw new Error(
241
- badArgs(
242
- "edit",
243
- [`- edits: mixed formats (hash at [${at("hash")}], exact at [${at("exact")}])`],
244
- EDIT_FIX,
245
- ),
246
- );
247
- const format = kinds.includes("exact") ? "exact" : "hash";
248
- checkArgs(
249
- "edit",
250
- format === "hash" ? hashSchema : exactSchema,
251
- args,
252
- (path, problem) => editFix(format, path, problem),
253
- EDIT_FIX,
254
- );
255
- return args;
256
- }
257
-
258
- // ponytail: pix-ask has a copy of this host-error reformatter (clip/badArgs/checkArgs). Move it
259
- // to pix-runtime when a third tool needs it (public API addition, minor bump).
260
- const clip = (text: string, max: number) =>
261
- text.length > max ? `${text.slice(0, max - 1)}…` : text;
262
-
263
- function badArgs(tool: string, rows: string[], footer: string): string {
264
- const head =
265
- rows.length > 5 ? `${tool}: ${rows.length} bad arguments, first 5` : `${tool}: bad arguments`;
266
- return [head, ...rows.slice(0, 5), footer].join("\n");
267
- }
268
-
269
- /** Host-validate `args`; on failure throw a short field/fix list, not the full-argument echo. */
270
- function checkArgs(
271
- tool: string,
272
- schema: object,
273
- args: unknown,
274
- fix: (path: string, problem: string, value: unknown) => string,
275
- footer: string,
276
- ): void {
277
- try {
278
- validateToolArguments(
279
- { name: tool, description: "", parameters: schema },
280
- { type: "toolCall", id: "", name: tool, arguments: args as never },
281
- );
282
- return;
283
- } catch (error) {
284
- // ponytail: reads pi-ai's " - path: problem" rows; an unknown format rethrows the host error.
285
- const text = (error instanceof Error ? error.message : String(error)).split(
286
- "\n\nReceived arguments:",
287
- )[0];
288
- const seen = new Set<string>();
289
- const rows = [...(text ?? "").matchAll(/^ {2}- (\S+): (.+)$/gm)].map(
290
- ([, path = "", problem = ""]) => {
291
- const value =
292
- path === "root"
293
- ? args
294
- : path
295
- .split(".")
296
- .reduce<unknown>(
297
- (v, k) =>
298
- v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined,
299
- args,
300
- );
301
- const shown =
302
- value === undefined ? "" : ` ${clip(JSON.stringify(value) ?? String(value), 40)}`;
303
- // State a repeated fix once, so five rows stay a few hundred bytes.
304
- const advice = fix(path, problem, value);
305
- const said = seen.has(advice);
306
- seen.add(advice);
307
- return `- ${path.replace(/\.(\d+)/g, "[$1]")}${shown}: ${said ? "same fix" : advice}`;
308
- },
309
- );
310
- if (!rows.length) throw error;
311
- throw new Error(badArgs(tool, rows, footer));
312
- }
313
- }
314
-
315
- export function editPath(path: string, cwd: string): string {
316
- let value = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ").replace(/^@/, "");
317
- if (
318
- process.platform === "win32" &&
319
- value.startsWith("/") &&
320
- !value.startsWith("//") &&
321
- !value.includes("\\")
322
- ) {
323
- value = value.replace(
324
- /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i,
325
- (_all, drive: string, tail: string | undefined) =>
326
- `${drive.toUpperCase()}:\\${(tail ?? "").replaceAll("/", "\\")}`,
327
- );
328
- }
329
- value = expandHome(value);
330
- if (value.startsWith("file://")) value = fileURLToPath(value);
331
- return resolve(cwd, value);
332
- }
333
-
334
- function applyHashEdits(bytes: Buffer, input: unknown) {
335
- const args = prepareHashArguments(input) as HashParams;
336
- if (
337
- !args ||
338
- typeof args.path !== "string" ||
339
- !args.path ||
340
- !Array.isArray(args.edits) ||
341
- !args.edits.length
342
- )
343
- throw new Error("Invalid hashline edit input");
344
- if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
345
- throw new Error("Legacy exact-text fields are not supported");
346
- const source = decodeLines(bytes);
347
- // Stale/out-of-bounds anchors carry the current lines around the target so the
348
- // model can retry at once instead of spending a read round-trip.
349
- const nearby = (line: number) => {
350
- const from = Math.max(1, Math.min(line, source.lines.length) - 2);
351
- const rows = source.lines.slice(from - 1, from + 4).map((l, i) => {
352
- const text = l.text.length > 200 ? `${l.text.slice(0, 200)}…` : l.text;
353
- return `${anchor(from + i, l.text)}|${text}`;
354
- });
355
- return rows.length ? `\nCurrent lines:\n${rows.join("\n")}` : "";
356
- };
357
- const check = (value: unknown) => {
358
- const parsed = parseAnchor(value);
359
- const line = source.lines[parsed.line - 1];
360
- if (!line)
361
- throw new Error(
362
- `Anchor out of bounds: ${String(value)} (file has ${source.lines.length} lines).${nearby(parsed.line)}`,
363
- );
364
- if (hashLine(line.text) !== parsed.hash)
365
- throw new Error(
366
- `Stale anchor: ${String(value)}. Use the current anchors below, or read again if the target moved.${nearby(parsed.line)}`,
367
- );
368
- return parsed.line;
369
- };
370
- const replacements: Array<{ start: number; end: number; lines: string[] }> = [];
371
- const gaps = new Map<number, string[]>();
372
- for (const edit of args.edits) {
373
- if (
374
- !edit ||
375
- typeof edit !== "object" ||
376
- Object.keys(edit).some((key) => !["op", "pos", "end", "lines"].includes(key))
377
- )
378
- throw new Error("Legacy or invalid edit fields");
379
- if (
380
- !["replace", "insert_before", "insert_after"].includes(edit.op) ||
381
- !Array.isArray(edit.lines) ||
382
- edit.lines.some(
383
- (line) =>
384
- typeof line !== "string" ||
385
- /[\r\n\0]|^[1-9]\d*#[0-9A-F]{3}\|/.test(line) ||
386
- /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(line),
387
- )
388
- )
389
- throw new Error("Invalid edit operation or source lines");
390
- const start = check(edit.pos);
391
- if (edit.op === "replace") {
392
- const end = edit.end === undefined ? start : check(edit.end);
393
- if (end < start) throw new Error("Reversed replacement range");
394
- replacements.push({ start, end, lines: edit.lines });
395
- } else {
396
- if (edit.end !== undefined) throw new Error("end is only allowed for replace");
397
- const gap = edit.op === "insert_before" ? start - 1 : start;
398
- gaps.set(gap, [...(gaps.get(gap) ?? []), ...edit.lines]);
399
- }
400
- }
401
- replacements.sort((a, b) => a.start - b.start);
402
- for (let i = 0; i < replacements.length; i++) {
403
- const range = replacements[i]!;
404
- if (i && replacements[i - 1]!.end >= range.start)
405
- throw new Error("Overlapping replacement ranges");
406
- for (const gap of gaps.keys())
407
- if (gap >= range.start && gap < range.end)
408
- throw new Error("Insertion overlaps a replacement range");
409
- }
410
- const output: SourceLine[] = [];
411
- // 1-based output line spans each op wrote, for the fresh anchors in the result.
412
- const spans: Array<[number, number]> = [];
413
- const ops: Array<EditOperation & { editLine: number }> = [];
414
- const append = (lines: string[]) =>
415
- output.push(...lines.map((text) => ({ text, ending: source.delimiter })));
416
- let rangeIndex = 0;
417
- for (let gap = 0; gap <= source.lines.length; ) {
418
- const insertion = gaps.get(gap);
419
- if (insertion?.length) {
420
- const context = source.lines[Math.max(0, gap - 1)]!.text;
421
- ops.push({
422
- oldText: context,
423
- newText:
424
- gap === 0 ? [...insertion, context].join("\n") : [context, ...insertion].join("\n"),
425
- editLine: Math.max(1, output.length + (gap === 0 ? 1 : 0)),
426
- });
427
- spans.push([output.length + 1, output.length + insertion.length]);
428
- append(insertion);
429
- }
430
- if (gap === source.lines.length) break;
431
- const range = replacements[rangeIndex];
432
- if (range?.start === gap + 1) {
433
- ops.push({
434
- oldText: source.lines
435
- .slice(gap, range.end)
436
- .map((line) => line.text)
437
- .join("\n"),
438
- newText: range.lines.join("\n"),
439
- editLine: output.length + 1,
440
- });
441
- // A deletion has no new lines; mark the line that now sits in its place.
442
- spans.push([output.length + 1, output.length + Math.max(1, range.lines.length)]);
443
- append(range.lines);
444
- gap = range.end;
445
- rangeIndex++;
446
- } else {
447
- output.push({ ...source.lines[gap]! });
448
- gap++;
449
- }
450
- }
451
- for (let i = 0; i < output.length; i++) {
452
- if (i === output.length - 1)
453
- output[i]!.ending = source.finalNewline ? output[i]!.ending || source.delimiter : "";
454
- else if (!output[i]!.ending) output[i]!.ending = source.delimiter;
455
- }
456
- return {
457
- content: source.bom + output.map((line) => line.text + line.ending).join(""),
458
- ops,
459
- spans,
460
- };
461
- }
462
-
463
- function applyExactEdits(bytes: Buffer, args: ExactParams) {
464
- if (Object.keys(args).some((key) => !["path", "edits"].includes(key)))
465
- throw new Error("Invalid exact-text edit fields");
466
- const source = decodeLines(bytes);
467
- const original = source.lines.map((line) => line.text + line.ending).join("");
468
- // ponytail: exact bytes only. Add newline normalization only if callers need it.
469
- const ranges = args.edits
470
- .map((edit) => {
471
- if (
472
- !edit ||
473
- typeof edit !== "object" ||
474
- Object.keys(edit).some((key) => !["oldText", "newText"].includes(key)) ||
475
- typeof edit.oldText !== "string" ||
476
- !edit.oldText ||
477
- typeof edit.newText !== "string" ||
478
- [edit.oldText, edit.newText].some((text) =>
479
- /\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(text),
480
- )
481
- )
482
- throw new Error("Invalid exact-text edit");
483
- const start = original.indexOf(edit.oldText);
484
- if (start < 0) throw new Error("oldText was not found. Use exact source text.");
485
- if (original.indexOf(edit.oldText, start + 1) >= 0)
486
- throw new Error("oldText matches more than once. Include more context.");
487
- return { ...edit, start, end: start + edit.oldText.length };
488
- })
489
- .sort((a, b) => a.start - b.start);
490
- let content = "";
491
- let offset = 0;
492
- const ops: Array<EditOperation & { editLine: number }> = [];
493
- const spans: Array<[number, number]> = [];
494
- const lineCount = (text: string) => text.match(/\r\n|\r|\n/g)?.length ?? 0;
495
- for (const range of ranges) {
496
- if (range.start < offset) throw new Error("Overlapping exact-text edits");
497
- content += original.slice(offset, range.start);
498
- const editLine = lineCount(content) + 1;
499
- ops.push({ oldText: range.oldText, newText: range.newText, editLine });
500
- spans.push([editLine, editLine + lineCount(range.newText)]);
501
- content += range.newText;
502
- offset = range.end;
503
- }
504
- return { content: source.bom + content + original.slice(offset), ops, spans };
505
- }
506
-
507
- /** Context lines around each changed span, and the cap on anchored lines per result. */
508
- const FRESH_CONTEXT = 2;
509
- const FRESH_MAX_LINES = 20;
510
-
511
- /**
512
- * Current `LINE#HASH|text` rows around the changed spans, so a chained edit on
513
- * the same file needs no `read` between calls (SWE-agent shows the edited
514
- * window after every edit). Gaps between spans are marked with `…`.
515
- */
516
- export function freshAnchors(content: string, spans: Array<[number, number]>): string {
517
- const lines = decodeLines(Buffer.from(content, "utf8")).lines;
518
- const wanted = new Set<number>();
519
- for (const [from, to] of spans)
520
- for (
521
- let n = Math.max(1, from - FRESH_CONTEXT);
522
- n <= Math.min(lines.length, to + FRESH_CONTEXT);
523
- n++
524
- )
525
- wanted.add(n);
526
- const picked = [...wanted].sort((a, b) => a - b);
527
- const shown = picked.slice(0, FRESH_MAX_LINES);
528
- const rows: string[] = [];
529
- for (const [i, n] of shown.entries()) {
530
- if (i && n !== (shown[i - 1] ?? 0) + 1) rows.push("…");
531
- const text = lines[n - 1]?.text ?? "";
532
- rows.push(`${anchor(n, text)}|${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
533
- }
534
- if (picked.length > shown.length)
535
- rows.push(`… ${picked.length - shown.length} more changed lines; read for anchors`);
536
- return rows.join("\n");
537
- }
538
-
539
98
  export function summarizeEditOperations(operations: EditOperation[]) {
540
99
  const diffs = operations.map((e) => parseDiff(e.oldText, e.newText));
541
100
  const totalAdded = diffs.reduce((sum, d) => sum + d.added, 0);
@@ -588,40 +147,18 @@ export function registerEditTool(
588
147
  try {
589
148
  if (!fp) throw new Error("path must be a nonempty string");
590
149
  const absolutePath = editPath(fp, toolCtx?.cwd || cwd);
591
- result = await withFileMutationQueue(absolutePath, async () => {
592
- const abort = () => {
593
- if (sig?.aborted) throw new Error("Operation aborted");
594
- };
595
- abort();
596
- await access(absolutePath, constants.R_OK | constants.W_OK);
597
- abort();
598
- const bytes = await readFile(absolutePath);
599
- abort();
600
- const args = prepareHashArguments(params) as EditInput;
601
- if (!Array.isArray(args.edits) || !args.edits.length)
602
- throw new Error("edits must be a nonempty array");
603
- const applied = args.edits.every(
604
- (edit) => edit && typeof edit === "object" && "oldText" in edit,
605
- )
606
- ? applyExactEdits(bytes, args as ExactParams)
607
- : applyHashEdits(bytes, args);
608
- operations = applied.ops;
609
- abort();
610
- // ponytail: in-place writes preserve links and mode, not crash atomicity. Use a separate atomic-write design if needed.
611
- await writeFile(absolutePath, applied.content, "utf8");
612
- abort();
613
- const fresh = freshAnchors(applied.content, applied.spans);
614
- const text = `Edited ${fp}: ${operations.length} operations.`;
615
- return {
616
- content: [
617
- {
618
- type: "text",
619
- text: fresh ? `${text}\nCurrent anchors:\n${fresh}` : text,
620
- },
621
- ],
622
- details: undefined,
623
- };
624
- });
150
+ const applied = await editText(absolutePath, params, undefined, sig);
151
+ operations = applied.ops;
152
+ const text = `Edited ${fp}: ${operations.length} operations.`;
153
+ result = {
154
+ content: [
155
+ {
156
+ type: "text",
157
+ text: applied.fresh ? `${text}\nCurrent anchors:\n${applied.fresh}` : text,
158
+ },
159
+ ],
160
+ details: undefined,
161
+ };
625
162
  } catch (error) {
626
163
  const text = getErrorMessage(error);
627
164
  if (sig?.aborted || /aborted/i.test(text)) throw error;
@@ -690,7 +227,7 @@ export function registerEditTool(
690
227
  )
691
228
  )
692
229
  return text;
693
- const hdr = `${formatToolCallTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
230
+ const hdr = `${renderTitle(theme, "edit", renderCtx)} ${theme.fg("dim", sp(fp))}`;
694
231
 
695
232
  if (Array.isArray(args.edits) && args.edits.some((edit) => "pos" in edit)) {
696
233
  text.setText(
@@ -753,7 +290,7 @@ export function registerEditTool(
753
290
  filePath = String(ops?.[0]?.filePath ?? "");
754
291
  }
755
292
  text.setText(
756
- renderCollapsedToolRow(
293
+ renderCollapsed(
757
294
  theme,
758
295
  "edit",
759
296
  sp(filePath),