lsd-pi 1.3.2 → 1.3.7

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 (205) hide show
  1. package/dist/cli.js +2 -1
  2. package/dist/lsd-settings-manager.d.ts +2 -0
  3. package/dist/lsd-settings-manager.js +5 -0
  4. package/dist/resource-loader.js +33 -3
  5. package/dist/resources/extensions/browser-tools/tools/codegen.js +5 -5
  6. package/dist/resources/extensions/browser-tools/tools/navigation.js +107 -178
  7. package/dist/resources/extensions/browser-tools/tools/network-mock.js +112 -167
  8. package/dist/resources/extensions/browser-tools/tools/pages.js +182 -234
  9. package/dist/resources/extensions/browser-tools/tools/refs.js +202 -461
  10. package/dist/resources/extensions/browser-tools/tools/session.js +176 -323
  11. package/dist/resources/extensions/browser-tools/tools/state-persistence.js +91 -154
  12. package/dist/resources/extensions/browser-tools/utils.js +1 -1
  13. package/dist/resources/extensions/cache-timer/index.js +3 -2
  14. package/dist/resources/extensions/slash-commands/extension-manifest.json +2 -2
  15. package/dist/resources/extensions/slash-commands/fast.js +73 -0
  16. package/dist/resources/extensions/slash-commands/index.js +2 -0
  17. package/dist/resources/extensions/slash-commands/plan.js +37 -12
  18. package/dist/resources/extensions/subagent/background-job-manager.js +13 -0
  19. package/dist/resources/extensions/subagent/in-process-runner.js +387 -0
  20. package/dist/resources/extensions/subagent/index.js +278 -626
  21. package/dist/resources/extensions/subagent/legacy-runner.js +503 -0
  22. package/dist/resources/extensions/voice/index.js +96 -36
  23. package/dist/resources/extensions/voice/push-to-talk.js +26 -0
  24. package/dist/welcome-screen.js +2 -2
  25. package/package.json +1 -1
  26. package/packages/pi-agent-core/dist/agent.d.ts +19 -0
  27. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  28. package/packages/pi-agent-core/dist/agent.js +16 -0
  29. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  30. package/packages/pi-agent-core/src/agent.ts +32 -2
  31. package/packages/pi-ai/dist/providers/openai-codex-responses.d.ts +34 -1
  32. package/packages/pi-ai/dist/providers/openai-codex-responses.d.ts.map +1 -1
  33. package/packages/pi-ai/dist/providers/openai-codex-responses.js +32 -4
  34. package/packages/pi-ai/dist/providers/openai-codex-responses.js.map +1 -1
  35. package/packages/pi-ai/dist/providers/openai-codex-responses.test.js +127 -16
  36. package/packages/pi-ai/dist/providers/openai-codex-responses.test.js.map +1 -1
  37. package/packages/pi-ai/dist/providers/openai-responses.d.ts +8 -1
  38. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  39. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.d.ts +2 -0
  40. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.d.ts.map +1 -0
  41. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.js +67 -0
  42. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.js.map +1 -0
  43. package/packages/pi-ai/dist/providers/openai-responses.js +21 -3
  44. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  45. package/packages/pi-ai/dist/providers/simple-options.d.ts.map +1 -1
  46. package/packages/pi-ai/dist/providers/simple-options.js +2 -0
  47. package/packages/pi-ai/dist/providers/simple-options.js.map +1 -1
  48. package/packages/pi-ai/dist/types.d.ts +5 -0
  49. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  50. package/packages/pi-ai/dist/types.js.map +1 -1
  51. package/packages/pi-ai/src/providers/openai-codex-responses.test.ts +143 -20
  52. package/packages/pi-ai/src/providers/openai-codex-responses.ts +47 -4
  53. package/packages/pi-ai/src/providers/openai-responses.fast-mode.test.ts +73 -0
  54. package/packages/pi-ai/src/providers/openai-responses.ts +26 -3
  55. package/packages/pi-ai/src/providers/simple-options.ts +2 -0
  56. package/packages/pi-ai/src/types.ts +5 -0
  57. package/packages/pi-coding-agent/dist/core/keybindings.d.ts +1 -1
  58. package/packages/pi-coding-agent/dist/core/keybindings.d.ts.map +1 -1
  59. package/packages/pi-coding-agent/dist/core/keybindings.js +2 -0
  60. package/packages/pi-coding-agent/dist/core/keybindings.js.map +1 -1
  61. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  62. package/packages/pi-coding-agent/dist/core/sdk.js +4 -2
  63. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.d.ts +2 -0
  65. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.d.ts.map +1 -0
  66. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.js +35 -0
  67. package/packages/pi-coding-agent/dist/core/settings-manager.collapse-tool-calls.test.js.map +1 -0
  68. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +12 -0
  69. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.d.ts +2 -0
  71. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.d.ts.map +1 -0
  72. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.js +35 -0
  73. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.js.map +1 -0
  74. package/packages/pi-coding-agent/dist/core/settings-manager.js +24 -0
  75. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  76. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  78. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  80. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -1
  81. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/tool-priority.d.ts +4 -0
  83. package/packages/pi-coding-agent/dist/core/tool-priority.d.ts.map +1 -0
  84. package/packages/pi-coding-agent/dist/core/tool-priority.js +18 -0
  85. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -0
  86. package/packages/pi-coding-agent/dist/core/tool-priority.test.d.ts +2 -0
  87. package/packages/pi-coding-agent/dist/core/tool-priority.test.d.ts.map +1 -0
  88. package/packages/pi-coding-agent/dist/core/tool-priority.test.js +27 -0
  89. package/packages/pi-coding-agent/dist/core/tool-priority.test.js.map +1 -0
  90. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +5 -0
  91. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +21 -0
  93. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  94. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +16 -1
  95. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.d.ts +2 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.d.ts.map +1 -0
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +34 -0
  99. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -0
  100. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.d.ts +45 -0
  101. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.d.ts.map +1 -0
  102. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.js +314 -0
  103. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.js.map +1 -0
  104. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.d.ts +2 -0
  105. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.d.ts.map +1 -0
  106. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.js +122 -0
  107. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.js.map +1 -0
  108. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.d.ts +7 -5
  109. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.d.ts.map +1 -1
  110. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.js +86 -28
  111. package/packages/pi-coding-agent/dist/modes/interactive/components/diff.js.map +1 -1
  112. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts +4 -0
  113. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  114. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +23 -10
  115. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts +8 -0
  117. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js +52 -6
  119. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +19 -0
  121. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  122. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +127 -14
  123. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  124. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +14 -0
  125. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -0
  126. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +93 -0
  127. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -0
  128. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.d.ts +2 -0
  129. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.d.ts.map +1 -0
  130. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +328 -0
  131. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -0
  132. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  133. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +123 -0
  134. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  135. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -1
  136. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  137. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts.map +1 -1
  138. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js +7 -0
  139. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js.map +1 -1
  140. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
  141. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  142. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  143. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +9 -1
  144. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  145. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +103 -23
  146. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  147. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.d.ts.map +1 -1
  148. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js +41 -0
  149. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js.map +1 -1
  150. package/packages/pi-coding-agent/dist/modes/interactive/theme/themes.js +4 -4
  151. package/packages/pi-coding-agent/dist/modes/interactive/theme/themes.js.map +1 -1
  152. package/packages/pi-coding-agent/package.json +1 -1
  153. package/packages/pi-coding-agent/src/core/keybindings.ts +4 -1
  154. package/packages/pi-coding-agent/src/core/sdk.ts +4 -2
  155. package/packages/pi-coding-agent/src/core/settings-manager.collapse-tool-calls.test.ts +46 -0
  156. package/packages/pi-coding-agent/src/core/settings-manager.fast-mode.test.ts +46 -0
  157. package/packages/pi-coding-agent/src/core/settings-manager.ts +36 -0
  158. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  159. package/packages/pi-coding-agent/src/core/system-prompt.ts +6 -1
  160. package/packages/pi-coding-agent/src/core/tool-priority.test.ts +30 -0
  161. package/packages/pi-coding-agent/src/core/tool-priority.ts +17 -0
  162. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +20 -0
  163. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +26 -0
  164. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +41 -0
  165. package/packages/pi-coding-agent/src/modes/interactive/components/btw-overlay.test.ts +172 -0
  166. package/packages/pi-coding-agent/src/modes/interactive/components/btw-overlay.ts +402 -0
  167. package/packages/pi-coding-agent/src/modes/interactive/components/diff.ts +105 -28
  168. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +21 -6
  169. package/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +63 -6
  170. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +1262 -1138
  171. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +120 -0
  172. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +396 -0
  173. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +530 -398
  174. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -1
  175. package/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +7 -0
  176. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +4 -0
  177. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +109 -23
  178. package/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +60 -1
  179. package/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts +4 -4
  180. package/packages/pi-tui/dist/components/editor.js +3 -3
  181. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  182. package/packages/pi-tui/src/components/editor.ts +3 -3
  183. package/pkg/dist/modes/interactive/theme/themes.js +4 -4
  184. package/pkg/dist/modes/interactive/theme/themes.js.map +1 -1
  185. package/pkg/package.json +1 -1
  186. package/src/resources/extensions/browser-tools/tools/codegen.ts +5 -5
  187. package/src/resources/extensions/browser-tools/tools/navigation.ts +118 -196
  188. package/src/resources/extensions/browser-tools/tools/network-mock.ts +114 -205
  189. package/src/resources/extensions/browser-tools/tools/pages.ts +183 -237
  190. package/src/resources/extensions/browser-tools/tools/refs.ts +193 -507
  191. package/src/resources/extensions/browser-tools/tools/session.ts +182 -321
  192. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +94 -172
  193. package/src/resources/extensions/browser-tools/utils.ts +1 -1
  194. package/src/resources/extensions/cache-timer/index.ts +3 -2
  195. package/src/resources/extensions/slash-commands/extension-manifest.json +2 -2
  196. package/src/resources/extensions/slash-commands/fast.ts +89 -0
  197. package/src/resources/extensions/slash-commands/index.ts +2 -0
  198. package/src/resources/extensions/slash-commands/plan.ts +42 -12
  199. package/src/resources/extensions/subagent/background-job-manager.ts +28 -0
  200. package/src/resources/extensions/subagent/in-process-runner.ts +534 -0
  201. package/src/resources/extensions/subagent/index.ts +489 -799
  202. package/src/resources/extensions/subagent/legacy-runner.ts +607 -0
  203. package/src/resources/extensions/voice/index.ts +308 -238
  204. package/src/resources/extensions/voice/push-to-talk.ts +42 -0
  205. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +109 -0
@@ -1,18 +1,24 @@
1
1
  import {
2
- Box,
3
- Container,
4
- getCapabilities,
5
- Image,
6
- imageFallback,
7
- Spacer,
8
- Text,
9
- type TUI,
10
- truncateToWidth,
2
+ Box,
3
+ Container,
4
+ getCapabilities,
5
+ Image,
6
+ imageFallback,
7
+ Spacer,
8
+ Text,
9
+ type TUI,
10
+ truncateToWidth,
11
11
  } from "@gsd/pi-tui";
12
12
  import stripAnsi from "strip-ansi";
13
13
  import type { ToolDefinition } from "../../../core/extensions/types.js";
14
- import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
14
+ import {
15
+ computeEditDiff,
16
+ computeWriteDiff,
17
+ type EditDiffError,
18
+ type EditDiffResult,
19
+ } from "../../../core/tools/edit-diff.js";
15
20
  import { allTools } from "../../../core/tools/index.js";
21
+ import { shouldCollapse } from "../../../core/tool-priority.js";
16
22
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
17
23
  import { convertToPng } from "../../../utils/image-convert.js";
18
24
  import { sanitizeBinaryOutput } from "../../../utils/shell.js";
@@ -20,7 +26,7 @@ import { renderTerminalText } from "../../../utils/terminal-serializer.js";
20
26
  import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
21
27
  import { type EditorScheme, editorLink } from "../utils/editor-link.js";
22
28
  import { shortenPath } from "../utils/shorten-path.js";
23
- import { renderDiff } from "./diff.js";
29
+ import { renderDiff, renderDiffLines } from "./diff.js";
24
30
  import { keyHint } from "./keybinding-hints.js";
25
31
  import { truncateToVisualLines } from "./visual-truncate.js";
26
32
 
@@ -41,7 +47,7 @@ const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
41
47
  * Replace tabs with spaces for consistent rendering
42
48
  */
43
49
  function replaceTabs(text: string): string {
44
- return text.replace(/\t/g, " ");
50
+ return text.replace(/\t/g, " ");
45
51
  }
46
52
 
47
53
  /**
@@ -49,14 +55,14 @@ function replaceTabs(text: string): string {
49
55
  * Keep tool arguments unchanged, sanitize only display text.
50
56
  */
51
57
  function normalizeDisplayText(text: string): string {
52
- return text.replace(/\r/g, "");
58
+ return text.replace(/\r/g, "");
53
59
  }
