@oh-my-pi/pi-coding-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/CHANGELOG.md +1629 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/config-usage.md +113 -0
  5. package/docs/custom-tools.md +541 -0
  6. package/docs/extension-loading.md +1004 -0
  7. package/docs/hooks.md +867 -0
  8. package/docs/rpc.md +1040 -0
  9. package/docs/sdk.md +994 -0
  10. package/docs/session-tree-plan.md +441 -0
  11. package/docs/session.md +240 -0
  12. package/docs/skills.md +290 -0
  13. package/docs/theme.md +670 -0
  14. package/docs/tree.md +197 -0
  15. package/docs/tui.md +341 -0
  16. package/examples/README.md +21 -0
  17. package/examples/custom-tools/README.md +124 -0
  18. package/examples/custom-tools/hello/index.ts +20 -0
  19. package/examples/custom-tools/question/index.ts +84 -0
  20. package/examples/custom-tools/subagent/README.md +172 -0
  21. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +89 -0
  57. package/src/bun-imports.d.ts +16 -0
  58. package/src/capability/context-file.ts +40 -0
  59. package/src/capability/extension.ts +48 -0
  60. package/src/capability/hook.ts +40 -0
  61. package/src/capability/index.ts +616 -0
  62. package/src/capability/instruction.ts +37 -0
  63. package/src/capability/mcp.ts +52 -0
  64. package/src/capability/prompt.ts +35 -0
  65. package/src/capability/rule.ts +56 -0
  66. package/src/capability/settings.ts +35 -0
  67. package/src/capability/skill.ts +49 -0
  68. package/src/capability/slash-command.ts +40 -0
  69. package/src/capability/system-prompt.ts +35 -0
  70. package/src/capability/tool.ts +38 -0
  71. package/src/capability/types.ts +166 -0
  72. package/src/cli/args.ts +259 -0
  73. package/src/cli/file-processor.ts +121 -0
  74. package/src/cli/list-models.ts +104 -0
  75. package/src/cli/plugin-cli.ts +661 -0
  76. package/src/cli/session-picker.ts +41 -0
  77. package/src/cli/update-cli.ts +274 -0
  78. package/src/cli.ts +10 -0
  79. package/src/config.ts +391 -0
  80. package/src/core/agent-session.ts +2178 -0
  81. package/src/core/auth-storage.ts +258 -0
  82. package/src/core/bash-executor.ts +197 -0
  83. package/src/core/compaction/branch-summarization.ts +315 -0
  84. package/src/core/compaction/compaction.ts +664 -0
  85. package/src/core/compaction/index.ts +7 -0
  86. package/src/core/compaction/utils.ts +153 -0
  87. package/src/core/custom-commands/bundled/review/index.ts +156 -0
  88. package/src/core/custom-commands/index.ts +15 -0
  89. package/src/core/custom-commands/loader.ts +226 -0
  90. package/src/core/custom-commands/types.ts +112 -0
  91. package/src/core/custom-tools/index.ts +22 -0
  92. package/src/core/custom-tools/loader.ts +248 -0
  93. package/src/core/custom-tools/types.ts +185 -0
  94. package/src/core/custom-tools/wrapper.ts +29 -0
  95. package/src/core/exec.ts +139 -0
  96. package/src/core/export-html/index.ts +159 -0
  97. package/src/core/export-html/template.css +774 -0
  98. package/src/core/export-html/template.generated.ts +2 -0
  99. package/src/core/export-html/template.html +45 -0
  100. package/src/core/export-html/template.js +1185 -0
  101. package/src/core/export-html/template.macro.ts +24 -0
  102. package/src/core/file-mentions.ts +54 -0
  103. package/src/core/hooks/index.ts +16 -0
  104. package/src/core/hooks/loader.ts +288 -0
  105. package/src/core/hooks/runner.ts +434 -0
  106. package/src/core/hooks/tool-wrapper.ts +98 -0
  107. package/src/core/hooks/types.ts +770 -0
  108. package/src/core/index.ts +53 -0
  109. package/src/core/logger.ts +112 -0
  110. package/src/core/mcp/client.ts +185 -0
  111. package/src/core/mcp/config.ts +248 -0
  112. package/src/core/mcp/index.ts +45 -0
  113. package/src/core/mcp/loader.ts +99 -0
  114. package/src/core/mcp/manager.ts +235 -0
  115. package/src/core/mcp/tool-bridge.ts +156 -0
  116. package/src/core/mcp/transports/http.ts +316 -0
  117. package/src/core/mcp/transports/index.ts +6 -0
  118. package/src/core/mcp/transports/stdio.ts +252 -0
  119. package/src/core/mcp/types.ts +228 -0
  120. package/src/core/messages.ts +211 -0
  121. package/src/core/model-registry.ts +334 -0
  122. package/src/core/model-resolver.ts +494 -0
  123. package/src/core/plugins/doctor.ts +67 -0
  124. package/src/core/plugins/index.ts +38 -0
  125. package/src/core/plugins/installer.ts +189 -0
  126. package/src/core/plugins/loader.ts +339 -0
  127. package/src/core/plugins/manager.ts +672 -0
  128. package/src/core/plugins/parser.ts +105 -0
  129. package/src/core/plugins/paths.ts +37 -0
  130. package/src/core/plugins/types.ts +190 -0
  131. package/src/core/sdk.ts +900 -0
  132. package/src/core/session-manager.ts +1837 -0
  133. package/src/core/settings-manager.ts +860 -0
  134. package/src/core/skills.ts +352 -0
  135. package/src/core/slash-commands.ts +132 -0
  136. package/src/core/system-prompt.ts +442 -0
  137. package/src/core/timings.ts +25 -0
  138. package/src/core/title-generator.ts +110 -0
  139. package/src/core/tools/ask.ts +193 -0
  140. package/src/core/tools/bash-interceptor.ts +120 -0
  141. package/src/core/tools/bash.ts +91 -0
  142. package/src/core/tools/context.ts +32 -0
  143. package/src/core/tools/edit-diff.ts +487 -0
  144. package/src/core/tools/edit.ts +140 -0
  145. package/src/core/tools/exa/company.ts +59 -0
  146. package/src/core/tools/exa/index.ts +63 -0
  147. package/src/core/tools/exa/linkedin.ts +59 -0
  148. package/src/core/tools/exa/mcp-client.ts +368 -0
  149. package/src/core/tools/exa/render.ts +200 -0
  150. package/src/core/tools/exa/researcher.ts +90 -0
  151. package/src/core/tools/exa/search.ts +338 -0
  152. package/src/core/tools/exa/types.ts +167 -0
  153. package/src/core/tools/exa/websets.ts +248 -0
  154. package/src/core/tools/find.ts +244 -0
  155. package/src/core/tools/grep.ts +584 -0
  156. package/src/core/tools/index.ts +283 -0
  157. package/src/core/tools/ls.ts +142 -0
  158. package/src/core/tools/lsp/client.ts +767 -0
  159. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  160. package/src/core/tools/lsp/clients/index.ts +49 -0
  161. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  162. package/src/core/tools/lsp/config.ts +845 -0
  163. package/src/core/tools/lsp/edits.ts +110 -0
  164. package/src/core/tools/lsp/index.ts +1364 -0
  165. package/src/core/tools/lsp/render.ts +560 -0
  166. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  167. package/src/core/tools/lsp/types.ts +495 -0
  168. package/src/core/tools/lsp/utils.ts +526 -0
  169. package/src/core/tools/notebook.ts +182 -0
  170. package/src/core/tools/output.ts +198 -0
  171. package/src/core/tools/path-utils.ts +61 -0
  172. package/src/core/tools/read.ts +507 -0
  173. package/src/core/tools/renderers.ts +820 -0
  174. package/src/core/tools/review.ts +275 -0
  175. package/src/core/tools/rulebook.ts +124 -0
  176. package/src/core/tools/task/agents.ts +158 -0
  177. package/src/core/tools/task/artifacts.ts +114 -0
  178. package/src/core/tools/task/commands.ts +157 -0
  179. package/src/core/tools/task/discovery.ts +217 -0
  180. package/src/core/tools/task/executor.ts +531 -0
  181. package/src/core/tools/task/index.ts +548 -0
  182. package/src/core/tools/task/model-resolver.ts +176 -0
  183. package/src/core/tools/task/parallel.ts +38 -0
  184. package/src/core/tools/task/render.ts +502 -0
  185. package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
  186. package/src/core/tools/task/types.ts +142 -0
  187. package/src/core/tools/truncate.ts +265 -0
  188. package/src/core/tools/web-fetch.ts +2511 -0
  189. package/src/core/tools/web-search/auth.ts +199 -0
  190. package/src/core/tools/web-search/index.ts +583 -0
  191. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  192. package/src/core/tools/web-search/providers/exa.ts +196 -0
  193. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  194. package/src/core/tools/web-search/render.ts +372 -0
  195. package/src/core/tools/web-search/types.ts +180 -0
  196. package/src/core/tools/write.ts +63 -0
  197. package/src/core/ttsr.ts +211 -0
  198. package/src/core/utils.ts +187 -0
  199. package/src/discovery/agents-md.ts +75 -0
  200. package/src/discovery/builtin.ts +647 -0
  201. package/src/discovery/claude.ts +623 -0
  202. package/src/discovery/cline.ts +104 -0
  203. package/src/discovery/codex.ts +571 -0
  204. package/src/discovery/cursor.ts +266 -0
  205. package/src/discovery/gemini.ts +368 -0
  206. package/src/discovery/github.ts +120 -0
  207. package/src/discovery/helpers.test.ts +127 -0
  208. package/src/discovery/helpers.ts +249 -0
  209. package/src/discovery/index.ts +84 -0
  210. package/src/discovery/mcp-json.ts +127 -0
  211. package/src/discovery/vscode.ts +99 -0
  212. package/src/discovery/windsurf.ts +219 -0
  213. package/src/index.ts +192 -0
  214. package/src/main.ts +507 -0
  215. package/src/migrations.ts +156 -0
  216. package/src/modes/cleanup.ts +23 -0
  217. package/src/modes/index.ts +48 -0
  218. package/src/modes/interactive/components/armin.ts +382 -0
  219. package/src/modes/interactive/components/assistant-message.ts +86 -0
  220. package/src/modes/interactive/components/bash-execution.ts +199 -0
  221. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  222. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  223. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  224. package/src/modes/interactive/components/custom-editor.ts +122 -0
  225. package/src/modes/interactive/components/diff.ts +147 -0
  226. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  227. package/src/modes/interactive/components/extensions/extension-dashboard.ts +296 -0
  228. package/src/modes/interactive/components/extensions/extension-list.ts +479 -0
  229. package/src/modes/interactive/components/extensions/index.ts +9 -0
  230. package/src/modes/interactive/components/extensions/inspector-panel.ts +313 -0
  231. package/src/modes/interactive/components/extensions/state-manager.ts +558 -0
  232. package/src/modes/interactive/components/extensions/types.ts +191 -0
  233. package/src/modes/interactive/components/hook-editor.ts +117 -0
  234. package/src/modes/interactive/components/hook-input.ts +64 -0
  235. package/src/modes/interactive/components/hook-message.ts +96 -0
  236. package/src/modes/interactive/components/hook-selector.ts +91 -0
  237. package/src/modes/interactive/components/model-selector.ts +560 -0
  238. package/src/modes/interactive/components/oauth-selector.ts +136 -0
  239. package/src/modes/interactive/components/plugin-settings.ts +481 -0
  240. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  241. package/src/modes/interactive/components/session-selector.ts +220 -0
  242. package/src/modes/interactive/components/settings-defs.ts +597 -0
  243. package/src/modes/interactive/components/settings-selector.ts +545 -0
  244. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  245. package/src/modes/interactive/components/status-line/index.ts +4 -0
  246. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  247. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  248. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  249. package/src/modes/interactive/components/status-line/types.ts +81 -0
  250. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  251. package/src/modes/interactive/components/status-line.ts +384 -0
  252. package/src/modes/interactive/components/theme-selector.ts +62 -0
  253. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  254. package/src/modes/interactive/components/tool-execution.ts +946 -0
  255. package/src/modes/interactive/components/tree-selector.ts +877 -0
  256. package/src/modes/interactive/components/ttsr-notification.ts +82 -0
  257. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  258. package/src/modes/interactive/components/user-message.ts +18 -0
  259. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  260. package/src/modes/interactive/components/welcome.ts +228 -0
  261. package/src/modes/interactive/interactive-mode.ts +2669 -0
  262. package/src/modes/interactive/theme/dark.json +102 -0
  263. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  264. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  265. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  266. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  267. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  268. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  269. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  270. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  271. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  272. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  273. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  274. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  275. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  276. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  277. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  278. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  279. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  280. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  281. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  282. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  283. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  284. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  285. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  286. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  287. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  288. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  289. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  290. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  291. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  292. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  293. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  294. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  295. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  296. package/src/modes/interactive/theme/light.json +99 -0
  297. package/src/modes/interactive/theme/theme-schema.json +424 -0
  298. package/src/modes/interactive/theme/theme.ts +2211 -0
  299. package/src/modes/print-mode.ts +163 -0
  300. package/src/modes/rpc/rpc-client.ts +527 -0
  301. package/src/modes/rpc/rpc-mode.ts +494 -0
  302. package/src/modes/rpc/rpc-types.ts +203 -0
  303. package/src/prompts/architect-plan.md +10 -0
  304. package/src/prompts/branch-summary-preamble.md +3 -0
  305. package/src/prompts/branch-summary.md +28 -0
  306. package/src/prompts/browser.md +71 -0
  307. package/src/prompts/compaction-summary.md +34 -0
  308. package/src/prompts/compaction-turn-prefix.md +16 -0
  309. package/src/prompts/compaction-update-summary.md +41 -0
  310. package/src/prompts/explore.md +82 -0
  311. package/src/prompts/implement-with-critic.md +11 -0
  312. package/src/prompts/implement.md +11 -0
  313. package/src/prompts/init.md +30 -0
  314. package/src/prompts/plan.md +54 -0
  315. package/src/prompts/reviewer.md +81 -0
  316. package/src/prompts/summarization-system.md +3 -0
  317. package/src/prompts/system-prompt.md +27 -0
  318. package/src/prompts/task.md +56 -0
  319. package/src/prompts/title-system.md +8 -0
  320. package/src/prompts/tools/ask.md +24 -0
  321. package/src/prompts/tools/bash.md +23 -0
  322. package/src/prompts/tools/edit.md +9 -0
  323. package/src/prompts/tools/find.md +6 -0
  324. package/src/prompts/tools/grep.md +12 -0
  325. package/src/prompts/tools/lsp.md +14 -0
  326. package/src/prompts/tools/output.md +23 -0
  327. package/src/prompts/tools/read.md +25 -0
  328. package/src/prompts/tools/web-fetch.md +8 -0
  329. package/src/prompts/tools/web-search.md +10 -0
  330. package/src/prompts/tools/write.md +10 -0
  331. package/src/utils/changelog.ts +99 -0
  332. package/src/utils/clipboard.ts +265 -0
  333. package/src/utils/fuzzy.ts +108 -0
  334. package/src/utils/mime.ts +30 -0
  335. package/src/utils/shell-snapshot.ts +218 -0
  336. package/src/utils/shell.ts +364 -0
  337. package/src/utils/tools-manager.ts +265 -0
