@oh-my-pi/pi-coding-agent 3.20.0 → 3.21.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 (95) hide show
  1. package/CHANGELOG.md +78 -8
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +3 -3
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/custom-tools/subagent/index.ts +1 -1
  8. package/examples/extensions/README.md +76 -74
  9. package/examples/extensions/todo.ts +2 -5
  10. package/examples/hooks/custom-compaction.ts +1 -1
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/12-full-control.ts +1 -1
  15. package/examples/sdk/README.md +1 -1
  16. package/package.json +5 -5
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +13 -2
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/hooks/runner.ts +2 -2
  29. package/src/core/hooks/types.ts +1 -1
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +1 -1
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +11 -22
  35. package/src/core/settings-manager.ts +66 -1
  36. package/src/core/slash-commands.ts +12 -5
  37. package/src/core/system-prompt.ts +27 -3
  38. package/src/core/title-generator.ts +2 -2
  39. package/src/core/tools/ask.ts +88 -1
  40. package/src/core/tools/bash-interceptor.ts +7 -0
  41. package/src/core/tools/bash.ts +106 -0
  42. package/src/core/tools/edit-diff.ts +73 -24
  43. package/src/core/tools/edit.ts +214 -20
  44. package/src/core/tools/find.ts +162 -1
  45. package/src/core/tools/gemini-image.ts +279 -56
  46. package/src/core/tools/git.ts +4 -0
  47. package/src/core/tools/grep.ts +191 -0
  48. package/src/core/tools/index.ts +3 -6
  49. package/src/core/tools/ls.ts +142 -2
  50. package/src/core/tools/lsp/render.ts +34 -14
  51. package/src/core/tools/notebook.ts +110 -0
  52. package/src/core/tools/output.ts +179 -7
  53. package/src/core/tools/read.ts +122 -9
  54. package/src/core/tools/render-utils.ts +241 -0
  55. package/src/core/tools/renderers.ts +40 -828
  56. package/src/core/tools/review.ts +26 -7
  57. package/src/core/tools/rulebook.ts +3 -1
  58. package/src/core/tools/task/index.ts +18 -3
  59. package/src/core/tools/task/render.ts +7 -2
  60. package/src/core/tools/task/types.ts +1 -1
  61. package/src/core/tools/truncate.ts +27 -1
  62. package/src/core/tools/web-fetch.ts +23 -15
  63. package/src/core/tools/web-search/index.ts +130 -45
  64. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  65. package/src/core/tools/web-search/providers/exa.ts +2 -1
  66. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  67. package/src/core/tools/web-search/render.ts +5 -0
  68. package/src/core/tools/web-search/types.ts +13 -0
  69. package/src/core/tools/write.ts +90 -0
  70. package/src/core/voice.ts +1 -1
  71. package/src/lib/worktree/constants.ts +6 -6
  72. package/src/main.ts +1 -1
  73. package/src/modes/interactive/components/assistant-message.ts +1 -1
  74. package/src/modes/interactive/components/custom-message.ts +1 -1
  75. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  76. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  77. package/src/modes/interactive/components/footer.ts +1 -1
  78. package/src/modes/interactive/components/hook-message.ts +1 -1
  79. package/src/modes/interactive/components/model-selector.ts +1 -1
  80. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  81. package/src/modes/interactive/components/settings-defs.ts +49 -0
  82. package/src/modes/interactive/components/status-line.ts +1 -1
  83. package/src/modes/interactive/components/tool-execution.ts +93 -538
  84. package/src/modes/interactive/interactive-mode.ts +19 -7
  85. package/src/modes/print-mode.ts +1 -1
  86. package/src/modes/rpc/rpc-client.ts +1 -1
  87. package/src/modes/rpc/rpc-types.ts +1 -1
  88. package/src/prompts/system-prompt.md +4 -0
  89. package/src/prompts/tools/gemini-image.md +5 -1
  90. package/src/prompts/tools/output.md +4 -0
  91. package/src/prompts/tools/web-fetch.md +1 -0
  92. package/src/prompts/tools/web-search.md +2 -0
  93. package/src/utils/image-convert.ts +8 -2
  94. package/src/utils/image-magick.ts +247 -0
  95. package/src/utils/image-resize.ts +53 -13
