@codex-infinity/pi-infinity 0.61.1 → 0.61.3

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 (103) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +10 -0
  3. package/dist/core/agent-session.d.ts +9 -2
  4. package/dist/core/agent-session.d.ts.map +1 -1
  5. package/dist/core/agent-session.js +36 -23
  6. package/dist/core/agent-session.js.map +1 -1
  7. package/dist/core/export-html/index.d.ts +2 -2
  8. package/dist/core/export-html/index.d.ts.map +1 -1
  9. package/dist/core/export-html/index.js +2 -2
  10. package/dist/core/export-html/index.js.map +1 -1
  11. package/dist/core/export-html/tool-renderer.d.ts +2 -2
  12. package/dist/core/export-html/tool-renderer.d.ts.map +1 -1
  13. package/dist/core/export-html/tool-renderer.js +41 -16
  14. package/dist/core/export-html/tool-renderer.js.map +1 -1
  15. package/dist/core/extensions/types.d.ts +31 -4
  16. package/dist/core/extensions/types.d.ts.map +1 -1
  17. package/dist/core/extensions/types.js.map +1 -1
  18. package/dist/core/extensions/wrapper.d.ts.map +1 -1
  19. package/dist/core/extensions/wrapper.js +2 -8
  20. package/dist/core/extensions/wrapper.js.map +1 -1
  21. package/dist/core/package-manager.d.ts +2 -0
  22. package/dist/core/package-manager.d.ts.map +1 -1
  23. package/dist/core/package-manager.js +42 -1
  24. package/dist/core/package-manager.js.map +1 -1
  25. package/dist/core/system-prompt.d.ts.map +1 -1
  26. package/dist/core/system-prompt.js +3 -38
  27. package/dist/core/system-prompt.js.map +1 -1
  28. package/dist/core/tools/bash.d.ts +19 -9
  29. package/dist/core/tools/bash.d.ts.map +1 -1
  30. package/dist/core/tools/bash.js +154 -59
  31. package/dist/core/tools/bash.js.map +1 -1
  32. package/dist/core/tools/edit.d.ts +14 -2
  33. package/dist/core/tools/edit.d.ts.map +1 -1
  34. package/dist/core/tools/edit.js +92 -21
  35. package/dist/core/tools/edit.js.map +1 -1
  36. package/dist/core/tools/find.d.ts +11 -4
  37. package/dist/core/tools/find.d.ts.map +1 -1
  38. package/dist/core/tools/find.js +76 -27
  39. package/dist/core/tools/find.js.map +1 -1
  40. package/dist/core/tools/grep.d.ts +15 -4
  41. package/dist/core/tools/grep.d.ts.map +1 -1
  42. package/dist/core/tools/grep.js +83 -29
  43. package/dist/core/tools/grep.js.map +1 -1
  44. package/dist/core/tools/index.d.ts +57 -19
  45. package/dist/core/tools/index.d.ts.map +1 -1
  46. package/dist/core/tools/index.js +50 -26
  47. package/dist/core/tools/index.js.map +1 -1
  48. package/dist/core/tools/ls.d.ts +9 -3
  49. package/dist/core/tools/ls.d.ts.map +1 -1
  50. package/dist/core/tools/ls.js +67 -13
  51. package/dist/core/tools/ls.js.map +1 -1
  52. package/dist/core/tools/read.d.ts +10 -3
  53. package/dist/core/tools/read.d.ts.map +1 -1
  54. package/dist/core/tools/read.js +94 -45
  55. package/dist/core/tools/read.js.map +1 -1
  56. package/dist/core/tools/render-utils.d.ts +21 -0
  57. package/dist/core/tools/render-utils.d.ts.map +1 -0
  58. package/dist/core/tools/render-utils.js +49 -0
  59. package/dist/core/tools/render-utils.js.map +1 -0
  60. package/dist/core/tools/tool-definition-wrapper.d.ts +14 -0
  61. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -0
  62. package/dist/core/tools/tool-definition-wrapper.js +30 -0
  63. package/dist/core/tools/tool-definition-wrapper.js.map +1 -0
  64. package/dist/core/tools/write.d.ts +9 -3
  65. package/dist/core/tools/write.d.ts.map +1 -1
  66. package/dist/core/tools/write.js +161 -27
  67. package/dist/core/tools/write.js.map +1 -1
  68. package/dist/index.d.ts +1 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -1
  71. package/dist/index.js.map +1 -1
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +20 -3
  74. package/dist/main.js.map +1 -1
  75. package/dist/modes/interactive/components/tool-execution.d.ts +15 -40
  76. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  77. package/dist/modes/interactive/components/tool-execution.js +120 -670
  78. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  79. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  80. package/dist/modes/interactive/interactive-mode.js +4 -6
  81. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  82. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  83. package/dist/modes/interactive/theme/theme.js +12 -0
  84. package/dist/modes/interactive/theme/theme.js.map +1 -1
  85. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  86. package/dist/modes/rpc/rpc-mode.js +19 -6
  87. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  88. package/docs/extensions.md +41 -26
  89. package/docs/tui.md +2 -2
  90. package/examples/extensions/built-in-tool-renderer.ts +8 -8
  91. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  92. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  93. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  94. package/examples/extensions/custom-provider-qwen-cli/package.json +1 -1
  95. package/examples/extensions/minimal-mode.ts +14 -14
  96. package/examples/extensions/question.ts +2 -2
  97. package/examples/extensions/questionnaire.ts +2 -2
  98. package/examples/extensions/subagent/index.ts +23 -3
  99. package/examples/extensions/todo.ts +2 -2
  100. package/examples/extensions/truncated-tool.ts +2 -2
  101. package/examples/extensions/with-deps/package-lock.json +2 -2
  102. package/examples/extensions/with-deps/package.json +1 -1
  103. package/package.json +4 -4
