nastechai-desktop 18.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 (546) hide show
  1. package/.prettierrc +11 -0
  2. package/DESIGN.md +167 -0
  3. package/README.md +141 -0
  4. package/assets/icon.icns +0 -0
  5. package/assets/icon.ico +0 -0
  6. package/assets/icon.png +0 -0
  7. package/components.json +21 -0
  8. package/electron/backend-env.cjs +112 -0
  9. package/electron/backend-env.test.cjs +111 -0
  10. package/electron/backend-probes.cjs +106 -0
  11. package/electron/backend-probes.test.cjs +82 -0
  12. package/electron/backend-ready.cjs +66 -0
  13. package/electron/bootstrap-platform.cjs +91 -0
  14. package/electron/bootstrap-platform.test.cjs +111 -0
  15. package/electron/bootstrap-runner.cjs +720 -0
  16. package/electron/bootstrap-runner.test.cjs +138 -0
  17. package/electron/connection-config.cjs +254 -0
  18. package/electron/connection-config.test.cjs +329 -0
  19. package/electron/dashboard-token.cjs +99 -0
  20. package/electron/dashboard-token.test.cjs +142 -0
  21. package/electron/desktop-uninstall.cjs +232 -0
  22. package/electron/desktop-uninstall.test.cjs +246 -0
  23. package/electron/entitlements.mac.inherit.plist +14 -0
  24. package/electron/entitlements.mac.plist +14 -0
  25. package/electron/fs-read-dir.cjs +109 -0
  26. package/electron/fs-read-dir.test.cjs +364 -0
  27. package/electron/gateway-ws-probe.cjs +188 -0
  28. package/electron/gateway-ws-probe.test.cjs +122 -0
  29. package/electron/git-root.cjs +54 -0
  30. package/electron/git-root.test.cjs +40 -0
  31. package/electron/git-worktrees.cjs +174 -0
  32. package/electron/hardening.cjs +184 -0
  33. package/electron/hardening.test.cjs +116 -0
  34. package/electron/main.cjs +5762 -0
  35. package/electron/oauth-net-request.cjs +20 -0
  36. package/electron/oauth-net-request.test.cjs +34 -0
  37. package/electron/preload.cjs +135 -0
  38. package/electron/session-windows.cjs +99 -0
  39. package/electron/session-windows.test.cjs +177 -0
  40. package/electron/update-remote.cjs +56 -0
  41. package/electron/update-remote.test.cjs +78 -0
  42. package/electron/vscode-marketplace.cjs +331 -0
  43. package/electron/vscode-marketplace.test.cjs +113 -0
  44. package/electron/windows-child-process.test.cjs +57 -0
  45. package/electron/windows-user-env.cjs +76 -0
  46. package/electron/windows-user-env.test.cjs +90 -0
  47. package/electron/workspace-cwd.cjs +38 -0
  48. package/electron/workspace-cwd.test.cjs +45 -0
  49. package/eslint.config.mjs +122 -0
  50. package/index.html +17 -0
  51. package/package.json +254 -0
  52. package/pr-assets/session-source-folders.png +0 -0
  53. package/preview-demo.html +65 -0
  54. package/public/apple-touch-icon.png +0 -0
  55. package/public/ds-assets/filler-bg0.jpg +0 -0
  56. package/public/nastech-frames/nastech-frame-0.png +0 -0
  57. package/public/nastech-frames/nastech-frame-1.png +0 -0
  58. package/public/nastech-frames/nastech-frame-2.png +0 -0
  59. package/public/nastech-frames/nastech-frame-3.png +0 -0
  60. package/public/nastech-frames/nastech-frame-4.png +0 -0
  61. package/public/nastech-frames/nastech-frame-5.png +0 -0
  62. package/public/nastech-frames/nastech-frame-6.png +0 -0
  63. package/public/nastech-frames/nastech-frame-7.png +0 -0
  64. package/public/nastech-girl.jpg +0 -0
  65. package/public/nastech-sprite.png +0 -0
  66. package/public/nastech.png +0 -0
  67. package/scripts/after-pack.cjs +41 -0
  68. package/scripts/assert-dist-built.cjs +70 -0
  69. package/scripts/assert-dist-built.test.cjs +84 -0
  70. package/scripts/assert-root-install.cjs +13 -0
  71. package/scripts/before-build.cjs +11 -0
  72. package/scripts/before-pack.cjs +78 -0
  73. package/scripts/before-pack.test.cjs +53 -0
  74. package/scripts/click-session.mjs +51 -0
  75. package/scripts/dev-no-hmr.mjs +22 -0
  76. package/scripts/diag-jump.mjs +115 -0
  77. package/scripts/diag-scroll-reset.mjs +229 -0
  78. package/scripts/eval.mjs +21 -0
  79. package/scripts/leak-typing.mjs +222 -0
  80. package/scripts/measure-jump.mjs +108 -0
  81. package/scripts/measure-latency.mjs +184 -0
  82. package/scripts/measure-real-stream.mjs +252 -0
  83. package/scripts/measure-submit.mjs +179 -0
  84. package/scripts/measure-synthetic-stream.mjs +322 -0
  85. package/scripts/notarize-artifact.cjs +77 -0
  86. package/scripts/notarize.cjs +100 -0
  87. package/scripts/patch-electron-builder-mac-binary.cjs +59 -0
  88. package/scripts/probe-renderer.mjs +38 -0
  89. package/scripts/probe-thread.mjs +40 -0
  90. package/scripts/profile-long-stream.mjs +191 -0
  91. package/scripts/profile-real-stream.mjs +137 -0
  92. package/scripts/profile-synth-stream.mjs +103 -0
  93. package/scripts/profile-typing-lag.md +381 -0
  94. package/scripts/profile-typing.mjs +260 -0
  95. package/scripts/reload-renderer.mjs +25 -0
  96. package/scripts/reload.mjs +36 -0
  97. package/scripts/set-exe-identity.cjs +94 -0
  98. package/scripts/stage-native-deps.cjs +159 -0
  99. package/scripts/test-desktop.mjs +425 -0
  100. package/scripts/write-build-stamp.cjs +126 -0
  101. package/src/app/agents/index.tsx +398 -0
  102. package/src/app/artifacts/index.test.ts +62 -0
  103. package/src/app/artifacts/index.tsx +906 -0
  104. package/src/app/chat/chat-drop-overlay.tsx +48 -0
  105. package/src/app/chat/chat-swap-overlay.tsx +47 -0
  106. package/src/app/chat/composer/attachments.tsx +114 -0
  107. package/src/app/chat/composer/completion-drawer.tsx +63 -0
  108. package/src/app/chat/composer/context-menu.tsx +172 -0
  109. package/src/app/chat/composer/controls.tsx +289 -0
  110. package/src/app/chat/composer/drop-affordance.ts +2 -0
  111. package/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
  112. package/src/app/chat/composer/focus.ts +134 -0
  113. package/src/app/chat/composer/help-hint.tsx +59 -0
  114. package/src/app/chat/composer/hooks/use-at-completions.ts +141 -0
  115. package/src/app/chat/composer/hooks/use-live-completion-adapter.ts +119 -0
  116. package/src/app/chat/composer/hooks/use-mic-recorder.ts +291 -0
  117. package/src/app/chat/composer/hooks/use-slash-completions.ts +114 -0
  118. package/src/app/chat/composer/hooks/use-voice-conversation.ts +390 -0
  119. package/src/app/chat/composer/hooks/use-voice-recorder.ts +116 -0
  120. package/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
  121. package/src/app/chat/composer/index.tsx +1611 -0
  122. package/src/app/chat/composer/inline-refs.ts +138 -0
  123. package/src/app/chat/composer/model-pill.tsx +86 -0
  124. package/src/app/chat/composer/queue-panel.tsx +130 -0
  125. package/src/app/chat/composer/rich-editor.test.ts +18 -0
  126. package/src/app/chat/composer/rich-editor.ts +165 -0
  127. package/src/app/chat/composer/skin-slash-popover.tsx +61 -0
  128. package/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
  129. package/src/app/chat/composer/status-stack/index.tsx +202 -0
  130. package/src/app/chat/composer/status-stack/status-row.tsx +155 -0
  131. package/src/app/chat/composer/text-utils.test.ts +77 -0
  132. package/src/app/chat/composer/text-utils.ts +107 -0
  133. package/src/app/chat/composer/trigger-popover.test.tsx +42 -0
  134. package/src/app/chat/composer/trigger-popover.tsx +116 -0
  135. package/src/app/chat/composer/types.ts +64 -0
  136. package/src/app/chat/composer/url-dialog.tsx +82 -0
  137. package/src/app/chat/composer/voice-activity.tsx +252 -0
  138. package/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
  139. package/src/app/chat/hooks/use-composer-actions.ts +525 -0
  140. package/src/app/chat/hooks/use-file-drop-zone.ts +118 -0
  141. package/src/app/chat/index.tsx +390 -0
  142. package/src/app/chat/perf-probe.tsx +269 -0
  143. package/src/app/chat/right-rail/index.ts +1 -0
  144. package/src/app/chat/right-rail/preview-console-state.ts +82 -0
  145. package/src/app/chat/right-rail/preview-console.tsx +290 -0
  146. package/src/app/chat/right-rail/preview-file.tsx +559 -0
  147. package/src/app/chat/right-rail/preview-pane.test.tsx +43 -0
  148. package/src/app/chat/right-rail/preview-pane.tsx +657 -0
  149. package/src/app/chat/right-rail/preview.tsx +171 -0
  150. package/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
  151. package/src/app/chat/scroll-to-bottom-button.tsx +74 -0
  152. package/src/app/chat/sidebar/cron-jobs-section.tsx +325 -0
  153. package/src/app/chat/sidebar/index.tsx +1219 -0
  154. package/src/app/chat/sidebar/load-more-row.tsx +30 -0
  155. package/src/app/chat/sidebar/order.test.ts +21 -0
  156. package/src/app/chat/sidebar/order.ts +17 -0
  157. package/src/app/chat/sidebar/profile-switcher.tsx +516 -0
  158. package/src/app/chat/sidebar/session-actions-menu.tsx +264 -0
  159. package/src/app/chat/sidebar/session-row.tsx +257 -0
  160. package/src/app/chat/sidebar/virtual-session-list.tsx +154 -0
  161. package/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
  162. package/src/app/chat/sidebar/workspace-groups.ts +326 -0
  163. package/src/app/chat/thread-loading.test.ts +34 -0
  164. package/src/app/chat/thread-loading.ts +26 -0
  165. package/src/app/command-center/index.tsx +654 -0
  166. package/src/app/command-palette/index.tsx +513 -0
  167. package/src/app/command-palette/marketplace-theme-page.tsx +157 -0
  168. package/src/app/cron/index.tsx +942 -0
  169. package/src/app/cron/job-state.ts +29 -0
  170. package/src/app/desktop-controller.tsx +938 -0
  171. package/src/app/floating-hud.ts +22 -0
  172. package/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
  173. package/src/app/gateway/hooks/use-gateway-boot.ts +387 -0
  174. package/src/app/gateway/hooks/use-gateway-request.ts +138 -0
  175. package/src/app/hooks/use-keybinds.ts +186 -0
  176. package/src/app/hooks/use-refresh-hotkey.ts +45 -0
  177. package/src/app/hooks/use-route-enum-param.ts +38 -0
  178. package/src/app/index.tsx +1 -0
  179. package/src/app/layout-constants.ts +13 -0
  180. package/src/app/messaging/index.tsx +648 -0
  181. package/src/app/messaging/platform-icon.tsx +93 -0
  182. package/src/app/model-picker-overlay.tsx +42 -0
  183. package/src/app/model-visibility-overlay.tsx +31 -0
  184. package/src/app/overlays/overlay-chrome.tsx +66 -0
  185. package/src/app/overlays/overlay-search-input.tsx +33 -0
  186. package/src/app/overlays/overlay-split-layout.tsx +130 -0
  187. package/src/app/overlays/overlay-view.tsx +91 -0
  188. package/src/app/page-search-shell.tsx +75 -0
  189. package/src/app/profiles/create-profile-dialog.tsx +154 -0
  190. package/src/app/profiles/delete-profile-dialog.tsx +65 -0
  191. package/src/app/profiles/index.tsx +671 -0
  192. package/src/app/profiles/rename-profile-dialog.tsx +125 -0
  193. package/src/app/right-sidebar/files/dnd-manager.ts +27 -0
  194. package/src/app/right-sidebar/files/ipc.test.ts +100 -0
  195. package/src/app/right-sidebar/files/ipc.ts +161 -0
  196. package/src/app/right-sidebar/files/remote-picker.tsx +177 -0
  197. package/src/app/right-sidebar/files/tree.tsx +224 -0
  198. package/src/app/right-sidebar/files/use-project-tree.test.ts +190 -0
  199. package/src/app/right-sidebar/files/use-project-tree.ts +268 -0
  200. package/src/app/right-sidebar/index.test.tsx +75 -0
  201. package/src/app/right-sidebar/index.tsx +395 -0
  202. package/src/app/right-sidebar/store.ts +15 -0
  203. package/src/app/right-sidebar/terminal/buffer.ts +65 -0
  204. package/src/app/right-sidebar/terminal/index.tsx +98 -0
  205. package/src/app/right-sidebar/terminal/persistent.tsx +122 -0
  206. package/src/app/right-sidebar/terminal/selection.ts +75 -0
  207. package/src/app/right-sidebar/terminal/use-terminal-session.ts +504 -0
  208. package/src/app/routes.ts +88 -0
  209. package/src/app/session/hooks/use-context-suggestions.ts +58 -0
  210. package/src/app/session/hooks/use-cwd-actions.ts +109 -0
  211. package/src/app/session/hooks/use-message-stream.ts +957 -0
  212. package/src/app/session/hooks/use-model-controls.test.tsx +198 -0
  213. package/src/app/session/hooks/use-model-controls.ts +106 -0
  214. package/src/app/session/hooks/use-nastech-config.ts +74 -0
  215. package/src/app/session/hooks/use-preview-routing.test.tsx +168 -0
  216. package/src/app/session/hooks/use-preview-routing.ts +223 -0
  217. package/src/app/session/hooks/use-prompt-actions.test.tsx +316 -0
  218. package/src/app/session/hooks/use-prompt-actions.ts +1030 -0
  219. package/src/app/session/hooks/use-route-resume.test.tsx +136 -0
  220. package/src/app/session/hooks/use-route-resume.ts +115 -0
  221. package/src/app/session/hooks/use-session-actions.test.tsx +119 -0
  222. package/src/app/session/hooks/use-session-actions.ts +885 -0
  223. package/src/app/session/hooks/use-session-state-cache.test.tsx +118 -0
  224. package/src/app/session/hooks/use-session-state-cache.ts +191 -0
  225. package/src/app/session-picker-overlay.tsx +32 -0
  226. package/src/app/session-switcher.tsx +107 -0
  227. package/src/app/settings/about-settings.tsx +173 -0
  228. package/src/app/settings/appearance-settings.tsx +162 -0
  229. package/src/app/settings/config-settings.tsx +384 -0
  230. package/src/app/settings/constants.ts +545 -0
  231. package/src/app/settings/credential-key-ui.tsx +373 -0
  232. package/src/app/settings/env-credentials.tsx +198 -0
  233. package/src/app/settings/env-var-actions-menu.tsx +136 -0
  234. package/src/app/settings/field-copy.ts +56 -0
  235. package/src/app/settings/gateway-settings.tsx +620 -0
  236. package/src/app/settings/helpers.test.ts +138 -0
  237. package/src/app/settings/helpers.ts +151 -0
  238. package/src/app/settings/index.tsx +237 -0
  239. package/src/app/settings/keys-settings.tsx +96 -0
  240. package/src/app/settings/mcp-settings.tsx +271 -0
  241. package/src/app/settings/model-settings.test.tsx +157 -0
  242. package/src/app/settings/model-settings.tsx +559 -0
  243. package/src/app/settings/notifications-settings.tsx +150 -0
  244. package/src/app/settings/primitives.tsx +115 -0
  245. package/src/app/settings/providers-settings.test.tsx +100 -0
  246. package/src/app/settings/providers-settings.tsx +258 -0
  247. package/src/app/settings/sessions-settings.tsx +276 -0
  248. package/src/app/settings/toolset-config-panel.test.tsx +289 -0
  249. package/src/app/settings/toolset-config-panel.tsx +449 -0
  250. package/src/app/settings/types.ts +42 -0
  251. package/src/app/settings/uninstall-section.tsx +185 -0
  252. package/src/app/settings/use-deep-link-highlight.ts +60 -0
  253. package/src/app/shell/app-shell.tsx +167 -0
  254. package/src/app/shell/gateway-menu-panel.tsx +150 -0
  255. package/src/app/shell/hooks/use-overlay-routing.ts +71 -0
  256. package/src/app/shell/hooks/use-status-snapshot.ts +57 -0
  257. package/src/app/shell/hooks/use-statusbar-items.tsx +403 -0
  258. package/src/app/shell/keybind-panel.tsx +220 -0
  259. package/src/app/shell/model-edit-submenu.test.tsx +84 -0
  260. package/src/app/shell/model-edit-submenu.tsx +245 -0
  261. package/src/app/shell/model-menu-panel.tsx +295 -0
  262. package/src/app/shell/sidebar-label.tsx +22 -0
  263. package/src/app/shell/statusbar-controls.tsx +185 -0
  264. package/src/app/shell/titlebar-controls.tsx +244 -0
  265. package/src/app/shell/titlebar.test.ts +26 -0
  266. package/src/app/shell/titlebar.ts +45 -0
  267. package/src/app/shell/use-group-registry.ts +39 -0
  268. package/src/app/skills/index.test.tsx +103 -0
  269. package/src/app/skills/index.tsx +371 -0
  270. package/src/app/types.ts +99 -0
  271. package/src/app/updates-overlay.tsx +369 -0
  272. package/src/components/Backdrop.tsx +114 -0
  273. package/src/components/assistant-ui/ansi-text.tsx +34 -0
  274. package/src/components/assistant-ui/clarify-tool.tsx +281 -0
  275. package/src/components/assistant-ui/directive-text.test.ts +39 -0
  276. package/src/components/assistant-ui/directive-text.tsx +389 -0
  277. package/src/components/assistant-ui/markdown-text.test.ts +204 -0
  278. package/src/components/assistant-ui/markdown-text.tsx +497 -0
  279. package/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
  280. package/src/components/assistant-ui/message-render-boundary.tsx +48 -0
  281. package/src/components/assistant-ui/streaming.test.tsx +739 -0
  282. package/src/components/assistant-ui/thread-list.tsx +307 -0
  283. package/src/components/assistant-ui/thread-virtualizer.tsx +512 -0
  284. package/src/components/assistant-ui/thread.tsx +1474 -0
  285. package/src/components/assistant-ui/todo-tool.tsx +109 -0
  286. package/src/components/assistant-ui/tool-approval-group.test.tsx +158 -0
  287. package/src/components/assistant-ui/tool-approval.test.tsx +81 -0
  288. package/src/components/assistant-ui/tool-approval.tsx +209 -0
  289. package/src/components/assistant-ui/tool-fallback-model.test.ts +66 -0
  290. package/src/components/assistant-ui/tool-fallback-model.ts +1368 -0
  291. package/src/components/assistant-ui/tool-fallback.tsx +466 -0
  292. package/src/components/assistant-ui/tooltip-icon-button.tsx +33 -0
  293. package/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
  294. package/src/components/assistant-ui/user-message-text.tsx +150 -0
  295. package/src/components/boot-failure-overlay.tsx +246 -0
  296. package/src/components/boot-failure-reauth.test.ts +100 -0
  297. package/src/components/boot-failure-reauth.ts +81 -0
  298. package/src/components/brand-mark.tsx +19 -0
  299. package/src/components/chat/activity-timer-text.tsx +24 -0
  300. package/src/components/chat/activity-timer.test.tsx +43 -0
  301. package/src/components/chat/activity-timer.ts +64 -0
  302. package/src/components/chat/code-card.tsx +78 -0
  303. package/src/components/chat/compact-markdown.tsx +113 -0
  304. package/src/components/chat/composer-dock.ts +31 -0
  305. package/src/components/chat/diff-lines.tsx +54 -0
  306. package/src/components/chat/disclosure-row.tsx +63 -0
  307. package/src/components/chat/generated-image-context.tsx +19 -0
  308. package/src/components/chat/generated-image-result.tsx +174 -0
  309. package/src/components/chat/image-generation-placeholder.tsx +279 -0
  310. package/src/components/chat/intro-copy.jsonl +75 -0
  311. package/src/components/chat/intro.tsx +182 -0
  312. package/src/components/chat/preview-attachment.tsx +125 -0
  313. package/src/components/chat/shiki-highlighter.tsx +107 -0
  314. package/src/components/chat/status-row.tsx +70 -0
  315. package/src/components/chat/status-section.tsx +42 -0
  316. package/src/components/chat/terminal-output.tsx +50 -0
  317. package/src/components/chat/zoomable-image.tsx +177 -0
  318. package/src/components/desktop-install-overlay.tsx +595 -0
  319. package/src/components/desktop-onboarding-overlay.test.tsx +100 -0
  320. package/src/components/desktop-onboarding-overlay.tsx +1286 -0
  321. package/src/components/error-boundary.tsx +77 -0
  322. package/src/components/gateway-connecting-overlay.test.tsx +143 -0
  323. package/src/components/gateway-connecting-overlay.tsx +183 -0
  324. package/src/components/haptics-provider.tsx +19 -0
  325. package/src/components/language-switcher.test.tsx +53 -0
  326. package/src/components/language-switcher.tsx +175 -0
  327. package/src/components/model-picker.tsx +340 -0
  328. package/src/components/model-visibility-dialog.tsx +155 -0
  329. package/src/components/notifications.tsx +196 -0
  330. package/src/components/page-loader.tsx +34 -0
  331. package/src/components/pane-shell/context.ts +14 -0
  332. package/src/components/pane-shell/index.ts +4 -0
  333. package/src/components/pane-shell/pane-shell.test.tsx +333 -0
  334. package/src/components/pane-shell/pane-shell.tsx +330 -0
  335. package/src/components/prompt-overlays.tsx +234 -0
  336. package/src/components/session-picker.tsx +108 -0
  337. package/src/components/status-dot.tsx +26 -0
  338. package/src/components/ui/action-status.tsx +25 -0
  339. package/src/components/ui/alert.tsx +53 -0
  340. package/src/components/ui/badge.tsx +35 -0
  341. package/src/components/ui/braille-spinner.tsx +61 -0
  342. package/src/components/ui/button.tsx +81 -0
  343. package/src/components/ui/checkbox.tsx +27 -0
  344. package/src/components/ui/codicon.tsx +20 -0
  345. package/src/components/ui/command.tsx +111 -0
  346. package/src/components/ui/confirm-dialog.tsx +109 -0
  347. package/src/components/ui/context-menu.tsx +141 -0
  348. package/src/components/ui/control.ts +25 -0
  349. package/src/components/ui/copy-button.test.tsx +36 -0
  350. package/src/components/ui/copy-button.tsx +229 -0
  351. package/src/components/ui/dialog.tsx +152 -0
  352. package/src/components/ui/disclosure-caret.tsx +20 -0
  353. package/src/components/ui/dropdown-menu.tsx +291 -0
  354. package/src/components/ui/error-state.tsx +50 -0
  355. package/src/components/ui/fade-text.tsx +110 -0
  356. package/src/components/ui/glyph-spinner.tsx +63 -0
  357. package/src/components/ui/input.tsx +22 -0
  358. package/src/components/ui/kbd.tsx +37 -0
  359. package/src/components/ui/loader.tsx +558 -0
  360. package/src/components/ui/log-view.tsx +17 -0
  361. package/src/components/ui/pagination.tsx +114 -0
  362. package/src/components/ui/popover.tsx +44 -0
  363. package/src/components/ui/scroll-area.tsx +43 -0
  364. package/src/components/ui/search-field.tsx +80 -0
  365. package/src/components/ui/segmented-control.tsx +51 -0
  366. package/src/components/ui/select.tsx +92 -0
  367. package/src/components/ui/separator.tsx +26 -0
  368. package/src/components/ui/sheet.tsx +116 -0
  369. package/src/components/ui/sidebar.tsx +674 -0
  370. package/src/components/ui/skeleton.tsx +7 -0
  371. package/src/components/ui/switch.tsx +49 -0
  372. package/src/components/ui/tabs.tsx +36 -0
  373. package/src/components/ui/text-tab.tsx +43 -0
  374. package/src/components/ui/textarea.tsx +11 -0
  375. package/src/components/ui/tool-icon.tsx +65 -0
  376. package/src/components/ui/tooltip.tsx +69 -0
  377. package/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
  378. package/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
  379. package/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
  380. package/src/global.d.ts +457 -0
  381. package/src/hooks/use-image-download.ts +85 -0
  382. package/src/hooks/use-media-query.ts +24 -0
  383. package/src/hooks/use-mobile.ts +3 -0
  384. package/src/hooks/use-resize-observer.ts +38 -0
  385. package/src/hooks/use-worktree-info.ts +68 -0
  386. package/src/i18n/catalog.ts +12 -0
  387. package/src/i18n/context.test.tsx +232 -0
  388. package/src/i18n/context.tsx +183 -0
  389. package/src/i18n/define-locale.ts +41 -0
  390. package/src/i18n/en.ts +1779 -0
  391. package/src/i18n/index.ts +20 -0
  392. package/src/i18n/ja.ts +1890 -0
  393. package/src/i18n/languages.test.ts +43 -0
  394. package/src/i18n/languages.ts +86 -0
  395. package/src/i18n/runtime.test.ts +75 -0
  396. package/src/i18n/runtime.ts +53 -0
  397. package/src/i18n/types.ts +1452 -0
  398. package/src/i18n/zh-hant.ts +1849 -0
  399. package/src/i18n/zh.ts +1923 -0
  400. package/src/lib/ansi.test.ts +123 -0
  401. package/src/lib/ansi.ts +175 -0
  402. package/src/lib/chat-messages.test.ts +708 -0
  403. package/src/lib/chat-messages.ts +885 -0
  404. package/src/lib/chat-runtime.test.ts +18 -0
  405. package/src/lib/chat-runtime.ts +335 -0
  406. package/src/lib/clipboard.ts +28 -0
  407. package/src/lib/commit-changelog.test.ts +114 -0
  408. package/src/lib/commit-changelog.ts +177 -0
  409. package/src/lib/completion-sound.ts +519 -0
  410. package/src/lib/desktop-fs.test.ts +116 -0
  411. package/src/lib/desktop-fs.ts +113 -0
  412. package/src/lib/desktop-slash-commands.test.ts +126 -0
  413. package/src/lib/desktop-slash-commands.ts +286 -0
  414. package/src/lib/embedded-images.test.ts +35 -0
  415. package/src/lib/embedded-images.ts +60 -0
  416. package/src/lib/external-link.test.tsx +168 -0
  417. package/src/lib/external-link.tsx +303 -0
  418. package/src/lib/gateway-events.test.ts +27 -0
  419. package/src/lib/gateway-events.ts +49 -0
  420. package/src/lib/gateway-ws-url.test.ts +78 -0
  421. package/src/lib/gateway-ws-url.ts +91 -0
  422. package/src/lib/generated-images.test.ts +97 -0
  423. package/src/lib/generated-images.ts +116 -0
  424. package/src/lib/haptics.ts +129 -0
  425. package/src/lib/icons.ts +203 -0
  426. package/src/lib/incremental-external-store-runtime.ts +188 -0
  427. package/src/lib/katex-memo.ts +260 -0
  428. package/src/lib/keybinds/actions.ts +125 -0
  429. package/src/lib/keybinds/combo.test.ts +86 -0
  430. package/src/lib/keybinds/combo.ts +169 -0
  431. package/src/lib/local-preview.ts +126 -0
  432. package/src/lib/markdown-code.test.ts +23 -0
  433. package/src/lib/markdown-code.ts +195 -0
  434. package/src/lib/markdown-preprocess.ts +386 -0
  435. package/src/lib/media.remote.test.ts +58 -0
  436. package/src/lib/media.ts +111 -0
  437. package/src/lib/model-status-label.test.ts +31 -0
  438. package/src/lib/model-status-label.ts +103 -0
  439. package/src/lib/mutable-ref.ts +6 -0
  440. package/src/lib/preview-targets.test.ts +27 -0
  441. package/src/lib/preview-targets.ts +63 -0
  442. package/src/lib/profile-color.ts +58 -0
  443. package/src/lib/provider-setup-errors.test.ts +26 -0
  444. package/src/lib/provider-setup-errors.ts +12 -0
  445. package/src/lib/query-client.ts +13 -0
  446. package/src/lib/remend-tail.test.ts +105 -0
  447. package/src/lib/remend-tail.ts +108 -0
  448. package/src/lib/runtime-readiness.test.ts +65 -0
  449. package/src/lib/runtime-readiness.ts +147 -0
  450. package/src/lib/session-export.ts +57 -0
  451. package/src/lib/session-search.test.ts +58 -0
  452. package/src/lib/session-search.ts +19 -0
  453. package/src/lib/session-source.ts +62 -0
  454. package/src/lib/speech-text.ts +35 -0
  455. package/src/lib/statusbar.ts +91 -0
  456. package/src/lib/storage.test.ts +25 -0
  457. package/src/lib/storage.ts +107 -0
  458. package/src/lib/todos.test.ts +35 -0
  459. package/src/lib/todos.ts +51 -0
  460. package/src/lib/tool-result-summary.test.ts +106 -0
  461. package/src/lib/tool-result-summary.ts +467 -0
  462. package/src/lib/update-copy.test.ts +38 -0
  463. package/src/lib/update-copy.ts +44 -0
  464. package/src/lib/use-enter-animation.ts +100 -0
  465. package/src/lib/utils.ts +6 -0
  466. package/src/lib/voice-playback.ts +128 -0
  467. package/src/lib/yolo-session.ts +26 -0
  468. package/src/main.tsx +43 -0
  469. package/src/nastech.test.ts +49 -0
  470. package/src/nastech.ts +718 -0
  471. package/src/store/activity.ts +100 -0
  472. package/src/store/boot.ts +91 -0
  473. package/src/store/clarify.test.ts +81 -0
  474. package/src/store/clarify.ts +69 -0
  475. package/src/store/command-palette.ts +20 -0
  476. package/src/store/compaction.test.ts +53 -0
  477. package/src/store/compaction.ts +38 -0
  478. package/src/store/completion-sound.ts +32 -0
  479. package/src/store/composer-input-history.test.ts +147 -0
  480. package/src/store/composer-input-history.ts +158 -0
  481. package/src/store/composer-queue.test.ts +148 -0
  482. package/src/store/composer-queue.ts +239 -0
  483. package/src/store/composer-status.test.ts +99 -0
  484. package/src/store/composer-status.ts +277 -0
  485. package/src/store/composer.test.ts +106 -0
  486. package/src/store/composer.ts +184 -0
  487. package/src/store/cron.ts +19 -0
  488. package/src/store/gateway.ts +290 -0
  489. package/src/store/haptics.ts +17 -0
  490. package/src/store/keybinds.ts +139 -0
  491. package/src/store/layout.ts +176 -0
  492. package/src/store/model-presets.test.ts +51 -0
  493. package/src/store/model-presets.ts +86 -0
  494. package/src/store/model-visibility.test.ts +37 -0
  495. package/src/store/model-visibility.ts +108 -0
  496. package/src/store/native-notifications.test.ts +192 -0
  497. package/src/store/native-notifications.ts +203 -0
  498. package/src/store/notifications.ts +165 -0
  499. package/src/store/onboarding.test.ts +372 -0
  500. package/src/store/onboarding.ts +866 -0
  501. package/src/store/panes.test.ts +146 -0
  502. package/src/store/panes.ts +145 -0
  503. package/src/store/preview.test.ts +135 -0
  504. package/src/store/preview.ts +466 -0
  505. package/src/store/profile.test.ts +89 -0
  506. package/src/store/profile.ts +365 -0
  507. package/src/store/prompts.test.ts +121 -0
  508. package/src/store/prompts.ts +115 -0
  509. package/src/store/session-switcher.test.ts +115 -0
  510. package/src/store/session-switcher.ts +128 -0
  511. package/src/store/session-sync.ts +25 -0
  512. package/src/store/session.test.ts +131 -0
  513. package/src/store/session.ts +255 -0
  514. package/src/store/subagents.test.ts +111 -0
  515. package/src/store/subagents.ts +260 -0
  516. package/src/store/thread-scroll.ts +46 -0
  517. package/src/store/todos.test.ts +47 -0
  518. package/src/store/todos.ts +64 -0
  519. package/src/store/tool-diffs.ts +23 -0
  520. package/src/store/tool-dismiss.ts +45 -0
  521. package/src/store/tool-view.ts +91 -0
  522. package/src/store/translucency.ts +38 -0
  523. package/src/store/updates.test.ts +77 -0
  524. package/src/store/updates.ts +315 -0
  525. package/src/store/voice-playback.ts +24 -0
  526. package/src/store/windows.test.ts +143 -0
  527. package/src/store/windows.ts +77 -0
  528. package/src/styles.css +1235 -0
  529. package/src/themes/color.ts +142 -0
  530. package/src/themes/context.tsx +339 -0
  531. package/src/themes/index.ts +3 -0
  532. package/src/themes/install.test.ts +119 -0
  533. package/src/themes/install.ts +95 -0
  534. package/src/themes/presets.test.ts +33 -0
  535. package/src/themes/presets.ts +293 -0
  536. package/src/themes/profile-theme.test.ts +41 -0
  537. package/src/themes/types.ts +66 -0
  538. package/src/themes/use-skin-command.ts +60 -0
  539. package/src/themes/user-themes.test.ts +63 -0
  540. package/src/themes/user-themes.ts +122 -0
  541. package/src/themes/vscode.test.ts +171 -0
  542. package/src/themes/vscode.ts +343 -0
  543. package/src/types/nastech.ts +646 -0
  544. package/src/vite-env.d.ts +1 -0
  545. package/tsconfig.json +25 -0
  546. package/vite.config.ts +56 -0
