@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,547 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import Markdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeHighlight from "rehype-highlight";
5
+ import { Check, Copy } from "lucide-react";
6
+ import {
7
+ classifyRepoPath,
8
+ isInternalRepoDir,
9
+ isInternalRepoPath,
10
+ useFileStore,
11
+ } from "../../stores/file-store.js";
12
+ import { useUIStore } from "../../stores/ui-store.js";
13
+ import { ShareBlock, type ShareBlockFormat } from "./share-block.js";
14
+
15
+ const ENTITY_TAGS: Record<string, string> = {
16
+ co: "entity-co",
17
+ p: "entity-p",
18
+ proj: "entity-proj",
19
+ ev: "entity-ev",
20
+ d: "entity-d",
21
+ st: "entity-st",
22
+ f: "entity-f",
23
+ };
24
+
25
+ // Use a unicode marker that won't appear in normal text to wrap entities
26
+ // so they survive markdown parsing without relying on rehypeRaw
27
+ const ENTITY_START = "​​"; // zero-width spaces as delimiter
28
+ const ENTITY_SEP = "​";
29
+ const ENTITY_END = "​​​";
30
+
31
+ function renderEntityTags(md: string): string {
32
+ // Convert entity tags to marker-based format: ​​tag​content​​​
33
+ let result = md.replace(
34
+ /<(co|p|proj|ev|d|st|f)>([\s\S]*?)<\/\1>/g,
35
+ (_match, tag, content) => `${ENTITY_START}${tag}${ENTITY_SEP}${content}${ENTITY_END}`
36
+ );
37
+ // Clean up any orphaned/unmatched entity tags
38
+ result = result.replace(/<\/?(?:co|p|proj|ev|d|st|f)>/g, "");
39
+
40
+ // Log and strip any remaining non-standard HTML-like tags the LLM may have output
41
+ result = result.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tag) => {
42
+ const allowed = [
43
+ "span", "strong", "em", "b", "i", "a", "br", "hr", "p",
44
+ "h1", "h2", "h3", "h4", "h5", "h6",
45
+ "ul", "ol", "li", "code", "pre", "blockquote",
46
+ "table", "thead", "tbody", "tr", "th", "td",
47
+ "div", "img", "del", "sup", "sub",
48
+ ];
49
+ if (allowed.includes(tag.toLowerCase())) return match;
50
+ console.warn("[entity-tags] Stripping unrecognized tag:", match);
51
+ return "";
52
+ });
53
+
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Parse entity markers in a text string. Each entity content is run through
59
+ * `innerTransform` so file-path linkification (or any other inline transform)
60
+ * applies inside `<f>...</f>` spans too.
61
+ */
62
+ function renderEntitiesInText(
63
+ text: string,
64
+ innerTransform: (s: string) => React.ReactNode = (s) => s
65
+ ): React.ReactNode[] {
66
+ const pattern = new RegExp(
67
+ `${ENTITY_START}(co|p|proj|ev|d|st|f)${ENTITY_SEP}(.*?)${ENTITY_END}`,
68
+ "g"
69
+ );
70
+ const nodes: React.ReactNode[] = [];
71
+ let lastIndex = 0;
72
+ let match;
73
+ let key = 0;
74
+
75
+ while ((match = pattern.exec(text)) !== null) {
76
+ if (match.index > lastIndex) {
77
+ nodes.push(innerTransform(text.slice(lastIndex, match.index)));
78
+ }
79
+ const cls = ENTITY_TAGS[match[1]] || "";
80
+ nodes.push(
81
+ <span key={key++} className={cls}>
82
+ {innerTransform(match[2])}
83
+ </span>
84
+ );
85
+ lastIndex = pattern.lastIndex;
86
+ }
87
+ if (lastIndex < text.length) {
88
+ nodes.push(innerTransform(text.slice(lastIndex)));
89
+ }
90
+ return nodes;
91
+ }
92
+
93
+ function CopyButton({ getText }: { getText: () => string }) {
94
+ const [copied, setCopied] = useState(false);
95
+ return (
96
+ <button
97
+ type="button"
98
+ onClick={() => {
99
+ navigator.clipboard.writeText(getText());
100
+ setCopied(true);
101
+ setTimeout(() => setCopied(false), 2000);
102
+ }}
103
+ className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground opacity-0 transition-all hover:bg-surface-overlay hover:text-foreground group-hover:opacity-100"
104
+ >
105
+ {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
106
+ </button>
107
+ );
108
+ }
109
+
110
+ interface BrainMarkdownProps {
111
+ content: string;
112
+ className?: string;
113
+ entityTags?: boolean;
114
+ fileLinks?: boolean;
115
+ }
116
+
117
+ type ShareSegment = { kind: "share"; format: ShareBlockFormat; title?: string; body: string };
118
+ type TextSegment = { kind: "text"; text: string };
119
+ type Segment = TextSegment | ShareSegment;
120
+
121
+ const SHARE_BLOCK_RE = /<share\b([^>]*)>([\s\S]*?)<\/share>/gi;
122
+ const VALID_FORMATS: ReadonlySet<ShareBlockFormat> = new Set([
123
+ "image",
124
+ "pdf",
125
+ "text",
126
+ "markdown",
127
+ "richtext",
128
+ ]);
129
+
130
+ function parseShareAttrs(raw: string): { format: ShareBlockFormat; title?: string } {
131
+ const attrs: Record<string, string> = {};
132
+ const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
133
+ let m: RegExpExecArray | null;
134
+ while ((m = re.exec(raw)) !== null) {
135
+ attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
136
+ }
137
+ const format = (attrs.format ?? "image").toLowerCase() as ShareBlockFormat;
138
+ return {
139
+ format: VALID_FORMATS.has(format) ? format : "image",
140
+ title: attrs.title,
141
+ };
142
+ }
143
+
144
+ function splitShareBlocks(input: string): Segment[] {
145
+ const out: Segment[] = [];
146
+ let last = 0;
147
+ let m: RegExpExecArray | null;
148
+ SHARE_BLOCK_RE.lastIndex = 0;
149
+ while ((m = SHARE_BLOCK_RE.exec(input)) !== null) {
150
+ if (m.index > last) {
151
+ out.push({ kind: "text", text: input.slice(last, m.index) });
152
+ }
153
+ const { format, title } = parseShareAttrs(m[1] ?? "");
154
+ out.push({ kind: "share", format, title, body: m[2].trim() });
155
+ last = SHARE_BLOCK_RE.lastIndex;
156
+ }
157
+ if (last < input.length) {
158
+ out.push({ kind: "text", text: input.slice(last) });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** Wrap any element's render to process entity markers + file paths in its text children */
164
+ function withTextProcessing<T extends keyof React.JSX.IntrinsicElements>(
165
+ Tag: T,
166
+ opts: { entityTags: boolean; fileLinks: boolean }
167
+ ) {
168
+ return ({ children, ...props }: React.ComponentPropsWithoutRef<T> & { children?: React.ReactNode }) => (
169
+ React.createElement(Tag, props as any, processChildText(children, opts))
170
+ );
171
+ }
172
+
173
+ export function BrainMarkdown({ content, className, entityTags = false, fileLinks = false }: BrainMarkdownProps) {
174
+ const segments = splitShareBlocks(content);
175
+ if (segments.length > 1 || (segments.length === 1 && segments[0].kind === "share")) {
176
+ return (
177
+ <>
178
+ {segments.map((seg, i) =>
179
+ seg.kind === "share" ? (
180
+ <ShareBlock key={i} body={seg.body} format={seg.format} title={seg.title} />
181
+ ) : seg.text.trim() ? (
182
+ <BrainMarkdownInner
183
+ key={i}
184
+ content={seg.text}
185
+ className={className}
186
+ entityTags={entityTags}
187
+ fileLinks={fileLinks}
188
+ />
189
+ ) : null
190
+ )}
191
+ </>
192
+ );
193
+ }
194
+ return (
195
+ <BrainMarkdownInner
196
+ content={content}
197
+ className={className}
198
+ entityTags={entityTags}
199
+ fileLinks={fileLinks}
200
+ />
201
+ );
202
+ }
203
+
204
+ function BrainMarkdownInner({ content, className, entityTags = false, fileLinks = false }: BrainMarkdownProps) {
205
+ const processed = entityTags ? renderEntityTags(content) : content;
206
+ const ensureWikilinks = useFileStore((s) => s.ensureWikilinks);
207
+
208
+ useEffect(() => {
209
+ if (fileLinks) void ensureWikilinks();
210
+ }, [fileLinks, ensureWikilinks]);
211
+
212
+ // Override block/inline elements that can contain text. We need to do this
213
+ // whenever either entityTags or fileLinks is enabled so we can scan text nodes.
214
+ const useTextProcessing = entityTags || fileLinks;
215
+ const opts = { entityTags, fileLinks };
216
+ const textComponents = useTextProcessing
217
+ ? {
218
+ p: withTextProcessing("p", opts),
219
+ li: withTextProcessing("li", opts),
220
+ strong: withTextProcessing("strong", opts),
221
+ em: withTextProcessing("em", opts),
222
+ h1: withTextProcessing("h1", opts),
223
+ h2: withTextProcessing("h2", opts),
224
+ h3: withTextProcessing("h3", opts),
225
+ h4: withTextProcessing("h4", opts),
226
+ td: withTextProcessing("td", opts),
227
+ th: withTextProcessing("th", opts),
228
+ // Paths and wikilinks frequently appear inside inline code spans
229
+ // in brain notes (e.g. `talks/_index.md`). Process code elements
230
+ // too. Note: this also linkifies paths inside fenced code blocks,
231
+ // which is harmless in the brain UI context (paths in shell
232
+ // snippets remain clickable). The known downside is that literal
233
+ // `[[slug]]` syntax shown for teaching purposes will attempt to
234
+ // resolve as a wikilink.
235
+ code: withTextProcessing("code", opts),
236
+ }
237
+ : {};
238
+
239
+ return (
240
+ <div className={className ?? "brain-prose"}>
241
+ <Markdown
242
+ remarkPlugins={[remarkGfm]}
243
+ rehypePlugins={entityTags ? [] : [rehypeHighlight]}
244
+ components={{
245
+ ...textComponents,
246
+ pre: ({ children, ...props }) => {
247
+ const ref = useRef<HTMLPreElement>(null);
248
+ return (
249
+ <div className="group relative">
250
+ <pre
251
+ ref={ref}
252
+ className="overflow-x-auto rounded-lg border border-border bg-surface p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed"
253
+ {...props}
254
+ >
255
+ {children}
256
+ </pre>
257
+ <CopyButton getText={() => ref.current?.textContent ?? ""} />
258
+ </div>
259
+ );
260
+ },
261
+ table: ({ children, ...props }) => (
262
+ <div className="overflow-x-auto rounded-lg border border-border">
263
+ <table {...props}>{children}</table>
264
+ </div>
265
+ ),
266
+ a: ({ href, children, ...props }) => {
267
+ if (fileLinks) {
268
+ const kind = classifyRepoPath(href);
269
+ if (kind === "file") {
270
+ return <FileLink path={href as string}>{children}</FileLink>;
271
+ }
272
+ if (kind === "dir") {
273
+ return <DirLink path={href as string}>{children}</DirLink>;
274
+ }
275
+ }
276
+ return (
277
+ <a target="_blank" rel="noopener noreferrer" href={href} {...props}>
278
+ {children}
279
+ </a>
280
+ );
281
+ },
282
+ }}
283
+ >
284
+ {processed}
285
+ </Markdown>
286
+ </div>
287
+ );
288
+ }
289
+
290
+ /** Click target for a repo-relative file reference. Opens the file panel + viewer. */
291
+ export function FileLink({ path, children }: { path: string; children?: React.ReactNode }) {
292
+ const openFile = useFileStore((s) => s.openFile);
293
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
294
+ return (
295
+ <a
296
+ href={`#/files/${path}`}
297
+ onClick={(e) => {
298
+ e.preventDefault();
299
+ setFilePanelOpen(true);
300
+ void openFile(path);
301
+ }}
302
+ className="brain-file-link"
303
+ title={path}
304
+ >
305
+ {children ?? path}
306
+ </a>
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Resolve an obsidian-style `[[target]]` to a repo-relative path using the
312
+ * wikilink map. Targets containing `/` are treated as paths directly; bare
313
+ * slugs are looked up by basename. An `#anchor` suffix is stripped before
314
+ * lookup.
315
+ */
316
+ function resolveWikilinkTarget(
317
+ raw: string,
318
+ slugMap: Record<string, string>
319
+ ): string | null {
320
+ const hashIdx = raw.indexOf("#");
321
+ const noAnchor = hashIdx >= 0 ? raw.slice(0, hashIdx).trim() : raw.trim();
322
+ if (!noAnchor) return null;
323
+ if (noAnchor.includes("/")) {
324
+ if (noAnchor.endsWith("/")) return null;
325
+ return /\.[a-z0-9]{1,8}$/i.test(noAnchor) ? noAnchor : `${noAnchor}.md`;
326
+ }
327
+ return slugMap[noAnchor.toLowerCase()] ?? null;
328
+ }
329
+
330
+ /** Click target for `[[slug]]` / `[[slug|Label]]`. */
331
+ export function WikiLink({
332
+ target,
333
+ label,
334
+ }: {
335
+ target: string;
336
+ label?: string;
337
+ }) {
338
+ const slugMap = useFileStore((s) => s.wikilinkMap);
339
+ const loaded = useFileStore((s) => s.wikilinkLoaded);
340
+ const openFile = useFileStore((s) => s.openFile);
341
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
342
+
343
+ const resolved = resolveWikilinkTarget(target, slugMap);
344
+ const display = label ?? target;
345
+
346
+ if (!resolved) {
347
+ return (
348
+ <span
349
+ className="brain-wiki-link brain-wiki-link--unresolved"
350
+ title={loaded ? `Unresolved wikilink: ${target}` : "Resolving…"}
351
+ >
352
+ {display}
353
+ </span>
354
+ );
355
+ }
356
+
357
+ return (
358
+ <a
359
+ href={`#/files/${resolved}`}
360
+ onClick={(e) => {
361
+ e.preventDefault();
362
+ setFilePanelOpen(true);
363
+ void openFile(resolved);
364
+ }}
365
+ className="brain-file-link brain-wiki-link"
366
+ title={resolved}
367
+ >
368
+ {display}
369
+ </a>
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Click target for a repo-relative directory reference. Opens the file
375
+ * panel, expands the tree down to the directory, and highlights it so
376
+ * the user sees its contents.
377
+ */
378
+ export function DirLink({ path, children }: { path: string; children?: React.ReactNode }) {
379
+ const openDir = useFileStore((s) => s.openDir);
380
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
381
+ const trimmed = path.replace(/\/+$/, "");
382
+ const display = `${trimmed}/`;
383
+ return (
384
+ <a
385
+ href={`#/files/${display}`}
386
+ onClick={(e) => {
387
+ e.preventDefault();
388
+ setFilePanelOpen(true);
389
+ void openDir(trimmed);
390
+ }}
391
+ className="brain-file-link brain-file-link--dir"
392
+ title={display}
393
+ >
394
+ {children ?? display}
395
+ </a>
396
+ );
397
+ }
398
+
399
+ // Bare-path tokens in prose. Three passes:
400
+ // - files: "notes/projects/foo.md" — ends with .ext
401
+ // - dirs: "notes/projects/post-templates/" — ends with /
402
+ // - wikilinks: "[[slug]]" or "[[slug|Display]]" — obsidian-style
403
+ const BARE_FILE_RE =
404
+ /\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\.[a-z0-9]{1,8})\b/gi;
405
+ const BARE_DIR_RE =
406
+ /\b([a-z][a-z0-9_-]*(?:\/[a-z0-9._-]+)+\/)(?=$|[\s,;:!?)\]])/gi;
407
+ const WIKILINK_RE = /\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g;
408
+
409
+ interface PathMatch {
410
+ start: number;
411
+ end: number;
412
+ kind: "file" | "dir" | "wikilink";
413
+ /** For file/dir kinds. */
414
+ path?: string;
415
+ /** Raw target inside the brackets (may include `#anchor`). For wikilink. */
416
+ target?: string;
417
+ /** Display label override (after the pipe). For wikilink. */
418
+ label?: string;
419
+ }
420
+
421
+ function scanBarePaths(text: string): PathMatch[] {
422
+ const out: PathMatch[] = [];
423
+ BARE_FILE_RE.lastIndex = 0;
424
+ let m: RegExpExecArray | null;
425
+ while ((m = BARE_FILE_RE.exec(text)) !== null) {
426
+ if (isInternalRepoPath(m[1])) {
427
+ out.push({
428
+ start: m.index,
429
+ end: BARE_FILE_RE.lastIndex,
430
+ path: m[1],
431
+ kind: "file",
432
+ });
433
+ }
434
+ }
435
+ BARE_DIR_RE.lastIndex = 0;
436
+ while ((m = BARE_DIR_RE.exec(text)) !== null) {
437
+ if (isInternalRepoDir(m[1])) {
438
+ out.push({
439
+ start: m.index,
440
+ end: BARE_DIR_RE.lastIndex,
441
+ path: m[1],
442
+ kind: "dir",
443
+ });
444
+ }
445
+ }
446
+ WIKILINK_RE.lastIndex = 0;
447
+ while ((m = WIKILINK_RE.exec(text)) !== null) {
448
+ out.push({
449
+ start: m.index,
450
+ end: WIKILINK_RE.lastIndex,
451
+ kind: "wikilink",
452
+ target: m[1].trim(),
453
+ label: m[2]?.trim(),
454
+ });
455
+ }
456
+ out.sort((a, b) => a.start - b.start);
457
+
458
+ // Drop overlapping matches — keep the first (earlier) when ranges collide
459
+ const merged: PathMatch[] = [];
460
+ let cursor = 0;
461
+ for (const cand of out) {
462
+ if (cand.start >= cursor) {
463
+ merged.push(cand);
464
+ cursor = cand.end;
465
+ }
466
+ }
467
+ return merged;
468
+ }
469
+
470
+ function renderBarePathsInText(text: string): React.ReactNode[] {
471
+ const matches = scanBarePaths(text);
472
+ if (matches.length === 0) return [text];
473
+ const nodes: React.ReactNode[] = [];
474
+ let last = 0;
475
+ let key = 0;
476
+ for (const m of matches) {
477
+ if (m.start > last) nodes.push(text.slice(last, m.start));
478
+ if (m.kind === "file" && m.path) {
479
+ nodes.push(
480
+ <FileLink key={`p${key++}`} path={m.path}>
481
+ {m.path}
482
+ </FileLink>
483
+ );
484
+ } else if (m.kind === "dir" && m.path) {
485
+ nodes.push(
486
+ <DirLink key={`p${key++}`} path={m.path.replace(/\/+$/, "")}>
487
+ {m.path}
488
+ </DirLink>
489
+ );
490
+ } else if (m.kind === "wikilink" && m.target) {
491
+ nodes.push(
492
+ <WikiLink key={`p${key++}`} target={m.target} label={m.label} />
493
+ );
494
+ }
495
+ last = m.end;
496
+ }
497
+ if (last < text.length) nodes.push(text.slice(last));
498
+ return nodes;
499
+ }
500
+
501
+ /**
502
+ * Public helper: turn any raw text blob into React nodes with bare-path
503
+ * tokens replaced by FileLink elements. Use this in non-markdown surfaces
504
+ * (tool outputs, streaming logs, plain user text).
505
+ */
506
+ export function linkifyPaths(text: string): React.ReactNode {
507
+ if (!text) return text;
508
+ const parts = renderBarePathsInText(text);
509
+ if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
510
+ return <>{parts}</>;
511
+ }
512
+
513
+ /** Process a text string for entity markers and/or bare file paths. */
514
+ function transformTextString(
515
+ text: string,
516
+ opts: { entityTags: boolean; fileLinks: boolean }
517
+ ): React.ReactNode {
518
+ const linkify = opts.fileLinks
519
+ ? (s: string): React.ReactNode => {
520
+ if (!s) return s;
521
+ const parts = renderBarePathsInText(s);
522
+ if (parts.length === 1 && typeof parts[0] === "string") return parts[0];
523
+ return <>{parts}</>;
524
+ }
525
+ : (s: string): React.ReactNode => s;
526
+
527
+ if (opts.entityTags && text.includes(ENTITY_START)) {
528
+ return <>{renderEntitiesInText(text, opts.fileLinks ? (s) => linkify(s) : undefined)}</>;
529
+ }
530
+
531
+ return linkify(text);
532
+ }
533
+
534
+ /** Recursively process React children to expand entity markers / file paths in text nodes */
535
+ function processChildText(children: React.ReactNode, opts: { entityTags: boolean; fileLinks: boolean }): React.ReactNode {
536
+ if (typeof children === "string") {
537
+ return transformTextString(children, opts);
538
+ }
539
+ if (Array.isArray(children)) {
540
+ return children.map((child, i) =>
541
+ typeof child === "string"
542
+ ? <React.Fragment key={i}>{transformTextString(child, opts)}</React.Fragment>
543
+ : child
544
+ );
545
+ }
546
+ return children;
547
+ }