@polderlabs/bizar 3.23.1 → 4.2.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 (361) hide show
  1. package/README.md +83 -16
  2. package/bizar-dash/.bizar/activity.log +3 -0
  3. package/bizar-dash/.bizar/graph/.graphify_analysis.json +1540 -0
  4. package/bizar-dash/.bizar/graph/.graphify_labels.json +1 -0
  5. package/bizar-dash/.bizar/graph/GRAPH_REPORT.md +404 -0
  6. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/0661b816358db84dfdb7f10443db8a61152fd20b3fee950caf8fbf32920a1790.json +1 -0
  7. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/16da06d774142a6e5d74829ad982531286a6193ef798de8f593db029e123ec32.json +1 -0
  8. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/27e5697c01254c69c64c5a33896669073b40afb5ba88375102ae77fa5404136d.json +1 -0
  9. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/532997898027254aa4c0932243dc71d2f55888181232b65974961934997dc85b.json +1 -0
  10. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/84e3451a64c7f99f0cd6a88b0748d7426780486f53ab4935b7476b28c08f8293.json +1 -0
  11. package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/88e5ea5f3e78fa69353fe9e6a8d1a05391d54605e25600cb125531eb5a6432ff.json +1 -0
  12. package/bizar-dash/.bizar/graph/cache/stat-index.json +1 -0
  13. package/bizar-dash/.bizar/graph/graph.html +307 -0
  14. package/bizar-dash/.bizar/graph/graph.json +40267 -0
  15. package/bizar-dash/.bizar/graph/manifest.json +762 -0
  16. package/bizar-dash/.graphifyignore +40 -0
  17. package/bizar-dash/.obsidian/README.md +14 -0
  18. package/bizar-dash/.obsidian/daily/2026-06-25.md +9 -0
  19. package/bizar-dash/.obsidian/vault.json +7 -0
  20. package/bizar-dash/.od-skills/web-prototype-e1429c1737/SKILL.md +103 -0
  21. package/bizar-dash/.od-skills/web-prototype-e1429c1737/assets/template.html +338 -0
  22. package/bizar-dash/.od-skills/web-prototype-e1429c1737/example.html +81 -0
  23. package/bizar-dash/.od-skills/web-prototype-e1429c1737/open-design.json +172 -0
  24. package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/checklist.md +44 -0
  25. package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/layouts.md +247 -0
  26. package/bizar-dash/CHANGELOG.md +474 -0
  27. package/bizar-dash/DESIGN.md +136 -0
  28. package/bizar-dash/dist/assets/main-B3RgW6FS.js +331 -0
  29. package/bizar-dash/dist/assets/main-B3RgW6FS.js.map +1 -0
  30. package/bizar-dash/dist/assets/main-DlJ8wgLR.css +1 -0
  31. package/bizar-dash/dist/assets/mobile-CsZQAswA.css +1 -0
  32. package/bizar-dash/dist/assets/mobile-DbxIw8BG.js +2 -0
  33. package/bizar-dash/dist/assets/mobile-DbxIw8BG.js.map +1 -0
  34. package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js +354 -0
  35. package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js.map +1 -0
  36. package/bizar-dash/dist/index.html +40 -0
  37. package/bizar-dash/dist/mobile.html +22 -0
  38. package/bizar-dash/plans/debug-plan/comments.json +1 -0
  39. package/bizar-dash/plans/debug-plan/meta.json +10 -0
  40. package/bizar-dash/plans/debug-plan/plan.json +174 -0
  41. package/bizar-dash/scripts/smoke-bg-retry.mjs +246 -0
  42. package/bizar-dash/src/cli/dashboard-ports.mjs +488 -0
  43. package/bizar-dash/src/cli.mjs +522 -0
  44. package/bizar-dash/src/server/activity-log.mjs +174 -0
  45. package/bizar-dash/src/server/agents-store.mjs +465 -0
  46. package/bizar-dash/src/server/api.mjs +138 -0
  47. package/bizar-dash/src/server/artifact-template.html +810 -0
  48. package/bizar-dash/src/server/artifacts-store.mjs +733 -0
  49. package/bizar-dash/src/server/auth.mjs +381 -0
  50. package/bizar-dash/src/server/background-store.mjs +447 -0
  51. package/bizar-dash/src/server/bg-poller.mjs +305 -0
  52. package/bizar-dash/src/server/bg-retry.mjs +574 -0
  53. package/bizar-dash/src/server/browser.mjs +40 -0
  54. package/bizar-dash/src/server/diagnostics-store.mjs +148 -0
  55. package/bizar-dash/src/server/dialog-poller.mjs +105 -0
  56. package/bizar-dash/src/server/dialog-store.mjs +177 -0
  57. package/bizar-dash/src/server/glyphs/mdx-compiler.mjs +583 -0
  58. package/bizar-dash/src/server/lib/path-safe.mjs +283 -0
  59. package/bizar-dash/src/server/memory-git.mjs +329 -0
  60. package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
  61. package/bizar-dash/src/server/memory-schema.mjs +145 -0
  62. package/bizar-dash/src/server/memory-secrets.mjs +84 -0
  63. package/bizar-dash/src/server/memory-store.mjs +471 -0
  64. package/bizar-dash/src/server/mod-security.mjs +398 -0
  65. package/bizar-dash/src/server/mods-loader.mjs +1067 -0
  66. package/bizar-dash/src/server/notifications-store.mjs +243 -0
  67. package/bizar-dash/src/server/obsidian-store.mjs +318 -0
  68. package/bizar-dash/src/server/opencode-runner.mjs +279 -0
  69. package/bizar-dash/src/server/pair-store.mjs +138 -0
  70. package/bizar-dash/src/server/projects-store.mjs +364 -0
  71. package/bizar-dash/src/server/providers-store.mjs +789 -0
  72. package/bizar-dash/src/server/routes/_shared.mjs +415 -0
  73. package/bizar-dash/src/server/routes/activity.mjs +110 -0
  74. package/bizar-dash/src/server/routes/agents.mjs +134 -0
  75. package/bizar-dash/src/server/routes/artifacts.mjs +279 -0
  76. package/bizar-dash/src/server/routes/auth.mjs +69 -0
  77. package/bizar-dash/src/server/routes/background.mjs +165 -0
  78. package/bizar-dash/src/server/routes/chat.mjs +485 -0
  79. package/bizar-dash/src/server/routes/config.mjs +129 -0
  80. package/bizar-dash/src/server/routes/diagnostics.mjs +50 -0
  81. package/bizar-dash/src/server/routes/dialogs.mjs +86 -0
  82. package/bizar-dash/src/server/routes/fs.mjs +429 -0
  83. package/bizar-dash/src/server/routes/history.mjs +78 -0
  84. package/bizar-dash/src/server/routes/memory.mjs +658 -0
  85. package/bizar-dash/src/server/routes/misc.mjs +89 -0
  86. package/bizar-dash/src/server/routes/mods.mjs +359 -0
  87. package/bizar-dash/src/server/routes/notifications.mjs +54 -0
  88. package/bizar-dash/src/server/routes/obsidian.mjs +239 -0
  89. package/bizar-dash/src/server/routes/opencode-sessions.mjs +55 -0
  90. package/bizar-dash/src/server/routes/overview.mjs +91 -0
  91. package/bizar-dash/src/server/routes/pair.mjs +67 -0
  92. package/bizar-dash/src/server/routes/projects.mjs +175 -0
  93. package/bizar-dash/src/server/routes/providers.mjs +47 -0
  94. package/bizar-dash/src/server/routes/schedules.mjs +104 -0
  95. package/bizar-dash/src/server/routes/settings.mjs +51 -0
  96. package/bizar-dash/src/server/routes/skills.mjs +76 -0
  97. package/bizar-dash/src/server/routes/tasks.mjs +468 -0
  98. package/bizar-dash/src/server/routes/themes.mjs +51 -0
  99. package/bizar-dash/src/server/routes-v2/auth.mjs +59 -0
  100. package/bizar-dash/src/server/routes-v2/events.mjs +107 -0
  101. package/bizar-dash/src/server/routes-v2/health.mjs +21 -0
  102. package/bizar-dash/src/server/routes-v2/index.mjs +90 -0
  103. package/bizar-dash/src/server/routes-v2/sessions.mjs +111 -0
  104. package/bizar-dash/src/server/schedules-runner.mjs +369 -0
  105. package/bizar-dash/src/server/schedules-store.mjs +268 -0
  106. package/bizar-dash/src/server/search-store.mjs +220 -0
  107. package/bizar-dash/src/server/serve-info.mjs +684 -0
  108. package/bizar-dash/src/server/server.mjs +796 -0
  109. package/bizar-dash/src/server/settings-store.mjs +45 -0
  110. package/bizar-dash/src/server/skills-store.mjs +300 -0
  111. package/bizar-dash/src/server/state.mjs +437 -0
  112. package/bizar-dash/src/server/tailscale-store.mjs +113 -0
  113. package/bizar-dash/src/server/task-delegator.mjs +824 -0
  114. package/bizar-dash/src/server/tasks-store.mjs +508 -0
  115. package/bizar-dash/src/server/tui.mjs +923 -0
  116. package/bizar-dash/src/server/update-store.mjs +168 -0
  117. package/bizar-dash/src/server/v2-auth-file.mjs +99 -0
  118. package/bizar-dash/src/server/v2-event-bus.mjs +128 -0
  119. package/bizar-dash/src/server/watcher.mjs +81 -0
  120. package/bizar-dash/src/server/yaml.mjs +238 -0
  121. package/bizar-dash/src/web/App.tsx +718 -0
  122. package/bizar-dash/src/web/MobileApp.tsx +408 -0
  123. package/bizar-dash/src/web/components/ArtifactCreateDialog.tsx +95 -0
  124. package/bizar-dash/src/web/components/ArtifactListDialog.tsx +55 -0
  125. package/bizar-dash/src/web/components/ArtifactViewer.tsx +143 -0
  126. package/bizar-dash/src/web/components/AuditDialog.tsx +79 -0
  127. package/bizar-dash/src/web/components/BgStatusBadge.tsx +32 -0
  128. package/bizar-dash/src/web/components/Button.tsx +56 -0
  129. package/bizar-dash/src/web/components/CanvasContextMenu.tsx +68 -0
  130. package/bizar-dash/src/web/components/Card.tsx +40 -0
  131. package/bizar-dash/src/web/components/CollapsibleSection.tsx +72 -0
  132. package/bizar-dash/src/web/components/CommandDialog.tsx +66 -0
  133. package/bizar-dash/src/web/components/EmptyState.tsx +30 -0
  134. package/bizar-dash/src/web/components/FileBrowser.tsx +633 -0
  135. package/bizar-dash/src/web/components/HelpDialog.tsx +55 -0
  136. package/bizar-dash/src/web/components/KillConfirmDialog.tsx +88 -0
  137. package/bizar-dash/src/web/components/Modal.tsx +215 -0
  138. package/bizar-dash/src/web/components/Notifications.tsx +241 -0
  139. package/bizar-dash/src/web/components/SearchModal.tsx +224 -0
  140. package/bizar-dash/src/web/components/Sidebar.tsx +66 -0
  141. package/bizar-dash/src/web/components/Spinner.tsx +19 -0
  142. package/bizar-dash/src/web/components/StatusBadge.tsx +25 -0
  143. package/bizar-dash/src/web/components/Tag.tsx +28 -0
  144. package/bizar-dash/src/web/components/Toast.tsx +150 -0
  145. package/bizar-dash/src/web/components/Topbar.tsx +368 -0
  146. package/bizar-dash/src/web/components/VisualPlanDialog.tsx +58 -0
  147. package/bizar-dash/src/web/components/chat/AgentChip.tsx +68 -0
  148. package/bizar-dash/src/web/components/chat/ChatBubble.tsx +60 -0
  149. package/bizar-dash/src/web/components/chat/ChatThread.tsx +88 -0
  150. package/bizar-dash/src/web/components/chat/ChatTopBar.tsx +50 -0
  151. package/bizar-dash/src/web/components/chat/Composer.tsx +164 -0
  152. package/bizar-dash/src/web/components/chat/ConfirmModal.tsx +54 -0
  153. package/bizar-dash/src/web/components/chat/EmptyState.tsx +21 -0
  154. package/bizar-dash/src/web/components/chat/FirstRunGreeting.tsx +18 -0
  155. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +32 -0
  156. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +119 -0
  157. package/bizar-dash/src/web/components/chat/LoadingSkeleton.tsx +19 -0
  158. package/bizar-dash/src/web/components/chat/MessageBubble.tsx +60 -0
  159. package/bizar-dash/src/web/components/chat/SessionList.tsx +153 -0
  160. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +9 -0
  161. package/bizar-dash/src/web/components/chat/SuggestionCards.tsx +55 -0
  162. package/bizar-dash/src/web/components/chat/WelcomeScreen.tsx +36 -0
  163. package/bizar-dash/src/web/components/chat/index.ts +20 -0
  164. package/bizar-dash/src/web/components/chat/useAutoGrowTextarea.ts +18 -0
  165. package/bizar-dash/src/web/components/chat/useChat.ts +219 -0
  166. package/bizar-dash/src/web/components/chat/useSlashCommands.ts +47 -0
  167. package/bizar-dash/src/web/index.html +37 -0
  168. package/bizar-dash/src/web/lib/api.ts +193 -0
  169. package/bizar-dash/src/web/lib/markdown.tsx +59 -0
  170. package/bizar-dash/src/web/lib/types.ts +668 -0
  171. package/bizar-dash/src/web/lib/utils.ts +114 -0
  172. package/bizar-dash/src/web/lib/ws.ts +176 -0
  173. package/bizar-dash/src/web/main.tsx +34 -0
  174. package/bizar-dash/src/web/mobile/MobileApp.tsx +278 -0
  175. package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +38 -0
  176. package/bizar-dash/src/web/mobile/MobileTopbar.tsx +115 -0
  177. package/bizar-dash/src/web/mobile/components/MobileBottomSheet.tsx +182 -0
  178. package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +64 -0
  179. package/bizar-dash/src/web/mobile/components/MobileModal.tsx +123 -0
  180. package/bizar-dash/src/web/mobile/views/MobileActivity.tsx +273 -0
  181. package/bizar-dash/src/web/mobile/views/MobileAgents.tsx +374 -0
  182. package/bizar-dash/src/web/mobile/views/MobileArtifactCanvas.tsx +439 -0
  183. package/bizar-dash/src/web/mobile/views/MobileArtifacts.tsx +143 -0
  184. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +180 -0
  185. package/bizar-dash/src/web/mobile/views/MobileConfig.tsx +175 -0
  186. package/bizar-dash/src/web/mobile/views/MobileHistory.tsx +191 -0
  187. package/bizar-dash/src/web/mobile/views/MobileMods.tsx +149 -0
  188. package/bizar-dash/src/web/mobile/views/MobileMore.tsx +121 -0
  189. package/bizar-dash/src/web/mobile/views/MobileNotifications.tsx +137 -0
  190. package/bizar-dash/src/web/mobile/views/MobilePlans.tsx +26 -0
  191. package/bizar-dash/src/web/mobile/views/MobileSchedules.tsx +685 -0
  192. package/bizar-dash/src/web/mobile/views/MobileSearchModal.tsx +172 -0
  193. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +388 -0
  194. package/bizar-dash/src/web/mobile/views/MobileSkills.tsx +153 -0
  195. package/bizar-dash/src/web/mobile/views/MobileTasks.tsx +564 -0
  196. package/bizar-dash/src/web/mobile.html +20 -0
  197. package/bizar-dash/src/web/mobile.tsx +13 -0
  198. package/bizar-dash/src/web/styles/chat.css +198 -0
  199. package/bizar-dash/src/web/styles/glyphs.css +960 -0
  200. package/bizar-dash/src/web/styles/main.css +8855 -0
  201. package/bizar-dash/src/web/styles/mobile-chat.css +5 -0
  202. package/bizar-dash/src/web/styles/mobile.css +1960 -0
  203. package/bizar-dash/src/web/views/Activity.tsx +1203 -0
  204. package/bizar-dash/src/web/views/Agents.tsx +701 -0
  205. package/bizar-dash/src/web/views/Artifacts.tsx +1480 -0
  206. package/bizar-dash/src/web/views/BackgroundAgents.tsx +450 -0
  207. package/bizar-dash/src/web/views/Chat.tsx +190 -0
  208. package/bizar-dash/src/web/views/Config.tsx +2063 -0
  209. package/bizar-dash/src/web/views/History.tsx +335 -0
  210. package/bizar-dash/src/web/views/ModView.tsx +207 -0
  211. package/bizar-dash/src/web/views/Mods.tsx +693 -0
  212. package/bizar-dash/src/web/views/Overview.tsx +812 -0
  213. package/bizar-dash/src/web/views/Providers.tsx +302 -0
  214. package/bizar-dash/src/web/views/Schedules.tsx +802 -0
  215. package/bizar-dash/src/web/views/Settings.tsx +1787 -0
  216. package/bizar-dash/src/web/views/Skills.tsx +431 -0
  217. package/bizar-dash/src/web/views/Tasks.tsx +1523 -0
  218. package/bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx +613 -0
  219. package/bizar-dash/src/web/views/glyphs/components.tsx +1098 -0
  220. package/bizar-dash/templates/mod/FORMAT.md +76 -0
  221. package/bizar-dash/templates/mod/hello-mod/README.md +19 -0
  222. package/bizar-dash/templates/mod/hello-mod/agents/greeter.md +8 -0
  223. package/bizar-dash/templates/mod/hello-mod/commands/hello.md +6 -0
  224. package/bizar-dash/templates/mod/hello-mod/mod.json +20 -0
  225. package/bizar-dash/templates/mod/hello-mod/routes/ping.mjs +9 -0
  226. package/bizar-dash/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  227. package/bizar-dash/tests/dashboard-ports.test.mjs +138 -0
  228. package/bizar-dash/tests/graphify-mod-spawn.node.test.mjs +90 -0
  229. package/bizar-dash/tests/memory-cli.test.mjs +542 -0
  230. package/bizar-dash/tests/memory-config.test.mjs +422 -0
  231. package/bizar-dash/tests/memory-git.test.mjs +246 -0
  232. package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
  233. package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
  234. package/bizar-dash/tests/memory-schema.test.mjs +198 -0
  235. package/bizar-dash/tests/memory-secrets.test.mjs +121 -0
  236. package/bizar-dash/tests/memory-store.test.mjs +390 -0
  237. package/bizar-dash/tests/memory-sync.test.mjs +141 -0
  238. package/bizar-dash/tests/mod-instructions.node.test.mjs +188 -0
  239. package/bizar-dash/tests/mod-security.test.mjs +273 -0
  240. package/bizar-dash/tests/mod-upgrade.node.test.mjs +357 -0
  241. package/bizar-dash/tests/no-agent-browser.node.test.mjs +98 -0
  242. package/bizar-dash/tests/obsidian-back-compat.test.mjs +199 -0
  243. package/bizar-dash/tests/opencode-runner.test.mjs +172 -0
  244. package/bizar-dash/tests/path-safe.test.mjs +108 -0
  245. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +333 -0
  246. package/bizar-dash/tests/smoke-v2.mjs +212 -0
  247. package/bizar-dash/tests/strip-thinking.test.mjs +175 -0
  248. package/bizar-dash/tests/submit-feedback.test.mjs +353 -0
  249. package/bizar-dash/tests/tmux-wrap.test.mjs +145 -0
  250. package/bizar-dash/tests/yaml.test.mjs +128 -0
  251. package/bizar-dash/tsconfig.json +23 -0
  252. package/bizar-dash/vite.config.ts +27 -0
  253. package/cli/atomic.mjs +73 -0
  254. package/cli/banner.mjs +1 -1
  255. package/cli/bin.mjs +44 -47
  256. package/cli/bootstrap.mjs +1 -1
  257. package/cli/copy.mjs +25 -16
  258. package/cli/dev-link.mjs +17 -1
  259. package/cli/doctor.mjs +4 -4
  260. package/cli/doctor.test.mjs +2 -2
  261. package/cli/init.mjs +135 -1
  262. package/cli/install.mjs +31 -39
  263. package/cli/memory-constants.mjs +59 -0
  264. package/cli/memory.mjs +1436 -0
  265. package/cli/service.mjs +5 -6
  266. package/cli/utils.mjs +6 -3
  267. package/config/AGENTS.md +7 -7
  268. package/config/agents/_shared/AGENT_BASELINE.md +59 -61
  269. package/config/opencode.json +13 -38
  270. package/config/skills/memory-protocol/SKILL.md +105 -0
  271. package/config/skills/obsidian/SKILL.md +270 -65
  272. package/install.sh +59 -19
  273. package/package.json +49 -14
  274. package/packages/sdk/LICENSE +21 -0
  275. package/packages/sdk/README.md +80 -0
  276. package/packages/sdk/src/client.ts +183 -0
  277. package/packages/sdk/src/errors.ts +120 -0
  278. package/packages/sdk/src/events.ts +153 -0
  279. package/packages/sdk/src/index.ts +63 -0
  280. package/packages/sdk/src/types.ts +176 -0
  281. package/packages/sdk/src/version.ts +4 -0
  282. package/packages/sdk/tests/client.test.ts +217 -0
  283. package/packages/sdk/tests/errors.test.ts +108 -0
  284. package/packages/sdk/tests/events.test.ts +139 -0
  285. package/packages/sdk/tests/fixtures/fetch-mock.ts +152 -0
  286. package/packages/sdk/tests/fixtures/sse-mock.ts +30 -0
  287. package/packages/sdk/tsconfig.json +25 -0
  288. package/packages/sdk/vitest.config.ts +9 -0
  289. package/plugins/bizar/LICENSE +21 -0
  290. package/plugins/bizar/README.md +448 -0
  291. package/plugins/bizar/dist/index.js +29901 -0
  292. package/plugins/bizar/index.ts +1491 -0
  293. package/plugins/bizar/scripts/check-forbidden-imports.sh +33 -0
  294. package/plugins/bizar/src/background-state.ts +551 -0
  295. package/plugins/bizar/src/background.ts +1250 -0
  296. package/plugins/bizar/src/commands-impl.ts +464 -0
  297. package/plugins/bizar/src/commands.ts +1168 -0
  298. package/plugins/bizar/src/dashboard-client.ts +233 -0
  299. package/plugins/bizar/src/event-stream.ts +606 -0
  300. package/plugins/bizar/src/fingerprint.ts +120 -0
  301. package/plugins/bizar/src/handoff.ts +79 -0
  302. package/plugins/bizar/src/http-client.ts +467 -0
  303. package/plugins/bizar/src/key-rotation.ts +218 -0
  304. package/plugins/bizar/src/logger.ts +144 -0
  305. package/plugins/bizar/src/loop.ts +176 -0
  306. package/plugins/bizar/src/opencode-runner.ts +390 -0
  307. package/plugins/bizar/src/options.ts +421 -0
  308. package/plugins/bizar/src/plan-fs.ts +323 -0
  309. package/plugins/bizar/src/reasoning-clean.ts +454 -0
  310. package/plugins/bizar/src/report.ts +178 -0
  311. package/plugins/bizar/src/research-prompt.ts +35 -0
  312. package/plugins/bizar/src/serve-info.ts +228 -0
  313. package/plugins/bizar/src/serve.ts +496 -0
  314. package/plugins/bizar/src/settings.ts +349 -0
  315. package/plugins/bizar/src/state.ts +298 -0
  316. package/plugins/bizar/src/tools/bg-collect.ts +104 -0
  317. package/plugins/bizar/src/tools/bg-get-comments.ts +239 -0
  318. package/plugins/bizar/src/tools/bg-kill.ts +87 -0
  319. package/plugins/bizar/src/tools/bg-spawn.ts +401 -0
  320. package/plugins/bizar/src/tools/bg-status.ts +99 -0
  321. package/plugins/bizar/src/tools/open-kb.ts +191 -0
  322. package/plugins/bizar/src/tools/plan-action.ts +767 -0
  323. package/plugins/bizar/src/tools/read-glyph-feedback.ts +170 -0
  324. package/plugins/bizar/src/tools/wait-for-feedback.ts +402 -0
  325. package/plugins/bizar/tests/attach-handler-bug.test.ts +169 -0
  326. package/plugins/bizar/tests/background-state.test.ts +277 -0
  327. package/plugins/bizar/tests/background.test.ts +402 -0
  328. package/plugins/bizar/tests/block.test.ts +195 -0
  329. package/plugins/bizar/tests/canonical-key-order.test.ts +75 -0
  330. package/plugins/bizar/tests/commands-impl.test.ts +442 -0
  331. package/plugins/bizar/tests/commands.test.ts +584 -0
  332. package/plugins/bizar/tests/config.test.ts +122 -0
  333. package/plugins/bizar/tests/dashboard-client.test.ts +159 -0
  334. package/plugins/bizar/tests/dispose.test.ts +336 -0
  335. package/plugins/bizar/tests/event-stream.test.ts +409 -0
  336. package/plugins/bizar/tests/event.test.ts +262 -0
  337. package/plugins/bizar/tests/fingerprint.test.ts +162 -0
  338. package/plugins/bizar/tests/http-client.test.ts +404 -0
  339. package/plugins/bizar/tests/init-helpers.test.ts +203 -0
  340. package/plugins/bizar/tests/integration/slash-command.test.ts +348 -0
  341. package/plugins/bizar/tests/integration/tool-routing.test.ts +314 -0
  342. package/plugins/bizar/tests/key-rotation.test.ts +396 -0
  343. package/plugins/bizar/tests/loop.test.ts +397 -0
  344. package/plugins/bizar/tests/options.test.ts +276 -0
  345. package/plugins/bizar/tests/reasoning-clean.test.ts +422 -0
  346. package/plugins/bizar/tests/serve.test.ts +339 -0
  347. package/plugins/bizar/tests/settings.test.ts +351 -0
  348. package/plugins/bizar/tests/stall-think.test.ts +750 -0
  349. package/plugins/bizar/tests/state.test.ts +276 -0
  350. package/plugins/bizar/tests/tools/bg-collect.test.ts +337 -0
  351. package/plugins/bizar/tests/tools/bg-get-comments.test.ts +485 -0
  352. package/plugins/bizar/tests/tools/bg-kill.test.ts +235 -0
  353. package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +147 -0
  354. package/plugins/bizar/tests/tools/bg-spawn.test.ts +311 -0
  355. package/plugins/bizar/tests/tools/bg-status.test.ts +217 -0
  356. package/plugins/bizar/tests/tools/opencode-runner.test.ts +115 -0
  357. package/plugins/bizar/tests/tools/plan-action.test.ts +599 -0
  358. package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +253 -0
  359. package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +390 -0
  360. package/plugins/bizar/tests/update-deadlock.test.ts +145 -0
  361. package/plugins/bizar/tsconfig.json +29 -0