54
60
 
55
61
  /** Safely coerce value to string for display. Returns null if invalid type. */
56
62
  function str(value: unknown): string | null {
57
- if (typeof value === "string") return value;
58
- if (value == null) return "";
59
- return null; // Invalid type
63
+ if (typeof value === "string") return value;
64
+ if (value == null) return "";
65
+ return null; // Invalid type
60
66
  }
61
67
 
62
68
  /**
@@ -64,1138 +70,1256 @@ function str(value: unknown): string | null {
64
70
  * that the agent process cannot resolve (sudo blocked, EPERM on root-owned files, etc.).
65
71
  */
66
72
  function isBashPrivilegeError(output: string, command: string): boolean {
67
- // Already handled by OS sandbox-policy path
68
- if (/sandbox blocked this operation/i.test(output)) return false;
69
- // sudo explicitly rejected by OS
70
- if (/sudo.*operation not permitted/i.test(output)) return true;
71
- if (/command exited with code 126/i.test(output) && /sudo/i.test(output)) return true;
72
- // npm / other tools hitting root-owned cache files
73
- if (/cache folder contains root-owned files/i.test(output)) return true;
74
- // Generic EPERM when command involves sudo
75
- if (/EPERM|operation not permitted/i.test(output) && /sudo/i.test(command)) return true;
76
- // Permission denied when command involves sudo
77
- if (/permission denied/i.test(output) && /sudo/i.test(command)) return true;
78
- return false;
73
+ // Already handled by OS sandbox-policy path
74
+ if (/sandbox blocked this operation/i.test(output)) return false;
75
+ // sudo explicitly rejected by OS
76
+ if (/sudo.*operation not permitted/i.test(output)) return true;
77
+ if (/command exited with code 126/i.test(output) && /sudo/i.test(output)) return true;
78
+ // npm / other tools hitting root-owned cache files
79
+ if (/cache folder contains root-owned files/i.test(output)) return true;
80
+ // Generic EPERM when command involves sudo
81
+ if (/EPERM|operation not permitted/i.test(output) && /sudo/i.test(command)) return true;
82
+ // Permission denied when command involves sudo
83
+ if (/permission denied/i.test(output) && /sudo/i.test(command)) return true;
84
+ return false;
79
85
  }
80
86
 
81
87
  export interface ToolExecutionOptions {
82
- showImages?: boolean; // default: true (only used if terminal supports images)
83
- renderMode?: "minimal" | "normal";
84
- editorScheme?: EditorScheme; // URI scheme for Cmd+click file links (default: "auto")
88
+ showImages?: boolean; // default: true (only used if terminal supports images)
89
+ renderMode?: "minimal" | "normal";
90
+ editorScheme?: EditorScheme; // URI scheme for Cmd+click file links (default: "auto")
85
91
  }
86
92
 
87
93
  type WriteHighlightCache = {
88
- rawPath: string | null;
89
- lang: string;
90
- rawContent: string;
91
- normalizedLines: string[];
92
- highlightedLines: string[];
94
+ rawPath: string | null;
95
+ lang: string;
96
+ rawContent: string;
97
+ normalizedLines: string[];
98
+ highlightedLines: string[];
93
99
  };
94
100
 
95
101
  /**
96
102
  * Component that renders a tool call with its result (updateable)
97
103
  */