@@ -0,0 +1,946 @@
1
+ import * as os from "node:os";
2
+ import {
3
+ Box,
4
+ Container,
5
+ getCapabilities,
6
+ getImageDimensions,
7
+ Image,
8
+ imageFallback,
9
+ Spacer,
10
+ Text,
11
+ type TUI,
12
+ } from "@oh-my-pi/pi-tui";
13
+ import stripAnsi from "strip-ansi";
14
+ import type { CustomTool } from "../../../core/custom-tools/types";
15
+ import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff";
16
+ import { toolRenderers } from "../../../core/tools/renderers";
17
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate";
18
+ import { sanitizeBinaryOutput } from "../../../utils/shell";
19
+ import { getLanguageFromPath, highlightCode, theme } from "../theme/theme";
20
+ import { renderDiff } from "./diff";
21
+ import { truncateToVisualLines } from "./visual-truncate";
22
+
23
+ // Preview line limit for bash when not expanded
24
+ const BASH_PREVIEW_LINES = 5;
25
+ const GENERIC_PREVIEW_LINES = 6;
26
+ const GENERIC_ARG_PREVIEW = 6;
27
+ const GENERIC_VALUE_MAX = 80;
28
+ const EDIT_DIFF_PREVIEW_HUNKS = 2;
29
+ const EDIT_DIFF_PREVIEW_LINES = 24;
30
+
31
+ function wrapBrackets(text: string): string {
32
+ return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
33
+ }
34
+
35
+ function countLines(text: string): number {
36
+ if (!text) return 0;
37
+ return text.split("\n").length;
38
+ }
39
+
40
+ function formatMetadataLine(lineCount: number | null, language: string | undefined): string {
41
+ const icon = theme.getLangIcon(language);
42
+ if (lineCount !== null) {
43
+ return theme.fg("dim", `${icon} ${lineCount} lines`);
44
+ }
45
+ return theme.fg("dim", `${icon}`);
46
+ }
47
+
48
+ const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]);
49
+ const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]);
50
+
51
+ function getFileType(filePath: string): "image" | "binary" | "text" {
52
+ const ext = filePath.split(".").pop()?.toLowerCase();
53
+ if (!ext) return "text";
54
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
55
+ if (BINARY_EXTENSIONS.has(ext)) return "binary";
56
+ return "text";
57
+ }
58
+
59
+ function formatDiffStats(added: number, removed: number, hunks: number): string {
60
+ const parts: string[] = [];
61
+ if (added > 0) parts.push(theme.fg("success", `+${added}`));
62
+ if (removed > 0) parts.push(theme.fg("error", `-${removed}`));
63
+ if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
64
+ return parts.join(theme.fg("dim", " / "));
65
+ }
66
+
67
+ type DiffStats = {
68
+ added: number;
69
+ removed: number;
70
+ hunks: number;
71
+ lines: number;
72
+ };
73
+
74
+ function getDiffStats(diffText: string): DiffStats {
75
+ const lines = diffText ? diffText.split("\n") : [];
76
+ let added = 0;
77
+ let removed = 0;
78
+ let hunks = 0;
79
+ let inHunk = false;
80
+
81
+ for (const line of lines) {
82
+ const isAdded = line.startsWith("+");
83
+ const isRemoved = line.startsWith("-");
84
+ const isChange = isAdded || isRemoved;
85
+
86
+ if (isAdded) added++;
87
+ if (isRemoved) removed++;
88
+
89
+ if (isChange && !inHunk) {
90
+ hunks++;
91
+ inHunk = true;
92
+ } else if (!isChange) {
93
+ inHunk = false;
94
+ }
95
+ }
96
+
97
+ return { added, removed, hunks, lines: lines.length };
98
+ }
99
+
100
+ function truncateDiffByHunk(
101
+ diffText: string,
102
+ maxHunks: number,
103
+ maxLines: number,
104
+ ): { text: string; hiddenHunks: number; hiddenLines: number } {
105
+ const lines = diffText ? diffText.split("\n") : [];
106
+ const totalStats = getDiffStats(diffText);
107
+ const kept: string[] = [];
108
+ let inHunk = false;
109
+ let currentHunks = 0;
110
+ let reachedLimit = false;
111
+
112
+ for (const line of lines) {
113
+ const isChange = line.startsWith("+") || line.startsWith("-");
114
+ if (isChange && !inHunk) {
115
+ currentHunks++;
116
+ inHunk = true;
117
+ }
118
+ if (!isChange) {
119
+ inHunk = false;
120
+ }
121
+
122
+ if (currentHunks > maxHunks) {
123
+ reachedLimit = true;
124
+ break;
125
+ }
126
+
127
+ kept.push(line);
128
+ if (kept.length >= maxLines) {
129
+ reachedLimit = true;
130
+ break;
131
+ }
132
+ }
133
+
134
+ if (!reachedLimit) {
135
+ return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
136
+ }
137
+
138
+ const keptStats = getDiffStats(kept.join("\n"));
139
+ return {
140
+ text: kept.join("\n"),
141
+ hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
142
+ hiddenLines: Math.max(0, totalStats.lines - kept.length),
143
+ };
144
+ }
145
+
146
+ interface ParsedDiagnostic {
147
+ filePath: string;
148
+ line: number;
149
+ col: number;
150
+ severity: "error" | "warning" | "info" | "hint";
151
+ source?: string;
152
+ message: string;
153
+ code?: string;
154
+ }
155
+
156
+ function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
157
+ // Format: filePath:line:col [severity] [source] message (code)
158
+ const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
159
+ if (!match) return null;
160
+ return {
161
+ filePath: match[1],
162
+ line: parseInt(match[2], 10),
163
+ col: parseInt(match[3], 10),
164
+ severity: match[4] as ParsedDiagnostic["severity"],
165
+ source: match[5],
166
+ message: match[6],
167
+ code: match[7],
168
+ };
169
+ }
170
+
171
+ function formatDiagnostics(diag: { errored: boolean; summary: string; messages: string[] }, expanded: boolean): string {
172
+ if (diag.messages.length === 0) return "";
173
+
174
+ // Parse and group diagnostics by file
175
+ const byFile = new Map<string, ParsedDiagnostic[]>();
176
+ const unparsed: string[] = [];
177
+
178
+ for (const msg of diag.messages) {
179
+ const parsed = parseDiagnosticMessage(msg);
180
+ if (parsed) {
181
+ const existing = byFile.get(parsed.filePath) ?? [];
182
+ existing.push(parsed);
183
+ byFile.set(parsed.filePath, existing);
184
+ } else {
185
+ unparsed.push(msg);
186
+ }
187
+ }
188
+
189
+ const headerIcon = diag.errored
190
+ ? theme.styledSymbol("status.error", "error")
191
+ : theme.styledSymbol("status.warning", "warning");
192
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
193
+
194
+ const maxDiags = expanded ? diag.messages.length : 5;
195
+ let shown = 0;
196
+
197
+ // Render grouped diagnostics with file icons
198
+ const files = Array.from(byFile.entries());
199
+ for (let fi = 0; fi < files.length && shown < maxDiags; fi++) {
200
+ const [filePath, diagnostics] = files[fi];
201
+ const isLastFile = fi === files.length - 1 && unparsed.length === 0;
202
+ const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch;
203
+
204
+ // File header with icon
205
+ output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("muted", theme.icon.file)} ${theme.fg("accent", filePath)}`;
206
+ shown++;
207
+
208
+ // Render diagnostics for this file
209
+ for (let di = 0; di < diagnostics.length && shown < maxDiags; di++) {
210
+ const d = diagnostics[di];
211
+ const isLastDiag = di === diagnostics.length - 1;
212
+ const diagBranch = isLastFile
213
+ ? isLastDiag
214
+ ? ` ${theme.tree.last}`
215
+ : ` ${theme.tree.branch}`
216
+ : isLastDiag
217
+ ? ` ${theme.tree.vertical} ${theme.tree.last}`
218
+ : ` ${theme.tree.vertical} ${theme.tree.branch}`;
219
+
220
+ const sevIcon =
221
+ d.severity === "error"
222
+ ? theme.styledSymbol("status.error", "error")
223
+ : d.severity === "warning"
224
+ ? theme.styledSymbol("status.warning", "warning")
225
+ : theme.styledSymbol("status.info", "muted");
226
+ const location = theme.fg("dim", `:${d.line}:${d.col}`);
227
+ const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
228
+ const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
229
+
230
+ output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
231
+ shown++;
232
+ }
233
+ }
234
+
235
+ // Render unparsed messages (fallback)
236
+ for (const msg of unparsed) {
237
+ if (shown >= maxDiags) break;
238
+ const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
239
+ output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg(color, msg)}`;
240
+ shown++;
241
+ }
242
+
243
+ if (diag.messages.length > shown) {
244
+ const remaining = diag.messages.length - shown;
245
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)} ${theme.fg("dim", "(Ctrl+O to expand)")}`;
246
+ }
247
+
248
+ return output;
249
+ }
250
+
251
+ function formatCompactValue(value: unknown, maxLength: number): string {
252
+ let rendered = "";
253
+
254
+ if (value === null) {
255
+ rendered = "null";
256
+ } else if (value === undefined) {
257
+ rendered = "undefined";
258
+ } else if (typeof value === "string") {
259
+ rendered = value;
260
+ } else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
261
+ rendered = String(value);
262
+ } else if (Array.isArray(value)) {
263
+ const previewItems = value.slice(0, 3).map((item) => formatCompactValue(item, maxLength));
264
+ rendered = `[${previewItems.join(", ")}${value.length > 3 ? ", ..." : ""}]`;
265
+ } else if (typeof value === "object") {
266
+ try {
267
+ rendered = JSON.stringify(value);
268
+ } catch {
269
+ rendered = "[object]";
270
+ }
271
+ } else if (typeof value === "function") {
272
+ rendered = "[function]";
273
+ } else {
274
+ rendered = String(value);
275
+ }
276
+
277
+ if (rendered.length > maxLength) {
278
+ rendered = `${rendered.slice(0, maxLength - 1)}${theme.format.ellipsis}`;
279
+ }
280
+
281
+ return rendered;
282
+ }
283
+
284
+ function formatArgsPreview(
285
+ args: unknown,
286
+ maxEntries: number,
287
+ maxValueLength: number,
288
+ ): { lines: string[]; remaining: number; total: number } {
289
+ if (args === undefined) {
290
+ return { lines: [theme.fg("dim", "(none)")], remaining: 0, total: 0 };
291
+ }
292
+ if (args === null || typeof args !== "object") {
293
+ const single = theme.fg("toolOutput", formatCompactValue(args, maxValueLength));
294
+ return { lines: [single], remaining: 0, total: 1 };
295
+ }
296
+
297
+ const entries = Object.entries(args as Record<string, unknown>);
298
+ const total = entries.length;
299
+ const visible = entries.slice(0, maxEntries);
300
+ const lines = visible.map(([key, value]) => {
301
+ const keyText = theme.fg("accent", key);
302
+ const valueText = theme.fg("toolOutput", formatCompactValue(value, maxValueLength));
303
+ return `${keyText}: ${valueText}`;
304
+ });
305
+
306
+ return { lines, remaining: Math.max(total - visible.length, 0), total };
307
+ }
308
+
309
+ /**
310
+ * Convert absolute path to tilde notation if it's in home directory
311
+ */
312
+ function shortenPath(path: string): string {
313
+ const home = os.homedir();
314
+ if (path.startsWith(home)) {
315
+ return `~${path.slice(home.length)}`;
316
+ }
317
+ return path;
318
+ }
319
+
320
+ /**
321
+ * Replace tabs with spaces for consistent rendering
322
+ */
323
+ function replaceTabs(text: string): string {
324
+ return text.replace(/\t/g, " ");
325
+ }
326
+
327
+ export interface ToolExecutionOptions {
328
+ showImages?: boolean; // default: true (only used if terminal supports images)
329
+ }
330
+
331
+ /**
332
+ * Component that renders a tool call with its result (updateable)
333
+ */
334
+ export class ToolExecutionComponent extends Container {
335
+ private contentBox: Box; // Used for custom tools and bash visual truncation
336
+ private contentText: Text; // For built-in tools (with its own padding/bg)
337
+ private imageComponents: Image[] = [];
338
+ private imageSpacers: Spacer[] = [];
339
+ private toolName: string;
340
+ private args: any;
341
+ private expanded = false;
342
+ private showImages: boolean;
343
+ private isPartial = true;
344
+ private customTool?: CustomTool;
345
+ private ui: TUI;
346
+ private cwd: string;
347
+ private result?: {
348
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
349
+ isError?: boolean;
350
+ details?: any;
351
+ };
352
+ // Cached edit diff preview (computed when args arrive, before tool executes)
353
+ private editDiffPreview?: EditDiffResult | EditDiffError;
354
+ private editDiffArgsKey?: string; // Track which args the preview is for
355
+ // Spinner animation for partial task results
356
+ private spinnerFrame = 0;
357
+ private spinnerInterval: ReturnType<typeof setInterval> | null = null;
358
+
359
+ constructor(
360
+ toolName: string,
361
+ args: any,
362
+ options: ToolExecutionOptions = {},
363
+ customTool: CustomTool | undefined,
364
+ ui: TUI,
365
+ cwd: string = process.cwd(),
366
+ ) {
367
+ super();
368
+ this.toolName = toolName;
369
+ this.args = args;
370
+ this.showImages = options.showImages ?? true;
371
+ this.customTool = customTool;
372
+ this.ui = ui;
373
+ this.cwd = cwd;
374
+
375
+ this.addChild(new Spacer(1));
376
+
377
+ // Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins
378
+ this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
379
+ this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
380
+
381
+ // Use Box for custom tools, bash, or built-in tools that have renderers
382
+ const hasRenderer = toolName in toolRenderers;
383
+ if (customTool || toolName === "bash" || hasRenderer) {
384
+ this.addChild(this.contentBox);
385
+ } else {
386
+ this.addChild(this.contentText);
387
+ }
388
+
389
+ this.updateDisplay();
390
+ }
391
+
392
+ updateArgs(args: any): void {
393
+ this.args = args;
394
+ this.updateDisplay();
395
+ }
396
+
397
+ /**
398
+ * Signal that args are complete (tool is about to execute).
399
+ * This triggers diff computation for edit tool.
400
+ */
401
+ setArgsComplete(): void {
402
+ this.maybeComputeEditDiff();
403
+ }
404
+
405
+ /**
406
+ * Compute edit diff preview when we have complete args.
407
+ * This runs async and updates display when done.
408
+ */
409
+ private maybeComputeEditDiff(): void {
410
+ if (this.toolName !== "edit") return;
411
+
412
+ const path = this.args?.path;
413
+ const oldText = this.args?.oldText;
414
+ const newText = this.args?.newText;
415
+
416
+ // Need all three params to compute diff
417
+ if (!path || oldText === undefined || newText === undefined) return;
418
+
419
+ // Create a key to track which args this computation is for
420
+ const argsKey = JSON.stringify({ path, oldText, newText });
421
+
422
+ // Skip if we already computed for these exact args
423
+ if (this.editDiffArgsKey === argsKey) return;
424
+
425
+ this.editDiffArgsKey = argsKey;
426
+
427
+ // Compute diff async
428
+ computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
429
+ // Only update if args haven't changed since we started
430
+ if (this.editDiffArgsKey === argsKey) {
431
+ this.editDiffPreview = result;
432
+ this.updateDisplay();
433
+ this.ui.requestRender();
434
+ }
435
+ });
436
+ }
437
+
438
+ updateResult(
439
+ result: {
440
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
441
+ details?: any;
442
+ isError?: boolean;
443
+ },
444
+ isPartial = false,
445
+ ): void {
446
+ this.result = result;
447
+ this.isPartial = isPartial;
448
+ this.updateSpinnerAnimation();
449
+ this.updateDisplay();
450
+ }
451
+
452
+ /**
453
+ * Start or stop spinner animation based on whether this is a partial task result.
454
+ */
455
+ private updateSpinnerAnimation(): void {
456
+ const needsSpinner = this.isPartial && this.toolName === "task";
457
+ if (needsSpinner && !this.spinnerInterval) {
458
+ this.spinnerInterval = setInterval(() => {
459
+ const frameCount = theme.spinnerFrames.length;
460
+ if (frameCount === 0) return;
461
+ this.spinnerFrame = (this.spinnerFrame + 1) % frameCount;
462
+ this.updateDisplay();
463
+ this.ui.requestRender();
464
+ }, 80);
465
+ } else if (!needsSpinner && this.spinnerInterval) {
466
+ clearInterval(this.spinnerInterval);
467
+ this.spinnerInterval = null;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Stop spinner animation and cleanup resources.
473
+ */
474
+ stopAnimation(): void {
475
+ if (this.spinnerInterval) {
476
+ clearInterval(this.spinnerInterval);
477
+ this.spinnerInterval = null;
478
+ }
479
+ }
480
+
481
+ setExpanded(expanded: boolean): void {
482
+ this.expanded = expanded;
483
+ this.updateDisplay();
484
+ }
485
+
486
+ setShowImages(show: boolean): void {
487
+ this.showImages = show;
488
+ this.updateDisplay();
489
+ }
490
+
491
+ private updateDisplay(): void {
492
+ // Set background based on state
493
+ const bgFn = this.isPartial
494
+ ? (text: string) => theme.bg("toolPendingBg", text)
495
+ : this.result?.isError
496
+ ? (text: string) => theme.bg("toolErrorBg", text)
497
+ : (text: string) => theme.bg("toolSuccessBg", text);
498
+
499
+ // Check for custom tool rendering
500
+ if (this.customTool) {
501
+ // Custom tools use Box for flexible component rendering
502
+ this.contentBox.setBgFn(bgFn);
503
+ this.contentBox.clear();
504
+
505
+ // Render call component
506
+ if (this.customTool.renderCall) {
507
+ try {
508
+ const callComponent = this.customTool.renderCall(this.args, theme);
509
+ if (callComponent) {
510
+ this.contentBox.addChild(callComponent);
511
+ }
512
+ } catch {
513
+ // Fall back to default on error
514
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
515
+ }
516
+ } else {
517
+ // No custom renderCall, show tool name
518
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
519
+ }
520
+
521
+ // Render result component if we have a result
522
+ if (this.result && this.customTool.renderResult) {
523
+ try {
524
+ const resultComponent = this.customTool.renderResult(
525
+ { content: this.result.content as any, details: this.result.details },
526
+ { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
527
+ theme,
528
+ );
529
+ if (resultComponent) {
530
+ this.contentBox.addChild(resultComponent);
531
+ }
532
+ } catch {
533
+ // Fall back to showing raw output on error
534
+ const output = this.getTextOutput();
535
+ if (output) {
536
+ this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
537
+ }
538
+ }
539
+ } else if (this.result) {
540
+ // Has result but no custom renderResult
541
+ const output = this.getTextOutput();
542
+ if (output) {
543
+ this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
544
+ }
545
+ }
546
+ } else if (this.toolName === "bash") {
547
+ // Bash uses Box with visual line truncation
548
+ this.contentBox.setBgFn(bgFn);
549
+ this.contentBox.clear();
550
+ this.renderBashContent();
551
+ } else if (this.toolName in toolRenderers) {
552
+ // Built-in tools with custom renderers
553
+ const renderer = toolRenderers[this.toolName];
554
+ this.contentBox.setBgFn(bgFn);
555
+ this.contentBox.clear();
556
+
557
+ // Render call component
558
+ try {
559
+ const callComponent = renderer.renderCall(this.args, theme);
560
+ if (callComponent) {
561
+ this.contentBox.addChild(callComponent);
562
+ }
563
+ } catch {
564
+ // Fall back to default on error
565
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0));
566
+ }
567
+
568
+ // Render result component if we have a result
569
+ if (this.result) {
570
+ try {
571
+ const resultComponent = renderer.renderResult(
572
+ { content: this.result.content as any, details: this.result.details },
573
+ { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
574
+ theme,
575
+ );
576
+ if (resultComponent) {
577
+ this.contentBox.addChild(resultComponent);
578
+ }
579
+ } catch {
580
+ // Fall back to showing raw output on error
581
+ const output = this.getTextOutput();
582
+ if (output) {
583
+ this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
584
+ }
585
+ }
586
+ }
587
+ } else {
588
+ // Other built-in tools: use Text directly with caching
589
+ this.contentText.setCustomBgFn(bgFn);
590
+ this.contentText.setText(this.formatToolExecution());
591
+ }
592
+
593
+ // Handle images (same for both custom and built-in)
594
+ for (const img of this.imageComponents) {
595
+ this.removeChild(img);
596
+ }
597
+ this.imageComponents = [];
598
+ for (const spacer of this.imageSpacers) {
599
+ this.removeChild(spacer);
600
+ }
601
+ this.imageSpacers = [];
602
+
603
+ if (this.result) {
604
+ const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
605
+ const caps = getCapabilities();
606
+
607
+ for (const img of imageBlocks) {
608
+ if (caps.images && this.showImages && img.data && img.mimeType) {
609
+ const spacer = new Spacer(1);
610
+ this.addChild(spacer);
611
+ this.imageSpacers.push(spacer);
612
+ const imageComponent = new Image(
613
+ img.data,
614
+ img.mimeType,
615
+ { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
616
+ { maxWidthCells: 60 },
617
+ );
618
+ this.imageComponents.push(imageComponent);
619
+ this.addChild(imageComponent);
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Render bash content using visual line truncation (like bash-execution.ts)
627
+ */
628
+ private renderBashContent(): void {
629
+ const command = this.args?.command || "";
630
+
631
+ // Header
632
+ this.contentBox.addChild(
633
+ new Text(
634
+ theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", theme.format.ellipsis)}`)),
635
+ 0,
636
+ 0,
637
+ ),
638
+ );
639
+
640
+ if (this.result) {
641
+ const output = this.getTextOutput().trim();
642
+
643
+ if (output) {
644
+ // Style each line for the output
645
+ const styledOutput = output
646
+ .split("\n")
647
+ .map((line) => theme.fg("toolOutput", line))
648
+ .join("\n");
649
+
650
+ if (this.expanded) {
651
+ // Show all lines when expanded
652
+ this.contentBox.addChild(new Text(`\n${styledOutput}`, 0, 0));
653
+ } else {
654
+ // Use visual line truncation when collapsed
655
+ // Box has paddingX=1, so content width = terminal.columns - 2
656
+ const { visualLines, skippedCount } = truncateToVisualLines(
657
+ `\n${styledOutput}`,
658
+ BASH_PREVIEW_LINES,
659
+ this.ui.terminal.columns - 2,
660
+ );
661
+
662
+ const totalVisualLines = skippedCount + visualLines.length;
663
+ if (skippedCount > 0) {
664
+ this.contentBox.addChild(
665
+ new Text(
666
+ theme.fg(
667
+ "dim",
668
+ `\n${theme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
669
+ ),
670
+ 0,
671
+ 0,
672
+ ),
673
+ );
674
+ }
675
+
676
+ // Add pre-rendered visual lines as a raw component
677
+ this.contentBox.addChild({
678
+ render: () => visualLines,
679
+ invalidate: () => {},
680
+ });
681
+ }
682
+ }
683
+
684
+ // Truncation warnings
685
+ const truncation = this.result.details?.truncation;
686
+ const fullOutputPath = this.result.details?.fullOutputPath;
687
+ if (truncation?.truncated || fullOutputPath) {
688
+ const warnings: string[] = [];
689
+ if (fullOutputPath) {
690
+ warnings.push(`Full output: ${fullOutputPath}`);
691
+ }
692
+ if (truncation?.truncated) {
693
+ if (truncation.truncatedBy === "lines") {
694
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
695
+ } else {
696
+ warnings.push(
697
+ `Truncated: ${truncation.outputLines} lines shown (${formatSize(
698
+ truncation.maxBytes ?? DEFAULT_MAX_BYTES,
699
+ )} limit)`,
700
+ );
701
+ }
702
+ }
703
+ this.contentBox.addChild(new Text(`\n${theme.fg("warning", wrapBrackets(warnings.join(". ")))}`, 0, 0));
704
+ }
705
+ }
706
+ }
707
+
708
+ private getTextOutput(): string {
709
+ if (!this.result) return "";
710
+
711
+ const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
712
+ const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
713
+
714
+ let output = textBlocks
715
+ .map((c: any) => {
716
+ // Use sanitizeBinaryOutput to handle binary data that crashes string-width
717
+ return sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "");
718
+ })
719
+ .join("\n");
720
+
721
+ const caps = getCapabilities();
722
+ if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) {
723
+ const imageIndicators = imageBlocks
724
+ .map((img: any) => {
725
+ const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
726
+ return imageFallback(img.mimeType, dims);
727
+ })
728
+ .join("\n");
729
+ output = output ? `${output}\n${imageIndicators}` : imageIndicators;
730
+ }
731
+
732
+ return output;
733
+ }
734
+
735
+ private formatToolExecution(): string {
736
+ let text = "";
737
+
738
+ if (this.toolName === "read") {
739
+ const rawPath = this.args?.file_path || this.args?.path || "";
740
+ const path = shortenPath(rawPath);
741
+ const offset = this.args?.offset;
742
+ const limit = this.args?.limit;
743
+ const fileType = getFileType(rawPath);
744
+
745
+ let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
746
+ if (offset !== undefined || limit !== undefined) {
747
+ const startLine = offset ?? 1;
748
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
749
+ pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
750
+ }
751
+
752
+ text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
753
+
754
+ if (this.result) {
755
+ const output = this.getTextOutput();
756
+
757
+ if (fileType === "image") {
758
+ // Image file - use image icon
759
+ const ext = rawPath.split(".").pop()?.toLowerCase() ?? "image";
760
+ text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`;
761
+ // Images are rendered by the image component, just show hint
762
+ text += `\n${theme.fg("muted", "Image rendered below")}`;
763
+ } else if (fileType === "binary") {
764
+ // Binary file - use binary/pdf/archive icon based on extension
765
+ const ext = rawPath.split(".").pop()?.toLowerCase() ?? "binary";
766
+ text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`;
767
+ } else {
768
+ // Text file - show line count and language on same line
769
+ const lang = getLanguageFromPath(rawPath);
770
+ const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
771
+ text += `${theme.sep.dot}${formatMetadataLine(null, lang)}`;
772
+
773
+ // Content is hidden by default, only shown when expanded
774
+ if (this.expanded) {
775
+ text +=
776
+ "\n\n" +
777
+ lines
778
+ .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
779
+ .join("\n");
780
+ } else {
781
+ text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to show content`)}`;
782
+ }
783
+
784
+ // Truncation warning
785
+ const truncation = this.result.details?.truncation;
786
+ if (truncation?.truncated) {
787
+ let warning: string;
788
+ if (truncation.firstLineExceedsLimit) {
789
+ warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`;
790
+ } else if (truncation.truncatedBy === "lines") {
791
+ warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`;
792
+ } else {
793
+ warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`;
794
+ }
795
+ text += `\n${theme.fg("warning", wrapBrackets(warning))}`;
796
+ }
797
+ }
798
+ }
799
+ } else if (this.toolName === "write") {
800
+ const rawPath = this.args?.file_path || this.args?.path || "";
801
+ const path = shortenPath(rawPath);
802
+ const fileContent = this.args?.content || "";
803
+ const lang = getLanguageFromPath(rawPath);
804
+ const lines = fileContent
805
+ ? lang
806
+ ? highlightCode(replaceTabs(fileContent), lang)
807
+ : fileContent.split("\n")
808
+ : [];
809
+ const totalLines = lines.length;
810
+
811
+ text =
812
+ theme.fg("toolTitle", theme.bold("write")) +
813
+ " " +
814
+ (path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis));
815
+
816
+ text += `\n${formatMetadataLine(countLines(fileContent), lang ?? "text")}`;
817
+
818
+ if (fileContent) {
819
+ const maxLines = this.expanded ? lines.length : 10;
820
+ const displayLines = lines.slice(0, maxLines);
821
+ const remaining = lines.length - maxLines;
822
+
823
+ text +=
824
+ "\n\n" +
825
+ displayLines
826
+ .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
827
+ .join("\n");
828
+ if (remaining > 0) {
829
+ text += theme.fg(
830
+ "toolOutput",
831
+ `\n${theme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${wrapBrackets("Ctrl+O to expand")}`,
832
+ );
833
+ }
834
+ }
835
+
836
+ // Show LSP diagnostics if available
837
+ if (this.result?.details?.diagnostics) {
838
+ text += formatDiagnostics(this.result.details.diagnostics, this.expanded);
839
+ }
840
+ } else if (this.toolName === "edit") {
841
+ const rawPath = this.args?.file_path || this.args?.path || "";
842
+ const path = shortenPath(rawPath);
843
+
844
+ // Build path display, appending :line if we have diff info
845
+ let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis);
846
+ const firstChangedLine =
847
+ (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
848
+ ? this.editDiffPreview.firstChangedLine
849
+ : undefined) ||
850
+ (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
851
+ if (firstChangedLine) {
852
+ pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
853
+ }
854
+
855
+ text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
856
+
857
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
858
+ const editLineCount = countLines(this.args?.newText ?? this.args?.oldText ?? "");
859
+ text += `\n${formatMetadataLine(editLineCount, editLanguage)}`;
860
+
861
+ if (this.result?.isError) {
862
+ // Show error from result
863
+ const errorText = this.getTextOutput();
864
+ if (errorText) {
865
+ text += `\n\n${theme.fg("error", errorText)}`;
866
+ }
867
+ } else if (this.editDiffPreview) {
868
+ // Use cached diff preview (works both before and after execution)
869
+ if ("error" in this.editDiffPreview) {
870
+ text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
871
+ } else if (this.editDiffPreview.diff) {
872
+ const diffStats = getDiffStats(this.editDiffPreview.diff);
873
+ text += `\n${theme.fg("dim", theme.format.bracketLeft)}${formatDiffStats(diffStats.added, diffStats.removed, diffStats.hunks)}${theme.fg("dim", theme.format.bracketRight)}`;
874
+
875
+ const {
876
+ text: diffText,
877
+ hiddenHunks,
878
+ hiddenLines,
879
+ } = this.expanded
880
+ ? { text: this.editDiffPreview.diff, hiddenHunks: 0, hiddenLines: 0 }
881
+ : truncateDiffByHunk(this.editDiffPreview.diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
882
+
883
+ text += `\n\n${renderDiff(diffText, { filePath: rawPath })}`;
884
+ if (!this.expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
885
+ const remainder: string[] = [];
886
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
887
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
888
+ text += theme.fg(
889
+ "toolOutput",
890
+ `\n${theme.format.ellipsis} (${remainder.join(", ")}) ${wrapBrackets("Ctrl+O to expand")}`,
891
+ );
892
+ }
893
+ }
894
+ }
895
+
896
+ // Show LSP diagnostics if available
897
+ if (this.result?.details?.diagnostics) {
898
+ text += formatDiagnostics(this.result.details.diagnostics, this.expanded);
899
+ }
900
+ } else {
901
+ // Generic tool (shouldn't reach here for custom tools)
902
+ text = theme.fg("toolTitle", theme.bold(this.toolName));
903
+
904
+ const argTotal =
905
+ this.args && typeof this.args === "object"
906
+ ? Object.keys(this.args as Record<string, unknown>).length
907
+ : this.args === undefined
908
+ ? 0
909
+ : 1;
910
+ const argPreviewLimit = this.expanded ? argTotal : GENERIC_ARG_PREVIEW;
911
+ const valueLimit = this.expanded ? 2000 : GENERIC_VALUE_MAX;
912
+ const argsPreview = formatArgsPreview(this.args, argPreviewLimit, valueLimit);
913
+
914
+ text += `\n\n${theme.fg("toolTitle", "Args")} ${theme.fg("dim", `(${argsPreview.total})`)}`;
915
+ if (argsPreview.lines.length > 0) {
916
+ text += `\n${argsPreview.lines.join("\n")}`;
917
+ } else {
918
+ text += `\n${theme.fg("dim", "(none)")}`;
919
+ }
920
+ if (argsPreview.remaining > 0) {
921
+ text += theme.fg(
922
+ "dim",
923
+ `\n${theme.format.ellipsis} (${argsPreview.remaining} more args) (ctrl+o to expand)`,
924
+ );
925
+ }
926
+
927
+ const output = this.getTextOutput().trim();
928
+ text += `\n\n${theme.fg("toolTitle", "Output")}`;
929
+ if (output) {
930
+ const lines = output.split("\n");
931
+ const maxLines = this.expanded ? lines.length : GENERIC_PREVIEW_LINES;
932
+ const displayLines = lines.slice(-maxLines);
933
+ const remaining = lines.length - displayLines.length;
934
+ text += ` ${theme.fg("dim", `(${lines.length} lines)`)}`;
935
+ text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
936
+ if (remaining > 0) {
937
+ text += theme.fg("dim", `\n${theme.format.ellipsis} (${remaining} earlier lines) (ctrl+o to expand)`);
938
+ }
939
+ } else {
940
+ text += ` ${theme.fg("dim", "(empty)")}`;
941
+ }
942
+ }
943
+
944
+ return text;
945
+ }
946
+ }