@schlessera/brain-ui-react 0.4.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 (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/components/chat/ask-user-card.d.ts +12 -0
  4. package/dist/components/chat/ask-user-card.d.ts.map +1 -0
  5. package/dist/components/chat/ask-user-card.js +185 -0
  6. package/dist/components/chat/ask-user-card.js.map +1 -0
  7. package/dist/components/chat/brain-markdown.d.ts +35 -0
  8. package/dist/components/chat/brain-markdown.d.ts.map +1 -0
  9. package/dist/components/chat/brain-markdown.js +363 -0
  10. package/dist/components/chat/brain-markdown.js.map +1 -0
  11. package/dist/components/chat/chat-page.d.ts +2 -0
  12. package/dist/components/chat/chat-page.d.ts.map +1 -0
  13. package/dist/components/chat/chat-page.js +442 -0
  14. package/dist/components/chat/chat-page.js.map +1 -0
  15. package/dist/components/chat/command-palette.d.ts +5 -0
  16. package/dist/components/chat/command-palette.d.ts.map +1 -0
  17. package/dist/components/chat/command-palette.js +18 -0
  18. package/dist/components/chat/command-palette.js.map +1 -0
  19. package/dist/components/chat/markdown-content.d.ts +4 -0
  20. package/dist/components/chat/markdown-content.d.ts.map +1 -0
  21. package/dist/components/chat/markdown-content.js +6 -0
  22. package/dist/components/chat/markdown-content.js.map +1 -0
  23. package/dist/components/chat/message-bubble.d.ts +9 -0
  24. package/dist/components/chat/message-bubble.d.ts.map +1 -0
  25. package/dist/components/chat/message-bubble.js +129 -0
  26. package/dist/components/chat/message-bubble.js.map +1 -0
  27. package/dist/components/chat/message-share.d.ts +10 -0
  28. package/dist/components/chat/message-share.d.ts.map +1 -0
  29. package/dist/components/chat/message-share.js +65 -0
  30. package/dist/components/chat/message-share.js.map +1 -0
  31. package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
  32. package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
  33. package/dist/components/chat/renderers/claude-tools.js +38 -0
  34. package/dist/components/chat/renderers/claude-tools.js.map +1 -0
  35. package/dist/components/chat/renderers/generic.d.ts +9 -0
  36. package/dist/components/chat/renderers/generic.d.ts.map +1 -0
  37. package/dist/components/chat/renderers/generic.js +68 -0
  38. package/dist/components/chat/renderers/generic.js.map +1 -0
  39. package/dist/components/chat/renderers/index.d.ts +5 -0
  40. package/dist/components/chat/renderers/index.d.ts.map +1 -0
  41. package/dist/components/chat/renderers/index.js +17 -0
  42. package/dist/components/chat/renderers/index.js.map +1 -0
  43. package/dist/components/chat/risk-hints.d.ts +20 -0
  44. package/dist/components/chat/risk-hints.d.ts.map +1 -0
  45. package/dist/components/chat/risk-hints.js +81 -0
  46. package/dist/components/chat/risk-hints.js.map +1 -0
  47. package/dist/components/chat/session-drawer.d.ts +6 -0
  48. package/dist/components/chat/session-drawer.d.ts.map +1 -0
  49. package/dist/components/chat/session-drawer.js +96 -0
  50. package/dist/components/chat/session-drawer.js.map +1 -0
  51. package/dist/components/chat/share-block.d.ts +9 -0
  52. package/dist/components/chat/share-block.d.ts.map +1 -0
  53. package/dist/components/chat/share-block.js +70 -0
  54. package/dist/components/chat/share-block.js.map +1 -0
  55. package/dist/components/chat/tool-call-timeline.d.ts +8 -0
  56. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
  57. package/dist/components/chat/tool-call-timeline.js +100 -0
  58. package/dist/components/chat/tool-call-timeline.js.map +1 -0
  59. package/dist/components/chat/tool-views.d.ts +116 -0
  60. package/dist/components/chat/tool-views.d.ts.map +1 -0
  61. package/dist/components/chat/tool-views.js +628 -0
  62. package/dist/components/chat/tool-views.js.map +1 -0
  63. package/dist/components/chat/welcome-state.d.ts +4 -0
  64. package/dist/components/chat/welcome-state.d.ts.map +1 -0
  65. package/dist/components/chat/welcome-state.js +10 -0
  66. package/dist/components/chat/welcome-state.js.map +1 -0
  67. package/dist/components/connectivity/connection-gate.d.ts +5 -0
  68. package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
  69. package/dist/components/connectivity/connection-gate.js +42 -0
  70. package/dist/components/connectivity/connection-gate.js.map +1 -0
  71. package/dist/components/connectivity/login-screen.d.ts +9 -0
  72. package/dist/components/connectivity/login-screen.d.ts.map +1 -0
  73. package/dist/components/connectivity/login-screen.js +99 -0
  74. package/dist/components/connectivity/login-screen.js.map +1 -0
  75. package/dist/components/files/file-panel.d.ts +5 -0
  76. package/dist/components/files/file-panel.d.ts.map +1 -0
  77. package/dist/components/files/file-panel.js +46 -0
  78. package/dist/components/files/file-panel.js.map +1 -0
  79. package/dist/components/files/file-tree.d.ts +2 -0
  80. package/dist/components/files/file-tree.d.ts.map +1 -0
  81. package/dist/components/files/file-tree.js +63 -0
  82. package/dist/components/files/file-tree.js.map +1 -0
  83. package/dist/components/files/file-viewer-binary.d.ts +5 -0
  84. package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
  85. package/dist/components/files/file-viewer-binary.js +20 -0
  86. package/dist/components/files/file-viewer-binary.js.map +1 -0
  87. package/dist/components/files/file-viewer-html.d.ts +4 -0
  88. package/dist/components/files/file-viewer-html.d.ts.map +1 -0
  89. package/dist/components/files/file-viewer-html.js +5 -0
  90. package/dist/components/files/file-viewer-html.js.map +1 -0
  91. package/dist/components/files/file-viewer-markdown.d.ts +4 -0
  92. package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
  93. package/dist/components/files/file-viewer-markdown.js +10 -0
  94. package/dist/components/files/file-viewer-markdown.js.map +1 -0
  95. package/dist/components/files/file-viewer-raw.d.ts +5 -0
  96. package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
  97. package/dist/components/files/file-viewer-raw.js +15 -0
  98. package/dist/components/files/file-viewer-raw.js.map +1 -0
  99. package/dist/components/files/file-viewer.d.ts +2 -0
  100. package/dist/components/files/file-viewer.d.ts.map +1 -0
  101. package/dist/components/files/file-viewer.js +175 -0
  102. package/dist/components/files/file-viewer.js.map +1 -0
  103. package/dist/components/files/frontmatter-panel.d.ts +5 -0
  104. package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
  105. package/dist/components/files/frontmatter-panel.js +12 -0
  106. package/dist/components/files/frontmatter-panel.js.map +1 -0
  107. package/dist/components/layout/app-shell.d.ts +5 -0
  108. package/dist/components/layout/app-shell.d.ts.map +1 -0
  109. package/dist/components/layout/app-shell.js +10 -0
  110. package/dist/components/layout/app-shell.js.map +1 -0
  111. package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
  112. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
  113. package/dist/components/layout/mobile-tab-bar.js +16 -0
  114. package/dist/components/layout/mobile-tab-bar.js.map +1 -0
  115. package/dist/components/layout/side-rail.d.ts +2 -0
  116. package/dist/components/layout/side-rail.d.ts.map +1 -0
  117. package/dist/components/layout/side-rail.js +26 -0
  118. package/dist/components/layout/side-rail.js.map +1 -0
  119. package/dist/components/layout/slide-panel.d.ts +9 -0
  120. package/dist/components/layout/slide-panel.d.ts.map +1 -0
  121. package/dist/components/layout/slide-panel.js +18 -0
  122. package/dist/components/layout/slide-panel.js.map +1 -0
  123. package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
  124. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
  125. package/dist/components/quick-actions/streaming-modal.js +92 -0
  126. package/dist/components/quick-actions/streaming-modal.js.map +1 -0
  127. package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
  128. package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
  129. package/dist/components/quick-actions/whatsup-modal.js +82 -0
  130. package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
  131. package/dist/components/settings/passkey-panel.d.ts +10 -0
  132. package/dist/components/settings/passkey-panel.d.ts.map +1 -0
  133. package/dist/components/settings/passkey-panel.js +118 -0
  134. package/dist/components/settings/passkey-panel.js.map +1 -0
  135. package/dist/components/share/share-menu.d.ts +23 -0
  136. package/dist/components/share/share-menu.d.ts.map +1 -0
  137. package/dist/components/share/share-menu.js +58 -0
  138. package/dist/components/share/share-menu.js.map +1 -0
  139. package/dist/components/voice/dictation-sheet.d.ts +6 -0
  140. package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
  141. package/dist/components/voice/dictation-sheet.js +91 -0
  142. package/dist/components/voice/dictation-sheet.js.map +1 -0
  143. package/dist/components/voice/mic-button.d.ts +6 -0
  144. package/dist/components/voice/mic-button.d.ts.map +1 -0
  145. package/dist/components/voice/mic-button.js +9 -0
  146. package/dist/components/voice/mic-button.js.map +1 -0
  147. package/dist/components/voice/review-card.d.ts +8 -0
  148. package/dist/components/voice/review-card.d.ts.map +1 -0
  149. package/dist/components/voice/review-card.js +8 -0
  150. package/dist/components/voice/review-card.js.map +1 -0
  151. package/dist/config.d.ts +19 -0
  152. package/dist/config.d.ts.map +1 -0
  153. package/dist/config.js +10 -0
  154. package/dist/config.js.map +1 -0
  155. package/dist/hooks/use-vpn-status.d.ts +2 -0
  156. package/dist/hooks/use-vpn-status.d.ts.map +1 -0
  157. package/dist/hooks/use-vpn-status.js +71 -0
  158. package/dist/hooks/use-vpn-status.js.map +1 -0
  159. package/dist/hooks/use-websocket.d.ts +8 -0
  160. package/dist/hooks/use-websocket.d.ts.map +1 -0
  161. package/dist/hooks/use-websocket.js +366 -0
  162. package/dist/hooks/use-websocket.js.map +1 -0
  163. package/dist/index.d.ts +25 -0
  164. package/dist/index.d.ts.map +1 -0
  165. package/dist/index.js +31 -0
  166. package/dist/index.js.map +1 -0
  167. package/dist/lib/api-client.d.ts +115 -0
  168. package/dist/lib/api-client.d.ts.map +1 -0
  169. package/dist/lib/api-client.js +70 -0
  170. package/dist/lib/api-client.js.map +1 -0
  171. package/dist/lib/backend.d.ts +7 -0
  172. package/dist/lib/backend.d.ts.map +1 -0
  173. package/dist/lib/backend.js +33 -0
  174. package/dist/lib/backend.js.map +1 -0
  175. package/dist/lib/frontmatter.d.ts +21 -0
  176. package/dist/lib/frontmatter.d.ts.map +1 -0
  177. package/dist/lib/frontmatter.js +88 -0
  178. package/dist/lib/frontmatter.js.map +1 -0
  179. package/dist/lib/image-attachments.d.ts +36 -0
  180. package/dist/lib/image-attachments.d.ts.map +1 -0
  181. package/dist/lib/image-attachments.js +175 -0
  182. package/dist/lib/image-attachments.js.map +1 -0
  183. package/dist/lib/passkeys.d.ts +18 -0
  184. package/dist/lib/passkeys.d.ts.map +1 -0
  185. package/dist/lib/passkeys.js +36 -0
  186. package/dist/lib/passkeys.js.map +1 -0
  187. package/dist/lib/share.d.ts +53 -0
  188. package/dist/lib/share.d.ts.map +1 -0
  189. package/dist/lib/share.js +144 -0
  190. package/dist/lib/share.js.map +1 -0
  191. package/dist/lib/strip-markdown.d.ts +10 -0
  192. package/dist/lib/strip-markdown.d.ts.map +1 -0
  193. package/dist/lib/strip-markdown.js +37 -0
  194. package/dist/lib/strip-markdown.js.map +1 -0
  195. package/dist/lib/tool-names.d.ts +17 -0
  196. package/dist/lib/tool-names.d.ts.map +1 -0
  197. package/dist/lib/tool-names.js +23 -0
  198. package/dist/lib/tool-names.js.map +1 -0
  199. package/dist/lib/utils.d.ts +3 -0
  200. package/dist/lib/utils.d.ts.map +1 -0
  201. package/dist/lib/utils.js +6 -0
  202. package/dist/lib/utils.js.map +1 -0
  203. package/dist/lib/ws-client.d.ts +26 -0
  204. package/dist/lib/ws-client.d.ts.map +1 -0
  205. package/dist/lib/ws-client.js +93 -0
  206. package/dist/lib/ws-client.js.map +1 -0
  207. package/dist/stores/chat-store.d.ts +131 -0
  208. package/dist/stores/chat-store.d.ts.map +1 -0
  209. package/dist/stores/chat-store.js +376 -0
  210. package/dist/stores/chat-store.js.map +1 -0
  211. package/dist/stores/connection-store.d.ts +11 -0
  212. package/dist/stores/connection-store.d.ts.map +1 -0
  213. package/dist/stores/connection-store.js +8 -0
  214. package/dist/stores/connection-store.js.map +1 -0
  215. package/dist/stores/file-store.d.ts +53 -0
  216. package/dist/stores/file-store.d.ts.map +1 -0
  217. package/dist/stores/file-store.js +284 -0
  218. package/dist/stores/file-store.js.map +1 -0
  219. package/dist/stores/provider-store.d.ts +24 -0
  220. package/dist/stores/provider-store.d.ts.map +1 -0
  221. package/dist/stores/provider-store.js +56 -0
  222. package/dist/stores/provider-store.js.map +1 -0
  223. package/dist/stores/ui-store.d.ts +21 -0
  224. package/dist/stores/ui-store.d.ts.map +1 -0
  225. package/dist/stores/ui-store.js +23 -0
  226. package/dist/stores/ui-store.js.map +1 -0
  227. package/dist/styles.css +2 -0
  228. package/dist/voice/asr-clients.d.ts +2 -0
  229. package/dist/voice/asr-clients.d.ts.map +1 -0
  230. package/dist/voice/asr-clients.js +33 -0
  231. package/dist/voice/asr-clients.js.map +1 -0
  232. package/dist/voice/asr-deepgram.d.ts +38 -0
  233. package/dist/voice/asr-deepgram.d.ts.map +1 -0
  234. package/dist/voice/asr-deepgram.js +307 -0
  235. package/dist/voice/asr-deepgram.js.map +1 -0
  236. package/dist/voice/asr-webspeech.d.ts +23 -0
  237. package/dist/voice/asr-webspeech.d.ts.map +1 -0
  238. package/dist/voice/asr-webspeech.js +93 -0
  239. package/dist/voice/asr-webspeech.js.map +1 -0
  240. package/dist/voice/use-dictation.d.ts +6 -0
  241. package/dist/voice/use-dictation.d.ts.map +1 -0
  242. package/dist/voice/use-dictation.js +174 -0
  243. package/dist/voice/use-dictation.js.map +1 -0
  244. package/dist/voice/voice-store.d.ts +26 -0
  245. package/dist/voice/voice-store.d.ts.map +1 -0
  246. package/dist/voice/voice-store.js +27 -0
  247. package/dist/voice/voice-store.js.map +1 -0
  248. package/package.json +71 -0
  249. package/src/components/chat/ask-user-card.tsx +451 -0
  250. package/src/components/chat/brain-markdown.tsx +547 -0
  251. package/src/components/chat/chat-page.tsx +820 -0
  252. package/src/components/chat/command-palette.tsx +68 -0
  253. package/src/components/chat/markdown-content.tsx +5 -0
  254. package/src/components/chat/message-bubble.tsx +362 -0
  255. package/src/components/chat/message-share.ts +78 -0
  256. package/src/components/chat/renderers/claude-tools.tsx +53 -0
  257. package/src/components/chat/renderers/generic.tsx +85 -0
  258. package/src/components/chat/renderers/index.ts +19 -0
  259. package/src/components/chat/risk-hints.ts +95 -0
  260. package/src/components/chat/session-drawer.tsx +213 -0
  261. package/src/components/chat/share-block.tsx +128 -0
  262. package/src/components/chat/tool-call-timeline.tsx +322 -0
  263. package/src/components/chat/tool-views.tsx +898 -0
  264. package/src/components/chat/welcome-state.tsx +105 -0
  265. package/src/components/connectivity/connection-gate.tsx +137 -0
  266. package/src/components/connectivity/login-screen.tsx +169 -0
  267. package/src/components/files/file-panel.tsx +116 -0
  268. package/src/components/files/file-tree.tsx +143 -0
  269. package/src/components/files/file-viewer-binary.tsx +63 -0
  270. package/src/components/files/file-viewer-html.tsx +10 -0
  271. package/src/components/files/file-viewer-markdown.tsx +14 -0
  272. package/src/components/files/file-viewer-raw.tsx +44 -0
  273. package/src/components/files/file-viewer.tsx +290 -0
  274. package/src/components/files/frontmatter-panel.tsx +78 -0
  275. package/src/components/layout/app-shell.tsx +25 -0
  276. package/src/components/layout/mobile-tab-bar.tsx +54 -0
  277. package/src/components/layout/side-rail.tsx +128 -0
  278. package/src/components/layout/slide-panel.tsx +64 -0
  279. package/src/components/quick-actions/streaming-modal.tsx +168 -0
  280. package/src/components/quick-actions/whatsup-modal.tsx +149 -0
  281. package/src/components/settings/passkey-panel.tsx +281 -0
  282. package/src/components/share/share-menu.tsx +121 -0
  283. package/src/components/voice/dictation-sheet.tsx +217 -0
  284. package/src/components/voice/mic-button.tsx +31 -0
  285. package/src/components/voice/review-card.tsx +66 -0
  286. package/src/config.ts +27 -0
  287. package/src/hooks/use-vpn-status.ts +75 -0
  288. package/src/hooks/use-websocket.ts +403 -0
  289. package/src/index.ts +46 -0
  290. package/src/lib/api-client.ts +173 -0
  291. package/src/lib/backend.ts +35 -0
  292. package/src/lib/frontmatter.ts +99 -0
  293. package/src/lib/image-attachments.ts +221 -0
  294. package/src/lib/passkeys.ts +50 -0
  295. package/src/lib/share.ts +167 -0
  296. package/src/lib/strip-markdown.ts +38 -0
  297. package/src/lib/tool-names.ts +27 -0
  298. package/src/lib/utils.ts +6 -0
  299. package/src/lib/ws-client.ts +109 -0
  300. package/src/stores/chat-store.ts +619 -0
  301. package/src/stores/connection-store.ts +23 -0
  302. package/src/stores/file-store.ts +344 -0
  303. package/src/stores/provider-store.ts +85 -0
  304. package/src/stores/ui-store.ts +48 -0
  305. package/src/styles.css +4 -0
  306. package/src/theme.css +425 -0
  307. package/src/voice/asr-clients.ts +45 -0
  308. package/src/voice/asr-deepgram.ts +325 -0
  309. package/src/voice/asr-webspeech.ts +135 -0
  310. package/src/voice/use-dictation.ts +187 -0
  311. package/src/voice/voice-store.ts +55 -0
@@ -0,0 +1,898 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import {
3
+ Terminal,
4
+ FileEdit,
5
+ FilePlus,
6
+ FileSearch,
7
+ FolderSearch,
8
+ Search,
9
+ Eye,
10
+ Globe,
11
+ Bot,
12
+ Wand2,
13
+ Braces,
14
+ NotebookPen,
15
+ MapPin,
16
+ ChevronRight,
17
+ } from "lucide-react";
18
+ import type { ToolCall } from "../../stores/chat-store.js";
19
+ import { cn } from "../../lib/utils.js";
20
+ import { linkifyPaths, FileLink } from "./brain-markdown.js";
21
+ import { MarkdownContent } from "./markdown-content.js";
22
+ import { isInternalRepoPath } from "../../stores/file-store.js";
23
+ import { GET_LOCATION_TOOL_NAME, normalizeToolName } from "../../lib/tool-names.js";
24
+
25
+ // ============================================================
26
+ // Shared helpers
27
+ // ============================================================
28
+
29
+ function str(v: unknown): string | null {
30
+ return typeof v === "string" && v.length > 0 ? v : null;
31
+ }
32
+
33
+ function num(v: unknown): number | null {
34
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
35
+ }
36
+
37
+ function basename(p: string): string {
38
+ return p.replace(/^.*\//, "");
39
+ }
40
+
41
+ /**
42
+ * Tool inputs carry absolute paths (`/data/brain/notes/foo.md`); the file
43
+ * panel wants repo-relative ones. Strip everything up to the brain repo root
44
+ * and verify the result actually looks like a repo path.
45
+ */
46
+ export function toRepoRelative(path: string): string | null {
47
+ // isInternalRepoPath is a `href is string` guard; feed it a widened local
48
+ // so the original string parameter is not narrowed to `never`.
49
+ const candidate: string | undefined = path;
50
+ if (isInternalRepoPath(candidate)) return candidate;
51
+ const idx = path.indexOf("/brain/");
52
+ if (idx >= 0) {
53
+ const rel: string | undefined = path.slice(idx + "/brain/".length);
54
+ if (isInternalRepoPath(rel)) return rel;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** Path as a clickable file link when it resolves inside the brain repo. */
60
+ function fileLabel(path: string): ReactNode {
61
+ const rel = toRepoRelative(path);
62
+ if (rel) return <FileLink path={rel}>{rel}</FileLink>;
63
+ return <span title={path}>{path}</span>;
64
+ }
65
+
66
+ export function formatDuration(ms: number): string {
67
+ if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
68
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
69
+ const m = Math.floor(ms / 60_000);
70
+ const s = Math.round((ms % 60_000) / 1000);
71
+ return `${m}m ${s}s`;
72
+ }
73
+
74
+ export function countLines(text: string): number {
75
+ let n = 1;
76
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
77
+ return n;
78
+ }
79
+
80
+ // ============================================================
81
+ // Header helpers: icon, summary, meta badge
82
+ // ============================================================
83
+
84
+ const toolIcons: Record<string, typeof Terminal> = {
85
+ Bash: Terminal,
86
+ Edit: FileEdit,
87
+ Write: FilePlus,
88
+ NotebookEdit: NotebookPen,
89
+ Read: Eye,
90
+ Grep: FileSearch,
91
+ Glob: FolderSearch,
92
+ WebSearch: Search,
93
+ WebFetch: Globe,
94
+ Agent: Bot,
95
+ Skill: Wand2,
96
+ LSP: Braces,
97
+ [GET_LOCATION_TOOL_NAME]: MapPin,
98
+ };
99
+
100
+ export function getToolIcon(name: string): typeof Terminal {
101
+ return toolIcons[normalizeToolName(name)] || Terminal;
102
+ }
103
+
104
+ /**
105
+ * Display name for a tool. In-process MCP tools stream as
106
+ * `mcp__<server>__<tool>`; show just the bare tool name in the timeline.
107
+ */
108
+ export function getToolLabel(name: string): string {
109
+ if (name.startsWith("mcp__")) {
110
+ const parts = name.split("__");
111
+ return parts[parts.length - 1] || name;
112
+ }
113
+ return name;
114
+ }
115
+
116
+ /** Extract a short inline summary from tool input for at-a-glance context. */
117
+ export function getToolSummary(tool: ToolCall): string | null {
118
+ const inp = tool.input;
119
+ if (!inp) return null;
120
+ switch (tool.name) {
121
+ case "Agent":
122
+ return str(inp.description);
123
+ case "Bash":
124
+ // The human-phrased description reads better than a truncated command
125
+ return str(inp.description) ?? str(inp.command)?.slice(0, 80) ?? null;
126
+ case "Read": {
127
+ const path = str(inp.file_path);
128
+ if (!path) return null;
129
+ const offset = num(inp.offset);
130
+ const limit = num(inp.limit);
131
+ if (offset !== null && limit !== null) {
132
+ return `${basename(path)} · ${offset}–${offset + limit}`;
133
+ }
134
+ return basename(path);
135
+ }
136
+ case "Edit":
137
+ case "Write":
138
+ return str(inp.file_path) ? basename(inp.file_path as string) : null;
139
+ case "NotebookEdit":
140
+ return str(inp.notebook_path) ? basename(inp.notebook_path as string) : null;
141
+ case "Glob":
142
+ case "Grep":
143
+ return str(inp.pattern);
144
+ case "WebSearch":
145
+ return str(inp.query);
146
+ case "WebFetch": {
147
+ const url = str(inp.url);
148
+ if (!url) return null;
149
+ try {
150
+ const u = new URL(url);
151
+ return `${u.host}${u.pathname === "/" ? "" : u.pathname}`;
152
+ } catch {
153
+ return url;
154
+ }
155
+ }
156
+ case "Skill":
157
+ return [str(inp.skill), str(inp.args)].filter(Boolean).join(" ") || null;
158
+ case "LSP": {
159
+ const op = str(inp.operation) ?? str(inp.method) ?? str(inp.action);
160
+ const path = str(inp.file_path);
161
+ return [op, path && basename(path)].filter(Boolean).join(" · ") || null;
162
+ }
163
+ default:
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /** Right-side header meta: output size + wall-clock duration. */
169
+ export function getOutputMeta(tool: ToolCall): string | null {
170
+ const parts: string[] = [];
171
+ if (tool.output && !tool.isError) {
172
+ const lines = countLines(tool.output.trimEnd());
173
+ switch (tool.name) {
174
+ case "Grep":
175
+ parts.push(
176
+ /no matches/i.test(tool.output) ? "no matches" : `${lines} matches`
177
+ );
178
+ break;
179
+ case "Glob":
180
+ parts.push(
181
+ /no files/i.test(tool.output)
182
+ ? "no files"
183
+ : `${lines} file${lines === 1 ? "" : "s"}`
184
+ );
185
+ break;
186
+ case "Read":
187
+ parts.push(`${lines} lines`);
188
+ break;
189
+ default:
190
+ if (lines > 5) parts.push(`${lines} lines`);
191
+ }
192
+ }
193
+ if (tool.startedAt && tool.endedAt && tool.endedAt > tool.startedAt) {
194
+ parts.push(formatDuration(tool.endedAt - tool.startedAt));
195
+ }
196
+ return parts.length ? parts.join(" · ") : null;
197
+ }
198
+
199
+ /** File a tool writes to — used for the "N files touched" group summary. */
200
+ export function getTouchedFile(tool: ToolCall): string | null {
201
+ if (tool.name === "Edit" || tool.name === "Write") {
202
+ return str(tool.input?.file_path);
203
+ }
204
+ if (tool.name === "NotebookEdit") {
205
+ return str(tool.input?.notebook_path);
206
+ }
207
+ return null;
208
+ }
209
+
210
+ // ============================================================
211
+ // Input views
212
+ // ============================================================
213
+
214
+ export function ToolInputView({ tool }: { tool: ToolCall }) {
215
+ // Input JSON still streaming in — a partial JSON dump is noise
216
+ if (tool.status === "streaming") {
217
+ return (
218
+ <div className="flex items-center gap-2 rounded-md bg-background/60 p-2 text-[11px] text-muted-foreground/60">
219
+ <span
220
+ className="inline-block h-1.5 w-1.5 rounded-full bg-primary"
221
+ style={{ animation: "breathe 1.5s ease-in-out infinite" }}
222
+ />
223
+ Preparing input…
224
+ </div>
225
+ );
226
+ }
227
+
228
+ switch (tool.name) {
229
+ case "Edit":
230
+ if (str(tool.input?.old_string) !== null && str(tool.input?.new_string) !== null) {
231
+ return <EditDiffView tool={tool} />;
232
+ }
233
+ break;
234
+ case "Write":
235
+ if (str(tool.input?.content) !== null) {
236
+ return <WriteFileView tool={tool} />;
237
+ }
238
+ break;
239
+ case "Bash":
240
+ if (str(tool.input?.command) !== null) {
241
+ return <BashCommandView tool={tool} />;
242
+ }
243
+ break;
244
+ case "Read":
245
+ if (str(tool.input?.file_path) !== null) {
246
+ return <ReadInputView tool={tool} />;
247
+ }
248
+ break;
249
+ }
250
+ return <KeyValueView tool={tool} />;
251
+ }
252
+
253
+ function PathHeader({ path, badge }: { path: string | null; badge?: string | null }) {
254
+ return (
255
+ <div className="flex items-center gap-2 border-b border-border-subtle bg-surface-raised/50 px-2 py-1.5 font-[family-name:var(--font-mono)] text-[11px]">
256
+ <span className="min-w-0 truncate text-foreground/80">
257
+ {path ? fileLabel(path) : "unknown file"}
258
+ </span>
259
+ {badge && (
260
+ <span className="ml-auto shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] text-primary">
261
+ {badge}
262
+ </span>
263
+ )}
264
+ </div>
265
+ );
266
+ }
267
+
268
+ // ------------------------------------------------------------
269
+ // Diff helpers — line-level LCS with word-level refinement
270
+ // ------------------------------------------------------------
271
+
272
+ type DiffOp<T> = { kind: "same" | "del" | "ins"; value: T };
273
+
274
+ /**
275
+ * Hand-rolled LCS diff (standard DP + forward walk) so we avoid pulling in the
276
+ * `diff` package. Returns ops in source order: `same` where the sequences
277
+ * agree, `del` for items only in `a`, `ins` for items only in `b`.
278
+ */
279
+ function diffSeq<T>(
280
+ a: T[],
281
+ b: T[],
282
+ eq: (x: T, y: T) => boolean = (x, y) => x === y
283
+ ): DiffOp<T>[] {
284
+ const m = a.length;
285
+ const n = b.length;
286
+ // dp[i][j] = length of the LCS of a[i:] and b[j:]
287
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
288
+ for (let i = m - 1; i >= 0; i--) {
289
+ for (let j = n - 1; j >= 0; j--) {
290
+ dp[i][j] = eq(a[i], b[j])
291
+ ? dp[i + 1][j + 1] + 1
292
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
293
+ }
294
+ }
295
+ const ops: DiffOp<T>[] = [];
296
+ let i = 0;
297
+ let j = 0;
298
+ while (i < m && j < n) {
299
+ if (eq(a[i], b[j])) {
300
+ ops.push({ kind: "same", value: a[i] });
301
+ i++;
302
+ j++;
303
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
304
+ ops.push({ kind: "del", value: a[i] });
305
+ i++;
306
+ } else {
307
+ ops.push({ kind: "ins", value: b[j] });
308
+ j++;
309
+ }
310
+ }
311
+ while (i < m) ops.push({ kind: "del", value: a[i++] });
312
+ while (j < n) ops.push({ kind: "ins", value: b[j++] });
313
+ return ops;
314
+ }
315
+
316
+ /** A word token, flagged when it differs from its counterpart line. */
317
+ type WordToken = { text: string; changed: boolean };
318
+
319
+ /**
320
+ * Token-level LCS for a single deleted/inserted line pair. Splitting on
321
+ * `/(\s+)/` keeps the whitespace as its own tokens so we can reassemble the
322
+ * line exactly. Unchanged tokens are shared between both sides.
323
+ */
324
+ function wordDiff(oldLine: string, newLine: string): { del: WordToken[]; ins: WordToken[] } {
325
+ const a = oldLine.split(/(\s+)/);
326
+ const b = newLine.split(/(\s+)/);
327
+ // Token-level LCS is O(words²); a pathological minified line would be slow.
328
+ // Above the cap, skip word refinement and flag the whole line as changed.
329
+ if (a.length * b.length > 10_000) {
330
+ return {
331
+ del: [{ text: oldLine, changed: true }],
332
+ ins: [{ text: newLine, changed: true }],
333
+ };
334
+ }
335
+ const ops = diffSeq(a, b);
336
+ const del: WordToken[] = [];
337
+ const ins: WordToken[] = [];
338
+ for (const op of ops) {
339
+ if (op.kind === "same") {
340
+ del.push({ text: op.value, changed: false });
341
+ ins.push({ text: op.value, changed: false });
342
+ } else if (op.kind === "del") {
343
+ del.push({ text: op.value, changed: true });
344
+ } else {
345
+ ins.push({ text: op.value, changed: true });
346
+ }
347
+ }
348
+ return { del, ins };
349
+ }
350
+
351
+ /** A rendered diff row. `tokens` is set only for word-refined single-line edits. */
352
+ type DiffRow = {
353
+ kind: "same" | "del" | "ins";
354
+ line: string;
355
+ tokens: WordToken[] | null;
356
+ };
357
+
358
+ /**
359
+ * Merge `old_string`/`new_string` into a single diff: unchanged lines appear
360
+ * once as neutral, deletions/insertions keep the red/green treatment. A change
361
+ * block that is exactly one deleted line against one inserted line gets
362
+ * word-level highlighting.
363
+ */
364
+ export function computeDiffRows(oldStr: string, newStr: string): DiffRow[] {
365
+ const oldLines = oldStr.split("\n");
366
+ const newLines = newStr.split("\n");
367
+ // Line-level LCS is O(m×n) in time and memory; a huge Edit (thousands of
368
+ // lines) would build a massive DP matrix and hang the UI. Above the cap, fall
369
+ // back to a naive "all old removed, all new added" diff.
370
+ if (oldLines.length * newLines.length > 250_000) {
371
+ return [
372
+ ...oldLines.map((line): DiffRow => ({ kind: "del", line, tokens: null })),
373
+ ...newLines.map((line): DiffRow => ({ kind: "ins", line, tokens: null })),
374
+ ];
375
+ }
376
+ const ops = diffSeq(oldLines, newLines);
377
+ const rows: DiffRow[] = [];
378
+ let idx = 0;
379
+ while (idx < ops.length) {
380
+ if (ops[idx].kind === "same") {
381
+ rows.push({ kind: "same", line: ops[idx].value, tokens: null });
382
+ idx++;
383
+ continue;
384
+ }
385
+ // Collect a maximal run of changes, grouping deletes before inserts.
386
+ const dels: string[] = [];
387
+ const inss: string[] = [];
388
+ while (idx < ops.length && ops[idx].kind !== "same") {
389
+ if (ops[idx].kind === "del") dels.push(ops[idx].value);
390
+ else inss.push(ops[idx].value);
391
+ idx++;
392
+ }
393
+ if (dels.length === 1 && inss.length === 1) {
394
+ const { del, ins } = wordDiff(dels[0], inss[0]);
395
+ rows.push({ kind: "del", line: dels[0], tokens: del });
396
+ rows.push({ kind: "ins", line: inss[0], tokens: ins });
397
+ } else {
398
+ for (const d of dels) rows.push({ kind: "del", line: d, tokens: null });
399
+ for (const s of inss) rows.push({ kind: "ins", line: s, tokens: null });
400
+ }
401
+ }
402
+ return rows;
403
+ }
404
+
405
+ /** Render a diff line, emphasizing changed word tokens when refined. */
406
+ function DiffLine({ tokens, line, changedClass }: {
407
+ tokens: WordToken[] | null;
408
+ line: string;
409
+ changedClass: string;
410
+ }) {
411
+ if (!tokens) return <>{line}</>;
412
+ return (
413
+ <>
414
+ {tokens.map((t, i) =>
415
+ t.changed ? (
416
+ <span key={i} className={cn("rounded-sm", changedClass)}>
417
+ {t.text}
418
+ </span>
419
+ ) : (
420
+ <span key={i}>{t.text}</span>
421
+ )
422
+ )}
423
+ </>
424
+ );
425
+ }
426
+
427
+ export function EditDiffView({ tool }: { tool: ToolCall }) {
428
+ const rows = computeDiffRows(str(tool.input.old_string) ?? "", str(tool.input.new_string) ?? "");
429
+ return (
430
+ <div className="overflow-hidden rounded-md border border-border-subtle">
431
+ <PathHeader
432
+ path={str(tool.input.file_path)}
433
+ badge={tool.input.replace_all ? "replace all" : null}
434
+ />
435
+ <div className="max-h-64 overflow-y-auto bg-background/60 py-1 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
436
+ {rows.map((row, i) => {
437
+ if (row.kind === "same") {
438
+ return (
439
+ <div key={`s${i}`} className="flex">
440
+ <span className="w-5 shrink-0 select-none text-center text-muted-foreground/30"> </span>
441
+ <span className="min-w-0 whitespace-pre-wrap break-all pr-2 text-muted-foreground/70">
442
+ {row.line}
443
+ </span>
444
+ </div>
445
+ );
446
+ }
447
+ if (row.kind === "del") {
448
+ return (
449
+ <div key={`d${i}`} className="flex bg-red-500/10">
450
+ <span className="w-5 shrink-0 select-none text-center text-red-400/70">-</span>
451
+ <span className="min-w-0 whitespace-pre-wrap break-all pr-2 text-red-200/90">
452
+ <DiffLine tokens={row.tokens} line={row.line} changedClass="bg-red-500/30" />
453
+ </span>
454
+ </div>
455
+ );
456
+ }
457
+ return (
458
+ <div key={`i${i}`} className="flex bg-emerald-500/10">
459
+ <span className="w-5 shrink-0 select-none text-center text-emerald-400/70">+</span>
460
+ <span className="min-w-0 whitespace-pre-wrap break-all pr-2 text-emerald-200/90">
461
+ <DiffLine tokens={row.tokens} line={row.line} changedClass="bg-emerald-500/30" />
462
+ </span>
463
+ </div>
464
+ );
465
+ })}
466
+ </div>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ /** Longest backtick run + 1 so the fence survives content containing fences. */
472
+ export function fenceFor(content: string): string {
473
+ const runs = content.match(/`+/g);
474
+ const longest = runs ? Math.max(...runs.map((r) => r.length)) : 0;
475
+ return "`".repeat(Math.max(3, longest + 1));
476
+ }
477
+
478
+ export function WriteFileView({ tool }: { tool: ToolCall }) {
479
+ const content = str(tool.input.content) ?? "";
480
+ const path = str(tool.input.file_path);
481
+ const ext = path?.match(/\.([a-z0-9]{1,8})$/i)?.[1] ?? "";
482
+ const fence = fenceFor(content);
483
+ return (
484
+ <div className="overflow-hidden rounded-md border border-border-subtle">
485
+ <PathHeader path={path} badge={`${countLines(content)} lines`} />
486
+ <div className="max-h-64 overflow-y-auto [&_pre]:!my-0 [&_pre]:rounded-none [&_pre]:border-0 [&_pre]:bg-background/60 [&_pre]:p-2 [&_pre]:text-[11px]">
487
+ <MarkdownContent content={`${fence}${ext}\n${content}\n${fence}`} />
488
+ </div>
489
+ </div>
490
+ );
491
+ }
492
+
493
+ export function BashCommandView({ tool }: { tool: ToolCall }) {
494
+ const command = str(tool.input.command) ?? "";
495
+ const flags: string[] = [];
496
+ if (tool.input.run_in_background) flags.push("background");
497
+ const timeout = num(tool.input.timeout);
498
+ if (timeout) flags.push(`timeout ${Math.round(timeout / 1000)}s`);
499
+ if (tool.input.dangerouslyDisableSandbox) flags.push("sandbox disabled");
500
+ return (
501
+ <div className="rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
502
+ <div className="flex gap-1.5">
503
+ <span className="select-none text-primary">$</span>
504
+ <span className="min-w-0 whitespace-pre-wrap break-all text-foreground/90">
505
+ {command}
506
+ </span>
507
+ </div>
508
+ {flags.length > 0 && (
509
+ <div className="mt-1.5 flex flex-wrap gap-1">
510
+ {flags.map((f) => (
511
+ <span
512
+ key={f}
513
+ className={cn(
514
+ "rounded px-1.5 py-0.5 text-[10px]",
515
+ f === "sandbox disabled"
516
+ ? "bg-destructive/15 text-destructive"
517
+ : "bg-surface-raised text-muted-foreground"
518
+ )}
519
+ >
520
+ {f}
521
+ </span>
522
+ ))}
523
+ </div>
524
+ )}
525
+ </div>
526
+ );
527
+ }
528
+
529
+ function ReadInputView({ tool }: { tool: ToolCall }) {
530
+ const path = str(tool.input.file_path) ?? "";
531
+ const offset = num(tool.input.offset);
532
+ const limit = num(tool.input.limit);
533
+ const range =
534
+ offset !== null && limit !== null
535
+ ? `lines ${offset}–${offset + limit}`
536
+ : offset !== null
537
+ ? `from line ${offset}`
538
+ : "full file";
539
+ return (
540
+ <div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5 font-[family-name:var(--font-mono)] text-[11px]">
541
+ <span className="min-w-0 truncate text-foreground/80">{fileLabel(path)}</span>
542
+ <span className="ml-auto shrink-0 text-muted-foreground/60">{range}</span>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ /** Generic structured input: one row per field beats raw JSON for scanning. */
548
+ export function KeyValueView({ tool }: { tool: ToolCall }) {
549
+ const entries = Object.entries(tool.input ?? {});
550
+ if (entries.length === 0) {
551
+ return (
552
+ <pre className="overflow-x-auto rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed text-muted-foreground">
553
+ {tool.inputJson || "{}"}
554
+ </pre>
555
+ );
556
+ }
557
+ return (
558
+ <div className="space-y-1 rounded-md bg-background/60 p-2">
559
+ {entries.map(([key, value]) => (
560
+ <div key={key} className="flex gap-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
561
+ <span className="shrink-0 text-muted-foreground/50">{key}:</span>
562
+ <span className="max-h-40 min-w-0 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">
563
+ {typeof value === "string"
564
+ ? linkifyPaths(value)
565
+ : JSON.stringify(value, null, 1)}
566
+ </span>
567
+ </div>
568
+ ))}
569
+ </div>
570
+ );
571
+ }
572
+
573
+ // ============================================================
574
+ // Output views
575
+ // ============================================================
576
+
577
+ export function ToolOutputView({ tool }: { tool: ToolCall }) {
578
+ const output = tool.output;
579
+ if (!output) return null;
580
+
581
+ if (tool.isError) {
582
+ return <ClampedPre text={output} isError />;
583
+ }
584
+
585
+ switch (tool.name) {
586
+ case "Agent":
587
+ case "WebFetch":
588
+ // These outputs are prose (subagent reports, page summaries)
589
+ return (
590
+ <ClampedBox lineLabel={`${countLines(output)} lines`}>
591
+ <div className="rounded-md bg-background/60 p-3 text-[13px]">
592
+ <MarkdownContent content={output} />
593
+ </div>
594
+ </ClampedBox>
595
+ );
596
+ case "Grep":
597
+ return <FileRowsView output={output} pattern={str(tool.input?.pattern)} />;
598
+ case "Glob":
599
+ return <FileRowsView output={output} />;
600
+ case "Read":
601
+ return <ReadOutputStub output={output} />;
602
+ case "WebSearch":
603
+ return <WebSearchResultsView output={output} />;
604
+ default:
605
+ return <ClampedPre text={output} />;
606
+ }
607
+ }
608
+
609
+ /** DOM-safety cap; "Show all" raises it but never renders unbounded output. */
610
+ const CLAMP_HARD_CAP = 50_000;
611
+ const CLAMP_SHOW_ALL_CAP = 200_000;
612
+
613
+ export function ClampedPre({ text, isError }: { text: string; isError?: boolean }) {
614
+ const lines = countLines(text.trimEnd());
615
+ const clampable = lines > 16 || text.length > 1600;
616
+ const [showAll, setShowAll] = useState(false);
617
+ const shown = showAll ? text.slice(0, CLAMP_SHOW_ALL_CAP) : text.slice(0, CLAMP_HARD_CAP);
618
+ return (
619
+ <div>
620
+ <div className={cn("relative", clampable && !showAll && "max-h-56 overflow-hidden")}>
621
+ <pre
622
+ className={cn(
623
+ "overflow-x-auto rounded-md p-2 pr-7 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed",
624
+ isError
625
+ ? "bg-destructive/10 text-destructive"
626
+ : "bg-background/60 text-muted-foreground"
627
+ )}
628
+ >
629
+ {linkifyPaths(shown)}
630
+ {text.length > shown.length && "\n…"}
631
+ </pre>
632
+ {clampable && !showAll && (
633
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 rounded-b-md bg-gradient-to-t from-surface to-transparent" />
634
+ )}
635
+ </div>
636
+ {clampable && (
637
+ <button
638
+ type="button"
639
+ onClick={() => setShowAll(!showAll)}
640
+ className="mt-1 text-[10px] text-muted-foreground/60 transition-colors hover:text-foreground"
641
+ >
642
+ {showAll ? "Collapse" : `Show all (${lines} lines)`}
643
+ </button>
644
+ )}
645
+ </div>
646
+ );
647
+ }
648
+
649
+ /** Same clamp behavior for arbitrary children (markdown outputs). */
650
+ function ClampedBox({ children, lineLabel }: { children: ReactNode; lineLabel: string }) {
651
+ const [showAll, setShowAll] = useState(false);
652
+ return (
653
+ <div>
654
+ <div className={cn("relative", !showAll && "max-h-72 overflow-hidden")}>
655
+ {children}
656
+ {!showAll && (
657
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 rounded-b-md bg-gradient-to-t from-surface to-transparent" />
658
+ )}
659
+ </div>
660
+ <button
661
+ type="button"
662
+ onClick={() => setShowAll(!showAll)}
663
+ className="mt-1 text-[10px] text-muted-foreground/60 transition-colors hover:text-foreground"
664
+ >
665
+ {showAll ? "Collapse" : `Show all (${lineLabel})`}
666
+ </button>
667
+ </div>
668
+ );
669
+ }
670
+
671
+ /**
672
+ * Compile a Grep pattern for match highlighting.
673
+ *
674
+ * We run this against every visible row during render, so it must be safe
675
+ * against catastrophic backtracking (ReDoS) — a synchronous `RegExp.exec` can't
676
+ * be interrupted, so a pattern like `(a+)+$` would freeze the UI thread. A
677
+ * pattern that contains no unbounded-repetition operator (`*`, `+`, `{`) cannot
678
+ * backtrack exponentially, so only those compile as real regexes; anything else
679
+ * (and any pattern that fails to compile, e.g. `c++`, `(`) is matched as an
680
+ * escaped literal. Both paths are linear-time. Returns null if even the literal
681
+ * form can't compile, so callers degrade to unhighlighted rows instead of
682
+ * throwing.
683
+ */
684
+ export function safeSearchRegex(pattern: string | null): RegExp | null {
685
+ if (!pattern) return null;
686
+ if (!/[*+{]/.test(pattern)) {
687
+ try {
688
+ return new RegExp(pattern, "gi");
689
+ } catch {
690
+ // fall through to the escaped-literal form
691
+ }
692
+ }
693
+ try {
694
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
695
+ } catch {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Split `text` into alternating non-match / match segments for a global regex.
702
+ * Zero-width matches are skipped (advancing lastIndex) so patterns like `a*`
703
+ * never spin. Purely string-in / segments-out so it's easy to unit test.
704
+ */
705
+ export function splitMatches(text: string, re: RegExp): Array<{ text: string; match: boolean }> {
706
+ const segments: Array<{ text: string; match: boolean }> = [];
707
+ re.lastIndex = 0;
708
+ let last = 0;
709
+ let m: RegExpExecArray | null;
710
+ let guard = 0;
711
+ while ((m = re.exec(text)) !== null) {
712
+ if (guard++ > 10_000) break;
713
+ if (m[0].length === 0) {
714
+ re.lastIndex++;
715
+ continue;
716
+ }
717
+ if (m.index > last) segments.push({ text: text.slice(last, m.index), match: false });
718
+ segments.push({ text: m[0], match: true });
719
+ last = m.index + m[0].length;
720
+ }
721
+ if (last < text.length) segments.push({ text: text.slice(last), match: false });
722
+ return segments;
723
+ }
724
+
725
+ /**
726
+ * Split a ripgrep-style `path:line:content` row into its linkable prefix and
727
+ * the content portion. Returns null when the row has no such prefix (e.g. Glob
728
+ * paths or an unusual format), so the caller can treat the whole line as content.
729
+ */
730
+ export function splitGrepRow(line: string): { prefix: string; content: string } | null {
731
+ const m = line.match(/^(.+?:\d+:)([\s\S]*)$/);
732
+ if (!m) return null;
733
+ return { prefix: m[1], content: m[2] };
734
+ }
735
+
736
+ /** Grep/Glob results: one clickable row per line. */
737
+ export function FileRowsView({ output, pattern }: { output: string; pattern?: string | null }) {
738
+ const lines = output
739
+ .split("\n")
740
+ .map((l) => l.trimEnd())
741
+ .filter((l) => l.trim().length > 0);
742
+ // Degenerate outputs fall back to the plain view
743
+ if (lines.length === 0 || lines.length > 400) {
744
+ return <ClampedPre text={output} />;
745
+ }
746
+ const re = pattern ? safeSearchRegex(pattern) : null;
747
+ // Highlight the matched term inside the CONTENT only — never the `path:line:`
748
+ // prefix (a highlight there would imply the match was in the path and would
749
+ // fragment the path link). The prefix keeps full path linkification.
750
+ const renderRow = (line: string): ReactNode => {
751
+ if (!re) return linkifyPaths(line);
752
+ const split = splitGrepRow(line);
753
+ const prefix = split?.prefix ?? "";
754
+ const content = split ? split.content : line;
755
+ return (
756
+ <>
757
+ {prefix && <span>{linkifyPaths(prefix)}</span>}
758
+ {splitMatches(content, re).map((seg, i) =>
759
+ seg.match ? (
760
+ <mark key={i} className="rounded-sm bg-amber-400/25 px-0.5 text-amber-100">
761
+ {seg.text}
762
+ </mark>
763
+ ) : (
764
+ <span key={i}>{linkifyPaths(seg.text)}</span>
765
+ )
766
+ )}
767
+ </>
768
+ );
769
+ };
770
+ return (
771
+ <div className="max-h-56 overflow-y-auto rounded-md bg-background/60 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed">
772
+ {lines.map((line, i) => (
773
+ <div
774
+ key={i}
775
+ className="whitespace-pre-wrap break-all border-b border-border/10 px-2 py-1 last:border-0"
776
+ >
777
+ {renderRow(line)}
778
+ </div>
779
+ ))}
780
+ </div>
781
+ );
782
+ }
783
+
784
+ /** A parsed WebSearch result: display title + destination URL. */
785
+ type WebResult = { title: string; url: string };
786
+
787
+ /**
788
+ * Tolerant parser for WebSearch output. The raw text is semi-structured, so we
789
+ * try a few shapes in priority order — JSON `{title,url}` objects, markdown
790
+ * links, then a line-scan that pairs a bare URL with nearby title text —
791
+ * de-duplicating by URL. Garbage input yields `[]` and never throws.
792
+ */
793
+ export function parseWebSearchResults(output: string): WebResult[] {
794
+ const results: WebResult[] = [];
795
+ const seen = new Set<string>();
796
+ const push = (title: string, url: string) => {
797
+ // Trim trailing sentence punctuation, but keep brackets that are balanced
798
+ // inside the URL itself — e.g. `…/Function_(mathematics)` must keep its `)`.
799
+ let u = url.trim().replace(/[.,;:'"]+$/, "");
800
+ while (
801
+ (u.endsWith(")") && !u.includes("(")) ||
802
+ (u.endsWith("]") && !u.includes("["))
803
+ ) {
804
+ u = u.slice(0, -1).replace(/[.,;:'"]+$/, "");
805
+ }
806
+ if (!/^https?:\/\//i.test(u) || seen.has(u)) return;
807
+ seen.add(u);
808
+ const t = title.trim().replace(/^[-*\d.)\s]+/, "").trim();
809
+ results.push({ title: t || u, url: u });
810
+ };
811
+
812
+ // 1. JSON objects carrying "title"/"url" keys
813
+ for (const m of output.matchAll(/\{[^{}]*\}/g)) {
814
+ const url = m[0].match(/"url"\s*:\s*"([^"]+)"/)?.[1];
815
+ if (!url) continue;
816
+ push(m[0].match(/"title"\s*:\s*"([^"]*)"/)?.[1] ?? "", url);
817
+ }
818
+ if (results.length) return results;
819
+
820
+ // 2. Markdown links [title](url)
821
+ for (const m of output.matchAll(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g)) {
822
+ push(m[1], m[2]);
823
+ }
824
+ if (results.length) return results;
825
+
826
+ // 3. Line scan: a URL uses the leading text on its line, else the line above.
827
+ // Capture greedily to whitespace (so balanced parens like `/Foo_(bar)` survive)
828
+ // and let `push` trim any unbalanced trailing bracket / sentence punctuation.
829
+ const urlRe = /(https?:\/\/\S+)/;
830
+ let lastText = "";
831
+ for (const raw of output.split("\n")) {
832
+ const line = raw.trim();
833
+ const um = line.match(urlRe);
834
+ if (um) {
835
+ push(line.slice(0, um.index).trim() || lastText, um[1]);
836
+ lastText = "";
837
+ } else if (line) {
838
+ lastText = line;
839
+ }
840
+ }
841
+ return results;
842
+ }
843
+
844
+ /** Structured WebSearch results: title links with a dimmed host beneath. */
845
+ function WebSearchResultsView({ output }: { output: string }) {
846
+ const results = parseWebSearchResults(output);
847
+ if (results.length === 0) return <ClampedPre text={output} />;
848
+ return (
849
+ <div className="space-y-1.5 rounded-md bg-background/60 p-2 text-[11px]">
850
+ {results.map((r, i) => {
851
+ let host = r.url;
852
+ try {
853
+ host = new URL(r.url).host;
854
+ } catch {
855
+ // keep the raw URL as the host label
856
+ }
857
+ return (
858
+ <div key={i} className="min-w-0">
859
+ <a
860
+ href={r.url}
861
+ target="_blank"
862
+ rel="noopener noreferrer"
863
+ className="block truncate text-primary hover:underline"
864
+ >
865
+ {r.title}
866
+ </a>
867
+ <span className="block truncate text-[10px] text-muted-foreground/60">{host}</span>
868
+ </div>
869
+ );
870
+ })}
871
+ </div>
872
+ );
873
+ }
874
+
875
+ /** Read output is rarely worth showing — collapse to a stub by default. */
876
+ function ReadOutputStub({ output }: { output: string }) {
877
+ const [open, setOpen] = useState(false);
878
+ const lines = countLines(output.trimEnd());
879
+ return (
880
+ <div>
881
+ <button
882
+ type="button"
883
+ onClick={() => setOpen(!open)}
884
+ className="flex items-center gap-1 text-[10px] text-muted-foreground/60 transition-colors hover:text-foreground"
885
+ >
886
+ <ChevronRight
887
+ className={cn("h-3 w-3 transition-transform", open && "rotate-90")}
888
+ />
889
+ {lines} lines read
890
+ </button>
891
+ {open && (
892
+ <div className="mt-1">
893
+ <ClampedPre text={output} />
894
+ </div>
895
+ )}
896
+ </div>
897
+ );
898
+ }