98
104
  export class ToolExecutionComponent extends Container {
99
- private contentBox: Box; // Used for custom tools and bash visual truncation
100
- private contentText: Text; // For built-in tools (with its own padding/bg)
101
- private imageComponents: Image[] = [];
102
- private imageSpacers: Spacer[] = [];
103
- private toolName: string;
104
- private args: any;
105
- private expanded = false;
106
- private renderMode: "minimal" | "normal";
107
- private showImages: boolean;
108
- private editorScheme: EditorScheme;
109
- private isPartial = true;
110
- private toolDefinition?: ToolDefinition;
111
- private ui: TUI;
112
- private cwd: string;
113
- private result?: {
114
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
115
- isError: boolean;
116
- details?: any;
117
- };
118
- // Cached edit diff preview (computed when args arrive, before tool executes)
119
- private editDiffPreview?: EditDiffResult | EditDiffError;
120
- private editDiffArgsKey?: string; // Track which args the preview is for
121
- // Cached converted images for Kitty protocol (which requires PNG), keyed by index
122
- private convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
123
- // Incremental syntax highlighting cache for write tool call args
124
- private writeHighlightCache?: WriteHighlightCache;
125
- // When true, this component intentionally renders no lines
126
- private hideComponent = false;
127
-
128
- // Tool status spinner state
129
- private spinnerTimer: NodeJS.Timeout | null = null;
130
- private spinnerFrame = 0;
131
-
132
- constructor(
133
- toolName: string,
134
- args: any,
135
- options: ToolExecutionOptions = {},
136
- toolDefinition: ToolDefinition | undefined,
137
- ui: TUI,
138
- cwd: string = process.cwd(),
139
- ) {
140
- super();
141
- this.toolName = toolName;
142
- this.args = args;
143
- this.showImages = options.showImages ?? true;
144
- this.renderMode = options.renderMode ?? "normal";
145
- this.editorScheme = options.editorScheme ?? "auto";
146
- this.toolDefinition = toolDefinition;
147
- this.ui = ui;
148
- this.cwd = cwd;
149
-
150
- // Always create both - contentBox for custom tools/bash, contentText for other built-ins
151
- this.contentBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
152
- this.contentText = new Text("", 1, 0, (text: string) => theme.bg("toolPendingBg", text));
153
-
154
- // Use contentBox for bash (visual truncation) or custom tools with custom renderers
155
- // Use contentText for built-in tools (including overrides without custom renderers)
156
- if (toolName === "bash" || (toolDefinition && !this.shouldUseBuiltInRenderer())) {
157
- this.addChild(this.contentBox);
158
- } else {
159
- this.addChild(this.contentText);
160
- }
161
-
162
- this.updateDisplay();
163
- }
164
-
165
- /**
166
- * Check if we should use built-in rendering for this tool.
167
- * Returns true if the tool name is a built-in AND either there's no toolDefinition
168
- * or the toolDefinition doesn't provide custom renderers.
169
- */
170
- private shouldUseBuiltInRenderer(): boolean {
171
- const isBuiltInName = this.toolName in allTools;
172
- const hasCustomRenderers = this.toolDefinition?.renderCall || this.toolDefinition?.renderResult;
173
- return isBuiltInName && !hasCustomRenderers;
174
- }
175
-
176
- updateArgs(args: any): void {
177
- this.args = args;
178
- if (this.toolName === "write" && this.isPartial) {
179
- this.updateWriteHighlightCacheIncremental();
180
- }
181
- this.updateDisplay();
182
- }
183
-
184
- private highlightSingleLine(line: string, lang: string): string {
185
- const highlighted = highlightCode(line, lang);
186
- return highlighted[0] ?? "";
187
- }
188
-
189
- private refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {
190
- const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
191
- if (prefixCount === 0) return;
192
-
193
- const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
194
- const prefixHighlighted = highlightCode(prefixSource, cache.lang);
195
- for (let i = 0; i < prefixCount; i++) {
196
- cache.highlightedLines[i] =
197
- prefixHighlighted[i] ?? this.highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
198
- }
199
- }
200
-
201
- private rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): void {
202
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
203
- if (!lang) {
204
- this.writeHighlightCache = undefined;
205
- return;
206
- }
207
-
208
- const displayContent = normalizeDisplayText(fileContent);
209
- const normalized = replaceTabs(displayContent);
210
- this.writeHighlightCache = {
211
- rawPath,
212
- lang,
213
- rawContent: fileContent,
214
- normalizedLines: normalized.split("\n"),
215
- highlightedLines: highlightCode(normalized, lang),
216
- };
217
- }
218
-
219
- private updateWriteHighlightCacheIncremental(): void {
220
- const rawPath = str(this.args?.file_path ?? this.args?.path);
221
- const fileContent = str(this.args?.content);
222
- if (rawPath === null || fileContent === null) {
223
- this.writeHighlightCache = undefined;
224
- return;
225
- }
226
-
227
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
228
- if (!lang) {
229
- this.writeHighlightCache = undefined;
230
- return;
231
- }
232
-
233
- if (!this.writeHighlightCache) {
234
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
235
- return;
236
- }
237
-
238
- const cache = this.writeHighlightCache;
239
- if (cache.lang !== lang || cache.rawPath !== rawPath) {
240
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
241
- return;
242
- }
243
-
244
- if (!fileContent.startsWith(cache.rawContent)) {
245
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
246
- return;
247
- }
248
-
249
- if (fileContent.length === cache.rawContent.length) {
250
- return;
251
- }
252
-
253
- const deltaRaw = fileContent.slice(cache.rawContent.length);
254
- const deltaDisplay = normalizeDisplayText(deltaRaw);
255
- const deltaNormalized = replaceTabs(deltaDisplay);
256
- cache.rawContent = fileContent;
257
-
258
- if (cache.normalizedLines.length === 0) {
259
- cache.normalizedLines.push("");
260
- cache.highlightedLines.push("");
261
- }
262
-
263
- const segments = deltaNormalized.split("\n");
264
- const lastIndex = cache.normalizedLines.length - 1;
265
- cache.normalizedLines[lastIndex] += segments[0];
266
- cache.highlightedLines[lastIndex] = this.highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
267
-
268
- for (let i = 1; i < segments.length; i++) {
269
- cache.normalizedLines.push(segments[i]);
270
- cache.highlightedLines.push(this.highlightSingleLine(segments[i], cache.lang));
271
- }
272
-
273
- this.refreshWriteHighlightPrefix(cache);
274
- }
275
-
276
- /**
277
- * Signal that args are complete (tool is about to execute).
278
- * This triggers diff computation for edit tool.
279
- */
280
- setArgsComplete(): void {
281
- if (this.toolName === "write") {
282
- const rawPath = str(this.args?.file_path ?? this.args?.path);
283
- const fileContent = str(this.args?.content);
284
- if (rawPath !== null && fileContent !== null) {
285
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
286
- }
287
- }
288
- this.maybeComputeEditDiff();
289
- }
290
-
291
- /**
292
- * Compute edit diff preview when we have complete args.
293
- * This runs async and updates display when done.
294
- */
295
- private maybeComputeEditDiff(): void {
296
- if (this.toolName !== "edit") return;
297
-
298
- const path = this.args?.path;
299
- const oldText = this.args?.oldText;
300
- const newText = this.args?.newText;
301
-
302
- // Need all three params to compute diff
303
- if (!path || oldText === undefined || newText === undefined) return;
304
-
305
- // Create a key to track which args this computation is for
306
- const argsKey = JSON.stringify({ path, oldText, newText });
307
-
308
- // Skip if we already computed for these exact args
309
- if (this.editDiffArgsKey === argsKey) return;
310
-
311
- this.editDiffArgsKey = argsKey;
312
-
313
- // Compute diff async
314
- computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
315
- // Only update if args haven't changed since we started
316
- if (this.editDiffArgsKey === argsKey) {
317
- this.editDiffPreview = result;
318
- this.updateDisplay();
319
- this.ui.requestRender();
320
- }
321
- });
322
- }
323
-
324
- updateResult(
325
- result: {
326
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
327
- details?: any;
328
- isError: boolean;
329
- },
330
- isPartial = false,
331
- ): void {
332
- this.result = result;
333
- this.isPartial = isPartial;
334
-
335
- if (this.toolName === "write" && !isPartial) {
336
- const rawPath = str(this.args?.file_path ?? this.args?.path);
337
- const fileContent = str(this.args?.content);
338
- if (rawPath !== null && fileContent !== null) {
339
- this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
340
- }
341
- }
342
- this.updateDisplay();
343
- // Convert non-PNG images to PNG for Kitty protocol (async)
344
- this.maybeConvertImagesForKitty();
345
- }
346
-
347
- /**
348
- * Convert non-PNG images to PNG for Kitty graphics protocol.
349
- * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
350
- */
351
- private maybeConvertImagesForKitty(): void {
352
- const caps = getCapabilities();
353
- // Only needed for Kitty protocol
354
- if (caps.images !== "kitty") return;
355
- if (!this.result) return;
356
-
357
- const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
358
-
359
- for (let i = 0; i < imageBlocks.length; i++) {
360
- const img = imageBlocks[i];
361
- if (!img.data || !img.mimeType) continue;
362
- // Skip if already PNG or already converted
363
- if (img.mimeType === "image/png") continue;
364
- if (this.convertedImages.has(i)) continue;
365
-
366
- // Convert async
367
- const index = i;
368
- convertToPng(img.data, img.mimeType).then((converted) => {
369
- if (converted) {
370
- this.convertedImages.set(index, converted);
371
- this.updateDisplay();
372
- this.ui.requestRender();
373
- }
374
- });
375
- }
376
- }
377
-
378
- setExpanded(expanded: boolean): void {
379
- this.expanded = expanded;
380
- this.updateDisplay();
381
- }
382
-
383
- setRenderMode(mode: "minimal" | "normal"): void {
384
- if (this.renderMode !== mode) {
385
- this.renderMode = mode;
386
- this.updateDisplay();
387
- }
388
- }
389
-
390
- setEditorScheme(scheme: EditorScheme): void {
391
- this.editorScheme = scheme;
392
- this.updateDisplay();
393
- }
394
-
395
- setShowImages(show: boolean): void {
396
- this.showImages = show;
397
- this.updateDisplay();
398
- }
399
-
400
- dispose(): void {
401
- if (this.spinnerTimer) {
402
- clearInterval(this.spinnerTimer);
403
- this.spinnerTimer = null;
404
- }
405
- }
406
-
407
- override invalidate(): void {
408
- super.invalidate();
409
- this.updateDisplay();
410
- }
411
-
412
- override render(width: number): string[] {
413
- if (this.hideComponent) {
414
- return [];
415
- }
416
- return [...super.render(width), ""];
417
- }
418
-
419
- private updateDisplay(): void {
420
- // Status indicator with circle
421
- let statusIndicator = "";
422
- let statusColor = "";
423
-
424
- if (this.isPartial) {
425
- // Loading spinner - start timer on first partial render
426
- if (!this.spinnerTimer) {
427
- this.spinnerTimer = setInterval(() => {
428
- this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
429
- this.updateDisplay();
430
- this.ui.requestRender();
431
- }, SPINNER_INTERVAL_MS);
432
- }
433
- statusIndicator = SPINNER_FRAMES[this.spinnerFrame];
434
- } else {
435
- // Stop spinner when no longer partial
436
- if (this.spinnerTimer) {
437
- clearInterval(this.spinnerTimer);
438
- this.spinnerTimer = null;
439
- }
440
- if (this.result?.isError) {
441
- // Failed - red circle
442
- statusIndicator = theme.fg("error", "●");
443
- statusColor = "error";
444
- } else {
445
- // Success - green circle
446
- statusIndicator = theme.fg("success", "●");
447
- statusColor = "success";
448
- }
449
- }
450
-
451
- const useBuiltInRenderer = this.shouldUseBuiltInRenderer();
452
- let customRendererHasContent = false;
453
- this.hideComponent = false;
454
-
455
- // Use built-in rendering for built-in tools (or overrides without custom renderers)
456
- if (useBuiltInRenderer) {
457
- if (this.toolName === "bash") {
458
- // Bash uses Box with visual line truncation - no background
459
- this.contentBox.setBgFn((text: string) => text);
460
- this.contentBox.clear();
461
- this.renderBashContent(statusIndicator);
462
- } else {
463
- // Other built-in tools: use Text directly with caching - no background
464
- this.contentText.setCustomBgFn((text: string) => text);
465
- this.contentText.setText(this.formatToolExecution(statusIndicator));
466
- }
467
- } else if (this.toolDefinition) {
468
- // Custom tools use Box for flexible component rendering - no background
469
- this.contentBox.setBgFn((text: string) => text);
470
- this.contentBox.clear();
471
-
472
- // Render call component
473
- if (this.toolDefinition.renderCall) {
474
- try {
475
- const callComponent = this.toolDefinition.renderCall(this.args, theme, { statusIndicator });
476
- if (callComponent !== undefined) {
477
- this.contentBox.addChild(callComponent);
478
- customRendererHasContent = true;
479
- }
480
- } catch {
481
- // Fall back to default on error
482
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
483
- customRendererHasContent = true;
484
- }
485
- } else {
486
- // No custom renderCall, show tool name
487
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
488
- customRendererHasContent = true;
489
- }
490
-
491
- // Render result component if we have a result
492
- if (this.result && this.toolDefinition.renderResult) {
493
- try {
494
- if (this.shouldHideCollapsedPreview()) {
495
- const output = this.getTextOutput();
496
- const hasDetails = output.trim().length > 0 || this.imageComponents.length > 0 || this.result.details !== undefined;
497
- const collapsedHint = this.collapsedHintWithPrefix("\n");
498
- if (hasDetails && collapsedHint) {
499
- this.contentBox.addChild(new Text(collapsedHint, 0, 0));
500
- customRendererHasContent = true;
501
- }
502
- } else {
503
- const resultComponent = this.toolDefinition.renderResult(
504
- { content: this.result.content as any, details: this.result.details },
505
- { expanded: this.expanded, isPartial: this.isPartial },
506
- theme,
507
- );
508
- if (resultComponent !== undefined) {
509
- this.contentBox.addChild(resultComponent);
510
- customRendererHasContent = true;
511
- // bg_shell: show /bg hint after start/list/digest actions
512
- if (this.toolName === 'bg_shell' && this.result && !this.isPartial) {
513
- const details = this.result.details as Record<string, unknown> | undefined;
514
- const action = details?.action as string | undefined;
515
- if (action === 'start' || action === 'list' || action === 'digest') {
516
- this.contentBox.addChild(new Text(
517
- '\n' + theme.fg('muted', 'Tip: run /bg to manage background processes'),
518
- 0, 0,
519
- ));
520
- }
521
- }
522
- }
523
- }
524
- } catch {
525
- // Fall back to showing raw output on error
526
- const output = this.getTextOutput();
527
- if (output) {
528
- this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
529
- customRendererHasContent = true;
530
- }
531
- }
532
- } else if (this.result) {
533
- // Has result but no custom renderResult
534
- const output = this.getTextOutput();
535
- if (output) {
536
- if (this.shouldHideCollapsedPreview()) {
537
- const collapsedHint = this.collapsedHintWithPrefix("\n");
538
- if (collapsedHint) {
539
- this.contentBox.addChild(new Text(collapsedHint, 0, 0));
540
- }
541
- } else {
542
- this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
543
- }
544
- customRendererHasContent = true;
545
- }
546
- }
547
- } else {
548
- // Unknown tool with no registered definition - show generic fallback
549
- this.contentText.setCustomBgFn((text: string) => text);
550
- this.contentText.setText(this.formatToolExecution(statusIndicator));
551
- }
552
-
553
- // Handle images (same for both custom and built-in)
554
- for (const img of this.imageComponents) {
555
- this.removeChild(img);
556
- }
557
- this.imageComponents = [];
558
- for (const spacer of this.imageSpacers) {
559
- this.removeChild(spacer);
560
- }
561
- this.imageSpacers = [];
562
-
563
- if (this.result) {
564
- const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
565
- const caps = getCapabilities();
566
-
567
- for (let i = 0; i < imageBlocks.length; i++) {
568
- const img = imageBlocks[i];
569
- if (caps.images && this.showImages && img.data && img.mimeType) {
570
- // Use converted PNG for Kitty protocol if available
571
- const converted = this.convertedImages.get(i);
572
- const imageData = converted?.data ?? img.data;
573
- const imageMimeType = converted?.mimeType ?? img.mimeType;
574
-
575
- // For Kitty, skip non-PNG images that haven't been converted yet
576
- if (caps.images === "kitty" && imageMimeType !== "image/png") {
577
- continue;
578
- }
579
-
580
- const spacer = new Spacer(1);
581
- this.addChild(spacer);
582
- this.imageSpacers.push(spacer);
583
- const imageComponent = new Image(
584
- imageData,
585
- imageMimeType,
586
- { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
587
- { maxWidthCells: 60 },
588
- );
589
- imageComponent.setOnDimensionsResolved(() => {
590
- this.updateDisplay();
591
- this.ui.requestRender();
592
- });
593
- this.imageComponents.push(imageComponent);
594
- this.addChild(imageComponent);
595
- }
596
- }
597
- }
598
-
599
- if (!useBuiltInRenderer && this.toolDefinition) {
600
- this.hideComponent = !customRendererHasContent && this.imageComponents.length === 0;
601
- }
602
- }
603
-
604
- /**
605
- * Render bash content using visual line truncation (like bash-execution.ts)
606
- */
607
- private renderBashContent(statusIndicator: string): void {
608
- const command = str(this.args?.command);
609
- const timeout = this.args?.timeout as number | undefined;
610
- const body = new Container();
611
-
612
- this.contentBox.addChild(body);
613
-
614
- const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
615
- const sandboxBadge = this.result?.details?.sandboxed ? ` ${theme.fg("success", "[sandboxed]")}` : "";
616
- const toolLabel = theme.fg("toolTitle", theme.bold("bash"));
617
- const shellPrompt = theme.fg("muted", "$ ");
618
-
619
- // Always show only the first meaningful non-comment line in the header.
620
- // The full command is shown below the header when expanded.
621
- let commandDisplay: string;
622
- let commandLines: string[] = [];
623
- let isMultiLine = false;
624
- if (command === null) {
625
- commandDisplay = theme.fg("error", "[invalid arg]");
626
- } else if (!command) {
627
- commandDisplay = theme.fg("toolOutput", "...");
628
- } else {
629
- commandLines = command.split("\n");
630
- isMultiLine = commandLines.length > 1;
631
- const firstMeaningful = commandLines.find((l) => l.trim() && !l.trim().startsWith("#")) ?? commandLines[0];
632
- const suffix = isMultiLine ? theme.fg("muted", " …") : "";
633
- commandDisplay = theme.fg("toolOutput", firstMeaningful ?? "") + suffix;
634
- }
635
-
636
- body.addChild(new Text(`${statusIndicator} ${toolLabel} ${shellPrompt}${commandDisplay}${timeoutSuffix}${sandboxBadge}`, 0, 0));
637
-
638
- // When expanded, show the full command below the header (only for multi-line commands)
639
- if (this.expanded && isMultiLine) {
640
- const fullCommand = commandLines.map((line) => theme.fg("toolOutput", line)).join("\n");
641
- body.addChild(new Text(`\n${fullCommand}`, 0, 0));
642
- }
643
-
644
- if (this.result) {
645
- const output = this.getTextOutput().trim();
646
-
647
- if (output) {
648
- const styledOutput = output
649
- .split("\n")
650
- .map((line) => theme.fg("toolOutput", line))
651
- .join("\n");
652
-
653
- if (this.expanded) {
654
- body.addChild(new Text(`\n${styledOutput}`, 0, 0));
655
- } else if (this.renderMode === "minimal") {
656
- const collapsedHint = this.collapsedHintWithPrefix("\n");
657
- if (collapsedHint) {
658
- body.addChild(new Text(collapsedHint, 1, 0));
659
- }
660
- } else {
661
- let cachedWidth: number | undefined;
662
- let cachedLines: string[] | undefined;
663
- let cachedSkipped: number | undefined;
664
-
665
- body.addChild({
666
- render: (width: number) => {
667
- if (cachedLines === undefined || cachedWidth !== width) {
668
- const result = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, Math.max(1, width - 1));
669
- cachedLines = result.visualLines.map((line) => ` ${line}`);
670
- cachedSkipped = result.skippedCount;
671
- cachedWidth = width;
672
- }
673
- if (cachedSkipped && cachedSkipped > 0) {
674
- const hint = theme.fg("muted", `... (${cachedSkipped} earlier lines)`);
675
- return ["", truncateToWidth(` ${hint}`, width, "..."), ...cachedLines];
676
- }
677
- return ["", ...cachedLines];
678
- },
679
- invalidate: () => {
680
- cachedWidth = undefined;
681
- cachedLines = undefined;
682
- cachedSkipped = undefined;
683
- },
684
- });
685
- }
686
- }
687
-
688
- const truncation = this.result.details?.truncation;
689
- const fullOutputPath = this.result.details?.fullOutputPath;
690
- if (truncation?.truncated || fullOutputPath) {
691
- const warnings: string[] = [];
692
- if (fullOutputPath) {
693
- warnings.push(`Full output: ${fullOutputPath}`);
694
- }
695
- if (truncation?.truncated) {
696
- if (truncation.truncatedBy === "lines") {
697
- warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
698
- } else {
699
- warnings.push(
700
- `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
701
- );
702
- }
703
- }
704
- body.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
705
- }
706
-
707
- // Privilege escalation error — agent cannot sudo, show manual step callout
708
- if (this.result.isError && command !== null && isBashPrivilegeError(output, command)) {
709
- body.addChild(
710
- new Text(
711
- `\n${theme.fg("warning", "⚠ This command requires elevated privileges the agent cannot use.")}\n${theme.fg("muted", `Run it manually in your terminal:\n ${command}`)}`,
712
- 0,
713
- 0,
714
- ),
715
- );
716
- }
717
- }
718
- }
719
-
720
- private getTextOutput(): string {
721
- if (!this.result) return "";
722
-
723
- const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
724
- const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
725
-
726
- let output = textBlocks
727
- .map((c: any) => {
728
- // Use sanitizeBinaryOutput to handle binary data that crashes string-width
729
- return sanitizeBinaryOutput(c.text || "");
730
- })
731
- .join("\n");
732
-
733
- if (this.toolName === "bash") {
734
- output = renderTerminalText(output);
735
- } else {
736
- output = output.replace(/\r/g, "");
737
- output = stripAnsi(output);
738
- }
739
-
740
- const caps = getCapabilities();
741
- if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
742
- const imageIndicators = imageBlocks
743
- .map((img: any) => {
744
- return imageFallback(img.mimeType);
745
- })
746
- .join("\n");
747
- output = output ? `${output}\n${imageIndicators}` : imageIndicators;
748
- }
749
-
750
- return output;
751
- }
752
-
753
- private shouldHideCollapsedPreview(): boolean {
754
- // Always hide preview for read tool when not expanded (like Claude Code)
755
- // For other tools, only hide in minimal mode without errors
756
- if (this.toolName === "read") {
757
- return !this.expanded && !this.result?.isError;
758
- }
759
- return !this.expanded && this.renderMode === "minimal" && !this.result?.isError;
760
- }
761
-
762
- private collapsedExpandHint(_label?: string): string {
763
- return ""; // hint is shown in editor bottom border instead
764
- }
765
-
766
- private collapsedHintWithPrefix(prefix = "\n\n"): string {
767
- const hint = this.collapsedExpandHint();
768
- return hint ? `${prefix}${hint}` : "";
769
- }
770
-
771
- private collapsedFirstLine(output: string): string | undefined {
772
- const first = output.split("\n").map((line) => line.trim()).find(Boolean);
773
- return first ? truncateToWidth(first, 120, "...") : undefined;
774
- }
775
-
776
- private formatToolExecution(statusIndicator: string): string {
777
- let text = "";
778
- const invalidArg = theme.fg("error", "[invalid arg]");
779
- const hideCollapsedPreview = this.shouldHideCollapsedPreview();
780
-
781
- if (this.toolName === "read") {
782
- const rawPath = str(this.args?.file_path ?? this.args?.path);
783
- const path = rawPath !== null ? shortenPath(rawPath) : null;
784
- const offset = this.args?.offset;
785
- const limit = this.args?.limit;
786
-
787
- const startLine = offset ?? 1;
788
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
789
- const lineNum = offset !== undefined ? startLine : undefined;
790
-
791
- let styledPath = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
792
- if (rawPath && path) {
793
- styledPath = editorLink(rawPath, styledPath, { cwd: this.cwd, line: lineNum, scheme: this.editorScheme });
794
- }
795
- let pathDisplay = styledPath;
796
- if (offset !== undefined || limit !== undefined) {
797
- pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
798
- }
799
-
800
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
801
-
802
- if (this.result) {
803
- const rawOutput = this.getTextOutput();
804
- // Strip hashline prefixes (e.g. "1#BQ:content") for TUI display
805
- const output = rawOutput.replace(/^(\s*)\d+#[ZPMQVRWSNKTXJBYH]{2}:/gm, "$1");
806
- const rawPath = str(this.args?.file_path ?? this.args?.path);
807
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
808
- const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
809
-
810
- if (hideCollapsedPreview) {
811
- if (output.trim()) {
812
- text += this.collapsedHintWithPrefix();
813
- }
814
- } else {
815
- const maxLines = this.expanded ? lines.length : 10;
816
- const displayLines = lines.slice(0, maxLines);
817
- const remaining = lines.length - maxLines;
818
-
819
- text +=
820
- "\n\n" +
821
- displayLines
822
- .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
823
- .join("\n");
824
- if (remaining > 0) {
825
- text += theme.fg("muted", `\n... (${remaining} more lines)`);
826
- }
827
- }
828
-
829
- const truncation = this.result.details?.truncation;
830
- if (truncation?.truncated) {
831
- if (truncation.firstLineExceedsLimit) {
832
- text +=
833
- "\n" +
834
- theme.fg(
835
- "warning",
836
- `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`,
837
- );
838
- } else if (truncation.truncatedBy === "lines") {
839
- text +=
840
- "\n" +
841
- theme.fg(
842
- "warning",
843
- `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`,
844
- );
845
- } else {
846
- text +=
847
- "\n" +
848
- theme.fg(
849
- "warning",
850
- `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`,
851
- );
852
- }
853
- }
854
- }
855
- } else if (this.toolName === "write") {
856
- const rawPath = str(this.args?.file_path ?? this.args?.path);
857
- const fileContent = str(this.args?.content);
858
- const path = rawPath !== null ? shortenPath(rawPath) : null;
859
-
860
- let writePathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
861
- if (rawPath && path) {
862
- writePathDisplay = editorLink(rawPath, writePathDisplay, { cwd: this.cwd, scheme: this.editorScheme });
863
- }
864
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("write"))} ${writePathDisplay}`;
865
-
866
- if (fileContent === null) {
867
- text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
868
- } else if (fileContent) {
869
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
870
-
871
- let lines: string[];
872
- if (lang) {
873
- const cache = this.writeHighlightCache;
874
- if (cache && cache.lang === lang && cache.rawPath === rawPath && cache.rawContent === fileContent) {
875
- lines = cache.highlightedLines;
876
- } else {
877
- const displayContent = normalizeDisplayText(fileContent);
878
- const normalized = replaceTabs(displayContent);
879
- lines = highlightCode(normalized, lang);
880
- this.writeHighlightCache = {
881
- rawPath,
882
- lang,
883
- rawContent: fileContent,
884
- normalizedLines: normalized.split("\n"),
885
- highlightedLines: lines,
886
- };
887
- }
888
- } else {
889
- lines = normalizeDisplayText(fileContent).split("\n");
890
- this.writeHighlightCache = undefined;
891
- }
892
-
893
- if (hideCollapsedPreview) {
894
- text += this.collapsedHintWithPrefix();
895
- } else {
896
- const totalLines = lines.length;
897
- const maxLines = this.expanded ? lines.length : 10;
898
- const displayLines = lines.slice(0, maxLines);
899
- const remaining = lines.length - maxLines;
900
-
901
- text +=
902
- "\n\n" +
903
- displayLines.map((line: string) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n");
904
- if (remaining > 0) {
905
- text += theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total)`);
906
- }
907
- }
908
- }
909
-
910
- // Show error if tool execution failed
911
- if (this.result?.isError) {
912
- const errorText = this.getTextOutput();
913
- if (errorText) {
914
- text += `\n\n${theme.fg("error", errorText)}`;
915
- }
916
- }
917
- } else if (this.toolName === "edit") {
918
- const rawPath = str(this.args?.file_path ?? this.args?.path);
919
- const path = rawPath !== null ? shortenPath(rawPath) : null;
920
-
921
- // Build path display, appending :line if we have diff info
922
- const firstChangedLine =
923
- (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
924
- ? this.editDiffPreview.firstChangedLine
925
- : undefined) ||
926
- (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
927
-
928
- let styledEditPath = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
929
- if (rawPath && path) {
930
- styledEditPath = editorLink(rawPath, styledEditPath, {
931
- cwd: this.cwd,
932
- line: firstChangedLine ?? undefined,
933
- scheme: this.editorScheme,
934
- });
935
- }
936
- let pathDisplay = styledEditPath;
937
- if (firstChangedLine) {
938
- pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
939
- }
940
-
941
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
942
-
943
- if (this.result?.isError) {
944
- // Show error from result
945
- const errorText = this.getTextOutput();
946
- if (errorText) {
947
- text += `\n\n${theme.fg("error", errorText)}`;
948
- }
949
- } else if (this.result?.details?.diff) {
950
- // Tool executed successfully - use the diff from result
951
- // This takes priority over editDiffPreview which may have a stale error
952
- // due to race condition (async preview computed after file was modified)
953
- text += hideCollapsedPreview
954
- ? this.collapsedHintWithPrefix()
955
- : `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath ?? undefined })}`;
956
- } else if (this.editDiffPreview) {
957
- // Use cached diff preview (before tool executes)
958
- if ("error" in this.editDiffPreview) {
959
- text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
960
- } else if (this.editDiffPreview.diff) {
961
- text += hideCollapsedPreview
962
- ? this.collapsedHintWithPrefix()
963
- : `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath ?? undefined })}`;
964
- }
965
- }
966
- } else if (this.toolName === "ls") {
967
- const rawPath = str(this.args?.path);
968
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
969
- const limit = this.args?.limit;
970
-
971
- let lsPathDisplay = path === null ? invalidArg : theme.fg("accent", path);
972
- if (path && rawPath !== null) {
973
- lsPathDisplay = editorLink(rawPath || ".", lsPathDisplay, { cwd: this.cwd, scheme: this.editorScheme });
974
- }
975
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("ls"))} ${lsPathDisplay}`;
976
- if (limit !== undefined) {
977
- text += theme.fg("toolOutput", ` (limit ${limit})`);
978
- }
979
-
980
- if (this.result) {
981
- const output = this.getTextOutput().trim();
982
- if (output) {
983
- if (hideCollapsedPreview) {
984
- text += this.collapsedHintWithPrefix();
985
- } else {
986
- const lines = output.split("\n");
987
- const maxLines = this.expanded ? lines.length : 20;
988
- const displayLines = lines.slice(0, maxLines);
989
- const remaining = lines.length - maxLines;
990
-
991
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
992
- if (remaining > 0) {
993
- text += theme.fg("muted", `\n... (${remaining} more lines)`);
994
- }
995
- }
996
- }
997
-
998
- const entryLimit = this.result.details?.entryLimitReached;
999
- const truncation = this.result.details?.truncation;
1000
- if (entryLimit || truncation?.truncated) {
1001
- const warnings: string[] = [];
1002
- if (entryLimit) {
1003
- warnings.push(`${entryLimit} entries limit`);
1004
- }
1005
- if (truncation?.truncated) {
1006
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
1007
- }
1008
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
1009
- }
1010
- }
1011
- } else if (this.toolName === "find") {
1012
- const pattern = str(this.args?.pattern);
1013
- const rawPath = str(this.args?.path);
1014
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
1015
- const limit = this.args?.limit;
1016
-
1017
- let findPathDisplay = path === null ? invalidArg : path;
1018
- if (path && rawPath !== null) {
1019
- findPathDisplay = editorLink(rawPath || ".", theme.fg("accent", findPathDisplay), { cwd: this.cwd, scheme: this.editorScheme });
1020
- } else {
1021
- findPathDisplay = theme.fg("accent", findPathDisplay);
1022
- }
1023
- text =
1024
- `${statusIndicator} ${theme.fg("toolTitle", theme.bold("find"))}` +
1025
- " " +
1026
- (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
1027
- theme.fg("toolOutput", " in ") +
1028
- findPathDisplay;
1029
- if (limit !== undefined) {
1030
- text += theme.fg("toolOutput", ` (limit ${limit})`);
1031
- }
1032
-
1033
- if (this.result) {
1034
- const output = this.getTextOutput().trim();
1035
- if (output) {
1036
- if (hideCollapsedPreview) {
1037
- text += this.collapsedHintWithPrefix();
1038
- } else {
1039
- const lines = output.split("\n");
1040
- const maxLines = this.expanded ? lines.length : 20;
1041
- const displayLines = lines.slice(0, maxLines);
1042
- const remaining = lines.length - maxLines;
1043
-
1044
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1045
- if (remaining > 0) {
1046
- text += theme.fg("muted", `\n... (${remaining} more lines)`);
1047
- }
1048
- }
1049
- }
1050
-
1051
- const resultLimit = this.result.details?.resultLimitReached;
1052
- const truncation = this.result.details?.truncation;
1053
- if (resultLimit || truncation?.truncated) {
1054
- const warnings: string[] = [];
1055
- if (resultLimit) {
1056
- warnings.push(`${resultLimit} results limit`);
1057
- }
1058
- if (truncation?.truncated) {
1059
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
1060
- }
1061
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
1062
- }
1063
- }
1064
- } else if (this.toolName === "grep") {
1065
- const pattern = str(this.args?.pattern);
1066
- const rawPath = str(this.args?.path);
1067
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
1068
- const glob = str(this.args?.glob);
1069
- const limit = this.args?.limit;
1070
-
1071
- let grepPathDisplay = path === null ? invalidArg : path;
1072
- if (path && rawPath !== null) {
1073
- grepPathDisplay = editorLink(rawPath || ".", theme.fg("accent", grepPathDisplay), { cwd: this.cwd, scheme: this.editorScheme });
1074
- } else {
1075
- grepPathDisplay = theme.fg("accent", grepPathDisplay);
1076
- }
1077
- text =
1078
- `${statusIndicator} ${theme.fg("toolTitle", theme.bold("grep"))}` +
1079
- " " +
1080
- (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
1081
- theme.fg("toolOutput", " in ") +
1082
- grepPathDisplay;
1083
- if (glob) {
1084
- text += theme.fg("toolOutput", ` (${glob})`);
1085
- }
1086
- if (limit !== undefined) {
1087
- text += theme.fg("toolOutput", ` limit ${limit}`);
1088
- }
1089
-
1090
- if (this.result) {
1091
- const output = this.getTextOutput().trim();
1092
- if (output) {
1093
- if (hideCollapsedPreview) {
1094
- text += this.collapsedHintWithPrefix();
1095
- } else {
1096
- const lines = output.split("\n");
1097
- const maxLines = this.expanded ? lines.length : 15;
1098
- const displayLines = lines.slice(0, maxLines);
1099
- const remaining = lines.length - maxLines;
1100
-
1101
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1102
- if (remaining > 0) {
1103
- text += theme.fg("muted", `\n... (${remaining} more lines)`);
1104
- }
1105
- }
1106
- }
1107
-
1108
- const matchLimit = this.result.details?.matchLimitReached;
1109
- const truncation = this.result.details?.truncation;
1110
- const linesTruncated = this.result.details?.linesTruncated;
1111
- if (matchLimit || truncation?.truncated || linesTruncated) {
1112
- const warnings: string[] = [];
1113
- if (matchLimit) {
1114
- warnings.push(`${matchLimit} matches limit`);
1115
- }
1116
- if (truncation?.truncated) {
1117
- warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
1118
- }
1119
- if (linesTruncated) {
1120
- warnings.push("some lines truncated");
1121
- }
1122
- text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
1123
- }
1124
- }
1125
- } else if (this.toolName === "web_search") {
1126
- // Server-side Anthropic web search
1127
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("web search"))}`;
1128
-
1129
- if (process.env.PI_OFFLINE === "1") {
1130
- text += "\n\n" + theme.fg("muted", "\u{1F50C} Offline \u{2014} web search unavailable");
1131
- } else if (this.result) {
1132
- const output = this.getTextOutput().trim();
1133
- if (output) {
1134
- if (hideCollapsedPreview) {
1135
- text += this.collapsedHintWithPrefix();
1136
- } else {
1137
- const lines = output.split("\n");
1138
- const maxLines = this.expanded ? lines.length : 10;
1139
- const displayLines = lines.slice(0, maxLines);
1140
- const remaining = lines.length - maxLines;
1141
-
1142
- text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1143
- if (remaining > 0) {
1144
- text += theme.fg("muted", `\n... (${remaining} more lines)`);
1145
- }
1146
- }
1147
- }
1148
- }
1149
- } else if (this.toolName === "lsp") {
1150
- const action = this.args?.action as string | undefined;
1151
- const file = this.args?.file as string | undefined;
1152
- const line = this.args?.line as number | undefined;
1153
- const symbol = this.args?.symbol as string | undefined;
1154
- const query = this.args?.query as string | undefined;
1155
- const newName = this.args?.new_name as string | undefined;
1156
-
1157
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("lsp"))}`;
1158
- if (action) text += ` ${theme.fg("accent", action)}`;
1159
- if (file) {
1160
- const shortFile = shortenPath(file);
1161
- let styledFile = theme.fg("muted", shortFile);
1162
- if (file && shortFile) {
1163
- styledFile = editorLink(file, styledFile, { cwd: this.cwd, line, scheme: this.editorScheme });
1164
- }
1165
- text += ` ${styledFile}`;
1166
- if (line !== undefined) text += theme.fg("warning", `:${line}`);
1167
- }
1168
- if (symbol) text += ` ${theme.fg("toolOutput", symbol)}`;
1169
- if (query) text += ` ${theme.fg("muted", `"${query}"`)}`;
1170
- if (newName) text += ` → ${theme.fg("accent", newName)}`;
1171
-
1172
- if (this.result) {
1173
- const output = this.getTextOutput().trim();
1174
- if (output) {
1175
- if (hideCollapsedPreview) {
1176
- text += this.collapsedHintWithPrefix();
1177
- } else {
1178
- const lines = output.split("\n");
1179
- const maxLines = this.expanded ? lines.length : 10;
1180
- const displayLines = lines.slice(0, maxLines);
1181
- const remaining = lines.length - maxLines;
1182
- text += `\n\n${displayLines.map((l: string) => theme.fg("toolOutput", l)).join("\n")}`;
1183
- if (remaining > 0) text += theme.fg("muted", `\n... (${remaining} more lines)`);
1184
- }
1185
- }
1186
- }
1187
- } else {
1188
- // Generic tool (shouldn't reach here for custom tools)
1189
- text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold(this.toolName))}`;
1190
-
1191
- const content = JSON.stringify(this.args, null, 2);
1192
- text += hideCollapsedPreview ? this.collapsedHintWithPrefix() : `\n\n${content}`;
1193
- const output = this.getTextOutput();
1194
- if (output && !hideCollapsedPreview) {
1195
- text += `\n${output}`;
1196
- }
1197
- }
1198
-
1199
- return text;
1200
- }
105
+ private contentBox: Box; // Used for custom tools and bash visual truncation
106
+ private contentText: Text; // For built-in tools (with its own padding/bg)
107
+ private imageComponents: Image[] = [];
108
+ private imageSpacers: Spacer[] = [];
109
+ private toolName: string;
110
+ private args: any;
111
+ private expanded = false;
112
+ private renderMode: "minimal" | "normal";
113
+ private showImages: boolean;
114
+ private editorScheme: EditorScheme;
115
+ private isPartial = true;
116
+ private toolDefinition?: ToolDefinition;
117
+ private ui: TUI;
118
+ private cwd: string;
119
+ private result?: {
120
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
121
+ isError: boolean;
122
+ details?: any;
123
+ };
124
+ // Cached edit diff preview (computed when args arrive, before tool executes)
125
+ private editDiffPreview?: EditDiffResult | EditDiffError;
126
+ private editDiffArgsKey?: string; // Track which args the preview is for
127
+ // Cached write diff preview (computed when args arrive, before tool executes)
128
+ private writeDiffPreview?: EditDiffResult | EditDiffError;
129
+ private writeDiffArgsKey?: string; // Track which args the preview is for
130
+ // Cached converted images for Kitty protocol (which requires PNG), keyed by index
131
+ private convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
132
+ // Incremental syntax highlighting cache for write tool call args
133
+ private writeHighlightCache?: WriteHighlightCache;
134
+ // When true, this component intentionally renders no lines
135
+ private hideComponent = false;
136
+ private manuallyHidden = false;
137
+ private startTime = Date.now();
138
+
139
+ // Tool status spinner state
140
+ private spinnerTimer: NodeJS.Timeout | null = null;
141
+ private spinnerFrame = 0;
142
+
143
+ constructor(
144
+ toolName: string,
145
+ args: any,
146
+ options: ToolExecutionOptions = {},
147
+ toolDefinition: ToolDefinition | undefined,
148
+ ui: TUI,
149
+ cwd: string = process.cwd(),
150
+ ) {
151
+ super();
152
+ this.toolName = toolName;
153
+ this.args = args;
154
+ this.showImages = options.showImages ?? true;
155
+ this.renderMode = options.renderMode ?? "normal";
156
+ this.editorScheme = options.editorScheme ?? "auto";
157
+ this.toolDefinition = toolDefinition;
158
+ this.ui = ui;
159
+ this.cwd = cwd;
160
+
161
+ // Always create both - contentBox for custom tools/bash, contentText for other built-ins
162
+ this.contentBox = new Box(1, 0, (text: string) => theme.bg("toolPendingBg", text));
163
+ this.contentText = new Text("", 1, 0, (text: string) => theme.bg("toolPendingBg", text));
164
+
165
+ // Use contentBox for bash (visual truncation) or custom tools with custom renderers
166
+ // Use contentText for built-in tools (including overrides without custom renderers)
167
+ if (toolName === "bash" || (toolDefinition && !this.shouldUseBuiltInRenderer())) {
168
+ this.addChild(this.contentBox);
169
+ } else {
170
+ this.addChild(this.contentText);
171
+ }
172
+
173
+ this.updateDisplay();
174
+ }
175
+
176
+ /**
177
+ * Check if we should use built-in rendering for this tool.
178
+ * Returns true if the tool name is a built-in AND either there's no toolDefinition
179
+ * or the toolDefinition doesn't provide custom renderers.
180
+ */
181
+ private shouldUseBuiltInRenderer(): boolean {
182
+ const isBuiltInName = this.toolName in allTools;
183
+ const hasCustomRenderers = this.toolDefinition?.renderCall || this.toolDefinition?.renderResult;
184
+ return isBuiltInName && !hasCustomRenderers;
185
+ }
186
+
187
+ private setPrimaryContent(useBox: boolean): void {
188
+ const hasBox = this.children.includes(this.contentBox);
189
+ const hasText = this.children.includes(this.contentText);
190
+
191
+ if (useBox) {
192
+ if (hasText) this.removeChild(this.contentText);
193
+ if (!hasBox) this.addChild(this.contentBox);
194
+ } else {
195
+ if (hasBox) this.removeChild(this.contentBox);
196
+ if (!hasText) this.addChild(this.contentText);
197
+ }
198
+ }
199
+
200
+ private getDiffTextToRender(): string | undefined {
201
+ if (this.toolName === "write") {
202
+ if (!this.isPartial && this.writeDiffPreview && !("error" in this.writeDiffPreview) && this.writeDiffPreview.diff) {
203
+ return this.writeDiffPreview.diff;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ if (this.toolName !== "edit" || this.result?.isError) {
209
+ return undefined;
210
+ }
211
+
212
+ if (this.result?.details?.diff) {
213
+ return this.result.details.diff;
214
+ }
215
+ if (this.editDiffPreview && !("error" in this.editDiffPreview) && this.editDiffPreview.diff) {
216
+ return this.editDiffPreview.diff;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ updateArgs(args: any): void {
222
+ this.args = args;
223
+ if (this.toolName === "write" && this.isPartial) {
224
+ this.updateWriteHighlightCacheIncremental();
225
+ }
226
+ this.updateDisplay();
227
+ }
228
+
229
+ private highlightSingleLine(line: string, lang: string): string {
230
+ const highlighted = highlightCode(line, lang);
231
+ return highlighted[0] ?? "";
232
+ }
233
+
234
+ private refreshWriteHighlightPrefix(cache: WriteHighlightCache): void {
235
+ const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
236
+ if (prefixCount === 0) return;
237
+
238
+ const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
239
+ const prefixHighlighted = highlightCode(prefixSource, cache.lang);
240
+ for (let i = 0; i < prefixCount; i++) {
241
+ cache.highlightedLines[i] =
242
+ prefixHighlighted[i] ?? this.highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
243
+ }
244
+ }
245
+
246
+ private rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): void {
247
+ const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
248
+ if (!lang) {
249
+ this.writeHighlightCache = undefined;
250
+ return;
251
+ }
252
+
253
+ const displayContent = normalizeDisplayText(fileContent);
254
+ const normalized = replaceTabs(displayContent);
255
+ this.writeHighlightCache = {
256
+ rawPath,
257
+ lang,
258
+ rawContent: fileContent,
259
+ normalizedLines: normalized.split("\n"),
260
+ highlightedLines: highlightCode(normalized, lang),
261
+ };
262
+ }
263
+
264
+ private updateWriteHighlightCacheIncremental(): void {
265
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
266
+ const fileContent = str(this.args?.content);
267
+ if (rawPath === null || fileContent === null) {
268
+ this.writeHighlightCache = undefined;
269
+ return;
270
+ }
271
+
272
+ const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
273
+ if (!lang) {
274
+ this.writeHighlightCache = undefined;
275
+ return;
276
+ }
277
+
278
+ if (!this.writeHighlightCache) {
279
+ this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
280
+ return;
281
+ }
282
+
283
+ const cache = this.writeHighlightCache;
284
+ if (cache.lang !== lang || cache.rawPath !== rawPath) {
285
+ this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
286
+ return;
287
+ }
288
+
289
+ if (!fileContent.startsWith(cache.rawContent)) {
290
+ this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
291
+ return;
292
+ }
293
+
294
+ if (fileContent.length === cache.rawContent.length) {
295
+ return;
296
+ }
297
+
298
+ const deltaRaw = fileContent.slice(cache.rawContent.length);
299
+ const deltaDisplay = normalizeDisplayText(deltaRaw);
300
+ const deltaNormalized = replaceTabs(deltaDisplay);
301
+ cache.rawContent = fileContent;
302
+
303
+ if (cache.normalizedLines.length === 0) {
304
+ cache.normalizedLines.push("");
305
+ cache.highlightedLines.push("");
306
+ }
307
+
308
+ const segments = deltaNormalized.split("\n");
309
+ const lastIndex = cache.normalizedLines.length - 1;
310
+ cache.normalizedLines[lastIndex] += segments[0];
311
+ cache.highlightedLines[lastIndex] = this.highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
312
+
313
+ for (let i = 1; i < segments.length; i++) {
314
+ cache.normalizedLines.push(segments[i]);
315
+ cache.highlightedLines.push(this.highlightSingleLine(segments[i], cache.lang));
316
+ }
317
+
318
+ this.refreshWriteHighlightPrefix(cache);
319
+ }
320
+
321
+ /**
322
+ * Signal that args are complete (tool is about to execute).
323
+ * This triggers diff computation for edit tool.
324
+ */
325
+ setArgsComplete(): void {
326
+ if (this.toolName === "write") {
327
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
328
+ const fileContent = str(this.args?.content);
329
+ if (rawPath !== null && fileContent !== null) {
330
+ this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
331
+ }
332
+ this.maybeComputeWriteDiff();
333
+ }
334
+ this.maybeComputeEditDiff();
335
+ }
336
+
337
+ /**
338
+ * Compute write diff preview when we have complete args.
339
+ * This runs async and updates display when done.
340
+ */
341
+ private maybeComputeWriteDiff(): void {
342
+ if (this.toolName !== "write") return;
343
+
344
+ const path = this.args?.path ?? this.args?.file_path;
345
+ const content = this.args?.content;
346
+
347
+ if (!path || content === undefined) return;
348
+
349
+ const argsKey = JSON.stringify({ path, content });
350
+ if (this.writeDiffArgsKey === argsKey) return;
351
+
352
+ this.writeDiffArgsKey = argsKey;
353
+
354
+ computeWriteDiff(path, content, this.cwd).then((result) => {
355
+ if (this.writeDiffArgsKey === argsKey) {
356
+ this.writeDiffPreview = result;
357
+ this.updateDisplay();
358
+ this.ui.requestRender();
359
+ }
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Compute edit diff preview when we have complete args.
365
+ * This runs async and updates display when done.
366
+ */
367
+ private maybeComputeEditDiff(): void {
368
+ if (this.toolName !== "edit") return;
369
+
370
+ const path = this.args?.path;
371
+ const oldText = this.args?.oldText;
372
+ const newText = this.args?.newText;
373
+
374
+ // Need all three params to compute diff
375
+ if (!path || oldText === undefined || newText === undefined) return;
376
+
377
+ // Create a key to track which args this computation is for
378
+ const argsKey = JSON.stringify({ path, oldText, newText });
379
+
380
+ // Skip if we already computed for these exact args
381
+ if (this.editDiffArgsKey === argsKey) return;
382
+
383
+ this.editDiffArgsKey = argsKey;
384
+
385
+ // Compute diff async
386
+ computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
387
+ // Only update if args haven't changed since we started
388
+ if (this.editDiffArgsKey === argsKey) {
389
+ this.editDiffPreview = result;
390
+ this.updateDisplay();
391
+ this.ui.requestRender();
392
+ }
393
+ });
394
+ }
395
+
396
+ updateResult(
397
+ result: {
398
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
399
+ details?: any;
400
+ isError: boolean;
401
+ },
402
+ isPartial = false,
403
+ ): void {
404
+ this.result = result;
405
+ this.isPartial = isPartial;
406
+
407
+ if (this.toolName === "write" && !isPartial) {
408
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
409
+ const fileContent = str(this.args?.content);
410
+ if (rawPath !== null && fileContent !== null) {
411
+ this.rebuildWriteHighlightCacheFull(rawPath, fileContent);
412
+ }
413
+ }
414
+ this.updateDisplay();
415
+ // Convert non-PNG images to PNG for Kitty protocol (async)
416
+ this.maybeConvertImagesForKitty();
417
+ }
418
+
419
+ /**
420
+ * Convert non-PNG images to PNG for Kitty graphics protocol.
421
+ * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
422
+ */
423
+ private maybeConvertImagesForKitty(): void {
424
+ const caps = getCapabilities();
425
+ // Only needed for Kitty protocol
426
+ if (caps.images !== "kitty") return;
427
+ if (!this.result) return;
428
+
429
+ const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
430
+
431
+ for (let i = 0; i < imageBlocks.length; i++) {
432
+ const img = imageBlocks[i];
433
+ if (!img.data || !img.mimeType) continue;
434
+ // Skip if already PNG or already converted
435
+ if (img.mimeType === "image/png") continue;
436
+ if (this.convertedImages.has(i)) continue;
437
+
438
+ // Convert async
439
+ const index = i;
440
+ convertToPng(img.data, img.mimeType).then((converted) => {
441
+ if (converted) {
442
+ this.convertedImages.set(index, converted);
443
+ this.updateDisplay();
444
+ this.ui.requestRender();
445
+ }
446
+ });
447
+ }
448
+ }
449
+
450
+ setExpanded(expanded: boolean): void {
451
+ this.expanded = expanded;
452
+ this.updateDisplay();
453
+ }
454
+
455
+ setHidden(hidden: boolean): void {
456
+ this.manuallyHidden = hidden;
457
+ this.updateDisplay();
458
+ }
459
+
460
+ isHidden(): boolean {
461
+ return this.hideComponent;
462
+ }
463
+
464
+ getElapsed(): number {
465
+ return Date.now() - this.startTime;
466
+ }
467
+
468
+ shouldHideWhenCollapsed(collapseToolCalls = true): boolean {
469
+ return collapseToolCalls && !this.isPartial && shouldCollapse(this.toolName, this.result?.isError ?? false);
470
+ }
471
+
472
+ setRenderMode(mode: "minimal" | "normal"): void {
473
+ if (this.renderMode !== mode) {
474
+ this.renderMode = mode;
475
+ this.updateDisplay();
476
+ }
477
+ }
478
+
479
+ setEditorScheme(scheme: EditorScheme): void {
480
+ this.editorScheme = scheme;
481
+ this.updateDisplay();
482
+ }
483
+
484
+ setShowImages(show: boolean): void {
485
+ this.showImages = show;
486
+ this.updateDisplay();
487
+ }
488
+
489
+ dispose(): void {
490
+ if (this.spinnerTimer) {
491
+ clearInterval(this.spinnerTimer);
492
+ this.spinnerTimer = null;
493
+ }
494
+ }
495
+
496
+ override invalidate(): void {
497
+ super.invalidate();
498
+ this.updateDisplay();
499
+ }
500
+
501
+ override render(width: number): string[] {
502
+ if (this.hideComponent) {
503
+ return [];
504
+ }
505
+ return [...super.render(width), ""];
506
+ }
507
+
508
+ private updateDisplay(): void {
509
+ // Status indicator with circle
510
+ let statusIndicator = "";
511
+ let statusColor = "";
512
+
513
+ if (this.isPartial) {
514
+ // Loading spinner - start timer on first partial render
515
+ if (!this.spinnerTimer) {
516
+ this.spinnerTimer = setInterval(() => {
517
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
518
+ this.updateDisplay();
519
+ this.ui.requestRender();
520
+ }, SPINNER_INTERVAL_MS);
521
+ }
522
+ statusIndicator = SPINNER_FRAMES[this.spinnerFrame];
523
+ } else {
524
+ // Stop spinner when no longer partial
525
+ if (this.spinnerTimer) {
526
+ clearInterval(this.spinnerTimer);
527
+ this.spinnerTimer = null;
528
+ }
529
+ if (this.result?.isError) {
530
+ // Failed - red circle
531
+ statusIndicator = theme.fg("error", "●");
532
+ statusColor = "error";
533
+ } else {
534
+ // Success - green circle
535
+ statusIndicator = theme.fg("success", "●");
536
+ statusColor = "success";
537
+ }
538
+ }
539
+
540
+ const useBuiltInRenderer = this.shouldUseBuiltInRenderer();
541
+ const diffTextToRender = this.getDiffTextToRender();
542
+ let customRendererHasContent = false;
543
+
544
+ // Use built-in rendering for built-in tools (or overrides without custom renderers)
545
+ if (useBuiltInRenderer) {
546
+ const useDiffBox = !!diffTextToRender && !this.shouldHideCollapsedPreview();
547
+ this.setPrimaryContent(this.toolName === "bash" || useDiffBox);
548
+ if (this.toolName === "bash") {
549
+ // Bash uses Box with visual line truncation - no background
550
+ this.contentBox.setBgFn((text: string) => text);
551
+ this.contentBox.clear();
552
+ this.renderBashContent(statusIndicator);
553
+ } else if (useDiffBox && diffTextToRender) {
554
+ this.contentBox.setBgFn((text: string) => text);
555
+ this.contentBox.clear();
556
+ this.renderBuiltInDiffContent(statusIndicator, diffTextToRender);
557
+ } else {
558
+ // Other built-in tools: use Text directly with caching - no background
559
+ this.contentText.setCustomBgFn((text: string) => text);
560
+ this.contentText.setText(this.formatToolExecution(statusIndicator));
561
+ }
562
+ } else if (this.toolDefinition) {
563
+ // Custom tools use Box for flexible component rendering - no background
564
+ this.contentBox.setBgFn((text: string) => text);
565
+ this.contentBox.clear();
566
+
567
+ // Render call component
568
+ if (this.toolDefinition.renderCall) {
569
+ try {
570
+ const callComponent = this.toolDefinition.renderCall(this.args, theme, { statusIndicator });
571
+ if (callComponent !== undefined) {
572
+ this.contentBox.addChild(callComponent);
573
+ customRendererHasContent = true;
574
+ }
575
+ } catch {
576
+ // Fall back to default on error
577
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
578
+ customRendererHasContent = true;
579
+ }
580
+ } else {
581
+ // No custom renderCall, show tool name
582
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
583
+ customRendererHasContent = true;
584
+ }
585
+
586
+ // Render result component if we have a result
587
+ if (this.result && this.toolDefinition.renderResult) {
588
+ try {
589
+ if (this.shouldHideCollapsedPreview()) {
590
+ const output = this.getTextOutput();
591
+ const hasDetails = output.trim().length > 0 || this.imageComponents.length > 0 || this.result.details !== undefined;
592
+ const collapsedHint = this.collapsedHintWithPrefix("\n");
593
+ if (hasDetails && collapsedHint) {
594
+ this.contentBox.addChild(new Text(collapsedHint, 0, 0));
595
+ customRendererHasContent = true;
596
+ }
597
+ } else {
598
+ const resultComponent = this.toolDefinition.renderResult(
599
+ { content: this.result.content as any, details: this.result.details },
600
+ { expanded: this.expanded, isPartial: this.isPartial },
601
+ theme,
602
+ );
603
+ if (resultComponent !== undefined) {
604
+ this.contentBox.addChild(resultComponent);
605
+ customRendererHasContent = true;
606
+ // bg_shell: show /bg hint after start/list/digest actions
607
+ if (this.toolName === 'bg_shell' && this.result && !this.isPartial) {
608
+ const details = this.result.details as Record<string, unknown> | undefined;
609
+ const action = details?.action as string | undefined;
610
+ if (action === 'start' || action === 'list' || action === 'digest') {
611
+ this.contentBox.addChild(new Text(
612
+ '\n' + theme.fg('muted', 'Tip: run /bg to manage background processes'),
613
+ 0, 0,
614
+ ));
615
+ }
616
+ }
617
+ }
618
+ }
619
+ } catch {
620
+ // Fall back to showing raw output on error
621
+ const output = this.getTextOutput();
622
+ if (output) {
623
+ this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
624
+ customRendererHasContent = true;
625
+ }
626
+ }
627
+ } else if (this.result) {
628
+ // Has result but no custom renderResult
629
+ const output = this.getTextOutput();
630
+ if (output) {
631
+ if (this.shouldHideCollapsedPreview()) {
632
+ const collapsedHint = this.collapsedHintWithPrefix("\n");
633
+ if (collapsedHint) {
634
+ this.contentBox.addChild(new Text(collapsedHint, 0, 0));
635
+ }
636
+ } else {
637
+ this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
638
+ }
639
+ customRendererHasContent = true;
640
+ }
641
+ }
642
+ } else {
643
+ // Unknown tool with no registered definition - show generic fallback
644
+ this.contentText.setCustomBgFn((text: string) => text);
645
+ this.contentText.setText(this.formatToolExecution(statusIndicator));
646
+ }
647
+
648
+ // Handle images (same for both custom and built-in)
649
+ for (const img of this.imageComponents) {
650
+ this.removeChild(img);
651
+ }
652
+ this.imageComponents = [];
653
+ for (const spacer of this.imageSpacers) {
654
+ this.removeChild(spacer);
655
+ }
656
+ this.imageSpacers = [];
657
+
658
+ if (this.result) {
659
+ const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
660
+ const caps = getCapabilities();
661
+
662
+ for (let i = 0; i < imageBlocks.length; i++) {
663
+ const img = imageBlocks[i];
664
+ if (caps.images && this.showImages && img.data && img.mimeType) {
665
+ // Use converted PNG for Kitty protocol if available
666
+ const converted = this.convertedImages.get(i);
667
+ const imageData = converted?.data ?? img.data;
668
+ const imageMimeType = converted?.mimeType ?? img.mimeType;
669
+
670
+ // For Kitty, skip non-PNG images that haven't been converted yet
671
+ if (caps.images === "kitty" && imageMimeType !== "image/png") {
672
+ continue;
673
+ }
674
+
675
+ const spacer = new Spacer(1);
676
+ this.addChild(spacer);
677
+ this.imageSpacers.push(spacer);
678
+ const imageComponent = new Image(
679
+ imageData,
680
+ imageMimeType,
681
+ { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
682
+ { maxWidthCells: 60 },
683
+ );
684
+ imageComponent.setOnDimensionsResolved(() => {
685
+ this.updateDisplay();
686
+ this.ui.requestRender();
687
+ });
688
+ this.imageComponents.push(imageComponent);
689
+ this.addChild(imageComponent);
690
+ }
691
+ }
692
+ }
693
+
694
+ const computedHidden = !useBuiltInRenderer && this.toolDefinition ? !customRendererHasContent && this.imageComponents.length === 0 : false;
695
+ this.hideComponent = this.manuallyHidden || computedHidden;
696
+ }
697
+
698
+ /**
699
+ * Render built-in edit/write diff blocks with width-aware full-line backgrounds.
700
+ */
701
+ private renderBuiltInDiffContent(statusIndicator: string, diffText: string): void {
702
+ const header = this.formatToolExecution(statusIndicator).split("\n\n", 1)[0] ?? "";
703
+ this.contentBox.addChild(new Text(header, 0, 0));
704
+ this.contentBox.addChild({
705
+ render: (width: number) => {
706
+ const contentWidth = Math.max(1, width - 2);
707
+ return ["", ...renderDiffLines(diffText, contentWidth).map((line) => ` ${line} `)];
708
+ },
709
+ invalidate: () => { },
710
+ });
711
+ }
712
+
713
+ /**
714
+ * Render bash content using visual line truncation (like bash-execution.ts)
715
+ */
716
+ private renderBashContent(statusIndicator: string): void {
717
+ const command = str(this.args?.command);
718
+ const timeout = this.args?.timeout as number | undefined;
719
+ const body = new Container();
720
+
721
+ this.contentBox.addChild(body);
722
+
723
+ const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
724
+ const sandboxBadge = this.result?.details?.sandboxed ? ` ${theme.fg("success", "[sandboxed]")}` : "";
725
+ const toolLabel = theme.fg("toolTitle", theme.bold("bash"));
726
+ const shellPrompt = theme.fg("muted", "$ ");
727
+
728
+ // Always show only the first meaningful non-comment line in the header.
729
+ // The full command is shown below the header when expanded.
730
+ let commandDisplay: string;
731
+ let commandLines: string[] = [];
732
+ let isMultiLine = false;
733
+ if (command === null) {
734
+ commandDisplay = theme.fg("error", "[invalid arg]");
735
+ } else if (!command) {
736
+ commandDisplay = theme.fg("toolOutput", "...");
737
+ } else {
738
+ commandLines = command.split("\n");
739
+ isMultiLine = commandLines.length > 1;
740
+ const firstMeaningful = commandLines.find((l) => l.trim() && !l.trim().startsWith("#")) ?? commandLines[0];
741
+ const suffix = isMultiLine ? theme.fg("muted", " …") : "";
742
+ commandDisplay = theme.fg("toolOutput", firstMeaningful ?? "") + suffix;
743
+ }
744
+
745
+ body.addChild(new Text(`${statusIndicator} ${toolLabel} ${shellPrompt}${commandDisplay}${timeoutSuffix}${sandboxBadge}`, 0, 0));
746
+
747
+ // When expanded, show the full command below the header (only for multi-line commands)
748
+ if (this.expanded && isMultiLine) {
749
+ const fullCommand = commandLines.map((line) => theme.fg("toolOutput", line)).join("\n");
750
+ body.addChild(new Text(`\n${fullCommand}`, 0, 0));
751
+ }
752
+
753
+ if (this.result) {
754
+ const output = this.getTextOutput().trim();
755
+
756
+ if (output) {
757
+ const styledOutput = output
758
+ .split("\n")
759
+ .map((line) => theme.fg("toolOutput", line))
760
+ .join("\n");
761
+
762
+ if (this.expanded) {
763
+ body.addChild(new Text(`\n${styledOutput}`, 0, 0));
764
+ } else if (this.renderMode === "minimal") {
765
+ const collapsedHint = this.collapsedHintWithPrefix("\n");
766
+ if (collapsedHint) {
767
+ body.addChild(new Text(collapsedHint, 1, 0));
768
+ }
769
+ } else {
770
+ let cachedWidth: number | undefined;
771
+ let cachedLines: string[] | undefined;
772
+ let cachedSkipped: number | undefined;
773
+
774
+ body.addChild({
775
+ render: (width: number) => {
776
+ if (cachedLines === undefined || cachedWidth !== width) {
777
+ const result = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, Math.max(1, width - 1));
778
+ cachedLines = result.visualLines.map((line) => ` ${line}`);
779
+ cachedSkipped = result.skippedCount;
780
+ cachedWidth = width;
781
+ }
782
+ if (cachedSkipped && cachedSkipped > 0) {
783
+ const hint = theme.fg("muted", `... (${cachedSkipped} earlier lines)`);
784
+ return ["", truncateToWidth(` ${hint}`, width, "..."), ...cachedLines];
785
+ }
786
+ return ["", ...cachedLines];
787
+ },
788
+ invalidate: () => {
789
+ cachedWidth = undefined;
790
+ cachedLines = undefined;
791
+ cachedSkipped = undefined;
792
+ },
793
+ });
794
+ }
795
+ }
796
+
797
+ const truncation = this.result.details?.truncation;
798
+ const fullOutputPath = this.result.details?.fullOutputPath;
799
+ if (truncation?.truncated || fullOutputPath) {
800
+ const warnings: string[] = [];
801
+ if (fullOutputPath) {
802
+ warnings.push(`Full output: ${fullOutputPath}`);
803
+ }
804
+ if (truncation?.truncated) {
805
+ if (truncation.truncatedBy === "lines") {
806
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
807
+ } else {
808
+ warnings.push(
809
+ `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
810
+ );
811
+ }
812
+ }
813
+ body.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
814
+ }
815
+
816
+ // Privilege escalation error — agent cannot sudo, show manual step callout
817
+ if (this.result.isError && command !== null && isBashPrivilegeError(output, command)) {
818
+ body.addChild(
819
+ new Text(
820
+ `\n${theme.fg("warning", "⚠ This command requires elevated privileges the agent cannot use.")}\n${theme.fg("muted", `Run it manually in your terminal:\n ${command}`)}`,
821
+ 0,
822
+ 0,
823
+ ),
824
+ );
825
+ }
826
+ }
827
+ }
828
+
829
+ private getTextOutput(): string {
830
+ if (!this.result) return "";
831
+
832
+ const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
833
+ const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
834
+
835
+ let output = textBlocks
836
+ .map((c: any) => {
837
+ // Use sanitizeBinaryOutput to handle binary data that crashes string-width
838
+ return sanitizeBinaryOutput(c.text || "");
839
+ })
840
+ .join("\n");
841
+
842
+ if (this.toolName === "bash") {
843
+ output = renderTerminalText(output);
844
+ } else {
845
+ output = output.replace(/\r/g, "");
846
+ output = stripAnsi(output);
847
+ }
848
+
849
+ const caps = getCapabilities();
850
+ if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
851
+ const imageIndicators = imageBlocks
852
+ .map((img: any) => {
853
+ return imageFallback(img.mimeType);
854
+ })
855
+ .join("\n");
856
+ output = output ? `${output}\n${imageIndicators}` : imageIndicators;
857
+ }
858
+
859
+ return output;
860
+ }
861
+
862
+ private shouldHideCollapsedPreview(): boolean {
863
+ // Always hide preview for read tool when not expanded (like Claude Code)
864
+ // For other tools, only hide in minimal mode without errors
865
+ if (this.toolName === "read") {
866
+ return !this.expanded && !this.result?.isError;
867
+ }
868
+ return !this.expanded && this.renderMode === "minimal" && !this.result?.isError;
869
+ }
870
+
871
+ private collapsedExpandHint(_label?: string): string {
872
+ return ""; // hint is shown in editor bottom border instead
873
+ }
874
+
875
+ private collapsedHintWithPrefix(prefix = "\n\n"): string {
876
+ const hint = this.collapsedExpandHint();
877
+ return hint ? `${prefix}${hint}` : "";
878
+ }
879
+
880
+ private collapsedFirstLine(output: string): string | undefined {
881
+ const first = output.split("\n").map((line) => line.trim()).find(Boolean);
882
+ return first ? truncateToWidth(first, 120, "...") : undefined;
883
+ }
884
+
885
+ private formatToolExecution(statusIndicator: string): string {
886
+ let text = "";
887
+ const invalidArg = theme.fg("error", "[invalid arg]");
888
+ const hideCollapsedPreview = this.shouldHideCollapsedPreview();
889
+
890
+ if (this.toolName === "read") {
891
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
892
+ const path = rawPath !== null ? shortenPath(rawPath) : null;
893
+ const offset = this.args?.offset;
894
+ const limit = this.args?.limit;
895
+
896
+ const startLine = offset ?? 1;
897
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
898
+ const lineNum = offset !== undefined ? startLine : undefined;
899
+
900
+ let styledPath = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
901
+ if (rawPath && path) {
902
+ styledPath = editorLink(rawPath, styledPath, { cwd: this.cwd, line: lineNum, scheme: this.editorScheme });
903
+ }
904
+ let pathDisplay = styledPath;
905
+ if (offset !== undefined || limit !== undefined) {
906
+ pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
907
+ }
908
+
909
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
910
+
911
+ if (this.result) {
912
+ const rawOutput = this.getTextOutput();
913
+ // Strip hashline prefixes (e.g. "1#BQ:content") for TUI display
914
+ const output = rawOutput.replace(/^(\s*)\d+#[ZPMQVRWSNKTXJBYH]{2}:/gm, "$1");
915
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
916
+ const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
917
+ const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
918
+
919
+ if (hideCollapsedPreview) {
920
+ if (output.trim()) {
921
+ text += this.collapsedHintWithPrefix();
922
+ }
923
+ } else {
924
+ const maxLines = this.expanded ? lines.length : 10;
925
+ const displayLines = lines.slice(0, maxLines);
926
+ const remaining = lines.length - maxLines;
927
+
928
+ text +=
929
+ "\n\n" +
930
+ displayLines
931
+ .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
932
+ .join("\n");
933
+ if (remaining > 0) {
934
+ text += theme.fg("muted", `\n... (${remaining} more lines)`);
935
+ }
936
+ }
937
+
938
+ const truncation = this.result.details?.truncation;
939
+ if (truncation?.truncated) {
940
+ if (truncation.firstLineExceedsLimit) {
941
+ text +=
942
+ "\n" +
943
+ theme.fg(
944
+ "warning",
945
+ `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`,
946
+ );
947
+ } else if (truncation.truncatedBy === "lines") {
948
+ text +=
949
+ "\n" +
950
+ theme.fg(
951
+ "warning",
952
+ `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`,
953
+ );
954
+ } else {
955
+ text +=
956
+ "\n" +
957
+ theme.fg(
958
+ "warning",
959
+ `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`,
960
+ );
961
+ }
962
+ }
963
+ }
964
+ } else if (this.toolName === "write") {
965
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
966
+ const fileContent = str(this.args?.content);
967
+ const path = rawPath !== null ? shortenPath(rawPath) : null;
968
+ const firstChangedLine = this.writeDiffPreview && "firstChangedLine" in this.writeDiffPreview
969
+ ? this.writeDiffPreview.firstChangedLine
970
+ : undefined;
971
+
972
+ let writePathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
973
+ if (rawPath && path) {
974
+ writePathDisplay = editorLink(rawPath, writePathDisplay, {
975
+ cwd: this.cwd,
976
+ line: firstChangedLine ?? undefined,
977
+ scheme: this.editorScheme,
978
+ });
979
+ }
980
+ if (firstChangedLine) {
981
+ writePathDisplay += theme.fg("warning", `:${firstChangedLine}`);
982
+ }
983
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("write"))} ${writePathDisplay}`;
984
+
985
+ if (fileContent === null) {
986
+ text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
987
+ } else if (this.result?.isError) {
988
+ const errorText = this.getTextOutput();
989
+ if (errorText) {
990
+ text += `\n\n${theme.fg("error", errorText)}`;
991
+ }
992
+ } else if (!this.isPartial && this.writeDiffPreview) {
993
+ if ("error" in this.writeDiffPreview) {
994
+ text += `\n\n${theme.fg("error", this.writeDiffPreview.error)}`;
995
+ } else if (this.writeDiffPreview.diff) {
996
+ text += hideCollapsedPreview
997
+ ? this.collapsedHintWithPrefix()
998
+ : `\n\n${renderDiff(this.writeDiffPreview.diff, { filePath: rawPath ?? undefined })}`;
999
+ }
1000
+ } else if (fileContent) {
1001
+ const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
1002
+
1003
+ let lines: string[];
1004
+ if (lang) {
1005
+ const cache = this.writeHighlightCache;
1006
+ if (cache && cache.lang === lang && cache.rawPath === rawPath && cache.rawContent === fileContent) {
1007
+ lines = cache.highlightedLines;
1008
+ } else {
1009
+ const displayContent = normalizeDisplayText(fileContent);
1010
+ const normalized = replaceTabs(displayContent);
1011
+ lines = highlightCode(normalized, lang);
1012
+ this.writeHighlightCache = {
1013
+ rawPath,
1014
+ lang,
1015
+ rawContent: fileContent,
1016
+ normalizedLines: normalized.split("\n"),
1017
+ highlightedLines: lines,
1018
+ };
1019
+ }
1020
+ } else {
1021
+ lines = normalizeDisplayText(fileContent).split("\n");
1022
+ this.writeHighlightCache = undefined;
1023
+ }
1024
+
1025
+ if (hideCollapsedPreview) {
1026
+ text += this.collapsedHintWithPrefix();
1027
+ } else {
1028
+ const totalLines = lines.length;
1029
+ const maxLines = this.expanded ? lines.length : 10;
1030
+ const displayLines = lines.slice(0, maxLines);
1031
+ const remaining = lines.length - maxLines;
1032
+
1033
+ text +=
1034
+ "\n\n" +
1035
+ displayLines.map((line: string) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n");
1036
+ if (remaining > 0) {
1037
+ text += theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total)`);
1038
+ }
1039
+ }
1040
+ }
1041
+ } else if (this.toolName === "edit") {
1042
+ const rawPath = str(this.args?.file_path ?? this.args?.path);
1043
+ const path = rawPath !== null ? shortenPath(rawPath) : null;
1044
+
1045
+ // Build path display, appending :line if we have diff info
1046
+ const firstChangedLine =
1047
+ (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
1048
+ ? this.editDiffPreview.firstChangedLine
1049
+ : undefined) ||
1050
+ (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
1051
+
1052
+ let styledEditPath = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
1053
+ if (rawPath && path) {
1054
+ styledEditPath = editorLink(rawPath, styledEditPath, {
1055
+ cwd: this.cwd,
1056
+ line: firstChangedLine ?? undefined,
1057
+ scheme: this.editorScheme,
1058
+ });
1059
+ }
1060
+ let pathDisplay = styledEditPath;
1061
+ if (firstChangedLine) {
1062
+ pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
1063
+ }
1064
+
1065
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
1066
+
1067
+ if (this.result?.isError) {
1068
+ // Show error from result
1069
+ const errorText = this.getTextOutput();
1070
+ if (errorText) {
1071
+ text += `\n\n${theme.fg("error", errorText)}`;
1072
+ }
1073
+ } else if (this.result?.details?.diff) {
1074
+ // Tool executed successfully - use the diff from result
1075
+ // This takes priority over editDiffPreview which may have a stale error
1076
+ // due to race condition (async preview computed after file was modified)
1077
+ text += hideCollapsedPreview
1078
+ ? this.collapsedHintWithPrefix()
1079
+ : `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath ?? undefined })}`;
1080
+ } else if (this.editDiffPreview) {
1081
+ // Use cached diff preview (before tool executes)
1082
+ if ("error" in this.editDiffPreview) {
1083
+ text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
1084
+ } else if (this.editDiffPreview.diff) {
1085
+ text += hideCollapsedPreview
1086
+ ? this.collapsedHintWithPrefix()
1087
+ : `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath ?? undefined })}`;
1088
+ }
1089
+ }
1090
+ } else if (this.toolName === "ls") {
1091
+ const rawPath = str(this.args?.path);
1092
+ const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
1093
+ const limit = this.args?.limit;
1094
+
1095
+ let lsPathDisplay = path === null ? invalidArg : theme.fg("accent", path);
1096
+ if (path && rawPath !== null) {
1097
+ lsPathDisplay = editorLink(rawPath || ".", lsPathDisplay, { cwd: this.cwd, scheme: this.editorScheme });
1098
+ }
1099
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("ls"))} ${lsPathDisplay}`;
1100
+ if (limit !== undefined) {
1101
+ text += theme.fg("toolOutput", ` (limit ${limit})`);
1102
+ }
1103
+
1104
+ if (this.result) {
1105
+ const output = this.getTextOutput().trim();
1106
+ if (output) {
1107
+ if (hideCollapsedPreview) {
1108
+ text += this.collapsedHintWithPrefix();
1109
+ } else {
1110
+ const lines = output.split("\n");
1111
+ const maxLines = this.expanded ? lines.length : 20;
1112
+ const displayLines = lines.slice(0, maxLines);
1113
+ const remaining = lines.length - maxLines;
1114
+
1115
+ text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1116
+ if (remaining > 0) {
1117
+ text += theme.fg("muted", `\n... (${remaining} more lines)`);
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ const entryLimit = this.result.details?.entryLimitReached;
1123
+ const truncation = this.result.details?.truncation;
1124
+ if (entryLimit || truncation?.truncated) {
1125
+ const warnings: string[] = [];
1126
+ if (entryLimit) {
1127
+ warnings.push(`${entryLimit} entries limit`);
1128
+ }
1129
+ if (truncation?.truncated) {
1130
+ warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
1131
+ }
1132
+ text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
1133
+ }
1134
+ }
1135
+ } else if (this.toolName === "find") {
1136
+ const pattern = str(this.args?.pattern);
1137
+ const rawPath = str(this.args?.path);
1138
+ const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
1139
+ const limit = this.args?.limit;
1140
+
1141
+ let findPathDisplay = path === null ? invalidArg : path;
1142
+ if (path && rawPath !== null) {
1143
+ findPathDisplay = editorLink(rawPath || ".", theme.fg("accent", findPathDisplay), { cwd: this.cwd, scheme: this.editorScheme });
1144
+ } else {
1145
+ findPathDisplay = theme.fg("accent", findPathDisplay);
1146
+ }
1147
+ text =
1148
+ `${statusIndicator} ${theme.fg("toolTitle", theme.bold("find"))}` +
1149
+ " " +
1150
+ (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
1151
+ theme.fg("toolOutput", " in ") +
1152
+ findPathDisplay;
1153
+ if (limit !== undefined) {
1154
+ text += theme.fg("toolOutput", ` (limit ${limit})`);
1155
+ }
1156
+
1157
+ if (this.result) {
1158
+ const output = this.getTextOutput().trim();
1159
+ if (output) {
1160
+ if (hideCollapsedPreview) {
1161
+ text += this.collapsedHintWithPrefix();
1162
+ } else {
1163
+ const lines = output.split("\n");
1164
+ const maxLines = this.expanded ? lines.length : 20;
1165
+ const displayLines = lines.slice(0, maxLines);
1166
+ const remaining = lines.length - maxLines;
1167
+
1168
+ text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1169
+ if (remaining > 0) {
1170
+ text += theme.fg("muted", `\n... (${remaining} more lines)`);
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ const resultLimit = this.result.details?.resultLimitReached;
1176
+ const truncation = this.result.details?.truncation;
1177
+ if (resultLimit || truncation?.truncated) {
1178
+ const warnings: string[] = [];
1179
+ if (resultLimit) {
1180
+ warnings.push(`${resultLimit} results limit`);
1181
+ }
1182
+ if (truncation?.truncated) {
1183
+ warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
1184
+ }
1185
+ text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
1186
+ }
1187
+ }
1188
+ } else if (this.toolName === "grep") {
1189
+ const pattern = str(this.args?.pattern);
1190
+ const rawPath = str(this.args?.path);
1191
+ const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
1192
+ const glob = str(this.args?.glob);
1193
+ const limit = this.args?.limit;
1194
+
1195
+ let grepPathDisplay = path === null ? invalidArg : path;
1196
+ if (path && rawPath !== null) {
1197
+ grepPathDisplay = editorLink(rawPath || ".", theme.fg("accent", grepPathDisplay), { cwd: this.cwd, scheme: this.editorScheme });
1198
+ } else {
1199
+ grepPathDisplay = theme.fg("accent", grepPathDisplay);
1200
+ }
1201
+ text =
1202
+ `${statusIndicator} ${theme.fg("toolTitle", theme.bold("grep"))}` +
1203
+ " " +
1204
+ (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
1205
+ theme.fg("toolOutput", " in ") +
1206
+ grepPathDisplay;
1207
+ if (glob) {
1208
+ text += theme.fg("toolOutput", ` (${glob})`);
1209
+ }
1210
+ if (limit !== undefined) {
1211
+ text += theme.fg("toolOutput", ` limit ${limit}`);
1212
+ }
1213
+
1214
+ if (this.result) {
1215
+ const output = this.getTextOutput().trim();
1216
+ if (output) {
1217
+ if (hideCollapsedPreview) {
1218
+ text += this.collapsedHintWithPrefix();
1219
+ } else {
1220
+ const lines = output.split("\n");
1221
+ const maxLines = this.expanded ? lines.length : 15;
1222
+ const displayLines = lines.slice(0, maxLines);
1223
+ const remaining = lines.length - maxLines;
1224
+
1225
+ text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1226
+ if (remaining > 0) {
1227
+ text += theme.fg("muted", `\n... (${remaining} more lines)`);
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ const matchLimit = this.result.details?.matchLimitReached;
1233
+ const truncation = this.result.details?.truncation;
1234
+ const linesTruncated = this.result.details?.linesTruncated;
1235
+ if (matchLimit || truncation?.truncated || linesTruncated) {
1236
+ const warnings: string[] = [];
1237
+ if (matchLimit) {
1238
+ warnings.push(`${matchLimit} matches limit`);
1239
+ }
1240
+ if (truncation?.truncated) {
1241
+ warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
1242
+ }
1243
+ if (linesTruncated) {
1244
+ warnings.push("some lines truncated");
1245
+ }
1246
+ text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
1247
+ }
1248
+ }
1249
+ } else if (this.toolName === "web_search") {
1250
+ // Server-side Anthropic web search
1251
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("web search"))}`;
1252
+
1253
+ if (process.env.PI_OFFLINE === "1") {
1254
+ text += "\n\n" + theme.fg("muted", "\u{1F50C} Offline \u{2014} web search unavailable");
1255
+ } else if (this.result) {
1256
+ const output = this.getTextOutput().trim();
1257
+ if (output) {
1258
+ if (hideCollapsedPreview) {
1259
+ text += this.collapsedHintWithPrefix();
1260
+ } else {
1261
+ const lines = output.split("\n");
1262
+ const maxLines = this.expanded ? lines.length : 10;
1263
+ const displayLines = lines.slice(0, maxLines);
1264
+ const remaining = lines.length - maxLines;
1265
+
1266
+ text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
1267
+ if (remaining > 0) {
1268
+ text += theme.fg("muted", `\n... (${remaining} more lines)`);
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+ } else if (this.toolName === "lsp") {
1274
+ const action = this.args?.action as string | undefined;
1275
+ const file = this.args?.file as string | undefined;
1276
+ const line = this.args?.line as number | undefined;
1277
+ const symbol = this.args?.symbol as string | undefined;
1278
+ const query = this.args?.query as string | undefined;
1279
+ const newName = this.args?.new_name as string | undefined;
1280
+
1281
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold("lsp"))}`;
1282
+ if (action) text += ` ${theme.fg("accent", action)}`;
1283
+ if (file) {
1284
+ const shortFile = shortenPath(file);
1285
+ let styledFile = theme.fg("muted", shortFile);
1286
+ if (file && shortFile) {
1287
+ styledFile = editorLink(file, styledFile, { cwd: this.cwd, line, scheme: this.editorScheme });
1288
+ }
1289
+ text += ` ${styledFile}`;
1290
+ if (line !== undefined) text += theme.fg("warning", `:${line}`);
1291
+ }
1292
+ if (symbol) text += ` ${theme.fg("toolOutput", symbol)}`;
1293
+ if (query) text += ` ${theme.fg("muted", `"${query}"`)}`;
1294
+ if (newName) text += ` → ${theme.fg("accent", newName)}`;
1295
+
1296
+ if (this.result) {
1297
+ const output = this.getTextOutput().trim();
1298
+ if (output) {
1299
+ if (hideCollapsedPreview) {
1300
+ text += this.collapsedHintWithPrefix();
1301
+ } else {
1302
+ const lines = output.split("\n");
1303
+ const maxLines = this.expanded ? lines.length : 10;
1304
+ const displayLines = lines.slice(0, maxLines);
1305
+ const remaining = lines.length - maxLines;
1306
+ text += `\n\n${displayLines.map((l: string) => theme.fg("toolOutput", l)).join("\n")}`;
1307
+ if (remaining > 0) text += theme.fg("muted", `\n... (${remaining} more lines)`);
1308
+ }
1309
+ }
1310
+ }
1311
+ } else {
1312
+ // Generic tool (shouldn't reach here for custom tools)
1313
+ text = `${statusIndicator} ${theme.fg("toolTitle", theme.bold(this.toolName))}`;
1314
+
1315
+ const content = JSON.stringify(this.args, null, 2);
1316
+ text += hideCollapsedPreview ? this.collapsedHintWithPrefix() : `\n\n${content}`;
1317
+ const output = this.getTextOutput();
1318
+ if (output && !hideCollapsedPreview) {
1319
+ text += `\n${output}`;
1320
+ }
1321
+ }
1322
+
1323
+ return text;
1324
+ }
1201
1325
  }