synabun 2.0.0 → 2.0.7

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 (507) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/NOTICE +5 -0
  3. package/README.md +2 -1
  4. package/THIRD-PARTY-LICENSES.md +59 -0
  5. package/benchmarks/fixtures/browser/README.md +1 -1
  6. package/benchmarks/fixtures/desktop/README.md +1 -1
  7. package/docs/whatsapp.md +153 -18
  8. package/hooks/claude-code/pre-compact.mjs +11 -0
  9. package/hooks/claude-code/prompt-submit.mjs +60 -184
  10. package/hooks/claude-code/session-start.mjs +73 -16
  11. package/hooks/claude-code/shared.mjs +26 -3
  12. package/hooks/claude-code/stop.mjs +5 -5
  13. package/lib/child-path.js +303 -0
  14. package/lib/claude-hooks.js +2 -1
  15. package/lib/data-home-migration.js +2 -0
  16. package/lib/desktop-app.js +265 -0
  17. package/lib/external-tools.js +7 -3
  18. package/lib/log-redact.js +102 -0
  19. package/lib/packaged-runtime.js +138 -11
  20. package/lib/start-launcher.js +88 -1
  21. package/mcp-server/dist/http.d.ts +3 -1
  22. package/mcp-server/dist/http.d.ts.map +1 -1
  23. package/mcp-server/dist/http.js +15 -4
  24. package/mcp-server/dist/http.js.map +1 -1
  25. package/mcp-server/dist/index.d.ts.map +1 -1
  26. package/mcp-server/dist/index.js +9 -6
  27. package/mcp-server/dist/index.js.map +1 -1
  28. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  29. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  30. package/mcp-server/dist/services/budget-judgments.js +153 -0
  31. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  32. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  33. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  34. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  35. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  36. package/mcp-server/dist/services/identity.d.ts +11 -0
  37. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  38. package/mcp-server/dist/services/identity.js +24 -0
  39. package/mcp-server/dist/services/identity.js.map +1 -1
  40. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  41. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  42. package/mcp-server/dist/services/neural-interface.js +34 -2
  43. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  44. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  45. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  46. package/mcp-server/dist/services/typesafe-config.js +4 -1
  47. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  48. package/mcp-server/dist/tools/agents.d.ts +10 -7
  49. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  50. package/mcp-server/dist/tools/agents.js +34 -13
  51. package/mcp-server/dist/tools/agents.js.map +1 -1
  52. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  53. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  54. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  55. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  56. package/mcp-server/dist/tui.js +2 -1
  57. package/mcp-server/dist/tui.js.map +1 -1
  58. package/mcp-server/dist/version.d.ts +5 -0
  59. package/mcp-server/dist/version.d.ts.map +1 -0
  60. package/mcp-server/dist/version.js +21 -0
  61. package/mcp-server/dist/version.js.map +1 -0
  62. package/mcp-server/package-lock.json +2 -2
  63. package/mcp-server/package.json +1 -3
  64. package/mcp-server/run.mjs +15 -9
  65. package/mcp-server/src/http.ts +18 -3
  66. package/mcp-server/src/index.ts +9 -6
  67. package/mcp-server/src/services/budget-judgments.ts +183 -0
  68. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  69. package/mcp-server/src/services/identity.ts +27 -0
  70. package/mcp-server/src/services/neural-interface.ts +34 -2
  71. package/mcp-server/src/services/typesafe-config.ts +4 -1
  72. package/mcp-server/src/tools/agents.ts +33 -14
  73. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  74. package/mcp-server/src/tui.ts +2 -1
  75. package/mcp-server/src/version.ts +22 -0
  76. package/neural-interface/i18n/en.json +2126 -209
  77. package/neural-interface/i18n/pt-BR.json +2123 -207
  78. package/neural-interface/lib/account-usage.js +163 -0
  79. package/neural-interface/lib/assistant-api.js +316 -57
  80. package/neural-interface/lib/assistant-brains/claude.js +19 -2
  81. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  82. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  83. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  84. package/neural-interface/lib/assistant-budget-split.js +408 -0
  85. package/neural-interface/lib/assistant-budget.js +94 -95
  86. package/neural-interface/lib/assistant-catalog.js +179 -27
  87. package/neural-interface/lib/assistant-config.js +91 -14
  88. package/neural-interface/lib/assistant-dispatch.js +494 -138
  89. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  90. package/neural-interface/lib/assistant-jev.js +26 -3
  91. package/neural-interface/lib/assistant-memory.js +24 -10
  92. package/neural-interface/lib/assistant-persona.js +73 -12
  93. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  94. package/neural-interface/lib/assistant-pricing.js +21 -6
  95. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  96. package/neural-interface/lib/assistant-route-gate.js +19 -3
  97. package/neural-interface/lib/assistant-router.js +373 -42
  98. package/neural-interface/lib/assistant-runtime.js +488 -59
  99. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  100. package/neural-interface/lib/assistant-test-density.js +142 -0
  101. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  102. package/neural-interface/lib/assistant-user-context.js +908 -0
  103. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  104. package/neural-interface/lib/assistant-user-learn.js +404 -0
  105. package/neural-interface/lib/assistant-user-threads.js +226 -0
  106. package/neural-interface/lib/assistant-user-writer.js +183 -0
  107. package/neural-interface/lib/augmented-path.js +317 -53
  108. package/neural-interface/lib/browser-background-tab.js +134 -0
  109. package/neural-interface/lib/browser-screenshot.js +678 -61
  110. package/neural-interface/lib/claude-accounts.js +342 -31
  111. package/neural-interface/lib/claude-agent-bridge.js +123 -9
  112. package/neural-interface/lib/claude-executable.js +6 -1
  113. package/neural-interface/lib/claude-model-catalog.js +33 -5
  114. package/neural-interface/lib/claude-panel-auth.js +221 -0
  115. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  116. package/neural-interface/lib/claude-temporary.js +46 -7
  117. package/neural-interface/lib/cli-install-api.js +137 -0
  118. package/neural-interface/lib/cli-install.js +797 -0
  119. package/neural-interface/lib/cli-path.js +130 -0
  120. package/neural-interface/lib/cli-resolver.js +744 -0
  121. package/neural-interface/lib/cli-update-planner.js +143 -7
  122. package/neural-interface/lib/cloudflared/install.js +533 -0
  123. package/neural-interface/lib/cloudflared/manager.js +545 -0
  124. package/neural-interface/lib/cloudflared/pins.js +115 -0
  125. package/neural-interface/lib/cloudflared/pins.json +63 -0
  126. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  127. package/neural-interface/lib/codex-account-auth.js +178 -0
  128. package/neural-interface/lib/codex-accounts.js +489 -0
  129. package/neural-interface/lib/codex-capabilities.js +2 -1
  130. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  131. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  132. package/neural-interface/lib/codex-panel-auth.js +62 -0
  133. package/neural-interface/lib/codex-session-settings.js +1 -1
  134. package/neural-interface/lib/codex-settings-schema.js +120 -0
  135. package/neural-interface/lib/desktop/config.js +3 -0
  136. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  137. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  138. package/neural-interface/lib/editor-completion.js +714 -0
  139. package/neural-interface/lib/editor-config.js +149 -0
  140. package/neural-interface/lib/editor-files-api.js +268 -0
  141. package/neural-interface/lib/editor-git-api.js +134 -0
  142. package/neural-interface/lib/file-explorer-api.js +1039 -0
  143. package/neural-interface/lib/file-explorer-system.js +355 -0
  144. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  145. package/neural-interface/lib/file-explorer-watch.js +239 -0
  146. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  147. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  148. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  149. package/neural-interface/lib/git-bash/install.js +529 -0
  150. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  151. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  152. package/neural-interface/lib/git-bash/manager.js +268 -0
  153. package/neural-interface/lib/git-bash/pins.js +206 -0
  154. package/neural-interface/lib/git-bash/pins.json +34 -0
  155. package/neural-interface/lib/git-bash/resolver.js +194 -0
  156. package/neural-interface/lib/git-diff-api.js +103 -0
  157. package/neural-interface/lib/git-read.js +234 -0
  158. package/neural-interface/lib/greeting/api.js +89 -0
  159. package/neural-interface/lib/greeting/budgets.js +90 -0
  160. package/neural-interface/lib/greeting/engine.js +461 -0
  161. package/neural-interface/lib/greeting/facts.js +320 -0
  162. package/neural-interface/lib/greeting/sources.js +252 -0
  163. package/neural-interface/lib/greeting/writer.js +139 -0
  164. package/neural-interface/lib/http-guards.js +189 -13
  165. package/neural-interface/lib/install-details.js +145 -0
  166. package/neural-interface/lib/machine-names.js +88 -0
  167. package/neural-interface/lib/mcp-installer.js +14 -2
  168. package/neural-interface/lib/memory-api.js +31 -7
  169. package/neural-interface/lib/native-loop-providers.js +26 -8
  170. package/neural-interface/lib/native-loop-runtime.js +9 -4
  171. package/neural-interface/lib/os-path.js +319 -0
  172. package/neural-interface/lib/plugin-installer.js +4 -2
  173. package/neural-interface/lib/project-storage.js +5 -7
  174. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  175. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  176. package/neural-interface/lib/pty-host/core.js +2 -1
  177. package/neural-interface/lib/pty-host/manager.js +27 -2
  178. package/neural-interface/lib/remote-policy.js +20 -11
  179. package/neural-interface/lib/request-gate.js +589 -0
  180. package/neural-interface/lib/session-title-generator.js +4 -3
  181. package/neural-interface/lib/system-about.js +340 -0
  182. package/neural-interface/lib/system-activity.js +66 -0
  183. package/neural-interface/lib/terminal-env.js +11 -3
  184. package/neural-interface/lib/terminal-path.js +55 -0
  185. package/neural-interface/lib/ui-blob-api.js +108 -0
  186. package/neural-interface/lib/ui-blob-store.js +68 -0
  187. package/neural-interface/lib/virtual-socket.js +12 -1
  188. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  189. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  190. package/neural-interface/lib/whatsapp/commands.js +13 -14
  191. package/neural-interface/lib/whatsapp/config.js +134 -20
  192. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  193. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  194. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  195. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  196. package/neural-interface/lib/whatsapp/manager.js +92 -32
  197. package/neural-interface/lib/whatsapp/paths.js +11 -1
  198. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  199. package/neural-interface/lib/whatsapp/redact.js +63 -0
  200. package/neural-interface/lib/whatsapp/service.js +343 -91
  201. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  202. package/neural-interface/package-lock.json +701 -2
  203. package/neural-interface/package.json +13 -2
  204. package/neural-interface/public/index.html +2 -0
  205. package/neural-interface/public/index2d.html +2 -0
  206. package/neural-interface/public/install-app.html +9 -6
  207. package/neural-interface/public/offline.html +19 -3
  208. package/neural-interface/public/onboarding.html +107 -2
  209. package/neural-interface/public/shared/account-i18n.js +7 -0
  210. package/neural-interface/public/shared/account-menu.js +539 -0
  211. package/neural-interface/public/shared/account-usage.js +19 -0
  212. package/neural-interface/public/shared/account-view.js +335 -0
  213. package/neural-interface/public/shared/agent-runtime-options.js +50 -14
  214. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  215. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  216. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  217. package/neural-interface/public/shared/assistant/asst-brain-picker.js +430 -81
  218. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  219. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  220. package/neural-interface/public/shared/assistant/asst-mascot.js +140 -61
  221. package/neural-interface/public/shared/assistant/asst-menu.js +27 -11
  222. package/neural-interface/public/shared/assistant/asst-panel.js +198 -42
  223. package/neural-interface/public/shared/assistant/asst-render.js +197 -23
  224. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  225. package/neural-interface/public/shared/assistant/asst-route.js +95 -343
  226. package/neural-interface/public/shared/assistant/asst-routes-editor.js +534 -0
  227. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  228. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  229. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  230. package/neural-interface/public/shared/assistant/asst-state.js +186 -2
  231. package/neural-interface/public/shared/assistant/asst-styles.js +247 -11
  232. package/neural-interface/public/shared/blob-store.js +111 -22
  233. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  234. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  235. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  236. package/neural-interface/public/shared/cdx/cdx-panel.js +80 -118
  237. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  238. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  239. package/neural-interface/public/shared/cdx/cdx-styles.js +164 -107
  240. package/neural-interface/public/shared/cdx/cdx-tabs.js +348 -668
  241. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  242. package/neural-interface/public/shared/claude-account-view.js +3 -0
  243. package/neural-interface/public/shared/cli-install-view.js +392 -0
  244. package/neural-interface/public/shared/cli-status.js +282 -22
  245. package/neural-interface/public/shared/constants.js +6 -1
  246. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  247. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  248. package/neural-interface/public/shared/cp/cp-styles.js +61 -0
  249. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  250. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  251. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  252. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  253. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  254. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  255. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  256. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  257. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  258. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  259. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  260. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  261. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  262. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  263. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  264. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  265. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  266. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  267. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  268. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  269. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  270. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  271. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  272. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  273. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  274. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  275. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  276. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  277. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  278. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  279. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  280. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  281. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  282. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  283. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  284. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  285. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  286. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  287. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  288. package/neural-interface/public/shared/file-links.js +339 -0
  289. package/neural-interface/public/shared/git-bash-view.js +145 -0
  290. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  291. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  292. package/neural-interface/public/shared/html-shell.js +161 -165
  293. package/neural-interface/public/shared/i18n.js +19 -1
  294. package/neural-interface/public/shared/install-details-view.js +86 -0
  295. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  296. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  297. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +131 -17
  298. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  299. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +22 -0
  300. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +158 -0
  301. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  302. package/neural-interface/public/shared/pwa-install-page.js +32 -2
  303. package/neural-interface/public/shared/pwa-install.css +3 -3
  304. package/neural-interface/public/shared/pwa-install.js +100 -1
  305. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  306. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  307. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  308. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  309. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  310. package/neural-interface/public/shared/settings/settings-ia.js +33 -4
  311. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  312. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  313. package/neural-interface/public/shared/start-bridge.js +238 -19
  314. package/neural-interface/public/shared/storage.js +30 -3
  315. package/neural-interface/public/shared/styles.css +1710 -770
  316. package/neural-interface/public/shared/synabun-mascot.js +68 -58
  317. package/neural-interface/public/shared/tunnel-view.js +167 -0
  318. package/neural-interface/public/shared/ui-claude-panel.js +369 -229
  319. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  320. package/neural-interface/public/shared/ui-detail.js +15 -0
  321. package/neural-interface/public/shared/ui-explorer.js +22 -0
  322. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  323. package/neural-interface/public/shared/ui-file-links.js +103 -0
  324. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  325. package/neural-interface/public/shared/ui-help.js +26 -0
  326. package/neural-interface/public/shared/ui-invite.js +126 -116
  327. package/neural-interface/public/shared/ui-judgments.js +10 -1
  328. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  329. package/neural-interface/public/shared/ui-loading.js +187 -19
  330. package/neural-interface/public/shared/ui-menubar.js +25 -33
  331. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  332. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  333. package/neural-interface/public/shared/ui-navbar.js +48 -22
  334. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  335. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  336. package/neural-interface/public/shared/ui-sessions.js +9 -3
  337. package/neural-interface/public/shared/ui-settings.js +509 -490
  338. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  339. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  340. package/neural-interface/public/shared/ui-sync.js +22 -0
  341. package/neural-interface/public/shared/ui-terminal.js +40 -5
  342. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  343. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  344. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  345. package/neural-interface/public/shared/ui-update.js +594 -5
  346. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  347. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  348. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  349. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  350. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  351. package/neural-interface/public/shared/worker-end.js +253 -0
  352. package/neural-interface/public/sw.js +2 -2
  353. package/neural-interface/public/variant/2d/main.js +10 -1
  354. package/neural-interface/public/variant/3d/main.js +10 -1
  355. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  356. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  357. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  358. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  359. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  360. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  361. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  362. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  363. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  364. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  365. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  366. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  367. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  368. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  369. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  370. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  371. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  372. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  373. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  374. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  375. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  376. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  377. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  378. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  379. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  380. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  381. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  382. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  383. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  384. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  385. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  386. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  387. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  388. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  389. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  390. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  391. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  392. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  393. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  394. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  395. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  396. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  397. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  398. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  399. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  400. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  401. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  402. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  403. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  404. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  405. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  406. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  407. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  408. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  409. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  410. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  411. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  412. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  413. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  414. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  415. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  416. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  417. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  418. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  419. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  420. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  421. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  422. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  423. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  424. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  425. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  426. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  427. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  428. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  429. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  430. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  431. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  432. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  433. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  434. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  435. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  436. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  437. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  438. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  439. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  440. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  441. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  442. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  443. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  444. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  445. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  446. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  447. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  448. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  449. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  450. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  451. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  452. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  453. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  454. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  455. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  456. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  457. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  458. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  459. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  460. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  461. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  462. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  463. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  464. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  465. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  466. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  467. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  468. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  469. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  470. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  471. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  472. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  473. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  474. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  475. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  476. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  477. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  478. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  479. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  480. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  481. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  482. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  483. package/neural-interface/scripts/build-vendor.mjs +78 -2
  484. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  485. package/neural-interface/server.js +2085 -1629
  486. package/neural-interface/templates/rulesets/README.md +1 -1
  487. package/package.json +5 -8
  488. package/setup.js +53 -15
  489. package/benchmarks/browser-live-results.json +0 -79
  490. package/benchmarks/browser-v2-results.json +0 -258
  491. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  492. package/neural-interface/public/category-logos/claude.svg +0 -1
  493. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  494. package/neural-interface/public/category-logos/openai.svg +0 -1
  495. package/neural-interface/public/category-logos/synabun.png +0 -0
  496. package/neural-interface/public/claude.svg +0 -1
  497. package/neural-interface/public/gallery.svg +0 -6
  498. package/neural-interface/public/leonardoai.svg +0 -1
  499. package/neural-interface/public/mcp.svg +0 -4
  500. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  501. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  502. package/neural-interface/public/sessionicon.svg +0 -8
  503. package/neural-interface/public/toolbar-album.svg +0 -8
  504. package/neural-interface/public/toolbar-question.svg +0 -6
  505. package/neural-interface/public/toolbar-sessions.svg +0 -6
  506. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  507. package/skills/synabun/synabunicon.png +0 -0
