@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,1787 @@
1
+ // src/views/Settings.tsx — v3 settings: theme colors, UI layout, defaults, Tailscale, service.
2
+ import React, { useEffect, useState, useCallback } from 'react';
3
+ import {
4
+ Sliders,
5
+ Save,
6
+ RefreshCw,
7
+ Sun,
8
+ Moon,
9
+ Monitor,
10
+ Info,
11
+ Globe,
12
+ Palette,
13
+ Layout as LayoutIcon,
14
+ Server as ServerIcon,
15
+ RotateCcw,
16
+ Plug,
17
+ Download,
18
+ AlertTriangle,
19
+ QrCode,
20
+ Smartphone,
21
+ CheckCircle,
22
+ AlertCircle,
23
+ Shield,
24
+ Copy,
25
+ KeyRound,
26
+ Activity,
27
+ EyeOff,
28
+ Eye,
29
+ Trash2,
30
+ Search,
31
+ Sparkles,
32
+ } from 'lucide-react';
33
+ import { QRCodeSVG } from 'qrcode.react';
34
+ import { Button } from '../components/Button';
35
+ import { Card, CardTitle, CardMeta } from '../components/Card';
36
+ import { useToast } from '../components/Toast';
37
+ import { api } from '../lib/api';
38
+ import { cn } from '../lib/utils';
39
+ import { Ws } from '../lib/ws';
40
+ import {
41
+ applyTheme,
42
+ applyThemeTokens,
43
+ type Settings,
44
+ type SettingsResponse,
45
+ type Snapshot,
46
+ type ThemeName,
47
+ type TailscaleStatus,
48
+ } from '../lib/types';
49
+
50
+ type Props = {
51
+ snapshot: Snapshot;
52
+ settings: Settings;
53
+ activeTab: string;
54
+ setActiveTab: (id: string) => void;
55
+ refreshSnapshot: () => Promise<void>;
56
+ };
57
+
58
+ const THEMES: { id: ThemeName; label: string; Icon: typeof Sun }[] = [
59
+ { id: 'dark', label: 'Dark', Icon: Moon },
60
+ { id: 'light', label: 'Light', Icon: Sun },
61
+ { id: 'system', label: 'System', Icon: Monitor },
62
+ ];
63
+
64
+ // v3.3.1 — One-click accent color presets.
65
+ const PRESET_THEMES = [
66
+ { name: 'Purple', accent: '#8b5cf6' },
67
+ { name: 'Blue', accent: '#3b82f6' },
68
+ { name: 'Green', accent: '#10b981' },
69
+ { name: 'Orange', accent: '#f97316' },
70
+ { name: 'Red', accent: '#ef4444' },
71
+ { name: 'Pink', accent: '#ec4899' },
72
+ { name: 'Cyan', accent: '#06b6d4' },
73
+ { name: 'Mono', accent: '#6b7280' },
74
+ ];
75
+
76
+ const FONT_FAMILIES = [
77
+ 'Inter',
78
+ 'system-ui',
79
+ 'Segoe UI',
80
+ 'Roboto',
81
+ 'JetBrains Mono',
82
+ 'SF Mono',
83
+ 'Cascadia Code',
84
+ ];
85
+
86
+ // v3.5.2 — Pair-with-mobile companion card.
87
+ type PairSession = {
88
+ token: string;
89
+ qrPayload: string;
90
+ publicUrl: string;
91
+ expiresAt: number;
92
+ };
93
+
94
+ function formatCountdown(ms: number): string {
95
+ if (ms <= 0) return 'expired';
96
+ const s = Math.floor(ms / 1000);
97
+ const m = Math.floor(s / 60);
98
+ const r = s % 60;
99
+ return `${m}:${String(r).padStart(2, '0')}`;
100
+ }
101
+
102
+ function PairDeviceCard() {
103
+ const toast = useToast();
104
+ const [pair, setPair] = useState<PairSession | null>(null);
105
+ const [pairing, setPairing] = useState(false);
106
+ const [pairError, setPairError] = useState<string | null>(null);
107
+ const [now, setNow] = useState(Date.now());
108
+
109
+ const start = useCallback(async () => {
110
+ setPairing(true);
111
+ setPairError(null);
112
+ try {
113
+ const res = await api.post<PairSession>('/pair/start');
114
+ setPair(res);
115
+ } catch (err) {
116
+ setPairError((err as Error)?.message || 'Failed to start pairing');
117
+ toast.error('Pairing failed.');
118
+ } finally {
119
+ setPairing(false);
120
+ }
121
+ }, [toast]);
122
+
123
+ useEffect(() => {
124
+ if (!pair) return;
125
+ const t = setInterval(() => setNow(Date.now()), 1000);
126
+ return () => clearInterval(t);
127
+ }, [pair]);
128
+
129
+ const remaining = pair ? pair.expiresAt - now : 0;
130
+ const expired = pair != null && remaining <= 0;
131
+
132
+ return (
133
+ <Card id="settings-updates" data-section="updates">
134
+ <CardTitle>
135
+ <Smartphone size={14} /> Companion App
136
+ </CardTitle>
137
+ <CardMeta>
138
+ Scan the QR with <a href="https://github.com/DrB0rk/BizarHarness" target="_blank" rel="noopener noreferrer">Bizar Companion</a> to pair.
139
+ Tokens expire after 5 minutes.
140
+ </CardMeta>
141
+
142
+ <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
143
+ <div style={{ flex: '0 0 auto' }}>
144
+ {pair && !expired ? (
145
+ <div style={{ background: '#fff', padding: 12, borderRadius: 12 }}>
146
+ <QRCodeSVG value={pair.qrPayload} size={192} level="M" includeMargin={false} />
147
+ </div>
148
+ ) : (
149
+ <div
150
+ style={{
151
+ width: 216,
152
+ height: 216,
153
+ borderRadius: 12,
154
+ background: 'var(--surface-2, #161b22)',
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ color: 'var(--text-muted, #8b949e)',
159
+ fontSize: 12,
160
+ textAlign: 'center',
161
+ padding: 16,
162
+ border: '1px dashed var(--border, #30363d)',
163
+ }}
164
+ >
165
+ {pair && expired ? 'QR expired' : 'No QR generated yet'}
166
+ </div>
167
+ )}
168
+ </div>
169
+
170
+ <div style={{ flex: '1 1 240px', minWidth: 220 }}>
171
+ {!pair && (
172
+ <Button variant="primary" onClick={start} disabled={pairing}>
173
+ <QrCode size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
174
+ </Button>
175
+ )}
176
+
177
+ {pair && !expired && (
178
+ <>
179
+ <div style={{ marginBottom: 10 }}>
180
+ <strong>Expires in</strong> <span className="mono">{formatCountdown(remaining)}</span>
181
+ </div>
182
+ <div style={{ marginBottom: 6 }}>
183
+ <strong>URL:</strong> <span className="mono" style={{ wordBreak: 'break-all' }}>{pair.publicUrl}</span>
184
+ </div>
185
+ <div style={{ marginBottom: 12 }}>
186
+ <strong>Token:</strong>{' '}
187
+ <span className="mono" style={{ wordBreak: 'break-all', fontSize: 12 }}>
188
+ {pair.token.slice(0, 16)}…{pair.token.slice(-6)}
189
+ </span>
190
+ </div>
191
+ <Button variant="secondary" onClick={start} disabled={pairing}>
192
+ <RefreshCw size={14} /> Regenerate
193
+ </Button>
194
+ </>
195
+ )}
196
+
197
+ {pair && expired && (
198
+ <>
199
+ <div style={{ marginBottom: 12, color: 'var(--error, #f85149)' }}>
200
+ Token expired — generate a fresh QR to pair again.
201
+ </div>
202
+ <Button variant="primary" onClick={() => { setPair(null); start(); }}>
203
+ <RefreshCw size={14} /> Generate new QR
204
+ </Button>
205
+ </>
206
+ )}
207
+
208
+ {pairError && (
209
+ <div style={{ marginTop: 10, color: 'var(--error, #f85149)', fontSize: 12 }}>
210
+ {pairError}
211
+ </div>
212
+ )}
213
+ </div>
214
+ </div>
215
+ </Card>
216
+ );
217
+ }
218
+
219
+ const LAYOUTS = [
220
+ { id: 'topnav', label: 'Top nav' },
221
+ { id: 'sidebar', label: 'Sidebar' },
222
+ { id: 'both', label: 'Both' },
223
+ ] as const;
224
+
225
+ // v3.5.3 — Updates card with per-package selection, live progress, and auto-restart
226
+ type PkgStatus = {
227
+ status: 'idle' | 'starting' | 'installing' | 'done' | 'error';
228
+ error?: string;
229
+ newVersion?: string;
230
+ logs: string[];
231
+ };
232
+
233
+ function UpdatesCard() {
234
+ const toast = useToast();
235
+ const [status, setStatus] = useState<{
236
+ current: Record<string, string | null>;
237
+ latest: Record<string, string | null> | null;
238
+ checking: boolean;
239
+ updating: boolean;
240
+ hasUpdates: boolean;
241
+ requiresRestart: boolean;
242
+ perPackage: Record<string, PkgStatus>;
243
+ error?: string;
244
+ }>({
245
+ current: {},
246
+ latest: null,
247
+ checking: false,
248
+ updating: false,
249
+ hasUpdates: false,
250
+ requiresRestart: false,
251
+ perPackage: {},
252
+ });
253
+
254
+ // WebSocket subscription for live update progress (auto-reconnect via Ws class)
255
+ useEffect(() => {
256
+ const ws = new Ws();
257
+ ws.on((msg: Record<string, unknown>) => {
258
+ if (msg.type === 'update:progress' || msg.type === 'update:log' || msg.type === 'update:complete') {
259
+ setStatus((s) => {
260
+ if (msg.type === 'update:complete') {
261
+ return {
262
+ ...s,
263
+ updating: false,
264
+ requiresRestart: Boolean((msg as { requiresRestart?: boolean }).requiresRestart),
265
+ };
266
+ }
267
+ if (msg.type === 'update:log') {
268
+ const m = msg as { pkg: string; line: string };
269
+ const existing = s.perPackage[m.pkg] || { logs: [] };
270
+ return {
271
+ ...s,
272
+ perPackage: {
273
+ ...s.perPackage,
274
+ [m.pkg]: {
275
+ ...existing,
276
+ logs: [...(existing.logs || []).slice(-50), m.line],
277
+ },
278
+ },
279
+ };
280
+ }
281
+ // update:progress
282
+ const m = msg as { pkg: string; status: PkgStatus['status']; error?: string; newVersion?: string };
283
+ return {
284
+ ...s,
285
+ perPackage: {
286
+ ...s.perPackage,
287
+ [m.pkg]: {
288
+ ...s.perPackage[m.pkg],
289
+ status: m.status,
290
+ error: m.error,
291
+ newVersion: m.newVersion,
292
+ },
293
+ },
294
+ };
295
+ });
296
+ }
297
+ });
298
+ return () => ws.close();
299
+ }, []);
300
+
301
+ // Load current versions on mount
302
+ useEffect(() => {
303
+ api.get<{ current: Record<string, string | null> }>('/updates/status')
304
+ .then((r) => setStatus((s) => ({ ...s, current: r.current })))
305
+ .catch((e) => setStatus((s) => ({ ...s, error: e.message })));
306
+ }, []);
307
+
308
+ const check = async () => {
309
+ setStatus((s) => ({ ...s, checking: true, error: undefined }));
310
+ try {
311
+ const r = await api.get<{
312
+ current: Record<string, string | null>;
313
+ latest: Record<string, string | null>;
314
+ hasUpdates: boolean;
315
+ }>('/updates/check');
316
+ setStatus((s) => ({
317
+ ...s,
318
+ checking: false,
319
+ current: r.current,
320
+ latest: r.latest,
321
+ hasUpdates: r.hasUpdates,
322
+ }));
323
+ } catch (err) {
324
+ setStatus((s) => ({ ...s, checking: false, error: (err as Error).message }));
325
+ }
326
+ };
327
+
328
+ const applyUpdate = async () => {
329
+ if (!confirm('Update Bizar packages? The dashboard will restart automatically.')) return;
330
+ setStatus((s) => ({
331
+ ...s,
332
+ updating: true,
333
+ requiresRestart: false,
334
+ perPackage: {},
335
+ error: undefined,
336
+ }));
337
+ try {
338
+ await api.post('/updates/apply', { packages: ['bizar', 'bizar-dash', 'bizar-plugin'] });
339
+ // Progress streams via WebSocket
340
+ } catch (err) {
341
+ setStatus((s) => ({ ...s, updating: false, error: (err as Error).message }));
342
+ }
343
+ };
344
+
345
+ const restart = async () => {
346
+ if (!confirm('Restart the dashboard? You will be disconnected briefly.')) return;
347
+ try {
348
+ await api.post('/restart');
349
+ toast.info('Restarting…', 3000);
350
+ setTimeout(() => window.location.reload(), 3000);
351
+ } catch {
352
+ toast.error('Restart failed');
353
+ }
354
+ };
355
+
356
+ const packages = [
357
+ { id: 'bizar', name: 'Bizar CLI' },
358
+ { id: 'bizar-dash', name: 'Dashboard' },
359
+ { id: 'bizar-plugin', name: 'Opencode Plugin' },
360
+ ];
361
+
362
+ const isBusy = status.checking || status.updating;
363
+
364
+ return (
365
+ <Card id="settings-updates" data-section="updates">
366
+ <CardTitle><Download size={14} /> Updates</CardTitle>
367
+ <CardMeta>Check installed Bizar packages and apply dashboard updates.</CardMeta>
368
+ {/* Current versions */}
369
+ <div className="updates-current">
370
+ <h4>Installed versions</h4>
371
+ <ul>
372
+ {packages.map((p) => (
373
+ <li key={p.id}>
374
+ <span>{p.name}</span>
375
+ <code className="mono">{status.current[p.id] || '—'}</code>
376
+ </li>
377
+ ))}
378
+ </ul>
379
+ </div>
380
+
381
+ {/* Latest + per-package status */}
382
+ {status.latest && (
383
+ <div className="updates-latest">
384
+ <h4>Latest available</h4>
385
+ <ul>
386
+ {packages.map((p) => {
387
+ const cur = status.current[p.id];
388
+ const lat = status.latest?.[p.id];
389
+ const isOutdated = cur && lat && cur !== lat;
390
+ return (
391
+ <li key={p.id} className={isOutdated ? 'updates-outdated' : 'updates-current-version'}>
392
+ <span>{p.name}</span>
393
+ <code className="mono">
394
+ {lat || '—'}
395
+ {isOutdated && <span className="updates-badge">update available</span>}
396
+ </code>
397
+ </li>
398
+ );
399
+ })}
400
+ </ul>
401
+ </div>
402
+ )}
403
+
404
+ {/* Per-package progress rows (shown while updating) */}
405
+ {status.updating && (
406
+ <div className="updates-progress-rows">
407
+ {packages.map((p) => {
408
+ const pkgStatus = status.perPackage[p.id] || { status: 'idle', logs: [] as string[] };
409
+ return (
410
+ <div key={p.id} className="updates-pkg-row">
411
+ <div className="updates-pkg-row-header">
412
+ <span className="updates-pkg-name">{p.name}</span>
413
+ <div className="updates-pkg-status">
414
+ {pkgStatus.status === 'starting' && <span className="btn-spinner" />}
415
+ {pkgStatus.status === 'installing' && <span className="btn-spinner" />}
416
+ {pkgStatus.status === 'done' && <CheckCircle size={14} className="icon-success" />}
417
+ {pkgStatus.status === 'error' && <AlertCircle size={14} className="icon-error" />}
418
+ <span>{pkgStatus.status}</span>
419
+ {pkgStatus.newVersion && (
420
+ <code className="mono" style={{ fontSize: 11 }}>→ {pkgStatus.newVersion}</code>
421
+ )}
422
+ </div>
423
+ </div>
424
+ {pkgStatus.logs.length > 0 && (
425
+ <details className="updates-pkg-logs">
426
+ <summary>npm output ({pkgStatus.logs.length} lines)</summary>
427
+ <pre>{pkgStatus.logs.join('\n')}</pre>
428
+ </details>
429
+ )}
430
+ {pkgStatus.status === 'error' && pkgStatus.error && (
431
+ <div className="updates-pkg-error">
432
+ <AlertTriangle size={12} /> {pkgStatus.error}
433
+ </div>
434
+ )}
435
+ </div>
436
+ );
437
+ })}
438
+ </div>
439
+ )}
440
+
441
+ {/* Actions */}
442
+ <div className="updates-actions">
443
+ <Button onClick={check} disabled={isBusy}>
444
+ {status.checking ? <span className="btn-spinner" /> : <RefreshCw size={14} />}
445
+ Check for updates
446
+ </Button>
447
+ <Button
448
+ variant="primary"
449
+ onClick={applyUpdate}
450
+ disabled={!status.hasUpdates || status.updating}
451
+ >
452
+ {status.updating ? <span className="btn-spinner" /> : <Download size={14} />}
453
+ {status.updating ? 'Updating…' : (status.hasUpdates ? 'Update now' : 'Up to date')}
454
+ </Button>
455
+ {status.requiresRestart && (
456
+ <Button variant="danger" onClick={restart}>
457
+ <RefreshCw size={14} /> Restart Dashboard
458
+ </Button>
459
+ )}
460
+ </div>
461
+
462
+ {status.error && (
463
+ <div className="updates-error">
464
+ <AlertTriangle size={14} />
465
+ <span>{status.error}</span>
466
+ </div>
467
+ )}
468
+ </Card>
469
+ );
470
+ }
471
+
472
+ export function SettingsView({ settings: initial, refreshSnapshot }: Props) {
473
+ const toast = useToast();
474
+ const [settings, setSettings] = useState<Settings>(initial);
475
+ const [dirty, setDirty] = useState(false);
476
+ const [saving, setSaving] = useState(false);
477
+ const [tailscale, setTailscale] = useState<TailscaleStatus | null>(null);
478
+ const [tailscaleDraft, setTailscaleDraft] = useState({ port: 4321, https: true, hostname: '' });
479
+ const [pluginOptions, setPluginOptions] = useState<Record<string, number>>({});
480
+
481
+ useEffect(() => {
482
+ setSettings(initial);
483
+ setDirty(false);
484
+ if (initial.theme) applyThemeTokens(initial.theme);
485
+ }, [initial]);
486
+
487
+ useEffect(() => {
488
+ if (!tailscale) return;
489
+ setTailscaleDraft({
490
+ port: tailscale.settings.port,
491
+ https: tailscale.settings.https !== false,
492
+ hostname: tailscale.settings.hostname || '',
493
+ });
494
+ }, [tailscale]);
495
+
496
+ useEffect(() => {
497
+ api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined);
498
+ }, []);
499
+
500
+ useEffect(() => {
501
+ api.get<Record<string, number>>('/settings/plugin-options')
502
+ .then(setPluginOptions)
503
+ .catch(() => { /* not persisted yet — use defaults */ });
504
+ }, []);
505
+
506
+ const patchTheme = (patch: Partial<Settings['theme']>) => {
507
+ setSettings((cur) => {
508
+ const next = { ...cur, theme: { ...cur.theme, ...patch } };
509
+ applyThemeTokens(next.theme);
510
+ return next;
511
+ });
512
+ setDirty(true);
513
+ };
514
+
515
+ const patchUi = (patch: Partial<Settings['ui']>) => {
516
+ setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } }));
517
+ setDirty(true);
518
+ };
519
+
520
+ const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => {
521
+ setSettings((cur) => ({ ...cur, [key]: value }));
522
+ setDirty(true);
523
+ };
524
+
525
+ const patchNotifications = (patch: Partial<Settings['notifications']>) => {
526
+ setSettings((cur) => ({ ...cur, notifications: { ...cur.notifications, ...patch } }));
527
+ setDirty(true);
528
+ };
529
+
530
+ const patchAgents = (patch: Partial<Settings['agents']>) => {
531
+ setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } }));
532
+ setDirty(true);
533
+ };
534
+
535
+ // v3.17.0 — Settings subnav as a filter (not just scroll-to).
536
+ // Clicking a subnav button shows ONLY that section; clicking "All"
537
+ // restores the full layout. Anchors still work for deep-links but
538
+ // the visible content is filtered, not just scrolled.
539
+ //
540
+ // v3.18.0 — IDs use the section NAME (not the `settings-X` element id)
541
+ // because the inline-style wrap compares against the section name, not
542
+ // the prefixed DOM id.
543
+ const SECTION_LINKS: Array<{ id: string; label: string }> = [
544
+ { id: 'theme', label: 'Theme' },
545
+ { id: 'layout', label: 'Layout' },
546
+ { id: 'general', label: 'General' },
547
+ { id: 'service', label: 'Service' },
548
+ { id: 'tailscale', label: 'Tailscale' },
549
+ { id: 'notifications', label: 'Notifications' },
550
+ { id: 'auth', label: 'Auth' },
551
+ { id: 'agents', label: 'Agents' },
552
+ { id: 'dashboard', label: 'Dashboard' },
553
+ { id: 'background', label: 'Background' },
554
+ { id: 'system-llm', label: 'System LLM' },
555
+ { id: 'updates', label: 'Updates' },
556
+ { id: 'activity-log', label: 'Activity' },
557
+ { id: 'about', label: 'About' },
558
+ ];
559
+ const [activeSection, setActiveSection] = useState<string | null>(() => {
560
+ // Honour #settings-... deep-link on initial mount, otherwise null = All.
561
+ if (typeof window === 'undefined') return null;
562
+ const hash = window.location.hash.replace(/^#settings-/, '');
563
+ return SECTION_LINKS.some((s) => s.id === hash) ? hash : null;
564
+ });
565
+ const onJumpSection = (id: string | null) => {
566
+ setActiveSection(id);
567
+ try {
568
+ const url = id ? `#settings-${id}` : window.location.pathname;
569
+ history.replaceState(null, '', url);
570
+ if (id) {
571
+ const el = document.getElementById(`settings-${id}`);
572
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
573
+ } else {
574
+ window.scrollTo({ top: 0, behavior: 'smooth' });
575
+ }
576
+ } catch { /* ignore */ }
577
+ };
578
+ // The user picks a section via the subnav; their choice is respected.
579
+ // (v3.18.0 — Removed auto-scroll-based detection; with the inline-style
580
+ // filter approach, hidden sections still report bounding rects, so the
581
+ // detector would race the user's explicit selection and switch tabs back.)
582
+
583
+ const patchDashboard = (patch: Partial<Settings['dashboard']>) => {
584
+ setSettings((cur) => ({ ...cur, dashboard: { ...cur.dashboard, ...patch } }));
585
+ setDirty(true);
586
+ };
587
+
588
+ const onSave = async () => {
589
+ setSaving(true);
590
+ try {
591
+ const r = await api.put<SettingsResponse>('/settings', settings);
592
+ setSettings(r.data);
593
+ setDirty(false);
594
+ applyTheme(r.data.theme);
595
+ applyThemeTokens(r.data.theme);
596
+ toast.success('Settings saved.');
597
+ await refreshSnapshot();
598
+ } catch (err) {
599
+ toast.error(`Save failed: ${(err as Error).message}`);
600
+ } finally {
601
+ setSaving(false);
602
+ }
603
+ };
604
+
605
+ const onReload = async () => {
606
+ try {
607
+ const r = await api.get<SettingsResponse>('/settings');
608
+ setSettings(r.data);
609
+ setDirty(false);
610
+ applyTheme(r.data.theme);
611
+ applyThemeTokens(r.data.theme);
612
+ toast.info('Settings reloaded.', 1500);
613
+ } catch (err) {
614
+ toast.error(`Reload failed: ${(err as Error).message}`);
615
+ }
616
+ };
617
+
618
+ const onReset = async () => {
619
+ if (!confirm('Reset all settings to defaults?')) return;
620
+ try {
621
+ const r = await api.post<SettingsResponse>('/settings/reset');
622
+ setSettings(r.data);
623
+ setDirty(false);
624
+ applyTheme(r.data.theme);
625
+ applyThemeTokens(r.data.theme);
626
+ toast.success('Settings reset.');
627
+ await refreshSnapshot();
628
+ } catch (err) {
629
+ toast.error(`Reset failed: ${(err as Error).message}`);
630
+ }
631
+ };
632
+
633
+ const onTailscaleToggle = async () => {
634
+ try {
635
+ if (tailscale?.settings.enabled) {
636
+ await api.post('/tailscale/disable');
637
+ toast.success('Tailscale serve disabled.');
638
+ } else {
639
+ await api.post('/tailscale/enable', {
640
+ port: tailscaleDraft.port || 4321,
641
+ https: tailscaleDraft.https,
642
+ hostname: tailscaleDraft.hostname || '',
643
+ });
644
+ toast.success('Tailscale serve enabled.');
645
+ }
646
+ const r = await api.get<TailscaleStatus>('/tailscale/status');
647
+ setTailscale(r);
648
+ } catch (err) {
649
+ toast.error(`Tailscale failed: ${(err as Error).message}`);
650
+ }
651
+ };
652
+
653
+ const about = settings.about || {
654
+ version: '3.0.4',
655
+ homepage: 'https://github.com/DrB0rk/BizarHarness',
656
+ license: 'MIT',
657
+ };
658
+
659
+ // v3.6.0 — Auth token UI state. The Settings tab is the only place
660
+ // the operator can read the token back out. We default the input
661
+ // to whatever's already in localStorage so a page reload doesn't
662
+ // wipe the entry.
663
+ const [authToken, setAuthToken] = useState<string>(api.getToken());
664
+ const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
665
+ const [revealedToken, setRevealedToken] = useState<string>('');
666
+
667
+ // Probe the server once on mount so we can show "auth required" vs
668
+ // "auth off" in the UI. If /api/auth/status itself 401s, the token
669
+ // we're using is bad — surface that distinctly.
670
+ useEffect(() => {
671
+ let cancelled = false;
672
+ (async () => {
673
+ try {
674
+ const r = await api.probeAuthStatus();
675
+ if (!cancelled) setAuthStatus(r);
676
+ } catch {
677
+ if (!cancelled) {
678
+ setAuthStatus({ required: true, loopback: false, peer: '' });
679
+ // Don't toast — the toast spam would be annoying on every
680
+ // Settings tab open. The Copy/Regenerate buttons themselves
681
+ // surface the real error if it happens there.
682
+ }
683
+ }
684
+ })();
685
+ return () => { cancelled = true; };
686
+ }, []);
687
+
688
+ const onCopyToken = async () => {
689
+ try {
690
+ const r = await api.get<{ token: string }>('/auth/reveal');
691
+ setRevealedToken(r.token);
692
+ setAuthToken(r.token);
693
+ api.setToken(r.token);
694
+ try {
695
+ await navigator.clipboard.writeText(r.token);
696
+ toast.success('Token copied to clipboard.');
697
+ } catch {
698
+ toast.success('Token revealed — copy from the field below.');
699
+ }
700
+ } catch (err) {
701
+ toast.error(`Reveal failed: ${(err as Error).message}`);
702
+ }
703
+ };
704
+
705
+ const onRegenerateToken = async () => {
706
+ if (!confirm('Regenerate the auth token? Anything still using the old token will start getting 401 errors immediately.')) {
707
+ return;
708
+ }
709
+ try {
710
+ const r = await api.post<{ token: string }>('/auth/regenerate');
711
+ setRevealedToken(r.token);
712
+ setAuthToken(r.token);
713
+ api.setToken(r.token);
714
+ try {
715
+ await navigator.clipboard.writeText(r.token);
716
+ toast.success('New token generated and copied to clipboard.');
717
+ } catch {
718
+ toast.success('New token generated — copy from the field below. Old token is now invalid.');
719
+ }
720
+ } catch (err) {
721
+ toast.error(`Regenerate failed: ${(err as Error).message}`);
722
+ }
723
+ };
724
+
725
+ const onSaveToken = () => {
726
+ api.setToken(authToken.trim());
727
+ toast.success('Token saved. The dashboard will use it on the next request.');
728
+ };
729
+
730
+ return (
731
+ <div className="view view-settings">
732
+ <header className="view-header">
733
+ <div className="view-header-text">
734
+ <h2 className="view-title">
735
+ <Sliders size={18} /> Settings
736
+ </h2>
737
+ <p className="view-subtitle">
738
+ Personal preferences. Changes are saved to{' '}
739
+ <code>~/.config/bizar/settings.json</code>.
740
+ </p>
741
+ </div>
742
+ <div className="view-actions">
743
+ <Button variant="ghost" size="sm" onClick={onReset}>
744
+ <RotateCcw size={14} /> Reset
745
+ </Button>
746
+ <Button variant="secondary" size="sm" onClick={onReload}>
747
+ <RefreshCw size={14} /> Reload
748
+ </Button>
749
+ <Button
750
+ variant="primary"
751
+ size="sm"
752
+ disabled={!dirty || saving}
753
+ onClick={onSave}
754
+ >
755
+ {saving ? <span className="btn-spinner" /> : <Save size={14} />}
756
+ {saving ? 'Saving…' : 'Save'}
757
+ </Button>
758
+ </div>
759
+ </header>
760
+
761
+ <nav className="settings-subnav" aria-label="Settings sections">
762
+ <button
763
+ type="button"
764
+ className={cn('settings-subnav-button', 'settings-subnav-button-all', activeSection === null && 'settings-subnav-button-active')}
765
+ onClick={() => onJumpSection(null)}
766
+ title="Show all settings sections"
767
+ >
768
+ All
769
+ </button>
770
+ {SECTION_LINKS.map((s) => (
771
+ <button
772
+ key={s.id}
773
+ type="button"
774
+ className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')}
775
+ onClick={() => onJumpSection(s.id)}
776
+ >
777
+ {s.label}
778
+ </button>
779
+ ))}
780
+ </nav>
781
+
782
+ <div
783
+ className={cn('settings-grid', activeSection && 'settings-grid-filtered')}
784
+ data-active-section={activeSection || undefined}
785
+ >
786
+ {/* When a section is active, render a banner above the grid so
787
+ the user knows they're seeing a filtered view. */}
788
+ {activeSection && (
789
+ <div className="settings-filter-banner">
790
+ <span>
791
+ Showing only <strong>{SECTION_LINKS.find((s) => s.id === activeSection)?.label}</strong>.
792
+ </span>
793
+ <button
794
+ type="button"
795
+ className="settings-filter-clear"
796
+ onClick={() => onJumpSection(null)}
797
+ >
798
+ Show all sections
799
+ </button>
800
+ </div>
801
+ )}
802
+ <div data-section="theme" style={{display: (activeSection === null || activeSection === 'theme') ? 'block' : 'none'}}>
803
+ <Card id="settings-theme" data-section="theme">
804
+ <CardTitle><Palette size={14} /> Theme</CardTitle>
805
+ <CardMeta>Mode, accent, and colors. Live preview as you tweak.</CardMeta>
806
+
807
+ {/* v3.3.1 — Accent color presets */}
808
+ <div className="field" data-setting-id="theme.presets">
809
+ <label className="field-label">Accent presets</label>
810
+ <div className="theme-presets">
811
+ {PRESET_THEMES.map((t) => (
812
+ <button
813
+ key={t.name}
814
+ type="button"
815
+ className={cn(
816
+ 'theme-preset',
817
+ settings.theme.accent === t.accent && 'theme-preset-active',
818
+ )}
819
+ onClick={() => patchTheme({ accent: t.accent })}
820
+ title={t.name}
821
+ >
822
+ <span
823
+ className="theme-preset-swatch"
824
+ style={{ background: t.accent }}
825
+ />
826
+ <span className="theme-preset-name">{t.name}</span>
827
+ </button>
828
+ ))}
829
+ </div>
830
+ </div>
831
+
832
+ <div className="field" data-setting-id="theme.mode">
833
+ <label className="field-label">Mode</label>
834
+ <div className="theme-row">
835
+ {THEMES.map(({ id, label, Icon }) => {
836
+ const active = settings.theme.mode === id;
837
+ return (
838
+ <button
839
+ key={id}
840
+ type="button"
841
+ className={cn('theme-card', active && 'theme-card-active')}
842
+ onClick={() => patchTheme({ mode: id })}
843
+ >
844
+ <Icon size={16} />
845
+ <span className="theme-card-label">{label}</span>
846
+ <span
847
+ className={cn(
848
+ 'theme-card-swatch',
849
+ `theme-card-swatch-${id}`,
850
+ )}
851
+ />
852
+ </button>
853
+ );
854
+ })}
855
+ </div>
856
+ </div>
857
+
858
+ <div className="theme-colors">
859
+ <div className="field" data-setting-id="theme.accent">
860
+ <label className="field-label">Accent</label>
861
+ <div className="color-row">
862
+ <input
863
+ type="color"
864
+ className="input color-input"
865
+ value={settings.theme.accent}
866
+ onChange={(e) => patchTheme({ accent: e.target.value })}
867
+ aria-label="Accent color"
868
+ />
869
+ <input
870
+ type="text"
871
+ className="input"
872
+ value={settings.theme.accent}
873
+ onChange={(e) => patchTheme({ accent: e.target.value })}
874
+ />
875
+ </div>
876
+ </div>
877
+ <div className="field" data-setting-id="theme.success">
878
+ <label className="field-label">Success</label>
879
+ <div className="color-row">
880
+ <input
881
+ type="color"
882
+ className="input color-input"
883
+ value={settings.theme.success}
884
+ onChange={(e) => patchTheme({ success: e.target.value })}
885
+ aria-label="Success color"
886
+ />
887
+ <input
888
+ type="text"
889
+ className="input"
890
+ value={settings.theme.success}
891
+ onChange={(e) => patchTheme({ success: e.target.value })}
892
+ />
893
+ </div>
894
+ </div>
895
+ <div className="field" data-setting-id="theme.warning">
896
+ <label className="field-label">Warning</label>
897
+ <div className="color-row">
898
+ <input
899
+ type="color"
900
+ className="input color-input"
901
+ value={settings.theme.warning}
902
+ onChange={(e) => patchTheme({ warning: e.target.value })}
903
+ aria-label="Warning color"
904
+ />
905
+ <input
906
+ type="text"
907
+ className="input"
908
+ value={settings.theme.warning}
909
+ onChange={(e) => patchTheme({ warning: e.target.value })}
910
+ />
911
+ </div>
912
+ </div>
913
+ <div className="field" data-setting-id="theme.error">
914
+ <label className="field-label">Error</label>
915
+ <div className="color-row">
916
+ <input
917
+ type="color"
918
+ className="input color-input"
919
+ value={settings.theme.error}
920
+ onChange={(e) => patchTheme({ error: e.target.value })}
921
+ aria-label="Error color"
922
+ />
923
+ <input
924
+ type="text"
925
+ className="input"
926
+ value={settings.theme.error}
927
+ onChange={(e) => patchTheme({ error: e.target.value })}
928
+ />
929
+ </div>
930
+ </div>
931
+ <div className="field" data-setting-id="theme.info">
932
+ <label className="field-label">Info</label>
933
+ <div className="color-row">
934
+ <input
935
+ type="color"
936
+ className="input color-input"
937
+ value={settings.theme.info}
938
+ onChange={(e) => patchTheme({ info: e.target.value })}
939
+ aria-label="Info color"
940
+ />
941
+ <input
942
+ type="text"
943
+ className="input"
944
+ value={settings.theme.info}
945
+ onChange={(e) => patchTheme({ info: e.target.value })}
946
+ />
947
+ </div>
948
+ </div>
949
+ </div>
950
+
951
+ <div className="field" data-setting-id="theme.fontFamily">
952
+ <label className="field-label">Font family</label>
953
+ <select
954
+ className="select"
955
+ value={settings.theme.fontFamily}
956
+ onChange={(e) => patchTheme({ fontFamily: e.target.value })}
957
+ >
958
+ {FONT_FAMILIES.map((f) => (
959
+ <option key={f} value={f}>{f}</option>
960
+ ))}
961
+ </select>
962
+ </div>
963
+ <div className="field" data-setting-id="theme.fontSize">
964
+ <label className="field-label">Font size: {settings.theme.fontSize}px</label>
965
+ <input
966
+ type="range"
967
+ min={12}
968
+ max={20}
969
+ value={settings.theme.fontSize}
970
+ onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
971
+ />
972
+ </div>
973
+ <label className="checkbox-row" data-setting-id="theme.compactMode">
974
+ <input
975
+ type="checkbox"
976
+ checked={settings.theme.compactMode}
977
+ onChange={(e) => patchTheme({ compactMode: e.target.checked })}
978
+ />
979
+ <span>Compact mode (denser UI)</span>
980
+ </label>
981
+ <label className="checkbox-row" data-setting-id="theme.animations">
982
+ <input
983
+ type="checkbox"
984
+ checked={settings.theme.animations}
985
+ onChange={(e) => patchTheme({ animations: e.target.checked })}
986
+ />
987
+ <span>Enable animations</span>
988
+ </label>
989
+ </Card>
990
+ </div>
991
+
992
+ <div data-section="updates" style={{display: (activeSection === null || activeSection === 'updates') ? 'block' : 'none'}}>
993
+ <UpdatesCard />
994
+ </div>
995
+
996
+ <div data-section="layout" style={{display: (activeSection === null || activeSection === 'layout') ? 'block' : 'none'}}>
997
+ <Card id="settings-layout" data-section="layout">
998
+ <CardTitle><LayoutIcon size={14} /> UI layout</CardTitle>
999
+ <CardMeta>Choose how the dashboard's navigation is presented.</CardMeta>
1000
+ <div className="layout-row" data-setting-id="ui.layout">
1001
+ {LAYOUTS.map((l) => (
1002
+ <button
1003
+ key={l.id}
1004
+ type="button"
1005
+ className={cn('layout-card', settings.ui.layout === l.id && 'layout-card-active')}
1006
+ onClick={() => patchUi({ layout: l.id })}
1007
+ >
1008
+ <span className="layout-card-label">{l.label}</span>
1009
+ </button>
1010
+ ))}
1011
+ </div>
1012
+ <label className="checkbox-row" data-setting-id="ui.showHeader">
1013
+ <input
1014
+ type="checkbox"
1015
+ checked={settings.ui.showHeader}
1016
+ onChange={(e) => patchUi({ showHeader: e.target.checked })}
1017
+ />
1018
+ <span>Show header</span>
1019
+ </label>
1020
+ <label className="checkbox-row" data-setting-id="ui.showStatusBar">
1021
+ <input
1022
+ type="checkbox"
1023
+ checked={settings.ui.showStatusBar}
1024
+ onChange={(e) => patchUi({ showStatusBar: e.target.checked })}
1025
+ />
1026
+ <span>Show status bar</span>
1027
+ </label>
1028
+ <div className="field" data-setting-id="ui.defaultTab">
1029
+ <label className="field-label">Default tab</label>
1030
+ <select
1031
+ className="select"
1032
+ value={settings.ui.defaultTab}
1033
+ onChange={(e) => patchUi({ defaultTab: e.target.value })}
1034
+ >
1035
+ <option value="overview">Overview</option>
1036
+ <option value="chat">Chat</option>
1037
+ <option value="agents">Agents</option>
1038
+ <option value="artifacts">Plans</option>
1039
+ <option value="projects">Projects</option>
1040
+ <option value="tasks">Tasks</option>
1041
+ <option value="config">Config</option>
1042
+ <option value="settings">Settings</option>
1043
+ <option value="mods">Mods</option>
1044
+ <option value="schedules">Schedules</option>
1045
+ </select>
1046
+ </div>
1047
+ </Card>
1048
+ </div>
1049
+
1050
+ <div data-section="general" style={{display: (activeSection === null || activeSection === 'general') ? 'block' : 'none'}}>
1051
+ <Card id="settings-general" data-section="general">
1052
+ <CardTitle>General</CardTitle>
1053
+ <CardMeta>Default agent + model override.</CardMeta>
1054
+ <div className="field" data-setting-id="defaultAgent">
1055
+ <label className="field-label" htmlFor="set-default-agent">Default agent</label>
1056
+ <input
1057
+ id="set-default-agent"
1058
+ className="input"
1059
+ type="text"
1060
+ placeholder="e.g. odin"
1061
+ value={settings.defaultAgent || ''}
1062
+ onChange={(e) => patchTop('defaultAgent', e.target.value)}
1063
+ />
1064
+ </div>
1065
+ <div className="field" data-setting-id="defaultModel">
1066
+ <label className="field-label" htmlFor="set-default-model">Model override</label>
1067
+ <input
1068
+ id="set-default-model"
1069
+ className="input"
1070
+ type="text"
1071
+ placeholder="(leave empty for provider default)"
1072
+ value={settings.defaultModel || ''}
1073
+ onChange={(e) => patchTop('defaultModel', e.target.value)}
1074
+ />
1075
+ </div>
1076
+ </Card>
1077
+ </div>
1078
+
1079
+ <div data-section="service" style={{display: (activeSection === null || activeSection === 'service') ? 'block' : 'none'}}>
1080
+ <Card id="settings-service" data-section="service">
1081
+ <CardTitle><ServerIcon size={14} /> Service</CardTitle>
1082
+ <CardMeta>Background daemon that runs schedules.</CardMeta>
1083
+ <div data-setting-id="service.enabled">
1084
+ {tailscale ? (
1085
+ <div className="service-card">
1086
+ <p>
1087
+ Status: <strong>{tailscale?.settings.enabled ? 'enabled' : 'disabled'}</strong>
1088
+ {' '}· Tailscale installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>
1089
+ {' '}· authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
1090
+ </p>
1091
+ <p className="muted">
1092
+ Use <code>bizar service start</code> / <code>bizar service stop</code> in
1093
+ your terminal to control the daemon.
1094
+ </p>
1095
+ </div>
1096
+ ) : (
1097
+ <p className="muted">Loading service status…</p>
1098
+ )}
1099
+ </div>
1100
+ </Card>
1101
+ </div>
1102
+
1103
+ <div data-section="tailscale" style={{display: (activeSection === null || activeSection === 'tailscale') ? 'block' : 'none'}}>
1104
+ <Card id="settings-tailscale" data-section="tailscale">
1105
+ <CardTitle><Plug size={14} /> Tailscale serve</CardTitle>
1106
+ <CardMeta>Expose the dashboard over your Tailscale network.</CardMeta>
1107
+ {tailscale ? (
1108
+ <>
1109
+ <p>
1110
+ Installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>{' '}
1111
+ {tailscale.version && <span className="muted">({tailscale.version})</span>}
1112
+ </p>
1113
+ <p>
1114
+ Authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
1115
+ </p>
1116
+ <p>
1117
+ Serve enabled: <strong>{tailscale.settings.enabled ? 'yes' : 'no'}</strong>
1118
+ </p>
1119
+ <div className="task-form-row">
1120
+ <div className="task-form-field">
1121
+ <label className="field-label">Port</label>
1122
+ <input
1123
+ type="number"
1124
+ className="input"
1125
+ value={tailscaleDraft.port}
1126
+ onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, port: Number(e.target.value) || 4321 }))}
1127
+ />
1128
+ </div>
1129
+ <div className="task-form-field">
1130
+ <label className="field-label">Use HTTPS</label>
1131
+ <input
1132
+ type="checkbox"
1133
+ checked={tailscaleDraft.https}
1134
+ onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
1135
+ />
1136
+ </div>
1137
+ </div>
1138
+ <Button variant="primary" onClick={onTailscaleToggle}>
1139
+ {tailscale.settings.enabled ? 'Disable serve' : 'Enable serve'}
1140
+ </Button>
1141
+ </>
1142
+ ) : (
1143
+ <p className="muted">Loading Tailscale status…</p>
1144
+ )}
1145
+ </Card>
1146
+ </div>
1147
+
1148
+ <div data-section="notifications" style={{display: (activeSection === null || activeSection === 'notifications') ? 'block' : 'none'}}>
1149
+ <Card id="settings-notifications" data-section="notifications">
1150
+ <CardTitle>Notifications</CardTitle>
1151
+ <CardMeta>Toast triggers inside the dashboard.</CardMeta>
1152
+ <label className="checkbox-row" data-setting-id="notifications.onAgentComplete">
1153
+ <input
1154
+ type="checkbox"
1155
+ checked={!!settings.notifications.onAgentComplete}
1156
+ onChange={(e) => patchNotifications({ onAgentComplete: e.target.checked })}
1157
+ />
1158
+ <span>Notify when an agent invocation completes</span>
1159
+ </label>
1160
+ <label className="checkbox-row" data-setting-id="notifications.onPlanApproval">
1161
+ <input
1162
+ type="checkbox"
1163
+ checked={!!settings.notifications.onPlanApproval}
1164
+ onChange={(e) => patchNotifications({ onPlanApproval: e.target.checked })}
1165
+ />
1166
+ <span>Notify when a plan needs approval</span>
1167
+ </label>
1168
+ </Card>
1169
+ </div>
1170
+
1171
+ {/* v3.6.0 — Auth token management. The Settings tab is the
1172
+ only place a human can read the token. The input + Save
1173
+ button lets the operator paste a token they got from
1174
+ server stderr or from another machine. Copy / Regenerate
1175
+ buttons act via the authed /api/auth/* endpoints. */}
1176
+ <div data-section="auth" style={{display: (activeSection === null || activeSection === 'auth') ? 'block' : 'none'}}>
1177
+ <Card id="settings-auth" data-section="auth">
1178
+ <CardTitle><Shield size={14} /> Authentication</CardTitle>
1179
+ <CardMeta>
1180
+ Localhost and Tailscale browser access are auto-trusted via loopback.
1181
+ A bearer token is still available for non-loopback clients and
1182
+ forced-auth mode.
1183
+ </CardMeta>
1184
+ <div className="field" data-setting-id="auth.status">
1185
+ <label className="field-label">Server status</label>
1186
+ <p style={{ margin: '4px 0' }}>
1187
+ Auth required:{' '}
1188
+ <strong>{authStatus ? (authStatus.required ? 'yes' : 'no') : 'probing…'}</strong>
1189
+ </p>
1190
+ <p style={{ margin: '4px 0' }}>
1191
+ Connection:{' '}
1192
+ <strong>
1193
+ {authStatus
1194
+ ? (authStatus.loopback ? 'loopback (auto-trusted)' : 'remote')
1195
+ : 'probing…'}
1196
+ </strong>
1197
+ </p>
1198
+ <p style={{ margin: '4px 0' }}>
1199
+ Peer address:{' '}
1200
+ {authStatus?.peer ? <code>{authStatus.peer}</code> : <span className="muted">probing…</span>}
1201
+ </p>
1202
+ <p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
1203
+ Localhost and Tailscale browser access are auto-trusted because the
1204
+ dashboard sees a loopback peer. Paste a token only for non-loopback
1205
+ API clients/scripts, or if you force auth for every connection with{' '}
1206
+ <code>BIZAR_DASHBOARD_REQUIRE_AUTH=1</code>.
1207
+ </p>
1208
+ <p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
1209
+ Dashboard tokens are generated on first boot and saved to{' '}
1210
+ <code>~/.config/bizar/dashboard-secret</code> (mode 0600).
1211
+ </p>
1212
+ </div>
1213
+ <div className="field" data-setting-id="auth.token">
1214
+ <label className="field-label">Token (this browser)</label>
1215
+ <input
1216
+ type="password"
1217
+ className="input mono"
1218
+ value={authToken}
1219
+ onChange={(e) => setAuthToken(e.target.value)}
1220
+ placeholder="Paste token from server stderr or another browser"
1221
+ spellCheck={false}
1222
+ autoComplete="off"
1223
+ />
1224
+ <div className="task-form-row" style={{ marginTop: 8 }}>
1225
+ <Button variant="secondary" size="sm" onClick={onSaveToken}>
1226
+ <KeyRound size={14} /> Save token
1227
+ </Button>
1228
+ <Button variant="ghost" size="sm" onClick={onCopyToken}>
1229
+ <Copy size={14} /> Reveal &amp; copy server token
1230
+ </Button>
1231
+ <Button variant="ghost" size="sm" onClick={onRegenerateToken}>
1232
+ <RotateCcw size={14} /> Regenerate
1233
+ </Button>
1234
+ </div>
1235
+ {revealedToken ? (
1236
+ <p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
1237
+ Last revealed token (one-time): <code className="mono">{revealedToken}</code>
1238
+ </p>
1239
+ ) : null}
1240
+ <p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
1241
+ Regenerating invalidates the current token immediately. Anything
1242
+ still using the old token will see 401 until it's updated.
1243
+ </p>
1244
+ </div>
1245
+ </Card>
1246
+ </div>
1247
+
1248
+ <div data-section="agents" style={{display: (activeSection === null || activeSection === 'agents') ? 'block' : 'none'}}>
1249
+ <Card id="settings-agents" data-section="agents">
1250
+ <CardTitle><ServerIcon size={14} /> Agent Behavior</CardTitle>
1251
+ <CardMeta>Limits and timeouts for background agent dispatch.</CardMeta>
1252
+ <div className="form-row">
1253
+ <label htmlFor="agents-maxParallel">
1254
+ Max parallel agents
1255
+ <span className="meta-badge">default: 6</span>
1256
+ </label>
1257
+ <input
1258
+ id="agents-maxParallel"
1259
+ type="number"
1260
+ min={1}
1261
+ max={20}
1262
+ value={settings.agents?.maxParallel ?? 6}
1263
+ onChange={(e) => patchAgents({ maxParallel: Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 6)) })}
1264
+ />
1265
+ </div>
1266
+ <div className="form-row">
1267
+ <label htmlFor="agents-stuckThresholdMs">
1268
+ Stuck threshold (ms)
1269
+ <span className="meta-badge">default: 600000 (10 min)</span>
1270
+ </label>
1271
+ <input
1272
+ id="agents-stuckThresholdMs"
1273
+ type="number"
1274
+ min={60000}
1275
+ max={3600000}
1276
+ step={60000}
1277
+ value={settings.agents?.stuckThresholdMs ?? 600000}
1278
+ onChange={(e) => patchAgents({ stuckThresholdMs: Math.max(60000, Math.min(3600000, parseInt(e.target.value, 10) || 600000)) })}
1279
+ />
1280
+ </div>
1281
+ <label className="checkbox-row" data-setting-id="agents.autoRestart">
1282
+ <input
1283
+ type="checkbox"
1284
+ checked={!!settings.agents?.autoRestart}
1285
+ onChange={(e) => patchAgents({ autoRestart: e.target.checked })}
1286
+ />
1287
+ <span>Auto-restart stuck agents</span>
1288
+ </label>
1289
+ </Card>
1290
+ </div>
1291
+
1292
+ <div data-section="dashboard" style={{display: (activeSection === null || activeSection === 'dashboard') ? 'block' : 'none'}}>
1293
+ <Card id="settings-dashboard" data-section="dashboard">
1294
+ <CardTitle><Globe size={14} /> Dashboard</CardTitle>
1295
+ <CardMeta>Controls how <code>bizar</code> starts up.</CardMeta>
1296
+ <label className="checkbox-row" data-setting-id="dashboard.autoLaunchWeb">
1297
+ <input
1298
+ type="checkbox"
1299
+ checked={settings.dashboard.autoLaunchWeb !== false}
1300
+ onChange={(e) => patchDashboard({ autoLaunchWeb: e.target.checked })}
1301
+ />
1302
+ <span>Auto-launch web UI alongside TUI</span>
1303
+ </label>
1304
+
1305
+ <div className="field" data-setting-id="dashboard.projectsDirectory" style={{ marginTop: 'var(--space-4)' }}>
1306
+ <label className="field-label" htmlFor="set-projects-directory">Projects directory</label>
1307
+ <input
1308
+ id="set-projects-directory"
1309
+ className="input"
1310
+ type="text"
1311
+ placeholder="/home/user/projects"
1312
+ value={settings.dashboard.projectsDirectory ?? ''}
1313
+ onChange={(e) => patchDashboard({ projectsDirectory: e.target.value })}
1314
+ />
1315
+ <p className="field-help">
1316
+ New projects created via the dashboard will land here, and existing project
1317
+ directories inside this folder are auto-recognized on startup.
1318
+ </p>
1319
+ {settings.dashboard.projectsDirectory && (
1320
+ <>
1321
+ {!/^\/|^[A-Za-z]:/.test(settings.dashboard.projectsDirectory) && (
1322
+ <p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 4 }}>
1323
+ Path should be absolute (start with / on Linux/Mac, or a drive letter on Windows).
1324
+ </p>
1325
+ )}
1326
+ {settings.dashboard.projectsDirectory.includes('..') && (
1327
+ <p style={{ color: 'var(--error)', fontSize: 11, marginTop: 4 }}>
1328
+ Path traversal not allowed — this will be rejected server-side.
1329
+ </p>
1330
+ )}
1331
+ </>
1332
+ )}
1333
+ </div>
1334
+
1335
+ {/* allowedRoots textarea */}
1336
+ <div className="field" data-setting-id="dashboard.allowedRoots" style={{ marginTop: 'var(--space-4)' }}>
1337
+ <label className="field-label" htmlFor="set-allowed-roots">
1338
+ Additional allowed roots <span className="muted">(advanced)</span>
1339
+ </label>
1340
+ <textarea
1341
+ id="set-allowed-roots"
1342
+ className="textarea"
1343
+ rows={4}
1344
+ placeholder="/workspace&#10;/srv/projects"
1345
+ value={(settings.dashboard.allowedRoots ?? []).join('\n')}
1346
+ onChange={(e) => {
1347
+ const lines = e.target.value
1348
+ .split('\n')
1349
+ .map((l) => l.trim())
1350
+ .filter(Boolean);
1351
+ patchDashboard({ allowedRoots: lines });
1352
+ }}
1353
+ />
1354
+ <p className="field-help">
1355
+ Optional. Add filesystem roots beyond your home directory that the file
1356
+ browser and project scanner can access. Each path must be inside your
1357
+ home directory. One per line.
1358
+ </p>
1359
+ {(() => {
1360
+ const rawLines = (settings.dashboard.allowedRoots ?? []).join('\n').split('\n');
1361
+ const warnings: { key: string; msg: React.ReactNode }[] = [];
1362
+ rawLines.forEach((line, i) => {
1363
+ if (!line.trim()) return;
1364
+ if (!/^\/|^[A-Za-z]:/.test(line)) {
1365
+ warnings.push({
1366
+ key: `noabs-${i}`,
1367
+ msg: (
1368
+ <p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 2 }}>
1369
+ Line {i + 1}: "{line}" — should be absolute (start with / or a drive letter).
1370
+ </p>
1371
+ ),
1372
+ });
1373
+ }
1374
+ if (line.includes('..')) {
1375
+ warnings.push({
1376
+ key: `dots-${i}`,
1377
+ msg: (
1378
+ <p style={{ color: 'var(--error)', fontSize: 11, marginTop: 2 }}>
1379
+ Line {i + 1}: "{line}" — contains '..' (server will reject this).
1380
+ </p>
1381
+ ),
1382
+ });
1383
+ }
1384
+ });
1385
+ return warnings.map((w) => w.msg);
1386
+ })()}
1387
+ </div>
1388
+ </Card>
1389
+ </div>
1390
+
1391
+ <div data-section="background" style={{display: (activeSection === null || activeSection === 'background') ? 'block' : 'none'}}>
1392
+ <Card id="settings-background" data-section="background">
1393
+ <CardTitle><ServerIcon size={14} /> Background Agents</CardTitle>
1394
+ <CardMeta>Tune plugin options. Changes take effect on next plugin restart.</CardMeta>
1395
+
1396
+ <div className="form-row">
1397
+ <label htmlFor="bg-maxConcurrent">
1398
+ Max concurrent instances
1399
+ <span className="meta-badge">default: 8</span>
1400
+ </label>
1401
+ <input
1402
+ id="bg-maxConcurrent"
1403
+ type="number"
1404
+ min={1}
1405
+ max={32}
1406
+ value={pluginOptions.maxConcurrentInstances ?? 8}
1407
+ onChange={(e) => setPluginOptions((cur) => ({ ...cur, maxConcurrentInstances: Math.max(1, Math.min(32, parseInt(e.target.value, 10) || 8)) }))}
1408
+ />
1409
+ <small className="muted">Plugin option: <code>maxConcurrentInstances</code></small>
1410
+ </div>
1411
+
1412
+ <div className="form-row">
1413
+ <label htmlFor="bg-toolCallCap">
1414
+ Tool-call cap
1415
+ <span className="meta-badge">default: 500</span>
1416
+ </label>
1417
+ <input
1418
+ id="bg-toolCallCap"
1419
+ type="number"
1420
+ min={1}
1421
+ max={5000}
1422
+ value={pluginOptions.backgroundToolCallCap ?? 500}
1423
+ onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundToolCallCap: Math.max(1, Math.min(5000, parseInt(e.target.value, 10) || 500)) }))}
1424
+ />
1425
+ <small className="muted">Plugin option: <code>backgroundToolCallCap</code></small>
1426
+ </div>
1427
+
1428
+ <div className="form-row">
1429
+ <label htmlFor="bg-stallTimeout">
1430
+ Stall timeout (ms)
1431
+ <span className="meta-badge">default: 180000</span>
1432
+ </label>
1433
+ <input
1434
+ id="bg-stallTimeout"
1435
+ type="number"
1436
+ min={10000}
1437
+ max={600000}
1438
+ step={1000}
1439
+ value={pluginOptions.backgroundStallTimeoutMs ?? 180000}
1440
+ onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundStallTimeoutMs: Math.max(10000, Math.min(600000, parseInt(e.target.value, 10) || 180000)) }))}
1441
+ />
1442
+ <small className="muted">Plugin option: <code>backgroundStallTimeoutMs</code></small>
1443
+ </div>
1444
+
1445
+ <div className="form-row">
1446
+ <label htmlFor="bg-thinkingLoopTimeout">
1447
+ Thinking-loop timeout (ms)
1448
+ <span className="meta-badge">default: 300000</span>
1449
+ </label>
1450
+ <input
1451
+ id="bg-thinkingLoopTimeout"
1452
+ type="number"
1453
+ min={30000}
1454
+ max={900000}
1455
+ step={1000}
1456
+ value={pluginOptions.backgroundThinkingLoopTimeoutMs ?? 300000}
1457
+ onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundThinkingLoopTimeoutMs: Math.max(30000, Math.min(900000, parseInt(e.target.value, 10) || 300000)) }))}
1458
+ />
1459
+ <small className="muted">Plugin option: <code>backgroundThinkingLoopTimeoutMs</code></small>
1460
+ </div>
1461
+
1462
+ <div className="form-row">
1463
+ <label htmlFor="bg-maxInterventions">
1464
+ Max interventions
1465
+ <span className="meta-badge">default: 1</span>
1466
+ </label>
1467
+ <input
1468
+ id="bg-maxInterventions"
1469
+ type="number"
1470
+ min={1}
1471
+ max={3}
1472
+ value={pluginOptions.backgroundMaxInterventions ?? 1}
1473
+ onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundMaxInterventions: Math.max(1, Math.min(3, parseInt(e.target.value, 10) || 1)) }))}
1474
+ />
1475
+ <small className="muted">Plugin option: <code>backgroundMaxInterventions</code></small>
1476
+ </div>
1477
+
1478
+ <div className="form-row">
1479
+ <Button variant="secondary" size="sm" onClick={async () => {
1480
+ try {
1481
+ await api.put('/settings/plugin-options', pluginOptions);
1482
+ toast.success('Saved — restart opencode for changes to take effect.');
1483
+ } catch (err) {
1484
+ toast.error(`Save failed: ${(err as Error).message}`);
1485
+ }
1486
+ }}>
1487
+ <Save size={14} /> Save plugin options
1488
+ </Button>
1489
+
1490
+ <Button variant="secondary" size="sm" onClick={async () => {
1491
+ try {
1492
+ const r = await fetch('/api/background/cleanup', {
1493
+ method: 'POST',
1494
+ headers: { 'Content-Type': 'application/json' },
1495
+ body: JSON.stringify({ maxAgeDays: 7 }),
1496
+ });
1497
+ const result = await r.json();
1498
+ toast.success(`Cleaned up ${result.deleted} old instances.`);
1499
+ } catch (err) {
1500
+ toast.error(`Cleanup failed: ${(err as Error).message}`);
1501
+ }
1502
+ }}>
1503
+ Cleanup old instances (&gt;7 days)
1504
+ </Button>
1505
+ </div>
1506
+
1507
+ <div className="form-row">
1508
+ <small>Plugin options are read at startup. Save changes and run <code>bizar update</code> to apply.</small>
1509
+ </div>
1510
+ </Card>
1511
+ </div>
1512
+ </div>
1513
+
1514
+ <div data-section="system-llm" style={{display: (activeSection === null || activeSection === 'system-llm') ? 'block' : 'none'}}>
1515
+ <Card id="settings-system-llm" data-section="system-llm">
1516
+ <CardTitle><Sparkles size={14} /> System LLM API</CardTitle>
1517
+ <CardMeta>
1518
+ Configures the LLM used for automatic title generation, prompt enhancement,
1519
+ summarization, and other system-level calls.
1520
+ </CardMeta>
1521
+
1522
+ <label className="checkbox-row" data-setting-id="systemLlm.enabled">
1523
+ <input
1524
+ type="checkbox"
1525
+ checked={!!settings.systemLlm?.enabled}
1526
+ onChange={(e) => {
1527
+ const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
1528
+ patchTop('systemLlm', { ...cur, enabled: e.target.checked });
1529
+ }}
1530
+ />
1531
+ <span>Enable system LLM calls</span>
1532
+ </label>
1533
+
1534
+ <div className="field" data-setting-id="systemLlm.provider" style={{ marginTop: 'var(--space-3)' }}>
1535
+ <label className="field-label" htmlFor="set-system-llm-provider">Provider</label>
1536
+ <select
1537
+ id="set-system-llm-provider"
1538
+ className="select"
1539
+ value={settings.systemLlm?.provider || 'opencode'}
1540
+ onChange={(e) => {
1541
+ const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
1542
+ patchTop('systemLlm', { ...cur, provider: e.target.value });
1543
+ }}
1544
+ >
1545
+ <option value="opencode">Opencode</option>
1546
+ <option value="openrouter">OpenRouter</option>
1547
+ <option value="minimax">MiniMax</option>
1548
+ </select>
1549
+ </div>
1550
+
1551
+ <div className="field" data-setting-id="systemLlm.model">
1552
+ <label className="field-label" htmlFor="set-system-llm-model">Model</label>
1553
+ <input
1554
+ id="set-system-llm-model"
1555
+ className="input mono"
1556
+ type="text"
1557
+ placeholder="opencode/deepseek-v4-flash-free"
1558
+ value={settings.systemLlm?.model || 'opencode/deepseek-v4-flash-free'}
1559
+ onChange={(e) => {
1560
+ const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
1561
+ patchTop('systemLlm', { ...cur, model: e.target.value });
1562
+ }}
1563
+ />
1564
+ <p className="field-help">
1565
+ The API key is read from <code>auth.json</code> for the selected provider.
1566
+ Leave the default model for best results.
1567
+ </p>
1568
+ </div>
1569
+
1570
+ <div style={{ marginTop: 'var(--space-4)' }}>
1571
+ <h4 style={{ margin: '0 0 var(--space-2)' }}>Features using this API</h4>
1572
+ <ul className="settings-feature-list" style={{ margin: 0, paddingLeft: 'var(--space-4)', lineHeight: 1.8 }}>
1573
+ <li>Auto-title generation for new chat sessions</li>
1574
+ <li>Auto-title generation for new tasks</li>
1575
+ <li>"Enhance prompt" button in the task input</li>
1576
+ <li>"Enhance prompt" button in the chat composer</li>
1577
+ <li className="muted" style={{ fontSize: 12 }}>Future: summarization, name generation, and more</li>
1578
+ </ul>
1579
+ </div>
1580
+ </Card>
1581
+ </div>
1582
+
1583
+ <PairDeviceCard />
1584
+
1585
+ <div style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
1586
+ <div data-section="activity-log" style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
1587
+ <section id="settings-activity-log" data-section="activity-log" className="settings-section-wrap">
1588
+ <ActivityLogCard />
1589
+ </section>
1590
+ </div>
1591
+ </div>
1592
+
1593
+ <div style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
1594
+ <div data-section="about" style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
1595
+ <Card id="settings-about" data-section="about">
1596
+ <CardTitle><Info size={14} /> About</CardTitle>
1597
+ <CardMeta>Build metadata.</CardMeta>
1598
+ <dl className="about-table">
1599
+ <dt>Version</dt>
1600
+ <dd className="mono">{about.version}</dd>
1601
+ <dt>Homepage</dt>
1602
+ <dd>
1603
+ <a href={about.homepage} target="_blank" rel="noopener noreferrer">
1604
+ {about.homepage}
1605
+ </a>
1606
+ </dd>
1607
+ <dt>License</dt>
1608
+ <dd>{about.license}</dd>
1609
+ </dl>
1610
+ </Card>
1611
+ </div>
1612
+ </div>
1613
+ </div>
1614
+ );
1615
+ }
1616
+
1617
+ /**
1618
+ * v3.15.0 — Full activity log + hidden set management.
1619
+ *
1620
+ * Two sources of truth:
1621
+ * - GET /activity — the full unfiltered log (incl. hidden entries)
1622
+ * - GET /activity/hidden — the list of keys the user chose to hide
1623
+ *
1624
+ * Hiding is non-destructive: hidden items still appear here so the
1625
+ * user can review / restore them.
1626
+ */
1627
+ function ActivityLogCard() {
1628
+ const toast = useToast();
1629
+ const [items, setItems] = useState<Array<{ kind?: string; ts?: string; slug?: string; message?: string }>>([]);
1630
+ const [hidden, setHidden] = useState<Set<string>>(new Set());
1631
+ const [loading, setLoading] = useState(true);
1632
+ const [filter, setFilter] = useState('');
1633
+ const [showHidden, setShowHidden] = useState(true);
1634
+
1635
+ const refresh = useCallback(async () => {
1636
+ setLoading(true);
1637
+ try {
1638
+ const [log, hid] = await Promise.all([
1639
+ api.get<{ items: Array<{ kind?: string; ts?: string; slug?: string; message?: string }> }>('/activity'),
1640
+ api.get<{ hidden: string[] }>('/activity/hidden'),
1641
+ ]);
1642
+ setItems(Array.isArray(log.items) ? log.items : []);
1643
+ setHidden(new Set(hid.hidden || []));
1644
+ } catch (err) {
1645
+ toast.error(`Load failed: ${(err as Error).message}`);
1646
+ } finally {
1647
+ setLoading(false);
1648
+ }
1649
+ }, [toast]);
1650
+
1651
+ useEffect(() => { refresh(); }, [refresh]);
1652
+
1653
+ const keyOf = (it: { kind?: string; ts?: string; slug?: string }, idx: number) => {
1654
+ const k = `${it.kind || ''}|${it.ts || ''}|${it.slug || ''}|${idx}`;
1655
+ let h = 0;
1656
+ for (let i = 0; i < k.length; i++) h = ((h << 5) - h + k.charCodeAt(i)) | 0;
1657
+ return Math.abs(h).toString(16).padStart(8, '0').slice(0, 16);
1658
+ };
1659
+
1660
+ const onRestoreAll = async () => {
1661
+ try {
1662
+ await api.del('/activity/hide');
1663
+ setHidden(new Set());
1664
+ toast.success('All hidden activity restored.');
1665
+ } catch (err) {
1666
+ toast.error(`Restore failed: ${(err as Error).message}`);
1667
+ }
1668
+ };
1669
+
1670
+ const onRestoreOne = async (key: string) => {
1671
+ try {
1672
+ await api.del(`/activity/hide/${encodeURIComponent(key)}`);
1673
+ const next = new Set(hidden);
1674
+ next.delete(key);
1675
+ setHidden(next);
1676
+ } catch (err) {
1677
+ toast.error(`Restore failed: ${(err as Error).message}`);
1678
+ }
1679
+ };
1680
+
1681
+ const filtered = items.filter((it, idx) => {
1682
+ const k = keyOf(it, idx);
1683
+ if (!showHidden && hidden.has(k)) return false;
1684
+ if (!filter) return true;
1685
+ const q = filter.toLowerCase();
1686
+ return [it.kind, it.slug, it.message].some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
1687
+ });
1688
+
1689
+ return (
1690
+ <Card id="settings-diagnostics" data-section="diagnostics">
1691
+ <CardTitle>
1692
+ <Activity size={14} /> Activity log
1693
+ <span className="muted" style={{ fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
1694
+ {items.length} total · {hidden.size} hidden
1695
+ </span>
1696
+ <Button variant="ghost" size="sm" style={{ marginLeft: 'auto' }} onClick={refresh} title="Reload">
1697
+ <RefreshCw size={12} />
1698
+ </Button>
1699
+ </CardTitle>
1700
+ <CardMeta>
1701
+ Full history from <code>~/.bizar/activity.log</code>. Hiding an item in the Overview only hides it there — the entry stays here.
1702
+ </CardMeta>
1703
+
1704
+ <div className="activity-log-toolbar">
1705
+ <div className="activity-log-search">
1706
+ <Search size={12} />
1707
+ <input
1708
+ type="text"
1709
+ className="input"
1710
+ placeholder="Filter by kind, slug, or message…"
1711
+ value={filter}
1712
+ onChange={(e) => setFilter(e.target.value)}
1713
+ />
1714
+ </div>
1715
+ <label className="activity-log-toggle">
1716
+ <input
1717
+ type="checkbox"
1718
+ checked={showHidden}
1719
+ onChange={(e) => setShowHidden(e.target.checked)}
1720
+ />
1721
+ Show hidden
1722
+ </label>
1723
+ <Button
1724
+ variant="ghost"
1725
+ size="sm"
1726
+ disabled={hidden.size === 0}
1727
+ onClick={onRestoreAll}
1728
+ title="Restore all hidden items to the Overview"
1729
+ >
1730
+ <Eye size={12} /> Restore all
1731
+ </Button>
1732
+ </div>
1733
+
1734
+ {loading ? (
1735
+ <div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>Loading…</div>
1736
+ ) : filtered.length === 0 ? (
1737
+ <div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>
1738
+ {items.length === 0 ? 'No activity yet.' : 'No items match the current filter.'}
1739
+ </div>
1740
+ ) : (
1741
+ <div className="activity-log-table-wrap">
1742
+ <table className="activity-log-table">
1743
+ <thead>
1744
+ <tr>
1745
+ <th>Kind</th>
1746
+ <th>Detail</th>
1747
+ <th>Time</th>
1748
+ <th></th>
1749
+ </tr>
1750
+ </thead>
1751
+ <tbody>
1752
+ {filtered.slice(0, 200).map((it, idx) => {
1753
+ const k = keyOf(it, idx);
1754
+ const isHidden = hidden.has(k);
1755
+ return (
1756
+ <tr key={`${it.ts}-${idx}`} className={cn(isHidden && 'activity-log-row-hidden')}>
1757
+ <td className="activity-log-kind">{it.kind || 'activity'}</td>
1758
+ <td className="activity-log-detail">
1759
+ {it.message || it.slug || '—'}
1760
+ </td>
1761
+ <td className="activity-log-time mono">
1762
+ {it.ts ? new Date(it.ts).toLocaleString() : '—'}
1763
+ </td>
1764
+ <td>
1765
+ {isHidden ? (
1766
+ <Button variant="ghost" size="sm" onClick={() => onRestoreOne(k)} title="Restore to Overview">
1767
+ <Eye size={12} />
1768
+ </Button>
1769
+ ) : (
1770
+ <span className="activity-log-state-tag">shown</span>
1771
+ )}
1772
+ </td>
1773
+ </tr>
1774
+ );
1775
+ })}
1776
+ </tbody>
1777
+ </table>
1778
+ {filtered.length > 200 && (
1779
+ <div className="muted" style={{ fontSize: 11, padding: '8px 0' }}>
1780
+ Showing first 200 of {filtered.length}.
1781
+ </div>
1782
+ )}
1783
+ </div>
1784
+ )}
1785
+ </Card>
1786
+ );
1787
+ }