@oh-my-pi/pi-coding-agent 14.2.1 → 14.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/package.json +19 -19
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/cli/args.ts +10 -1
  5. package/src/cli/shell-cli.ts +15 -3
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/config/model-registry.ts +67 -15
  8. package/src/config/prompt-templates.ts +5 -5
  9. package/src/config/settings-schema.ts +63 -4
  10. package/src/cursor.ts +3 -8
  11. package/src/debug/system-info.ts +6 -2
  12. package/src/discovery/claude.ts +58 -36
  13. package/src/discovery/helpers.ts +3 -3
  14. package/src/discovery/opencode.ts +20 -2
  15. package/src/edit/diff.ts +50 -47
  16. package/src/edit/index.ts +87 -57
  17. package/src/edit/line-hash.ts +735 -19
  18. package/src/edit/modes/apply-patch.ts +0 -9
  19. package/src/edit/modes/atom.ts +658 -0
  20. package/src/edit/modes/chunk.ts +144 -78
  21. package/src/edit/modes/hashline.ts +223 -146
  22. package/src/edit/modes/patch.ts +5 -9
  23. package/src/edit/modes/replace.ts +6 -11
  24. package/src/edit/renderer.ts +112 -143
  25. package/src/edit/streaming.ts +385 -0
  26. package/src/exec/bash-executor.ts +58 -5
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +4 -12
  29. package/src/extensibility/custom-tools/types.ts +2 -0
  30. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  31. package/src/internal-urls/docs-index.generated.ts +7 -7
  32. package/src/internal-urls/pi-protocol.ts +0 -2
  33. package/src/lsp/client.ts +8 -1
  34. package/src/lsp/defaults.json +2 -1
  35. package/src/lsp/index.ts +1 -1
  36. package/src/mcp/render.ts +1 -8
  37. package/src/modes/acp/acp-agent.ts +76 -2
  38. package/src/modes/components/assistant-message.ts +5 -34
  39. package/src/modes/components/diff.ts +23 -14
  40. package/src/modes/components/footer.ts +21 -16
  41. package/src/modes/components/hook-editor.ts +1 -1
  42. package/src/modes/components/settings-defs.ts +6 -1
  43. package/src/modes/components/todo-reminder.ts +1 -8
  44. package/src/modes/components/tool-execution.ts +112 -105
  45. package/src/modes/controllers/input-controller.ts +1 -1
  46. package/src/modes/controllers/selector-controller.ts +1 -1
  47. package/src/modes/interactive-mode.ts +0 -2
  48. package/src/modes/print-mode.ts +8 -0
  49. package/src/modes/theme/mermaid-cache.ts +13 -52
  50. package/src/modes/theme/theme.ts +2 -2
  51. package/src/prompts/agents/librarian.md +1 -1
  52. package/src/prompts/agents/reviewer.md +4 -4
  53. package/src/prompts/ci-green-request.md +1 -1
  54. package/src/prompts/review-request.md +1 -1
  55. package/src/prompts/system/subagent-system-prompt.md +3 -3
  56. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  57. package/src/prompts/system/system-prompt.md +4 -1
  58. package/src/prompts/tools/ask.md +3 -2
  59. package/src/prompts/tools/ast-edit.md +15 -19
  60. package/src/prompts/tools/ast-grep.md +18 -24
  61. package/src/prompts/tools/atom.md +96 -0
  62. package/src/prompts/tools/browser.md +1 -0
  63. package/src/prompts/tools/chunk-edit.md +58 -179
  64. package/src/prompts/tools/debug.md +4 -5
  65. package/src/prompts/tools/exit-plan-mode.md +4 -5
  66. package/src/prompts/tools/find.md +4 -8
  67. package/src/prompts/tools/github.md +18 -0
  68. package/src/prompts/tools/grep.md +8 -8
  69. package/src/prompts/tools/hashline.md +22 -89
  70. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  71. package/src/prompts/tools/inspect-image.md +6 -6
  72. package/src/prompts/tools/lsp.md +6 -0
  73. package/src/prompts/tools/patch.md +12 -19
  74. package/src/prompts/tools/python.md +3 -2
  75. package/src/prompts/tools/read-chunk.md +46 -8
  76. package/src/prompts/tools/read.md +9 -6
  77. package/src/prompts/tools/ssh.md +8 -17
  78. package/src/prompts/tools/todo-write.md +54 -41
  79. package/src/sdk.ts +22 -14
  80. package/src/session/agent-session.ts +61 -22
  81. package/src/session/session-manager.ts +228 -57
  82. package/src/session/streaming-output.ts +11 -0
  83. package/src/system-prompt.ts +7 -2
  84. package/src/task/executor.ts +44 -48
  85. package/src/task/render.ts +11 -13
  86. package/src/tools/ask.ts +7 -7
  87. package/src/tools/ast-edit.ts +45 -41
  88. package/src/tools/ast-grep.ts +77 -85
  89. package/src/tools/bash.ts +21 -9
  90. package/src/tools/browser.ts +32 -30
  91. package/src/tools/calculator.ts +4 -4
  92. package/src/tools/cancel-job.ts +1 -1
  93. package/src/tools/checkpoint.ts +2 -2
  94. package/src/tools/debug.ts +41 -37
  95. package/src/tools/exit-plan-mode.ts +1 -1
  96. package/src/tools/find.ts +4 -4
  97. package/src/tools/gh-renderer.ts +12 -4
  98. package/src/tools/gh.ts +514 -712
  99. package/src/tools/grep.ts +115 -130
  100. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  101. package/src/tools/index.ts +14 -32
  102. package/src/tools/inspect-image.ts +3 -3
  103. package/src/tools/json-tree.ts +114 -114
  104. package/src/tools/match-line-format.ts +9 -8
  105. package/src/tools/notebook.ts +8 -7
  106. package/src/tools/poll-tool.ts +2 -1
  107. package/src/tools/python.ts +9 -23
  108. package/src/tools/read.ts +32 -21
  109. package/src/tools/render-mermaid.ts +1 -1
  110. package/src/tools/render-utils.ts +18 -0
  111. package/src/tools/renderers.ts +2 -2
  112. package/src/tools/report-tool-issue.ts +3 -2
  113. package/src/tools/resolve.ts +1 -1
  114. package/src/tools/review.ts +12 -10
  115. package/src/tools/search-tool-bm25.ts +2 -4
  116. package/src/tools/sqlite-reader.ts +116 -3
  117. package/src/tools/ssh.ts +4 -4
  118. package/src/tools/todo-write.ts +172 -147
  119. package/src/tools/vim.ts +14 -15
  120. package/src/tools/write.ts +4 -4
  121. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  122. package/src/utils/edit-mode.ts +2 -1
  123. package/src/utils/file-display-mode.ts +10 -5
  124. package/src/utils/git.ts +9 -5
  125. package/src/utils/shell-snapshot.ts +2 -3
  126. package/src/vim/render.ts +4 -4
  127. package/src/web/search/providers/codex.ts +129 -6
  128. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  129. package/src/prompts/tools/gh-issue-view.md +0 -11
  130. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  131. package/src/prompts/tools/gh-pr-diff.md +0 -12
  132. package/src/prompts/tools/gh-pr-push.md +0 -11
  133. package/src/prompts/tools/gh-pr-view.md +0 -11
  134. package/src/prompts/tools/gh-repo-view.md +0 -11
  135. package/src/prompts/tools/gh-run-watch.md +0 -12
  136. package/src/prompts/tools/gh-search-issues.md +0 -11
  137. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -1577,7 +1577,7 @@ export async function computePatchDiff(
1577
1577
  }