@@ -0,0 +1,1368 @@
1
+ import { normalizeExternalUrl } from '@/lib/external-link'
2
+ import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
3
+ import { translateNow } from '@/i18n'
4
+
5
+ export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
6
+ export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
7
+
8
+ export interface ToolPart {
9
+ args?: unknown
10
+ isError?: boolean
11
+ result?: unknown
12
+ toolCallId?: string
13
+ toolName: string
14
+ type: 'tool-call'
15
+ }
16
+
17
+ export interface SearchResultRow {
18
+ snippet: string
19
+ title: string
20
+ url: string
21
+ }
22
+
23
+ interface CountMetric {
24
+ count: number
25
+ noun: string
26
+ }
27
+
28
+ export interface ToolView {
29
+ countLabel?: string
30
+ detail: string
31
+ detailLabel: string
32
+ durationLabel?: string
33
+ icon?: string
34
+ imageUrl?: string
35
+ inlineDiff: string
36
+ previewTarget?: string
37
+ rawArgs: string
38
+ rawResult: string
39
+ /** Set for tools whose output naturally contains ANSI escape codes
40
+ * (terminal/execute_code) so the renderer knows to run them through
41
+ * the ANSI parser instead of printing them as literals. */
42
+ rendersAnsi?: boolean
43
+ searchHits?: SearchResultRow[]
44
+ /** When the backend reports stderr as a separate stream (terminal /
45
+ * execute_code), the renderer shows it as its own labeled, neutrally
46
+ * tinted block under stdout — distinct from an error tone. */
47
+ stderr?: string
48
+ /** When set, the renderer uses stdout+stderr as separate sections and
49
+ * ignores the merged `detail`. */
50
+ stdout?: string
51
+ status: ToolStatus
52
+ subtitle: string
53
+ title: string
54
+ tone: ToolTone
55
+ }
56
+
57
+ interface ToolMeta {
58
+ done: string
59
+ icon?: string
60
+ pending: string
61
+ tone: ToolTone
62
+ }
63
+
64
+ export interface MessageRunningStateSlice {
65
+ message: {
66
+ status?: {
67
+ type?: string
68
+ }
69
+ }
70
+ thread: {
71
+ isRunning: boolean
72
+ }
73
+ }
74
+
75
+ const TOOL_META: Record<string, ToolMeta> = {
76
+ browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
77
+ browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
78
+ browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' },
79
+ browser_snapshot: {
80
+ done: 'Captured page snapshot',
81
+ pending: 'Capturing page snapshot',
82
+ icon: 'globe',
83
+ tone: 'browser'
84
+ },
85
+ browser_take_screenshot: {
86
+ done: 'Captured screenshot',
87
+ pending: 'Capturing screenshot',
88
+ icon: 'file-media',
89
+ tone: 'browser'
90
+ },
91
+ browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
92
+ clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
93
+ cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
94
+ edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
95
+ execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
96
+ image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
97
+ list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
98
+ patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' },
99
+ read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
100
+ search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
101
+ session_search_recall: {
102
+ done: 'Searched session history',
103
+ pending: 'Searching session history',
104
+ icon: 'search',
105
+ tone: 'agent'
106
+ },
107
+ terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
108
+ todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
109
+ vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' },
110
+ web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
111
+ web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
112
+ write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
113
+ }
114
+
115
+ const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
116
+ const CITATION_MARKER_RE = /(?<=[\p{L}\p{N})\].,!?:;"'”’])\[(?:\d+(?:\s*,\s*\d+)*)\](?!\()/gu
117
+ const BACKTICK_NOISE_RE = /`{3,}/g
118
+
119
+ export const selectMessageRunning = (state: MessageRunningStateSlice) =>
120
+ state.thread.isRunning && state.message.status?.type === 'running'
121
+
122
+ function titleForTool(name: string): string {
123
+ const normalized = name.replace(/^browser_/, '').replace(/^web_/, '')
124
+
125
+ return (
126
+ normalized
127
+ .split('_')
128
+ .filter(Boolean)
129
+ .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
130
+ .join(' ') || name
131
+ )
132
+ }
133
+
134
+ const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [
135
+ { prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' },
136
+ { prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' }
137
+ ]
138
+
139
+ function toolMeta(name: string): ToolMeta {
140
+ if (TOOL_META[name]) {
141
+ return TOOL_META[name]
142
+ }
143
+
144
+ const action = titleForTool(name)
145
+ const prefix = PREFIX_META.find(p => name.startsWith(p.prefix))
146
+
147
+ return prefix
148
+ ? {
149
+ done: `${prefix.verb} ${action}`,
150
+ pending: `Running ${prefix.verb.toLowerCase()} ${action.toLowerCase()}`,
151
+ icon: prefix.icon,
152
+ tone: prefix.tone
153
+ }
154
+ : { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' }
155
+ }
156
+
157
+ function isRecord(value: unknown): value is Record<string, unknown> {
158
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
159
+ }
160
+
161
+ export function compactPreview(value: unknown, max = 72): string {
162
+ let raw: unknown
163
+
164
+ if (typeof value === 'string') {
165
+ raw = value
166
+ } else {
167
+ raw = parseMaybeObject(value).context
168
+ }
169
+
170
+ if (typeof raw !== 'string') {
171
+ if (raw == null) {
172
+ raw = ''
173
+ } else {
174
+ try {
175
+ raw = JSON.stringify(raw)
176
+ } catch {
177
+ raw = String(raw)
178
+ }
179
+ }
180
+ }
181
+
182
+ const line = (raw as string).replace(/\s+/g, ' ').trim()
183
+
184
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line
185
+ }
186
+
187
+ function contextValue(value: unknown): string {
188
+ const row = parseMaybeObject(value)
189
+
190
+ if (typeof row.context === 'string') {
191
+ return row.context
192
+ }
193
+
194
+ if (typeof row.preview === 'string') {
195
+ return row.preview
196
+ }
197
+
198
+ return typeof value === 'string' ? value : ''
199
+ }
200
+
201
+ function prettyJson(value: unknown): string {
202
+ return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
203
+ }
204
+
205
+ function parseMaybeObject(value: unknown): Record<string, unknown> {
206
+ if (isRecord(value)) {
207
+ return value
208
+ }
209
+
210
+ if (typeof value !== 'string' || !value.trim()) {
211
+ return {}
212
+ }
213
+
214
+ try {
215
+ const parsed = JSON.parse(value)
216
+
217
+ return isRecord(parsed) ? parsed : {}
218
+ } catch {
219
+ return {}
220
+ }
221
+ }
222
+
223
+ function unwrapToolPayload(value: unknown): unknown {
224
+ const record = parseMaybeObject(value)
225
+
226
+ for (const key of ['data', 'result', 'output', 'response', 'payload']) {
227
+ const payload = record[key]
228
+
229
+ if (payload !== undefined && payload !== null) {
230
+ return payload
231
+ }
232
+ }
233
+
234
+ return value
235
+ }
236
+
237
+ function numberValue(value: unknown): null | number {
238
+ const n = typeof value === 'number' ? value : Number(value)
239
+
240
+ return Number.isFinite(n) ? n : null
241
+ }
242
+
243
+ function formatDurationSeconds(seconds: number): string {
244
+ if (!Number.isFinite(seconds) || seconds < 0) {
245
+ return ''
246
+ }
247
+
248
+ if (seconds < 1) {
249
+ const ms = Math.max(1, Math.round(seconds * 1000))
250
+
251
+ return `${ms}ms`
252
+ }
253
+
254
+ if (seconds < 60) {
255
+ return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
256
+ }
257
+
258
+ const wholeSeconds = Math.round(seconds)
259
+ const minutes = Math.floor(wholeSeconds / 60)
260
+ const remSeconds = wholeSeconds % 60
261
+
262
+ if (minutes < 60) {
263
+ return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`
264
+ }
265
+
266
+ const hours = Math.floor(minutes / 60)
267
+ const remMinutes = minutes % 60
268
+
269
+ return remMinutes ? `${hours}h ${remMinutes}m` : `${hours}h`
270
+ }
271
+
272
+ const COUNT_FIELD_KEYS = [
273
+ 'count',
274
+ 'total',
275
+ 'result_count',
276
+ 'results_count',
277
+ 'num_results',
278
+ 'match_count',
279
+ 'matches_count',
280
+ 'file_count',
281
+ 'files_count',
282
+ 'item_count',
283
+ 'items_count',
284
+ 'search_count',
285
+ 'searches_count',
286
+ 'source_count',
287
+ 'sources_count',
288
+ 'document_count',
289
+ 'documents_count',
290
+ 'updated',
291
+ 'added',
292
+ 'removed',
293
+ 'deleted',
294
+ 'created',
295
+ 'changed',
296
+ 'processed',
297
+ 'steps'
298
+ ] as const
299
+
300
+ const COUNT_ARRAY_KEYS = ['results', 'items', 'matches', 'files', 'documents', 'sources', 'rows'] as const
301
+
302
+ const COUNT_EXCLUDED_KEYS = new Set(['duration_s', 'exit_code', 'status_code'])
303
+
304
+ const COUNT_NOUN_BY_FIELD: Partial<Record<(typeof COUNT_FIELD_KEYS)[number], string>> = {
305
+ count: '',
306
+ total: '',
307
+ result_count: 'result',
308
+ results_count: 'result',
309
+ num_results: 'result',
310
+ match_count: 'match',
311
+ matches_count: 'match',
312
+ file_count: 'file',
313
+ files_count: 'file',
314
+ item_count: 'item',
315
+ items_count: 'item',
316
+ search_count: 'search',
317
+ searches_count: 'search',
318
+ source_count: 'source',
319
+ sources_count: 'source',
320
+ document_count: 'document',
321
+ documents_count: 'document',
322
+ updated: 'item',
323
+ added: 'item',
324
+ removed: 'item',
325
+ deleted: 'item',
326
+ created: 'item',
327
+ changed: 'item',
328
+ processed: 'item',
329
+ steps: 'step'
330
+ }
331
+
332
+ const COUNT_NOUN_BY_ARRAY: Record<(typeof COUNT_ARRAY_KEYS)[number], string> = {
333
+ documents: 'document',
334
+ files: 'file',
335
+ items: 'item',
336
+ matches: 'match',
337
+ results: 'result',
338
+ rows: 'row',
339
+ sources: 'source'
340
+ }
341
+
342
+ const DEFAULT_COUNT_NOUN_BY_TOOL: Record<string, string> = {
343
+ browser_snapshot: 'item',
344
+ list_files: 'file',
345
+ search_files: 'result',
346
+ session_search_recall: 'result',
347
+ todo: 'todo',
348
+ web_search: 'result'
349
+ }
350
+
351
+ function countFromUnknown(value: unknown): null | number {
352
+ if (Array.isArray(value)) {
353
+ return value.length > 0 ? value.length : null
354
+ }
355
+
356
+ const n = numberValue(value)
357
+
358
+ if (n === null || n <= 0) {
359
+ return null
360
+ }
361
+
362
+ return Math.round(n)
363
+ }
364
+
365
+ function singularizeNoun(noun: string): string {
366
+ const normalized = noun.trim().toLowerCase()
367
+
368
+ if (!normalized) {
369
+ return ''
370
+ }
371
+
372
+ if (normalized.endsWith('ies') && normalized.length > 3) {
373
+ return `${normalized.slice(0, -3)}y`
374
+ }
375
+
376
+ if (/(xes|zes|ches|shes|sses)$/.test(normalized) && normalized.length > 3) {
377
+ return normalized.slice(0, -2)
378
+ }
379
+
380
+ if (normalized.endsWith('s') && normalized.length > 2 && !normalized.endsWith('ss')) {
381
+ return normalized.slice(0, -1)
382
+ }
383
+
384
+ return normalized
385
+ }
386
+
387
+ function pluralizeNoun(noun: string, count: number): string {
388
+ if (count === 1) {
389
+ return noun
390
+ }
391
+
392
+ if (noun === 'search') {
393
+ return 'searches'
394
+ }
395
+
396
+ if (noun.endsWith('y') && noun.length > 1 && !/[aeiou]y$/i.test(noun)) {
397
+ return `${noun.slice(0, -1)}ies`
398
+ }
399
+
400
+ if (/(s|x|z|ch|sh)$/i.test(noun)) {
401
+ return `${noun}es`
402
+ }
403
+
404
+ return `${noun}s`
405
+ }
406
+
407
+ function formatCountLabel(metric: CountMetric): string {
408
+ return `${metric.count} ${pluralizeNoun(metric.noun, metric.count)}`
409
+ }
410
+
411
+ function countMetric(count: number, noun: string): CountMetric {
412
+ return { count, noun: singularizeNoun(noun) || 'item' }
413
+ }
414
+
415
+ function normalizeMetricForTool(toolName: string, metric: CountMetric): CountMetric {
416
+ if (toolName === 'web_search') {
417
+ return countMetric(metric.count, 'result')
418
+ }
419
+
420
+ return metric
421
+ }
422
+
423
+ function fallbackCountNoun(toolName: string): string {
424
+ return DEFAULT_COUNT_NOUN_BY_TOOL[toolName] || 'item'
425
+ }
426
+
427
+ function dynamicCountNounFromKey(key: string, fallbackNoun: string): string {
428
+ const normalized = key.toLowerCase()
429
+
430
+ if (normalized === 'count' || normalized === 'total') {
431
+ return fallbackNoun
432
+ }
433
+
434
+ const stripped = normalized.replace(/_(count|total)$/i, '').replace(/^num_/, '')
435
+
436
+ return singularizeNoun(stripped) || fallbackNoun
437
+ }
438
+
439
+ function countFromRecord(record: Record<string, unknown>, fallbackNoun: string): CountMetric | null {
440
+ for (const key of COUNT_FIELD_KEYS) {
441
+ const value = record[key]
442
+ const count = countFromUnknown(value)
443
+
444
+ if (count !== null) {
445
+ return countMetric(count, COUNT_NOUN_BY_FIELD[key] || fallbackNoun)
446
+ }
447
+ }
448
+
449
+ for (const key of COUNT_ARRAY_KEYS) {
450
+ const value = record[key]
451
+ const count = countFromUnknown(value)
452
+
453
+ if (count !== null) {
454
+ return countMetric(count, COUNT_NOUN_BY_ARRAY[key] || fallbackNoun)
455
+ }
456
+ }
457
+
458
+ for (const [key, value] of Object.entries(record)) {
459
+ if (COUNT_EXCLUDED_KEYS.has(key)) {
460
+ continue
461
+ }
462
+
463
+ if (!/_count$|_total$/i.test(key)) {
464
+ continue
465
+ }
466
+
467
+ const count = countFromUnknown(value)
468
+
469
+ if (count !== null) {
470
+ return countMetric(count, dynamicCountNounFromKey(key, fallbackNoun))
471
+ }
472
+ }
473
+
474
+ return null
475
+ }
476
+
477
+ function countFromText(value: string, fallbackNoun: string): CountMetric | null {
478
+ const text = value.trim()
479
+
480
+ if (!text) {
481
+ return null
482
+ }
483
+
484
+ const unitMatch =
485
+ text.match(/\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b/i) ||
486
+ text.match(/\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b/i)
487
+
488
+ if (unitMatch?.[1]) {
489
+ const n = Number(unitMatch[1])
490
+ const noun = unitMatch[2] ? singularizeNoun(unitMatch[2]) : fallbackNoun
491
+
492
+ return Number.isFinite(n) && n > 0 ? countMetric(Math.round(n), noun) : null
493
+ }
494
+
495
+ return null
496
+ }
497
+
498
+ function toolResultCount(
499
+ part: ToolPart,
500
+ argsRecord: Record<string, unknown>,
501
+ resultRecord: Record<string, unknown>
502
+ ): CountMetric | null {
503
+ if (part.result === undefined) {
504
+ return null
505
+ }
506
+
507
+ const fallbackNounByTool = fallbackCountNoun(part.toolName)
508
+
509
+ if (part.toolName === 'web_search') {
510
+ const hits = collectResultItems(part.result)
511
+
512
+ if (hits.length) {
513
+ return countMetric(hits.length, 'result')
514
+ }
515
+ }
516
+
517
+ const directCount = countFromRecord(resultRecord, fallbackNounByTool)
518
+
519
+ if (directCount !== null) {
520
+ return normalizeMetricForTool(part.toolName, directCount)
521
+ }
522
+
523
+ const payload = unwrapToolPayload(part.result)
524
+
525
+ if (isRecord(payload)) {
526
+ const payloadCount = countFromRecord(payload, fallbackNounByTool)
527
+
528
+ if (payloadCount !== null) {
529
+ return normalizeMetricForTool(part.toolName, payloadCount)
530
+ }
531
+ }
532
+
533
+ const summaryText =
534
+ firstStringField(resultRecord, ['summary', 'message', 'detail']) || fallbackDetailText(argsRecord, resultRecord)
535
+
536
+ const textMetric = countFromText(summaryText, fallbackNounByTool)
537
+
538
+ return textMetric ? normalizeMetricForTool(part.toolName, textMetric) : null
539
+ }
540
+
541
+ function looksLikeUrl(value: string): boolean {
542
+ return /^https?:\/\//i.test(value)
543
+ }
544
+
545
+ function looksLikePath(value: string): boolean {
546
+ return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
547
+ }
548
+
549
+ export function isPreviewableTarget(target: string): boolean {
550
+ return Boolean(
551
+ target &&
552
+ (/^file:\/\//i.test(target) ||
553
+ /^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
554
+ /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
555
+ )
556
+ }
557
+
558
+ function stableHash(value: string): string {
559
+ let hash = 0
560
+
561
+ for (let index = 0; index < value.length; index += 1) {
562
+ hash = Math.imul(31, hash) + value.charCodeAt(index)
563
+ }
564
+
565
+ return Math.abs(hash).toString(36)
566
+ }
567
+
568
+ export function toolPartDisclosureId(part: ToolPart): string {
569
+ if (part.toolCallId) {
570
+ return `tool:${part.toolCallId}`
571
+ }
572
+
573
+ return `tool:${part.toolName}:${stableHash(JSON.stringify(part.args ?? ''))}`
574
+ }
575
+
576
+ export function toolGroupDisclosureId(parts: ToolPart[]): string {
577
+ return `tool-group:${parts.map(toolPartDisclosureId).join('|')}`
578
+ }
579
+
580
+ const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i
581
+
582
+ function findFirstUrl(...sources: unknown[]): string {
583
+ for (const src of sources) {
584
+ if (typeof src === 'string') {
585
+ const m = src.match(URL_PATTERN)
586
+
587
+ if (m) {
588
+ return m[0]
589
+ }
590
+ } else if (src && typeof src === 'object') {
591
+ for (const v of Object.values(src as Record<string, unknown>)) {
592
+ const found = findFirstUrl(v)
593
+
594
+ if (found) {
595
+ return found
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ return ''
602
+ }
603
+
604
+ function hostnameOf(value: string): string {
605
+ try {
606
+ const url = new URL(value)
607
+
608
+ return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}`
609
+ } catch {
610
+ return value
611
+ }
612
+ }
613
+
614
+ export function looksRedundant(title: string, detail: string): boolean {
615
+ if (!detail) {
616
+ return true
617
+ }
618
+
619
+ const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim()
620
+
621
+ return norm(title) === norm(detail)
622
+ }
623
+
624
+ export function cleanVisibleText(text: string): string {
625
+ return text
626
+ .split(INLINE_CODE_SPLIT_RE)
627
+ .map(part =>
628
+ part.startsWith('`')
629
+ ? part
630
+ : part
631
+ .replace(BACKTICK_NOISE_RE, '')
632
+ .replace(CITATION_MARKER_RE, '')
633
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label: string, href: string) => {
634
+ const normalized = normalizeExternalUrl(href)
635
+
636
+ return `${label} ${normalized}`
637
+ })
638
+ )
639
+ .join('')
640
+ }
641
+
642
+ function summarizeBrowserSnapshot(snapshot: string): string {
643
+ const count = (re: RegExp) => snapshot.match(re)?.length ?? 0
644
+
645
+ const stats = [
646
+ `${count(/button\s+"[^"]+"/g)} buttons`,
647
+ `${count(/link\s+"[^"]+"/g)} links`,
648
+ `${count(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)} inputs`
649
+ ].join(' · ')
650
+
651
+ const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g))
652
+ .map(m => m[1].trim())
653
+ .filter(Boolean)
654
+ .slice(0, 4)
655
+
656
+ return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
657
+ }
658
+
659
+ function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
660
+ for (const key of keys) {
661
+ const value = record[key]
662
+
663
+ if (typeof value === 'string' && value.trim()) {
664
+ return value.trim()
665
+ }
666
+ }
667
+
668
+ return ''
669
+ }
670
+
671
+ function collectResultItems(value: unknown): unknown[] {
672
+ if (Array.isArray(value)) {
673
+ return value
674
+ }
675
+
676
+ const record = parseMaybeObject(value)
677
+
678
+ for (const key of [
679
+ 'web',
680
+ 'results',
681
+ 'search_results',
682
+ 'sources',
683
+ 'web_sources',
684
+ 'items',
685
+ 'organic_results',
686
+ 'organic',
687
+ 'matches',
688
+ 'documents'
689
+ ]) {
690
+ const candidate = record[key]
691
+
692
+ if (Array.isArray(candidate)) {
693
+ return candidate
694
+ }
695
+
696
+ if (isRecord(candidate)) {
697
+ const nested = collectResultItems(candidate)
698
+
699
+ if (nested.length) {
700
+ return nested
701
+ }
702
+ }
703
+ }
704
+
705
+ const payload = unwrapToolPayload(record)
706
+
707
+ return payload === record ? [] : collectResultItems(payload)
708
+ }
709
+
710
+ function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
711
+ const list = collectResultItems(result)
712
+
713
+ return list
714
+ .map(item => {
715
+ const r = parseMaybeObject(item)
716
+
717
+ return {
718
+ title: cleanVisibleText(firstStringField(r, ['title', 'name'])),
719
+ url: firstStringField(r, ['url', 'href', 'link']),
720
+ snippet: cleanVisibleText(firstStringField(r, ['snippet', 'description', 'body']))
721
+ }
722
+ })
723
+ .filter(hit => hit.title || hit.url)
724
+ .slice(0, limit)
725
+ }
726
+
727
+ function toolErrorText(part: ToolPart, result: Record<string, unknown>): string {
728
+ const extractedError = extractToolErrorMessage(part.result)
729
+
730
+ if (part.isError) {
731
+ return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.'
732
+ }
733
+
734
+ if (typeof result.error === 'string' && result.error.trim()) {
735
+ return result.error.trim()
736
+ }
737
+
738
+ if (extractedError) {
739
+ return extractedError
740
+ }
741
+
742
+ if (result.success === false || result.ok === false) {
743
+ return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.'
744
+ }
745
+
746
+ if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) {
747
+ return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
748
+ }
749
+
750
+ // A non-zero exit code alone is a weak failure signal: grep returns 1 on
751
+ // no-match, diff returns 1 on differences, piped commands surface the last
752
+ // stage's code, etc. — all routinely produce useful output and aren't
753
+ // failures. Only treat it as an error when the command produced no real
754
+ // output to show; otherwise render the output normally (not red).
755
+ const exit = numberValue(result.exit_code)
756
+
757
+ if (exit !== null && exit !== 0) {
758
+ const hasOutput = Boolean(firstStringField(result, ['output', 'stdout', 'stderr'])?.trim())
759
+
760
+ return hasOutput ? '' : `Command failed with exit code ${exit}.`
761
+ }
762
+
763
+ return ''
764
+ }
765
+
766
+ function toolStatus(part: ToolPart, resultRecord: Record<string, unknown>): ToolStatus {
767
+ if (part.result === undefined) {
768
+ return 'running'
769
+ }
770
+
771
+ return toolErrorText(part, resultRecord) ? 'error' : 'success'
772
+ }
773
+
774
+ function durationLabel(resultRecord: Record<string, unknown>): string | undefined {
775
+ const seconds = numberValue(resultRecord.duration_s)
776
+
777
+ if (seconds === null || seconds < 0) {
778
+ return undefined
779
+ }
780
+
781
+ return formatDurationSeconds(seconds)
782
+ }
783
+
784
+ function toolPreviewTarget(toolName: string, args: Record<string, unknown>, result: Record<string, unknown>): string {
785
+ const direct =
786
+ firstStringField(result, ['preview', 'url', 'target']) ||
787
+ firstStringField(args, ['preview', 'url', 'target', 'path', 'file', 'filepath']) ||
788
+ firstStringField(result, ['path', 'file', 'filepath'])
789
+
790
+ if (direct && (looksLikeUrl(direct) || looksLikePath(direct))) {
791
+ return direct
792
+ }
793
+
794
+ if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') {
795
+ const explicit = firstStringField(args, ['url', 'search_term', 'query']) || firstStringField(result, ['url'])
796
+
797
+ return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result)
798
+ }
799
+
800
+ if (toolName === 'write_file' || toolName === 'edit_file') {
801
+ return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff']))
802
+ }
803
+
804
+ return ''
805
+ }
806
+
807
+ function toolImageUrl(args: Record<string, unknown>, result: Record<string, unknown>): string {
808
+ const candidate =
809
+ firstStringField(result, ['image_url', 'url', 'path', 'image_path']) ||
810
+ firstStringField(args, ['image_url', 'url', 'path'])
811
+
812
+ if (!candidate) {
813
+ return ''
814
+ }
815
+
816
+ // Only inline-render images the renderer can actually fetch: data URLs or
817
+ // remote http(s). A bare filesystem path (e.g. vision_analyze's input image)
818
+ // resolves against the dev-server origin and 404s — fall back to the tool's
819
+ // codicon instead of a broken <img>.
820
+ const isDataImage = candidate.toLowerCase().startsWith('data:image/')
821
+ const isRemoteImage = /^https?:\/\//i.test(candidate) && /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
822
+
823
+ return isDataImage || isRemoteImage ? candidate : ''
824
+ }
825
+
826
+ function stripAnsi(value: string): string {
827
+ return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
828
+ }
829
+
830
+ export function stripInlineDiffChrome(value: string): string {
831
+ return value
832
+ ? stripAnsi(value)
833
+ .replace(/^\s*┊\s*review diff\s*\n/i, '')
834
+ .trim()
835
+ : ''
836
+ }
837
+
838
+ function htmlPathFromInlineDiff(value: string): string {
839
+ const cleaned = stripInlineDiffChrome(value)
840
+
841
+ for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
842
+ const candidate = match[1]?.trim()
843
+
844
+ if (candidate) {
845
+ return candidate
846
+ }
847
+ }
848
+
849
+ return ''
850
+ }
851
+
852
+ function stripDividerLines(value: string): string {
853
+ return value
854
+ .split('\n')
855
+ .filter(line => !/^[-=]{3,}\s*$/.test(line.trim()))
856
+ .join('\n')
857
+ .trim()
858
+ }
859
+
860
+ export function inlineDiffFromResult(result: unknown): string {
861
+ const value = parseMaybeObject(result).inline_diff
862
+
863
+ return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
864
+ }
865
+
866
+ // Falls back to a string only when there's something concrete to render —
867
+ // counts of opaque items/fields are noise, not signal.
868
+ function minimalValueSummary(value: unknown): string {
869
+ if (value == null) {
870
+ return ''
871
+ }
872
+
873
+ if (typeof value === 'string') {
874
+ return value
875
+ }
876
+
877
+ if (typeof value === 'number' || typeof value === 'boolean') {
878
+ return String(value)
879
+ }
880
+
881
+ return ''
882
+ }
883
+
884
+ function fallbackDetailText(args: unknown, result: unknown): string {
885
+ const argContext = contextValue(args)
886
+ const resultContext = contextValue(result)
887
+
888
+ if (resultContext && resultContext !== argContext) {
889
+ return resultContext
890
+ }
891
+
892
+ if (argContext) {
893
+ return argContext
894
+ }
895
+
896
+ if (result !== undefined) {
897
+ return formatToolResultSummary(result) || minimalValueSummary(result)
898
+ }
899
+
900
+ return formatToolResultSummary(args) || minimalValueSummary(args)
901
+ }
902
+
903
+ function cronScalar(value: unknown): string {
904
+ if (typeof value === 'string') return value.trim()
905
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
906
+
907
+ return ''
908
+ }
909
+
910
+ function formatCronTime(iso: string): string {
911
+ const ts = Date.parse(iso)
912
+
913
+ if (Number.isNaN(ts)) return iso
914
+
915
+ return new Date(ts).toLocaleString(undefined, {
916
+ month: 'short',
917
+ day: 'numeric',
918
+ hour: '2-digit',
919
+ minute: '2-digit'
920
+ })
921
+ }
922
+
923
+ function cronjobSubtitle(
924
+ argsRecord: Record<string, unknown>,
925
+ resultRecord: Record<string, unknown>
926
+ ): string {
927
+ const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
928
+
929
+ if (jobs) {
930
+ return jobs.length ? `${jobs.length} cron job${jobs.length === 1 ? '' : 's'}` : 'No cron jobs'
931
+ }
932
+
933
+ const message = firstStringField(resultRecord, ['message'])
934
+
935
+ if (message) return message
936
+
937
+ const action = firstStringField(argsRecord, ['action']) || 'manage'
938
+ const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
939
+ const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}`
940
+
941
+ return name ? `${label} ${name}` : `Cron ${action}`
942
+ }
943
+
944
+ function cronjobDetail(
945
+ argsRecord: Record<string, unknown>,
946
+ resultRecord: Record<string, unknown>
947
+ ): string {
948
+ const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
949
+
950
+ if (jobs) {
951
+ if (!jobs.length) return 'No cron jobs scheduled'
952
+
953
+ return jobs
954
+ .slice(0, 20)
955
+ .map(job => {
956
+ const row = isRecord(job) ? job : {}
957
+ const name = firstStringField(row, ['name', 'id']) || 'job'
958
+ const sched = firstStringField(row, ['schedule_display', 'schedule'])
959
+
960
+ return sched ? `- ${name} · ${sched}` : `- ${name}`
961
+ })
962
+ .join('\n')
963
+ }
964
+
965
+ const nextRun = cronScalar(resultRecord.next_run_at)
966
+ const rows: [string, string][] = [
967
+ ['Schedule', cronScalar(resultRecord.schedule)],
968
+ ['Repeat', cronScalar(resultRecord.repeat)],
969
+ ['Delivery', cronScalar(resultRecord.deliver)],
970
+ ['Next run', nextRun ? formatCronTime(nextRun) : '']
971
+ ]
972
+ const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
973
+
974
+ return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
975
+ }
976
+
977
+ function toolSubtitle(
978
+ part: ToolPart,
979
+ argsRecord: Record<string, unknown>,
980
+ resultRecord: Record<string, unknown>
981
+ ): string {
982
+ const toolName = part.toolName
983
+
984
+ if (toolName === 'browser_navigate') {
985
+ const url =
986
+ firstStringField(argsRecord, ['url', 'target']) ||
987
+ firstStringField(resultRecord, ['url']) ||
988
+ findFirstUrl(argsRecord, resultRecord)
989
+
990
+ return url ? hostnameOf(url) : 'Navigated in browser'
991
+ }
992
+
993
+ if (toolName === 'browser_snapshot') {
994
+ const snapshot = firstStringField(resultRecord, ['snapshot'])
995
+
996
+ return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot'
997
+ }
998
+
999
+ if (toolName === 'browser_click') {
1000
+ const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target'])
1001
+
1002
+ if (!clicked) {
1003
+ return 'Clicked on page'
1004
+ }
1005
+
1006
+ return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}`
1007
+ }
1008
+
1009
+ if (toolName === 'browser_fill' || toolName === 'browser_type') {
1010
+ const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target'])
1011
+ const value = firstStringField(argsRecord, ['value', 'text'])
1012
+
1013
+ return (
1014
+ [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') ||
1015
+ 'Filled page input'
1016
+ )
1017
+ }
1018
+
1019
+ if (toolName === 'web_search') {
1020
+ const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord)
1021
+
1022
+ return query ? `Query: ${query}` : 'Queried web sources'
1023
+ }
1024
+
1025
+ if (toolName === 'terminal' || toolName === 'execute_code') {
1026
+ const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
1027
+
1028
+ const lines = Array.isArray(resultRecord.lines)
1029
+ ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n')
1030
+ : ''
1031
+
1032
+ const previewSource = (output || lines).trim()
1033
+
1034
+ if (previewSource) {
1035
+ const firstMeaningfulLine = previewSource
1036
+ .split('\n')
1037
+ .map(line => line.trim())
1038
+ .find(line => line.length > 0)
1039
+
1040
+ if (firstMeaningfulLine) {
1041
+ return compactPreview(firstMeaningfulLine, 160)
1042
+ }
1043
+ }
1044
+
1045
+ const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord)
1046
+
1047
+ return command ? compactPreview(command, 120) : 'Executed command'
1048
+ }
1049
+
1050
+ if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') {
1051
+ const path =
1052
+ firstStringField(argsRecord, ['path', 'file', 'filepath']) ||
1053
+ htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff']))
1054
+
1055
+ return (
1056
+ path ||
1057
+ (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord))
1058
+ )
1059
+ }
1060
+
1061
+ if (toolName === 'web_extract') {
1062
+ const url =
1063
+ firstStringField(argsRecord, ['url']) ||
1064
+ firstStringField(resultRecord, ['url']) ||
1065
+ findFirstUrl(argsRecord, resultRecord)
1066
+
1067
+ return url ? hostnameOf(url) : 'Fetched webpage'
1068
+ }
1069
+
1070
+ if (toolName === 'cronjob') {
1071
+ return cronjobSubtitle(argsRecord, resultRecord)
1072
+ }
1073
+
1074
+ return (
1075
+ compactPreview(formatToolResultSummary(part.result), 120) ||
1076
+ compactPreview(resultRecord, 120) ||
1077
+ compactPreview(argsRecord, 120) ||
1078
+ fallbackDetailText(argsRecord, resultRecord)
1079
+ )
1080
+ }
1081
+
1082
+ function toolDetailLabel(toolName: string): string {
1083
+ if (toolName === 'web_search') {
1084
+ return 'Details'
1085
+ }
1086
+
1087
+ if (toolName === 'browser_snapshot') {
1088
+ return 'Snapshot summary'
1089
+ }
1090
+
1091
+ if (toolName === 'terminal' || toolName === 'execute_code') {
1092
+ return 'Command output'
1093
+ }
1094
+
1095
+ return ''
1096
+ }
1097
+
1098
+ function toolDetailText(
1099
+ part: ToolPart,
1100
+ argsRecord: Record<string, unknown>,
1101
+ resultRecord: Record<string, unknown>
1102
+ ): string {
1103
+ if (part.toolName === 'browser_snapshot') {
1104
+ const snapshot = firstStringField(resultRecord, ['snapshot'])
1105
+
1106
+ return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord)
1107
+ }
1108
+
1109
+ if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
1110
+ // Streams are split out into ToolView.stdout / ToolView.stderr by
1111
+ // buildToolView so the renderer can label them separately. The merged
1112
+ // fallback here is only used when the backend doesn't expose either
1113
+ // stream individually.
1114
+ const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
1115
+
1116
+ const lines = Array.isArray(resultRecord.lines)
1117
+ ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n')
1118
+ : ''
1119
+
1120
+ if (output || lines) {
1121
+ return [output, lines].filter(Boolean).join('\n')
1122
+ }
1123
+ }
1124
+
1125
+ if (part.toolName === 'web_extract') {
1126
+ const direct = firstStringField(resultRecord, ['content', 'text', 'markdown', 'body', 'summary', 'message'])
1127
+
1128
+ if (direct) {
1129
+ return direct.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim()
1130
+ }
1131
+
1132
+ const results = Array.isArray(resultRecord.results) ? resultRecord.results : []
1133
+
1134
+ const aggregated = results
1135
+ .map(item => {
1136
+ const row = parseMaybeObject(item)
1137
+
1138
+ return firstStringField(row, ['content', 'text', 'markdown', 'body'])
1139
+ })
1140
+ .filter(Boolean)
1141
+ .join('\n\n---\n\n')
1142
+
1143
+ if (aggregated) {
1144
+ return aggregated
1145
+ }
1146
+ }
1147
+
1148
+ if (part.toolName === 'read_file') {
1149
+ const content = firstStringField(resultRecord, ['content', 'text', 'data', 'body'])
1150
+
1151
+ if (content) {
1152
+ return content
1153
+ }
1154
+ }
1155
+
1156
+ if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
1157
+ return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord)
1158
+ }
1159
+
1160
+ if (part.toolName === 'web_search') {
1161
+ const detail = fallbackDetailText(argsRecord, resultRecord)
1162
+ const seconds = numberValue(resultRecord.duration_s)
1163
+ const duration = seconds === null ? '' : formatDurationSeconds(seconds)
1164
+
1165
+ if (!duration) {
1166
+ return detail
1167
+ }
1168
+
1169
+ return detail
1170
+ .replace(/^\s*-\s*Duration\s+S\s*:\s*[-+]?[\d.]+(?:e[-+]?\d+)?\s*$/gim, `- Duration: ${duration}`)
1171
+ .replace(/\bDuration\s+S\s*:/gi, 'Duration:')
1172
+ }
1173
+
1174
+ if (part.toolName === 'cronjob') {
1175
+ return cronjobDetail(argsRecord, resultRecord)
1176
+ }
1177
+
1178
+ return fallbackDetailText(argsRecord, resultRecord)
1179
+ }
1180
+
1181
+ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
1182
+ const copy = {
1183
+ command: translateNow('assistant.tool.copyCommand'),
1184
+ content: translateNow('assistant.tool.copyContent'),
1185
+ file: translateNow('assistant.tool.copyFile'),
1186
+ output: translateNow('assistant.tool.copyOutput'),
1187
+ path: translateNow('assistant.tool.copyPath'),
1188
+ query: translateNow('assistant.tool.copyQuery'),
1189
+ results: translateNow('assistant.tool.copyResults'),
1190
+ url: translateNow('assistant.tool.copyUrl'),
1191
+ generic: translateNow('common.copy')
1192
+ }
1193
+ const args = parseMaybeObject(part.args)
1194
+ const result = parseMaybeObject(part.result)
1195
+ const detail = view.detail.trim()
1196
+ const hasSubstantialOutput = detail.length > 16
1197
+
1198
+ if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
1199
+ if (hasSubstantialOutput) {
1200
+ return { label: copy.output, text: detail }
1201
+ }
1202
+
1203
+ const command = firstStringField(args, ['command', 'code']) || contextValue(args)
1204
+
1205
+ if (command) {
1206
+ return { label: copy.command, text: command }
1207
+ }
1208
+ }
1209
+
1210
+ if (part.toolName === 'web_extract') {
1211
+ if (hasSubstantialOutput) {
1212
+ return { label: copy.content, text: detail }
1213
+ }
1214
+
1215
+ const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
1216
+
1217
+ if (url) {
1218
+ return { label: copy.url, text: url }
1219
+ }
1220
+ }
1221
+
1222
+ if (part.toolName === 'browser_navigate') {
1223
+ const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
1224
+
1225
+ if (url) {
1226
+ return { label: copy.url, text: url }
1227
+ }
1228
+ }
1229
+
1230
+ if (part.toolName === 'web_search') {
1231
+ if (view.searchHits?.length) {
1232
+ const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
1233
+
1234
+ return { label: copy.results, text }
1235
+ }
1236
+
1237
+ const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
1238
+
1239
+ if (query) {
1240
+ return { label: copy.query, text: query }
1241
+ }
1242
+ }
1243
+
1244
+ if (part.toolName === 'read_file') {
1245
+ if (hasSubstantialOutput) {
1246
+ return { label: copy.file, text: detail }
1247
+ }
1248
+
1249
+ const path = firstStringField(args, ['path', 'file', 'filepath'])
1250
+
1251
+ if (path) {
1252
+ return { label: copy.path, text: path }
1253
+ }
1254
+ }
1255
+
1256
+ if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
1257
+ const path = firstStringField(args, ['path', 'file', 'filepath'])
1258
+
1259
+ if (path) {
1260
+ return { label: copy.path, text: path }
1261
+ }
1262
+ }
1263
+
1264
+ if (detail) {
1265
+ return { label: copy.output, text: detail }
1266
+ }
1267
+
1268
+ return { label: copy.generic, text: view.title }
1269
+ }
1270
+
1271
+ function dynamicTitle(
1272
+ part: ToolPart,
1273
+ args: Record<string, unknown>,
1274
+ result: Record<string, unknown>,
1275
+ fallback: string
1276
+ ): string {
1277
+ const verb = (gerund: string, past: string) => (part.result === undefined ? gerund : past)
1278
+
1279
+ if (part.toolName === 'web_extract') {
1280
+ const url = findFirstUrl(args, result)
1281
+
1282
+ return url ? `${verb('Reading', 'Read')} ${hostnameOf(url)}` : fallback
1283
+ }
1284
+
1285
+ if (part.toolName === 'browser_navigate') {
1286
+ const url = findFirstUrl(args, result)
1287
+
1288
+ return url ? `${verb('Opening', 'Opened')} ${hostnameOf(url)}` : fallback
1289
+ }
1290
+
1291
+ if (part.toolName === 'web_search') {
1292
+ const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
1293
+
1294
+ return query ? `${verb('Searching', 'Searched')} “${compactPreview(query, 48)}”` : fallback
1295
+ }
1296
+
1297
+ if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
1298
+ const command = firstStringField(args, ['command', 'code']) || contextValue(args)
1299
+
1300
+ if (command) {
1301
+ const verbText = part.toolName === 'execute_code' ? verb('Running code', 'Ran code') : verb('Running', 'Ran')
1302
+
1303
+ return `${verbText} · ${compactPreview(command, 160)}`
1304
+ }
1305
+ }
1306
+
1307
+ return fallback
1308
+ }
1309
+
1310
+ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
1311
+ const argsRecord = parseMaybeObject(part.args)
1312
+ const resultRecord = parseMaybeObject(part.result)
1313
+ const meta = toolMeta(part.toolName)
1314
+ const status = toolStatus(part, resultRecord)
1315
+ const error = toolErrorText(part, resultRecord)
1316
+ const baseTitle = part.result === undefined ? meta.pending : meta.done
1317
+ const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
1318
+ const titleEnriched = title !== baseTitle
1319
+ const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
1320
+ const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code'
1321
+ const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle
1322
+ const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord))
1323
+
1324
+ const detail = error
1325
+ ? [error, detailBody]
1326
+ .filter(Boolean)
1327
+ .filter((value, index, list) => list.findIndex(entry => entry.trim() === value.trim()) === index)
1328
+ .join('\n\n')
1329
+ : detailBody
1330
+
1331
+ const searchHits =
1332
+ part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
1333
+
1334
+ const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
1335
+
1336
+ // For shell/code tools we surface stdout and stderr as separate labeled
1337
+ // streams in the renderer. Many CLIs use stderr for informational
1338
+ // messages (npm progress, git hints), so we deliberately don't paint
1339
+ // stderr destructively even though it's tagged.
1340
+ const rendersAnsi = part.toolName === 'terminal' || part.toolName === 'execute_code'
1341
+ const stdout = rendersAnsi ? firstStringField(resultRecord, ['stdout']) : ''
1342
+ const stderrRaw = rendersAnsi ? firstStringField(resultRecord, ['stderr']) : ''
1343
+ // Only attach stderr when the backend actually returned it as its own
1344
+ // field — otherwise the merged `detail` already covers it and double-
1345
+ // rendering would duplicate output.
1346
+ const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
1347
+
1348
+ return {
1349
+ countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
1350
+ detail,
1351
+ detailLabel: error ? 'Error details' : toolDetailLabel(part.toolName),
1352
+ durationLabel: durationLabel(resultRecord),
1353
+ icon: meta.icon,
1354
+ imageUrl: toolImageUrl(argsRecord, resultRecord),
1355
+ inlineDiff,
1356
+ previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
1357
+ rawArgs: prettyJson(part.args),
1358
+ rawResult: prettyJson(part.result),
1359
+ rendersAnsi: rendersAnsi || undefined,
1360
+ searchHits: searchHits?.length ? searchHits : undefined,
1361
+ stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
1362
+ stdout: hasSplitStreams ? stdout || undefined : undefined,
1363
+ status,
1364
+ subtitle,
1365
+ title,
1366
+ tone: meta.tone
1367
+ }
1368
+ }