decorated-pi 0.2.2 → 0.4.0

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.
@@ -0,0 +1,587 @@
1
+ /**
2
+ * IO — Replace Pi native edit/write with `patch` tool (single-file)
3
+ *
4
+ * Keeps: read (for stale-read protection via mtime tracking)
5
+ * Removes: edit, write
6
+ * Adds: patch (old_str/new_str exact replacement, single file per call)
7
+ *
8
+ * Schema: { path, edits?, overwrite?, new_str? }
9
+ * Pi’s native parallel tool calls handle multi-file scenarios.
10
+ *
11
+ * Stale-read protection:
12
+ * - `read` tool records file mtime when LLM reads a file
13
+ * - `patch` tool checks: if file mtime > last-read mtime → reject
14
+ * - `patch` tool updates mtime after successful write
15
+ *
16
+ * TUI Rendering Pitfalls (learned the hard way):
17
+ * 1. execute() MUST throw errors, NOT return { isError: true }
18
+ * 2. TUI rendering MUST mirror the edit tool pattern exactly
19
+ * 3. getPatchHeaderBg: settledError MUST be checked first
20
+ * 4. renderResult must NOT return the Box
21
+ * renderCall returns the Box (callComponent). If renderResult
22
+ * also returns it, pi's ToolExecutionComponent adds it twice
23
+ * to the container, causing duplicate boxes. renderResult
24
+ * must return context.lastComponent (a separate Container).
25
+ * 5. Error text must go INSIDE the Box, not in the result Container
26
+ * 6. prepareArguments must handle literal newlines in JSON strings
27
+ */
28
+
29
+ import { defineTool, isReadToolResult, keyHint, getLanguageFromPath, highlightCode, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { renderDiff } from "@earendil-works/pi-coding-agent";
31
+ import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
32
+ import { Type } from "typebox";
33
+ import {
34
+ applyPatch,
35
+ formatPatchResult,
36
+ generatePatchDiff,
37
+ type PatchPreview,
38
+ } from "./patch.js";
39
+ import {
40
+ recordReadTime,
41
+ checkStaleFile,
42
+ clearReadMarkers,
43
+ resolveAbsolutePath,
44
+ FILE_TIMES_CUSTOM_TYPE,
45
+ createFileTimeMarkerData,
46
+ restoreReadMarkersFromBranch,
47
+ } from "./file-times.js";
48
+
49
+ // ─── Schema ─────────────────────────────────────────────────────────────────────────
50
+
51
+ const EditSchema = Type.Object({
52
+ anchor: Type.Optional(Type.String({
53
+ description: "Optional unique string that appears BEFORE old_str in the file. Narrows the search range.",
54
+ })),
55
+ old_str: Type.String({
56
+ description: "Exact text to find. Must be unique within the search range. String, not regex.",
57
+ }),
58
+ new_str: Type.String({
59
+ description: "Replacement text. String. Use empty string to delete.",
60
+ }),
61
+ });
62
+
63
+ const PatchSchema = Type.Object({
64
+ path: Type.String({
65
+ description: "Path to the file to edit (relative or absolute).",
66
+ }),
67
+ edits: Type.Optional(Type.Array(EditSchema, {
68
+ description: "Targeted replacements applied sequentially.",
69
+ })),
70
+ overwrite: Type.Optional(Type.Boolean({
71
+ description: "If true, replace the entire file atomically (write temp → mv).",
72
+ })),
73
+ new_str: Type.Optional(Type.String({
74
+ description: "Entire new file content when overwrite is true.",
75
+ })),
76
+ });
77
+
78
+ // ─── Argument repair ───────────────────────────────────────────────────────────────
79
+
80
+ function fixJsonNewlines(str: string): string {
81
+ let result = '';
82
+ let inString = false;
83
+ let escaped = false;
84
+ for (let i = 0; i < str.length; i++) {
85
+ const ch = str[i];
86
+ if (escaped) { result += ch; escaped = false; continue; }
87
+ if (ch === '\\') { result += ch; escaped = true; continue; }
88
+ if (ch === '"') { inString = !inString; result += ch; continue; }
89
+ if (inString && (ch === '\n' || ch === '\r')) {
90
+ result += ch === '\n' ? '\\n' : '\\r';
91
+ continue;
92
+ }
93
+ result += ch;
94
+ }
95
+ return result;
96
+ }
97
+
98
+ function jsonParseWithNewlineFix(str: string): any {
99
+ try { return JSON.parse(str); }
100
+ catch { try { return JSON.parse(fixJsonNewlines(str)); } catch { return undefined; } }
101
+ }
102
+
103
+ export function preparePatchArguments(input: any): any {
104
+ if (!input || typeof input !== "object") return input;
105
+
106
+ const args = input as Record<string, any>;
107
+
108
+ // Legacy multi-file format: { patches: [{ path, edits }] } → extract first
109
+ if (Array.isArray(args.patches) && !args.path) {
110
+ const first = args.patches[0];
111
+ if (first && typeof first === "object" && first.path) {
112
+ Object.assign(args, first);
113
+ delete args.patches;
114
+ }
115
+ } else if (typeof args.patches === "string" && !args.path) {
116
+ try {
117
+ const parsed = jsonParseWithNewlineFix(args.patches);
118
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.path) {
119
+ Object.assign(args, parsed[0]);
120
+ delete args.patches;
121
+ } else if (parsed && typeof parsed === "object" && parsed.path) {
122
+ Object.assign(args, parsed);
123
+ delete args.patches;
124
+ }
125
+ } catch { /* keep original */ }
126
+ }
127
+
128
+ // Edits serialized as JSON string
129
+ if (typeof args.edits === "string") {
130
+ try {
131
+ const parsed = jsonParseWithNewlineFix(args.edits);
132
+ if (Array.isArray(parsed)) args.edits = parsed;
133
+ } catch { /* keep original */ }
134
+ }
135
+
136
+ // Legacy: top-level old_str/new_str instead of edits array
137
+ if (typeof args.old_str === "string" && typeof args.new_str === "string") {
138
+ const edit: any = { old_str: args.old_str, new_str: args.new_str };
139
+ if (typeof args.anchor === "string") edit.anchor = args.anchor;
140
+ args.edits = args.edits ? [...args.edits, edit] : [edit];
141
+ delete args.old_str;
142
+ delete args.new_str;
143
+ delete args.anchor;
144
+ }
145
+
146
+ return args;
147
+ }
148
+
149
+ // ─── TUI rendering ─────────────────────────────────────────────────────────────────
150
+
151
+ interface PatchCallComponent extends Box {
152
+ preview?: PatchPreview;
153
+ previewArgsKey?: string;
154
+ previewPending?: boolean;
155
+ settledError: boolean;
156
+ /** Overwrite streaming highlight cache (mirrors write tool design) */
157
+ overwriteHighlightCache?: {
158
+ rawPath: string;
159
+ lang: string | undefined;
160
+ rawContent: string;
161
+ normalizedLines: string[];
162
+ highlightedLines: string[];
163
+ };
164
+ }
165
+
166
+ export function createPatchCallComponent(): PatchCallComponent {
167
+ return Object.assign(new Box(1, 1, (text: string) => text), {
168
+ preview: undefined,
169
+ previewArgsKey: undefined,
170
+ previewPending: false,
171
+ settledError: false,
172
+ });
173
+ }
174
+
175
+ function getPatchCallComponent(state: any, lastComponent: any): PatchCallComponent {
176
+ if (lastComponent instanceof Box) {
177
+ const comp = lastComponent as PatchCallComponent;
178
+ state.callComponent = comp;
179
+ return comp;
180
+ }
181
+ if (state.callComponent) return state.callComponent;
182
+ const comp = createPatchCallComponent();
183
+ state.callComponent = comp;
184
+ return comp;
185
+ }
186
+
187
+ // ─── Syntax highlighting for overwrite mode (mirrors write tool's incremental design) ──
188
+
189
+ const OVERWRITE_PARTIAL_HIGHLIGHT_LINES = 50;
190
+
191
+ function normalizeDisplayText(text: string): string {
192
+ return text.replace(/\r/g, "");
193
+ }
194
+
195
+ function replaceTabs(text: string): string {
196
+ return text.replace(/\t/g, " ");
197
+ }
198
+
199
+ function highlightSingleLine(line: string, lang: string | undefined): string {
200
+ const highlighted = highlightCode(line, lang);
201
+ return highlighted[0] ?? "";
202
+ }
203
+
204
+ function refreshOverwriteHighlightPrefix(cache: NonNullable<PatchCallComponent["overwriteHighlightCache"]>): void {
205
+ const prefixCount = Math.min(OVERWRITE_PARTIAL_HIGHLIGHT_LINES, cache.normalizedLines.length);
206
+ if (prefixCount === 0) return;
207
+ const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
208
+ const prefixHighlighted = highlightCode(prefixSource, cache.lang);
209
+ for (let i = 0; i < prefixCount; i++) {
210
+ cache.highlightedLines[i] =
211
+ prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
212
+ }
213
+ }
214
+
215
+ function rebuildOverwriteHighlightCache(rawPath: string, fileContent: string): PatchCallComponent["overwriteHighlightCache"] | undefined {
216
+ const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
217
+ if (!lang) return undefined;
218
+ const normalized = replaceTabs(normalizeDisplayText(fileContent));
219
+ return {
220
+ rawPath,
221
+ lang,
222
+ rawContent: fileContent,
223
+ normalizedLines: normalized.split("\n"),
224
+ highlightedLines: highlightCode(normalized, lang),
225
+ };
226
+ }
227
+
228
+ function updateOverwriteHighlightCache(
229
+ cache: PatchCallComponent["overwriteHighlightCache"] | undefined,
230
+ rawPath: string,
231
+ fileContent: string,
232
+ ): PatchCallComponent["overwriteHighlightCache"] | undefined {
233
+ if (cache) {
234
+ const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
235
+ if (!lang || cache.lang !== lang || cache.rawPath !== rawPath) {
236
+ return rebuildOverwriteHighlightCache(rawPath, fileContent);
237
+ }
238
+ if (!fileContent.startsWith(cache.rawContent)) {
239
+ return rebuildOverwriteHighlightCache(rawPath, fileContent);
240
+ }
241
+ if (fileContent.length === cache.rawContent.length) return cache;
242
+ const deltaRaw = fileContent.slice(cache.rawContent.length);
243
+ const deltaNormalized = replaceTabs(normalizeDisplayText(deltaRaw));
244
+ cache.rawContent = fileContent;
245
+ if (cache.normalizedLines.length === 0) {
246
+ cache.normalizedLines.push("");
247
+ cache.highlightedLines.push("");
248
+ }
249
+ const segments = deltaNormalized.split("\n");
250
+ const lastIndex = cache.normalizedLines.length - 1;
251
+ cache.normalizedLines[lastIndex] += segments[0];
252
+ cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex]!, cache.lang);
253
+ for (let i = 1; i < segments.length; i++) {
254
+ cache.normalizedLines.push(segments[i]!);
255
+ cache.highlightedLines.push(highlightSingleLine(segments[i]!, cache.lang));
256
+ }
257
+ refreshOverwriteHighlightPrefix(cache);
258
+ return cache;
259
+ }
260
+ return rebuildOverwriteHighlightCache(rawPath, fileContent);
261
+ }
262
+
263
+ function addHighlightedContent(parent: Box, lines: string[]): void {
264
+ for (const line of lines) {
265
+ parent.addChild(new Text(line, 0, 0));
266
+ }
267
+ }
268
+
269
+ function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
270
+ if (component.settledError) {
271
+ return (text: string) => theme.bg("toolErrorBg", text);
272
+ }
273
+ if (component.preview) {
274
+ if ("error" in component.preview && component.preview.error) {
275
+ return (text: string) => theme.bg("toolErrorBg", text);
276
+ }
277
+ return (text: string) => theme.bg("toolSuccessBg", text);
278
+ }
279
+ return (text: string) => theme.bg("toolPendingBg", text);
280
+ }
281
+
282
+ function createSingleLineComponent(text: string) {
283
+ return {
284
+ render(width: number) {
285
+ return [truncateToWidth(text, width)];
286
+ },
287
+ invalidate() {},
288
+ };
289
+ }
290
+
291
+ function formatPatchMetaLine(line: string, theme: any): string {
292
+ const missingSuffix = " (missing)";
293
+ if (line.endsWith(missingSuffix)) {
294
+ return theme.fg("accent", line.slice(0, -missingSuffix.length)) + theme.fg("warning", missingSuffix);
295
+ }
296
+ return theme.fg("accent", line);
297
+ }
298
+
299
+ function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
300
+ const rawLines = body.split("\n");
301
+ let buffer: string[] = [];
302
+
303
+ const flush = () => {
304
+ if (buffer.length === 0) return;
305
+ parent.addChild(new Text(renderDiff(buffer.join("\n")), 0, 0));
306
+ buffer = [];
307
+ };
308
+
309
+ for (const line of rawLines) {
310
+ if (line.startsWith("@@ lines ")) {
311
+ flush();
312
+ parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
313
+ continue;
314
+ }
315
+ if (line === "anchors:") {
316
+ flush();
317
+ parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
318
+ continue;
319
+ }
320
+ if (line.startsWith(" - ")) {
321
+ flush();
322
+ parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
323
+ continue;
324
+ }
325
+ buffer.push(line);
326
+ }
327
+
328
+ flush();
329
+ }
330
+
331
+ export function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
332
+ component.setBgFn(getPatchHeaderBg(component, theme));
333
+ component.clear();
334
+
335
+ let label = "";
336
+ if (args?.path) {
337
+ label = theme.fg("accent", args.path);
338
+ if (args.overwrite) {
339
+ label += theme.fg("warning", " [overwrite]");
340
+ } else if (args.edits?.length > 0) {
341
+ label += theme.fg("dim", ` (${args.edits.length} edit${args.edits.length > 1 ? "s" : ""})`);
342
+ }
343
+ }
344
+ const headerText = theme.fg("toolTitle", theme.bold("patch")) + (label ? " " + label : "");
345
+ component.addChild(new Text(headerText, 0, 0));
346
+
347
+ if (component.settledError || !component.preview) return component;
348
+
349
+ const preview = component.preview;
350
+ let body = "";
351
+ if ("isOverwrite" in preview && preview.isOverwrite && preview.preview) {
352
+ body = preview.preview;
353
+ } else if ("diff" in preview && preview.diff) {
354
+ body = preview.diff;
355
+ } else if ("error" in preview && preview.error) {
356
+ component.addChild(new Spacer(1));
357
+ component.addChild(new Text(theme.fg("error", ` Error: ${preview.error}`), 0, 0));
358
+ return component;
359
+ }
360
+
361
+ if (!body) return component;
362
+
363
+ const lines = body.split("\n");
364
+ const FOLD_THRESHOLD = 45;
365
+
366
+ component.addChild(new Spacer(1));
367
+ const isOverwrite = "isOverwrite" in preview && preview.isOverwrite;
368
+
369
+ if (lines.length > FOLD_THRESHOLD && !expanded) {
370
+ // 折叠态: 显示前几行 + 摘要
371
+ if (isOverwrite) {
372
+ if (component.overwriteHighlightCache) {
373
+ const n = Math.min(10, component.overwriteHighlightCache.highlightedLines.length);
374
+ addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines.slice(0, n));
375
+ } else {
376
+ component.addChild(new Text(theme.fg("toolDiffAdded", lines.slice(0, 10).join("\n")), 0, 0));
377
+ }
378
+ } else {
379
+ const shown = lines.slice(0, 10).join("\n");
380
+ appendPatchDiffChildren(component, shown, theme);
381
+ }
382
+ component.addChild(new Text(
383
+ theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
384
+ 0, 0,
385
+ ));
386
+ } else {
387
+ // 展开态: 完整显示
388
+ if (isOverwrite) {
389
+ if (component.overwriteHighlightCache) {
390
+ addHighlightedContent(component, component.overwriteHighlightCache.highlightedLines);
391
+ } else {
392
+ component.addChild(new Text(theme.fg("toolDiffAdded", body), 0, 0));
393
+ }
394
+ } else {
395
+ appendPatchDiffChildren(component, body, theme);
396
+ }
397
+ }
398
+
399
+ return component;
400
+ }
401
+
402
+ // ─── Setup ──────────────────────────────────────────────────────────────────────────
403
+
404
+ export function setupIO(pi: ExtensionAPI) {
405
+ pi.on("session_start", (_event, ctx) => {
406
+ restoreReadMarkersFromBranch(ctx.sessionManager.getBranch() as any[], ctx.cwd);
407
+ const active = pi.getActiveTools();
408
+ pi.setActiveTools(active.filter(t => !["edit", "write", "grep", "find", "ls"].includes(t)));
409
+ });
410
+
411
+ pi.on("session_compact", () => {
412
+ clearReadMarkers();
413
+ });
414
+
415
+ // Track file read times
416
+ pi.on("tool_result", (event, ctx) => {
417
+ if (!isReadToolResult(event)) return;
418
+ const filePath = event.input?.path;
419
+ if (typeof filePath !== "string" || !filePath.trim()) return;
420
+ const cwd: string = ctx.cwd ?? process.cwd();
421
+ const absPath = resolveAbsolutePath(cwd, filePath);
422
+ recordReadTime(absPath);
423
+ const marker = createFileTimeMarkerData(cwd, absPath);
424
+ if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
425
+ });
426
+
427
+ pi.registerTool(defineTool({
428
+ name: "patch",
429
+ label: "Patch",
430
+ description: [
431
+ "Edits a file using exact string replacement, with anchor support and overwrite mode.",
432
+ "When old_str is not unique, add more surrounding context or use anchor to narrow search.",
433
+ "",
434
+ "Examples:",
435
+ ' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
436
+ ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return x + 1" }] }',
437
+ ' { path: "src/foo.ts", edits: [{ anchor: "function init() {", old_str: "const DEBUG = true;", new_str: "const DEBUG = false;" }, { old_str: "log(\"debug\");", new_str: "// debug disabled" }] }',
438
+ ' { path: "src/bar.ts", overwrite: true, new_str: "entire file content" }',
439
+ "",
440
+ "Anchor (optional): narrows old_str search to lines after a unique marker.",
441
+ " Code: use the enclosing definition — function/class/struct/method signature.",
442
+ ' e.g. "function handleClick() {" or "class UserService {" or "struct Config {".',
443
+ " Non-code (markdown, config, etc.): use section headings, key names, or distinctive lines.",
444
+ ' e.g. "## API Reference" in .md or "[dependencies]" in .toml files.',
445
+ ].join("\n"),
446
+ promptSnippet: "Edits a file using exact string replacement, with anchor support and overwrite mode.",
447
+ promptGuidelines: [
448
+ "Always prefer modifying files with PATCH tool over bash commands or python scripts.",
449
+ "For full-file replacement, always use patch tool to prevent unintended edits or data loss.",
450
+ ],
451
+ parameters: PatchSchema,
452
+ renderShell: "self",
453
+ prepareArguments: preparePatchArguments,
454
+ execute: async (_toolCallId: string, input: { path: string; edits?: any[]; overwrite?: boolean; new_str?: string }, _signal: any, _onUpdate: any, ctx: any) => {
455
+ const cwd: string = ctx.cwd ?? process.cwd();
456
+
457
+ // Stale-read protection (only for edits, not overwrite)
458
+ if (!input.overwrite && input.path?.trim()) {
459
+ const absPath = resolveAbsolutePath(cwd, input.path);
460
+ const staleError = checkStaleFile(absPath, input.path);
461
+ if (staleError) throw new Error(staleError);
462
+ }
463
+
464
+ const result = await applyPatch(input as any, cwd);
465
+
466
+ // Update read markers after successful write
467
+ for (const filePath of [...result.modified, ...result.created]) {
468
+ const absPath = resolveAbsolutePath(cwd, filePath);
469
+ recordReadTime(absPath);
470
+ const marker = createFileTimeMarkerData(cwd, absPath);
471
+ if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
472
+ }
473
+
474
+ const summary = formatPatchResult(result);
475
+ const diff = generatePatchDiff(result);
476
+ return {
477
+ content: [{ type: "text", text: summary }],
478
+ details: { diff },
479
+ };
480
+ },
481
+
482
+ renderCall(args: any, theme: any, context: any) {
483
+ const state = context.state;
484
+ const component = getPatchCallComponent(state, context.lastComponent);
485
+
486
+ const argsKey = args ? JSON.stringify(args) : undefined;
487
+ if (component.previewArgsKey !== argsKey) {
488
+ component.preview = undefined;
489
+ component.previewArgsKey = argsKey;
490
+ component.previewPending = false;
491
+ component.settledError = false;
492
+ component.overwriteHighlightCache = undefined;
493
+ }
494
+
495
+ // Overwrite streaming: incrementally highlight as new_str streams in
496
+ if (args?.overwrite && typeof args?.path === "string" && typeof args?.new_str === "string") {
497
+ if (context.argsComplete) {
498
+ // Final: full rebuild for best quality
499
+ component.overwriteHighlightCache = rebuildOverwriteHighlightCache(args.path, args.new_str);
500
+ } else {
501
+ // Streaming: incremental update
502
+ component.overwriteHighlightCache = updateOverwriteHighlightCache(
503
+ component.overwriteHighlightCache, args.path, args.new_str,
504
+ );
505
+ }
506
+ // Inject highlighted content as synthetic preview for buildPatchCallComponent
507
+ const cache = component.overwriteHighlightCache;
508
+ if (cache) {
509
+ component.preview = { preview: cache.rawContent, isOverwrite: true };
510
+ } else {
511
+ // No language detected: fall back to plain text
512
+ component.preview = { preview: args.new_str, isOverwrite: true };
513
+ }
514
+ }
515
+
516
+ // Preview diff is computed during execute and delivered via result.details.diff.
517
+ // Skipping async preview here avoids a redundant file read — same design as
518
+ // Pi's native edit tool where renderResult overwrites the preview with execute's diff.
519
+
520
+ return buildPatchCallComponent(component, args, theme, context.expanded);
521
+ },
522
+
523
+ renderResult(result: any, options: any, theme: any, context: any) {
524
+ const callComponent: PatchCallComponent | undefined = context.state.callComponent;
525
+ let changed = false;
526
+
527
+ if (callComponent) {
528
+ // overwrite: 保留完整文件预览,不要被空 diff 覆盖成只剩 header
529
+ const overwriteContent = !context.isError && context.args?.overwrite && typeof context.args?.new_str === "string"
530
+ ? context.args.new_str
531
+ : undefined;
532
+
533
+ if (typeof overwriteContent === "string") {
534
+ const nextCache = rebuildOverwriteHighlightCache(context.args?.path, overwriteContent);
535
+ const prevContent = (callComponent.overwriteHighlightCache?.rawContent ?? undefined);
536
+ const prevPath = callComponent.overwriteHighlightCache?.rawPath;
537
+ callComponent.overwriteHighlightCache = nextCache;
538
+ if (
539
+ !(callComponent.preview && "isOverwrite" in callComponent.preview && callComponent.preview.isOverwrite && callComponent.preview.preview === overwriteContent) ||
540
+ prevContent !== overwriteContent ||
541
+ prevPath !== context.args?.path
542
+ ) {
543
+ callComponent.preview = { preview: overwriteContent, isOverwrite: true };
544
+ changed = true;
545
+ }
546
+ } else {
547
+ // 非 overwrite:优先使用 execute 返回的 diff(与 Pi 原生 edit 工具一致)
548
+ const resultDiff = !context.isError && result.details?.diff;
549
+ if (typeof resultDiff === "string") {
550
+ const newPreview = { diff: resultDiff };
551
+ if (callComponent.preview?.diff !== resultDiff) {
552
+ callComponent.preview = newPreview;
553
+ changed = true;
554
+ }
555
+ }
556
+ }
557
+
558
+ // 更新错误状态
559
+ if (callComponent.settledError !== context.isError) {
560
+ callComponent.settledError = context.isError;
561
+ changed = true;
562
+ }
563
+
564
+ if (changed) {
565
+ buildPatchCallComponent(callComponent, context.args, theme, options.expanded);
566
+ if (context.isError) {
567
+ const errorText = result.content
568
+ .filter((c: any) => c.type === "text")
569
+ .map((c: any) => c.text || "")
570
+ .join("\n");
571
+ if (errorText) {
572
+ callComponent.addChild(new Spacer(1));
573
+ callComponent.addChild(new Text(theme.fg("error", errorText), 0, 0));
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ // Return empty Container — the Box (callComponent) already holds all content.
580
+ // Per pitfall #4: returning the Box would cause ToolExecutionComponent to add
581
+ // it twice to the container, producing duplicate rendering.
582
+ const component = context.lastComponent ?? new Container();
583
+ component.clear();
584
+ return component;
585
+ },
586
+ }));
587
+ }