@@ -1,4 +1,3 @@
1
- import { homedir } from "node:os";
2
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
3
2
  import {
4
3
  Box,
@@ -14,10 +13,9 @@ import {
14
13
  import stripAnsi from "strip-ansi";
15
14
  import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff";
16
15
  import { toolRenderers } from "../../../core/tools/renderers";
17
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate";
18
16
  import { convertToPng } from "../../../utils/image-convert";
19
17
  import { sanitizeBinaryOutput } from "../../../utils/shell";
20
- import { getLanguageFromPath, highlightCode, theme } from "../theme/theme";
18
+ import { theme } from "../theme/theme";
21
19
  import { renderDiff } from "./diff";
22
20
  import { truncateToVisualLines } from "./visual-truncate";
23
21
 
@@ -26,230 +24,6 @@ const BASH_PREVIEW_LINES = 5;
26
24
  const GENERIC_PREVIEW_LINES = 6;
27
25
  const GENERIC_ARG_PREVIEW = 6;
28
26
  const GENERIC_VALUE_MAX = 80;
29
- const EDIT_DIFF_PREVIEW_HUNKS = 2;
30
- const EDIT_DIFF_PREVIEW_LINES = 24;
31
-
32
- function wrapBrackets(text: string): string {
33
- return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
34
- }
35
-
36
- function countLines(text: string): number {
37
- if (!text) return 0;
38
- return text.split("\n").length;
39
- }
40
-
41
- function formatMetadataLine(lineCount: number | null, language: string | undefined): string {
42
- const icon = theme.getLangIcon(language);
43
- if (lineCount !== null) {
44
- return theme.fg("dim", `${icon} ${lineCount} lines`);
45
- }
46
- return theme.fg("dim", `${icon}`);
47
- }
48
-
49
- const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
50
- const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
51
-
52
- function getFileType(filePath: string): "image" | "binary" | "text" {
53
- const ext = filePath.split(".").pop()?.toLowerCase();
54
- if (!ext) return "text";
55
- if (IMAGE_EXTENSIONS.has(ext)) return "image";
56
- if (BINARY_EXTENSIONS.has(ext)) return "binary";
57
- return "text";
58
- }
59
-
60
- function formatDiffStats(added: number, removed: number, hunks: number): string {
61
- const parts: string[] = [];
62
- if (added > 0) parts.push(theme.fg("success", `+${added}`));
63
- if (removed > 0) parts.push(theme.fg("error", `-${removed}`));
64
- if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
65
- return parts.join(theme.fg("dim", " / "));
66
- }
67
-
68
- type DiffStats = {
69
- added: number;
70
- removed: number;
71
- hunks: number;
72
- lines: number;
73
- };
74
-
75
- function getDiffStats(diffText: string): DiffStats {
76
- const lines = diffText ? diffText.split("\n") : [];
77
- let added = 0;
78
- let removed = 0;
79
- let hunks = 0;
80
- let inHunk = false;
81
-
82
- for (const line of lines) {
83
- const isAdded = line.startsWith("+");
84
- const isRemoved = line.startsWith("-");
85
- const isChange = isAdded || isRemoved;
86
-
87
- if (isAdded) added++;
88
- if (isRemoved) removed++;
89
-
90
- if (isChange && !inHunk) {
91
- hunks++;
92
- inHunk = true;
93
- } else if (!isChange) {
94
- inHunk = false;
95
- }
96
- }
97
-
98
- return { added, removed, hunks, lines: lines.length };
99
- }
100
-
101
- function truncateDiffByHunk(
102
- diffText: string,
103
- maxHunks: number,
104
- maxLines: number,
105
- ): { text: string; hiddenHunks: number; hiddenLines: number } {
106
- const lines = diffText ? diffText.split("\n") : [];
107
- const totalStats = getDiffStats(diffText);
108
- const kept: string[] = [];
109
- let inHunk = false;
110
- let currentHunks = 0;
111
- let reachedLimit = false;
112
-
113
- for (const line of lines) {
114
- const isChange = line.startsWith("+") || line.startsWith("-");
115
- if (isChange && !inHunk) {
116
- currentHunks++;
117
- inHunk = true;
118
- }
119
- if (!isChange) {
120
- inHunk = false;
121
- }
122
-
123
- if (currentHunks > maxHunks) {
124
- reachedLimit = true;
125
- break;
126
- }
127
-
128
- kept.push(line);
129
- if (kept.length >= maxLines) {
130
- reachedLimit = true;
131
- break;
132
- }
133
- }
134
-
135
- if (!reachedLimit) {
136
- return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
137
- }
138
-
139
- const keptStats = getDiffStats(kept.join("\n"));
140
- return {
141
- text: kept.join("\n"),
142
- hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
143
- hiddenLines: Math.max(0, totalStats.lines - kept.length),
144
- };
145
- }
146
-
147
- interface ParsedDiagnostic {
148
- filePath: string;
149
- line: number;
150
- col: number;
151
- severity: "error" | "warning" | "info" | "hint";
152
- source?: string;
153
- message: string;
154
- code?: string;
155
- }
156
-
157
- function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
158
- // Format: filePath:line:col [severity] [source] message (code)
159
- const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
160
- if (!match) return null;
161
- return {
162
- filePath: match[1],
163
- line: parseInt(match[2], 10),
164
- col: parseInt(match[3], 10),
165
- severity: match[4] as ParsedDiagnostic["severity"],
166
- source: match[5],
167
- message: match[6],
168
- code: match[7],
169
- };
170
- }
171
-
172
- function formatDiagnostics(diag: { errored: boolean; summary: string; messages: string[] }, expanded: boolean): string {
173
- if (diag.messages.length === 0) return "";
174
-
175
- // Parse and group diagnostics by file
176
- const byFile = new Map<string, ParsedDiagnostic[]>();
177
- const unparsed: string[] = [];
178
-
179
- for (const msg of diag.messages) {
180
- const parsed = parseDiagnosticMessage(msg);
181
- if (parsed) {
182
- const existing = byFile.get(parsed.filePath) ?? [];
183
- existing.push(parsed);
184
- byFile.set(parsed.filePath, existing);
185
- } else {
186
- unparsed.push(msg);
187
- }
188
- }
189
-
190
- const headerIcon = diag.errored
191
- ? theme.styledSymbol("status.error", "error")
192
- : theme.styledSymbol("status.warning", "warning");
193
- let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
194
-
195
- const maxDiags = expanded ? diag.messages.length : 5;
196
- let shown = 0;
197
-
198
- // Render grouped diagnostics with file icons
199
- const files = Array.from(byFile.entries());
200
- for (let fi = 0; fi < files.length && shown < maxDiags; fi++) {
201
- const [filePath, diagnostics] = files[fi];
202
- const isLastFile = fi === files.length - 1 && unparsed.length === 0;
203
- const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
204
-
205
- // File header with icon
206
- const fileLang = getLanguageFromPath(filePath);
207
- const fileIcon = theme.fg("muted", theme.getLangIcon(fileLang));
208
- output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
209
- shown++;
210
-
211
- // Render diagnostics for this file
212
- for (let di = 0; di < diagnostics.length && shown < maxDiags; di++) {
213
- const d = diagnostics[di];
214
- const isLastDiag = di === diagnostics.length - 1;
215
- const diagBranch = isLastFile
216
- ? isLastDiag
217
- ? ` ${theme.tree.last}`
218
- : ` ${theme.tree.branch}`
219
- : isLastDiag
220
- ? ` ${theme.tree.vertical} ${theme.tree.last}`
221
- : ` ${theme.tree.vertical} ${theme.tree.branch}`;
222
-
223
- const sevIcon =
224
- d.severity === "error"
225
- ? theme.styledSymbol("status.error", "error")
226
- : d.severity === "warning"
227
- ? theme.styledSymbol("status.warning", "warning")
228
- : theme.styledSymbol("status.info", "muted");
229
- const location = theme.fg("dim", `:${d.line}:${d.col}`);
230
- const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
231
- const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
232
-
233
- output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
234
- shown++;
235
- }
236
- }
237
-
238
- // Render unparsed messages (fallback)
239
- for (const msg of unparsed) {
240
- if (shown >= maxDiags) break;
241
- const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
242
- output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg(color, msg)}`;
243
- shown++;
244
- }
245
-
246
- if (diag.messages.length > shown) {
247
- const remaining = diag.messages.length - shown;
248
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)} ${theme.fg("dim", "(Ctrl+O to expand)")}`;
249
- }
250
-
251
- return output;
252
- }
253
27
 