@@ -1,80 +1,34 @@
1
- import * as os from "node:os";
2
- import { Box, Container, getCapabilities, getImageDimensions, Image, imageFallback, Spacer, Text, truncateToWidth, } from "@mariozechner/pi-tui";
3
- import stripAnsi from "strip-ansi";
4
- import { computeEditDiff } from "../../../core/tools/edit-diff.js";
5
- import { allTools } from "../../../core/tools/index.js";
6
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
1
+ import { Box, Container, getCapabilities, Image, Spacer, Text } from "@mariozechner/pi-tui";
2
+ import { allToolDefinitions } from "../../../core/tools/index.js";
3
+ import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
7
4
  import { convertToPng } from "../../../utils/image-convert.js";
8
- import { sanitizeBinaryOutput } from "../../../utils/shell.js";
9
- import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
10
- import { renderDiff } from "./diff.js";
11
- import { keyHint } from "./keybinding-hints.js";
12
- import { truncateToVisualLines } from "./visual-truncate.js";
13
- // Preview line limit for bash when not expanded
14
- const BASH_PREVIEW_LINES = 5;
15
- // During partial write tool-call streaming, re-highlight the first N lines fully
16
- // to keep multiline tokenization mostly correct without re-highlighting the full file.
17
- const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
18
- /**
19
- * Convert absolute path to tilde notation if it's in home directory
20
- */
21
- function shortenPath(path) {
22
- if (typeof path !== "string")
23
- return "";
24
- const home = os.homedir();
25
- if (path.startsWith(home)) {
26
- return `~${path.slice(home.length)}`;
27
- }
28
- return path;
29
- }
30
- /**
31
- * Replace tabs with spaces for consistent rendering
32
- */
33
- function replaceTabs(text) {
34
- return text.replace(/\t/g, " ");
35
- }
36
- /**
37
- * Normalize control characters for terminal preview rendering.
38
- * Keep tool arguments unchanged, sanitize only display text.
39
- */
40
- function normalizeDisplayText(text) {
41
- return text.replace(/\r/g, "");
42
- }
43
- /** Safely coerce value to string for display. Returns null if invalid type. */
44
- function str(value) {
45
- if (typeof value === "string")
46
- return value;
47
- if (value == null)
48
- return "";
49
- return null; // Invalid type
50
- }
51
- /**
52
- * Component that renders a tool call with its result (updateable)
53
- */
5
+ import { theme } from "../theme/theme.js";
54
6
  export class ToolExecutionComponent extends Container {
55
- constructor(toolName, args, options = {}, toolDefinition, ui, cwd = process.cwd()) {
7
+ constructor(toolName, toolCallId, args, options = {}, toolDefinition, ui, cwd = process.cwd()) {
56
8
  super();
9
+ this.rendererState = {};
57
10
  this.imageComponents = [];
58
11
  this.imageSpacers = [];
59
12
  this.expanded = false;
60
13
  this.isPartial = true;
61
- // Cached converted images for Kitty protocol (which requires PNG), keyed by index
14
+ this.executionStarted = false;
15
+ this.argsComplete = false;
62
16
  this.convertedImages = new Map();
63
- // When true, this component intentionally renders no lines
64
17
  this.hideComponent = false;
65
18
  this.toolName = toolName;
19
+ this.toolCallId = toolCallId;
66
20
  this.args = args;
67
- this.showImages = options.showImages ?? true;
68
21
  this.toolDefinition = toolDefinition;
22
+ this.builtInToolDefinition = allToolDefinitions[toolName];
23
+ this.showImages = options.showImages ?? true;
69
24
  this.ui = ui;
70
25
  this.cwd = cwd;
71
26
  this.addChild(new Spacer(1));
72
- // Always create both - contentBox for custom tools/bash, contentText for other built-ins
27
+ // Always create both. contentBox is used for tools with renderer-based call/result composition.
28
+ // contentText is reserved for generic fallback rendering when no tool definition exists.
73
29
  this.contentBox = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
74
30
  this.contentText = new Text("", 1, 1, (text) => theme.bg("toolPendingBg", text));
75
- // Use contentBox for bash (visual truncation) or custom tools with custom renderers
76
- // Use contentText for built-in tools (including overrides without custom renderers)
77
- if (toolName === "bash" || (toolDefinition && !this.shouldUseBuiltInRenderer())) {
31
+ if (this.hasRendererDefinition()) {
78
32
  this.addChild(this.contentBox);
79
33
  }
80
34
  else {
@@ -82,217 +36,96 @@ export class ToolExecutionComponent extends Container {
82
36
  }
83
37
  this.updateDisplay();
84
38
  }
85
- /**
86
- * Check if we should use built-in rendering for this tool.
87
- * Returns true if the tool name is a built-in AND either there's no toolDefinition
88
- * or the toolDefinition doesn't provide custom renderers.
89
- */
90
- shouldUseBuiltInRenderer() {
91
- const isBuiltInName = this.toolName in allTools;
92
- const hasCustomRenderers = this.toolDefinition?.renderCall || this.toolDefinition?.renderResult;
93
- return isBuiltInName && !hasCustomRenderers;
39
+ isBuiltInDefinition(definition) {
40
+ return (definition !== undefined &&
41
+ this.builtInToolDefinition !== undefined &&
42
+ definition.parameters === this.builtInToolDefinition.parameters);
94
43
  }
95
- updateArgs(args) {
96
- this.args = args;
97
- if (this.toolName === "write" && this.isPartial) {
98
- this.updateWriteHighlightCacheIncremental();
44
+ getCallRenderer() {
45
+ if (!this.builtInToolDefinition) {
46
+ return this.toolDefinition?.renderCall;
99
47
  }
100
- this.updateDisplay();
101
- }
102
- markExecutionStarted() {
103
- if (this.toolName !== "bash" || this.bashStartedAt !== undefined)
104
- return;
105
- this.bashStartedAt = Date.now();
106
- this.ensureBashElapsedTimer();
107
- this.updateDisplay();
108
- this.ui.requestRender();
109
- }
110
- ensureBashElapsedTimer() {
111
- if (this.toolName !== "bash" || !this.isPartial || this.bashStartedAt === undefined || this.bashElapsedInterval)
112
- return;
113
- this.bashElapsedInterval = setInterval(() => {
114
- this.updateDisplay();
115
- this.ui.requestRender();
116
- }, 1000);
48
+ if (!this.toolDefinition || this.isBuiltInDefinition(this.toolDefinition)) {
49
+ return this.builtInToolDefinition.renderCall;
50
+ }
51
+ return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall;
117
52
  }
118
- stopBashElapsedTimer() {
119
- if (!this.bashElapsedInterval)
120
- return;
121
- clearInterval(this.bashElapsedInterval);
122
- this.bashElapsedInterval = undefined;
53
+ getResultRenderer() {
54
+ if (!this.builtInToolDefinition) {
55
+ return this.toolDefinition?.renderResult;
56
+ }
57
+ if (!this.toolDefinition || this.isBuiltInDefinition(this.toolDefinition)) {
58
+ return this.builtInToolDefinition.renderResult;
59
+ }
60
+ return this.toolDefinition.renderResult ?? this.builtInToolDefinition.renderResult;
123
61
  }
124
- getBashDurationMs() {
125
- if (this.toolName !== "bash" || this.bashStartedAt === undefined)
126
- return undefined;
127
- return Date.now() - this.bashStartedAt;
62
+ hasRendererDefinition() {
63
+ return this.builtInToolDefinition !== undefined || this.toolDefinition !== undefined;
128
64
  }
129
- formatDuration(ms) {
130
- return `${(ms / 1000).toFixed(1)}s`;
65
+ getRenderContext(lastComponent) {
66
+ return {
67
+ args: this.args,
68
+ toolCallId: this.toolCallId,
69
+ invalidate: () => {
70
+ this.invalidate();
71
+ this.ui.requestRender();
72
+ },
73
+ lastComponent,
74
+ state: this.rendererState,
75
+ cwd: this.cwd,
76
+ executionStarted: this.executionStarted,
77
+ argsComplete: this.argsComplete,
78
+ isPartial: this.isPartial,
79
+ expanded: this.expanded,
80
+ showImages: this.showImages,
81
+ isError: this.result?.isError ?? false,
82
+ };
131
83
  }
132
- highlightSingleLine(line, lang) {
133
- const highlighted = highlightCode(line, lang);
134
- return highlighted[0] ?? "";
84
+ createCallFallback() {
85
+ return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0);
135
86
  }
136
- refreshWriteHighlightPrefix(cache) {
137
- const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
138
- if (prefixCount === 0)
139
- return;
140
- const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
141
- const prefixHighlighted = highlightCode(prefixSource, cache.lang);
142
- for (let i = 0; i < prefixCount; i++) {
143
- cache.highlightedLines[i] =
144
- prefixHighlighted[i] ?? this.highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
87
+ createResultFallback() {
88
+ const output = this.getTextOutput();
89
+ if (!output) {
90
+ return undefined;
145
91
  }
92
+ return new Text(theme.fg("toolOutput", output), 0, 0);
146
93
  }
147
- rebuildWriteHighlightCacheFull(rawPath, fileContent) {
148
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
149
- if (!lang) {
150
- this.writeHighlightCache = undefined;
151
- return;
152
- }
153
- const displayContent = normalizeDisplayText(fileContent);
154
- const normalized = replaceTabs(displayContent);
155
- this.writeHighlightCache = {
156
- rawPath,
157
- lang,
158
- rawContent: fileContent,
159
- normalizedLines: normalized.split("\n"),
160
- highlightedLines: highlightCode(normalized, lang),
161
- };
94
+ updateArgs(args) {
95
+ this.args = args;
96
+ this.updateDisplay();
162
97
  }
163
- updateWriteHighlightCacheIncremental() {
164
- const rawPath = str(this.args?.file_path ?? this.args?.path);
165
- const fileContent = str(this.args?.content);
166
- if (rawPath === null || fileContent === null) {
167
- this.writeHighlightCache = undefined;
168
- return;
169
- }
170
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
171
- if (!lang) {
172
- this.writeHighlightCache = undefined;
173
- return;
174
- }
175
- if (!this.writeHighlightCache) {
176
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
177
- return;
178
- }
179
- const cache = this.writeHighlightCache;
180
- if (cache.lang !== lang || cache.rawPath !== rawPath) {
181
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
182
- return;
183
- }
184
- if (!fileContent.startsWith(cache.rawContent)) {
185
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
186
- return;
187
- }
188
- if (fileContent.length === cache.rawContent.length) {
189
- return;
190
- }
191
- const deltaRaw = fileContent.slice(cache.rawContent.length);
192
- const deltaDisplay = normalizeDisplayText(deltaRaw);
193
- const deltaNormalized = replaceTabs(deltaDisplay);
194
- cache.rawContent = fileContent;
195
- if (cache.normalizedLines.length === 0) {
196
- cache.normalizedLines.push("");
197
- cache.highlightedLines.push("");
198
- }
199
- const segments = deltaNormalized.split("\n");
200
- const lastIndex = cache.normalizedLines.length - 1;
201
- cache.normalizedLines[lastIndex] += segments[0];
202
- cache.highlightedLines[lastIndex] = this.highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
203
- for (let i = 1; i < segments.length; i++) {
204
- cache.normalizedLines.push(segments[i]);
205
- cache.highlightedLines.push(this.highlightSingleLine(segments[i], cache.lang));
206
- }
207
- this.refreshWriteHighlightPrefix(cache);
98
+ markExecutionStarted() {
99
+ this.executionStarted = true;
100
+ this.updateDisplay();
101
+ this.ui.requestRender();
208
102
  }
209
- /**
210
- * Signal that args are complete (tool is about to execute).
211
- * This triggers diff computation for edit tool.
212
- */
213
103
  setArgsComplete() {
214
- if (this.toolName === "write") {
215
- const rawPath = str(this.args?.file_path ?? this.args?.path);
216
- const fileContent = str(this.args?.content);
217
- if (rawPath !== null && fileContent !== null) {
218
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
219
- }
220
- }
221
- this.maybeComputeEditDiff();
222
- }
223
- /**
224
- * Compute edit diff preview when we have complete args.
225
- * This runs async and updates display when done.
226
- */
227
- maybeComputeEditDiff() {
228
- if (this.toolName !== "edit")
229
- return;
230
- const path = this.args?.path;
231
- const oldText = this.args?.oldText;
232
- const newText = this.args?.newText;
233
- // Need all three params to compute diff
234
- if (!path || oldText === undefined || newText === undefined)
235
- return;
236
- // Create a key to track which args this computation is for
237
- const argsKey = JSON.stringify({ path, oldText, newText });
238
- // Skip if we already computed for these exact args
239
- if (this.editDiffArgsKey === argsKey)
240
- return;
241
- this.editDiffArgsKey = argsKey;
242
- // Compute diff async
243
- computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
244
- // Only update if args haven't changed since we started
245
- if (this.editDiffArgsKey === argsKey) {
246
- this.editDiffPreview = result;
247
- this.updateDisplay();
248
- this.ui.requestRender();
249
- }
250
- });
104
+ this.argsComplete = true;
105
+ this.updateDisplay();
106
+ this.ui.requestRender();
251
107
  }
252
108
  updateResult(result, isPartial = false) {
253
109
  this.result = result;
254
110
  this.isPartial = isPartial;
255
- if (this.toolName === "bash") {
256
- if (isPartial) {
257
- this.ensureBashElapsedTimer();
258
- }
259
- else {
260
- this.stopBashElapsedTimer();
261
- }
262
- }
263
- if (this.toolName === "write" && !isPartial) {
264
- const rawPath = str(this.args?.file_path ?? this.args?.path);
265
- const fileContent = str(this.args?.content);
266
- if (rawPath !== null && fileContent !== null) {
267
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
268
- }
269
- }
270
111
  this.updateDisplay();
271
- // Convert non-PNG images to PNG for Kitty protocol (async)
272
112
  this.maybeConvertImagesForKitty();
273
113
  }
274
- /**
275
- * Convert non-PNG images to PNG for Kitty graphics protocol.
276
- * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
277
- */
278
114
  maybeConvertImagesForKitty() {
279
115
  const caps = getCapabilities();
280
- // Only needed for Kitty protocol
281
116
  if (caps.images !== "kitty")
282
117
  return;
283
118
  if (!this.result)
284
119
  return;
285
- const imageBlocks = this.result.content?.filter((c) => c.type === "image") || [];
120
+ const imageBlocks = this.result.content.filter((c) => c.type === "image");
286
121
  for (let i = 0; i < imageBlocks.length; i++) {
287
122
  const img = imageBlocks[i];
288
123
  if (!img.data || !img.mimeType)
289
124
  continue;
290
- // Skip if already PNG or already converted
291
125
  if (img.mimeType === "image/png")
292
126
  continue;
293
127
  if (this.convertedImages.has(i))
294
128
  continue;
295
- // Convert async
296
129
  const index = i;
297
130
  convertToPng(img.data, img.mimeType).then((converted) => {
298
131
  if (converted) {
@@ -322,86 +155,66 @@ export class ToolExecutionComponent extends Container {
322
155
  return super.render(width);
323
156
  }
324
157
  updateDisplay() {
325
- // Set background based on state
326
158
  const bgFn = this.isPartial
327
159
  ? (text) => theme.bg("toolPendingBg", text)
328
160
  : this.result?.isError
329
161
  ? (text) => theme.bg("toolErrorBg", text)
330
162
  : (text) => theme.bg("toolSuccessBg", text);
331
- const useBuiltInRenderer = this.shouldUseBuiltInRenderer();
332
- let customRendererHasContent = false;
163
+ let hasContent = false;
333
164
  this.hideComponent = false;
334
- // Use built-in rendering for built-in tools (or overrides without custom renderers)
335
- if (useBuiltInRenderer) {
336
- if (this.toolName === "bash") {
337
- // Bash uses Box with visual line truncation
338
- this.contentBox.setBgFn(bgFn);
339
- this.contentBox.clear();
340
- this.renderBashContent();
341
- }
342
- else {
343
- // Other built-in tools: use Text directly with caching
344
- this.contentText.setCustomBgFn(bgFn);
345
- this.contentText.setText(this.formatToolExecution());
346
- }
347
- }
348
- else if (this.toolDefinition) {
349
- // Custom tools use Box for flexible component rendering
165
+ if (this.hasRendererDefinition()) {
350
166
  this.contentBox.setBgFn(bgFn);
351
167
  this.contentBox.clear();
352
- // Render call component
353
- if (this.toolDefinition.renderCall) {
168
+ const callRenderer = this.getCallRenderer();
169
+ if (!callRenderer) {
170
+ this.contentBox.addChild(this.createCallFallback());
171
+ hasContent = true;
172
+ }
173
+ else {
354
174
  try {
355
- const callComponent = this.toolDefinition.renderCall(this.args, theme);
356
- if (callComponent !== undefined) {
357
- this.contentBox.addChild(callComponent);
358
- customRendererHasContent = true;
359
- }
175
+ const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent));
176
+ this.callRendererComponent = component;
177
+ this.contentBox.addChild(component);
178
+ hasContent = true;
360
179
  }
361
180
  catch {
362
- // Fall back to default on error
363
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
364
- customRendererHasContent = true;
181
+ this.callRendererComponent = undefined;
182
+ this.contentBox.addChild(this.createCallFallback());
183
+ hasContent = true;
365
184
  }
366
185
  }
367
- else {
368
- // No custom renderCall, show tool name
369
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
370
- customRendererHasContent = true;
371
- }
372
- // Render result component if we have a result
373
- if (this.result && this.toolDefinition.renderResult) {
374
- try {
375
- const resultComponent = this.toolDefinition.renderResult({ content: this.result.content, details: this.result.details }, { expanded: this.expanded, isPartial: this.isPartial }, theme);
376
- if (resultComponent !== undefined) {
377
- this.contentBox.addChild(resultComponent);
378
- customRendererHasContent = true;
186
+ if (this.result) {
187
+ const resultRenderer = this.getResultRenderer();
188
+ if (!resultRenderer) {
189
+ const component = this.createResultFallback();
190
+ if (component) {
191
+ this.contentBox.addChild(component);
192
+ hasContent = true;
379
193
  }
380
194
  }
381
- catch {
382
- // Fall back to showing raw output on error
383
- const output = this.getTextOutput();
384
- if (output) {
385
- this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
386
- customRendererHasContent = true;
195
+ else {
196
+ try {
197
+ const component = resultRenderer({ content: this.result.content, details: this.result.details }, { expanded: this.expanded, isPartial: this.isPartial }, theme, this.getRenderContext(this.resultRendererComponent));
198
+ this.resultRendererComponent = component;
199
+ this.contentBox.addChild(component);
200
+ hasContent = true;
201
+ }
202
+ catch {
203
+ this.resultRendererComponent = undefined;
204
+ const component = this.createResultFallback();
205
+ if (component) {
206
+ this.contentBox.addChild(component);
207
+ hasContent = true;
208
+ }
387
209
  }
388
- }
389
- }
390
- else if (this.result) {
391
- // Has result but no custom renderResult
392
- const output = this.getTextOutput();
393
- if (output) {
394
- this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
395
- customRendererHasContent = true;
396
210
  }
397
211
  }
398
212
  }
399
213
  else {
400
- // Unknown tool with no registered definition - show generic fallback
401
214
  this.contentText.setCustomBgFn(bgFn);
402
215
  this.contentText.setText(this.formatToolExecution());
216
+ hasContent = true;
403
217
  }
404
- // Handle images (same for both custom and built-in)
405
218
  for (const img of this.imageComponents) {
406
219
  this.removeChild(img);
407
220
  }
@@ -411,19 +224,16 @@ export class ToolExecutionComponent extends Container {
411
224
  }
412
225
  this.imageSpacers = [];
413
226
  if (this.result) {
414
- const imageBlocks = this.result.content?.filter((c) => c.type === "image") || [];
227
+ const imageBlocks = this.result.content.filter((c) => c.type === "image");
415
228
  const caps = getCapabilities();
416
229
  for (let i = 0; i < imageBlocks.length; i++) {
417
230
  const img = imageBlocks[i];
418
231
  if (caps.images && this.showImages && img.data && img.mimeType) {
419
- // Use converted PNG for Kitty protocol if available
420
232
  const converted = this.convertedImages.get(i);
421
233
  const imageData = converted?.data ?? img.data;
422
234
  const imageMimeType = converted?.mimeType ?? img.mimeType;
423
- // For Kitty, skip non-PNG images that haven't been converted yet
424
- if (caps.images === "kitty" && imageMimeType !== "image/png") {
235
+ if (caps.images === "kitty" && imageMimeType !== "image/png")
425
236
  continue;
426
- }
427
237
  const spacer = new Spacer(1);
428
238
  this.addChild(spacer);
429
239
  this.imageSpacers.push(spacer);
@@ -433,382 +243,22 @@ export class ToolExecutionComponent extends Container {
433
243
  }
434
244
  }
435
245
  }
436
- if (!useBuiltInRenderer && this.toolDefinition) {
437
- this.hideComponent = !customRendererHasContent && this.imageComponents.length === 0;
438
- }
439
- }
440
- /**
441
- * Render bash content using visual line truncation (like bash-execution.ts)
442
- */
443
- renderBashContent() {
444
- const command = str(this.args?.command);
445
- const timeout = this.args?.timeout;
446
- // Header
447
- const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
448
- const commandDisplay = command === null ? theme.fg("error", "[invalid arg]") : command ? command : theme.fg("toolOutput", "...");
449
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix, 0, 0));
450
- if (this.result) {
451
- const output = this.getTextOutput().trim();
452
- if (output) {
453
- // Style each line for the output
454
- const styledOutput = output
455
- .split("\n")
456
- .map((line) => theme.fg("toolOutput", line))
457
- .join("\n");
458
- if (this.expanded) {
459
- // Show all lines when expanded
460
- this.contentBox.addChild(new Text(`\n${styledOutput}`, 0, 0));
461
- }
462
- else {
463
- // Use visual line truncation when collapsed with width-aware caching
464
- let cachedWidth;
465
- let cachedLines;
466
- let cachedSkipped;
467
- this.contentBox.addChild({
468
- render: (width) => {
469
- if (cachedLines === undefined || cachedWidth !== width) {
470
- const result = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
471
- cachedLines = result.visualLines;
472
- cachedSkipped = result.skippedCount;
473
- cachedWidth = width;
474
- }
475
- if (cachedSkipped && cachedSkipped > 0) {
476
- const hint = theme.fg("muted", `... (${cachedSkipped} earlier lines,`) +
477
- ` ${keyHint("app.tools.expand", "to expand")})`;
478
- return ["", truncateToWidth(hint, width, "..."), ...cachedLines];
479
- }
480
- // Add blank line for spacing (matches expanded case)
481
- return ["", ...cachedLines];
482
- },
483
- invalidate: () => {
484
- cachedWidth = undefined;
485
- cachedLines = undefined;
486
- cachedSkipped = undefined;
487
- },
488
- });
489
- }
490
- }
491
- // Truncation warnings
492
- const truncation = this.result.details?.truncation;
493
- const fullOutputPath = this.result.details?.fullOutputPath;
494
- if (truncation?.truncated || fullOutputPath) {
495
- const warnings = [];
496
- if (fullOutputPath) {
497
- warnings.push(`Full output: ${fullOutputPath}`);
498
- }
499
- if (truncation?.truncated) {
500
- if (truncation.truncatedBy === "lines") {
501
- warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
502
- }
503
- else {
504
- warnings.push(`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`);
505
- }
506
- }
507
- this.contentBox.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
508
- }
509
- }
510
- const bashDurationMs = this.getBashDurationMs();
511
- if (bashDurationMs !== undefined) {
512
- const label = this.isPartial ? "Elapsed" : "Took";
513
- this.contentBox.addChild(new Text(`\n${theme.fg("muted", `${label} ${this.formatDuration(bashDurationMs)}`)}`, 0, 0));
246
+ if (this.hasRendererDefinition() && !hasContent && this.imageComponents.length === 0) {
247
+ this.hideComponent = true;
514
248
  }
515
249
  }
516
250
  getTextOutput() {
517
- if (!this.result)
518
- return "";
519
- const textBlocks = this.result.content?.filter((c) => c.type === "text") || [];
520
- const imageBlocks = this.result.content?.filter((c) => c.type === "image") || [];
521
- let output = textBlocks
522
- .map((c) => {
523
- // Use sanitizeBinaryOutput to handle binary data that crashes string-width
524
- return sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "");
525
- })
526
- .join("\n");
527
- const caps = getCapabilities();
528
- if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
529
- const imageIndicators = imageBlocks
530
- .map((img) => {
531
- const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
532
- return imageFallback(img.mimeType, dims);
533
- })
534
- .join("\n");
535
- output = output ? `${output}\n${imageIndicators}` : imageIndicators;
536
- }
537
- return output;
251
+ return getRenderedTextOutput(this.result, this.showImages);
538
252
  }
539
253
  formatToolExecution() {
540
- let text = "";
541
- const invalidArg = theme.fg("error", "[invalid arg]");
542
- if (this.toolName === "read") {
543
- const rawPath = str(this.args?.file_path ?? this.args?.path);
544
- const path = rawPath !== null ? shortenPath(rawPath) : null;
545
- const offset = this.args?.offset;
546
- const limit = this.args?.limit;
547
- let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
548
- if (offset !== undefined || limit !== undefined) {
549
- const startLine = offset ?? 1;
550
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
551
- pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
552
- }
553
- text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
554
- if (this.result) {
555
- const output = this.getTextOutput();
556
- const rawPath = str(this.args?.file_path ?? this.args?.path);
557
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
558
- const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
559
- const maxLines = this.expanded ? lines.length : 10;
560
- const displayLines = lines.slice(0, maxLines);
561
- const remaining = lines.length - maxLines;
562
- text +=
563
- "\n\n" +
564
- displayLines
565
- .map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
566
- .join("\n");
567
- if (remaining > 0) {
568
- text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
569
- }
570
- const truncation = this.result.details?.truncation;
571
- if (truncation?.truncated) {
572
- if (truncation.firstLineExceedsLimit) {
573
- text +=
574
- "\n" +
575
- theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`);
576
- }
577
- else if (truncation.truncatedBy === "lines") {
578
- text +=
579
- "\n" +
580
- theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`);
581
- }
582
- else {
583
- text +=
584
- "\n" +
585
- theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`);
586
- }
587
- }
588
- }
589
- }
590
- else if (this.toolName === "write") {
591
- const rawPath = str(this.args?.file_path ?? this.args?.path);
592
- const fileContent = str(this.args?.content);
593
- const path = rawPath !== null ? shortenPath(rawPath) : null;
594
- text =
595
- theme.fg("toolTitle", theme.bold("write")) +
596
- " " +
597
- (path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
598
- if (fileContent === null) {
599
- text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
600
- }
601
- else if (fileContent) {
602
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
603
- let lines;
604
- if (lang) {
605
- const cache = this.writeHighlightCache;
606
- if (cache && cache.lang === lang && cache.rawPath === rawPath && cache.rawContent === fileContent) {
607
- lines = cache.highlightedLines;
608
- }
609
- else {
610
- const displayContent = normalizeDisplayText(fileContent);
611
- const normalized = replaceTabs(displayContent);
612
- lines = highlightCode(normalized, lang);
613
- this.writeHighlightCache = {
614
- rawPath,
615
- lang,
616
- rawContent: fileContent,
617
- normalizedLines: normalized.split("\n"),
618
- highlightedLines: lines,
619
- };
620
- }
621
- }
622
- else {
623
- lines = normalizeDisplayText(fileContent).split("\n");
624
- this.writeHighlightCache = undefined;
625
- }
626
- const totalLines = lines.length;
627
- const maxLines = this.expanded ? lines.length : 10;
628
- const displayLines = lines.slice(0, maxLines);
629
- const remaining = lines.length - maxLines;
630
- text +=
631
- "\n\n" +
632
- displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n");
633
- if (remaining > 0) {
634
- text +=
635
- theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`) +
636
- ` ${keyHint("app.tools.expand", "to expand")})`;
637
- }
638
- }
639
- // Show error if tool execution failed
640
- if (this.result?.isError) {
641
- const errorText = this.getTextOutput();
642
- if (errorText) {
643
- text += `\n\n${theme.fg("error", errorText)}`;
644
- }
645
- }
646
- }
647
- else if (this.toolName === "edit") {
648
- const rawPath = str(this.args?.file_path ?? this.args?.path);
649
- const path = rawPath !== null ? shortenPath(rawPath) : null;
650
- // Build path display, appending :line if we have diff info
651
- let pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
652
- const firstChangedLine = (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
653
- ? this.editDiffPreview.firstChangedLine
654
- : undefined) ||
655
- (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
656
- if (firstChangedLine) {
657
- pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
658
- }
659
- text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
660
- if (this.result?.isError) {
661
- // Show error from result
662
- const errorText = this.getTextOutput();
663
- if (errorText) {
664
- text += `\n\n${theme.fg("error", errorText)}`;
665
- }
666
- }
667
- else if (this.result?.details?.diff) {
668
- // Tool executed successfully - use the diff from result
669
- // This takes priority over editDiffPreview which may have a stale error
670
- // due to race condition (async preview computed after file was modified)
671
- text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath ?? undefined })}`;
672
- }
673
- else if (this.editDiffPreview) {
674
- // Use cached diff preview (before tool executes)
675
- if ("error" in this.editDiffPreview) {
676
- text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
677
- }
678
- else if (this.editDiffPreview.diff) {
679
- text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath ?? undefined })}`;
680
- }
681
- }
682
- }
683
- else if (this.toolName === "ls") {
684
- const rawPath = str(this.args?.path);
685
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
686
- const limit = this.args?.limit;
687
- text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`;
688
- if (limit !== undefined) {
689
- text += theme.fg("toolOutput", ` (limit ${limit})`);
690
- }
691
- if (this.result) {
692
- const output = this.getTextOutput().trim();
693
- if (output) {
694
- const lines = output.split("\n");
695
- const maxLines = this.expanded ? lines.length : 20;
696
- const displayLines = lines.slice(0, maxLines);
697
- const remaining = lines.length - maxLines;
698
- text += `\n\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
699
- if (remaining > 0) {
700
- text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
701
- }
702
- }
703
- const entryLimit = this.result.details?.entryLimitReached;
704
- const truncation = this.result.details?.truncation;
705
- if (entryLimit || truncation?.truncated) {
706
- const warnings = [];
707
- if (entryLimit) {
708
- warnings.push(`${entryLimit} entries limit`);
709
- }
710
- if (truncation?.truncated) {
711
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
712
- }
713
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
714
- }
715
- }
716
- }
717
- else if (this.toolName === "find") {
718
- const pattern = str(this.args?.pattern);
719
- const rawPath = str(this.args?.path);
720
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
721
- const limit = this.args?.limit;
722
- text =
723
- theme.fg("toolTitle", theme.bold("find")) +
724
- " " +
725
- (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
726
- theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
727
- if (limit !== undefined) {
728
- text += theme.fg("toolOutput", ` (limit ${limit})`);
729
- }
730
- if (this.result) {
731
- const output = this.getTextOutput().trim();
732
- if (output) {
733
- const lines = output.split("\n");
734
- const maxLines = this.expanded ? lines.length : 20;
735
- const displayLines = lines.slice(0, maxLines);
736
- const remaining = lines.length - maxLines;
737
- text += `\n\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
738
- if (remaining > 0) {
739
- text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
740
- }
741
- }
742
- const resultLimit = this.result.details?.resultLimitReached;
743
- const truncation = this.result.details?.truncation;
744
- if (resultLimit || truncation?.truncated) {
745
- const warnings = [];
746
- if (resultLimit) {
747
- warnings.push(`${resultLimit} results limit`);
748
- }
749
- if (truncation?.truncated) {
750
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
751
- }
752
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
753
- }
754
- }
755
- }
756
- else if (this.toolName === "grep") {
757
- const pattern = str(this.args?.pattern);
758
- const rawPath = str(this.args?.path);
759
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
760
- const glob = str(this.args?.glob);
761
- const limit = this.args?.limit;
762
- text =
763
- theme.fg("toolTitle", theme.bold("grep")) +
764
- " " +
765
- (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
766
- theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
767
- if (glob) {
768
- text += theme.fg("toolOutput", ` (${glob})`);
769
- }
770
- if (limit !== undefined) {
771
- text += theme.fg("toolOutput", ` limit ${limit}`);
772
- }
773
- if (this.result) {
774
- const output = this.getTextOutput().trim();
775
- if (output) {
776
- const lines = output.split("\n");
777
- const maxLines = this.expanded ? lines.length : 15;
778
- const displayLines = lines.slice(0, maxLines);
779
- const remaining = lines.length - maxLines;
780
- text += `\n\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
781
- if (remaining > 0) {
782
- text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
783
- }
784
- }
785
- const matchLimit = this.result.details?.matchLimitReached;
786
- const truncation = this.result.details?.truncation;
787
- const linesTruncated = this.result.details?.linesTruncated;
788
- if (matchLimit || truncation?.truncated || linesTruncated) {
789
- const warnings = [];
790
- if (matchLimit) {
791
- warnings.push(`${matchLimit} matches limit`);
792
- }
793
- if (truncation?.truncated) {
794
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
795
- }
796
- if (linesTruncated) {
797
- warnings.push("some lines truncated");
798
- }
799
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
800
- }
801
- }
802
- }
803
- else {
804
- // Generic tool (shouldn't reach here for custom tools)
805
- text = theme.fg("toolTitle", theme.bold(this.toolName));
806
- const content = JSON.stringify(this.args, null, 2);
254
+ let text = theme.fg("toolTitle", theme.bold(this.toolName));
255
+ const content = JSON.stringify(this.args, null, 2);
256
+ if (content) {
807
257
  text += `\n\n${content}`;
808
- const output = this.getTextOutput();
809
- if (output) {
810
- text += `\n${output}`;
811
- }
258
+ }
259
+ const output = this.getTextOutput();
260
+ if (output) {
261
+ text += `\n${output}`;
812
262
  }
813
263
  return text;
814
264
  }