@pellux/goodvibes-agent 0.1.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 (398) hide show
  1. package/.goodvibes/GOODVIBES.md +35 -0
  2. package/.goodvibes/agents/reviewer.md +48 -0
  3. package/.goodvibes/skills/add-provider/SKILL.md +199 -0
  4. package/CHANGELOG.md +25 -0
  5. package/README.md +74 -0
  6. package/bin/goodvibes-agent.ts +2 -0
  7. package/docs/README.md +23 -0
  8. package/docs/deployment-and-services.md +57 -0
  9. package/docs/getting-started.md +53 -0
  10. package/docs/release-and-publishing.md +46 -0
  11. package/package.json +134 -0
  12. package/scripts/check-bun.sh +20 -0
  13. package/src/audio/player.ts +156 -0
  14. package/src/audio/spoken-turn-controller.ts +203 -0
  15. package/src/audio/spoken-turn-model-routing.ts +117 -0
  16. package/src/audio/spoken-turn-wiring.ts +44 -0
  17. package/src/audio/text-chunker.ts +110 -0
  18. package/src/cli/bundle-command.ts +227 -0
  19. package/src/cli/completion.ts +90 -0
  20. package/src/cli/config-overrides.ts +159 -0
  21. package/src/cli/endpoints.ts +63 -0
  22. package/src/cli/entrypoint.ts +172 -0
  23. package/src/cli/help.ts +299 -0
  24. package/src/cli/index.ts +11 -0
  25. package/src/cli/management-commands.ts +426 -0
  26. package/src/cli/management.ts +744 -0
  27. package/src/cli/network-posture.ts +46 -0
  28. package/src/cli/package-verification.ts +123 -0
  29. package/src/cli/parser.ts +369 -0
  30. package/src/cli/provider-auth-routes.ts +22 -0
  31. package/src/cli/provider-classification.ts +107 -0
  32. package/src/cli/redaction.ts +105 -0
  33. package/src/cli/service-command.ts +26 -0
  34. package/src/cli/service-posture.ts +482 -0
  35. package/src/cli/status.ts +383 -0
  36. package/src/cli/surface-command.ts +247 -0
  37. package/src/cli/tui-startup.ts +32 -0
  38. package/src/cli/types.ts +69 -0
  39. package/src/cli-flags.ts +21 -0
  40. package/src/config/goodvibes-home-audit.ts +465 -0
  41. package/src/config/index.ts +57 -0
  42. package/src/config/provider-model.ts +23 -0
  43. package/src/config/secret-config.ts +119 -0
  44. package/src/config/secrets.ts +71 -0
  45. package/src/config/surface.ts +1 -0
  46. package/src/core/composer-state.ts +61 -0
  47. package/src/core/conversation-rendering.ts +359 -0
  48. package/src/core/conversation.ts +551 -0
  49. package/src/core/history.ts +45 -0
  50. package/src/core/orchestrator.ts +7 -0
  51. package/src/core/system-message-router.ts +171 -0
  52. package/src/daemon/cli.ts +55 -0
  53. package/src/daemon/safe-serve.ts +61 -0
  54. package/src/input/agent-workspace.ts +428 -0
  55. package/src/input/autocomplete.ts +96 -0
  56. package/src/input/bookmark-modal.ts +115 -0
  57. package/src/input/command-args-hint.ts +36 -0
  58. package/src/input/command-registry.ts +329 -0
  59. package/src/input/commands/agent-externalized-tui.ts +73 -0
  60. package/src/input/commands/agent-workspace-runtime.ts +17 -0
  61. package/src/input/commands/branch-runtime.ts +72 -0
  62. package/src/input/commands/cloudflare-runtime.ts +370 -0
  63. package/src/input/commands/config.ts +18 -0
  64. package/src/input/commands/control-room-runtime.ts +255 -0
  65. package/src/input/commands/conversation-runtime.ts +207 -0
  66. package/src/input/commands/discovery-runtime.ts +52 -0
  67. package/src/input/commands/eval.ts +204 -0
  68. package/src/input/commands/experience-runtime.ts +278 -0
  69. package/src/input/commands/guidance-runtime.ts +106 -0
  70. package/src/input/commands/health-runtime.ts +434 -0
  71. package/src/input/commands/hooks-runtime.ts +148 -0
  72. package/src/input/commands/incident-runtime.ts +95 -0
  73. package/src/input/commands/integration-runtime.ts +394 -0
  74. package/src/input/commands/intelligence-runtime.ts +223 -0
  75. package/src/input/commands/knowledge.ts +531 -0
  76. package/src/input/commands/local-auth-runtime.ts +105 -0
  77. package/src/input/commands/local-provider-runtime.ts +170 -0
  78. package/src/input/commands/local-runtime.ts +392 -0
  79. package/src/input/commands/local-setup-review.ts +199 -0
  80. package/src/input/commands/local-setup-transfer.ts +135 -0
  81. package/src/input/commands/local-setup.ts +282 -0
  82. package/src/input/commands/managed-runtime.ts +209 -0
  83. package/src/input/commands/marketplace-runtime.ts +290 -0
  84. package/src/input/commands/mcp-runtime.ts +432 -0
  85. package/src/input/commands/memory-product-runtime.ts +111 -0
  86. package/src/input/commands/memory.ts +151 -0
  87. package/src/input/commands/notify-runtime.ts +83 -0
  88. package/src/input/commands/onboarding-runtime.ts +14 -0
  89. package/src/input/commands/operator-panel-runtime.ts +146 -0
  90. package/src/input/commands/operator-runtime.ts +392 -0
  91. package/src/input/commands/planning-runtime.ts +205 -0
  92. package/src/input/commands/platform-access-runtime.ts +422 -0
  93. package/src/input/commands/platform-services-runtime.ts +246 -0
  94. package/src/input/commands/policy-dispatch.ts +339 -0
  95. package/src/input/commands/policy.ts +17 -0
  96. package/src/input/commands/product-runtime.ts +351 -0
  97. package/src/input/commands/profile-sync-runtime.ts +99 -0
  98. package/src/input/commands/provider-accounts-runtime.ts +113 -0
  99. package/src/input/commands/provider.ts +363 -0
  100. package/src/input/commands/qrcode-runtime.ts +20 -0
  101. package/src/input/commands/quit-shared.ts +162 -0
  102. package/src/input/commands/recall-bundle.ts +132 -0
  103. package/src/input/commands/recall-capture.ts +152 -0
  104. package/src/input/commands/recall-query.ts +229 -0
  105. package/src/input/commands/recall-review.ts +98 -0
  106. package/src/input/commands/recall-shared.ts +22 -0
  107. package/src/input/commands/remote-runtime-pool.ts +106 -0
  108. package/src/input/commands/remote-runtime-setup.ts +199 -0
  109. package/src/input/commands/remote-runtime.ts +431 -0
  110. package/src/input/commands/replay-runtime.ts +18 -0
  111. package/src/input/commands/runtime-services.ts +291 -0
  112. package/src/input/commands/schedule-runtime.ts +91 -0
  113. package/src/input/commands/services-runtime.ts +209 -0
  114. package/src/input/commands/session-content.ts +408 -0
  115. package/src/input/commands/session-workflow.ts +464 -0
  116. package/src/input/commands/session.ts +375 -0
  117. package/src/input/commands/settings-sync-runtime.ts +174 -0
  118. package/src/input/commands/share-runtime.ts +119 -0
  119. package/src/input/commands/shell-core.ts +307 -0
  120. package/src/input/commands/skills-runtime.ts +221 -0
  121. package/src/input/commands/subscription-runtime.ts +434 -0
  122. package/src/input/commands/tasks-runtime.ts +230 -0
  123. package/src/input/commands/teamwork-runtime.ts +339 -0
  124. package/src/input/commands/teleport-runtime.ts +57 -0
  125. package/src/input/commands/tts-runtime.ts +29 -0
  126. package/src/input/commands/work-plan-runtime.ts +169 -0
  127. package/src/input/commands.ts +131 -0
  128. package/src/input/feed-context-factory.ts +254 -0
  129. package/src/input/file-picker.ts +192 -0
  130. package/src/input/handler-command-route.ts +180 -0
  131. package/src/input/handler-content-actions.ts +497 -0
  132. package/src/input/handler-feed-routes.ts +648 -0
  133. package/src/input/handler-feed.ts +452 -0
  134. package/src/input/handler-interactions.ts +281 -0
  135. package/src/input/handler-modal-routes.ts +418 -0
  136. package/src/input/handler-modal-stack.ts +263 -0
  137. package/src/input/handler-modal-token-routes.ts +329 -0
  138. package/src/input/handler-onboarding-cloudflare.ts +391 -0
  139. package/src/input/handler-onboarding.ts +620 -0
  140. package/src/input/handler-picker-routes.ts +472 -0
  141. package/src/input/handler-prompt-buffer.ts +320 -0
  142. package/src/input/handler-shortcuts.ts +213 -0
  143. package/src/input/handler-ui-state.ts +372 -0
  144. package/src/input/handler.ts +729 -0
  145. package/src/input/input-history.ts +297 -0
  146. package/src/input/keybindings.ts +292 -0
  147. package/src/input/mcp-workspace.ts +554 -0
  148. package/src/input/model-picker-provider-filter.ts +28 -0
  149. package/src/input/model-picker-types.ts +137 -0
  150. package/src/input/model-picker.ts +797 -0
  151. package/src/input/onboarding/handler-onboarding-routes.ts +125 -0
  152. package/src/input/onboarding/onboarding-runtime-status.ts +87 -0
  153. package/src/input/onboarding/onboarding-wizard-apply.ts +277 -0
  154. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +494 -0
  155. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +204 -0
  156. package/src/input/onboarding/onboarding-wizard-constants.ts +158 -0
  157. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +130 -0
  158. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +762 -0
  159. package/src/input/onboarding/onboarding-wizard-helpers.ts +167 -0
  160. package/src/input/onboarding/onboarding-wizard-rules.ts +256 -0
  161. package/src/input/onboarding/onboarding-wizard-state.ts +365 -0
  162. package/src/input/onboarding/onboarding-wizard-steps.ts +798 -0
  163. package/src/input/onboarding/onboarding-wizard-types.ts +195 -0
  164. package/src/input/onboarding/onboarding-wizard.ts +711 -0
  165. package/src/input/panel-integration-actions.ts +78 -0
  166. package/src/input/profile-picker-modal.ts +222 -0
  167. package/src/input/search.ts +100 -0
  168. package/src/input/selection-modal.ts +163 -0
  169. package/src/input/selection.ts +135 -0
  170. package/src/input/session-picker-modal.ts +136 -0
  171. package/src/input/settings-modal-behavior.ts +37 -0
  172. package/src/input/settings-modal-secrets.ts +41 -0
  173. package/src/input/settings-modal-subscriptions.ts +95 -0
  174. package/src/input/settings-modal-types.ts +91 -0
  175. package/src/input/settings-modal.ts +793 -0
  176. package/src/input/submission-intent.ts +17 -0
  177. package/src/input/submission-router.ts +59 -0
  178. package/src/input/tts-settings-actions.ts +100 -0
  179. package/src/main.ts +792 -0
  180. package/src/mcp/runtime-reload.ts +81 -0
  181. package/src/panels/agent-inspector-panel.ts +521 -0
  182. package/src/panels/agent-inspector-shared.ts +94 -0
  183. package/src/panels/agent-logs-panel.ts +559 -0
  184. package/src/panels/agent-logs-shared.ts +129 -0
  185. package/src/panels/approval-panel.ts +150 -0
  186. package/src/panels/automation-control-panel.ts +212 -0
  187. package/src/panels/base-panel.ts +254 -0
  188. package/src/panels/builtin/agent.ts +117 -0
  189. package/src/panels/builtin/development.ts +31 -0
  190. package/src/panels/builtin/knowledge.ts +26 -0
  191. package/src/panels/builtin/operations.ts +349 -0
  192. package/src/panels/builtin/session.ts +129 -0
  193. package/src/panels/builtin/shared.ts +274 -0
  194. package/src/panels/builtin-panels.ts +23 -0
  195. package/src/panels/cockpit-panel.ts +183 -0
  196. package/src/panels/communication-panel.ts +153 -0
  197. package/src/panels/confirm-state.ts +61 -0
  198. package/src/panels/context-visualizer-panel.ts +204 -0
  199. package/src/panels/control-plane-panel.ts +211 -0
  200. package/src/panels/cost-tracker-panel.ts +444 -0
  201. package/src/panels/debug-panel.ts +432 -0
  202. package/src/panels/diff-panel.ts +520 -0
  203. package/src/panels/docs-panel.ts +283 -0
  204. package/src/panels/eval-panel.ts +399 -0
  205. package/src/panels/file-explorer-panel.ts +584 -0
  206. package/src/panels/file-preview-panel.ts +434 -0
  207. package/src/panels/forensics-panel.ts +364 -0
  208. package/src/panels/git-panel.ts +638 -0
  209. package/src/panels/hooks-panel.ts +239 -0
  210. package/src/panels/incident-review-panel.ts +197 -0
  211. package/src/panels/index.ts +46 -0
  212. package/src/panels/intelligence-panel.ts +176 -0
  213. package/src/panels/knowledge-panel.ts +345 -0
  214. package/src/panels/local-auth-panel.ts +130 -0
  215. package/src/panels/marketplace-panel.ts +212 -0
  216. package/src/panels/memory-panel.ts +225 -0
  217. package/src/panels/ops-control-panel.ts +150 -0
  218. package/src/panels/ops-strategy-panel.ts +235 -0
  219. package/src/panels/orchestration-panel.ts +273 -0
  220. package/src/panels/panel-list-panel.ts +509 -0
  221. package/src/panels/panel-manager.ts +570 -0
  222. package/src/panels/panel-picker.ts +106 -0
  223. package/src/panels/plan-dashboard-panel.ts +274 -0
  224. package/src/panels/plugins-panel.ts +178 -0
  225. package/src/panels/policy-panel.ts +308 -0
  226. package/src/panels/polish.ts +717 -0
  227. package/src/panels/project-planning-panel.ts +711 -0
  228. package/src/panels/provider-account-snapshot.ts +259 -0
  229. package/src/panels/provider-accounts-panel.ts +218 -0
  230. package/src/panels/provider-health-domains.ts +215 -0
  231. package/src/panels/provider-health-panel.ts +727 -0
  232. package/src/panels/provider-health-tracker.ts +115 -0
  233. package/src/panels/provider-stats-panel.ts +366 -0
  234. package/src/panels/qr-panel.ts +182 -0
  235. package/src/panels/remote-panel.ts +449 -0
  236. package/src/panels/routes-panel.ts +178 -0
  237. package/src/panels/sandbox-panel.ts +283 -0
  238. package/src/panels/schedule-panel.ts +329 -0
  239. package/src/panels/scrollable-list-panel.ts +491 -0
  240. package/src/panels/search-focus.ts +32 -0
  241. package/src/panels/security-panel.ts +295 -0
  242. package/src/panels/services-panel.ts +231 -0
  243. package/src/panels/session-browser-panel.ts +400 -0
  244. package/src/panels/session-maintenance.ts +125 -0
  245. package/src/panels/settings-sync-panel.ts +120 -0
  246. package/src/panels/skills-panel.ts +431 -0
  247. package/src/panels/subscription-panel.ts +263 -0
  248. package/src/panels/symbol-outline-panel.ts +486 -0
  249. package/src/panels/system-messages-panel.ts +230 -0
  250. package/src/panels/tasks-panel.ts +399 -0
  251. package/src/panels/thinking-panel.ts +304 -0
  252. package/src/panels/token-budget-panel.ts +475 -0
  253. package/src/panels/tool-inspector-panel.ts +429 -0
  254. package/src/panels/types.ts +54 -0
  255. package/src/panels/watchers-panel.ts +193 -0
  256. package/src/panels/work-plan-panel.ts +175 -0
  257. package/src/panels/worktree-panel.ts +182 -0
  258. package/src/panels/wrfc-panel.ts +609 -0
  259. package/src/permissions/prompt.ts +165 -0
  260. package/src/planning/project-planning-coordinator.ts +543 -0
  261. package/src/plugins/loader.ts +15 -0
  262. package/src/renderer/agent-detail-modal.ts +331 -0
  263. package/src/renderer/agent-workspace.ts +238 -0
  264. package/src/renderer/ansi-sanitize.ts +76 -0
  265. package/src/renderer/autocomplete-overlay.ts +154 -0
  266. package/src/renderer/block-actions.ts +76 -0
  267. package/src/renderer/bookmark-modal.ts +101 -0
  268. package/src/renderer/bottom-bar.ts +58 -0
  269. package/src/renderer/buffer.ts +113 -0
  270. package/src/renderer/code-block.ts +373 -0
  271. package/src/renderer/compositor.ts +283 -0
  272. package/src/renderer/context-inspector.ts +219 -0
  273. package/src/renderer/conversation-layout.ts +67 -0
  274. package/src/renderer/conversation-overlays.ts +140 -0
  275. package/src/renderer/conversation-surface.ts +260 -0
  276. package/src/renderer/diff-view.ts +132 -0
  277. package/src/renderer/diff.ts +130 -0
  278. package/src/renderer/file-picker-overlay.ts +101 -0
  279. package/src/renderer/file-tree.ts +153 -0
  280. package/src/renderer/fullscreen-primitives.ts +130 -0
  281. package/src/renderer/fullscreen-workspace.ts +199 -0
  282. package/src/renderer/git-status.ts +89 -0
  283. package/src/renderer/help-overlay.ts +267 -0
  284. package/src/renderer/history-search-overlay.ts +73 -0
  285. package/src/renderer/layout-engine.ts +97 -0
  286. package/src/renderer/layout.ts +32 -0
  287. package/src/renderer/live-tail-modal.ts +156 -0
  288. package/src/renderer/markdown.ts +635 -0
  289. package/src/renderer/mcp-workspace.ts +237 -0
  290. package/src/renderer/modal-factory.ts +467 -0
  291. package/src/renderer/modal-utils.ts +24 -0
  292. package/src/renderer/model-picker-overlay.ts +473 -0
  293. package/src/renderer/model-workspace.ts +488 -0
  294. package/src/renderer/onboarding/onboarding-wizard.ts +615 -0
  295. package/src/renderer/overlay-box.ts +146 -0
  296. package/src/renderer/overlay-viewport.ts +104 -0
  297. package/src/renderer/panel-composite.ts +158 -0
  298. package/src/renderer/panel-picker-overlay.ts +202 -0
  299. package/src/renderer/panel-tab-bar.ts +69 -0
  300. package/src/renderer/panel-workspace-bar.ts +42 -0
  301. package/src/renderer/process-indicator.ts +96 -0
  302. package/src/renderer/process-modal.ts +656 -0
  303. package/src/renderer/process-summary.ts +67 -0
  304. package/src/renderer/profile-picker-modal.ts +129 -0
  305. package/src/renderer/progress.ts +98 -0
  306. package/src/renderer/qr-renderer.ts +120 -0
  307. package/src/renderer/search-overlay.ts +54 -0
  308. package/src/renderer/selection-modal-overlay.ts +214 -0
  309. package/src/renderer/semantic-diff.ts +369 -0
  310. package/src/renderer/session-picker-modal.ts +127 -0
  311. package/src/renderer/settings-modal-helpers.ts +193 -0
  312. package/src/renderer/settings-modal.ts +537 -0
  313. package/src/renderer/shell-surface.ts +88 -0
  314. package/src/renderer/status-glyphs.ts +21 -0
  315. package/src/renderer/status-token.ts +67 -0
  316. package/src/renderer/surface-layout.ts +101 -0
  317. package/src/renderer/syntax-highlighter.ts +542 -0
  318. package/src/renderer/system-message.ts +83 -0
  319. package/src/renderer/tab-strip.ts +108 -0
  320. package/src/renderer/text-layout.ts +31 -0
  321. package/src/renderer/thinking.ts +17 -0
  322. package/src/renderer/tool-call.ts +234 -0
  323. package/src/renderer/ui-factory.ts +524 -0
  324. package/src/renderer/ui-primitives.ts +96 -0
  325. package/src/runtime/bootstrap-command-context.ts +278 -0
  326. package/src/runtime/bootstrap-command-parts.ts +386 -0
  327. package/src/runtime/bootstrap-core.ts +540 -0
  328. package/src/runtime/bootstrap-hook-bridge.ts +112 -0
  329. package/src/runtime/bootstrap-shell.ts +283 -0
  330. package/src/runtime/bootstrap.ts +575 -0
  331. package/src/runtime/cloudflare-control-plane.ts +349 -0
  332. package/src/runtime/context.ts +142 -0
  333. package/src/runtime/diagnostics/panels/index.ts +24 -0
  334. package/src/runtime/diagnostics/panels/ops.ts +156 -0
  335. package/src/runtime/diagnostics/panels/panel-resources.ts +118 -0
  336. package/src/runtime/diagnostics/panels/policy.ts +177 -0
  337. package/src/runtime/index.ts +662 -0
  338. package/src/runtime/onboarding/apply.ts +642 -0
  339. package/src/runtime/onboarding/derivation.ts +534 -0
  340. package/src/runtime/onboarding/index.ts +7 -0
  341. package/src/runtime/onboarding/markers.ts +148 -0
  342. package/src/runtime/onboarding/snapshot.ts +406 -0
  343. package/src/runtime/onboarding/state.ts +141 -0
  344. package/src/runtime/onboarding/types.ts +404 -0
  345. package/src/runtime/onboarding/verify.ts +171 -0
  346. package/src/runtime/operator-token-cleanup.ts +27 -0
  347. package/src/runtime/perf/panel-contracts.ts +32 -0
  348. package/src/runtime/perf/panel-health-monitor.ts +18 -0
  349. package/src/runtime/sandbox-public-gaps.ts +358 -0
  350. package/src/runtime/services.ts +670 -0
  351. package/src/runtime/store/domains/domain-read-matrix.ts +15 -0
  352. package/src/runtime/store/domains/index.ts +222 -0
  353. package/src/runtime/store/domains/panels.ts +117 -0
  354. package/src/runtime/store/domains/ui-perf.ts +103 -0
  355. package/src/runtime/store/index.ts +305 -0
  356. package/src/runtime/store/selectors/index.ts +359 -0
  357. package/src/runtime/store/state.ts +145 -0
  358. package/src/runtime/surface-feature-flags.ts +65 -0
  359. package/src/runtime/terminal-output-guard.ts +228 -0
  360. package/src/runtime/ui/index.ts +39 -0
  361. package/src/runtime/ui/model-picker/data-provider.ts +182 -0
  362. package/src/runtime/ui/model-picker/health-enrichment.ts +228 -0
  363. package/src/runtime/ui/model-picker/index.ts +59 -0
  364. package/src/runtime/ui/model-picker/types.ts +149 -0
  365. package/src/runtime/ui/provider-health/data-provider.ts +244 -0
  366. package/src/runtime/ui/provider-health/fallback-visualizer.ts +71 -0
  367. package/src/runtime/ui/provider-health/index.ts +46 -0
  368. package/src/runtime/ui/provider-health/types.ts +146 -0
  369. package/src/runtime/ui-events.ts +1 -0
  370. package/src/runtime/ui-read-model-helpers.ts +1 -0
  371. package/src/runtime/ui-read-models-observability-maintenance.ts +1 -0
  372. package/src/runtime/ui-read-models-observability-options.ts +1 -0
  373. package/src/runtime/ui-read-models-observability-remote.ts +1 -0
  374. package/src/runtime/ui-read-models-observability-security.ts +1 -0
  375. package/src/runtime/ui-read-models-observability-system.ts +1 -0
  376. package/src/runtime/ui-read-models-observability.ts +1 -0
  377. package/src/runtime/ui-read-models.ts +61 -0
  378. package/src/runtime/ui-service-queries.ts +1 -0
  379. package/src/runtime/ui-services.ts +190 -0
  380. package/src/scripts/process-messages.ts +42 -0
  381. package/src/shell/blocking-input.ts +98 -0
  382. package/src/shell/service-settings-sync.ts +273 -0
  383. package/src/shell/ui-openers.ts +352 -0
  384. package/src/tools/index.ts +1 -0
  385. package/src/tools/wrfc-agent-guard.ts +49 -0
  386. package/src/types/grid.ts +48 -0
  387. package/src/types/sql-js.d.ts +15 -0
  388. package/src/utils/clipboard.ts +22 -0
  389. package/src/utils/splash-lines.ts +46 -0
  390. package/src/utils/terminal-width.ts +185 -0
  391. package/src/verification/live-verifier.ts +430 -0
  392. package/src/verification/verification-ledger.ts +242 -0
  393. package/src/version.ts +17 -0
  394. package/src/widget/index.ts +2 -0
  395. package/src/widget/types.ts +9 -0
  396. package/src/widget/widget.ts +8 -0
  397. package/src/work-plans/work-plan-store.ts +374 -0
  398. package/tsconfig.json +18 -0