@@ -19,6 +19,8 @@ import {
19
19
  getCachedCodexAccounts,
20
20
  getCachedMcpProfilePresets,
21
21
  getCachedOpencodeAgents,
22
+ getClaudeWorkerAccountId,
23
+ getCodexWorkerAccountId,
22
24
  getEffortLevelsForModel,
23
25
  getModelsForProfile,
24
26
  modelEffortIds,
@@ -27,22 +29,33 @@ import {
27
29
  import { getHiddenModels, HIDDEN_MODELS_EVENT, hiddenModelMatch } from '../ocp-hidden-models.js';
28
30
  import { getProviderMeta } from '../provider-icons.js';
29
31
  import { on } from '../state.js';
30
- import { fetchAssistantCatalog, fetchProjects } from './asst-api.js';
31
- import { closeMenu, isMenuOpen, openMenu } from './asst-menu.js';
32
+ import { fetchAssistantCatalog, fetchProjects, patchClaudeAccount, patchCodexAccount, setClaudeWorkerAccount, setCodexWorkerAccount } from './asst-api.js';
33
+ import { accountBadge, accountCompactMode, accountIdentity, accountName, accountUsable, modeChoices } from '../account-view.js';
34
+ import { accountUsageText, autoAccountLabel } from '../account-usage.js';
35
+ import { closeMenu, currentMenu, isMenuOpen, openMenu } from './asst-menu.js';
36
+ import { createMascotControl } from './asst-mascot.js';
32
37
  import {
33
38
  ACCOUNT_PROVIDERS,
34
39
  brainRequiresSwitch,
40
+ emptyProjectView,
35
41
  modelShortName,
36
42
  normalizeBrain,
37
43
  PERMISSION_MODES,
38
44
  PLAN_MODE_PROVIDERS,
39
45
  permissionModeLabel,
40
46
  PROVIDERS,
47
+ projectLabel,
41
48
  resolveBrainDefaults,
49
+ TEST_DENSITIES,
50
+ normalizeTestDensity,
51
+ testDensityBars,
42
52
  } from './asst-state.js';
43
53
 
44
54
  let _projectsCache = null;
45
55
 
56
+ /** SynaBun's registered projects as the last load saw them ([{ path, label }]; [] before the first). */
57
+ export function cachedProjects() { return _projectsCache || []; }
58
+
46
59
  const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
47
60
  const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 5 6v5c0 4.5 3 8 7 10 4-2 7-5.5 7-10V6z"/></svg>';
48
61
  const ICON_PLUG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0zM12 17v4"/></svg>';
@@ -53,6 +66,25 @@ export const ICON_ACCOUNT = '<svg viewBox="0 0 24 24" fill="none" stroke="curren
53
66
  /** Dropdown caret shared by every toolbar dropdown (fields, route mode). */
54
67
  export const ICON_CARET = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 3.75 5 6.25l2.5-2.5"/></svg>';
55
68
 
69
+ // Test density: three bars centred in the 24 box (x 6–18, y 6–18; 5–19 with the stroke), `filled` of them at full strength.
70
+ function testsIcon(filled) {
71
+ const bar = (index, d) => `<path d="${d}"${index < filled ? '' : ' opacity=".3"'}/>`;
72
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${bar(0, 'M6 18v-4')}${bar(1, 'M12 18v-8')}${bar(2, 'M18 18V6')}</svg>`;
73
+ }
74
+ // English copy of the Test density menu (i18n: assistant.tests.levels.* / assistant.tests.desc.*).
75
+ const TEST_DENSITY_COPY = {
76
+ none: ['None', 'No tests. Only checks that the change loads.'],
77
+ light: ['Light', 'Runs the tests that cover the change. New tests only when they matter.'],
78
+ standard: ['Standard', 'Adds focused tests and runs the area\'s suite once.'],
79
+ thorough: ['Thorough', 'Full coverage, whole suite, live checks.'],
80
+ };
81
+
82
+ /** "Light" — the level's name (a missing or unknown level is the default one). */
83
+ export function testDensityLabel(level, t) {
84
+ const id = normalizeTestDensity(level);
85
+ return tf(t)(`assistant.tests.levels.${id}`, TEST_DENSITY_COPY[id][0]);
86
+ }
87
+
56
88
  function el(tag, className, text) {
57
89
  const node = document.createElement(tag);
58
90
  if (className) node.className = className;
@@ -92,6 +124,9 @@ let _catalog = null;
92
124
  let _catalogAt = 0;
93
125
  let _catalogPartial = false; // built without the server's catalog (no context variants)
94
126
  let _catalogLoading = null;
127
+ let _catalogEpoch = 0; // bumped by every invalidation: a load that began before it is not kept
128
+ let _catalogForceNext = false; // the lists changed: the next load asks the server for a new build
129
+ const _catalogListeners = new Set();
95
130
  const CATALOG_TTL_MS = 60_000;
96
131
 
97
132
  function trimNumber(n) {
@@ -150,11 +185,16 @@ let _hidden = { 'claude-code': new Set(), codex: new Set(), opencode: new Set()
150
185
 
151
186
  function hiddenSets(remote) {
152
187
  const out = {};
188
+ // Entries with a pin record (an alias hidden as the model it named then): each hides its own row only.
189
+ const rowOnly = {};
153
190
  for (const provider of PROVIDERS) {
154
191
  const list = Array.isArray(remote?.hidden?.[provider]) ? remote.hidden[provider] : [];
155
192
  out[provider] = new Set(list.map(id => String(id).toLowerCase()));
193
+ const pins = remote?.hiddenPins?.[provider];
194
+ rowOnly[provider] = new Set(pins && typeof pins === 'object' ? Object.keys(pins).map(id => id.toLowerCase()) : []);
156
195
  }
157
196
  out.opencode = new Set([...(out.opencode || []), ...hiddenOpencodeIds(remote)]);
197
+ out.rowOnly = rowOnly;
158
198
  return out;
159
199
  }
160
200
 
@@ -163,6 +203,9 @@ function hiddenSets(remote) {
163
203
  * id, or the model it resolves to at the same context window ("opus[1m]" →
164
204
  * "claude-opus-5-5[1m]"); "opus" and "opus[1m]" hide independently. OpenCode:
165
205
  * full or bare id (this window's Settings → OpenCode mirror counts too).
206
+ * A hidden alias hides the model it named when it was hidden, and the server
207
+ * keeps the list true to that (an alias that moved to another model is taken
208
+ * off it); such an entry hides its own row only, never through `resolved`.
166
209
  */
167
210
  export function hiddenModelFor(provider, id, { resolved = null, sets = _hidden } = {}) {
168
211
  const want = String(id || '').trim().toLowerCase();
@@ -173,22 +216,87 @@ export function hiddenModelFor(provider, id, { resolved = null, sets = _hidden }
173
216
  const suffix = (want.match(/\[(1m|extended)\]$/) || [''])[0];
174
217
  const up = String(resolved || '').trim().toLowerCase().replace(/\[(1m|extended)\]$/, '');
175
218
  const key = up ? `${up}${suffix}` : '';
176
- return key && key !== want && list.has(key) ? key : null;
219
+ return key && key !== want && list.has(key) && !sets.rowOnly?.[provider]?.has(key) ? key : null;
220
+ }
221
+
222
+ /**
223
+ * One provider's menu rows. Which models exist is the server catalog's word: it
224
+ * is built from the list the provider's own selector reads, with the models the
225
+ * user hid already left out. The window's runtime list (`local`) only adds what
226
+ * the catalog does not carry (the tagline, the default / top tag, a legacy
227
+ * selector). It stands in for the catalog only when the catalog has no row for
228
+ * the provider (request failed, older server, provider not answering there), or
229
+ * holds the provider's static stand-in while this window has a verified list.
230
+ */
231
+ export function mergeProviderModels(provider, remoteList, localList, { sets = _hidden, remoteSource = null } = {}) {
232
+ const remote = (Array.isArray(remoteList) ? remoteList : []).filter(m => m?.id);
233
+ const local = (Array.isArray(localList) ? localList : []).filter(m => m?.id || m?.model);
234
+ const localVerified = local.length > 0 && !local.some(m => m.catalogSource === 'fallback');
235
+ const out = [];
236
+ const seen = new Set();
237
+ const push = (m) => { if (m && !seen.has(m.id)) { seen.add(m.id); out.push(m); } };
238
+ if (remote.length && !(remoteSource === 'fallback' && localVerified)) {
239
+ const own = new Map(local.map(m => [String(m.id || m.model), m]));
240
+ for (const r of remote) {
241
+ if (hiddenModelFor(provider, r.id, { resolved: r.upstream || null, sets })) continue;
242
+ const mine = own.get(String(r.id)) || null;
243
+ // The catalog row first. What it does not carry (an older server, a row with no
244
+ // label or effort list) is read from the window's row for the same id.
245
+ const row = normalizeCatalogModel(
246
+ { ...r, desc: mine?.desc || r.desc || r.description || '', tier: mine?.tier || r.tier || null, upstream: r.upstream || mine?.resolvedModel || null },
247
+ mine ? {
248
+ label: mine.label || mine.displayName || '', efforts: modelEffortIds(mine) || undefined, contextWindow: mine.contextWindow || null,
249
+ defaultEffort: mine.defaultEffort || mine.defaultReasoningEffort || null, isDefault: mine.isDefault,
250
+ } : {},
251
+ );
252
+ if (row) row.selector = mine ? (modelSelectorValue(mine) || row.id) : row.id;
253
+ push(row);
254
+ }
255
+ return out;
256
+ }
257
+ const byId = new Map(remote.map(m => [String(m.id), m]));
258
+ for (const m of local) {
259
+ const extra = byId.get(String(m?.id)) || {};
260
+ if (hiddenModelFor(provider, m.id || m.model, { resolved: extra.upstream || m.resolvedModel || null, sets })) continue;
261
+ push(normalizeCatalogModel(m, extra));
262
+ }
263
+ return out;
177
264
  }
178
265
 
179
266
  /**
180
- * { 'claude-code': [...], codex: [...], opencode: [...] } — the runtime option
181
- * lists the sidepanels use, enriched with tier/vision/price from
182
- * GET /api/assistant/catalog (which alone is used when a local list is empty).
183
- * Models the user hid (Assistant → Models, and Settings → OpenCode for
184
- * OpenCode) never appear, from either list.
267
+ * The catalog's empty list for `provider` is its answer, not a gap: the list was
268
+ * read (`sources`: live, or the last verified one) and the provider said it has
269
+ * no model (`known`: OpenCode with nothing connected) or listed some and the
270
+ * user hid every one (`listed`). Not an answer: no list or no `sources` (request
271
+ * failed, an older server), a provider that did not answer ('unavailable'), and
272
+ * the server's static stand-in while this window holds a verified list (the
273
+ * same precedence as mergeProviderModels).
274
+ */
275
+ function catalogSaysNone(remote, provider, local) {
276
+ const rows = remote?.models?.[provider];
277
+ if (!Array.isArray(rows) || rows.length) return false;
278
+ const source = remote.sources?.[provider];
279
+ if (source !== 'live' && source !== 'stale' && source !== 'fallback') return false;
280
+ if (!(Number(remote.listed?.[provider]) > 0) && remote.known?.[provider] !== true) return false;
281
+ return !(source === 'fallback' && local.length > 0 && !local.some(m => m.catalogSource === 'fallback'));
282
+ }
283
+
284
+ /**
285
+ * { 'claude-code': [...], codex: [...], opencode: [...] }: the models of every
286
+ * Assistant model menu and of the Routes editor, from GET /api/assistant/catalog
287
+ * (see mergeProviderModels). Models the user hid (Assistant → Models, and
288
+ * Settings → OpenCode for OpenCode) never appear. `force` asks the server for a
289
+ * new build instead of its cached one.
185
290
  */
186
291
  export async function loadModelCatalog({ force = false } = {}) {
187
292
  if (!force && _catalog && Date.now() - _catalogAt < CATALOG_TTL_MS) return _catalog;
188
293
  if (_catalogLoading) return _catalogLoading;
294
+ const epoch = _catalogEpoch;
295
+ const serverForce = force || _catalogForceNext;
296
+ _catalogForceNext = false;
189
297
  _catalogLoading = (async () => {
190
298
  const [remote] = await Promise.all([
191
- fetchAssistantCatalog().catch(() => null),
299
+ fetchAssistantCatalog({ force: serverForce }).catch(() => null),
192
300
  ...PROVIDERS.map(p => Promise.resolve(ensureModelsForProfile(p, force)).catch(() => [])),
193
301
  ]);
194
302
  const out = {};
@@ -196,36 +304,16 @@ export async function loadModelCatalog({ force = false } = {}) {
196
304
  const sets = remote ? hiddenSets(remote) : { ..._hidden, opencode: new Set([...(_hidden.opencode || []), ...hiddenOpencodeIds(null)]) };
197
305
  _hidden = sets;
198
306
  for (const provider of PROVIDERS) {
199
- const remoteList = Array.isArray(remote?.models?.[provider]) ? remote.models[provider] : [];
200
- const byId = new Map(remoteList.filter(m => m?.id).map(m => [String(m.id), m]));
201
307
  const local = getModelsForProfile(provider) || [];
202
- const source = local.length ? local : remoteList;
203
- const seen = new Set();
204
- out[provider] = source.map(m => normalizeCatalogModel(m, byId.get(String(m?.id)) || {})).filter((m) => {
205
- if (!m || seen.has(m.id)) return false;
206
- const extra = byId.get(String(m.id)) || {};
207
- if (hiddenModelFor(provider, m.id, { resolved: extra.upstream || m.resolvedModel || null, sets })) return false;
208
- seen.add(m.id);
209
- return true;
210
- });
211
- // Context variants ("opus[1m]", "gpt-x[extended]") the local list lacks: the
212
- // catalog lists every one, each right after its base model.
213
- if (provider !== 'opencode' && source !== remoteList) {
214
- for (const r of remoteList) {
215
- if (!/\[(1m|extended)\]$/i.test(String(r?.id || '')) || seen.has(String(r.id))) continue;
216
- if (hiddenModelFor(provider, r.id, { resolved: r.upstream || null, sets })) continue;
217
- const m = normalizeCatalogModel(r, r);
218
- if (!m) continue;
219
- seen.add(m.id);
220
- const base = String(r.id).replace(/\[(1m|extended)\]$/i, '');
221
- const at = out[provider].findIndex(x => x.id === base);
222
- if (at >= 0) out[provider].splice(at + 1, 0, m); else out[provider].push(m);
223
- }
224
- }
308
+ // An empty list the catalog vouches for is its answer: the window's own rows do not stand in for it.
309
+ out[provider] = catalogSaysNone(remote, provider, local)
310
+ ? []
311
+ : mergeProviderModels(provider, remote?.models?.[provider], local, { sets, remoteSource: remote?.sources?.[provider] || null });
225
312
  }
226
313
  _catalog = out;
227
- // Built without the server's catalog: not cached, the next open asks again.
228
- _catalogAt = remote ? Date.now() : 0;
314
+ // Not kept when built without the server's catalog, or when the lists changed
315
+ // while it loaded: the next open asks again.
316
+ _catalogAt = remote && epoch === _catalogEpoch ? Date.now() : 0;
229
317
  _catalogPartial = !remote;
230
318
  return out;
231
319
  })().finally(() => { _catalogLoading = null; });
@@ -235,6 +323,11 @@ export async function loadModelCatalog({ force = false } = {}) {
235
323
  /** The last loadModelCatalog() result, synchronously (null before the first load). */
236
324
  export function cachedModelCatalog() { return _catalog; }
237
325
 
326
+ /** UI metadata from the same catalog load; no second list or extra endpoint. */
327
+ export function modelCatalogStatus() {
328
+ return { partial: _catalogPartial, hiddenCounts: Object.fromEntries(PROVIDERS.map(provider => [provider, _hidden[provider]?.size || 0])) };
329
+ }
330
+
238
331
  /** The catalog row for provider/model (covers "[1m]" / "[extended]" ids), else null. */
239
332
  export function catalogModelRow(provider, model, catalog = _catalog) {
240
333
  if (!provider || !model) return null;
@@ -246,6 +339,27 @@ export function invalidateModelCatalog() {
246
339
  _catalog = null;
247
340
  _catalogAt = 0;
248
341
  _catalogPartial = false;
342
+ _catalogEpoch += 1;
343
+ }
344
+
345
+ /**
346
+ * Tells `listener` whenever the lists behind loadModelCatalog() changed: a model
347
+ * hidden or shown, a provider listing other models (the server's
348
+ * `assistant:catalog-changed`), an OpenCode provider connected or removed. The
349
+ * cache is already dropped when it is called. Returns the function that stops it.
350
+ */
351
+ export function onModelCatalogChange(listener) {
352
+ if (typeof listener !== 'function') return () => {};
353
+ _catalogListeners.add(listener);
354
+ return () => { _catalogListeners.delete(listener); };
355
+ }
356
+
357
+ function modelListsChanged() {
358
+ invalidateModelCatalog();
359
+ _catalogForceNext = true;
360
+ for (const listener of [..._catalogListeners]) {
361
+ try { listener(); } catch { /* a listener's failure is its own */ }
362
+ }
249
363
  }
250
364
 
251
365
  /**
@@ -267,19 +381,25 @@ export function resolvableModels(provider, keep = '', catalog = _catalog) {
267
381
  return hiddenModelFor(provider, keep) || unsure ? [...rows, { id: keep, selector: keep }] : rows;
268
382
  }
269
383
 
270
- // A Settings → OpenCode checkbox or an Assistant → Models toggle (this window, or another via sync).
271
- if (typeof document !== 'undefined') document.addEventListener(HIDDEN_MODELS_EVENT, invalidateModelCatalog);
272
- on('sync:opencode:hidden-models-changed', invalidateModelCatalog);
273
- on('sync:assistant:hidden-models-changed', invalidateModelCatalog);
384
+ // A Settings → OpenCode checkbox or an Assistant → Models toggle (this window, or another via
385
+ // sync), an OpenCode provider connected or removed in Settings, a provider listing other models.
386
+ if (typeof document !== 'undefined') {
387
+ document.addEventListener(HIDDEN_MODELS_EVENT, modelListsChanged);
388
+ document.addEventListener('ocp-providers-changed', modelListsChanged);
389
+ }
390
+ on('sync:opencode:hidden-models-changed', modelListsChanged);
391
+ on('sync:assistant:hidden-models-changed', modelListsChanged);
392
+ on('sync:assistant:catalog-changed', modelListsChanged);
274
393
 
275
394
  function sameModel(selected, m) {
276
395
  if (!selected) return false;
277
396
  return selected === m.id || selected === m.selector;
278
397
  }
279
398
 
280
- function modelDesc(m, tr) {
399
+ function modelDesc(m, tr, showUnknown = false) {
281
400
  return [
282
- m.ctxLabel, m.desc, fmtModelPrice(m.price), m.vision === true ? tr('assistant.brain.vision', 'vision') : '',
401
+ m.ctxLabel || (showUnknown ? m.contextWindow ? tr('assistant.routes.menu.contextTokens', '{count} tokens', { count: m.contextWindow.toLocaleString() }) : tr('assistant.routes.menu.contextUnknown', 'Context unknown') : ''), m.desc,
402
+ fmtModelPrice(m.price) || (showUnknown ? tr('assistant.routes.menu.priceUnknown', 'Price unknown') : ''), m.vision === true ? tr('assistant.brain.vision', 'vision') : '',
283
403
  m.outputs?.image === true ? tr('assistant.brain.imageOut', 'makes images') : '',
284
404
  m.outputs?.video === true ? tr('assistant.brain.videoOut', 'makes video') : '',
285
405
  ].filter(Boolean).join(' · ');
@@ -309,10 +429,23 @@ export async function openModelMenu(anchor, opts = {}) {
309
429
  const tr = tf(t);
310
430
  const b = normalizeBrain(brain);
311
431
  anchor.setAttribute('aria-busy', 'true');
432
+ // Open immediately: a slow provider still has a visible, cancellable state.
433
+ const loading = opts.routingEditor ? openMenu(anchor, {
434
+ title, className: 'asst-model-menu asst-route-model-menu',
435
+ items: [{ kind: 'info', label: tr('assistant.routes.editorState.loadingModels', 'Loading models…') }],
436
+ width: 380, zIndex: opts.zIndex ?? null, repositionOnResize: true,
437
+ }) : null;
438
+ if (loading) loading.el.setAttribute('aria-busy', 'true');
439
+ if (loading) { loading.el.setAttribute('role', 'dialog'); anchor.setAttribute('aria-haspopup', 'dialog'); }
312
440
  let catalog = {};
313
- try { catalog = await loadModelCatalog(); } catch { catalog = {}; } finally { anchor.removeAttribute('aria-busy'); }
314
- if (!anchor.isConnected) return null;
441
+ let failed = false;
442
+ try { catalog = await loadModelCatalog({ force: opts.forceReload === true }); } catch { failed = true; } finally { anchor.removeAttribute('aria-busy'); }
443
+ if (!anchor.isConnected || (loading && currentMenu() !== loading.el)) return null;
444
+ const metadata = modelCatalogStatus();
315
445
  const items = [];
446
+ if (Array.isArray(opts.accountItems) && opts.accountItems.length) {
447
+ items.push({ kind: 'header', label: tr('assistant.brain.account', 'Account profile') }, ...opts.accountItems, { kind: 'separator' });
448
+ }
316
449
  if (includeHere) {
317
450
  items.push({ kind: 'radio', id: '__here', label: hereLabel || tr('assistant.routes.brainDecides', 'Let the brain decide'), selected: !current, onSelect: () => onSelect?.({ here: true }) });
318
451
  }
@@ -320,26 +453,30 @@ export async function openModelMenu(anchor, opts = {}) {
320
453
  let listed = 0;
321
454
  for (const provider of list) {
322
455
  const models = (catalog[provider] || []).filter(m => typeof accept !== 'function' || accept(provider, m));
323
- if (!models.length) continue;
456
+ if (!models.length && !opts.routingEditor) continue;
324
457
  listed += models.length;
325
458
  const meta = getProviderMeta(provider);
326
- const note = warnSwitch && provider !== b.provider ? tr('assistant.brain.newConversation', 'starts a new conversation') : '';
459
+ const hiddenCount = metadata.hiddenCounts[provider] || 0;
460
+ const note = opts.routingEditor ? tr('assistant.routes.menu.hiddenCount', '{count} hidden in Models', { count: hiddenCount }) : warnSwitch && provider !== b.provider ? tr('assistant.brain.newConversation', 'starts a new conversation') : '';
327
461
  items.push({ kind: 'header', label: meta.label, icon: meta.icon, color: meta.color, note });
328
462
  for (const m of models) {
329
463
  items.push({
330
464
  kind: 'radio',
331
465
  id: `${provider}:${m.id}`,
332
466
  label: m.label,
333
- desc: modelDesc(m, tr),
467
+ desc: modelDesc(m, tr, opts.routingEditor),
468
+ title: `${m.label || m.id} · ${m.id}`,
334
469
  tag: tierTag(m, tr),
335
470
  keywords: `${meta.label} ${m.id}`,
336
471
  selected: !!current && current.provider === provider && sameModel(current.model, m),
337
472
  onSelect: () => onSelect?.({ provider, model: m.id, label: m.label, tier: m.tier || null, vision: m.vision ?? null, outputs: m.outputs || null, efforts: m.efforts || null, price: m.price || null }),
338
473
  });
339
474
  }
475
+ if (opts.routingEditor && !models.length) items.push({ kind: 'info', label: typeof accept === 'function' ? tr('assistant.routes.menu.noCompatible', 'No compatible models shown for this provider.') : tr('assistant.routes.menu.providerEmpty', 'No models shown for this provider.') });
340
476
  }
341
477
  // Nothing passes the filter (e.g. no model makes video yet): say so instead of an empty list.
342
478
  if (!listed && typeof accept === 'function' && emptyLabel) items.push({ kind: 'info', label: emptyLabel });
479
+ if (opts.routingEditor && !listed && typeof accept !== 'function') items.push({ kind: 'info', label: tr('assistant.routes.editorState.modelsEmpty', 'No models are available. Check Models or connect a provider, then reload.') });
343
480
  if (allowCustom) {
344
481
  items.push({ kind: 'separator' });
345
482
  items.push({
@@ -356,29 +493,63 @@ export async function openModelMenu(anchor, opts = {}) {
356
493
  },
357
494
  });
358
495
  }
359
- if (typeof opts.onManageModels === 'function') {
496
+ if (!opts.routingEditor && typeof opts.onManageModels === 'function') {
360
497
  items.push({ id: '__manage_models', label: tr('assistant.models.manage', 'Manage models…'), onSelect: () => opts.onManageModels() });
361
498
  }
362
- return openMenu(anchor, {
499
+ const menu = openMenu(anchor, {
363
500
  title: title || tr('assistant.brain.model', 'Model'),
364
501
  items,
365
502
  filter: true,
366
503
  filterPlaceholder: tr('assistant.brain.filterModels', 'Filter models…'),
367
504
  placement,
368
- width: 300,
505
+ width: opts.routingEditor ? 380 : 300,
369
506
  role: 'menu',
370
- className: 'asst-model-menu',
507
+ className: `asst-model-menu${opts.routingEditor ? ' asst-route-model-menu' : ''}`,
371
508
  zIndex: opts.zIndex ?? null,
509
+ repositionOnResize: true,
510
+ filterEmptyLabel: tr('assistant.routes.menu.noMatches', 'No matching models. Check Models for hidden choices.'),
372
511
  });
512
+ if (opts.routingEditor && menu) {
513
+ // Search and a custom-id input belong to a dialog; choices are a radio group.
514
+ // A menu role cannot contain these form fields or recovery buttons.
515
+ menu.el.setAttribute('role', 'dialog');
516
+ anchor.setAttribute('aria-haspopup', 'dialog');
517
+ const choices = menu.list.querySelectorAll('[role="menuitemradio"]');
518
+ for (const choice of choices) choice.setAttribute('role', 'radio');
519
+ const groupRole = () => {
520
+ const visible = [...choices].filter(n => !n.hidden);
521
+ const focusChoice = visible.find(n => n.getAttribute('aria-checked') === 'true') || visible[0];
522
+ for (const choice of choices) choice.tabIndex = choice === focusChoice ? 0 : -1;
523
+ menu.list.setAttribute('role', visible.length ? 'radiogroup' : 'group');
524
+ menu.list.setAttribute('aria-labelledby', `${menu.el.id}-title`);
525
+ };
526
+ groupRole(); menu.el.querySelector('.asst-dd-filter')?.addEventListener('input', groupRole);
527
+ // Outside the filter's scroller: recovery stays visible, including zero results.
528
+ const footer = document.createElement('div'); footer.className = 'asst-route-menu-foot';
529
+ const note = document.createElement('div'); note.className = 'asst-dd-info';
530
+ note.textContent = failed || metadata.partial ? tr('assistant.routes.menu.failed', 'Model list could not be refreshed. Provider lists may be out of date.') : tr('assistant.routes.menu.hiddenHelp', 'Missing a model? Your Models list controls what is shown.');
531
+ note.setAttribute('role', 'status'); footer.append(note);
532
+ const customInput = menu.list.querySelector('.asst-dd-input');
533
+ if (customInput) footer.append(customInput);
534
+ for (const [label, action] of [
535
+ ...(typeof opts.onManageModels === 'function' ? [[tr('assistant.models.manage', 'Manage models…'), () => { closeMenu(); opts.onManageModels(); }]] : []),
536
+ [tr('assistant.routes.reloadModels', 'Reload models'), () => { closeMenu(); openModelMenu(anchor, { ...opts, forceReload: true }); }],
537
+ ]) {
538
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'asst-dd-item'; button.tabIndex = -1; button.textContent = label; button.addEventListener('click', action); footer.append(button);
539
+ }
540
+ menu.el.append(footer); menu.reposition();
541
+ }
542
+ return menu;
373
543
  }
374
544
 
375
545
  // ── Picker ──────────────────────────────────────────────────────────────────
376
546
 
377
- // Creation order is DOM order within a slot: model · effort · variant · mode · account in the composer.
547
+ // Creation order is DOM order within a slot: model · effort · variant · mode · tests · account in the composer.
378
548
  // `effort` is the OpenCode agent for OpenCode; `variant` is OpenCode's effort (the model's variants).
379
- const FIELDS = ['model', 'effort', 'variant', 'mode', 'account', 'project', 'mcp'];
549
+ // `tests` is the session's Test density, not a brain field: it shows only while the host supplies a level.
550
+ const FIELDS = ['model', 'effort', 'variant', 'mode', 'tests', 'account', 'project', 'mcp'];
380
551
  // Slot of each field: the composer row (the brain and how it acts) · the toolbar (where it works).
381
- const FIELD_GROUP = { model: 'brain', effort: 'brain', variant: 'brain', mode: 'brain', account: 'brain', project: 'context', mcp: 'context' };
552
+ const FIELD_GROUP = { model: 'brain', effort: 'brain', variant: 'brain', mode: 'brain', tests: 'brain', account: 'brain', project: 'context', mcp: 'context' };
382
553
 
383
554
  /**
384
555
  * createBrainPicker(host, hooks) — `host` is either one element (all fields in
@@ -386,7 +557,9 @@ const FIELD_GROUP = { model: 'brain', effort: 'brain', variant: 'brain', mode: '
386
557
  * hooks: { t, getBrain() → brain, onChange(nextBrain, { requiresSwitch, field }),
387
558
  * onAddAccount(provider), onManageAccounts(provider), onToast(text),
388
559
  * getMenuAnchor() → fallback anchor (the toolbar "⋯"),
389
- * menuPlacement: 'below' (default) | 'above' | 'auto' }
560
+ * menuPlacement: 'below' (default) | 'above' | 'auto',
561
+ * getTestDensity() → level | null (null hides the field), onTestDensity(level),
562
+ * onProjects(projects) — the registered projects were (re)loaded }
390
563
  */
391
564
  export function createBrainPicker(host, hooks = {}) {
392
565
  const t = tf(hooks.t);
@@ -415,6 +588,9 @@ export function createBrainPicker(host, hooks = {}) {
415
588
 
416
589
  let destroyed = false;
417
590
  let loading = null;
591
+ // The empty state's "Choose a project" (mountEmptyProject): the project field's second button.
592
+ let heroProject = null;
593
+ let heroProjectMascot = null;
418
594
 
419
595
  function currentBrain() {
420
596
  return normalizeBrain(hooks.getBrain?.());
@@ -454,9 +630,16 @@ export function createBrainPicker(host, hooks = {}) {
454
630
  return accounts.find(a => a.id === brain.accountId) || null;
455
631
  }
456
632
 
633
+ const accountProvider = (brain) => (brain.provider === 'codex' ? 'codex' : 'claude');
457
634
  function accountLabel(brain) {
458
635
  const acc = accountOf(brain);
459
- return acc ? (acc.label || acc.email || acc.id) : (brain.accountId || t('assistant.brain.accountDefault', 'Default'));
636
+ const name = acc ? accountName(acc, accountProvider(brain)) : (brain.accountId || t('assistant.brain.accountDefault', 'Default'));
637
+ // An account says what it runs on (its subscription or its API key), or that it is signed out.
638
+ if (acc && acc.mode !== undefined) {
639
+ const badge = accountBadge(acc, accountProvider(brain));
640
+ return badge.text ? `${name} · ${badge.text}` : name;
641
+ }
642
+ return name;
460
643
  }
461
644
 
462
645
  function modelLabel(brain = currentBrain()) {
@@ -479,13 +662,19 @@ export function createBrainPicker(host, hooks = {}) {
479
662
  : (level ? level.label : (brain.effort || t('assistant.brain.effortOff', 'off'))),
480
663
  variantSupported: brain.provider === 'opencode' && hasEfforts,
481
664
  variant: level ? level.label : (brain.effort || t('assistant.brain.variantDefault', 'default variant')),
482
- project: brain.project ? basename(brain.project) : t('assistant.brain.projectHome', 'Home'),
665
+ project: brain.project ? projectLabel(brain.project, _projectsCache || []) : t('assistant.brain.projectHome', 'Home'),
483
666
  mode: permissionModeLabel(brain, (id, fallback) => t(`assistant.brain.modes.${id}`, fallback)),
484
667
  mcp: presets[brain.mcpProfile]?.label || brain.mcpProfile || 'full',
485
668
  account: ACCOUNT_PROVIDERS.has(brain.provider) ? accountLabel(brain) : '',
486
669
  };
487
670
  }
488
671
 
672
+ /** The session's Test density, or null while the host has none to show (a server that does not know it). */
673
+ function testDensity() {
674
+ const level = typeof hooks.getTestDensity === 'function' ? hooks.getTestDensity() : null;
675
+ return level ? normalizeTestDensity(level) : null;
676
+ }
677
+
489
678
  function setBtn(field, { label, icon, color, hidden, disabled, tooltip, aria }) {
490
679
  const btn = buttons[field];
491
680
  if (!btn) return;
@@ -506,6 +695,13 @@ export function createBrainPicker(host, hooks = {}) {
506
695
  const v = values(brain);
507
696
  const modelTip = [v.meta.label, brain.model || t('assistant.brain.modelDefault', 'Default model'), v.account ? t('assistant.brain.asAccount', 'as {account}', { account: v.account }) : ''].filter(Boolean).join(' · ');
508
697
  setBtn('model', { label: v.model, icon: v.meta.icon, color: v.meta.color, tooltip: modelTip, aria: `${t('assistant.brain.model', 'Model')}: ${modelTip}` });
698
+ if (ACCOUNT_PROVIDERS.has(brain.provider)) {
699
+ const acc = accountOf(brain);
700
+ const label = buttons.model.querySelector('.asst-dd-label');
701
+ label.classList.add('asst-model-account-label');
702
+ const mode = acc ? accountCompactMode(acc, accountProvider(brain)) : t('accounts.checking', 'Checking…');
703
+ label.replaceChildren(el('span', 'asst-model-name', v.model), el('span', '', '·'), el('span', 'asst-model-account', acc ? accountName(acc, accountProvider(brain)) : brain.accountId || t('assistant.brain.accountDefault', 'Default')), el('span', '', '·'), el('span', 'asst-model-mode', mode));
704
+ } else buttons.model.querySelector('.asst-dd-label').classList.remove('asst-model-account-label');
509
705
  // A display name ("Sonnet 5") reads in sans; a raw id nothing names ("opus[1m]") stays mono.
510
706
  buttons.model?.toggleAttribute('data-raw', /^[a-z0-9][\w.\-[\]:/]*$/.test(String(v.model || '')));
511
707
 
@@ -515,12 +711,70 @@ export function createBrainPicker(host, hooks = {}) {
515
711
  setBtn('effort', { label: v.effort, icon: ICON_EFFORT, hidden: !v.effortSupported, tooltip: `${t('assistant.brain.effort', 'Thinking effort')}: ${v.effort}` });
516
712
  }
517
713
  setBtn('variant', { label: v.variant, icon: ICON_EFFORT, hidden: !v.variantSupported, tooltip: `${t('assistant.brain.variant', 'Model variant')}: ${v.variant}` });
518
- // Inline only when there is a choice to make; the "⋯" menu always has it (add / manage).
519
- const accountChoices = ACCOUNT_PROVIDERS.has(brain.provider) ? (brain.provider === 'codex' ? getCachedCodexAccounts() : getCachedClaudeAccounts()).length : 0;
520
- setBtn('account', { label: v.account, icon: ICON_ACCOUNT, hidden: accountChoices < 2, tooltip: `${t('assistant.brain.account', 'Account profile')}: ${v.account}` });
714
+ // A Claude or Codex brain always shows it: which account and which credential (its subscription or
715
+ // its API key) it runs on, or that the account is signed out.
716
+ const brainAcc = ACCOUNT_PROVIDERS.has(brain.provider) ? accountOf(brain) : null;
717
+ const accountTip = [v.account, brainAcc ? accountIdentity(brainAcc) : ''].filter(Boolean).join(' · ');
718
+ setBtn('account', { label: v.account, icon: ICON_ACCOUNT, hidden: true, tooltip: `${t('assistant.brain.account', 'Account profile')}: ${accountTip}` });
719
+ buttons.account?.classList.toggle('asst-dd-warn', !!brainAcc && brainAcc.signedIn === false);
521
720
  setBtn('project', { label: v.project, icon: ICON_FOLDER, tooltip: brain.project || t('assistant.brain.projectNoneTip', 'Working project: home directory'), aria: `${t('assistant.brain.project', 'Working project')}: ${brain.project || v.project}` });
522
721
  setBtn('mode', { label: v.mode, icon: ICON_SHIELD, tooltip: `${t('assistant.brain.mode', 'Permission mode')}: ${v.mode}` });
722
+ const tests = testDensity();
723
+ const testsLabel = tests ? testDensityLabel(tests, hooks.t) : '';
724
+ setBtn('tests', { label: testsLabel, icon: testsIcon(testDensityBars(tests)), hidden: !tests, tooltip: `${t('assistant.tests.title', 'Test density')}: ${testsLabel}` });
725
+ if (tests) buttons.tests.dataset.level = tests; else delete buttons.tests.dataset.level;
523
726
  setBtn('mcp', { label: v.mcp, icon: ICON_PLUG, tooltip: `${t('assistant.brain.mcp', 'MCP tool profile')}: ${v.mcp}` });
727
+ renderEmptyProject(brain);
728
+ }
729
+
730
+ function renderEmptyProject(brain = currentBrain()) {
731
+ const btn = heroProject;
732
+ if (!btn) return;
733
+ const view = emptyProjectView(brain.project, _projectsCache || [], t);
734
+ btn.dataset.chosen = view.chosen ? '1' : '0';
735
+ const key = btn.querySelector('.asst-empty-project-key');
736
+ key.textContent = view.key;
737
+ key.hidden = !view.key;
738
+ btn.querySelector('.asst-dd-label').textContent = view.label;
739
+ if (view.title) btn.setAttribute('data-tooltip', view.title); else btn.removeAttribute('data-tooltip');
740
+ btn.setAttribute('aria-label', view.aria);
741
+ }
742
+
743
+ /**
744
+ * The empty state's project picker, in `slot` (asst-render.js): one more
745
+ * button for the project field, drawn by render() like the toolbar's and
746
+ * opening the same menu, so the two always show the same project. A pick
747
+ * sends focus back to it. A new slot replaces the last one; null removes it.
748
+ */
749
+ function mountEmptyProject(slot) {
750
+ if (heroProject && isMenuOpen(heroProject)) closeMenu();
751
+ heroProjectMascot?.destroy();
752
+ heroProjectMascot = null;
753
+ heroProject?.remove();
754
+ heroProject = null;
755
+ if (destroyed || !slot || typeof slot.appendChild !== 'function') return null;
756
+ const btn = el('button', 'asst-dd asst-empty-control-btn asst-empty-project-btn');
757
+ btn.type = 'button';
758
+ btn.dataset.field = 'project';
759
+ btn.setAttribute('aria-haspopup', 'menu');
760
+ btn.setAttribute('aria-expanded', 'false');
761
+ // glyph | seam | "Project" + name | caret: the field's capsule at the suggestions' md size.
762
+ const icon = el('span', 'asst-icon');
763
+ icon.innerHTML = ICON_FOLDER;
764
+ const caret = el('span', 'asst-dd-caret');
765
+ caret.innerHTML = ICON_CARET;
766
+ for (const part of [icon, caret]) part.setAttribute('aria-hidden', 'true');
767
+ btn.append(icon, el('span', 'asst-empty-project-key'), el('span', 'asst-dd-label'), caret);
768
+ btn.addEventListener('click', (e) => {
769
+ e.stopPropagation();
770
+ if (isMenuOpen(btn)) { closeMenu(); return; }
771
+ openField('project', btn, { restoreFocus: true });
772
+ });
773
+ slot.appendChild(btn);
774
+ heroProject = btn;
775
+ heroProjectMascot = createMascotControl(btn, { director: hooks.mascot, pose: 'project' });
776
+ renderEmptyProject();
777
+ return btn;
524
778
  }
525
779
 
526
780
  /** Current values for the toolbar "⋯" menu (fields hidden inline stay reachable there). */
@@ -535,6 +789,7 @@ export function createBrainPicker(host, hooks = {}) {
535
789
  { field: 'variant', label: t('assistant.brain.variant', 'Model variant'), value: v.variant, icon: ICON_EFFORT, hidden: !v.variantSupported },
536
790
  { field: 'project', label: t('assistant.brain.project', 'Working project'), value: brain.project || v.project, icon: ICON_FOLDER, hidden: false },
537
791
  { field: 'mode', label: t('assistant.brain.mode', 'Permission mode'), value: v.mode, icon: ICON_SHIELD, hidden: false },
792
+ { field: 'tests', label: t('assistant.tests.title', 'Test density'), value: testDensity() ? testDensityLabel(testDensity(), hooks.t) : '', icon: testsIcon(testDensityBars(testDensity())), hidden: !testDensity() },
538
793
  { field: 'mcp', label: t('assistant.brain.mcp', 'MCP tool profile'), value: v.mcp, icon: ICON_PLUG, hidden: false },
539
794
  { field: 'account', label: t('assistant.brain.account', 'Account profile'), value: v.account, icon: ICON_ACCOUNT, hidden: !ACCOUNT_PROVIDERS.has(brain.provider) },
540
795
  ];
@@ -555,7 +810,12 @@ export function createBrainPicker(host, hooks = {}) {
555
810
  if (provider === 'codex') jobs.push(fetchCodexAccounts(force));
556
811
  if (provider === 'claude-code') jobs.push(fetchClaudeAccounts(force));
557
812
  if (provider === 'opencode') jobs.push(fetchOpencodeAgents(force));
558
- if (!_projectsCache || force) jobs.push(fetchProjects().then((p) => { _projectsCache = p; }).catch(() => { _projectsCache = _projectsCache || []; }));
813
+ if (!_projectsCache || force) {
814
+ jobs.push(fetchProjects().then((p) => {
815
+ _projectsCache = p;
816
+ if (!destroyed) { try { hooks.onProjects?.(p); } catch { /* the host's own failure */ } }
817
+ }).catch(() => { _projectsCache = _projectsCache || []; }));
818
+ }
559
819
  await Promise.allSettled(jobs);
560
820
  }
561
821
 
@@ -580,17 +840,22 @@ export function createBrainPicker(host, hooks = {}) {
580
840
  return fallback || btn || root;
581
841
  }
582
842
 
583
- /** Open a field menu; `anchorOverride` (e.g. the toolbar "⋯") when the inline button is hidden. */
584
- async function openField(field, anchorOverride = null) {
843
+ /**
844
+ * Open a field menu; `anchorOverride` (e.g. the toolbar "⋯") when the inline
845
+ * button is hidden. `restoreFocus`: a pick sends focus back to the anchor.
846
+ */
847
+ async function openField(field, anchorOverride = null, { restoreFocus = false } = {}) {
585
848
  const anchor = anchorFor(field, anchorOverride);
586
849
  const brain = currentBrain();
587
850
  if (field === 'model') {
851
+ if (ACCOUNT_PROVIDERS.has(brain.provider)) await ensureLoaded(brain.provider);
588
852
  await openModelMenu(anchor, {
589
853
  brain,
590
854
  t: hooks.t,
591
855
  placement,
592
856
  current: { provider: brain.provider, model: brain.model },
593
857
  warnSwitch: true,
858
+ accountItems: ACCOUNT_PROVIDERS.has(brain.provider) ? accountItems(brain, { composer: true }) : [],
594
859
  onToast: hooks.onToast,
595
860
  onManageModels: hooks.onManageModels,
596
861
  onSelect: (target) => {
@@ -601,6 +866,22 @@ export function createBrainPicker(host, hooks = {}) {
601
866
  });
602
867
  return;
603
868
  }
869
+ // The session's own setting: nothing to load.
870
+ if (field === 'tests') {
871
+ const current = testDensity();
872
+ if (!current) return;
873
+ openMenu(anchor, {
874
+ title: t('assistant.tests.title', 'Test density'),
875
+ items: TEST_DENSITIES.map((id) => ({
876
+ kind: 'radio', id, label: testDensityLabel(id, hooks.t),
877
+ desc: t(`assistant.tests.desc.${id}`, TEST_DENSITY_COPY[id][1]),
878
+ selected: id === current,
879
+ onSelect: () => { hooks.onTestDensity?.(id); render(); },
880
+ })),
881
+ placement, role: 'menu', className: 'asst-tests-menu',
882
+ });
883
+ return;
884
+ }
604
885
  // Everything else needs the provider catalog — load it lazily on first open.
605
886
  anchor.setAttribute('aria-busy', 'true');
606
887
  try { await ensureLoaded(brain.provider); } finally { anchor.removeAttribute('aria-busy'); }
@@ -639,29 +920,23 @@ export function createBrainPicker(host, hooks = {}) {
639
920
 
640
921
  if (field === 'account') {
641
922
  if (!ACCOUNT_PROVIDERS.has(brain.provider)) return;
642
- const accounts = brain.provider === 'codex' ? getCachedCodexAccounts() : getCachedClaudeAccounts();
643
- const items = accounts.map((a) => ({
644
- kind: 'radio',
645
- id: a.id,
646
- label: a.label || a.email || a.id,
647
- desc: a.email && a.email !== a.label ? a.email : (a.isDefault ? t('assistant.accounts.ambient', 'ambient config') : ''),
648
- selected: a.id === brain.accountId,
649
- onSelect: () => commit({ accountId: a.id }, 'account'),
650
- }));
651
- items.push({ kind: 'separator' });
652
- items.push({ id: '__add', label: t('assistant.accounts.add', 'Add account…'), onSelect: () => hooks.onAddAccount?.(brain.provider) });
653
- items.push({ id: '__manage', label: t('assistant.accounts.manage', 'Manage…'), onSelect: () => hooks.onManageAccounts?.(brain.provider) });
654
- menu(t('assistant.brain.account', 'Account profile'), items);
923
+ menu(t('assistant.brain.account', 'Account profile'), accountItems(brain), { width: 300 });
655
924
  return;
656
925
  }
657
926
 
658
927
  if (field === 'project') {
928
+ const actor = anchor === heroProject ? heroProjectMascot : null;
929
+ const pick = project => {
930
+ const changed = (currentBrain().project || null) !== (project || null);
931
+ commit({ project }, 'project');
932
+ actor?.commit(changed ? 'hop' : 'nod');
933
+ };
659
934
  const projects = _projectsCache || [];
660
- const items = [{ kind: 'radio', id: '', label: t('assistant.brain.projectNone', 'Home directory'), desc: t('assistant.brain.projectNoneDesc', 'no project'), selected: !brain.project, onSelect: () => commit({ project: null }, 'project') }];
661
- for (const p of projects) items.push({ kind: 'radio', id: p.path, label: p.label || basename(p.path), desc: p.path, selected: p.path === brain.project, onSelect: () => commit({ project: p.path }, 'project') });
935
+ const items = [{ kind: 'radio', id: '', label: t('assistant.brain.projectNone', 'Home directory'), desc: t('assistant.brain.projectNoneDesc', 'no project'), selected: !brain.project, restoreFocus, onSelect: () => pick(null) }];
936
+ for (const p of projects) items.push({ kind: 'radio', id: p.path, label: p.label || basename(p.path), desc: p.path, selected: p.path === brain.project, restoreFocus, onSelect: () => pick(p.path) });
662
937
  items.push({ kind: 'separator' });
663
- items.push({ kind: 'input', placeholder: t('assistant.brain.projectCustom', 'Type a directory path…'), value: brain.project && !projects.some(p => p.path === brain.project) ? brain.project : '', onSubmit: (v) => commit({ project: v }, 'project') });
664
- menu(t('assistant.brain.project', 'Working project'), items, { width: 280, filter: projects.length > 8, filterPlaceholder: t('assistant.brain.filterProjects', 'Filter projects…') });
938
+ items.push({ kind: 'input', placeholder: t('assistant.brain.projectCustom', 'Type a directory path…'), value: brain.project && !projects.some(p => p.path === brain.project) ? brain.project : '', restoreFocus, onSubmit: pick });
939
+ menu(t('assistant.brain.project', 'Working project'), items, { width: 280, filter: projects.length > 8, filterPlaceholder: t('assistant.brain.filterProjects', 'Filter projects…'), onOpen: node => actor?.open(node), onClose: () => actor?.close() });
665
940
  return;
666
941
  }
667
942
 
@@ -704,6 +979,69 @@ export function createBrainPicker(host, hooks = {}) {
704
979
  }
705
980
  }
706
981
 
982
+ // The brain's account menu (Claude or Codex): every account with what it runs on and who it is (a
983
+ // signed-out one is listed, not offered), the mode of the brain's account when it holds both credentials,
984
+ // and the account dispatched workers of that provider use when a dispatch names none.
985
+ function accountItems(brain, { composer = false } = {}) {
986
+ const codex = brain.provider === 'codex';
987
+ const p = codex ? 'codex' : 'claude';
988
+ const accounts = codex ? getCachedCodexAccounts() : getCachedClaudeAccounts();
989
+ const refetch = () => (codex ? fetchCodexAccounts(true) : fetchClaudeAccounts(true));
990
+ const items = accounts.map((a) => {
991
+ const badge = accountBadge(a, p);
992
+ const usable = accountUsable(a);
993
+ return {
994
+ kind: 'radio', id: a.id, label: accountName(a, p),
995
+ desc: [accountIdentity(a), accountUsageText(a, hooks.t), !usable ? t('assistant.accounts.signInFirst', 'Signed out: sign it in from Manage…') : composer && a.id !== brain.accountId ? t('assistant.brain.newConversation', 'starts a new conversation') : ''].filter(Boolean).join(' · '),
996
+ tag: badge.text, disabled: !usable,
997
+ selected: a.id === brain.accountId,
998
+ onSelect: () => commit({ accountId: a.id }, 'account'),
999
+ };
1000
+ });
1001
+ items.unshift({ kind: 'radio', id: 'auto', label: autoAccountLabel(hooks.t), desc: t('assistant.accountUsage.composerHint', 'Pick the subscription with most usage left and start a new conversation.'), onSelect: () => commit({ accountId: 'auto' }, 'account') });
1002
+ if (composer) for (const a of accounts.filter(a => !accountUsable(a))) items.push({ id: `signin:${a.id}`, label: t('assistant.accounts.signInAccount', 'Sign in to {label}…', { label: accountName(a, p) }), onSelect: () => hooks.onManageAccounts?.(brain.provider, a.id) });
1003
+ const current = accounts.find((a) => a.id === brain.accountId);
1004
+ const modes = modeChoices(current, p);
1005
+ if (modes.length) {
1006
+ items.push({ kind: 'separator' });
1007
+ items.push({ kind: 'header', label: t('assistant.accounts.usesHeader', '{label} uses (every session on it)', { label: accountName(current, p) }) });
1008
+ for (const m of modes) {
1009
+ items.push({
1010
+ kind: 'radio', id: `mode:${m.mode}`, label: m.label, desc: [m.detail, !m.current ? t('assistant.brain.newConversation', 'starts a new conversation') : ''].filter(Boolean).join(' · '), selected: m.current, disabled: hooks.getRunning?.() === true,
1011
+ onSelect: async () => {
1012
+ if (m.current) return;
1013
+ try {
1014
+ await (codex ? patchCodexAccount : patchClaudeAccount)(current.id, { mode: m.mode }); await refetch();
1015
+ hooks.onChange?.(currentBrain(), { requiresSwitch: true, restartBrain: true, field: 'accountMode', previous: brain }); render();
1016
+ }
1017
+ catch (err) { hooks.onToast?.(err?.message || String(err)); }
1018
+ },
1019
+ });
1020
+ }
1021
+ }
1022
+ const worker = codex ? getCodexWorkerAccountId() : getClaudeWorkerAccountId();
1023
+ if (!composer) {
1024
+ items.push({ kind: 'separator' });
1025
+ items.push({ kind: 'header', label: codex ? t('assistant.accounts.codexWorkersHeader', 'Codex workers use') : t('assistant.accounts.workersHeader', 'Claude Code workers use') });
1026
+ for (const a of accounts) {
1027
+ const usable = accountUsable(a);
1028
+ items.push({
1029
+ kind: 'radio', id: `worker:${a.id}`, label: accountName(a, p), tag: accountBadge(a, p).text,
1030
+ disabled: !usable && a.id !== worker, selected: a.id === worker,
1031
+ onSelect: async () => {
1032
+ if (a.id === worker) return;
1033
+ try { await (codex ? setCodexWorkerAccount : setClaudeWorkerAccount)(a.id); await refetch(); render(); }
1034
+ catch (err) { hooks.onToast?.(err?.message || String(err)); }
1035
+ },
1036
+ });
1037
+ }
1038
+ }
1039
+ items.push({ kind: 'separator' });
1040
+ items.push({ id: '__add', label: t('assistant.accounts.add', 'Add account…'), onSelect: () => hooks.onAddAccount?.(brain.provider) });
1041
+ items.push({ id: '__manage', label: t('assistant.accounts.manage', 'Manage…'), onSelect: () => hooks.onManageAccounts?.(brain.provider) });
1042
+ return items;
1043
+ }
1044
+
707
1045
  async function switchProvider(provider, model = '') {
708
1046
  const before = currentBrain();
709
1047
  if (provider === before.provider) return;
@@ -727,6 +1065,16 @@ export function createBrainPicker(host, hooks = {}) {
727
1065
 
728
1066
  render();
729
1067
  refresh().catch(() => {});
1068
+ // Any window changed a Claude or Codex account (signed in or out, a key, its mode, the worker choice,
1069
+ // added, removed): its list is read again and the picker re-drawn, so it never shows a stale state.
1070
+ const accountsChanged = (provider) => (msg) => {
1071
+ const which = provider || (msg?.provider === 'codex' ? 'codex' : msg?.provider === 'claude-code' ? 'claude-code' : null);
1072
+ const jobs = [];
1073
+ if (!which || which === 'claude-code') jobs.push(fetchClaudeAccounts(true));
1074
+ if (!which || which === 'codex') jobs.push(fetchCodexAccounts(true));
1075
+ Promise.all(jobs).then(() => { if (!destroyed) render(); }).catch(() => {});
1076
+ };
1077
+ const offAccounts = [on('sync:claude:accounts-changed', accountsChanged('claude-code')), on('sync:codex:accounts-changed', accountsChanged('codex')), on('sync:assistant:accounts-changed', accountsChanged(null))];
730
1078
 
731
1079
  return {
732
1080
  el: root,
@@ -737,8 +1085,9 @@ export function createBrainPicker(host, hooks = {}) {
737
1085
  openField,
738
1086
  fieldSummary,
739
1087
  modelLabel: () => modelLabel(),
1088
+ mountEmptyProject,
740
1089
  setBrain() { render(); },
741
1090
  close: closeMenu,
742
- destroy() { destroyed = true; closeMenu(); Object.values(buttons).forEach((b) => b.remove()); root.remove(); },
1091
+ destroy() { destroyed = true; for (const off of offAccounts) { try { off?.(); } catch {} } closeMenu(); heroProjectMascot?.destroy(); Object.values(buttons).forEach((b) => b.remove()); heroProject?.remove(); heroProject = null; root.remove(); },
743
1092
  };
744
1093
  }