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