@@ -0,0 +1,797 @@
1
+ import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
2
+ import type { FavoritesStore } from '@pellux/goodvibes-sdk/platform/providers';
3
+ import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
4
+ import { getQualityTier, getQualityTierFromScore, compositeScore, A_TIER_THRESHOLD } from '@pellux/goodvibes-sdk/platform/providers';
5
+ import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
6
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
7
+ import { detectFamily, POPULAR_PROVIDERS, tierToCategoryFilter } from './model-picker-types.ts';
8
+ import type { BenchmarkSort, CapabilityFilter, CategoryFilter, FilteredModelsCache, FilteredProvidersCache, GroupByMode, ModelItemsCache, ModelPickerFocusPane, ModelPickerTarget, ModelPickerTargetInfo, PickerItem, PickerMode, ProviderItemsCache } from './model-picker-types.ts';
9
+ import { filterProviders, groupProviders } from './model-picker-provider-filter.ts';
10
+
11
+ export { detectFamily, POPULAR_PROVIDERS, tierToCategoryFilter } from './model-picker-types.ts';
12
+ export type { BenchmarkSort, CapabilityFilter, CategoryFilter, GroupByMode, ModelFamily, ModelPickerFocusPane, ModelPickerTarget, ModelPickerTargetInfo, PickerItem, PickerMode } from './model-picker-types.ts';
13
+
14
+ /**
15
+ * ModelPickerModal - Multi-step interactive picker for model, provider, and effort.
16
+ * Supports three modes: 'model', 'provider', 'effort'.
17
+ *
18
+ * Stage 5 features:
19
+ * - Pricing tier filter: Free / Paid / Subscription / All
20
+ * - Family grouping: GPT, Claude, Gemini, Llama, Qwen, etc.
21
+ * - Capability filters: reasoning, toolUse, multimodal
22
+ * - Available-only toggle (filters to configured providers)
23
+ * - Benchmark sort: composite / SWE / GPQA
24
+ * - Group-by cycling: provider → family → pricingTier → qualityTier
25
+ * - Quality tier badge (S/A/B/C) from model-benchmarks
26
+ * - Pinned/favorite indicator
27
+ */
28
+ export class ModelPickerModal {
29
+ constructor(
30
+ private readonly favoritesStore: Pick<FavoritesStore, 'getRecentModels'>,
31
+ private readonly benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
32
+ private readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
33
+ ) {}
34
+
35
+ public active = false;
36
+ public mode: PickerMode = 'model';
37
+ /** Which config target this picker session will write to on commit. */
38
+ public target: ModelPickerTarget = 'main';
39
+ public focusPane: ModelPickerFocusPane = 'items';
40
+ public targetInfos: ModelPickerTargetInfo[] = [];
41
+ public targetIndex = 0;
42
+ public searchFocused = false;
43
+ /** Tracks the mode we came from, for back-navigation. */
44
+ public previousMode: PickerMode | null = null;
45
+ public selectedIndex = 0;
46
+ /** Scroll offset for the visible item window (tracks first visible item index). */
47
+ public scrollOffset = 0;
48
+ public models: ModelDefinition[] = [];
49
+ public providers: string[] = [];
50
+ public effortLevels: string[] = [];
51
+ /** The model chosen in model-mode, awaiting effort selection. */
52
+ public pendingModel: ModelDefinition | null = null;
53
+ /** The model awaiting context cap input (contextCap mode). */
54
+ public contextCapPendingModel: ModelDefinition | null = null;
55
+ /** Current input string in contextCap mode. */
56
+ public contextCapQuery = '';
57
+
58
+ // ── Search / filter ──────────────────────────────────────────────────────────
59
+ /** Current search query string (empty = no filter). */
60
+ public query = '';
61
+ /** Active pricing tier filter. */
62
+ public categoryFilter: CategoryFilter = 'all';
63
+ /** Active capability filter. */
64
+ public capabilityFilter: CapabilityFilter = 'none';
65
+ /** When true, only show models from providers with a configured API key. */
66
+ public availableOnly = true;
67
+ /** Set of provider names that have a configured key (used for availableOnly filter). */
68
+ public configuredProviders: Set<string> = new Set();
69
+ /** How each provider is configured — drives badge display in provider mode. */
70
+ public configuredViaMap: Map<string, 'env' | 'secrets' | 'subscription' | 'anonymous'> = new Map();
71
+ /** IDs of pinned/favorite models — shown at top of list. */
72
+ public pinnedIds: Set<string> = new Set();
73
+ /** IDs of recently used models — shown after pinned, before the rest. */
74
+ public recentIds: string[] = [];
75
+ /** Benchmark score sort order. */
76
+ public benchmarkSort: BenchmarkSort = 'none';
77
+ /** Current group-by mode. */
78
+ public groupBy: GroupByMode = 'provider';
79
+
80
+ private filteredModelsCache: FilteredModelsCache | null = null;
81
+ private filteredProvidersCache: FilteredProvidersCache | null = null;
82
+ private modelItemsCache: ModelItemsCache | null = null;
83
+ private providerItemsCache: ProviderItemsCache | null = null;
84
+
85
+ setTargetInfos(infos: ModelPickerTargetInfo[]): void {
86
+ this.targetInfos = infos;
87
+ const idx = infos.findIndex((entry) => entry.target === this.target);
88
+ this.targetIndex = idx >= 0 ? idx : 0;
89
+ }
90
+
91
+ getSelectedTargetInfo(): ModelPickerTargetInfo | null {
92
+ return this.targetInfos[this.targetIndex] ?? null;
93
+ }
94
+
95
+ focusTargets(): void {
96
+ this.focusPane = 'targets';
97
+ this.searchFocused = false;
98
+ }
99
+
100
+ focusItems(): void {
101
+ this.focusPane = 'items';
102
+ }
103
+
104
+ moveTarget(delta: number): void {
105
+ if (this.targetInfos.length === 0) return;
106
+ const nextIndex = (this.targetIndex + delta + this.targetInfos.length) % this.targetInfos.length;
107
+ this.setTarget(this.targetInfos[nextIndex]!.target);
108
+ }
109
+
110
+ setTarget(target: ModelPickerTarget): void {
111
+ this.target = target;
112
+ const idx = this.targetInfos.findIndex((entry) => entry.target === target);
113
+ this.targetIndex = idx >= 0 ? idx : this.targetIndex;
114
+ this.alignSelectionToTarget();
115
+ }
116
+
117
+ alignSelectionToTarget(): void {
118
+ const info = this.getSelectedTargetInfo();
119
+ if (!info) return;
120
+ if (this.mode === 'provider') {
121
+ const providers = this.getFilteredProviders();
122
+ const providerIdx = providers.findIndex((provider) => provider === info.provider);
123
+ this.selectedIndex = providerIdx >= 0 ? providerIdx : 0;
124
+ this.scrollOffset = 0;
125
+ this._scrollToSelection(20);
126
+ return;
127
+ }
128
+ if (this.mode === 'model') {
129
+ const models = this.getFilteredModels();
130
+ const modelIdx = models.findIndex((model) => model.registryKey === info.model || model.id === info.model);
131
+ this.selectedIndex = modelIdx >= 0 ? modelIdx : 0;
132
+ this.scrollOffset = 0;
133
+ this._scrollToSelection(20);
134
+ }
135
+ }
136
+
137
+ // ── Category filter cycling ───────────────────────────────────────────────
138
+ private static readonly CATEGORY_CYCLE: CategoryFilter[] = ['all', 'free', 'paid', 'subscription'];
139
+ /** Cycle to next pricing tier filter. */
140
+ cycleCategory(): void {
141
+ const idx = ModelPickerModal.CATEGORY_CYCLE.indexOf(this.categoryFilter);
142
+ this.categoryFilter = ModelPickerModal.CATEGORY_CYCLE[(idx + 1) % ModelPickerModal.CATEGORY_CYCLE.length];
143
+ this.clearFilteredCaches();
144
+ this._clampSelection();
145
+ }
146
+
147
+ // ── Group-by cycling ──────────────────────────────────────────────────────
148
+ private static readonly GROUP_BY_CYCLE: GroupByMode[] = ['provider', 'family', 'pricingTier', 'qualityTier'];
149
+ /** Cycle to next group-by mode. */
150
+ cycleGroupBy(): void {
151
+ const idx = ModelPickerModal.GROUP_BY_CYCLE.indexOf(this.groupBy);
152
+ this.groupBy = ModelPickerModal.GROUP_BY_CYCLE[(idx + 1) % ModelPickerModal.GROUP_BY_CYCLE.length];
153
+ this.clearFilteredCaches();
154
+ this._clampSelection();
155
+ }
156
+
157
+ // ── Benchmark sort cycling ────────────────────────────────────────────────
158
+ private static readonly BENCHMARK_SORT_CYCLE: BenchmarkSort[] = ['none', 'composite', 'swe', 'gpqa'];
159
+ /** Cycle to next benchmark sort order. */
160
+ cycleBenchmarkSort(): void {
161
+ const idx = ModelPickerModal.BENCHMARK_SORT_CYCLE.indexOf(this.benchmarkSort);
162
+ this.benchmarkSort = ModelPickerModal.BENCHMARK_SORT_CYCLE[(idx + 1) % ModelPickerModal.BENCHMARK_SORT_CYCLE.length];
163
+ this.clearFilteredCaches();
164
+ this._clampSelection();
165
+ }
166
+
167
+ /**
168
+ * Return true when a model is from a custom or discovered (local) provider.
169
+ * Local models have `contextWindowProvenance` set; catalog cloud models do not.
170
+ */
171
+ isLocalModel(model: ModelDefinition): boolean {
172
+ return model.contextWindowProvenance !== undefined;
173
+ }
174
+
175
+ /** Enter context window cap input mode for a local model. */
176
+ enterContextCapMode(model: ModelDefinition): void {
177
+ this.previousMode = 'model';
178
+ this.contextCapPendingModel = model;
179
+ this.contextCapQuery = '';
180
+ this.mode = 'contextCap';
181
+ }
182
+
183
+ /** Append a character to the context cap query. */
184
+ appendContextCapChar(ch: string): void {
185
+ // Only allow digits; limit to 9 characters (max representable: 999_999_999)
186
+ if (this.contextCapQuery.length >= 9) return;
187
+ if (/^[0-9]$/.test(ch)) {
188
+ this.contextCapQuery += ch;
189
+ }
190
+ }
191
+
192
+ /** Delete the last character from the context cap query. */
193
+ deleteContextCapChar(): void {
194
+ if (this.contextCapQuery.length > 0) {
195
+ this.contextCapQuery = this.contextCapQuery.slice(0, -1);
196
+ }
197
+ }
198
+
199
+ /** Open showing all models — entry point for /model */
200
+ openAllModels(models: ModelDefinition[], currentModelId: string): void {
201
+ this.models = models;
202
+ this.mode = 'model';
203
+ this.active = true;
204
+ this.pendingModel = null;
205
+ this.focusPane = 'items';
206
+ this.searchFocused = false;
207
+ this.query = '';
208
+ this.categoryFilter = 'all';
209
+ this.capabilityFilter = 'none';
210
+ const filtered = this.getFilteredModels();
211
+ const idx = filtered.findIndex(m => m.id === currentModelId);
212
+ this.selectedIndex = idx >= 0 ? idx : 0;
213
+ this.scrollOffset = 0;
214
+ }
215
+
216
+ /** Open showing providers first — entry point for /provider */
217
+ openProviders(providers: string[], currentProvider: string): void {
218
+ this.previousMode = null;
219
+ this.providers = providers;
220
+ this.mode = 'provider';
221
+ this.active = true;
222
+ this.pendingModel = null;
223
+ this.focusPane = 'items';
224
+ this.searchFocused = false;
225
+ this.query = '';
226
+ this.categoryFilter = 'all';
227
+ this.capabilityFilter = 'none';
228
+ const filtered = this.getFilteredProviders();
229
+ const currentIndex = filtered.findIndex((provider) => provider === currentProvider);
230
+ this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
231
+ this.scrollOffset = 0;
232
+ }
233
+
234
+ /** Transition to model list filtered by provider (called from provider mode Enter). */
235
+ showModelsForProvider(models: ModelDefinition[], _provider: string): void {
236
+ this.previousMode = 'provider';
237
+ this.models = models;
238
+ this.mode = 'model';
239
+ this.searchFocused = false;
240
+ this.query = '';
241
+ this.categoryFilter = 'all';
242
+ this.capabilityFilter = 'none';
243
+ // User explicitly chose this provider — disable availability filter so synthetic
244
+ // models (which have no real API key) are not filtered out.
245
+ this.availableOnly = false;
246
+ this.selectedIndex = 0;
247
+ this.scrollOffset = 0;
248
+ }
249
+
250
+ /** Transition to effort picker after model is chosen. */
251
+ showEffortPicker(model: ModelDefinition, currentEffort: string): void {
252
+ this.previousMode = 'model';
253
+ this.pendingModel = model;
254
+ this.searchFocused = false;
255
+ this.effortLevels = model.reasoningEffort ?? [];
256
+ this.mode = 'effort';
257
+ const idx = this.effortLevels.indexOf(currentEffort);
258
+ this.selectedIndex = idx >= 0 ? idx : 0;
259
+ this.scrollOffset = 0;
260
+ }
261
+
262
+ /** Close the picker entirely. */
263
+ close(): void {
264
+ this.active = false;
265
+ this.mode = 'model';
266
+ this.target = 'main';
267
+ this.focusPane = 'items';
268
+ this.targetInfos = [];
269
+ this.targetIndex = 0;
270
+ this.models = [];
271
+ this.providers = [];
272
+ this.pendingModel = null;
273
+ this.contextCapPendingModel = null;
274
+ this.contextCapQuery = '';
275
+ this.searchFocused = false;
276
+ this.selectedIndex = 0;
277
+ this.scrollOffset = 0;
278
+ this.query = '';
279
+ this.categoryFilter = 'all';
280
+ this.capabilityFilter = 'none';
281
+ this.clearCaches();
282
+ }
283
+
284
+ // ── Search helpers ─────────────────────────────────────────────────────────
285
+
286
+ /** Append a character to the search query and clamp selectedIndex. */
287
+ appendChar(ch: string): void {
288
+ this.query += ch;
289
+ this.clearFilteredCaches();
290
+ this._clampSelection();
291
+ }
292
+
293
+ /** Delete the last character from the search query and clamp selectedIndex. */
294
+ deleteChar(): void {
295
+ if (this.query.length > 0) {
296
+ this.query = this.query.slice(0, -1);
297
+ this.clearFilteredCaches();
298
+ this._clampSelection();
299
+ }
300
+ }
301
+
302
+ /** Clear the search query and clamp selectedIndex. */
303
+ clearQuery(): void {
304
+ this.query = '';
305
+ this.clearFilteredCaches();
306
+ this._clampSelection();
307
+ }
308
+
309
+ canFocusSearch(): boolean {
310
+ return this.mode === 'model' || this.mode === 'provider';
311
+ }
312
+
313
+ focusSearch(): void {
314
+ if (this.canFocusSearch()) this.searchFocused = true;
315
+ }
316
+
317
+ blurSearch(): void {
318
+ this.searchFocused = false;
319
+ }
320
+
321
+ /** Set category filter and clamp selectedIndex. */
322
+ setCategoryFilter(filter: CategoryFilter): void {
323
+ this.categoryFilter = filter;
324
+ this.clearFilteredCaches();
325
+ this._clampSelection();
326
+ }
327
+
328
+ /** Set capability filter and clamp selectedIndex. */
329
+ setCapabilityFilter(filter: CapabilityFilter): void {
330
+ this.capabilityFilter = filter;
331
+ this.clearFilteredCaches();
332
+ this._clampSelection();
333
+ }
334
+
335
+ /** Toggle the available-only filter. */
336
+ toggleAvailableOnly(): void {
337
+ this.availableOnly = !this.availableOnly;
338
+ this.clearFilteredCaches();
339
+ this._clampSelection();
340
+ }
341
+
342
+ /**
343
+ * Split providers into two ordered groups: Popular, All.
344
+ * Each group is alphabetized. Popular contains providers in POPULAR_PROVIDERS;
345
+ * All contains the rest. Configuration status is shown via checkmarks in the
346
+ * renderer and does not affect grouping.
347
+ */
348
+ getGroupedProviders(): { popular: string[]; all: string[] } {
349
+ return groupProviders(this.providers);
350
+ }
351
+
352
+ /** Return providers matching the current query (case-insensitive substring), in grouped order. */
353
+ getFilteredProviders(): string[] {
354
+ const cached = this.filteredProvidersCache;
355
+ if (
356
+ cached !== null
357
+ && cached.providersRef === this.providers
358
+ && cached.query === this.query
359
+ ) {
360
+ return cached.result;
361
+ }
362
+
363
+ const result = filterProviders(this.providers, this.query);
364
+ this.filteredProvidersCache = {
365
+ providersRef: this.providers,
366
+ query: this.query,
367
+ result,
368
+ };
369
+ return result;
370
+ }
371
+
372
+ /** Return models matching all current filters, sorted per benchmarkSort. */
373
+ getFilteredModels(): ModelDefinition[] {
374
+ const configuredProvidersKey = setKey(this.configuredProviders);
375
+ const pinnedIdsKey = setKey(this.pinnedIds);
376
+ const recentIdsKey = orderedListKey(this.recentIds);
377
+ const cached = this.filteredModelsCache;
378
+ if (
379
+ cached !== null
380
+ && cached.modelsRef === this.models
381
+ && cached.configuredProvidersKey === configuredProvidersKey
382
+ && cached.pinnedIdsKey === pinnedIdsKey
383
+ && cached.recentIdsKey === recentIdsKey
384
+ && cached.query === this.query
385
+ && cached.categoryFilter === this.categoryFilter
386
+ && cached.capabilityFilter === this.capabilityFilter
387
+ && cached.availableOnly === this.availableOnly
388
+ && cached.benchmarkSort === this.benchmarkSort
389
+ && cached.groupBy === this.groupBy
390
+ ) {
391
+ return cached.result;
392
+ }
393
+
394
+ let result = this.models;
395
+
396
+ // Available-only filter
397
+ if (this.availableOnly && this.configuredProviders.size > 0) {
398
+ result = result.filter(m => this.configuredProviders.has(m.provider));
399
+ }
400
+
401
+ // Pricing tier / category filter
402
+ if (this.categoryFilter === 'free') {
403
+ result = result.filter(m => m.tier === 'free');
404
+ } else if (this.categoryFilter === 'paid') {
405
+ result = result.filter(m => m.tier === 'standard' || m.tier === 'premium' || m.tier == null);
406
+ } else if (this.categoryFilter === 'subscription') {
407
+ result = result.filter(m => tierToCategoryFilter(m.tier) === 'subscription');
408
+ }
409
+
410
+ // Capability filter
411
+ if (this.capabilityFilter === 'reasoning') {
412
+ result = result.filter(m => m.capabilities?.reasoning === true);
413
+ } else if (this.capabilityFilter === 'toolUse') {
414
+ result = result.filter(m => m.capabilities?.toolCalling === true);
415
+ } else if (this.capabilityFilter === 'multimodal') {
416
+ result = result.filter(m => m.capabilities?.multimodal === true);
417
+ }
418
+
419
+ // Query filter — fuzzy: every space-separated word must appear somewhere
420
+ if (this.query.trim().length > 0) {
421
+ const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
422
+ result = result.filter(m => {
423
+ const haystack = `${m.id} ${m.displayName} ${m.provider}`.toLowerCase();
424
+ return words.every(w => haystack.includes(w));
425
+ });
426
+ }
427
+
428
+ // Benchmark sort
429
+ if (this.benchmarkSort !== 'none') {
430
+ result = [...result].sort((a, b) => {
431
+ let scoreA: number | null = null;
432
+ let scoreB: number | null = null;
433
+
434
+ // For synthetic models, use pre-computed bestCompositeScore from backend lookup
435
+ // (synthetic canonical slugs don't exist in ZeroEval benchmark data)
436
+ if (this.benchmarkSort === 'composite') {
437
+ if (a.provider === 'synthetic') {
438
+ scoreA = this.providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
439
+ } else {
440
+ const bA = this.benchmarkStore.getBenchmarks(a.id) ?? this.benchmarkStore.getBenchmarks(a.displayName);
441
+ scoreA = bA ? compositeScore(bA.benchmarks) : null;
442
+ }
443
+ if (b.provider === 'synthetic') {
444
+ scoreB = this.providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
445
+ } else {
446
+ const bB = this.benchmarkStore.getBenchmarks(b.id) ?? this.benchmarkStore.getBenchmarks(b.displayName);
447
+ scoreB = bB ? compositeScore(bB.benchmarks) : null;
448
+ }
449
+ } else {
450
+ // swe/gpqa sort — individual benchmark scores not available for synthetic models — only composite is cached
451
+ const bA = a.provider === 'synthetic' ? null : (this.benchmarkStore.getBenchmarks(a.id) ?? this.benchmarkStore.getBenchmarks(a.displayName));
452
+ const bB = b.provider === 'synthetic' ? null : (this.benchmarkStore.getBenchmarks(b.id) ?? this.benchmarkStore.getBenchmarks(b.displayName));
453
+ if (this.benchmarkSort === 'swe') {
454
+ scoreA = bA?.benchmarks.swe ?? null;
455
+ scoreB = bB?.benchmarks.swe ?? null;
456
+ } else if (this.benchmarkSort === 'gpqa') {
457
+ scoreA = bA?.benchmarks.gpqa ?? null;
458
+ scoreB = bB?.benchmarks.gpqa ?? null;
459
+ }
460
+ }
461
+ // Models with no score sink to the end
462
+ if (scoreA == null && scoreB == null) return 0;
463
+ if (scoreA == null) return 1;
464
+ if (scoreB == null) return -1;
465
+ return scoreB - scoreA; // descending
466
+ });
467
+ }
468
+
469
+ // Synthetic sub-grouping: when groupBy is 'provider', order synthetic models so that
470
+ // "Top Models" (score ≥ 0.65) appear before "All Synthetic", each sub-group internally
471
+ // sorted: top by composite score desc, all alphabetically by id.
472
+ if (this.groupBy === 'provider' && this.benchmarkSort === 'none') {
473
+ const nonSynthetic = result.filter(m => m.provider !== 'synthetic');
474
+ const synthetic = result.filter(m => m.provider === 'synthetic');
475
+
476
+ if (synthetic.length > 0) {
477
+ const topModels = synthetic.filter(m => this._getSyntheticSubgroup(m) === 'top');
478
+ const allModels = synthetic.filter(m => this._getSyntheticSubgroup(m) === 'all');
479
+
480
+ // Sort top models by composite score descending
481
+ topModels.sort((a, b) => {
482
+ const sA = this.providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
483
+ const sB = this.providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
484
+ if (sA == null && sB == null) return 0;
485
+ if (sA == null) return 1;
486
+ if (sB == null) return -1;
487
+ return sB - sA;
488
+ });
489
+
490
+ // Sort remaining alphabetically by id
491
+ allModels.sort((a, b) => a.id.localeCompare(b.id));
492
+
493
+ result = [...nonSynthetic, ...topModels, ...allModels];
494
+ }
495
+ }
496
+
497
+ // Boost recent (non-pinned) models to the front of the list,
498
+ // preserving relative order within the recent group and within the rest.
499
+ if (this.recentIds.length > 0) {
500
+ const recentSet = new Set(this.recentIds);
501
+ const recent = this.recentIds
502
+ .filter(id => result.some(m => m.id === id && !this.pinnedIds.has(id)))
503
+ .map(id => result.find(m => m.id === id)!)
504
+ .filter(Boolean);
505
+ const rest = result.filter(m => !recentSet.has(m.id) || this.pinnedIds.has(m.id));
506
+ result = [...recent, ...rest];
507
+ }
508
+
509
+ this.filteredModelsCache = {
510
+ modelsRef: this.models,
511
+ configuredProvidersKey,
512
+ pinnedIdsKey,
513
+ recentIdsKey,
514
+ query: this.query,
515
+ categoryFilter: this.categoryFilter,
516
+ capabilityFilter: this.capabilityFilter,
517
+ availableOnly: this.availableOnly,
518
+ benchmarkSort: this.benchmarkSort,
519
+ groupBy: this.groupBy,
520
+ result,
521
+ };
522
+ this.modelItemsCache = null;
523
+ return result;
524
+ }
525
+
526
+ /**
527
+ * Load recently used model IDs from favorites and cache them in recentIds.
528
+ * Call this when opening the picker to ensure recent models appear near the top.
529
+ */
530
+ async loadRecentModels(n = 10): Promise<void> {
531
+ this.recentIds = await this.favoritesStore.getRecentModels(n);
532
+ this.clearFilteredCaches();
533
+ }
534
+
535
+ /**
536
+ * Return the group key for a model under the current groupBy mode.
537
+ * Used for inserting group headers in getItems().
538
+ *
539
+ * For synthetic provider models with groupBy 'provider', returns sub-group keys:
540
+ * - 'Top Models' — benchmark composite score ≥ 0.65 (A-tier or S-tier)
541
+ * - 'All Synthetic' — remaining synthetic models
542
+ */
543
+ getModelGroupKey(model: ModelDefinition): string {
544
+ switch (this.groupBy) {
545
+ case 'provider':
546
+ if (model.provider === 'synthetic') {
547
+ return this._getSyntheticSubgroup(model) === 'top' ? 'Top Models' : 'All Synthetic';
548
+ }
549
+ return model.provider;
550
+ case 'family': return detectFamily(model);
551
+ case 'pricingTier': return tierToCategoryFilter(model.tier);
552
+ case 'qualityTier': {
553
+ if (model.provider === 'synthetic') {
554
+ const info = this.providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
555
+ return info?.bestCompositeScore != null ? getQualityTierFromScore(info.bestCompositeScore) : 'C';
556
+ }
557
+ const b = this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
558
+ return b ? getQualityTier(b.benchmarks) : 'C';
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Classify a synthetic model as 'top' or 'all' based on benchmark composite score.
565
+ * 'top': has benchmark data and score ≥ 0.65 (A-tier or S-tier)
566
+ * 'all': no benchmark data or score < 0.65
567
+ */
568
+ private _getSyntheticSubgroup(model: ModelDefinition): 'top' | 'all' {
569
+ const info = this.providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
570
+ const score = info?.bestCompositeScore ?? null;
571
+ return score !== null && score >= A_TIER_THRESHOLD ? 'top' : 'all';
572
+ }
573
+
574
+ /** Get the items for the current mode as a unified list. */
575
+ getItems(): PickerItem[] {
576
+ if (this.mode === 'model') {
577
+ const filtered = this.getFilteredModels();
578
+ const pinnedIdsKey = setKey(this.pinnedIds);
579
+ const cached = this.modelItemsCache;
580
+ if (
581
+ cached !== null
582
+ && cached.filteredModelsRef === filtered
583
+ && cached.pinnedIdsKey === pinnedIdsKey
584
+ && cached.groupBy === this.groupBy
585
+ ) {
586
+ return cached.result;
587
+ }
588
+
589
+ // Separate pinned and unpinned
590
+ const pinned = filtered.filter(m => this.pinnedIds.has(m.id));
591
+ const unpinned = filtered.filter(m => !this.pinnedIds.has(m.id));
592
+
593
+ const items: PickerItem[] = [];
594
+
595
+ // Pinned section header (only if pinned models are in the filtered list)
596
+ if (pinned.length > 0) {
597
+ items.push({ id: '__header__pinned', label: 'Favorites', isGroupHeader: true });
598
+ for (const m of pinned) {
599
+ items.push(this._modelToItem(m, true));
600
+ }
601
+ }
602
+
603
+ // Grouped unpinned models
604
+ let lastGroupKey = '';
605
+ for (const m of unpinned) {
606
+ const groupKey = this.getModelGroupKey(m);
607
+ if (groupKey !== lastGroupKey) {
608
+ items.push({ id: `__header__${groupKey}`, label: groupKey, isGroupHeader: true });
609
+ lastGroupKey = groupKey;
610
+ }
611
+ items.push(this._modelToItem(m, false));
612
+ }
613
+
614
+ this.modelItemsCache = {
615
+ filteredModelsRef: filtered,
616
+ pinnedIdsKey,
617
+ groupBy: this.groupBy,
618
+ result: items,
619
+ };
620
+ return items;
621
+ }
622
+ if (this.mode === 'provider') {
623
+ const filteredProviders = this.getFilteredProviders();
624
+ const configuredProvidersKey = setKey(this.configuredProviders);
625
+ const configuredViaKey = mapKey(this.configuredViaMap);
626
+ const cached = this.providerItemsCache;
627
+ if (
628
+ cached !== null
629
+ && cached.filteredProvidersRef === filteredProviders
630
+ && cached.configuredProvidersKey === configuredProvidersKey
631
+ && cached.configuredViaKey === configuredViaKey
632
+ ) {
633
+ return cached.result;
634
+ }
635
+
636
+ const providerItems: PickerItem[] = [];
637
+ let currentGroup: 'Popular' | 'All Providers' | null = null;
638
+ for (const p of filteredProviders) {
639
+ const group: 'Popular' | 'All Providers' = POPULAR_PROVIDERS.has(p.toLowerCase()) ? 'Popular' : 'All Providers';
640
+ if (group !== currentGroup) {
641
+ providerItems.push({ id: `__header__${group}`, label: group, isGroupHeader: true });
642
+ currentGroup = group;
643
+ }
644
+ providerItems.push({ id: p, label: p, isConfigured: this.configuredProviders.has(p), configuredVia: this.configuredViaMap.get(p) });
645
+ }
646
+
647
+ this.providerItemsCache = {
648
+ filteredProvidersRef: filteredProviders,
649
+ configuredProvidersKey,
650
+ configuredViaKey,
651
+ result: providerItems,
652
+ };
653
+ return providerItems;
654
+ }
655
+ // effort mode
656
+ return this.effortLevels.map(e => ({ id: e, label: e, detail: EFFORT_DESCRIPTIONS[e] ?? '' }));
657
+ }
658
+
659
+ /** Build a PickerItem for a model, including quality tier and pin status. */
660
+ private _modelToItem(model: ModelDefinition, isPinned: boolean): PickerItem {
661
+ // For synthetic models, derive quality tier from cached bestCompositeScore
662
+ // (synthetic canonical slugs don't exist in ZeroEval benchmark data)
663
+ let qualityTier: string | undefined;
664
+ let detail: string;
665
+ if (model.provider === 'synthetic') {
666
+ const synthInfo = this.providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
667
+ if (synthInfo?.bestCompositeScore != null) {
668
+ qualityTier = getQualityTierFromScore(synthInfo.bestCompositeScore);
669
+ }
670
+ // Reuse synthInfo for provider count detail
671
+ detail = synthInfo !== null
672
+ ? `${model.provider} [${synthInfo.keyedBackendCount} provider${synthInfo.keyedBackendCount !== 1 ? 's' : ''}]`
673
+ : model.provider;
674
+ } else {
675
+ detail = model.provider;
676
+ const b = this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
677
+ qualityTier = b ? getQualityTier(b.benchmarks) : undefined;
678
+ }
679
+ const isFree = tierToCategoryFilter(model.tier) === 'free';
680
+
681
+ return {
682
+ id: model.id,
683
+ label: model.displayName,
684
+ detail,
685
+ qualityTier,
686
+ isPinned,
687
+ isFree,
688
+ };
689
+ }
690
+
691
+ /** Get count of selectable (non-header) items in current mode. */
692
+ getItemCount(): number {
693
+ if (this.mode === 'model') return this.getFilteredModels().length;
694
+ if (this.mode === 'provider') return this.getFilteredProviders().length;
695
+ return this.effortLevels.length;
696
+ }
697
+
698
+ /**
699
+ * Move selection up (stops at 0 — no wrap to avoid going off-screen).
700
+ * Updates scrollOffset to keep selection visible.
701
+ */
702
+ moveUp(maxVisible = 20): void {
703
+ const count = this.getItemCount();
704
+ if (count === 0) return;
705
+ if (this.selectedIndex > 0) {
706
+ this.selectedIndex--;
707
+ this._scrollToSelection(maxVisible);
708
+ }
709
+ // At index 0 — stop. Do NOT wrap to count-1 (that puts selection off-screen).
710
+ }
711
+
712
+ /**
713
+ * Move selection down (wraps to 0 at bottom).
714
+ * Updates scrollOffset to keep selection visible.
715
+ */
716
+ moveDown(maxVisible = 20): void {
717
+ const count = this.getItemCount();
718
+ if (count === 0) return;
719
+ this.selectedIndex = this.selectedIndex < count - 1
720
+ ? this.selectedIndex + 1
721
+ : 0;
722
+ this._scrollToSelection(maxVisible);
723
+ }
724
+
725
+ /** Get the currently highlighted model, or null if not in model mode / empty. */
726
+ getSelected(): ModelDefinition | null {
727
+ if (this.mode !== 'model') return null;
728
+ const filtered = this.getFilteredModels();
729
+ if (filtered.length === 0) return null;
730
+ return filtered[this.selectedIndex] ?? null;
731
+ }
732
+
733
+ // ── Private helpers ────────────────────────────────────────────────────────
734
+
735
+ private _clampSelection(): void {
736
+ const count = this.getItemCount();
737
+ if (count === 0) {
738
+ this.selectedIndex = 0;
739
+ this.scrollOffset = 0;
740
+ } else if (this.selectedIndex >= count) {
741
+ this.selectedIndex = count - 1;
742
+ }
743
+ // Clamp scrollOffset too
744
+ const maxOffset = Math.max(0, count - 1);
745
+ if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
746
+ }
747
+
748
+ /**
749
+ * Adjust scrollOffset so selectedIndex is within the visible window [scrollOffset, scrollOffset + maxVisible).
750
+ * Called after every navigation action.
751
+ */
752
+ _scrollToSelection(maxVisible: number): void {
753
+ if (this.selectedIndex < this.scrollOffset) {
754
+ // Selection moved above viewport — scroll up
755
+ this.scrollOffset = this.selectedIndex;
756
+ } else if (this.selectedIndex >= this.scrollOffset + maxVisible) {
757
+ // Selection moved below viewport — scroll down
758
+ this.scrollOffset = this.selectedIndex - maxVisible + 1;
759
+ }
760
+ }
761
+
762
+ getSyntheticModelInfo(modelId: string) {
763
+ return this.providerRegistry.getSyntheticModelInfoFromCatalog(modelId);
764
+ }
765
+
766
+ getBenchmarkEntry(model: ModelDefinition) {
767
+ return this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
768
+ }
769
+
770
+ private clearFilteredCaches(): void {
771
+ this.filteredModelsCache = null;
772
+ this.filteredProvidersCache = null;
773
+ this.modelItemsCache = null;
774
+ this.providerItemsCache = null;
775
+ }
776
+
777
+ private clearCaches(): void {
778
+ this.clearFilteredCaches();
779
+ }
780
+ }
781
+
782
+ function setKey(values: ReadonlySet<string>): string {
783
+ if (values.size === 0) return '';
784
+ return [...values].sort().join('\u001f');
785
+ }
786
+
787
+ function orderedListKey(values: readonly string[]): string {
788
+ return values.length === 0 ? '' : values.join('\u001f');
789
+ }
790
+
791
+ function mapKey(values: ReadonlyMap<string, string | undefined>): string {
792
+ if (values.size === 0) return '';
793
+ return [...values.entries()]
794
+ .sort(([left], [right]) => left.localeCompare(right))
795
+ .map(([key, value]) => `${key}\u001e${value ?? ''}`)
796
+ .join('\u001f');
797
+ }