@@ -0,0 +1,2063 @@
1
+ // src/views/Config.tsx — v3.4.0: sidebar nav + fully editable providers/MCPs.
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ Settings2,
5
+ RefreshCw,
6
+ Save,
7
+ FileCode2,
8
+ Stethoscope,
9
+ Server as ServerIcon,
10
+ Plug,
11
+ Plus,
12
+ Trash2,
13
+ Pencil,
14
+ Download,
15
+ Database,
16
+ History as HistoryIcon,
17
+ X,
18
+ ShieldCheck,
19
+ AlertTriangle,
20
+ ChevronDown,
21
+ ChevronRight,
22
+ } from 'lucide-react';
23
+ import { Spinner } from '../components/Spinner';
24
+ import { Button } from '../components/Button';
25
+ import { Card, CardTitle, CardMeta } from '../components/Card';
26
+ import { useToast } from '../components/Toast';
27
+ import { useModal } from '../components/Modal';
28
+ import { api } from '../lib/api';
29
+ import { cn, debounce, hashText } from '../lib/utils';
30
+ import type {
31
+ ConfigResponse,
32
+ Diagnostics,
33
+ McpServer,
34
+ Provider,
35
+ Settings,
36
+ Snapshot,
37
+ } from '../lib/types';
38
+
39
+ type LightragStatus = {
40
+ running: boolean;
41
+ pid: number | null;
42
+ host: string;
43
+ port: number;
44
+ llmBinding: string;
45
+ embeddingBinding: string;
46
+ llmModel: string;
47
+ embeddingModel: string;
48
+ lastError: string | null;
49
+ logTail: string[];
50
+ };
51
+
52
+ type Props = {
53
+ snapshot: Snapshot;
54
+ settings: Settings;
55
+ activeTab: string;
56
+ setActiveTab: (id: string) => void;
57
+ refreshSnapshot: () => Promise<void>;
58
+ };
59
+
60
+ type NavId = 'opencode' | 'providers' | 'mcps' | 'diagnostics' | 'memory' | 'export';
61
+
62
+ const NAV_ITEMS: { id: NavId; label: string; icon: typeof Settings2; desc: string }[] = [
63
+ { id: 'opencode', label: 'OpenCode config', icon: FileCode2, desc: 'Edit opencode.json directly' },
64
+ { id: 'providers', label: 'Providers', icon: ServerIcon, desc: 'AI providers + API keys' },
65
+ { id: 'mcps', label: 'MCPs', icon: Plug, desc: 'Model Context Protocol servers' },
66
+ { id: 'diagnostics', label: 'Diagnostics', icon: Stethoscope, desc: 'Service health + counts' },
67
+ { id: 'memory', label: 'Memory & LightRAG', icon: Database, desc: 'Configure Bizar Memory Service and the LightRAG embedding server.' },
68
+ { id: 'export', label: 'Export / Import', icon: Download, desc: 'Download diagnostics bundle' },
69
+ ];
70
+
71
+ export function Config({ snapshot, refreshSnapshot }: Props) {
72
+ const toast = useToast();
73
+ const modal = useModal();
74
+ const initial: ConfigResponse | undefined = snapshot.config;
75
+ const [activeNav, setActiveNav] = useState<NavId>('opencode');
76
+
77
+ // OpenCode editor state
78
+ const [original, setOriginal] = useState<string>(
79
+ initial?.raw || (initial?.data ? JSON.stringify(initial.data, null, 2) : ''),
80
+ );
81
+ const [parsed, setParsed] = useState({
82
+ raw: initial?.raw || '',
83
+ data: initial?.data ?? null,
84
+ error: null as string | null,
85
+ });
86
+ const [dirty, setDirty] = useState(false);
87
+ const [saving, setSaving] = useState(false);
88
+ const [path, setPath] = useState<string>(initial?.path || '');
89
+ const taRef = useRef<HTMLTextAreaElement>(null);
90
+
91
+ // Diagnostics state
92
+ const [diagnostics, setDiagnostics] = useState<Diagnostics | null>(null);
93
+
94
+ // Memory & LightRAG state
95
+ const [lightragStatus, setLightragStatus] = useState<LightragStatus | null>(null);
96
+ const [lightragLog, setLightragLog] = useState<string[]>([]);
97
+
98
+ // Providers + MCPs state — re-fetch when nav switches so we always have fresh data.
99
+ const [providers, setProviders] = useState<Provider[]>(snapshot.providers || []);
100
+ const [mcps, setMcps] = useState<McpServer[]>(snapshot.mcps || []);
101
+
102
+ const originalHash = useMemo(() => hashText(original), [original]);
103
+
104
+ useEffect(() => {
105
+ if (snapshot.providers) setProviders(snapshot.providers);
106
+ if (snapshot.mcps) setMcps(snapshot.mcps);
107
+ }, [snapshot.providers, snapshot.mcps]);
108
+
109
+ const reloadConfig = async () => {
110
+ try {
111
+ const d = await api.post<ConfigResponse>('/config/reload');
112
+ const raw = d.raw || (d.data ? JSON.stringify(d.data, null, 2) : '');
113
+ setOriginal(raw);
114
+ setParsed({ raw, data: d.data ?? null, error: null });
115
+ setDirty(false);
116
+ setPath(d.path || '');
117
+ toast.info('Config reloaded.', 1500);
118
+ } catch (err) {
119
+ toast.error(`Reload failed: ${(err as Error).message}`);
120
+ }
121
+ };
122
+
123
+ const reloadProviders = async () => {
124
+ try {
125
+ const r = await api.get<{ providers: Provider[] }>('/config/providers');
126
+ setProviders(r.providers || []);
127
+ } catch (err) {
128
+ toast.error(`Providers load failed: ${(err as Error).message}`);
129
+ }
130
+ };
131
+
132
+ const reloadMcps = async () => {
133
+ try {
134
+ const r = await api.get<{ mcps: McpServer[] }>('/config/mcps');
135
+ setMcps(r.mcps || []);
136
+ } catch (err) {
137
+ toast.error(`MCPs load failed: ${(err as Error).message}`);
138
+ }
139
+ };
140
+
141
+ // Reload data when nav changes so each panel shows fresh server data.
142
+ useEffect(() => {
143
+ if (activeNav === 'providers' && providers.length === 0) reloadProviders();
144
+ if (activeNav === 'mcps' && mcps.length === 0) reloadMcps();
145
+ if (activeNav === 'diagnostics' && !diagnostics) loadDiagnostics();
146
+ if (activeNav === 'memory') loadLightragStatus();
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [activeNav]);
149
+
150
+ const applyEdit = (val: string) => {
151
+ setParsed((cur) => {
152
+ const next: typeof parsed = { raw: val, data: cur.data, error: null };
153
+ try {
154
+ next.data = JSON.parse(val);
155
+ next.error = null;
156
+ } catch (e) {
157
+ next.data = null;
158
+ next.error = (e as Error).message;
159
+ }
160
+ return next;
161
+ });
162
+ onChangeDebounced(val);
163
+ };
164
+
165
+ const onChangeDebounced = useMemo(
166
+ () =>
167
+ debounce((val: string) => {
168
+ setDirty(hashText(val) !== originalHash);
169
+ }, 100),
170
+ [originalHash],
171
+ );
172
+
173
+ const save = async () => {
174
+ if (!parsed.data || parsed.error || saving) return;
175
+ setSaving(true);
176
+ try {
177
+ const result = await api.put<ConfigResponse>('/config', parsed.data);
178
+ const raw = result.raw || JSON.stringify(result.data, null, 2);
179
+ setOriginal(raw);
180
+ setParsed({ raw, data: result.data ?? null, error: null });
181
+ setDirty(false);
182
+ toast.success('Config saved.');
183
+ await refreshSnapshot();
184
+ // Re-fetch providers + MCPs because config may have changed.
185
+ await reloadProviders();
186
+ await reloadMcps();
187
+ } catch (err) {
188
+ toast.error(`Save failed: ${(err as Error).message}`);
189
+ } finally {
190
+ setSaving(false);
191
+ }
192
+ };
193
+
194
+ const loadDiagnostics = async () => {
195
+ try {
196
+ const d = await api.get<Diagnostics>('/diagnostics');
197
+ setDiagnostics(d);
198
+ } catch (err) {
199
+ toast.error(`Diagnostics load failed: ${(err as Error).message}`);
200
+ }
201
+ };
202
+
203
+ const loadLightragStatus = async () => {
204
+ try {
205
+ const d = await api.get<LightragStatus>('/memory/lightrag/status');
206
+ setLightragStatus(d);
207
+ } catch (err) {
208
+ toast.error(`LightRAG status failed: ${(err as Error).message}`);
209
+ }
210
+ };
211
+
212
+ const loadLightragLog = async () => {
213
+ try {
214
+ const d = await api.get<{ lines: string[] }>('/memory/lightrag/log');
215
+ setLightragLog(d.lines || []);
216
+ } catch {
217
+ setLightragLog([]);
218
+ }
219
+ };
220
+
221
+ const onDownloadDiagnostics = () => {
222
+ const bundle = {
223
+ generatedAt: new Date().toISOString(),
224
+ diagnostics,
225
+ config: parsed.data,
226
+ opencodeJsonPath: path,
227
+ providers,
228
+ mcps,
229
+ };
230
+ const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
231
+ const url = URL.createObjectURL(blob);
232
+ const a = document.createElement('a');
233
+ a.href = url;
234
+ a.download = `bizar-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
235
+ a.click();
236
+ URL.revokeObjectURL(url);
237
+ toast.success('Bundle downloaded.');
238
+ };
239
+
240
+ return (
241
+ <div className="view view-config">
242
+ <header className="view-header">
243
+ <div className="view-header-text">
244
+ <h2 className="view-title">
245
+ <Settings2 size={18} /> Config
246
+ </h2>
247
+ <p className="view-subtitle">
248
+ Manage OpenCode config, providers, MCPs, and diagnostics.
249
+ </p>
250
+ </div>
251
+ </header>
252
+
253
+ <div className="config-layout">
254
+ <nav className="config-nav" role="navigation" aria-label="Config sections">
255
+ {NAV_ITEMS.map((n) => {
256
+ const Icon = n.icon;
257
+ return (
258
+ <button
259
+ key={n.id}
260
+ type="button"
261
+ className={cn('config-nav-item', activeNav === n.id && 'config-nav-item-active')}
262
+ onClick={() => setActiveNav(n.id)}
263
+ aria-current={activeNav === n.id ? 'page' : undefined}
264
+ >
265
+ <Icon size={14} />
266
+ <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
267
+ <span>{n.label}</span>
268
+ <span style={{ fontSize: 10, color: 'var(--text-dim)', fontWeight: 400 }}>
269
+ {n.desc}
270
+ </span>
271
+ </div>
272
+ </button>
273
+ );
274
+ })}
275
+ </nav>
276
+
277
+ <div className="config-content">
278
+ {activeNav === 'opencode' && (
279
+ <ConfigEditorPanel
280
+ parsed={parsed}
281
+ dirty={dirty}
282
+ saving={saving}
283
+ path={path}
284
+ onReload={reloadConfig}
285
+ onSave={save}
286
+ onChange={applyEdit}
287
+ textareaRef={taRef}
288
+ />
289
+ )}
290
+
291
+ {activeNav === 'providers' && (
292
+ <ProvidersPanel providers={providers} onChange={setProviders} onReload={reloadProviders} />
293
+ )}
294
+
295
+ {activeNav === 'mcps' && (
296
+ <McpsPanel mcps={mcps} onChange={setMcps} onReload={reloadMcps} />
297
+ )}
298
+
299
+ {activeNav === 'diagnostics' && (
300
+ <DiagnosticsPanel diagnostics={diagnostics} loading={!diagnostics} onReload={loadDiagnostics} />
301
+ )}
302
+
303
+ {activeNav === 'memory' && (
304
+ <MemoryLightragPanel
305
+ status={lightragStatus}
306
+ onReload={loadLightragStatus}
307
+ logLines={lightragLog}
308
+ onReloadLog={loadLightragLog}
309
+ />
310
+ )}
311
+
312
+ {activeNav === 'export' && (
313
+ <ExportPanel onDownload={onDownloadDiagnostics} />
314
+ )}
315
+ </div>
316
+ </div>
317
+ </div>
318
+ );
319
+ }
320
+
321
+ // ─── OpenCode config form (v3.5.2) ─────────────────────────────────
322
+ // Structured UI for opencode.json sections: Model, Plugins, Tools,
323
+ // Permissions, Hooks. Raw JSON available via "Advanced" toggle.
324
+
325
+ type OcSection = 'model' | 'plugins' | 'tools' | 'permissions' | 'hooks' | 'advanced';
326
+
327
+ const OC_SECTIONS: { id: OcSection; label: string }[] = [
328
+ { id: 'model', label: 'Model' },
329
+ { id: 'plugins', label: 'Plugins' },
330
+ { id: 'tools', label: 'Tools' },
331
+ { id: 'permissions', label: 'Permissions' },
332
+ { id: 'hooks', label: 'Hooks' },
333
+ { id: 'advanced', label: 'Advanced' },
334
+ ];
335
+
336
+ function ConfigEditorPanel({
337
+ parsed,
338
+ dirty,
339
+ saving,
340
+ path,
341
+ onReload,
342
+ onSave,
343
+ onChange,
344
+ textareaRef,
345
+ }: {
346
+ parsed: { raw: string; data: unknown; error: string | null };
347
+ dirty: boolean;
348
+ saving: boolean;
349
+ path: string;
350
+ onReload: () => void;
351
+ onSave: () => void;
352
+ onChange: (val: string) => void;
353
+ textareaRef: React.RefObject<HTMLTextAreaElement>;
354
+ }) {
355
+ const [section, setSection] = useState<OcSection>('model');
356
+
357
+ const cfg = parsed.data as Record<string, unknown> | null;
358
+
359
+ const apply = (patch: Record<string, unknown>) => {
360
+ if (!cfg) return;
361
+ const next = { ...cfg, ...patch };
362
+ onChange(JSON.stringify(next, null, 2));
363
+ };
364
+
365
+ return (
366
+ <Card>
367
+ <CardTitle>
368
+ <FileCode2 size={14} /> OpenCode config
369
+ </CardTitle>
370
+ <CardMeta>
371
+ <code className="mono ellipsis" style={{ fontSize: 11 }}>
372
+ {path || '~/.config/opencode/opencode.json'}
373
+ </code>
374
+ </CardMeta>
375
+ <div className="view-actions" style={{ marginBottom: 12 }}>
376
+ <Button variant="secondary" size="sm" onClick={onReload}>
377
+ <RefreshCw size={14} /> Reload
378
+ </Button>
379
+ <Button
380
+ variant="primary"
381
+ size="sm"
382
+ disabled={!parsed.data || !!parsed.error || !dirty}
383
+ onClick={onSave}
384
+ >
385
+ {saving ? <span className="btn-spinner" /> : <Save size={14} />}
386
+ Save
387
+ </Button>
388
+ </div>
389
+
390
+ {/* Section tabs */}
391
+ <div className="config-section-tabs" style={{ display: 'flex', gap: 4, marginBottom: 16, flexWrap: 'wrap' }}>
392
+ {OC_SECTIONS.map((s) => (
393
+ <button
394
+ key={s.id}
395
+ type="button"
396
+ className={cn('tab-btn', section === s.id && 'active')}
397
+ onClick={() => setSection(s.id)}
398
+ >
399
+ {s.label}
400
+ </button>
401
+ ))}
402
+ </div>
403
+
404
+ {/* Model section */}
405
+ {section === 'model' && (
406
+ <ModelForm cfg={cfg} apply={apply} />
407
+ )}
408
+
409
+ {/* Plugins section */}
410
+ {section === 'plugins' && (
411
+ <PluginsForm cfg={cfg} apply={apply} />
412
+ )}
413
+
414
+ {/* Tools section */}
415
+ {section === 'tools' && (
416
+ <ToolsForm cfg={cfg} apply={apply} />
417
+ )}
418
+
419
+ {/* Permissions section */}
420
+ {section === 'permissions' && (
421
+ <PermissionsForm cfg={cfg} apply={apply} />
422
+ )}
423
+
424
+ {/* Hooks section */}
425
+ {section === 'hooks' && (
426
+ <HooksForm cfg={cfg} apply={apply} />
427
+ )}
428
+
429
+ {/* Advanced: raw JSON editor */}
430
+ {section === 'advanced' && (
431
+ <div>
432
+ <div className="config-grid-label" style={{ marginBottom: 8 }}>
433
+ {parsed.error ? (
434
+ <span className="text-error">{parsed.error}</span>
435
+ ) : (
436
+ <span className="muted">Raw JSON — edit with care</span>
437
+ )}
438
+ </div>
439
+ <textarea
440
+ ref={textareaRef}
441
+ className={cn('textarea config-textarea', parsed.error && 'invalid')}
442
+ spellCheck={false}
443
+ value={parsed.raw}
444
+ onChange={(e) => onChange(e.target.value)}
445
+ style={{ minHeight: 300 }}
446
+ />
447
+ </div>
448
+ )}
449
+ </Card>
450
+ );
451
+ }
452
+
453
+ // ── Sub-forms ─────────────────────────────────────────────────────────
454
+
455
+ function ModelForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
456
+ const model = (cfg?.model || {}) as Record<string, unknown>;
457
+ return (
458
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
459
+ <p className="muted text-sm">Configure the default model used by OpenCode agents.</p>
460
+ <div className="field-row">
461
+ <label className="field-label">Provider</label>
462
+ <input
463
+ className="input"
464
+ value={String(model.provider || '')}
465
+ onChange={(e) => apply({ model: { ...model, provider: e.target.value } })}
466
+ placeholder="e.g. openai, anthropic, google"
467
+ />
468
+ </div>
469
+ <div className="field-row">
470
+ <label className="field-label">Model ID</label>
471
+ <input
472
+ className="input"
473
+ value={String(model.model || '')}
474
+ onChange={(e) => apply({ model: { ...model, model: e.target.value } })}
475
+ placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
476
+ />
477
+ </div>
478
+ {!!model.apiKey && (
479
+ <div className="field-row">
480
+ <label className="field-label">API Key</label>
481
+ <input
482
+ className="input"
483
+ type="password"
484
+ value={String(model.apiKey || '')}
485
+ onChange={(e) => apply({ model: { ...model, apiKey: e.target.value } })}
486
+ placeholder="sk-..."
487
+ />
488
+ </div>
489
+ )}
490
+ {!!model.baseURL && (
491
+ <div className="field-row">
492
+ <label className="field-label">Base URL</label>
493
+ <input
494
+ className="input"
495
+ value={String(model.baseURL || '')}
496
+ onChange={(e) => apply({ model: { ...model, baseURL: e.target.value } })}
497
+ placeholder="https://api.openai.com/v1"
498
+ />
499
+ </div>
500
+ )}
501
+ </div>
502
+ );
503
+ }
504
+
505
+ function PluginsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
506
+ const plugins = Array.isArray(cfg?.plugins) ? (cfg.plugins as Record<string, unknown>[]) : [];
507
+ const [editing, setEditing] = useState<number | null>(null);
508
+ const [draft, setDraft] = useState<Record<string, unknown>>({});
509
+
510
+ const addPlugin = () => {
511
+ const next = [...plugins, { name: '', enabled: true }];
512
+ apply({ plugins: next });
513
+ };
514
+
515
+ const removePlugin = (i: number) => {
516
+ const next = plugins.filter((_, idx) => idx !== i);
517
+ apply({ plugins: next });
518
+ };
519
+
520
+ const updatePlugin = (i: number, patch: Record<string, unknown>) => {
521
+ const next = plugins.map((p, idx) => (idx === i ? { ...p, ...patch } : p));
522
+ apply({ plugins: next });
523
+ setEditing(null);
524
+ };
525
+
526
+ return (
527
+ <div>
528
+ <p className="muted text-sm" style={{ marginBottom: 12 }}>Manage enabled plugins.</p>
529
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
530
+ {plugins.length === 0 && <p className="muted text-sm">No plugins configured.</p>}
531
+ {plugins.map((p, i) => (
532
+ <div key={i} className="config-list-row">
533
+ {editing === i ? (
534
+ <div style={{ display: 'flex', gap: 8, flex: 1, alignItems: 'center' }}>
535
+ <input
536
+ className="input"
537
+ value={String(draft.name || '')}
538
+ onChange={(e) => setDraft({ ...draft, name: e.target.value })}
539
+ placeholder="plugin-name"
540
+ />
541
+ <label style={{ display: 'flex', gap: 4, fontSize: 12 }}>
542
+ <input
543
+ type="checkbox"
544
+ checked={!!draft.enabled}
545
+ onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
546
+ />
547
+ enabled
548
+ </label>
549
+ <Button variant="primary" size="sm" onClick={() => updatePlugin(i, draft)}>Apply</Button>
550
+ <Button variant="ghost" size="sm" onClick={() => setEditing(null)}><X size={12} /></Button>
551
+ </div>
552
+ ) : (
553
+ <>
554
+ <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
555
+ {(p as { name?: string }).name || 'unnamed'}
556
+ </span>
557
+ <span className={cn('tag', (p as { enabled?: boolean }).enabled !== false ? 'tag-success' : 'tag-neutral')}>
558
+ {(p as { enabled?: boolean }).enabled !== false ? 'enabled' : 'disabled'}
559
+ </span>
560
+ <div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
561
+ <button type="button" className="icon-btn" onClick={() => { setEditing(i); setDraft(p as Record<string, unknown>); }} title="Edit">
562
+ <Pencil size={12} />
563
+ </button>
564
+ <button type="button" className="icon-btn text-error" onClick={() => removePlugin(i)} title="Remove">
565
+ <Trash2 size={12} />
566
+ </button>
567
+ </div>
568
+ </>
569
+ )}
570
+ </div>
571
+ ))}
572
+ </div>
573
+ <Button variant="secondary" size="sm" onClick={addPlugin} style={{ marginTop: 8 }}>
574
+ <Plus size={12} /> Add plugin
575
+ </Button>
576
+ </div>
577
+ );
578
+ }
579
+
580
+ function ToolsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
581
+ const tools = (cfg?.tools as Record<string, boolean> | null) || {};
582
+
583
+ const toggle = (name: string) => {
584
+ apply({ tools: { ...tools, [name]: !tools[name] } });
585
+ };
586
+
587
+ const addTool = (name: string) => {
588
+ if (!name || tools[name] !== undefined) return;
589
+ apply({ tools: { ...tools, [name]: true } });
590
+ };
591
+
592
+ const removeTool = (name: string) => {
593
+ const next = { ...tools };
594
+ delete next[name];
595
+ apply({ tools: next });
596
+ };
597
+
598
+ return (
599
+ <div>
600
+ <p className="muted text-sm" style={{ marginBottom: 12 }}>Enable or disable built-in tools.</p>
601
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
602
+ {Object.keys(tools).length === 0 && <p className="muted text-sm">No tools configured.</p>}
603
+ {Object.entries(tools).map(([name, enabled]) => (
604
+ <div key={name} className="config-list-row">
605
+ <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>{name}</span>
606
+ <label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
607
+ <input type="checkbox" checked={!!enabled} onChange={() => toggle(name)} />
608
+ enabled
609
+ </label>
610
+ <button type="button" className="icon-btn text-error" style={{ marginLeft: 'auto' }} onClick={() => removeTool(name)} title="Remove">
611
+ <Trash2 size={12} />
612
+ </button>
613
+ </div>
614
+ ))}
615
+ </div>
616
+ <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
617
+ <input
618
+ className="input"
619
+ placeholder="tool-name"
620
+ onKeyDown={(e) => {
621
+ if (e.key === 'Enter') addTool((e.target as HTMLInputElement).value.trim());
622
+ }}
623
+ />
624
+ <Button variant="secondary" size="sm"
625
+ onClick={(e) => addTool((e.currentTarget.closest('div')?.querySelector('input') as HTMLInputElement)?.value.trim() || '')}
626
+ >
627
+ <Plus size={12} /> Add
628
+ </Button>
629
+ </div>
630
+ </div>
631
+ );
632
+ }
633
+
634
+ function PermissionsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
635
+ const permissions = (cfg?.permissions as Record<string, unknown> | null) || {};
636
+ const [tab, setTab] = useState<'allow' | 'deny'>('allow');
637
+ const allow = Array.isArray(permissions.allow) ? (permissions.allow as string[]) : [];
638
+ const deny = Array.isArray(permissions.deny) ? (permissions.deny as string[]) : [];
639
+
640
+ const addRule = (list: string[], key: 'allow' | 'deny') => (name: string) => {
641
+ if (!name) return;
642
+ apply({ permissions: { ...permissions, [key]: [...list, name] } });
643
+ };
644
+
645
+ const removeRule = (key: 'allow' | 'deny', list: string[], i: number) => {
646
+ apply({ permissions: { ...permissions, [key]: list.filter((_, idx) => idx !== i) } });
647
+ };
648
+
649
+ const list = tab === 'allow' ? allow : deny;
650
+
651
+ return (
652
+ <div>
653
+ <p className="muted text-sm" style={{ marginBottom: 12 }}>Allow or deny tool/scope rules.</p>
654
+ <div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
655
+ <button type="button" className={cn('tab-btn', tab === 'allow' && 'active')} onClick={() => setTab('allow')}>Allow ({allow.length})</button>
656
+ <button type="button" className={cn('tab-btn', tab === 'deny' && 'active')} onClick={() => setTab('deny')}>Deny ({deny.length})</button>
657
+ </div>
658
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
659
+ {list.length === 0 && <p className="muted text-sm">No {tab} rules.</p>}
660
+ {list.map((rule, i) => (
661
+ <div key={i} className="config-list-row">
662
+ <span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>{rule}</span>
663
+ <button type="button" className="icon-btn text-error" style={{ marginLeft: 'auto' }} onClick={() => removeRule(tab, list, i)}>
664
+ <Trash2 size={12} />
665
+ </button>
666
+ </div>
667
+ ))}
668
+ </div>
669
+ <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
670
+ <input
671
+ className="input"
672
+ placeholder={`${tab} rule (e.g. tool:read, scope:filesystem)`}
673
+ onKeyDown={(e) => {
674
+ if (e.key === 'Enter') {
675
+ const v = (e.target as HTMLInputElement).value.trim();
676
+ if (v) { addRule(list, tab)(v); (e.target as HTMLInputElement).value = ''; }
677
+ }
678
+ }}
679
+ />
680
+ <Button variant="secondary" size="sm" onClick={(e) => {
681
+ const inp = e.currentTarget.closest('div')?.querySelector('input') as HTMLInputElement;
682
+ const v = inp?.value.trim();
683
+ if (v) { addRule(list, tab)(v); inp.value = ''; }
684
+ }}>
685
+ <Plus size={12} /> Add
686
+ </Button>
687
+ </div>
688
+ </div>
689
+ );
690
+ }
691
+
692
+ function HooksForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
693
+ const hooks = (cfg?.hooks as Record<string, unknown> | null) || {};
694
+
695
+ const updateHook = (name: string, value: unknown) => {
696
+ apply({ hooks: { ...hooks, [name]: value } });
697
+ };
698
+
699
+ return (
700
+ <div>
701
+ <p className="muted text-sm" style={{ marginBottom: 12 }}>Configure pre/post tool hooks.</p>
702
+ {Object.keys(hooks).length === 0 && <p className="muted text-sm">No hooks configured.</p>}
703
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
704
+ {(['preTool', 'postTool', 'preAgent', 'postAgent'] as const).map((hookName) => {
705
+ const hook = hooks[hookName];
706
+ return (
707
+ <div key={hookName} className="field-row" style={{ alignItems: 'flex-start' }}>
708
+ <label className="field-label" style={{ minWidth: 100 }}>{hookName}</label>
709
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
710
+ <input
711
+ className="input"
712
+ value={typeof hook === 'object' && hook !== null && 'command' in hook ? String((hook as { command: string }).command || '') : ''}
713
+ onChange={(e) => updateHook(hookName, { command: e.target.value })}
714
+ placeholder={`${hookName} command (e.g. node /path/to/hook.js)`}
715
+ />
716
+ </div>
717
+ </div>
718
+ );
719
+ })}
720
+ </div>
721
+ </div>
722
+ );
723
+ }
724
+
725
+ function DiagnosticsPanel({
726
+ diagnostics,
727
+ loading,
728
+ onReload,
729
+ }: {
730
+ diagnostics: Diagnostics | null;
731
+ loading: boolean;
732
+ onReload: () => void;
733
+ }) {
734
+ return (
735
+ <Card>
736
+ <CardTitle>
737
+ <Stethoscope size={14} /> Diagnostics
738
+ </CardTitle>
739
+ <CardMeta>
740
+ System health, file counts, recent errors.{' '}
741
+ <button type="button" className="link-btn" onClick={onReload}>Refresh</button>
742
+ </CardMeta>
743
+ {loading && <p className="muted">Loading diagnostics…</p>}
744
+ {diagnostics && (
745
+ <>
746
+ <div className="diagnostics-grid">
747
+ <div className="diagnostic-tile">
748
+ <div className="diagnostic-tile-label">Version</div>
749
+ <div className="diagnostic-tile-value mono">{diagnostics.version}</div>
750
+ </div>
751
+ <div className="diagnostic-tile">
752
+ <div className="diagnostic-tile-label">Uptime</div>
753
+ <div className="diagnostic-tile-value mono">{diagnostics.uptime}s</div>
754
+ </div>
755
+ <div className="diagnostic-tile">
756
+ <div className="diagnostic-tile-label">Node</div>
757
+ <div className="diagnostic-tile-value mono">{diagnostics.nodeVersion}</div>
758
+ </div>
759
+ <div className="diagnostic-tile">
760
+ <div className="diagnostic-tile-label">Platform</div>
761
+ <div className="diagnostic-tile-value mono">{diagnostics.platform}</div>
762
+ </div>
763
+ <div className="diagnostic-tile">
764
+ <div className="diagnostic-tile-label">Memory (heap)</div>
765
+ <div className="diagnostic-tile-value mono">
766
+ {Math.round(diagnostics.memory.heapUsed / 1024 / 1024)}MB
767
+ </div>
768
+ </div>
769
+ <div className="diagnostic-tile">
770
+ <div className="diagnostic-tile-label">Memory (RSS)</div>
771
+ <div className="diagnostic-tile-value mono">
772
+ {Math.round(diagnostics.memory.rss / 1024 / 1024)}MB
773
+ </div>
774
+ </div>
775
+ <div className="diagnostic-tile">
776
+ <div className="diagnostic-tile-label">Service</div>
777
+ <div className="diagnostic-tile-value">
778
+ {diagnostics.service.running ? (
779
+ <span className="tag tag-success">running (pid {diagnostics.service.pid})</span>
780
+ ) : (
781
+ <span className="tag tag-neutral">stopped</span>
782
+ )}
783
+ </div>
784
+ </div>
785
+ <div className="diagnostic-tile">
786
+ <div className="diagnostic-tile-label">Active project</div>
787
+ <div className="diagnostic-tile-value mono">
788
+ {diagnostics.counts.activeProject || '—'}
789
+ </div>
790
+ </div>
791
+ </div>
792
+ <div className="diagnostic-counts">
793
+ <span>agents: <strong>{diagnostics.counts.agents}</strong></span>
794
+ <span>projects: <strong>{diagnostics.counts.projects}</strong></span>
795
+ <span>mods: <strong>{diagnostics.counts.mods}</strong></span>
796
+ <span>schedules: <strong>{diagnostics.counts.schedules}</strong></span>
797
+ <span>tasks: <strong>{diagnostics.counts.tasks}</strong></span>
798
+ <span>providers: <strong>{diagnostics.counts.providers}</strong></span>
799
+ <span>mcps: <strong>{diagnostics.counts.mcps}</strong></span>
800
+ </div>
801
+ <div className="diagnostic-errors">
802
+ <div className="muted">Last {diagnostics.errors.length} errors from service.log</div>
803
+ {diagnostics.errors.length === 0 ? (
804
+ <p className="muted">No errors recorded.</p>
805
+ ) : (
806
+ <ul>
807
+ {diagnostics.errors.map((e, i) => (
808
+ <li key={i} className="mono">
809
+ {e.ts && <span className="muted">[{e.ts}] </span>}
810
+ {e.line}
811
+ </li>
812
+ ))}
813
+ </ul>
814
+ )}
815
+ </div>
816
+ </>
817
+ )}
818
+ </Card>
819
+ );
820
+ }
821
+
822
+ /* ──────────────────────────────────────────────────────────────
823
+ Providers Panel — fully editable with proper modal forms.
824
+ ────────────────────────────────────────────────────────────── */
825
+
826
+ type ProviderDraft = {
827
+ id: string;
828
+ name: string;
829
+ baseURL: string;
830
+ apiKey: string;
831
+ models: string[];
832
+ enabled: boolean;
833
+ };
834
+
835
+ function ProvidersPanel({
836
+ providers,
837
+ onChange,
838
+ onReload,
839
+ }: {
840
+ providers: Provider[];
841
+ onChange: (p: Provider[]) => void;
842
+ onReload: () => Promise<void>;
843
+ }) {
844
+ const toast = useToast();
845
+ const modal = useModal();
846
+
847
+ const openProviderModal = (existing?: Provider) => {
848
+ let idEl: HTMLInputElement | null = null;
849
+ let nameEl: HTMLInputElement | null = null;
850
+ let baseEl: HTMLInputElement | null = null;
851
+ let keyEl: HTMLInputElement | null = null;
852
+ let modelsEl: HTMLTextAreaElement | null = null;
853
+ let enabledEl: HTMLInputElement | null = null;
854
+
855
+ const isEdit = !!existing;
856
+ const initialId = existing?.id || '';
857
+ const initialName = existing?.name || '';
858
+ const initialBase = existing?.baseURL || '';
859
+ // Show masked key as-is in edit mode (so user can see what's stored).
860
+ const initialKey = existing?.apiKey || '';
861
+ const initialModels = (existing?.models || []).join(', ');
862
+ const initialEnabled = existing?.enabled !== false;
863
+
864
+ modal.open({
865
+ title: isEdit ? `Edit provider "${existing!.id}"` : 'Add provider',
866
+ children: (
867
+ <div>
868
+ <div className="modal-form-row">
869
+ <label>ID (a-z, 0-9, dashes)</label>
870
+ <input
871
+ ref={(el) => (idEl = el)}
872
+ className="input"
873
+ type="text"
874
+ defaultValue={initialId}
875
+ placeholder="anthropic"
876
+ disabled={isEdit}
877
+ />
878
+ </div>
879
+ <div className="modal-form-row">
880
+ <label>Display name</label>
881
+ <input
882
+ ref={(el) => (nameEl = el)}
883
+ className="input"
884
+ type="text"
885
+ defaultValue={initialName}
886
+ placeholder="Anthropic"
887
+ />
888
+ </div>
889
+ <div className="modal-form-row">
890
+ <label>Base URL</label>
891
+ <input
892
+ ref={(el) => (baseEl = el)}
893
+ className="input"
894
+ type="text"
895
+ defaultValue={initialBase}
896
+ placeholder="https://api.anthropic.com"
897
+ />
898
+ </div>
899
+ <div className="modal-form-row">
900
+ <label>API key{isEdit && ' (leave masked value unchanged to keep)'}</label>
901
+ <input
902
+ ref={(el) => (keyEl = el)}
903
+ className="input"
904
+ type="password"
905
+ defaultValue={initialKey}
906
+ placeholder="sk-..."
907
+ autoComplete="off"
908
+ />
909
+ </div>
910
+ <div className="modal-form-row">
911
+ <label>Models (comma-separated)</label>
912
+ <textarea
913
+ ref={(el) => (modelsEl = el)}
914
+ className="textarea"
915
+ defaultValue={initialModels}
916
+ placeholder="claude-sonnet-4-5, claude-opus-4-1"
917
+ rows={3}
918
+ />
919
+ </div>
920
+ <div className="modal-form-row">
921
+ <label className="checkbox-row">
922
+ <input
923
+ ref={(el) => (enabledEl = el)}
924
+ type="checkbox"
925
+ defaultChecked={initialEnabled}
926
+ />
927
+ Enabled
928
+ </label>
929
+ </div>
930
+ </div>
931
+ ),
932
+ footer: (
933
+ <div className="modal-footer-actions">
934
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
935
+ <Button
936
+ variant="primary"
937
+ onClick={async () => {
938
+ const id = (idEl?.value || '').trim();
939
+ const name = (nameEl?.value || '').trim();
940
+ const baseURL = (baseEl?.value || '').trim();
941
+ const apiKey = keyEl?.value || '';
942
+ const modelsRaw = modelsEl?.value || '';
943
+ const models = modelsRaw.split(/[,\n]/).map((m) => m.trim()).filter(Boolean);
944
+ const enabled = !!enabledEl?.checked;
945
+
946
+ if (!id) {
947
+ toast.warning('ID is required.');
948
+ return;
949
+ }
950
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
951
+ toast.warning('ID must be a-z, 0-9, dashes.');
952
+ return;
953
+ }
954
+ try {
955
+ const payload: ProviderDraft & { id: string } = {
956
+ id,
957
+ name: name || id,
958
+ baseURL,
959
+ apiKey,
960
+ models,
961
+ enabled,
962
+ };
963
+ if (isEdit) {
964
+ const updated = await api.put<Provider>(
965
+ `/config/providers/${encodeURIComponent(id)}`,
966
+ payload,
967
+ );
968
+ onChange(providers.map((p) => (p.id === id ? updated : p)));
969
+ toast.success(`Provider "${id}" updated.`);
970
+ } else {
971
+ const created = await api.post<Provider>('/config/providers', payload);
972
+ onChange([...providers, created]);
973
+ toast.success(`Provider "${id}" added.`);
974
+ }
975
+ modal.close();
976
+ } catch (err) {
977
+ toast.error(`Save failed: ${(err as Error).message}`);
978
+ }
979
+ }}
980
+ >
981
+ {isEdit ? 'Update' : 'Add'} provider
982
+ </Button>
983
+ </div>
984
+ ),
985
+ });
986
+ };
987
+
988
+ const onRemove = async (id: string) => {
989
+ if (!confirm(`Remove provider "${id}"?`)) return;
990
+ try {
991
+ await api.del(`/config/providers/${encodeURIComponent(id)}`);
992
+ onChange(providers.filter((p) => p.id !== id));
993
+ toast.success('Provider removed.');
994
+ } catch (err) {
995
+ toast.error(`Remove failed: ${(err as Error).message}`);
996
+ }
997
+ };
998
+
999
+ return (
1000
+ <Card>
1001
+ <CardTitle>
1002
+ <ServerIcon size={14} /> Providers ({providers.length})
1003
+ </CardTitle>
1004
+ <CardMeta>
1005
+ AI providers configured under <code>opencode.json#provider</code>.
1006
+ Each provider has a base URL, API key, and a list of model IDs.
1007
+ </CardMeta>
1008
+ <AutoDetectBanner onAdd={(id, name, baseURL, key) => {
1009
+ openProviderModal({ id, name, baseURL, apiKey: key } as Provider);
1010
+ }} />
1011
+ <div className="view-actions" style={{ marginBottom: 12 }}>
1012
+ <Button variant="primary" size="sm" onClick={() => openProviderModal()}>
1013
+ <Plus size={14} /> Add provider
1014
+ </Button>
1015
+ <Button variant="ghost" size="sm" onClick={onReload}>
1016
+ <RefreshCw size={14} /> Refresh
1017
+ </Button>
1018
+ </div>
1019
+ {providers.length === 0 ? (
1020
+ <EmptyProviders onAdd={() => openProviderModal()} />
1021
+ ) : (
1022
+ <div className="provider-list">
1023
+ {providers.map((p) => (
1024
+ <Card key={p.id} className="provider-row">
1025
+ <div className="provider-row-head">
1026
+ <div style={{ minWidth: 0, flex: 1 }}>
1027
+ <div className="provider-name">{p.name || p.id}</div>
1028
+ <div className="provider-id">{p.id}</div>
1029
+ </div>
1030
+ <div className="provider-actions">
1031
+ <label className="toggle-row" title="Enabled">
1032
+ <input
1033
+ type="checkbox"
1034
+ checked={p.enabled !== false}
1035
+ onChange={async (e) => {
1036
+ try {
1037
+ const updated = await api.put<Provider>(
1038
+ `/config/providers/${encodeURIComponent(p.id)}`,
1039
+ { enabled: e.target.checked },
1040
+ );
1041
+ onChange(providers.map((x) => (x.id === p.id ? updated : x)));
1042
+ } catch (err) {
1043
+ toast.error(`Toggle failed: ${(err as Error).message}`);
1044
+ }
1045
+ }}
1046
+ />
1047
+ {p.enabled !== false ? 'on' : 'off'}
1048
+ </label>
1049
+ <button
1050
+ type="button"
1051
+ className="icon-btn"
1052
+ aria-label="Edit"
1053
+ title="Edit"
1054
+ onClick={() => openProviderModal(p)}
1055
+ >
1056
+ <Pencil size={12} />
1057
+ </button>
1058
+ <button
1059
+ type="button"
1060
+ className="icon-btn icon-btn-danger"
1061
+ aria-label="Remove"
1062
+ title="Remove"
1063
+ onClick={() => onRemove(p.id)}
1064
+ >
1065
+ <Trash2 size={12} />
1066
+ </button>
1067
+ </div>
1068
+ </div>
1069
+ <div className="provider-meta">
1070
+ <div><span className="muted">Base URL:</span> <code>{p.baseURL || '—'}</code></div>
1071
+ <div><span className="muted">API key:</span> <code>{p.apiKey || '—'}</code></div>
1072
+ <div><span className="muted">Models:</span> {(p.models || []).join(', ') || '—'}</div>
1073
+ </div>
1074
+ </Card>
1075
+ ))}
1076
+ </div>
1077
+ )}
1078
+ </Card>
1079
+ );
1080
+ }
1081
+
1082
+ function EmptyProviders({ onAdd }: { onAdd: () => void }) {
1083
+ return (
1084
+ <div style={{
1085
+ padding: '32px 16px',
1086
+ textAlign: 'center',
1087
+ color: 'var(--text-dim)',
1088
+ border: '1px dashed var(--border)',
1089
+ borderRadius: 'var(--radius-md)',
1090
+ display: 'flex',
1091
+ flexDirection: 'column',
1092
+ gap: 8,
1093
+ alignItems: 'center',
1094
+ }}>
1095
+ <ServerIcon size={28} />
1096
+ <div>No providers configured.</div>
1097
+ <div style={{ fontSize: 12 }}>Add a provider to connect an AI model.</div>
1098
+ <Button variant="primary" size="sm" onClick={onAdd}>
1099
+ <Plus size={14} /> Add your first provider
1100
+ </Button>
1101
+ </div>
1102
+ );
1103
+ }
1104
+
1105
+ /* ──────────────────────────────────────────────────────────────
1106
+ MCPs Panel — fully editable with proper modal forms.
1107
+ ────────────────────────────────────────────────────────────── */
1108
+
1109
+ type McpDraft = {
1110
+ id: string;
1111
+ type: 'local' | 'remote';
1112
+ command: string;
1113
+ args: string[];
1114
+ env: Record<string, string>;
1115
+ url: string;
1116
+ headers: Record<string, string>;
1117
+ oauth: boolean;
1118
+ enabled: boolean;
1119
+ };
1120
+
1121
+ function parseEnv(s: string): Record<string, string> {
1122
+ const out: Record<string, string> = {};
1123
+ for (const line of s.split(/\r?\n/)) {
1124
+ const trimmed = line.trim();
1125
+ if (!trimmed || trimmed.startsWith('#')) continue;
1126
+ const eq = trimmed.indexOf('=');
1127
+ if (eq <= 0) continue;
1128
+ const k = trimmed.slice(0, eq).trim();
1129
+ const v = trimmed.slice(eq + 1).trim();
1130
+ if (k) out[k] = v;
1131
+ }
1132
+ return out;
1133
+ }
1134
+
1135
+ function parseHeaders(s: string): Record<string, string> {
1136
+ const out: Record<string, string> = {};
1137
+ for (const line of s.split(/\r?\n/)) {
1138
+ const trimmed = line.trim();
1139
+ if (!trimmed || trimmed.startsWith('#')) continue;
1140
+ const sep = trimmed.indexOf(':');
1141
+ if (sep <= 0) continue;
1142
+ const k = trimmed.slice(0, sep).trim();
1143
+ const v = trimmed.slice(sep + 1).trim();
1144
+ if (k) out[k] = v;
1145
+ }
1146
+ return out;
1147
+ }
1148
+
1149
+ function McpsPanel({
1150
+ mcps,
1151
+ onChange,
1152
+ onReload,
1153
+ }: {
1154
+ mcps: McpServer[];
1155
+ onChange: (m: McpServer[]) => void;
1156
+ onReload: () => Promise<void>;
1157
+ }) {
1158
+ const toast = useToast();
1159
+ const modal = useModal();
1160
+
1161
+ const openMcpModal = (existing?: McpServer) => {
1162
+ let idEl: HTMLInputElement | null = null;
1163
+ let typeLocalEl: HTMLInputElement | null = null;
1164
+ let typeRemoteEl: HTMLInputElement | null = null;
1165
+ let commandEl: HTMLInputElement | null = null;
1166
+ let argsEl: HTMLInputElement | null = null;
1167
+ let envEl: HTMLTextAreaElement | null = null;
1168
+ let urlEl: HTMLInputElement | null = null;
1169
+ let headersEl: HTMLTextAreaElement | null = null;
1170
+ let oauthEl: HTMLInputElement | null = null;
1171
+ let enabledEl: HTMLInputElement | null = null;
1172
+
1173
+ const isEdit = !!existing;
1174
+ const isRemote = existing?.type === 'remote' || (!existing && false);
1175
+ const initialType: 'local' | 'remote' = existing?.type === 'remote' ? 'remote' : 'local';
1176
+ const initialCommand = existing?.command || '';
1177
+ const initialArgs = (existing?.args || []).join(' ');
1178
+ const initialEnv = existing?.env ? Object.entries(existing.env).map(([k, v]) => `${k}=${v}`).join('\n') : '';
1179
+ const initialUrl = existing?.url || '';
1180
+ const initialHeaders = existing?.headers ? Object.entries(existing.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
1181
+ const initialOauth = !!existing?.oauth;
1182
+ const initialEnabled = existing?.enabled !== false;
1183
+
1184
+ modal.open({
1185
+ title: isEdit ? `Edit MCP "${existing!.id}"` : 'Add MCP',
1186
+ children: (
1187
+ <div>
1188
+ <div className="modal-form-row">
1189
+ <label>ID (a-z, 0-9, dashes)</label>
1190
+ <input
1191
+ ref={(el) => (idEl = el)}
1192
+ className="input"
1193
+ type="text"
1194
+ defaultValue={existing?.id || ''}
1195
+ placeholder="supabase"
1196
+ disabled={isEdit}
1197
+ />
1198
+ </div>
1199
+ <div className="modal-form-row">
1200
+ <label>Type</label>
1201
+ <div style={{ display: 'flex', gap: 12 }}>
1202
+ <label className="radio-label">
1203
+ <input
1204
+ ref={(el) => (typeLocalEl = el)}
1205
+ type="radio"
1206
+ name="mcp-type"
1207
+ value="local"
1208
+ defaultChecked={initialType === 'local'}
1209
+ />
1210
+ Local (stdio)
1211
+ </label>
1212
+ <label className="radio-label">
1213
+ <input
1214
+ ref={(el) => (typeRemoteEl = el)}
1215
+ type="radio"
1216
+ name="mcp-type"
1217
+ value="remote"
1218
+ defaultChecked={initialType === 'remote'}
1219
+ />
1220
+ Remote (HTTP)
1221
+ </label>
1222
+ </div>
1223
+ </div>
1224
+
1225
+ <div data-mcp-section="local">
1226
+ <div className="modal-form-row">
1227
+ <label>Command (binary to invoke)</label>
1228
+ <input
1229
+ ref={(el) => (commandEl = el)}
1230
+ className="input"
1231
+ type="text"
1232
+ defaultValue={initialCommand}
1233
+ placeholder="uvx --from semble[mcp] semble"
1234
+ />
1235
+ </div>
1236
+ <div className="modal-form-row">
1237
+ <label>Extra args (space-separated)</label>
1238
+ <input
1239
+ ref={(el) => (argsEl = el)}
1240
+ className="input"
1241
+ type="text"
1242
+ defaultValue={initialArgs}
1243
+ placeholder="--port 8080"
1244
+ />
1245
+ <div className="field-help">These are appended after the command.</div>
1246
+ </div>
1247
+ <div className="modal-form-row">
1248
+ <label>Environment variables</label>
1249
+ <textarea
1250
+ ref={(el) => (envEl = el)}
1251
+ className="textarea"
1252
+ defaultValue={initialEnv}
1253
+ placeholder="API_KEY=xxx&#10;DEBUG=true"
1254
+ rows={3}
1255
+ />
1256
+ <div className="field-help">One KEY=VALUE per line.</div>
1257
+ </div>
1258
+ </div>
1259
+
1260
+ <div data-mcp-section="remote">
1261
+ <div className="modal-form-row">
1262
+ <label>URL</label>
1263
+ <input
1264
+ ref={(el) => (urlEl = el)}
1265
+ className="input"
1266
+ type="text"
1267
+ defaultValue={initialUrl}
1268
+ placeholder="https://mcp.example.com/mcp"
1269
+ />
1270
+ </div>
1271
+ <div className="modal-form-row">
1272
+ <label>Headers</label>
1273
+ <textarea
1274
+ ref={(el) => (headersEl = el)}
1275
+ className="textarea"
1276
+ defaultValue={initialHeaders}
1277
+ placeholder="Authorization: Bearer xxx&#10;Content-Type: application/json"
1278
+ rows={3}
1279
+ />
1280
+ <div className="field-help">One "Key: Value" per line.</div>
1281
+ </div>
1282
+ <div className="modal-form-row">
1283
+ <label className="checkbox-row">
1284
+ <input
1285
+ ref={(el) => (oauthEl = el)}
1286
+ type="checkbox"
1287
+ defaultChecked={initialOauth}
1288
+ />
1289
+ Use OAuth (browser-based auth flow)
1290
+ </label>
1291
+ </div>
1292
+ </div>
1293
+
1294
+ <div className="modal-form-row">
1295
+ <label className="checkbox-row">
1296
+ <input
1297
+ ref={(el) => (enabledEl = el)}
1298
+ type="checkbox"
1299
+ defaultChecked={initialEnabled}
1300
+ />
1301
+ Enabled
1302
+ </label>
1303
+ </div>
1304
+ </div>
1305
+ ),
1306
+ footer: (
1307
+ <div className="modal-footer-actions">
1308
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
1309
+ <Button
1310
+ variant="primary"
1311
+ onClick={async () => {
1312
+ const id = (idEl?.value || '').trim();
1313
+ const isRemote = !!typeRemoteEl?.checked;
1314
+ const command = (commandEl?.value || '').trim();
1315
+ const argsStr = (argsEl?.value || '').trim();
1316
+ const args = argsStr ? argsStr.split(/\s+/) : [];
1317
+ const env = parseEnv(envEl?.value || '');
1318
+ const url = (urlEl?.value || '').trim();
1319
+ const headers = parseHeaders(headersEl?.value || '');
1320
+ const oauth = !!oauthEl?.checked;
1321
+ const enabled = !!enabledEl?.checked;
1322
+
1323
+ if (!id) {
1324
+ toast.warning('ID is required.');
1325
+ return;
1326
+ }
1327
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
1328
+ toast.warning('ID must be a-z, 0-9, dashes.');
1329
+ return;
1330
+ }
1331
+ try {
1332
+ const payload: McpDraft = {
1333
+ id,
1334
+ type: isRemote ? 'remote' : 'local',
1335
+ command,
1336
+ args,
1337
+ env,
1338
+ url,
1339
+ headers,
1340
+ oauth,
1341
+ enabled,
1342
+ };
1343
+ if (isEdit) {
1344
+ const updated = await api.put<McpServer>(
1345
+ `/config/mcps/${encodeURIComponent(id)}`,
1346
+ payload,
1347
+ );
1348
+ onChange(mcps.map((m) => (m.id === id ? updated : m)));
1349
+ toast.success(`MCP "${id}" updated.`);
1350
+ } else {
1351
+ const created = await api.post<McpServer>('/config/mcps', payload);
1352
+ onChange([...mcps, created]);
1353
+ toast.success(`MCP "${id}" added.`);
1354
+ }
1355
+ modal.close();
1356
+ } catch (err) {
1357
+ toast.error(`Save failed: ${(err as Error).message}`);
1358
+ }
1359
+ }}
1360
+ >
1361
+ {isEdit ? 'Update' : 'Add'} MCP
1362
+ </Button>
1363
+ </div>
1364
+ ),
1365
+ });
1366
+ };
1367
+
1368
+ const onRemove = async (id: string) => {
1369
+ if (!confirm(`Remove MCP "${id}"?`)) return;
1370
+ try {
1371
+ await api.del(`/config/mcps/${encodeURIComponent(id)}`);
1372
+ onChange(mcps.filter((m) => m.id !== id));
1373
+ toast.success('MCP removed.');
1374
+ } catch (err) {
1375
+ toast.error(`Remove failed: ${(err as Error).message}`);
1376
+ }
1377
+ };
1378
+
1379
+ return (
1380
+ <Card>
1381
+ <CardTitle>
1382
+ <Plug size={14} /> MCPs ({mcps.length})
1383
+ </CardTitle>
1384
+ <CardMeta>
1385
+ Model Context Protocol servers under <code>opencode.json#mcp</code>.
1386
+ Local MCPs run as stdio subprocesses; remote MCPs are HTTP endpoints.
1387
+ </CardMeta>
1388
+ <div className="view-actions" style={{ marginBottom: 12 }}>
1389
+ <Button variant="primary" size="sm" onClick={() => openMcpModal()}>
1390
+ <Plus size={14} /> Add MCP
1391
+ </Button>
1392
+ <Button variant="ghost" size="sm" onClick={onReload}>
1393
+ <RefreshCw size={14} /> Refresh
1394
+ </Button>
1395
+ </div>
1396
+ {mcps.length === 0 ? (
1397
+ <div style={{
1398
+ padding: '32px 16px',
1399
+ textAlign: 'center',
1400
+ color: 'var(--text-dim)',
1401
+ border: '1px dashed var(--border)',
1402
+ borderRadius: 'var(--radius-md)',
1403
+ display: 'flex',
1404
+ flexDirection: 'column',
1405
+ gap: 8,
1406
+ alignItems: 'center',
1407
+ }}>
1408
+ <Plug size={28} />
1409
+ <div>No MCPs configured.</div>
1410
+ <Button variant="primary" size="sm" onClick={() => openMcpModal()}>
1411
+ <Plus size={14} /> Add your first MCP
1412
+ </Button>
1413
+ </div>
1414
+ ) : (
1415
+ <div className="mcp-list">
1416
+ {mcps.map((m) => (
1417
+ <Card key={m.id} className="mcp-row">
1418
+ <div className="mcp-row-head">
1419
+ <div style={{ minWidth: 0, flex: 1 }}>
1420
+ <div className="mcp-name">{m.id}</div>
1421
+ <div className="mcp-id">
1422
+ {m.type === 'remote' ? 'remote' : 'local'} {m.enabled !== false ? '· enabled' : '· disabled'}
1423
+ </div>
1424
+ </div>
1425
+ <div className="mcp-actions">
1426
+ <label className="toggle-row" title="Enabled">
1427
+ <input
1428
+ type="checkbox"
1429
+ checked={m.enabled !== false}
1430
+ onChange={async (e) => {
1431
+ try {
1432
+ const updated = await api.put<McpServer>(
1433
+ `/config/mcps/${encodeURIComponent(m.id)}`,
1434
+ { enabled: e.target.checked },
1435
+ );
1436
+ onChange(mcps.map((x) => (x.id === m.id ? updated : x)));
1437
+ } catch (err) {
1438
+ toast.error(`Toggle failed: ${(err as Error).message}`);
1439
+ }
1440
+ }}
1441
+ />
1442
+ {m.enabled !== false ? 'on' : 'off'}
1443
+ </label>
1444
+ <button
1445
+ type="button"
1446
+ className="icon-btn"
1447
+ aria-label="Edit"
1448
+ title="Edit"
1449
+ onClick={() => openMcpModal(m)}
1450
+ >
1451
+ <Pencil size={12} />
1452
+ </button>
1453
+ <button
1454
+ type="button"
1455
+ className="icon-btn icon-btn-danger"
1456
+ aria-label="Remove"
1457
+ title="Remove"
1458
+ onClick={() => onRemove(m.id)}
1459
+ >
1460
+ <Trash2 size={12} />
1461
+ </button>
1462
+ </div>
1463
+ </div>
1464
+ <div className="mcp-meta">
1465
+ {m.type === 'remote' ? (
1466
+ <>
1467
+ <div><span className="muted">URL:</span> <code>{m.url || '—'}</code></div>
1468
+ {Object.keys(m.headers || {}).length > 0 && (
1469
+ <div><span className="muted">Headers:</span> <code>{Object.keys(m.headers || {}).length} header(s)</code></div>
1470
+ )}
1471
+ {m.oauth && <div><span className="muted">Auth:</span> <code>OAuth</code></div>}
1472
+ </>
1473
+ ) : (
1474
+ <>
1475
+ <div><span className="muted">Command:</span> <code>{m.command || '—'}</code></div>
1476
+ {m.args && m.args.length > 0 && (
1477
+ <div><span className="muted">Args:</span> <code>{m.args.join(' ')}</code></div>
1478
+ )}
1479
+ {m.env && Object.keys(m.env).length > 0 && (
1480
+ <div><span className="muted">Env:</span> <code>{Object.keys(m.env).length} var(s)</code></div>
1481
+ )}
1482
+ </>
1483
+ )}
1484
+ </div>
1485
+ </Card>
1486
+ ))}
1487
+ </div>
1488
+ )}
1489
+ </Card>
1490
+ );
1491
+ }
1492
+
1493
+ function ExportPanel({ onDownload }: { onDownload: () => void }) {
1494
+ return (
1495
+ <Card>
1496
+ <CardTitle>
1497
+ <Download size={14} /> Export / Import
1498
+ </CardTitle>
1499
+ <CardMeta>
1500
+ Download a JSON bundle of diagnostics, configuration, providers, and MCPs
1501
+ for support requests or backup. Server-side imports are not yet implemented.
1502
+ </CardMeta>
1503
+ <div className="view-actions" style={{ marginTop: 12 }}>
1504
+ <Button variant="primary" onClick={onDownload}>
1505
+ <Download size={14} /> Download diagnostics bundle
1506
+ </Button>
1507
+ </div>
1508
+ <div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
1509
+ <Database size={12} style={{ display: 'inline', verticalAlign: -2 }} /> Bundles include:
1510
+ opencode.json snapshot, providers list (with masked API keys), MCP list, recent
1511
+ service log errors, and active project metadata.
1512
+ </div>
1513
+ </Card>
1514
+ );
1515
+ }
1516
+
1517
+ /* ──────────────────────────────────────────────────────────────
1518
+ Memory & LightRAG Panel
1519
+ ────────────────────────────────────────────────────────────── */
1520
+
1521
+ type LightragDraft = {
1522
+ enabled: boolean;
1523
+ host: string;
1524
+ port: number | '';
1525
+ workingDir: string;
1526
+ llmBinding: string;
1527
+ embeddingBinding: string;
1528
+ llmBindingHost: string;
1529
+ embeddingBindingHost: string;
1530
+ llmModel: string;
1531
+ embeddingModel: string;
1532
+ apiKeySource: 'env' | 'file';
1533
+ apiKey: string;
1534
+ };
1535
+
1536
+ const LLM_BINDING_OPTIONS = ['ollama', 'openai', 'lollms', 'azure_openai', 'bedrock', 'gemini'];
1537
+ const EMBEDDING_BINDING_OPTIONS = ['ollama', 'openai', 'azure_openai', 'bedrock', 'jina', 'gemini', 'voyageai'];
1538
+
1539
+ function MemoryLightragPanel({
1540
+ status,
1541
+ onReload,
1542
+ logLines,
1543
+ onReloadLog,
1544
+ }: {
1545
+ status: LightragStatus | null;
1546
+ onReload: () => void;
1547
+ logLines: string[];
1548
+ onReloadLog: () => void;
1549
+ }) {
1550
+ const toast = useToast();
1551
+ const [saving, setSaving] = useState(false);
1552
+ const [starting, setStarting] = useState(false);
1553
+ const [stopping, setStopping] = useState(false);
1554
+ const [loadingLog, setLoadingLog] = useState(false);
1555
+ const [showLog, setShowLog] = useState(false);
1556
+
1557
+ // Draft form state — initialise from status once loaded
1558
+ const [draft, setDraft] = useState<LightragDraft>({
1559
+ enabled: true,
1560
+ host: '127.0.0.1',
1561
+ port: 9621,
1562
+ workingDir: '',
1563
+ llmBinding: 'ollama',
1564
+ embeddingBinding: 'ollama',
1565
+ llmBindingHost: '',
1566
+ embeddingBindingHost: '',
1567
+ llmModel: 'minimax/MiniMax-M3',
1568
+ embeddingModel: 'text-embedding-3-small',
1569
+ apiKeySource: 'env',
1570
+ apiKey: '',
1571
+ });
1572
+ const [dirty, setDirty] = useState(false);
1573
+
1574
+ // Sync draft from status when status loads
1575
+ useEffect(() => {
1576
+ if (!status) return;
1577
+ setDraft((d) => ({
1578
+ ...d,
1579
+ enabled: true,
1580
+ host: status.host,
1581
+ port: status.port,
1582
+ llmBinding: status.llmBinding,
1583
+ embeddingBinding: status.embeddingBinding,
1584
+ llmBindingHost: status.llmBindingHost || '',
1585
+ embeddingBindingHost: status.embeddingBindingHost || '',
1586
+ llmModel: status.llmModel,
1587
+ embeddingModel: status.embeddingModel,
1588
+ apiKeySource: 'env',
1589
+ apiKey: '',
1590
+ }));
1591
+ setDirty(false);
1592
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1593
+ }, [status?.running]);
1594
+
1595
+ const set = <K extends keyof LightragDraft>(key: K, value: LightragDraft[K]) => {
1596
+ setDraft((d) => ({ ...d, [key]: value }));
1597
+ setDirty(true);
1598
+ };
1599
+
1600
+ const handleSave = async () => {
1601
+ if (saving) return;
1602
+ setSaving(true);
1603
+ try {
1604
+ // Build patch payload
1605
+ const patch: Record<string, unknown> = {
1606
+ enabled: draft.enabled,
1607
+ host: draft.host,
1608
+ port: draft.port === '' ? 9621 : Number(draft.port),
1609
+ workingDir: draft.workingDir,
1610
+ llmBinding: draft.llmBinding,
1611
+ embeddingBinding: draft.embeddingBinding,
1612
+ llmBindingHost: draft.llmBindingHost || null,
1613
+ embeddingBindingHost: draft.embeddingBindingHost || null,
1614
+ llmModel: draft.llmModel,
1615
+ embeddingModel: draft.embeddingModel,
1616
+ apiKeySource: draft.apiKeySource,
1617
+ };
1618
+ if (draft.apiKeySource === 'file' && draft.apiKey !== '') {
1619
+ patch.apiKey = draft.apiKey;
1620
+ }
1621
+ if (draft.apiKeySource === 'env') {
1622
+ patch.apiKey = '<empty>';
1623
+ }
1624
+
1625
+ await api.post('/memory/config', { patch: true, lightrag: patch });
1626
+ toast.success('LightRAG config saved.');
1627
+ setDirty(false);
1628
+ onReload();
1629
+ } catch (err) {
1630
+ toast.error(`Save failed: ${(err as Error).message}`);
1631
+ } finally {
1632
+ setSaving(false);
1633
+ }
1634
+ };
1635
+
1636
+ const handleStart = async () => {
1637
+ if (starting) return;
1638
+ setStarting(true);
1639
+ try {
1640
+ const r = await api.post<{ ok: boolean; error?: string; pid?: number }>('/memory/lightrag/start');
1641
+ if (r.ok) {
1642
+ toast.success(`LightRAG started (pid ${r.pid}).`);
1643
+ } else {
1644
+ toast.error(`Start failed: ${r.error}`);
1645
+ }
1646
+ onReload();
1647
+ } catch (err) {
1648
+ toast.error(`Start failed: ${(err as Error).message}`);
1649
+ } finally {
1650
+ setStarting(false);
1651
+ }
1652
+ };
1653
+
1654
+ const handleStop = async () => {
1655
+ if (stopping) return;
1656
+ setStopping(true);
1657
+ try {
1658
+ await api.post('/memory/lightrag/stop');
1659
+ toast.success('LightRAG stopped.');
1660
+ onReload();
1661
+ } catch (err) {
1662
+ toast.error(`Stop failed: ${(err as Error).message}`);
1663
+ } finally {
1664
+ setStopping(false);
1665
+ }
1666
+ };
1667
+
1668
+ const handleRestart = async () => {
1669
+ await handleStop();
1670
+ await handleStart();
1671
+ };
1672
+
1673
+ const handleShowLog = async () => {
1674
+ if (!showLog) {
1675
+ setLoadingLog(true);
1676
+ await onReloadLog();
1677
+ setLoadingLog(false);
1678
+ }
1679
+ setShowLog((v) => !v);
1680
+ };
1681
+
1682
+ const running = status?.running ?? false;
1683
+
1684
+ return (
1685
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
1686
+ {/* Status card */}
1687
+ <Card>
1688
+ <CardTitle>
1689
+ <Database size={14} /> LightRAG Server
1690
+ </CardTitle>
1691
+ <CardMeta>
1692
+ In-process embedding server for semantic memory search.{' '}
1693
+ <button type="button" className="link-btn" onClick={onReload}>Refresh</button>
1694
+ </CardMeta>
1695
+ <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
1696
+ {running ? (
1697
+ <span className="tag tag-success">
1698
+ running {status?.pid ? `(pid ${status.pid})` : ''}
1699
+ </span>
1700
+ ) : (
1701
+ <span className="tag tag-neutral">stopped</span>
1702
+ )}
1703
+ {status && (
1704
+ <span className="mono text-sm muted">
1705
+ {status.host}:{status.port} · {status.llmBinding} · {status.embeddingModel}
1706
+ </span>
1707
+ )}
1708
+ {status?.lastError && (
1709
+ <span className="text-error text-sm">⚠ {status.lastError}</span>
1710
+ )}
1711
+ </div>
1712
+ <div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
1713
+ {!running ? (
1714
+ <Button variant="primary" size="sm" onClick={handleStart} disabled={starting}>
1715
+ {starting ? <Spinner size="sm" /> : <RefreshCw size={12} />}
1716
+ {starting ? 'Starting…' : 'Start'}
1717
+ </Button>
1718
+ ) : (
1719
+ <>
1720
+ <Button variant="secondary" size="sm" onClick={handleRestart} disabled={stopping || starting}>
1721
+ <RefreshCw size={12} /> Restart
1722
+ </Button>
1723
+ <Button variant="ghost" size="sm" onClick={handleStop} disabled={stopping}>
1724
+ {stopping ? <Spinner size="sm" /> : null}
1725
+ {stopping ? 'Stopping…' : 'Stop'}
1726
+ </Button>
1727
+ </>
1728
+ )}
1729
+ <Button variant="ghost" size="sm" onClick={handleShowLog}>
1730
+ {showLog ? 'Hide' : 'Show'} log
1731
+ </Button>
1732
+ </div>
1733
+ {showLog && (
1734
+ <div style={{ marginTop: 12 }}>
1735
+ <div className="field-help" style={{ marginBottom: 6 }}>
1736
+ Last {logLines.length} line(s)
1737
+ <button type="button" className="link-btn" style={{ marginLeft: 8 }} onClick={onReloadLog}>
1738
+ Refresh
1739
+ </button>
1740
+ </div>
1741
+ {loadingLog ? (
1742
+ <p className="muted text-sm">Loading…</p>
1743
+ ) : logLines.length === 0 ? (
1744
+ <p className="muted text-sm">No log output yet.</p>
1745
+ ) : (
1746
+ <pre className="log-pre">{logLines.join('\n')}</pre>
1747
+ )}
1748
+ </div>
1749
+ )}
1750
+ </Card>
1751
+
1752
+ {/* Binding card */}
1753
+ <Card>
1754
+ <CardTitle>
1755
+ <ShieldCheck size={14} /> Bindings
1756
+ </CardTitle>
1757
+ <CardMeta>Choose the LLM and embedding provider bindings.</CardMeta>
1758
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1759
+ <div className="field-row">
1760
+ <label className="field-label">LLM Binding</label>
1761
+ <select
1762
+ className="input"
1763
+ value={draft.llmBinding}
1764
+ onChange={(e) => set('llmBinding', e.target.value)}
1765
+ >
1766
+ {LLM_BINDING_OPTIONS.map((b) => (
1767
+ <option key={b} value={b}>{b}</option>
1768
+ ))}
1769
+ </select>
1770
+ </div>
1771
+ <div className="field-row">
1772
+ <label className="field-label">Embedding Binding</label>
1773
+ <select
1774
+ className="input"
1775
+ value={draft.embeddingBinding}
1776
+ onChange={(e) => set('embeddingBinding', e.target.value)}
1777
+ >
1778
+ {EMBEDDING_BINDING_OPTIONS.map((b) => (
1779
+ <option key={b} value={b}>{b}</option>
1780
+ ))}
1781
+ </select>
1782
+ </div>
1783
+ {draft.llmBinding !== 'ollama' && (
1784
+ <div className="field-row">
1785
+ <label className="field-label">LLM Binding Host</label>
1786
+ <input
1787
+ className="input"
1788
+ type="text"
1789
+ value={draft.llmBindingHost}
1790
+ onChange={(e) => set('llmBindingHost', e.target.value)}
1791
+ placeholder="https://api.minimax.chat/v1"
1792
+ />
1793
+ </div>
1794
+ )}
1795
+ {draft.embeddingBinding !== 'ollama' && (
1796
+ <div className="field-row">
1797
+ <label className="field-label">Embedding Binding Host</label>
1798
+ <input
1799
+ className="input"
1800
+ type="text"
1801
+ value={draft.embeddingBindingHost}
1802
+ onChange={(e) => set('embeddingBindingHost', e.target.value)}
1803
+ placeholder="https://api.minimax.chat/v1"
1804
+ />
1805
+ </div>
1806
+ )}
1807
+ </div>
1808
+ </Card>
1809
+
1810
+ {/* Models card */}
1811
+ <Card>
1812
+ <CardTitle>
1813
+ <ServerIcon size={14} /> Models
1814
+ </CardTitle>
1815
+ <CardMeta>Model IDs for the LLM and embedding engine.</CardMeta>
1816
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1817
+ <div className="field-row">
1818
+ <label className="field-label">LLM Model</label>
1819
+ <input
1820
+ className="input"
1821
+ type="text"
1822
+ value={draft.llmModel}
1823
+ onChange={(e) => set('llmModel', e.target.value)}
1824
+ placeholder="minimax/MiniMax-M3"
1825
+ />
1826
+ </div>
1827
+ <div className="field-row">
1828
+ <label className="field-label">Embedding Model</label>
1829
+ <input
1830
+ className="input"
1831
+ type="text"
1832
+ value={draft.embeddingModel}
1833
+ onChange={(e) => set('embeddingModel', e.target.value)}
1834
+ placeholder="text-embedding-3-small"
1835
+ />
1836
+ </div>
1837
+ </div>
1838
+ </Card>
1839
+
1840
+ {/* Connection card */}
1841
+ <Card>
1842
+ <CardTitle>
1843
+ <Plug size={14} /> Connection
1844
+ </CardTitle>
1845
+ <CardMeta>Host, port, and working directory for the LightRAG server.</CardMeta>
1846
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1847
+ <div className="field-row">
1848
+ <label className="field-label">Host</label>
1849
+ <input
1850
+ className="input"
1851
+ type="text"
1852
+ value={draft.host}
1853
+ onChange={(e) => set('host', e.target.value)}
1854
+ placeholder="127.0.0.1"
1855
+ />
1856
+ </div>
1857
+ <div className="field-row">
1858
+ <label className="field-label">Port</label>
1859
+ <input
1860
+ className="input"
1861
+ type="number"
1862
+ min={1}
1863
+ max={65535}
1864
+ value={draft.port}
1865
+ onChange={(e) => set('port', e.target.value === '' ? '' : Number(e.target.value))}
1866
+ placeholder="9621"
1867
+ />
1868
+ </div>
1869
+ <div className="field-row">
1870
+ <label className="field-label">Working Dir</label>
1871
+ <input
1872
+ className="input"
1873
+ type="text"
1874
+ value={draft.workingDir}
1875
+ onChange={(e) => set('workingDir', e.target.value)}
1876
+ placeholder=".bizar/lightrag (default)"
1877
+ />
1878
+ </div>
1879
+ </div>
1880
+ </Card>
1881
+
1882
+ {/* Credentials card */}
1883
+ <Card>
1884
+ <CardTitle>
1885
+ <ShieldCheck size={14} /> Credentials
1886
+ </CardTitle>
1887
+ <CardMeta>API key source — set in your shell env, or stored in .bizar/memory.json.</CardMeta>
1888
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
1889
+ <div className="field-row">
1890
+ <label className="field-label">Source</label>
1891
+ <select
1892
+ className="input"
1893
+ value={draft.apiKeySource}
1894
+ onChange={(e) => set('apiKeySource', e.target.value as 'env' | 'file')}
1895
+ >
1896
+ <option value="env">Environment variable (recommended)</option>
1897
+ <option value="file">Stored in .bizar/memory.json</option>
1898
+ </select>
1899
+ </div>
1900
+ {draft.apiKeySource === 'env' ? (
1901
+ <p className="muted text-sm" style={{ padding: '8px 0' }}>
1902
+ Set <code>OPENAI_API_KEY</code> (or provider-specific env var) in your shell before
1903
+ running reindex or queries.
1904
+ </p>
1905
+ ) : (
1906
+ <div className="field-row">
1907
+ <label className="field-label">API Key</label>
1908
+ <input
1909
+ className="input"
1910
+ type="password"
1911
+ value={draft.apiKey}
1912
+ onChange={(e) => set('apiKey', e.target.value)}
1913
+ placeholder={status?.running ? '(unchanged — leave blank)' : 'sk-…'}
1914
+ autoComplete="off"
1915
+ />
1916
+ </div>
1917
+ )}
1918
+ </div>
1919
+ </Card>
1920
+
1921
+ <div className="view-actions">
1922
+ <Button variant="primary" disabled={!dirty || saving} onClick={handleSave}>
1923
+ {saving ? <Spinner size="sm" /> : <Save size={14} />}
1924
+ {saving ? 'Saving…' : 'Save config'}
1925
+ </Button>
1926
+ </div>
1927
+ </div>
1928
+ );
1929
+ }
1930
+
1931
+ // ─── AutoDetectBanner (v3.16.0) ────────────────────────────────────
1932
+ // Scans env vars + opencode.json for known provider API keys
1933
+ // (Anthropic, OpenAI, Google, Mistral, Groq, Cohere, OpenRouter,
1934
+ // DeepSeek, MiniMax). Surfaces status: configured / unknown / no-key.
1935
+ type AutoDetectResult = {
1936
+ id: string;
1937
+ name: string;
1938
+ baseURL?: string;
1939
+ status: 'configured' | 'unknown' | 'no-key';
1940
+ keySource: string;
1941
+ hasKey: boolean;
1942
+ probed?: { ok: boolean; status?: number; reason?: string; modelCount?: number } | null;
1943
+ };
1944
+
1945
+ function AutoDetectBanner({
1946
+ onAdd,
1947
+ }: {
1948
+ onAdd: (id: string, name: string, baseURL: string, apiKey?: string) => void;
1949
+ }) {
1950
+ const toast = useToast();
1951
+ const [results, setResults] = useState<AutoDetectResult[] | null>(null);
1952
+ const [loading, setLoading] = useState(false);
1953
+ const [expanded, setExpanded] = useState(false);
1954
+
1955
+ const run = async (probe: boolean) => {
1956
+ setLoading(true);
1957
+ try {
1958
+ const r = await api.get<{ providers: AutoDetectResult[] }>(
1959
+ `/providers/auto-detect${probe ? '' : '?probe=0'}`,
1960
+ );
1961
+ setResults(r.providers || []);
1962
+ const configured = (r.providers || []).filter((p) => p.status === 'configured');
1963
+ if (configured.length > 0) {
1964
+ toast.success(`Detected ${configured.length} configured provider${configured.length === 1 ? '' : 's'}.`);
1965
+ } else {
1966
+ toast.info('No configured providers detected in env or config.');
1967
+ }
1968
+ } catch (err) {
1969
+ toast.error(`Auto-detect failed: ${(err as Error).message}`);
1970
+ } finally {
1971
+ setLoading(false);
1972
+ }
1973
+ };
1974
+
1975
+ const statusIcon = (s: string) => {
1976
+ if (s === 'configured') return <ShieldCheck size={12} style={{ color: 'var(--success)' }} />;
1977
+ if (s === 'unknown') return <AlertTriangle size={12} style={{ color: 'var(--warning)' }} />;
1978
+ return <X size={12} style={{ color: 'var(--text-dim)' }} />;
1979
+ };
1980
+
1981
+ const configured = (results || []).filter((p) => p.status === 'configured');
1982
+ const other = (results || []).filter((p) => p.status !== 'configured');
1983
+
1984
+ return (
1985
+ <Card className="autodetect-banner">
1986
+ <div className="autodetect-head" onClick={() => setExpanded((v) => !v)}>
1987
+ <ShieldCheck size={14} />
1988
+ <span className="autodetect-title">Auto-detect providers</span>
1989
+ <span className="muted" style={{ fontSize: 11 }}>
1990
+ {results
1991
+ ? `${configured.length} configured · ${other.length} other`
1992
+ : 'scan env vars + opencode.json for known keys'}
1993
+ </span>
1994
+ <span className="autodetect-spacer" />
1995
+ <Button
1996
+ variant="ghost"
1997
+ size="sm"
1998
+ onClick={(e) => {
1999
+ e.stopPropagation();
2000
+ run(true);
2001
+ }}
2002
+ disabled={loading}
2003
+ title="Probe each provider's /models endpoint"
2004
+ >
2005
+ {loading ? <Spinner size="sm" /> : <RefreshCw size={12} />} Detect
2006
+ </Button>
2007
+ {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
2008
+ </div>
2009
+ {expanded && (
2010
+ <div className="autodetect-body">
2011
+ {!results ? (
2012
+ <div className="muted" style={{ padding: 12, fontSize: 12 }}>
2013
+ Click <strong>Detect</strong> to scan env vars and opencode.json for known provider keys.
2014
+ Probes each provider's <code>/models</code> endpoint with a 1.5s timeout.
2015
+ </div>
2016
+ ) : (
2017
+ <table className="autodetect-table">
2018
+ <thead>
2019
+ <tr>
2020
+ <th>Status</th>
2021
+ <th>Provider</th>
2022
+ <th>Source</th>
2023
+ <th>Probe</th>
2024
+ <th></th>
2025
+ </tr>
2026
+ </thead>
2027
+ <tbody>
2028
+ {results.map((r) => (
2029
+ <tr key={r.id}>
2030
+ <td>{statusIcon(r.status)} <span className="autodetect-status">{r.status}</span></td>
2031
+ <td><strong>{r.name}</strong> <span className="muted mono">{r.id}</span></td>
2032
+ <td className="muted">{r.keySource || '—'}</td>
2033
+ <td className="muted">
2034
+ {r.probed?.modelCount != null
2035
+ ? `${r.probed.modelCount} models`
2036
+ : r.probed?.reason
2037
+ ? r.probed.reason
2038
+ : r.probed?.ok
2039
+ ? 'ok'
2040
+ : '—'}
2041
+ </td>
2042
+ <td>
2043
+ {r.status === 'configured' && (
2044
+ <Button
2045
+ variant="ghost"
2046
+ size="sm"
2047
+ onClick={() => onAdd(r.id, r.name, r.baseURL || '')}
2048
+ title="Add this provider to opencode.json"
2049
+ >
2050
+ <Plus size={10} /> Add
2051
+ </Button>
2052
+ )}
2053
+ </td>
2054
+ </tr>
2055
+ ))}
2056
+ </tbody>
2057
+ </table>
2058
+ )}
2059
+ </div>
2060
+ )}
2061
+ </Card>
2062
+ );
2063
+ }