1578
1578
 
1579
1579
  export const patchEditEntrySchema = Type.Object({
1580
- path: Type.String({ description: "File path" }),
1580
+ path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
1581
1581
  op: Type.Optional(
1582
1582
  StringEnum(["create", "delete", "update"], {
1583
1583
  description: "Operation (default: update)",
@@ -1588,6 +1588,7 @@ export const patchEditEntrySchema = Type.Object({
1588
1588
  });
1589
1589
 
1590
1590
  export const patchEditSchema = Type.Object({
1591
+ path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
1591
1592
  edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
1592
1593
  });
1593
1594
 
@@ -1605,14 +1606,6 @@ export interface ExecutePatchSingleOptions {
1605
1606
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1606
1607
  }
1607
1608
 
1608
- export function isPatchParams(params: unknown): params is PatchParams {
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);
1614
- }
1615
-
1616
1609
  class LspFileSystem implements FileSystem {
1617
1610
  #lastDiagnostics: FileDiagnosticsResult | undefined;
1618
1611
  #fileCache: Record<string, Bun.BunFile> = {};
@@ -1710,6 +1703,9 @@ export async function executePatchSingle(
1710
1703
  beginDeferredDiagnosticsForPath,
1711
1704
  } = options;
1712
1705
  const { path, op: rawOp, rename, diff } = params;
1706
+ if (typeof path !== "string" || path.length === 0) {
1707
+ throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
1708
+ }
1713
1709
 
1714
1710
  const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
1715
1711
 
@@ -977,13 +977,14 @@ export function findContextLine(
977
977
  }
978
978
 
979
979
  export const replaceEditEntrySchema = Type.Object({
980
- path: Type.String({ description: "File path (relative or absolute)" }),
980
+ path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
981
981
  old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
982
982
  new_text: Type.String({ description: "Replacement text" }),
983
983
  all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
984
984
  });
985
985
 
986
986
  export const replaceEditSchema = Type.Object({
987
+ path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
987
988
  edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
988
989
  });
989
990
 
@@ -1001,13 +1002,6 @@ export interface ExecuteReplaceSingleOptions {
1001
1002
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1002
1003
  }
1003
1004
 
1004
- export function isReplaceParams(params: unknown): params is ReplaceParams {
1005
- if (typeof params !== "object" || params === null) return false;
1006
- if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
1007
- const first = (params as any).edits[0];
1008
- return first && typeof first === "object" && "old_text" in first && "new_text" in first;
1009
- }
1010
-
1011
1005
  export async function executeReplaceSingle(
1012
1006
  options: ExecuteReplaceSingleOptions,
1013
1007
  ): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
@@ -1022,6 +1016,9 @@ export async function executeReplaceSingle(
1022
1016
  beginDeferredDiagnosticsForPath,
1023
1017
  } = options;
1024
1018
  const { path, old_text, new_text, all } = params;
1019
+ if (typeof path !== "string" || path.length === 0) {
1020
+ throw new Error("replace edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
1021
+ }
1025
1022
 
1026
1023
  enforcePlanModeWrite(session, path);
1027
1024
 
@@ -1065,9 +1062,7 @@ export async function executeReplaceSingle(
1065
1062
  }
1066
1063
 
1067
1064
  if (normalizedContent === result.content) {
1068
- throw new Error(
1069
- `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
1070
- );
1065
+ throw new Error(`Edits to ${path} resulted in no changes being made.`);
1071
1066
  }
1072
1067
 
1073
1068
  const finalContent = bom + restoreLineEndings(result.content, originalEnding);
@@ -24,12 +24,12 @@ import {
24
24
  } from "../tools/render-utils";
25
25
  import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
26
26
  import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
27
+ import type { EditMode } from "../utils/edit-mode";
27
28
  import type { VimToolDetails } from "../vim/types";
28
29
  import type { DiffError, DiffResult } from "./diff";
29
30
  import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
30
- import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
31
- import type { HashlineToolEdit } from "./modes/hashline";
32
31
  import type { Operation, PatchEditEntry } from "./modes/patch";
32
+ import type { PerFileDiffPreview } from "./streaming";
33
33
 
34
34
  // ═══════════════════════════════════════════════════════════════════════════
35
35
  // LSP Batching
@@ -50,6 +50,9 @@ export interface EditToolPerFileResult {
50
50
  move?: string;
51
51
  isError?: boolean;
52
52
  errorText?: string;
53
+ /** TUI-friendly error text. When present, rendered to the user instead of `errorText`.
54
+ * Set when the underlying error carries a `displayMessage` (e.g. {@link HashlineMismatchError}). */
55
+ displayErrorText?: string;
53
56
  meta?: OutputMeta;
54
57
  }
55
58
 
@@ -90,6 +93,7 @@ interface EditRenderArgs {
90
93
  * Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
91
94
  */
92
95
  previewDiff?: string;
96
+ __partialJson?: string;
93
97
  // Hashline / chunk mode fields
94
98
  edits?: EditRenderEntry[];
95
99
  }
@@ -133,8 +137,12 @@ function isVimToolDetails(details: unknown): details is VimToolDetails {
133
137
 
134
138
  /** Extended context for edit tool rendering */
135
139
  export interface EditRenderContext {
140
+ /** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
141
+ editMode?: EditMode;
136
142
  /** Pre-computed diff preview (computed before tool executes) */
137
143
  editDiffPreview?: DiffResult | DiffError;
144
+ /** Multi-file streaming diff preview (chunk edits spanning several files) */
145
+ perFileDiffPreview?: PerFileDiffPreview[];
138
146
  /** Function to render diff text with syntax highlighting */
139
147
  renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
140
148
  }
@@ -142,14 +150,6 @@ export interface EditRenderContext {
142
150
  const EDIT_STREAMING_PREVIEW_LINES = 12;
143
151
  const CALL_TEXT_PREVIEW_LINES = 6;
144
152
  const CALL_TEXT_PREVIEW_WIDTH = 80;
145
- const STREAMING_EDIT_PREVIEW_WIDTH = 120;
146
- const STREAMING_EDIT_PREVIEW_LIMIT = 4;
147
- const STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT = 8;
148
-
149
- interface FormattedStreamingEdit {
150
- srcLabel: string;
151
- dst: string;
152
- }
153
153
 
154
154
  /** Extract file path from an edit entry's path (handles chunk's file:selector format). */
155
155
  function filePathFromEditEntry(p: string | undefined): string | undefined {
@@ -158,6 +158,48 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
158
158
  return ci === -1 ? p : p.slice(0, ci);
159
159
  }
160
160
 
161
+ function decodePartialJsonStringFragment(fragment: string): string {
162
+ let text = fragment;
163
+ const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
164
+ if (trailingBackslashes % 2 === 1) {
165
+ text = text.slice(0, -1);
166
+ }
167
+ try {
168
+ return JSON.parse(`"${text}"`) as string;
169
+ } catch {
170
+ return text
171
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
172
+ .replace(/\\(["\\/bfnrt])/g, (_, ch: string) => {
173
+ switch (ch) {
174
+ case "b":
175
+ return "\b";
176
+ case "f":
177
+ return "\f";
178
+ case "n":
179
+ return "\n";
180
+ case "r":
181
+ return "\r";
182
+ case "t":
183
+ return "\t";
184
+ default:
185
+ return ch;
186
+ }
187
+ });
188
+ }
189
+ }
190
+
191
+ function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
192
+ if (!partialJson) return undefined;
193
+ const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
194
+ const match = pattern.exec(partialJson);
195
+ if (!match) return undefined;
196
+ return decodePartialJsonStringFragment(match[1]);
197
+ }
198
+
199
+ function getPartialJsonEditPath(args: EditRenderArgs): string | undefined {
200
+ return filePathFromEditEntry(extractPartialJsonString(args.__partialJson, "path"));
201
+ }
202
+
161
203
  /** Count distinct file paths in an edits array. */
162
204
  function countEditFiles(edits: EditRenderEntry[]): number {
163
205
  return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
@@ -230,133 +272,46 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
230
272
  return text;
231
273
  }
232
274
 
233
- function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
234
- return (
235
- typeof edit === "object" &&
236
- edit !== null &&
237
- "path" in edit &&
238
- ("write" in edit || "replace" in edit || "insert" in edit)
239
- );
240
- }
241
-
242
- function getStreamingEditContent(content: unknown): string {
243
- if (Array.isArray(content)) {
244
- return content.join("\n");
245
- }
246
- return typeof content === "string" ? content : "";
247
- }
248
-
249
- function formatHashlineStreamingEdit(edit: Partial<HashlineToolEdit>): FormattedStreamingEdit {
250
- if (typeof edit !== "object" || !edit) {
251
- return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
252
- }
253
-
254
- const contentLines = getStreamingEditContent(edit.content);
255
- const loc = edit.loc;
256
-
257
- if (loc === "append" || loc === "prepend") {
258
- return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines };
259
- }
260
- if (typeof loc === "object" && loc) {
261
- if ("range" in loc && typeof loc.range === "object" && loc.range) {
262
- return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines };
263
- }
264
- if ("line" in loc) {
265
- return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines };
266
- }
267
- if ("append" in loc) {
268
- return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines };
269
- }
270
- if ("prepend" in loc) {
271
- return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
272
- }
273
- }
274
- return { srcLabel: "\u2022 (unknown edit)", dst: contentLines };
275
- }
276
-
277
- function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStreamingEdit {
278
- if (typeof edit !== "object" || !edit) {
279
- return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
280
- }
281
-
282
- const target = edit.path ? (parseChunkEditPath(edit.path).selector ?? edit.path) : "?";
283
- if (edit.write === null) {
284
- return { srcLabel: `\u2022 remove ${target}`, dst: "" };
285
- }
286
- if (typeof edit.write === "string") {
287
- return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.write) };
288
- }
289
- if (typeof edit.replace === "object" && edit.replace) {
290
- return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.replace.new) };
291
- }
292
- if (typeof edit.insert === "object" && edit.insert) {
293
- return { srcLabel: `\u2022 ${edit.insert.loc} ${target}`, dst: getStreamingEditContent(edit.insert.body) };
275
+ function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
276
+ const icon = uiTheme.getLangIcon(language);
277
+ if (lineCount !== null) {
278
+ return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
294
279
  }
295
- return { srcLabel: `\u2022 edit ${target}`, dst: "" };
280
+ return uiTheme.fg("dim", `${icon}`);
296
281
  }
297
282
 
298
- function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit | ChunkToolEdit>[], uiTheme: Theme): string {
299
- let text = "\n\n";
300
-
301
- // Detect whether these are chunk edits (target field) or hashline edits (loc field)
302
- const isChunk = edits.length > 0 && isChunkStreamingEdit(edits[0]);
303
- const label = isChunk ? "chunk edit" : "hashline edit";
304
- const formatEdit = isChunk ? formatChunkStreamingEdit : formatHashlineStreamingEdit;
305
- text += uiTheme.fg("dim", `[${edits.length} ${label}${edits.length === 1 ? "" : "s"}]`);
306
- text += "\n";
307
- let shownEdits = 0;
308
- let shownDstLines = 0;
309
- for (const edit of edits) {
310
- shownEdits++;
311
- if (shownEdits > STREAMING_EDIT_PREVIEW_LIMIT) break;
312
- const formatted = formatEdit(edit as never);
313
- text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), STREAMING_EDIT_PREVIEW_WIDTH));
314
- text += "\n";
315
- if (formatted.dst === "") {
316
- text += uiTheme.fg("dim", truncateToWidth(" (delete)", STREAMING_EDIT_PREVIEW_WIDTH));
317
- text += "\n";
283
+ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
284
+ const parts: string[] = [];
285
+ for (const preview of previews) {
286
+ if (!preview.diff && !preview.error) continue;
287
+ const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
288
+ if (preview.error) {
289
+ parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
318
290
  continue;
319
291
  }
320
- for (const dstLine of formatted.dst.split("\n")) {
321
- shownDstLines++;
322
- if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
323
- text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), STREAMING_EDIT_PREVIEW_WIDTH));
324
- text += "\n";
292
+ if (preview.diff) {
293
+ parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
325
294
  }
326
- if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
327
295
  }
328
- if (edits.length > STREAMING_EDIT_PREVIEW_LIMIT) {
329
- text += uiTheme.fg("dim", `\u2026 (${edits.length - STREAMING_EDIT_PREVIEW_LIMIT} more edits)`);
330
- }
331
- if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) {
332
- text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT} more dst lines)`);
333
- }
334
-
335
- return text.trimEnd();
296
+ return parts.join("");
336
297
  }
337
298
 
338
- function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
339
- const icon = uiTheme.getLangIcon(language);
340
- if (lineCount !== null) {
341
- return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
299
+ function getCallPreview(
300
+ args: EditRenderArgs,
301
+ rawPath: string,
302
+ uiTheme: Theme,
303
+ renderContext: EditRenderContext | undefined,
304
+ ): string {
305
+ const multi = renderContext?.perFileDiffPreview;
306
+ if (multi && multi.length > 0 && multi.some(p => p.diff || p.error)) {
307
+ return formatMultiFileStreamingDiff(multi, uiTheme);
342
308
  }
343
- return uiTheme.fg("dim", `${icon}`);
344
- }
345
-
346
- function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme): string {
347
309
  if (args.previewDiff) {
348
310
  return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
349
311
  }
350
312
  if (args.diff && args.op) {
351
313
  return formatStreamingDiff(args.diff, rawPath, uiTheme);
352
314
  }
353
- if (args.edits && args.edits.length > 0) {
354
- // Only show hashline/chunk streaming edits — replace/patch use previewDiff above
355
- const first = args.edits[0];
356
- if (first && typeof first === "object" && ("loc" in first || isChunkStreamingEdit(first))) {
357
- return formatStreamingHashlineEdits(args.edits, uiTheme);
358
- }
359
- }
360
315
  if (args.diff) {
361
316
  return renderPlainTextPreview(args.diff, uiTheme);
362
317
  }
@@ -425,18 +380,18 @@ function wrapEditRendererLine(line: string, width: number): string[] {
425
380
  const startAnsi = line.match(/^((?:\x1b\[[0-9;]*m)*)/)?.[1] ?? "";
426
381
  const bodyWithReset = line.slice(startAnsi.length);
427
382
  const body = bodyWithReset.endsWith("\x1b[39m") ? bodyWithReset.slice(0, -"\x1b[39m".length) : bodyWithReset;
428
- const diffMatch = /^([+\-\s])(\s*\d+)\|(.*)$/s.exec(body);
383
+ const diffMatch = /^([+\-\s])(\s*\d+)([|│])(.*)$/s.exec(body);
429
384
 
430
385
  if (!diffMatch) {
431
386
  return wrapTextWithAnsi(line, width);
432
387
  }
433
388
 
434
- const [, marker, lineNum, content] = diffMatch;
435
- const prefix = `${marker}${lineNum}|`;
389
+ const [, marker, lineNum, separator, content] = diffMatch;
390
+ const prefix = `${marker}${lineNum}${separator}`;
436
391
  const prefixWidth = visibleWidth(prefix);
437
392
  const contentWidth = Math.max(1, width - prefixWidth);
438
- const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}|`;
439
- const wrappedContent = wrapTextWithAnsi(content, contentWidth);
393
+ const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}${separator}`;
394
+ const wrappedContent = wrapTextWithAnsi(content ?? "", contentWidth);
440
395
 
441
396
  return wrappedContent.map(
442
397
  (segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[39m`,
@@ -446,31 +401,44 @@ function wrapEditRendererLine(line: string, width: number): string[] {
446
401
  export const editToolRenderer = {
447
402
  mergeCallAndResult: true,
448
403
 
449
- renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
450
- if (isVimRenderArgs(args)) {
451
- return vimToolRenderer.renderCall(args, options, uiTheme);
404
+ renderCall(
405
+ args: EditRenderArgs | VimRenderArgs,
406
+ options: RenderResultOptions & { renderContext?: EditRenderContext },
407
+ uiTheme: Theme,
408
+ ): Component {
409
+ const renderContext = options.renderContext;
410
+ // Dispatch on the explicit editMode when available; fall back to the
411
+ // shape probe for legacy call sites that don't thread renderContext.
412
+ if (renderContext?.editMode === "vim" || isVimRenderArgs(args)) {
413
+ return vimToolRenderer.renderCall(args as VimRenderArgs, options, uiTheme);
452
414
  }
453
415
 
454
- const applyPatchSummary = getApplyPatchRenderSummary(args, options.isPartial);
416
+ const editArgs = args as EditRenderArgs;
417
+ const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial);
455
418
  const firstApplyPatchEntry = applyPatchSummary?.entries[0];
456
419
  // Extract path from first edit entry when top-level path is absent (new schema)
457
- const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
420
+ const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
458
421
  const rawPath =
459
- args.file_path || args.path || filePathFromEditEntry(firstEdit?.path) || firstApplyPatchEntry?.path || "";
460
- const rename = args.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
461
- const op = args.op || firstEdit?.op || firstApplyPatchEntry?.op;
422
+ editArgs.file_path ||
423
+ editArgs.path ||
424
+ filePathFromEditEntry(firstEdit?.path) ||
425
+ getPartialJsonEditPath(editArgs) ||
426
+ firstApplyPatchEntry?.path ||
427
+ "";
428
+ const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
429
+ const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
462
430
  const { description } = formatEditDescription(rawPath, uiTheme, { rename });
463
431
  const spinner =
464
432
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
465
433
  let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
466
434
  // Show file count hint for multi-file edits
467
- const fileCount = Array.isArray(args.edits)
468
- ? countEditFiles(args.edits)
435
+ const fileCount = Array.isArray(editArgs.edits)
436
+ ? countEditFiles(editArgs.edits)
469
437
  : (applyPatchSummary?.entries.length ?? 0);
470
438
  if (fileCount > 1) {
471
439
  text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
472
440
  }
473
- text += getCallPreview(args, rawPath, uiTheme);
441
+ text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
474
442
  if (applyPatchSummary?.error) {
475
443
  text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
476
444
  }
@@ -484,7 +452,7 @@ export const editToolRenderer = {
484
452
  uiTheme: Theme,
485
453
  args?: EditRenderArgs,
486
454
  ): Component {
487
- if (isVimToolDetails(result.details)) {
455
+ if (options.renderContext?.editMode === "vim" || isVimToolDetails(result.details)) {
488
456
  return vimToolRenderer.renderResult(
489
457
  result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
490
458
  options,
@@ -524,13 +492,14 @@ function renderSingleFileResult(
524
492
  const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
525
493
  const { language } = formatEditDescription(rawPath, uiTheme, { rename });
526
494
 
527
- const metadataLine =
528
- op !== "delete"
529
- ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
530
- : "";
495
+ const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
496
+ const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
497
+ const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
531
498
 
499
+ const displayErrorText = isError && details && "displayErrorText" in details ? details.displayErrorText : undefined;
532
500
  const errorText = isError
533
- ? (details && "errorText" in details && details.errorText) ||
501
+ ? displayErrorText ||
502
+ (details && "errorText" in details && details.errorText) ||
534
503
  (result.content?.find(c => c.type === "text")?.text ?? "")
535
504
  : "";
536
505