@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. package/src/vim/types.ts +197 -0
@@ -1576,7 +1576,7 @@ export async function computePatchDiff(
1576
1576
  }
1577
1577
  }
1578
1578
 
1579
- export const patchEditSchema = Type.Object({
1579
+ export const patchEditEntrySchema = Type.Object({
1580
1580
  path: Type.String({ description: "File path" }),
1581
1581
  op: Type.Optional(
1582
1582
  StringEnum(["create", "delete", "update"], {
@@ -1587,11 +1587,16 @@ export const patchEditSchema = Type.Object({
1587
1587
  diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })),
1588
1588
  });
1589
1589
 
1590
+ export const patchEditSchema = Type.Object({
1591
+ edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
1592
+ });
1593
+
1594
+ export type PatchEditEntry = Static<typeof patchEditEntrySchema>;
1590
1595
  export type PatchParams = Static<typeof patchEditSchema>;
1591
1596
 
1592
- interface ExecutePatchModeOptions {
1597
+ export interface ExecutePatchSingleOptions {
1593
1598
  session: ToolSession;
1594
- params: PatchParams;
1599
+ params: PatchEditEntry;
1595
1600
  signal?: AbortSignal;
1596
1601
  batchRequest?: LspBatchRequest;
1597
1602
  allowFuzzy: boolean;
@@ -1601,10 +1606,11 @@ interface ExecutePatchModeOptions {
1601
1606
  }
1602
1607
 
1603
1608
  export function isPatchParams(params: unknown): params is PatchParams {
1604
- if (typeof params !== "object" || params === null || !("path" in params)) {
1605
- return false;
1606
- }
1607
- return !("old_text" in params) && !("new_text" in params) && !("edits" in params);
1609
+ if (typeof params !== "object" || params === null) return false;
1610
+ if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
1611
+ const first = (params as any).edits[0];
1612
+ if (!first || typeof first !== "object") return false;
1613
+ return "path" in first && !("old_text" in first) && !("new_text" in first);
1608
1614
  }
1609
1615
 
1610
1616
  class LspFileSystem implements FileSystem {
@@ -1690,9 +1696,9 @@ function mergeDiagnosticsWithWarnings(
1690
1696
  };
1691
1697
  }
1692
1698
 
1693
- export async function executePatchMode(
1694
- options: ExecutePatchModeOptions,
1695
- ): Promise<AgentToolResult<EditToolDetails, typeof patchEditSchema>> {
1699
+ export async function executePatchSingle(
1700
+ options: ExecutePatchSingleOptions,
1701
+ ): Promise<AgentToolResult<EditToolDetails, typeof patchEditEntrySchema>> {
1696
1702
  const {
1697
1703
  session,
1698
1704
  params,
@@ -987,18 +987,23 @@ export function findContextLine(
987
987
  return { index: undefined, confidence: bestScore };
988
988
  }
989
989
 
990
- export const replaceEditSchema = Type.Object({
990
+ export const replaceEditEntrySchema = Type.Object({
991
991
  path: Type.String({ description: "File path (relative or absolute)" }),
992
992
  old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
993
993
  new_text: Type.String({ description: "Replacement text" }),
994
994
  all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
995
995
  });
996
996
 
997
+ export const replaceEditSchema = Type.Object({
998
+ edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
999
+ });
1000
+
1001
+ export type ReplaceEditEntry = Static<typeof replaceEditEntrySchema>;
997
1002
  export type ReplaceParams = Static<typeof replaceEditSchema>;
998
1003
 
999
- interface ExecuteReplaceModeOptions {
1004
+ export interface ExecuteReplaceSingleOptions {
1000
1005
  session: ToolSession;
1001
- params: ReplaceParams;
1006
+ params: ReplaceEditEntry;
1002
1007
  signal?: AbortSignal;
1003
1008
  batchRequest?: LspBatchRequest;
1004
1009
  allowFuzzy: boolean;
@@ -1008,12 +1013,15 @@ interface ExecuteReplaceModeOptions {
1008
1013
  }
1009
1014
 
1010
1015
  export function isReplaceParams(params: unknown): params is ReplaceParams {
1011
- return typeof params === "object" && params !== null && "old_text" in params && "new_text" in params;
1016
+ if (typeof params !== "object" || params === null) return false;
1017
+ if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
1018
+ const first = (params as any).edits[0];
1019
+ return first && typeof first === "object" && "old_text" in first && "new_text" in first;
1012
1020
  }
1013
1021
 
1014
- export async function executeReplaceMode(
1015
- options: ExecuteReplaceModeOptions,
1016
- ): Promise<AgentToolResult<EditToolDetails, typeof replaceEditSchema>> {
1022
+ export async function executeReplaceSingle(
1023
+ options: ExecuteReplaceSingleOptions,
1024
+ ): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
1017
1025
  const {
1018
1026
  session,
1019
1027
  params,
@@ -21,9 +21,11 @@ import {
21
21
  shortenPath,
22
22
  truncateDiffByHunk,
23
23
  } from "../tools/render-utils";
24
+ import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
24
25
  import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
26
+ import type { VimToolDetails } from "../vim/types";
25
27
  import type { DiffError, DiffResult } from "./diff";
26
- import type { ChunkToolEdit } from "./modes/chunk";
28
+ import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
27
29
  import type { HashlineToolEdit } from "./modes/hashline";
28
30
  import type { Operation } from "./modes/patch";
29
31
 
@@ -56,6 +58,18 @@ export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBa
56
58
  // Tool Details Types
57
59
  // ═══════════════════════════════════════════════════════════════════════════
58
60
 
61
+ export interface EditToolPerFileResult {
62
+ path: string;
63
+ diff: string;
64
+ firstChangedLine?: number;
65
+ diagnostics?: FileDiagnosticsResult;
66
+ op?: Operation;
67
+ move?: string;
68
+ isError?: boolean;
69
+ errorText?: string;
70
+ meta?: OutputMeta;
71
+ }
72
+
59
73
  export interface EditToolDetails {
60
74
  /** Unified diff of the changes made */
61
75
  diff: string;
@@ -69,6 +83,8 @@ export interface EditToolDetails {
69
83
  move?: string;
70
84
  /** Structured output metadata */
71
85
  meta?: OutputMeta;
86
+ /** Per-file results (multi-file edits) */
87
+ perFileResults?: EditToolPerFileResult[];
72
88
  }
73
89
 
74
90
  // ═══════════════════════════════════════════════════════════════════════════
@@ -94,6 +110,31 @@ interface EditRenderArgs {
94
110
  edits?: Partial<HashlineToolEdit | ChunkToolEdit>[];
95
111
  }
96
112
 
113
+ function isVimRenderArgs(args: EditRenderArgs | VimRenderArgs): args is VimRenderArgs {
114
+ return (
115
+ typeof args === "object" &&
116
+ args !== null &&
117
+ typeof (args as { file?: unknown }).file === "string" &&
118
+ !("path" in args) &&
119
+ !("file_path" in args) &&
120
+ !("edits" in args)
121
+ );
122
+ }
123
+
124
+ function isVimToolDetails(details: unknown): details is VimToolDetails {
125
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
126
+ return false;
127
+ }
128
+ const cursor = (details as { cursor?: unknown }).cursor;
129
+ const viewportLines = (details as { viewportLines?: unknown }).viewportLines;
130
+ return (
131
+ typeof (details as { file?: unknown }).file === "string" &&
132
+ typeof cursor === "object" &&
133
+ cursor !== null &&
134
+ Array.isArray(viewportLines)
135
+ );
136
+ }
137
+
97
138
  /** Extended context for edit tool rendering */
98
139
  export interface EditRenderContext {
99
140
  /** Pre-computed diff preview (computed before tool executes) */
@@ -114,6 +155,18 @@ interface FormattedStreamingEdit {
114
155
  dst: string;
115
156
  }
116
157
 
158
+ /** Extract file path from an edit entry's path (handles chunk's file:selector format). */
159
+ function filePathFromEditEntry(p: string | undefined): string | undefined {
160
+ if (!p) return undefined;
161
+ const ci = /^[a-zA-Z]:[/\\]/.test(p) ? p.indexOf(":", 2) : p.indexOf(":");
162
+ return ci === -1 ? p : p.slice(0, ci);
163
+ }
164
+
165
+ /** Count distinct file paths in an edits array. */
166
+ function countEditFiles(edits: any[]): number {
167
+ return new Set(edits.map((e: any) => filePathFromEditEntry(e?.path)).filter(Boolean)).size;
168
+ }
169
+
117
170
  function countLines(text: string): number {
118
171
  if (!text) return 0;
119
172
  return text.split("\n").length;
@@ -182,7 +235,12 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
182
235
  }
183
236
 
184
237
  function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
185
- return "sel" in edit;
238
+ return (
239
+ typeof edit === "object" &&
240
+ edit !== null &&
241
+ "path" in edit &&
242
+ ("write" in edit || "replace" in edit || "insert" in edit)
243
+ );
186
244
  }
187
245
 
188
246
  function getStreamingEditContent(content: unknown): string {
@@ -225,25 +283,20 @@ function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStream
225
283
  return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
226
284
  }
227
285
 
228
- const contentLines = getStreamingEditContent(edit.content);
229
- const target = edit.sel ?? "?";
230
- const op = edit.op ?? "replace";
231
-
232
- switch (op) {
233
- case "append":
234
- return { srcLabel: `\u2022 append ${target}`, dst: contentLines };
235
- case "prepend":
236
- return { srcLabel: `\u2022 prepend ${target}`, dst: contentLines };
237
- case "after":
238
- return { srcLabel: `\u2022 insert after ${target}`, dst: contentLines };
239
- case "before":
240
- return { srcLabel: `\u2022 insert before ${target}`, dst: contentLines };
241
- default:
242
- return {
243
- srcLabel: contentLines.length === 0 ? `\u2022 remove ${target}` : `\u2022 replace ${target}`,
244
- dst: contentLines,
245
- };
286
+ const target = edit.path ? (parseChunkEditPath(edit.path).selector ?? edit.path) : "?";
287
+ if (edit.write === null) {
288
+ return { srcLabel: `\u2022 remove ${target}`, dst: "" };
246
289
  }
290
+ if (typeof edit.write === "string") {
291
+ return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.write) };
292
+ }
293
+ if (typeof edit.replace === "object" && edit.replace) {
294
+ return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.replace.new) };
295
+ }
296
+ if (typeof edit.insert === "object" && edit.insert) {
297
+ return { srcLabel: `\u2022 ${edit.insert.loc} ${target}`, dst: getStreamingEditContent(edit.insert.body) };
298
+ }
299
+ return { srcLabel: `\u2022 edit ${target}`, dst: "" };
247
300
  }
248
301
 
249
302
  function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit | ChunkToolEdit>[], uiTheme: Theme): string {
@@ -302,7 +355,11 @@ function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme):
302
355
  return formatStreamingDiff(args.diff, rawPath, uiTheme);
303
356
  }
304
357
  if (args.edits && args.edits.length > 0) {
305
- return formatStreamingHashlineEdits(args.edits, uiTheme);
358
+ // Only show hashline/chunk streaming edits — replace/patch use previewDiff above
359
+ const first = args.edits[0];
360
+ if (first && typeof first === "object" && ("loc" in first || isChunkStreamingEdit(first))) {
361
+ return formatStreamingHashlineEdits(args.edits, uiTheme);
362
+ }
306
363
  }
307
364
  if (args.diff) {
308
365
  return renderPlainTextPreview(args.diff, uiTheme);
@@ -376,11 +433,24 @@ export const editToolRenderer = {
376
433
  mergeCallAndResult: true,
377
434
 
378
435
  renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
379
- const rawPath = args.file_path || args.path || "";
380
- const { description } = formatEditDescription(rawPath, uiTheme, { rename: args.rename });
436
+ if (isVimRenderArgs(args)) {
437
+ return vimToolRenderer.renderCall(args, options, uiTheme);
438
+ }
439
+
440
+ // Extract path from first edit entry when top-level path is absent (new schema)
441
+ const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
442
+ const rawPath = args.file_path || args.path || (firstEdit as any)?.path || "";
443
+ const rename = args.rename || (firstEdit as any)?.rename;
444
+ const op = args.op || (firstEdit as any)?.op;
445
+ const { description } = formatEditDescription(rawPath, uiTheme, { rename });
381
446
  const spinner =
382
447
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
383
- let text = `${formatTitle(getOperationTitle(args.op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
448
+ let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
449
+ // Show file count hint for multi-file edits
450
+ const fileCount = Array.isArray(args.edits) ? countEditFiles(args.edits as any[]) : 0;
451
+ if (fileCount > 1) {
452
+ text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
453
+ }
384
454
  text += getCallPreview(args, rawPath, uiTheme);
385
455
 
386
456
  return new Text(text, 0, 0);
@@ -392,76 +462,160 @@ export const editToolRenderer = {
392
462
  uiTheme: Theme,
393
463
  args?: EditRenderArgs,
394
464
  ): Component {
395
- const rawPath = args?.file_path || args?.path || "";
396
- const op = args?.op || result.details?.op;
397
- const rename = args?.rename || result.details?.move;
398
- const { language } = formatEditDescription(rawPath, uiTheme, { rename });
399
-
400
- // Pre-compute metadata line (static across renders)
401
- const metadataLine =
402
- op !== "delete"
403
- ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
404
- : "";
405
-
406
- // Pre-compute error text (static)
407
- const errorText = result.isError ? (result.content?.find(c => c.type === "text")?.text ?? "") : "";
408
-
409
- let cached: RenderCache | undefined;
410
-
411
- return {
412
- render(width) {
413
- const { expanded, renderContext } = options;
414
- const editDiffPreview = renderContext?.editDiffPreview;
415
- const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
416
- const key = new Hasher().bool(expanded).u32(width).digest();
417
- if (cached?.key === key) return cached.lines;
418
-
419
- const firstChangedLine =
420
- (editDiffPreview && "firstChangedLine" in editDiffPreview
421
- ? editDiffPreview.firstChangedLine
422
- : undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined);
423
- const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
424
-
425
- const header = renderStatusLine(
426
- {
427
- icon: result.isError ? "error" : "success",
428
- title: getOperationTitle(op),
429
- description,
430
- },
431
- uiTheme,
432
- );
433
- let text = header;
434
- text += metadataLine;
435
-
436
- if (result.isError) {
437
- if (errorText) {
438
- text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
439
- }
440
- } else if (result.details?.diff) {
441
- text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, renderDiffFn);
442
- } else if (editDiffPreview) {
443
- if ("error" in editDiffPreview) {
444
- text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
445
- } else if (editDiffPreview.diff) {
446
- text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
447
- }
448
- }
449
-
450
- // Show LSP diagnostics if available
451
- if (result.details?.diagnostics) {
452
- text += formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp: string) =>
453
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
454
- );
455
- }
465
+ if (isVimToolDetails(result.details)) {
466
+ return vimToolRenderer.renderResult(
467
+ result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
468
+ options,
469
+ uiTheme,
470
+ );
471
+ }
456
472
 
457
- const lines =
458
- width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
459
- cached = { key, lines };
460
- return lines;
461
- },
462
- invalidate() {
463
- cached = undefined;
464
- },
465
- };
473
+ const perFileResults = result.details?.perFileResults;
474
+ const totalFiles = Array.isArray(args?.edits) ? countEditFiles(args!.edits as any[]) : 0;
475
+ if (perFileResults && (perFileResults.length > 1 || totalFiles > 1)) {
476
+ return renderMultiFileResult(perFileResults, totalFiles, options, uiTheme);
477
+ }
478
+ return renderSingleFileResult(result, options, uiTheme, args);
466
479
  },
467
480
  };
481
+
482
+ function renderSingleFileResult(
483
+ result: {
484
+ content: Array<{ type: string; text?: string }>;
485
+ details?: EditToolDetails | EditToolPerFileResult;
486
+ isError?: boolean;
487
+ },
488
+ options: RenderResultOptions & { renderContext?: EditRenderContext },
489
+ uiTheme: Theme,
490
+ args?: EditRenderArgs,
491
+ ): Component {
492
+ const details = result.details;
493
+ const isError = result.isError ?? (details && "isError" in details ? details.isError : false);
494
+ const rawPath = args?.file_path || args?.path || (details && "path" in details ? details.path : "") || "";
495
+ const op = args?.op || details?.op;
496
+ const rename = args?.rename || details?.move;
497
+ const { language } = formatEditDescription(rawPath, uiTheme, { rename });
498
+
499
+ const metadataLine =
500
+ op !== "delete"
501
+ ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
502
+ : "";
503
+
504
+ const errorText = isError
505
+ ? (details && "errorText" in details && details.errorText) ||
506
+ (result.content?.find(c => c.type === "text")?.text ?? "")
507
+ : "";
508
+
509
+ let cached: RenderCache | undefined;
510
+
511
+ return {
512
+ render(width) {
513
+ const { expanded, renderContext } = options;
514
+ const editDiffPreview = renderContext?.editDiffPreview;
515
+ const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
516
+ const key = new Hasher().bool(expanded).u32(width).digest();
517
+ if (cached?.key === key) return cached.lines;
518
+
519
+ const firstChangedLine =
520
+ (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
521
+ (details && !isError ? details.firstChangedLine : undefined);
522
+ const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
523
+
524
+ const header = renderStatusLine(
525
+ {
526
+ icon: isError ? "error" : "success",
527
+ title: getOperationTitle(op),
528
+ description,
529
+ },
530
+ uiTheme,
531
+ );
532
+ let text = header;
533
+ text += metadataLine;
534
+
535
+ if (isError) {
536
+ if (errorText) {
537
+ text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
538
+ }
539
+ } else if (details?.diff) {
540
+ text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
541
+ } else if (editDiffPreview) {
542
+ if ("error" in editDiffPreview) {
543
+ text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
544
+ } else if (editDiffPreview.diff) {
545
+ text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
546
+ }
547
+ }
548
+
549
+ if (details?.diagnostics) {
550
+ text += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
551
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
552
+ );
553
+ }
554
+
555
+ const lines =
556
+ width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
557
+ cached = { key, lines };
558
+ return lines;
559
+ },
560
+ invalidate() {
561
+ cached = undefined;
562
+ },
563
+ };
564
+ }
565
+
566
+ function renderMultiFileResult(
567
+ perFileResults: EditToolPerFileResult[],
568
+ totalFiles: number,
569
+ options: RenderResultOptions & { renderContext?: EditRenderContext },
570
+ uiTheme: Theme,
571
+ ): Component {
572
+ const fileComponents = perFileResults.map(fileResult =>
573
+ renderSingleFileResult({ content: [], details: fileResult, isError: fileResult.isError }, options, uiTheme),
574
+ );
575
+ const remaining = Math.max(0, totalFiles - perFileResults.length);
576
+
577
+ let cached: RenderCache | undefined;
578
+
579
+ return {
580
+ render(width) {
581
+ const key = new Hasher().bool(options.expanded).u32(width).u32(perFileResults.length).u32(remaining).digest();
582
+ if (cached?.key === key) return cached.lines;
583
+
584
+ const allLines: string[] = [];
585
+ for (let i = 0; i < fileComponents.length; i++) {
586
+ if (i > 0) {
587
+ allLines.push("");
588
+ }
589
+ allLines.push(...fileComponents[i].render(width));
590
+ }
591
+
592
+ // Show pending indicator for files still being processed
593
+ if (remaining > 0) {
594
+ if (allLines.length > 0) allLines.push("");
595
+ const spinnerFrame = options.spinnerFrame;
596
+ const spinner = spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, spinnerFrame) : "";
597
+ allLines.push(
598
+ renderStatusLine(
599
+ {
600
+ icon: "pending",
601
+ title: "Edit",
602
+ description: uiTheme.fg("dim", `${remaining} more file${remaining > 1 ? "s" : ""} pending…`),
603
+ },
604
+ uiTheme,
605
+ ),
606
+ );
607
+ if (spinner) {
608
+ // Replace the pending icon with spinner on the last line
609
+ allLines[allLines.length - 1] = allLines[allLines.length - 1].replace(/^(?:\x1b\[[^m]*m)*./u, spinner);
610
+ }
611
+ }
612
+
613
+ cached = { key, lines: allLines };
614
+ return allLines;
615
+ },
616
+ invalidate() {
617
+ cached = undefined;
618
+ for (const c of fileComponents) c.invalidate();
619
+ },
620
+ };
621
+ }