254
28
  function formatCompactValue(value: unknown, maxLength: number): string {
255
29
  let rendered = "";
@@ -309,24 +83,6 @@ function formatArgsPreview(
309
83
  return { lines, remaining: Math.max(total - visible.length, 0), total };
310
84
  }
311
85
 
312
- /**
313
- * Convert absolute path to tilde notation if it's in home directory
314
- */
315
- function shortenPath(path: string): string {
316
- const home = homedir();
317
- if (home && path.startsWith(home)) {
318
- return `~${path.slice(home.length)}`;
319
- }
320
- return path;
321
- }
322
-
323
- /**
324
- * Replace tabs with spaces for consistent rendering
325
- */
326
- function replaceTabs(text: string): string {
327
- return text.replace(/\t/g, " ");
328
- }
329
-
330
86
  export interface ToolExecutionOptions {
331
87
  showImages?: boolean; // default: true (only used if terminal supports images)
332
88
  }
@@ -340,6 +96,7 @@ export class ToolExecutionComponent extends Container {
340
96
  private imageComponents: Image[] = [];
341
97
  private imageSpacers: Spacer[] = [];
342
98
  private toolName: string;
99
+ private toolLabel: string;
343
100
  private args: any;
344
101
  private expanded = false;
345
102
  private showImages: boolean;
@@ -371,6 +128,7 @@ export class ToolExecutionComponent extends Container {
371
128
  ) {
372
129
  super();
373
130
  this.toolName = toolName;
131
+ this.toolLabel = tool?.label ?? toolName;
374
132
  this.args = args;
375
133
  this.showImages = options.showImages ?? true;
376
134
  this.tool = tool;
@@ -383,10 +141,10 @@ export class ToolExecutionComponent extends Container {
383
141
  this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
384
142
  this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
385
143
 
386
- // Use Box for custom tools, bash, or built-in tools that have renderers
144
+ // Use Box for custom tools or built-in tools that have renderers
387
145
  const hasRenderer = toolName in toolRenderers;
388
146
  const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult);
389
- if (hasCustomRenderer || toolName === "bash" || hasRenderer) {
147
+ if (hasCustomRenderer || hasRenderer) {
390
148
  this.addChild(this.contentBox);
391
149
  } else {
392
150
  this.addChild(this.contentText);
@@ -418,12 +176,13 @@ export class ToolExecutionComponent extends Container {
418
176
  const path = this.args?.path;
419
177
  const oldText = this.args?.oldText;
420
178
  const newText = this.args?.newText;
179
+ const all = this.args?.all;
421
180
 
422
181
  // Need all three params to compute diff
423
182
  if (!path || oldText === undefined || newText === undefined) return;
424
183
 
425
184
  // Create a key to track which args this computation is for
426
- const argsKey = JSON.stringify({ path, oldText, newText });
185
+ const argsKey = JSON.stringify({ path, oldText, newText, all });
427
186
 
428
187
  // Skip if we already computed for these exact args
429
188
  if (this.editDiffArgsKey === argsKey) return;
@@ -431,7 +190,7 @@ export class ToolExecutionComponent extends Container {
431
190
  this.editDiffArgsKey = argsKey;
432
191
 
433
192
  // Compute diff async
434
- computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
193
+ computeEditDiff(path, oldText, newText, this.cwd, true, all).then((result) => {
435
194
  // Only update if args haven't changed since we started
436
195
  if (this.editDiffArgsKey === argsKey) {
437
196
  this.editDiffPreview = result;
@@ -457,6 +216,17 @@ export class ToolExecutionComponent extends Container {
457
216
  this.maybeConvertImagesForKitty();
458
217
  }
459
218
 
219
+ /**
220
+ * Get all image blocks from result content and details.images.
221
+ * Some tools (like generate_image) store images in details to avoid bloating model context.
222
+ */
223
+ private getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> {
224
+ if (!this.result) return [];
225
+ const contentImages = this.result.content?.filter((c: any) => c.type === "image") || [];
226
+ const detailImages = this.result.details?.images || [];
227
+ return [...contentImages, ...detailImages];
228
+ }
229
+
460
230
  /**
461
231
  * Convert non-PNG images to PNG for Kitty graphics protocol.
462
232
  * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
@@ -467,7 +237,7 @@ export class ToolExecutionComponent extends Container {
467
237
  if (caps.images !== "kitty") return;
468
238
  if (!this.result) return;
469
239
 
470
- const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
240
+ const imageBlocks = this.getAllImageBlocks();
471
241
 
472
242
  for (let i = 0; i < imageBlocks.length; i++) {
473
243
  const img = imageBlocks[i];
@@ -556,11 +326,11 @@ export class ToolExecutionComponent extends Container {
556
326
  }
557
327
  } catch {
558
328
  // Fall back to default on error
559
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
329
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
560
330
  }
561
331
  } else {
562
332
  // No custom renderCall, show tool name
563
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
333
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
564
334
  }
565
335
 
566
336
  // Render result component if we have a result
@@ -593,13 +363,8 @@ export class ToolExecutionComponent extends Container {
593
363
  this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
594
364
  }
595
365
  }
596
- } else if (this.toolName === "bash") {
597
- // Bash uses Box with visual line truncation
598
- this.contentBox.setBgFn(bgFn);
599
- this.contentBox.clear();
600
- this.renderBashContent();
601
366
  } else if (this.toolName in toolRenderers) {
602
- // Built-in tools with custom renderers
367
+ // Built-in tools with renderers
603
368
  const renderer = toolRenderers[this.toolName];
604
369
  this.contentBox.setBgFn(bgFn);
605
370
  this.contentBox.clear();
@@ -617,16 +382,25 @@ export class ToolExecutionComponent extends Container {
617
382
  }
618
383
  } catch {
619
384
  // Fall back to default on error
620
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
385
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
621
386
  }
622
387
 
623
388
  // Render result component if we have a result
624
389
  if (this.result) {
625
390
  try {
391
+ // Build render context for tools that need extra state
392
+ const renderContext = this.buildRenderContext();
393
+
626
394
  const resultComponent = renderer.renderResult(
627
- { content: this.result.content as any, details: this.result.details },
628
- { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
395
+ { content: this.result.content as any, details: this.result.details, isError: this.result.isError },
396
+ {
397
+ expanded: this.expanded,
398
+ isPartial: this.isPartial,
399
+ spinnerFrame: this.spinnerFrame,
400
+ renderContext,
401
+ },
629
402
  theme,
403
+ this.args, // Pass args for tools that need them
630
404
  );
631
405
  if (resultComponent) {
632
406
  // Ensure component has invalidate() method for Component interface
@@ -661,7 +435,7 @@ export class ToolExecutionComponent extends Container {
661
435
  this.imageSpacers = [];
662
436
 
663
437
  if (this.result) {
664
- const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
438
+ const imageBlocks = this.getAllImageBlocks();
665
439
  const caps = getCapabilities();
666
440
 
667
441
  for (let i = 0; i < imageBlocks.length; i++) {
@@ -694,93 +468,42 @@ export class ToolExecutionComponent extends Container {
694
468
  }
695
469
 
696
470
  /**
697
- * Render bash content using visual line truncation (like bash-execution.ts)
471
+ * Build render context for tools that need extra state (bash, edit)
698
472
  */
699
- private renderBashContent(): void {
700
- const command = this.args?.command || "";
701
-
702
- // Header
703
- this.contentBox.addChild(
704
- new Text(
705
- theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", theme.format.ellipsis)}`)),
706
- 0,
707
- 0,
708
- ),
709
- );
473
+ private buildRenderContext(): Record<string, unknown> {
474
+ const context: Record<string, unknown> = {};
710
475
 
711
- if (this.result) {
476
+ if (this.toolName === "bash" && this.result) {
477
+ // Bash needs visual line truncation context
712
478
  const output = this.getTextOutput().trim();
713
-
714
- if (output) {
715
- // Style each line for the output
479
+ if (output && !this.expanded) {
716
480
  const styledOutput = output
717
481
  .split("\n")
718
482
  .map((line) => theme.fg("toolOutput", line))
719
483
  .join("\n");
720
-
721
- if (this.expanded) {
722
- // Show all lines when expanded
723
- this.contentBox.addChild(new Text(`\n${styledOutput}`, 0, 0));
724
- } else {
725
- // Use visual line truncation when collapsed
726
- // Box has paddingX=1, so content width = terminal.columns - 2
727
- const { visualLines, skippedCount } = truncateToVisualLines(
728
- `\n${styledOutput}`,
729
- BASH_PREVIEW_LINES,
730
- this.ui.terminal.columns - 2,
731
- );
732
-
733
- const totalVisualLines = skippedCount + visualLines.length;
734
- if (skippedCount > 0) {
735
- this.contentBox.addChild(
736
- new Text(
737
- theme.fg(
738
- "dim",
739
- `\n${theme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
740
- ),
741
- 0,
742
- 0,
743
- ),
744
- );
745
- }
746
-
747
- // Add pre-rendered visual lines as a raw component
748
- this.contentBox.addChild({
749
- render: () => visualLines,
750
- invalidate: () => {},
751
- });
752
- }
753
- }
754
-
755
- // Truncation warnings
756
- const truncation = this.result.details?.truncation;
757
- const fullOutputPath = this.result.details?.fullOutputPath;
758
- if (truncation?.truncated || fullOutputPath) {
759
- const warnings: string[] = [];
760
- if (fullOutputPath) {
761
- warnings.push(`Full output: ${fullOutputPath}`);
762
- }
763
- if (truncation?.truncated) {
764
- if (truncation.truncatedBy === "lines") {
765
- warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
766
- } else {
767
- warnings.push(
768
- `Truncated: ${truncation.outputLines} lines shown (${formatSize(
769
- truncation.maxBytes ?? DEFAULT_MAX_BYTES,
770
- )} limit)`,
771
- );
772
- }
773
- }
774
- this.contentBox.addChild(new Text(`\n${theme.fg("warning", wrapBrackets(warnings.join(". ")))}`, 0, 0));
484
+ const { visualLines, skippedCount } = truncateToVisualLines(
485
+ `\n${styledOutput}`,
486
+ BASH_PREVIEW_LINES,
487
+ this.ui.terminal.columns - 2,
488
+ );
489
+ context.visualLines = visualLines;
490
+ context.skippedCount = skippedCount;
491
+ context.totalVisualLines = skippedCount + visualLines.length;
775
492
  }
493
+ } else if (this.toolName === "edit") {
494
+ // Edit needs diff preview and renderDiff function
495
+ context.editDiffPreview = this.editDiffPreview;
496
+ context.renderDiff = renderDiff;
776
497
  }
498
+
499
+ return context;
777
500
  }
778
501
 
779
502
  private getTextOutput(): string {
780
503
  if (!this.result) return "";
781
504
 
782
505
  const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
783
- const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
506
+ const imageBlocks = this.getAllImageBlocks();
784
507
 
785
508
  let output = textBlocks
786
509
  .map((c: any) => {
@@ -803,214 +526,46 @@ export class ToolExecutionComponent extends Container {
803
526
  return output;
804
527
  }
805
528
 
529
+ /**
530
+ * Format a generic tool execution (fallback for tools without custom renderers)
531
+ */
806
532
  private formatToolExecution(): string {
807
- let text = "";
808
-
809
- if (this.toolName === "read") {
810
- const rawPath = this.args?.file_path || this.args?.path || "";
811
- const path = shortenPath(rawPath);
812
- const offset = this.args?.offset;
813
- const limit = this.args?.limit;
814
- const fileType = getFileType(rawPath);
815
-
816
- let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
817
- if (offset !== undefined || limit !== undefined) {
818
- const startLine = offset ?? 1;
819
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
820
- pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
821
- }
822
-
823
- text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
824
-
825
- if (this.result) {
826
- const output = this.getTextOutput();
827
-
828
- if (fileType === "image") {
829
- // Image file - use image icon
830
- const ext = rawPath.split(".").pop()?.toLowerCase() ?? "image";
831
- text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`;
832
- // Images are rendered by the image component, just show hint
833
- text += `\n${theme.fg("muted", "Image rendered below")}`;
834
- } else if (fileType === "binary") {
835
- // Binary file - use binary/pdf/archive icon based on extension
836
- const ext = rawPath.split(".").pop()?.toLowerCase() ?? "binary";
837
- text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`;
838
- } else {
839
- // Text file - show line count and language on same line
840
- const lang = getLanguageFromPath(rawPath);
841
- const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
842
- text += `${theme.sep.dot}${formatMetadataLine(null, lang)}`;
843
-
844
- // Content is hidden by default, only shown when expanded
845
- if (this.expanded) {
846
- text +=
847
- "\n\n" +
848
- lines
849
- .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
850
- .join("\n");
851
- } else {
852
- text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to show content`)}`;
853
- }
854
-
855
- // Truncation warning
856
- const truncation = this.result.details?.truncation;
857
- if (truncation?.truncated) {
858
- let warning: string;
859
- if (truncation.firstLineExceedsLimit) {
860
- warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
861
- } else if (truncation.truncatedBy === "lines") {
862
- warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
863
- } else {
864
- warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
865
- }
866
- text += `\n${theme.fg("warning", wrapBrackets(warning))}`;
867
- }
868
- }
869
- }
870
- } else if (this.toolName === "write") {
871
- const rawPath = this.args?.file_path || this.args?.path || "";
872
- const path = shortenPath(rawPath);
873
- const fileContent = this.args?.content || "";
874
- const lang = getLanguageFromPath(rawPath);
875
- const lines = fileContent
876
- ? lang
877
- ? highlightCode(replaceTabs(fileContent), lang)
878
- : fileContent.split("\n")
879
- : [];
880
- const totalLines = lines.length;
881
-
882
- text =
883
- theme.fg("toolTitle", theme.bold("write")) +
884
- " " +
885
- (path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis));
886
-
887
- text += `\n${formatMetadataLine(countLines(fileContent), lang ?? "text")}`;
888
-
889
- if (fileContent) {
890
- const maxLines = this.expanded ? lines.length : 10;
891
- const displayLines = lines.slice(0, maxLines);
892
- const remaining = lines.length - maxLines;
893
-
894
- text +=
895
- "\n\n" +
896
- displayLines
897
- .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
898
- .join("\n");
899
- if (remaining > 0) {
900
- text += theme.fg(
901
- "toolOutput",
902
- `\n${theme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${wrapBrackets("Ctrl+O to expand")}`,
903
- );
904
- }
905
- }
906
-
907
- // Show LSP diagnostics if available
908
- if (this.result?.details?.diagnostics) {
909
- text += formatDiagnostics(this.result.details.diagnostics, this.expanded);
910
- }
911
- } else if (this.toolName === "edit") {
912
- const rawPath = this.args?.file_path || this.args?.path || "";
913
- const path = shortenPath(rawPath);
914
- const editLanguage = getLanguageFromPath(rawPath) ?? "text";
915
- const editIcon = theme.fg("muted", theme.getLangIcon(editLanguage));
916
-
917
- // Build path display, appending :line if we have diff info
918
- let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
919
- const firstChangedLine =
920
- (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
921
- ? this.editDiffPreview.firstChangedLine
922
- : undefined) ||
923
- (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
924
- if (firstChangedLine) {
925
- pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
926
- }
927
-
928
- text = `${theme.fg("toolTitle", theme.bold("edit"))} ${editIcon} ${pathDisplay}`;
929
-
930
- const editLineCount = countLines(this.args?.newText ?? this.args?.oldText ?? "");
931
- text += `\n${formatMetadataLine(editLineCount, editLanguage)}`;
932
-
933
- if (this.result?.isError) {
934
- // Show error from result
935
- const errorText = this.getTextOutput();
936
- if (errorText) {
937
- text += `\n\n${theme.fg("error", errorText)}`;
938
- }
939
- } else if (this.editDiffPreview) {
940
- // Use cached diff preview (works both before and after execution)
941
- if ("error" in this.editDiffPreview) {
942
- text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
943
- } else if (this.editDiffPreview.diff) {
944
- const diffStats = getDiffStats(this.editDiffPreview.diff);
945
- text += `\n${theme.fg("dim", theme.format.bracketLeft)}${formatDiffStats(diffStats.added, diffStats.removed, diffStats.hunks)}${theme.fg("dim", theme.format.bracketRight)}`;
946
-
947
- const {
948
- text: diffText,
949
- hiddenHunks,
950
- hiddenLines,
951
- } = this.expanded
952
- ? { text: this.editDiffPreview.diff, hiddenHunks: 0, hiddenLines: 0 }
953
- : truncateDiffByHunk(this.editDiffPreview.diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
954
-
955
- text += `\n\n${renderDiff(diffText, { filePath: rawPath })}`;
956
- if (!this.expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
957
- const remainder: string[] = [];
958
- if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
959
- if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
960
- text += theme.fg(
961
- "toolOutput",
962
- `\n${theme.format.ellipsis} (${remainder.join(", ")}) ${wrapBrackets("Ctrl+O to expand")}`,
963
- );
964
- }
965
- }
966
- }
967
-
968
- // Show LSP diagnostics if available
969
- if (this.result?.details?.diagnostics) {
970
- text += formatDiagnostics(this.result.details.diagnostics, this.expanded);
971
- }
533
+ let text = theme.fg("toolTitle", theme.bold(this.toolLabel));
534
+
535
+ const argTotal =
536
+ this.args && typeof this.args === "object"
537
+ ? Object.keys(this.args as Record<string, unknown>).length
538
+ : this.args === undefined
539
+ ? 0
540
+ : 1;
541
+ const argPreviewLimit = this.expanded ? argTotal : GENERIC_ARG_PREVIEW;
542
+ const valueLimit = this.expanded ? 2000 : GENERIC_VALUE_MAX;
543
+ const argsPreview = formatArgsPreview(this.args, argPreviewLimit, valueLimit);
544
+
545
+ text += `\n\n${theme.fg("toolTitle", "Args")} ${theme.fg("dim", `(${argsPreview.total})`)}`;
546
+ if (argsPreview.lines.length > 0) {
547
+ text += `\n${argsPreview.lines.join("\n")}`;
972
548
  } else {
973
- // Generic tool (shouldn't reach here for custom tools)
974
- text = theme.fg("toolTitle", theme.bold(this.toolName));
975
-
976
- const argTotal =
977
- this.args && typeof this.args === "object"
978
- ? Object.keys(this.args as Record<string, unknown>).length
979
- : this.args === undefined
980
- ? 0
981
- : 1;
982
- const argPreviewLimit = this.expanded ? argTotal : GENERIC_ARG_PREVIEW;
983
- const valueLimit = this.expanded ? 2000 : GENERIC_VALUE_MAX;
984
- const argsPreview = formatArgsPreview(this.args, argPreviewLimit, valueLimit);
985
-
986
- text += `\n\n${theme.fg("toolTitle", "Args")} ${theme.fg("dim", `(${argsPreview.total})`)}`;
987
- if (argsPreview.lines.length > 0) {
988
- text += `\n${argsPreview.lines.join("\n")}`;
989
- } else {
990
- text += `\n${theme.fg("dim", "(none)")}`;
991
- }
992
- if (argsPreview.remaining > 0) {
993
- text += theme.fg(
994
- "dim",
995
- `\n${theme.format.ellipsis} (${argsPreview.remaining} more args) (ctrl+o to expand)`,
996
- );
997
- }
549
+ text += `\n${theme.fg("dim", "(none)")}`;
550
+ }
551
+ if (argsPreview.remaining > 0) {
552
+ text += theme.fg("dim", `\n${theme.format.ellipsis} (${argsPreview.remaining} more args) (ctrl+o to expand)`);
553
+ }
998
554
 
999
- const output = this.getTextOutput().trim();
1000
- text += `\n\n${theme.fg("toolTitle", "Output")}`;
1001
- if (output) {
1002
- const lines = output.split("\n");
1003
- const maxLines = this.expanded ? lines.length : GENERIC_PREVIEW_LINES;
1004
- const displayLines = lines.slice(-maxLines);
1005
- const remaining = lines.length - displayLines.length;
1006
- text += ` ${theme.fg("dim", `(${lines.length} lines)`)}`;
1007
- text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
1008
- if (remaining > 0) {
1009
- text += theme.fg("dim", `\n${theme.format.ellipsis} (${remaining} earlier lines) (ctrl+o to expand)`);
1010
- }
1011
- } else {
1012
- text += ` ${theme.fg("dim", "(empty)")}`;
555
+ const output = this.getTextOutput().trim();
556
+ text += `\n\n${theme.fg("toolTitle", "Output")}`;
557
+ if (output) {
558
+ const lines = output.split("\n");
559
+ const maxLines = this.expanded ? lines.length : GENERIC_PREVIEW_LINES;
560
+ const displayLines = lines.slice(-maxLines);
561
+ const remaining = lines.length - displayLines.length;
562
+ text += ` ${theme.fg("dim", `(${lines.length} lines)`)}`;
563
+ text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
564
+ if (remaining > 0) {
565
+ text += theme.fg("dim", `\n${theme.format.ellipsis} (${remaining} earlier lines) (ctrl+o to expand)`);
1013
566
  }
567
+ } else {
568
+ text += ` ${theme.fg("dim", "(empty)")}`;
1014
569
  }
1015
570
 
1016
571
  return text;