@pellux/goodvibes-tui 2.0.21 → 2.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 (388) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +16 -16
  3. package/docs/README.md +4 -4
  4. package/docs/foundation-artifacts/operator-contract.json +1 -1
  5. package/package.json +16 -6
  6. package/src/cli/management.ts +5 -2
  7. package/src/cli/tui-startup.ts +34 -39
  8. package/src/core/agent-view-render.ts +157 -0
  9. package/src/core/approval-alert.ts +49 -11
  10. package/src/core/bookmark-navigation.ts +7 -1
  11. package/src/core/budget-breach-notifier.ts +27 -8
  12. package/src/core/compaction-receipt.ts +1 -1
  13. package/src/core/context-usage.ts +1 -1
  14. package/src/core/conversation-compaction-render.ts +3 -3
  15. package/src/core/conversation-display-only.ts +86 -0
  16. package/src/core/conversation-event-navigation.ts +27 -0
  17. package/src/core/conversation-line-cache.ts +216 -447
  18. package/src/core/conversation-render-context.ts +20 -27
  19. package/src/core/conversation-rendering.ts +157 -575
  20. package/src/core/conversation-splash-state.ts +6 -1
  21. package/src/core/conversation-types.ts +35 -0
  22. package/src/core/conversation-usage.ts +12 -0
  23. package/src/core/conversation.ts +130 -131
  24. package/src/core/long-task-notifier.ts +100 -31
  25. package/src/core/notices.ts +75 -0
  26. package/src/core/pairing-tailscale-gateway.ts +1 -1
  27. package/src/core/process-output-log.ts +143 -0
  28. package/src/core/rewind-receipt.ts +1 -1
  29. package/src/core/session-focus.ts +190 -0
  30. package/src/core/session-resume-core.ts +23 -81
  31. package/src/core/stream-event-wiring.ts +31 -5
  32. package/src/core/system-message-noise.ts +2 -2
  33. package/src/core/system-message-router.ts +16 -18
  34. package/src/core/turn-event-wiring.ts +272 -33
  35. package/src/core/work-tree-focus.ts +290 -0
  36. package/src/core/work-tree-fold-store.ts +143 -0
  37. package/src/core/work-tree-model.ts +799 -0
  38. package/src/core/work-tree-render.ts +384 -0
  39. package/src/core/work-tree-sources.ts +184 -0
  40. package/src/core/work-tree-wiring.ts +176 -0
  41. package/src/core/workstream-notification.ts +36 -6
  42. package/src/core/wrfc-notice-titles.ts +37 -0
  43. package/src/input/agents-modal-text.ts +119 -0
  44. package/src/input/agents-modal.ts +699 -0
  45. package/src/input/bookmark-modal.ts +33 -9
  46. package/src/input/changes-git.ts +251 -0
  47. package/src/input/changes-modal.ts +629 -0
  48. package/src/input/changes-model.ts +125 -0
  49. package/src/input/command-palette-types.ts +41 -0
  50. package/src/input/command-palette.ts +272 -0
  51. package/src/input/command-registry.ts +77 -35
  52. package/src/input/commands/acp-runtime.ts +19 -9
  53. package/src/input/commands/channel-runtime.ts +2 -2
  54. package/src/input/commands/ci-runtime.ts +1 -1
  55. package/src/input/commands/codebase-runtime.ts +1 -1
  56. package/src/input/commands/config.ts +7 -1
  57. package/src/input/commands/context-window.ts +25 -19
  58. package/src/input/commands/control-room-runtime.ts +18 -18
  59. package/src/input/commands/conversation-runtime.ts +0 -1
  60. package/src/input/commands/cost-runtime.ts +29 -20
  61. package/src/input/commands/diff-runtime.ts +35 -181
  62. package/src/input/commands/eval.ts +1 -1
  63. package/src/input/commands/experience-runtime.ts +3 -7
  64. package/src/input/commands/fresh-conversation.ts +86 -0
  65. package/src/input/commands/git-runtime.ts +5 -22
  66. package/src/input/commands/graph-runtime.ts +1 -1
  67. package/src/input/commands/guidance-runtime.ts +1 -1
  68. package/src/input/commands/health-runtime.ts +8 -9
  69. package/src/input/commands/hooks-runtime.ts +2 -2
  70. package/src/input/commands/hosted-runtime.ts +3 -3
  71. package/src/input/commands/incident-runtime.ts +3 -3
  72. package/src/input/commands/intelligence-runtime.ts +6 -9
  73. package/src/input/commands/knowledge.ts +2 -2
  74. package/src/input/commands/legacy-panel-command.ts +57 -0
  75. package/src/input/commands/local-auth-runtime.ts +8 -9
  76. package/src/input/commands/local-provider-runtime.ts +7 -4
  77. package/src/input/commands/local-runtime.ts +12 -15
  78. package/src/input/commands/local-setup-review.ts +1 -1
  79. package/src/input/commands/local-setup.ts +13 -19
  80. package/src/input/commands/memory.ts +1 -1
  81. package/src/input/commands/notify-runtime.ts +9 -0
  82. package/src/input/commands/operator-runtime.ts +10 -12
  83. package/src/input/commands/permissions-provenance.ts +3 -3
  84. package/src/input/commands/planning-runtime.ts +7 -7
  85. package/src/input/commands/platform-sandbox-runtime.ts +2 -2
  86. package/src/input/commands/platform-services-runtime.ts +1 -1
  87. package/src/input/commands/policy-dispatch.ts +2 -2
  88. package/src/input/commands/policy.ts +3 -3
  89. package/src/input/commands/provider.ts +1 -1
  90. package/src/input/commands/remote-runtime.ts +1 -1
  91. package/src/input/commands/review-runtime.ts +39 -94
  92. package/src/input/commands/rewind-runtime.ts +81 -108
  93. package/src/input/commands/runtime-services.ts +15 -17
  94. package/src/input/commands/services-runtime.ts +2 -2
  95. package/src/input/commands/session-content.ts +21 -5
  96. package/src/input/commands/session-workflow.ts +13 -38
  97. package/src/input/commands/settings-sync-runtime.ts +2 -2
  98. package/src/input/commands/shell-core.ts +19 -4
  99. package/src/input/commands/skills-runtime.ts +2 -2
  100. package/src/input/commands/status-runtime.ts +13 -0
  101. package/src/input/commands/subscription-runtime.ts +1 -1
  102. package/src/input/commands/tasks-runtime.ts +2 -8
  103. package/src/input/commands/work-plan-runtime.ts +4 -4
  104. package/src/input/commands/workstream-attempts.ts +24 -47
  105. package/src/input/commands/workstream-runtime.ts +2 -2
  106. package/src/input/commands/worktree-runtime.ts +3 -3
  107. package/src/input/commands.ts +3 -1
  108. package/src/input/composer-editor.ts +44 -0
  109. package/src/input/concealed-input.ts +1 -1
  110. package/src/input/config-modal-types.ts +20 -8
  111. package/src/input/config-modal.ts +106 -105
  112. package/src/input/confirm-dialog-types.ts +28 -0
  113. package/src/input/confirm-dialog.ts +70 -0
  114. package/src/input/delete-key-policy.ts +3 -3
  115. package/src/input/feed-context-factory.ts +15 -16
  116. package/src/input/handler-command-route.ts +7 -42
  117. package/src/input/handler-content-actions.ts +16 -15
  118. package/src/input/handler-feed-routes.ts +26 -211
  119. package/src/input/handler-feed.ts +68 -95
  120. package/src/input/handler-interactions.ts +5 -3
  121. package/src/input/handler-modal-routes.ts +215 -216
  122. package/src/input/handler-modal-stack.ts +81 -11
  123. package/src/input/handler-modal-token-routes.ts +4 -0
  124. package/src/input/handler-onboarding.ts +0 -1
  125. package/src/input/handler-picker-routes.ts +65 -107
  126. package/src/input/handler-session-view-route.ts +149 -0
  127. package/src/input/handler-shortcuts.ts +16 -154
  128. package/src/input/handler-types.ts +4 -2
  129. package/src/input/handler-ui-state.ts +45 -15
  130. package/src/input/handler-work-tree-route.ts +123 -0
  131. package/src/input/handler.ts +25 -70
  132. package/src/input/keybindings.ts +16 -71
  133. package/src/input/masked-entry-modal.ts +117 -0
  134. package/src/input/mcp-workspace.ts +48 -17
  135. package/src/input/model-picker-filter.ts +8 -0
  136. package/src/input/model-picker-open.ts +99 -0
  137. package/src/input/model-picker-types.ts +1 -1
  138. package/src/input/model-picker.ts +92 -9
  139. package/src/input/notifications-modal.ts +123 -0
  140. package/src/input/overlay-filter.ts +26 -0
  141. package/src/input/profile-picker-modal.ts +37 -8
  142. package/src/input/search.ts +3 -3
  143. package/src/input/selection-modal-queue.ts +2 -0
  144. package/src/input/selection-modal.ts +28 -16
  145. package/src/input/session-picker-modal.ts +33 -8
  146. package/src/input/settings-modal-activation.ts +7 -2
  147. package/src/input/settings-modal-data.ts +11 -6
  148. package/src/input/settings-modal-types.ts +1 -1
  149. package/src/input/settings-modal.ts +20 -20
  150. package/src/input/submission-intent.ts +1 -1
  151. package/src/input/submission-router.ts +4 -9
  152. package/src/input/surface-modal-host.ts +110 -0
  153. package/src/input/theme-settings-actions.ts +82 -0
  154. package/src/input/usage-modal.ts +183 -0
  155. package/src/input/views.ts +97 -0
  156. package/src/main.ts +175 -165
  157. package/src/permissions/hunk-selection.ts +1 -1
  158. package/src/permissions/prompt-card.ts +511 -0
  159. package/src/permissions/prompt.ts +59 -622
  160. package/src/renderer/agents-modal.ts +310 -0
  161. package/src/renderer/autocomplete-overlay.ts +42 -220
  162. package/src/renderer/back-to-bottom.ts +80 -0
  163. package/src/renderer/block-actions-overlay.ts +30 -48
  164. package/src/renderer/bookmark-modal.ts +48 -79
  165. package/src/renderer/changes-modal.ts +325 -0
  166. package/src/renderer/code-block.ts +184 -106
  167. package/src/renderer/command-palette.ts +93 -0
  168. package/src/renderer/composer-fragments.ts +4 -4
  169. package/src/renderer/composer.ts +146 -0
  170. package/src/renderer/compositor.ts +26 -160
  171. package/src/renderer/config-modal.ts +185 -81
  172. package/src/renderer/confirm-dialog.ts +50 -0
  173. package/src/renderer/context-inspector.ts +116 -154
  174. package/src/renderer/context-status-hint.ts +4 -4
  175. package/src/renderer/conversation-layout.ts +20 -0
  176. package/src/renderer/conversation-overlays.ts +94 -84
  177. package/src/renderer/conversation-surface.ts +19 -38
  178. package/src/renderer/diff-tint.ts +98 -0
  179. package/src/renderer/file-picker-overlay.ts +63 -90
  180. package/src/renderer/footer-targets.ts +38 -0
  181. package/src/renderer/git-status.ts +3 -4
  182. package/src/renderer/header-line.ts +140 -117
  183. package/src/renderer/help-overlay.ts +133 -219
  184. package/src/renderer/history-search-overlay.ts +15 -12
  185. package/src/renderer/lane-graph/bead.ts +800 -0
  186. package/src/renderer/lane-graph/glyphs.ts +134 -0
  187. package/src/renderer/lane-graph/layout.ts +248 -0
  188. package/src/renderer/lane-graph/paint.ts +537 -0
  189. package/src/renderer/lane-graph/semantic-memo.ts +77 -0
  190. package/src/renderer/layout-engine.ts +2 -57
  191. package/src/renderer/layout.ts +7 -13
  192. package/src/renderer/markdown-inline.ts +2 -1
  193. package/src/renderer/markdown-table.ts +17 -12
  194. package/src/renderer/markdown.ts +92 -34
  195. package/src/renderer/masked-entry-modal.ts +78 -0
  196. package/src/renderer/mcp-workspace.ts +166 -203
  197. package/src/renderer/model-workspace.ts +381 -436
  198. package/src/renderer/notifications-modal.ts +116 -0
  199. package/src/renderer/onboarding/onboarding-wizard.ts +178 -588
  200. package/src/renderer/process-view.ts +192 -0
  201. package/src/renderer/profile-picker-modal.ts +58 -110
  202. package/src/renderer/search-overlay.ts +6 -3
  203. package/src/renderer/selection-modal-overlay.ts +87 -327
  204. package/src/renderer/semantic-diff.ts +2 -2
  205. package/src/renderer/session-chips.ts +102 -0
  206. package/src/renderer/session-picker-modal.ts +177 -297
  207. package/src/renderer/settings-modal-connections.ts +14 -23
  208. package/src/renderer/settings-modal-context.ts +367 -0
  209. package/src/renderer/settings-modal-helpers.ts +6 -11
  210. package/src/renderer/settings-modal.ts +322 -726
  211. package/src/renderer/shell-surface.ts +277 -175
  212. package/src/renderer/shortcuts-overlay.ts +217 -0
  213. package/src/renderer/status-line.ts +475 -0
  214. package/src/renderer/surface-compose.ts +174 -0
  215. package/src/renderer/surface-kit-extra.ts +303 -0
  216. package/src/renderer/surface-kit-list.ts +366 -0
  217. package/src/renderer/surface-kit-parts.ts +360 -0
  218. package/src/renderer/surface-kit.ts +568 -0
  219. package/src/renderer/syntax-highlighter.ts +280 -197
  220. package/src/renderer/syntax-theme.ts +51 -0
  221. package/src/renderer/system-message.ts +0 -1
  222. package/src/renderer/term-caps.ts +42 -1
  223. package/src/renderer/terminal-bg-probe.ts +116 -19
  224. package/src/renderer/terminal-palette-probe.ts +322 -0
  225. package/src/renderer/terminal-palette-reader.ts +183 -0
  226. package/src/renderer/terminal-palette.ts +58 -0
  227. package/src/renderer/theme-mode-config.ts +16 -1
  228. package/src/renderer/theme.ts +234 -207
  229. package/src/renderer/thinking.ts +0 -1
  230. package/src/renderer/throbber.ts +188 -0
  231. package/src/renderer/toast-center.ts +119 -0
  232. package/src/renderer/ui-factory.ts +39 -602
  233. package/src/renderer/ui-primitives.ts +8 -25
  234. package/src/renderer/usage-modal.ts +353 -0
  235. package/src/renderer/user-message.ts +58 -0
  236. package/src/renderer/voice-capture-chip.ts +53 -0
  237. package/src/runtime/bootstrap-command-context.ts +0 -5
  238. package/src/runtime/bootstrap-command-parts.ts +38 -89
  239. package/src/runtime/bootstrap-core.ts +13 -15
  240. package/src/runtime/bootstrap-hook-bridge.ts +12 -21
  241. package/src/runtime/bootstrap-notifier-sync.ts +17 -1
  242. package/src/runtime/bootstrap-shell.ts +16 -50
  243. package/src/runtime/bootstrap.ts +14 -14
  244. package/src/runtime/client/fleet-union.ts +5 -5
  245. package/src/runtime/client/hosted-exit.ts +2 -2
  246. package/src/runtime/client/hosted-sessions.ts +1 -1
  247. package/src/runtime/client/spine-adoption.ts +1 -1
  248. package/src/runtime/context-accounting-source.ts +5 -4
  249. package/src/runtime/index.ts +1 -0
  250. package/src/runtime/notification-dispatch.ts +102 -29
  251. package/src/runtime/recovery-offer-wiring.ts +4 -1
  252. package/src/runtime/recovery-prompt.ts +31 -15
  253. package/src/runtime/runtime-services-types.ts +10 -6
  254. package/src/runtime/services.ts +17 -12
  255. package/src/runtime/session-continuity-hints.ts +2 -5
  256. package/src/runtime/session-inbound-inputs.ts +1 -1
  257. package/src/runtime/session-resume-liveness-confirm.ts +14 -2
  258. package/src/runtime/startup-typeahead-gate.ts +103 -0
  259. package/src/runtime/store/domains/domain-read-matrix.ts +0 -1
  260. package/src/runtime/store/domains/index.ts +0 -8
  261. package/src/runtime/store/selectors/index.ts +0 -28
  262. package/src/runtime/store/state.ts +0 -8
  263. package/src/runtime/ui-services.ts +4 -6
  264. package/src/runtime/usage-tracker.ts +348 -0
  265. package/src/runtime/wrfc-persistence.ts +4 -4
  266. package/src/shell/blocking-input.ts +92 -2
  267. package/src/shell/recovery-input-helpers.ts +1 -1
  268. package/src/shell/session-views.ts +539 -0
  269. package/src/shell/status-report.ts +86 -0
  270. package/src/shell/transcript-scroll.ts +68 -0
  271. package/src/shell/ui-openers.ts +84 -172
  272. package/src/shell/view-openers.ts +205 -0
  273. package/src/tools/wrfc-agent-guard.ts +22 -2
  274. package/src/utils/format-duration.ts +4 -8
  275. package/src/utils/splash-lines.ts +9 -1
  276. package/src/utils/terminal-width.ts +56 -0
  277. package/src/verification/verification-ledger.ts +18 -16
  278. package/src/{panels → views}/agent-inspector-shared.ts +2 -2
  279. package/src/{panels → views}/builtin-modals.ts +22 -21
  280. package/src/views/builtin-views.ts +135 -0
  281. package/src/{panels → views}/confirm-state.ts +9 -9
  282. package/src/{panels → views}/diff-review-model.ts +10 -3
  283. package/src/{panels → views}/eval-registry.ts +1 -1
  284. package/src/{panels → views}/fleet-acts.ts +49 -85
  285. package/src/{panels/fleet-panel-format.ts → views/fleet-format.ts} +40 -40
  286. package/src/{panels → views}/fleet-gateway.ts +5 -5
  287. package/src/{panels → views}/fleet-observed-render.ts +16 -16
  288. package/src/{panels → views}/fleet-read-model.ts +9 -9
  289. package/src/{panels/fleet-tabs.ts → views/fleet-session-tabs.ts} +13 -13
  290. package/src/{panels → views}/fleet-spawn.ts +33 -49
  291. package/src/{panels → views}/fleet-steer.ts +12 -12
  292. package/src/{panels → views}/fleet-stop.ts +4 -4
  293. package/src/{panels → views}/fleet-transcript.ts +26 -27
  294. package/src/{panels/fleet-panel-worktree-detail.ts → views/fleet-worktree-detail.ts} +7 -7
  295. package/src/{panels → views}/hosted-session-feed.ts +6 -6
  296. package/src/views/modal-surface-registry.ts +41 -0
  297. package/src/{panels → views}/modals/devices-modal.ts +2 -2
  298. package/src/{panels → views}/modals/hooks-modal.ts +13 -13
  299. package/src/{panels → views}/modals/keybindings-modal.ts +19 -27
  300. package/src/{panels → views}/modals/knowledge-modal.ts +6 -5
  301. package/src/{panels → views}/modals/local-auth-modal.ts +22 -21
  302. package/src/{panels → views}/modals/marketplace-modal.ts +11 -11
  303. package/src/{panels → views}/modals/memory-modal.ts +8 -8
  304. package/src/{panels → views}/modals/modal-surface-helpers.ts +15 -5
  305. package/src/{panels → views}/modals/pairing-modal.ts +9 -9
  306. package/src/{panels → views}/modals/planning-modal.ts +15 -20
  307. package/src/{panels → views}/modals/plugins-modal.ts +6 -6
  308. package/src/{panels → views}/modals/policy-modal.ts +13 -5
  309. package/src/{panels → views}/modals/provider-health-modal.ts +4 -4
  310. package/src/{panels → views}/modals/remote-modal.ts +3 -3
  311. package/src/{panels → views}/modals/sandbox-modal.ts +4 -4
  312. package/src/{panels → views}/modals/security-modal.ts +22 -16
  313. package/src/{panels → views}/modals/services-modal.ts +3 -3
  314. package/src/{panels → views}/modals/settings-sync-modal.ts +4 -4
  315. package/src/{panels → views}/modals/skills-modal.ts +9 -9
  316. package/src/{panels → views}/modals/subscription-modal.ts +3 -3
  317. package/src/{panels → views}/modals/work-plan-modal.ts +10 -13
  318. package/src/views/notifications-feed.ts +314 -0
  319. package/src/{panels → views}/polish-core.ts +38 -36
  320. package/src/{panels → views}/polish-tables.ts +35 -26
  321. package/src/{panels → views}/polish.ts +101 -95
  322. package/src/{panels → views}/project-planning-answer-actions.ts +3 -3
  323. package/src/{panels → views}/provider-health-routes.ts +14 -14
  324. package/src/{panels → views}/search-focus.ts +8 -8
  325. package/src/{panels → views}/session-maintenance.ts +11 -11
  326. package/src/views/skills-discovery.ts +159 -0
  327. package/src/views/view-deps.ts +80 -0
  328. package/src/views/view-panel-adapter.ts +53 -0
  329. package/src/{panels → views}/workstream-graph-render.ts +1 -1
  330. package/src/core/conversation-fold.ts +0 -101
  331. package/src/core/conversation-turn-structure.ts +0 -464
  332. package/src/core/conversation-user-receipts.ts +0 -42
  333. package/src/input/commands/operator-panel-runtime.ts +0 -186
  334. package/src/input/panel-integration-actions.ts +0 -33
  335. package/src/input/panel-mouse-geometry.ts +0 -97
  336. package/src/input/panel-paste-flood-guard.ts +0 -86
  337. package/src/panels/base-panel.ts +0 -301
  338. package/src/panels/builtin/agent.ts +0 -27
  339. package/src/panels/builtin/development.ts +0 -92
  340. package/src/panels/builtin/knowledge.ts +0 -12
  341. package/src/panels/builtin/operations.ts +0 -177
  342. package/src/panels/builtin/session.ts +0 -81
  343. package/src/panels/builtin/shared.ts +0 -357
  344. package/src/panels/builtin-panels.ts +0 -28
  345. package/src/panels/cost-tracker-panel.ts +0 -746
  346. package/src/panels/diff-panel.ts +0 -797
  347. package/src/panels/diff-review-panel.ts +0 -395
  348. package/src/panels/expandable-list-panel.ts +0 -189
  349. package/src/panels/fleet-deep-link.ts +0 -31
  350. package/src/panels/fleet-panel.ts +0 -799
  351. package/src/panels/git-panel.ts +0 -800
  352. package/src/panels/hosted-session-panel.ts +0 -221
  353. package/src/panels/index.ts +0 -21
  354. package/src/panels/local-auth-panel.ts +0 -478
  355. package/src/panels/notifications-feed.ts +0 -127
  356. package/src/panels/notifications-panel.ts +0 -160
  357. package/src/panels/panel-confirm-overlay.ts +0 -72
  358. package/src/panels/panel-manager.ts +0 -791
  359. package/src/panels/plugins-panel.ts +0 -371
  360. package/src/panels/scrollable-list-panel.ts +0 -498
  361. package/src/panels/skills-panel.ts +0 -454
  362. package/src/panels/token-budget-panel.ts +0 -688
  363. package/src/panels/types.ts +0 -191
  364. package/src/panels/wrfc-panel-format.ts +0 -133
  365. package/src/renderer/diff-view.ts +0 -134
  366. package/src/renderer/fleet-tab-strip.ts +0 -56
  367. package/src/renderer/footer-tips.ts +0 -53
  368. package/src/renderer/fullscreen-primitives.ts +0 -146
  369. package/src/renderer/fullscreen-workspace.ts +0 -194
  370. package/src/renderer/hint-grammar.ts +0 -52
  371. package/src/renderer/modal-factory.ts +0 -480
  372. package/src/renderer/overlay-box.ts +0 -154
  373. package/src/renderer/overlay-viewport.ts +0 -104
  374. package/src/renderer/panel-composite.ts +0 -145
  375. package/src/renderer/panel-workspace-bar.ts +0 -54
  376. package/src/renderer/process-indicator.ts +0 -163
  377. package/src/renderer/tab-strip.ts +0 -222
  378. package/src/renderer/text-layout.ts +0 -31
  379. package/src/renderer/tool-call.ts +0 -276
  380. package/src/renderer/tool-result-expanded-lines.ts +0 -48
  381. package/src/renderer/tool-result-summary.ts +0 -219
  382. package/src/runtime/diagnostics/panels/index.ts +0 -21
  383. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  384. package/src/runtime/perf/panel-contracts.ts +0 -32
  385. package/src/runtime/store/domains/panels.ts +0 -117
  386. /package/src/{panels → views}/memory-consolidation-gateway.ts +0 -0
  387. /package/src/{panels → views}/modals/modal-theme.ts +0 -0
  388. /package/src/{panels → views}/provider-health-domains.ts +0 -0
@@ -1,117 +1,51 @@
1
1
  /**
2
- * Fullscreen provider/model workspace.
2
+ * renderModelWorkspace, the model picker drawn with the surface kit.
3
3
  *
4
- * This keeps the model-picker data and commit behavior, but presents it as a
5
- * stable workspace with explicit target slots instead of a compact overlay.
4
+ * ✦ Models esc
5
+ *
6
+ * Main Chat Helper Tool LLM TTS Embeddings
7
+ *
8
+ * ▏opus 6 of 5,582
9
+ *
10
+ * ✦ pinned ┌ element inset ──────────┐
11
+ * ● Claude Opus 5.5 anthropic 1.0M Claude Opus 5.5
12
+ * ✦ recent anthropic:claude-opus-5-5
13
+ * route-llm abacusai 8.2K Context ▰▰▰▰▰▰▰▰▰▰ 1.0M
14
+ * ✦ lm studio Quality ▰▰▰▰▰▰▱▱▱▱ 0.61
15
+ * qwen3-coder lmstudio 128K Cost $15 / $75 per 1M
16
+ *
17
+ * ⏎ use for Main Chat tab next target ctrl+f pin ctrl+r refresh
18
+ *
19
+ * The five model targets are tabs. The list is grouped Pinned, Recent, then by
20
+ * the group-by mode (provider by default, which includes LAN servers). The
21
+ * detail inset shows the selected model with meters for context, benchmark
22
+ * quality and price where that data exists, its capabilities, which targets it
23
+ * serves, and the picker's active filters. Provider, effort, context-cap and
24
+ * embedding-provider steps use the same frame.
6
25
  */
7
26
 
8
27
  import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
9
- import type { ModelPickerModal } from '../input/model-picker.ts';
10
- import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
11
- import type { Line } from '@pellux/goodvibes-sdk/platform/types';
12
- import { createEmptyLine, createStyledCell } from '@pellux/goodvibes-sdk/platform/types';
13
- import { fitDisplay, getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
28
+ import type { ModelPickerModal, ModelPickerTargetInfo } from '../input/model-picker.ts';
14
29
  import { abbreviateCount } from '../utils/format-number.ts';
15
- import { GLYPHS, UI_TONES } from './ui-primitives.ts';
16
-
17
- const PALETTE = {
18
- border: UI_TONES.border,
19
- title: '#67e8f9',
20
- subtitle: UI_TONES.accent.conversation,
21
- text: UI_TONES.fg.primary,
22
- muted: UI_TONES.fg.muted,
23
- dim: UI_TONES.border,
24
- selectedBg: UI_TONES.bg.selected,
25
- targetBg: '#141b25',
26
- detailBg: '#121923',
27
- bodyBg: '#0f141d',
28
- footerBg: UI_TONES.bg.footer,
29
- good: UI_TONES.state.good,
30
- warn: UI_TONES.state.warn,
31
- info: UI_TONES.state.info,
32
- };
33
-
34
- const renderCache = new WeakMap<ModelPickerModal, { key: string; lines: Line[] }>();
35
- const objectIds = new WeakMap<object, number>();
36
- let nextObjectId = 1;
37
-
38
- function clamp(value: number, min: number, max: number): number {
39
- return Math.max(min, Math.min(max, value));
40
- }
41
-
42
- function fillRange(line: Line, startX: number, endX: number, bg: string): void {
43
- for (let x = Math.max(0, startX); x <= Math.min(line.length - 1, endX); x += 1) {
44
- const cell = line[x] ?? createStyledCell(' ');
45
- line[x] = createStyledCell(cell.char, {
46
- fg: cell.fg,
47
- bg,
48
- bold: cell.bold,
49
- dim: cell.dim,
50
- underline: cell.underline,
51
- italic: cell.italic,
52
- strikethrough: cell.strikethrough,
53
- link: cell.link,
54
- });
55
- }
56
- }
57
-
58
- function makeLine(width: number, bg = ''): Line {
59
- const line = createEmptyLine(width);
60
- if (bg) fillRange(line, 0, width - 1, bg);
61
- return line;
62
- }
63
-
64
- function writeText(line: Line, startX: number, maxWidth: number, text: string, style: Partial<Omit<Line[number], 'char'>> = {}): void {
65
- let x = startX;
66
- let used = 0;
67
- for (const ch of text) {
68
- const width = getDisplayWidth(ch);
69
- if (width <= 0) continue;
70
- if (used + width > maxWidth || x >= line.length) break;
71
- line[x] = createStyledCell(ch, style);
72
- if (width > 1 && x + 1 < line.length) {
73
- line[x + 1] = createStyledCell(' ', style);
74
- }
75
- x += width;
76
- used += width;
77
- }
78
- }
79
-
80
- function borderLine(width: number, left: string, fill: string, right: string): Line {
81
- const line = makeLine(width);
82
- if (width <= 0) return line;
83
- line[0] = createStyledCell(left, { fg: PALETTE.border });
84
- for (let x = 1; x < width - 1; x += 1) {
85
- line[x] = createStyledCell(fill, { fg: PALETTE.border });
86
- }
87
- if (width > 1) line[width - 1] = createStyledCell(right, { fg: PALETTE.border });
88
- return line;
89
- }
90
-
91
- function contentLine(width: number, bg: string): Line {
92
- const line = makeLine(width, bg);
93
- if (width > 0) line[0] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border });
94
- if (width > 1) line[width - 1] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border });
95
- return line;
96
- }
97
-
98
- function drawVertical(line: Line, x: number, bg = ''): void {
99
- if (x <= 0 || x >= line.length - 1) return;
100
- line[x] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border, bg });
101
- }
102
-
103
- function padDisplay(text: string, width: number): string {
104
- return fitDisplay(text, width, '');
105
- }
106
-
107
- function stableWindow(total: number, selected: number, visible: number): { start: number; end: number } {
108
- if (total <= 0 || visible <= 0) return { start: 0, end: 0 };
109
- const clamped = clamp(selected, 0, total - 1);
110
- const half = Math.floor(visible / 2);
111
- const maxStart = Math.max(0, total - visible);
112
- const start = clamp(clamped - half, 0, maxStart);
113
- return { start, end: Math.min(total, start + visible) };
114
- }
30
+ import { activeTokens } from './theme.ts';
31
+ import {
32
+ beginModal,
33
+ finishModal,
34
+ modalInnerWidth,
35
+ scrollCountText,
36
+ standardModalWidth,
37
+ searchRow,
38
+ wrapLines,
39
+ type KitHint,
40
+ type ModalFrame,
41
+ type SurfaceLayer,
42
+ } from './surface-kit.ts';
43
+ import { drawList, type KitListResult, type KitRow } from './surface-kit-list.ts';
44
+ import { meter, inset, insetPut, insetWrap, tabs, type KitInset } from './surface-kit-parts.ts';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Formatting
48
+ // ---------------------------------------------------------------------------
115
49
 
116
50
  function formatContext(value: number | undefined): string {
117
51
  if (!value) return '-';
@@ -122,22 +56,6 @@ function modelKey(model: ModelDefinition): string {
122
56
  return model.registryKey ?? `${model.provider}:${model.id}`;
123
57
  }
124
58
 
125
- function targetSummary(info: ModelPickerTargetInfo): string {
126
- if (!info.enabled) return 'disabled';
127
- // 'embeddings' has no model concept, configuredNote carries the honest
128
- // provider id + dimensions + configured-state summary instead of a
129
- // computed provider:model route (which would print a phantom "model:").
130
- if (info.configuredNote) return info.configuredNote;
131
- const route = formatRoute(info.provider, info.model);
132
- if (info.inherited) return `inherits ${route}`;
133
- return route;
134
- }
135
-
136
- function selectedModel(picker: ModelPickerModal): ModelDefinition | null {
137
- if (picker.mode !== 'model') return null;
138
- return picker.getSelected();
139
- }
140
-
141
59
  function formatRoute(provider: string, model: string): string {
142
60
  if (!provider && !model) return '(not set)';
143
61
  if (!provider) return model;
@@ -145,377 +63,406 @@ function formatRoute(provider: string, model: string): string {
145
63
  return model.startsWith(`${provider}:`) ? model : `${provider}:${model}`;
146
64
  }
147
65
 
66
+ function targetSummary(info: ModelPickerTargetInfo): string {
67
+ if (!info.enabled) return 'disabled';
68
+ // 'embeddings' has no model concept; configuredNote carries the honest
69
+ // provider id + dimensions + configured-state summary instead.
70
+ if (info.configuredNote) return info.configuredNote;
71
+ const route = formatRoute(info.provider, info.model);
72
+ return info.inherited ? `inherits ${route}` : route;
73
+ }
74
+
148
75
  function targetLabelFor(target: string): string {
149
- if (target === 'helper') return 'Helper Model';
76
+ if (target === 'helper') return 'Helper';
150
77
  if (target === 'tool') return 'Tool LLM';
151
- if (target === 'tts') return 'TTS LLM';
78
+ if (target === 'tts') return 'TTS';
152
79
  if (target === 'embeddings') return 'Embeddings';
153
80
  return 'Main Chat';
154
81
  }
155
82
 
156
- function detailLines(picker: ModelPickerModal, width: number): string[] {
157
- const target = picker.getSelectedTargetInfo();
158
- const targetLabel = target?.label ?? targetLabelFor(picker.target);
159
- const targetState = target ? (target.enabled ? 'enabled' : 'disabled') : 'active';
160
- const selected = selectedModel(picker);
161
- const lines: string[] = [];
162
- lines.push(`Target: ${targetLabel} (${targetState})`);
163
- if (target) lines.push(`Current: ${targetSummary(target)}`);
164
- if (picker.mode === 'provider') {
165
- const provider = picker.getFilteredProviders()[picker.selectedIndex] ?? '';
166
- lines.push(`Provider selection: choose a provider, then choose a model for ${targetLabel}.`);
167
- if (provider) lines.push(`Selected provider: ${provider}`);
168
- } else if (picker.mode === 'model') {
169
- lines.push(`Model selection: choose the model to store for ${targetLabel}. Use filters to narrow large catalogs.`);
170
- if (selected) {
171
- const caps = selected.capabilities ?? {};
172
- const capText = [
173
- caps.reasoning ? 'reasoning' : '',
174
- caps.multimodal ? 'vision' : '',
175
- caps.toolCalling ? 'tools' : '',
176
- caps.codeEditing ? 'code' : '',
177
- ].filter(Boolean).join(', ') || 'standard';
178
- lines.push(`Selected: ${modelKey(selected)} | ${selected.displayName} | context ${formatContext(selected.contextWindow)} | ${capText}`);
179
- }
180
- } else if (picker.mode === 'effort') {
181
- lines.push(`Reasoning effort applies to the main chat model. Select the default effort for this model.`);
182
- } else if (picker.mode === 'embeddingProvider') {
183
- lines.push('Embedding provider selection: choose the provider memory search and the code index use to embed content.');
184
- const selectedEmbedding = picker.embeddingProviders[picker.selectedIndex];
185
- if (selectedEmbedding) {
186
- const status = selectedEmbedding.configured ? 'configured' : 'unconfigured';
187
- lines.push(`Selected: ${selectedEmbedding.id} | ${selectedEmbedding.dimensions} dimensions | ${status}${selectedEmbedding.detail ? ` | ${selectedEmbedding.detail}` : ''}`);
188
- }
189
- } else {
190
- lines.push(`Context cap overrides the detected local-model context window for this selection.`);
191
- }
192
- // A visible cursor glyph on the query itself when search is focused,
193
- // the only prior affordance was a footer-hint text swap ('/ search' vs.
194
- // 'Typing filters search...'), buried at the end of a long hint line and
195
- // easy to miss (confirmed via live tmux repro). This puts the "you are
196
- // typing here" signal where the eye already is, on the Search: value.
197
- const searchDisplay = picker.query.length > 0
198
- ? `${picker.query}${picker.searchFocused ? GLYPHS.surface.cursor : ''}`
199
- : (picker.searchFocused ? GLYPHS.surface.cursor : '(none)');
200
- const filterText = `Search: ${searchDisplay} | Price: ${picker.categoryFilter} | Capability: ${picker.capabilityFilter} | Group: ${picker.groupBy} | Available only: ${picker.availableOnly ? 'yes' : 'no'}`;
201
- lines.push(filterText);
202
- return lines.flatMap((line) => wrapText(line, Math.max(1, width)));
83
+ function isTargetModel(info: ModelPickerTargetInfo | null, model: ModelDefinition): boolean {
84
+ if (!info || !info.enabled) return false;
85
+ return info.model === model.registryKey || (info.model === model.id && (!info.provider || info.provider === model.provider));
86
+ }
87
+
88
+ function capabilityNames(model: ModelDefinition): string[] {
89
+ const caps = model.capabilities ?? {};
90
+ return [
91
+ caps.toolCalling ? 'tools' : '',
92
+ caps.multimodal ? 'vision' : '',
93
+ caps.reasoning ? 'reasoning' : '',
94
+ caps.codeEditing ? 'code' : '',
95
+ ].filter(Boolean);
203
96
  }
204
97
 
205
- function renderTargets(picker: ModelPickerModal, line: Line, startX: number, width: number, rowIndex: number): void {
206
- const info = picker.targetInfos[rowIndex];
207
- if (!info) return;
208
- const selected = rowIndex === picker.targetIndex;
209
- const bg = selected ? PALETTE.selectedBg : PALETTE.targetBg;
210
- fillRange(line, startX, startX + width - 1, bg);
211
- const marker = selected ? (picker.focusPane === 'targets' ? GLYPHS.navigation.selected : '•') : ' ';
212
- const state = info.enabled ? (info.inherited ? 'inherit' : 'set') : 'off';
213
- writeText(line, startX + 1, width - 2, `${marker} ${info.label} (${state})`, {
214
- fg: selected ? PALETTE.text : PALETTE.muted,
215
- bg,
216
- bold: selected,
98
+ // ---------------------------------------------------------------------------
99
+ // List rows per mode
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Model rows; without a detail inset the selected row names its full key instead of the provider. */
103
+ /** The muted row shown while the catalog is still loading behind an open picker. */
104
+ const LOADING_ROW: KitRow = { label: 'loading catalog…', muted: true };
105
+
106
+ function modelRows(picker: ModelPickerModal, compact = false): KitRow[] {
107
+ const t = activeTokens();
108
+ const models = picker.getFilteredModels();
109
+ const target = picker.getSelectedTargetInfo();
110
+ const recent = new Set(picker.recentIds);
111
+ const rows: KitRow[] = [];
112
+ let section = '';
113
+ models.forEach((model, index) => {
114
+ const group = picker.isPinned(model) ? 'Pinned' : recent.has(model.id) ? 'Recent' : picker.getModelGroupKey(model);
115
+ if (group !== section) {
116
+ rows.push({ header: group });
117
+ section = group;
118
+ }
119
+ const current = isTargetModel(target, model);
120
+ const selected = index === picker.selectedIndex;
121
+ rows.push({
122
+ label: model.displayName || model.id,
123
+ desc: compact && selected ? modelKey(model) : model.provider,
124
+ right: formatContext(model.contextWindow),
125
+ mark: current ? '●' : undefined,
126
+ markFg: t.brand,
127
+ current,
128
+ selected,
129
+ });
217
130
  });
131
+ if (rows.length === 0 && !picker.catalogLoading) rows.push({ label: picker.query ? `No models match "${picker.query}".` : 'No models are available for this target.', muted: true });
132
+ return rows;
218
133
  }
219
134
 
220
- function renderProviderRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
135
+ function providerRows(picker: ModelPickerModal): KitRow[] {
136
+ const t = activeTokens();
137
+ const counts = new Map<string, number>();
138
+ for (const model of picker.models) counts.set(model.provider, (counts.get(model.provider) ?? 0) + 1);
221
139
  const providers = picker.getFilteredProviders();
222
- const { start, end } = stableWindow(providers.length, picker.selectedIndex, rows);
223
- const modelCounts = new Map<string, number>();
224
- for (const model of picker.models) modelCounts.set(model.provider, (modelCounts.get(model.provider) ?? 0) + 1);
225
- for (let visibleRow = 0; visibleRow < rows; visibleRow += 1) {
226
- const absolute = start + visibleRow;
227
- const provider = providers[absolute];
228
- const line = lines[visibleRow]!;
229
- if (!provider) continue;
230
- const selected = absolute === picker.selectedIndex;
231
- const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
232
- fillRange(line, startX, startX + width - 1, bg);
233
- const marker = selected ? (picker.focusPane === 'items' ? GLYPHS.navigation.selected : '•') : ' ';
234
- const via = picker.configuredViaMap.get(provider) ?? (picker.configuredProviders.has(provider) ? 'configured' : 'not configured');
235
- const count = String(modelCounts.get(provider) ?? 0);
236
- writeText(line, startX + 1, width, padDisplay(marker, 2), { fg: PALETTE.text, bg, bold: selected });
237
- writeText(line, startX + 3, Math.max(0, width - 36), padDisplay(provider, Math.max(0, width - 36)), { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
238
- writeText(line, startX + Math.max(4, width - 31), 18, padDisplay(via, 18), { fg: picker.configuredProviders.has(provider) ? PALETTE.good : PALETTE.warn, bg });
239
- writeText(line, startX + Math.max(4, width - 12), 10, padDisplay(`${count} models`, 10), { fg: PALETTE.dim, bg });
140
+ const rows: KitRow[] = [];
141
+ let index = 0;
142
+ for (const item of picker.getItems()) {
143
+ if (item.isGroupHeader) {
144
+ rows.push({ header: item.label });
145
+ continue;
146
+ }
147
+ const provider = item.id;
148
+ const configured = picker.configuredProviders.has(provider);
149
+ const via = picker.configuredViaMap.get(provider) ?? (configured ? 'configured' : 'not configured');
150
+ const i = providers.indexOf(provider);
151
+ rows.push({
152
+ label: provider,
153
+ desc: via,
154
+ right: `${counts.get(provider) ?? 0} models`,
155
+ mark: configured ? '●' : '○',
156
+ markFg: configured ? t.success : t.warning,
157
+ selected: (i >= 0 ? i : index) === picker.selectedIndex,
158
+ });
159
+ index++;
240
160
  }
241
- if (start > 0 && lines[0]) writeText(lines[0], startX + 1, width - 2, `${GLYPHS.navigation.moreAbove} ${start} more provider(s) above`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
242
- if (end < providers.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${providers.length - end} more provider(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
161
+ if (rows.length === 0 && !picker.catalogLoading) rows.push({ label: picker.query ? `No providers match "${picker.query}".` : 'No providers are registered.', muted: true });
162
+ return rows;
243
163
  }
244
164
 
245
- function renderModelRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
246
- const models = picker.getFilteredModels();
247
- const { start, end } = stableWindow(models.length, picker.selectedIndex, rows);
248
- const providerW = clamp(Math.floor(width * 0.14), 10, 18);
249
- const ctxW = 8;
250
- const tierW = 8;
251
- const capsW = 14;
252
- const nameW = clamp(Math.floor(width * 0.28), 16, 36);
253
- const keyW = Math.max(10, width - providerW - ctxW - tierW - capsW - nameW - 10);
254
- for (let visibleRow = 0; visibleRow < rows; visibleRow += 1) {
255
- const absolute = start + visibleRow;
256
- const model = models[absolute];
257
- const line = lines[visibleRow]!;
258
- if (!model) continue;
259
- const selected = absolute === picker.selectedIndex;
260
- const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
261
- fillRange(line, startX, startX + width - 1, bg);
262
- const marker = selected ? (picker.focusPane === 'items' ? GLYPHS.navigation.selected : '•') : ' ';
263
- const caps = model.capabilities ?? {};
264
- const capText = [
265
- caps.reasoning ? 'R' : '-',
266
- caps.multimodal ? 'V' : '-',
267
- caps.toolCalling ? 'T' : '-',
268
- caps.codeEditing ? 'C' : '-',
269
- ].join('');
270
- let x = startX + 1;
271
- writeText(line, x, 2, padDisplay(marker, 2), { fg: PALETTE.text, bg, bold: selected }); x += 2;
272
- writeText(line, x, keyW, padDisplay(modelKey(model), keyW), { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected }); x += keyW + 1;
273
- writeText(line, x, nameW, padDisplay(model.displayName, nameW), { fg: selected ? PALETTE.subtitle : PALETTE.text, bg }); x += nameW + 1;
274
- writeText(line, x, providerW, padDisplay(model.provider, providerW), { fg: PALETTE.muted, bg }); x += providerW + 1;
275
- writeText(line, x, ctxW, padDisplay(formatContext(model.contextWindow), ctxW), { fg: PALETTE.dim, bg }); x += ctxW + 1;
276
- writeText(line, x, tierW, padDisplay(model.tier ?? 'paid', tierW), { fg: model.tier === 'free' ? PALETTE.good : PALETTE.dim, bg }); x += tierW + 1;
277
- writeText(line, x, capsW, padDisplay(capText, capsW), { fg: PALETTE.info, bg });
278
- }
279
- if (start > 0 && lines[0]) writeText(lines[0], startX + 1, width - 2, `${GLYPHS.navigation.moreAbove} ${start} more model(s) above`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
280
- if (end < models.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${models.length - end} more model(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
165
+ function embeddingRows(picker: ModelPickerModal): KitRow[] {
166
+ const t = activeTokens();
167
+ if (picker.embeddingProviders.length === 0) return picker.catalogLoading ? [] : [{ label: 'No embedding providers are registered.', muted: true }];
168
+ return picker.embeddingProviders.map((provider, index) => ({
169
+ label: provider.label,
170
+ desc: provider.detail,
171
+ right: `${provider.dimensions}d · ${provider.configured ? 'configured' : 'unconfigured'}`,
172
+ rightFg: provider.configured ? undefined : t.warning,
173
+ mark: provider.configured ? '●' : '○',
174
+ markFg: provider.configured ? t.success : t.warning,
175
+ selected: index === picker.selectedIndex,
176
+ }));
281
177
  }
282
178
 
283
179
  /**
284
- * The effort step of the model picker.
285
- *
286
- * The list is exactly the levels the chosen model resolved to, not a fixed
287
- * four, and it is introduced by a headline naming what this model does with
288
- * the setting (named levels, a thinking-token budget, an on/off toggle, or
289
- * nothing at all). A best-guess spec prints its caveat under the list rather
290
- * than presenting unverified levels as if the provider had confirmed them.
180
+ * The effort step: exactly the levels the chosen model resolved to, introduced
181
+ * by a headline naming what the model does with the setting. A best-guess spec
182
+ * prints its caveat under the list rather than presenting unverified levels as
183
+ * confirmed; a model with nothing to configure gets a statement, not a list.
291
184
  */
292
- function renderEffortRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
185
+ function effortRows(picker: ModelPickerModal): KitRow[] {
186
+ const t = activeTokens();
293
187
  const presentation = picker.effortPresentation;
294
- let row = 0;
295
-
296
- if (presentation && rows > 0) {
297
- fillRange(lines[row]!, startX, startX + width - 1, PALETTE.bodyBg);
298
- writeText(lines[row]!, startX + 1, width - 2, presentation.headline, { fg: PALETTE.subtitle, bg: PALETTE.bodyBg });
299
- row += 1;
300
- }
301
-
302
- // A model with nothing to configure gets a statement, not an empty list.
188
+ const rows: KitRow[] = [];
189
+ if (presentation) rows.push({ label: presentation.headline, labelFg: t.accent });
303
190
  if (presentation && !presentation.configurable) {
304
- if (row < rows) {
305
- fillRange(lines[row]!, startX, startX + width - 1, PALETTE.bodyBg);
306
- writeText(lines[row]!, startX + 1, width - 2, 'Esc returns to the model list.', { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
307
- }
308
- return;
191
+ rows.push({ label: 'Esc returns to the model list.', labelFg: t.textFaint });
192
+ return rows;
309
193
  }
194
+ picker.effortLevels.forEach((effort, index) => {
195
+ rows.push({ label: effort, desc: presentation?.choices[index]?.description || undefined, selected: index === picker.selectedIndex });
196
+ });
197
+ if (presentation?.caveat && picker.effortLevels.length > 0) rows.push({ label: presentation.caveat, labelFg: t.textFaint });
198
+ return rows;
199
+ }
310
200
 
311
- const firstLevelRow = row;
312
- for (let i = 0; i < picker.effortLevels.length && row < rows; i += 1, row += 1) {
313
- const effort = picker.effortLevels[i]!;
314
- const selected = i === picker.selectedIndex;
315
- const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
316
- fillRange(lines[row]!, startX, startX + width - 1, bg);
317
- const marker = selected ? GLYPHS.navigation.selected : ' ';
318
- const detail = presentation?.choices[i]?.description ?? '';
319
- const text = detail ? `${marker} ${effort.padEnd(8)}${detail}` : `${marker} ${effort}`;
320
- writeText(lines[row]!, startX + 1, width - 2, text, { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
321
- }
201
+ // ---------------------------------------------------------------------------
202
+ // Detail inset
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** Active filters in a few words, '' when none are set. */
206
+ function filterSummary(picker: ModelPickerModal): string {
207
+ const parts: string[] = [];
208
+ if (picker.categoryFilter !== 'all') parts.push(`${picker.categoryFilter} only`);
209
+ if (picker.capabilityFilter !== 'none') parts.push(`needs ${picker.capabilityFilter}`);
210
+ if (picker.availableOnly) parts.push('available only');
211
+ if (picker.benchmarkSort !== 'none') parts.push(`sorted by ${picker.benchmarkSort}`);
212
+ if (picker.groupBy !== 'provider') parts.push(`grouped by ${picker.groupBy}`);
213
+ return parts.join(' · ');
214
+ }
322
215
 
323
- if (presentation?.caveat && row < rows && row > firstLevelRow) {
324
- fillRange(lines[row]!, startX, startX + width - 1, PALETTE.bodyBg);
325
- writeText(lines[row]!, startX + 1, width - 2, presentation.caveat, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
326
- }
216
+ function kv(canvas: ModalFrame['canvas'], p: KitInset, y: number, key: string, value: string, fg?: string): number {
217
+ const t = activeTokens();
218
+ if (y > p.bottom) return y;
219
+ insetPut(canvas, p, p.l, y, key, { fg: t.textMuted });
220
+ return insetWrap(canvas, p, p.l + 12, y, value, { fg: fg ?? t.text });
327
221
  }
328
222
 
329
- function renderContextCapRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
330
- if (rows <= 0) return;
331
- const model = picker.contextCapPendingModel;
332
- const input = picker.contextCapQuery.length > 0 ? picker.contextCapQuery : '(use detected context)';
333
- const copy = [
334
- `Model: ${model ? modelKey(model) : '(none)'}`,
335
- `Detected context: ${formatContext(model?.contextWindow)}`,
336
- `Override: ${input}`,
337
- 'Type digits to set a cap. Enter confirms; Esc returns to the model list.',
338
- ];
339
- for (let row = 0; row < Math.min(rows, copy.length); row += 1) {
340
- const line = lines[row]!;
341
- fillRange(line, startX, startX + width - 1, PALETTE.bodyBg);
342
- writeText(line, startX + 1, width - 2, copy[row]!, { fg: row === 2 ? PALETTE.title : PALETTE.text, bg: PALETTE.bodyBg, bold: row === 2 });
223
+ function drawModelDetail(f: ModalFrame, picker: ModelPickerModal, p: KitInset): void {
224
+ const t = activeTokens();
225
+ const c = f.canvas;
226
+ const model = picker.getSelected();
227
+ let y = p.top;
228
+ if (!model) {
229
+ y = insetWrap(c, p, p.l, y, 'Nothing is selected.', { fg: t.textMuted });
230
+ } else {
231
+ y = insetWrap(c, p, p.l, y, model.displayName || model.id, { fg: t.text, bold: true });
232
+ y = insetWrap(c, p, p.l, y, modelKey(model), { fg: t.textFaint });
233
+ y++;
234
+ const serves = picker.targetInfos.filter((info) => isTargetModel(info, model)).map((info) => targetLabelFor(info.target));
235
+ const configured = picker.configuredProviders.has(model.provider);
236
+ y = kv(c, p, y, 'Provider', model.provider);
237
+ if (model.tier) y = kv(c, p, y, 'Tier', model.tier);
238
+ if (serves.length > 0) y = kv(c, p, y, 'Serves', serves.join(' · '));
239
+ const target = picker.getSelectedTargetInfo();
240
+ if (target && !isTargetModel(target, model)) y = kv(c, p, y, 'Now', `${targetLabelFor(target.target)}: ${targetSummary(target)}`);
241
+ y = kv(c, p, y, 'Status', configured ? 'configured' : 'not configured', configured ? t.success : t.warning);
242
+ if (picker.isPinned(model)) y = kv(c, p, y, 'Pinned', 'yes');
243
+ y++;
244
+ // Meters only where the data exists.
245
+ if (model.contextWindow && y <= p.bottom) {
246
+ insetPut(c, p, p.l, y, 'Context', { fg: t.textMuted });
247
+ const end = meter(c, p.l + 12, y, Math.min(1, model.contextWindow / 1_000_000), t.brand, p.bg);
248
+ insetPut(c, p, end + 2, y++, formatContext(model.contextWindow), { fg: t.textFaint });
249
+ }
250
+ const score = picker.benchmarkScore(model);
251
+ if (score !== null && y <= p.bottom) {
252
+ insetPut(c, p, p.l, y, 'Quality', { fg: t.textMuted });
253
+ const end = meter(c, p.l + 12, y, score, t.success, p.bg);
254
+ insetPut(c, p, end + 2, y++, score.toFixed(2), { fg: t.textFaint });
255
+ }
256
+ if (model.pricing && y <= p.bottom) {
257
+ y = kv(c, p, y, 'Cost', `$${model.pricing.input} in · $${model.pricing.output} out per 1M tokens`);
258
+ }
259
+ const caps = capabilityNames(model);
260
+ if (caps.length > 0 && y <= p.bottom) {
261
+ let cx = p.l;
262
+ for (const cap of caps) {
263
+ if (cx + cap.length + 2 > p.r + 1) break;
264
+ cx = c.put(cx, y, ` ${cap} `, { fg: t.text, bg: t.border }) + 1;
265
+ }
266
+ y += 2;
267
+ }
268
+ if (model.description && y <= p.bottom) y = insetWrap(c, p, p.l, y, model.description, { fg: t.textMuted }) + 1;
269
+ if (picker.isLocalModel(model) && y <= p.bottom) y = insetWrap(c, p, p.l, y, 'Local model: space sets a context cap.', { fg: t.textFaint }) + 1;
343
270
  }
271
+ drawTargetFooter(f, picker, p, y);
344
272
  }
345
273
 
346
- function renderEmbeddingProviderRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
347
- const providers = picker.embeddingProviders;
348
- const { start, end } = stableWindow(providers.length, picker.selectedIndex, rows);
349
- for (let visibleRow = 0; visibleRow < rows; visibleRow += 1) {
350
- const absolute = start + visibleRow;
351
- const provider = providers[absolute];
352
- const line = lines[visibleRow]!;
353
- if (!provider) continue;
354
- const selected = absolute === picker.selectedIndex;
355
- const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
356
- fillRange(line, startX, startX + width - 1, bg);
357
- const marker = selected ? (picker.focusPane === 'items' ? GLYPHS.navigation.selected : '•') : ' ';
358
- const status = provider.configured ? 'configured' : 'unconfigured';
359
- writeText(line, startX + 1, width, padDisplay(marker, 2), { fg: PALETTE.text, bg, bold: selected });
360
- writeText(line, startX + 3, Math.max(0, width - 30), padDisplay(provider.label, Math.max(0, width - 30)), { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
361
- writeText(line, startX + Math.max(4, width - 25), 12, padDisplay(`${provider.dimensions}d`, 12), { fg: PALETTE.dim, bg });
362
- writeText(line, startX + Math.max(4, width - 13), 12, padDisplay(status, 12), { fg: provider.configured ? PALETTE.good : PALETTE.warn, bg });
274
+ function drawTargetFooter(f: ModalFrame, picker: ModelPickerModal, p: KitInset, fromY: number): void {
275
+ const t = activeTokens();
276
+ const target = picker.getSelectedTargetInfo();
277
+ const lines: Array<{ text: string; fg: string }> = [];
278
+ const width = p.r - p.l + 1;
279
+ if (target && picker.mode !== 'model') {
280
+ for (const l of wrapLines(`${target.label}: ${targetSummary(target)}`, width)) lines.push({ text: l, fg: t.textMuted });
281
+ if (target.description) for (const l of wrapLines(target.description, width)) lines.push({ text: l, fg: t.textFaint });
282
+ }
283
+ if (picker.mode === 'model') {
284
+ const chords = FILTER_HINTS.map(([key, action]) => `${key} ${action}`).join(' · ');
285
+ for (const l of wrapLines(chords, width)) lines.push({ text: l, fg: t.textFaint });
286
+ }
287
+ // Pinned to the bottom of the inset when there is room, else right after the content.
288
+ let y = Math.max(fromY, p.bottom - lines.length + 1);
289
+ for (const line of lines) {
290
+ if (y > p.bottom) break;
291
+ insetPut(f.canvas, p, p.l, y++, line.text, { fg: line.fg });
363
292
  }
364
- if (start > 0 && lines[0]) writeText(lines[0], startX + 1, width - 2, `${GLYPHS.navigation.moreAbove} ${start} more provider(s) above`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
365
- if (end < providers.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${providers.length - end} more provider(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
366
293
  }
367
294
 
368
- function writeTableHeader(line: Line, picker: ModelPickerModal, startX: number, width: number): void {
369
- fillRange(line, startX, startX + width - 1, PALETTE.footerBg);
370
- const style = { fg: PALETTE.muted, bg: PALETTE.footerBg, bold: true };
295
+ function drawStepDetail(f: ModalFrame, picker: ModelPickerModal, p: KitInset): void {
296
+ const t = activeTokens();
297
+ const c = f.canvas;
298
+ let y = p.top;
371
299
  if (picker.mode === 'provider') {
372
- writeText(line, startX + 1, width - 2, 'Provider Configuration Catalog', style);
373
- return;
374
- }
375
- if (picker.mode === 'effort') {
376
- writeText(line, startX + 1, width - 2, 'Reasoning effort Meaning', style);
377
- return;
378
- }
379
- if (picker.mode === 'contextCap') {
380
- writeText(line, startX + 1, width - 2, 'Context cap input', style);
381
- return;
300
+ const provider = picker.getFilteredProviders()[picker.selectedIndex] ?? '';
301
+ const target = picker.getSelectedTargetInfo();
302
+ y = insetWrap(c, p, p.l, y, provider || 'No provider selected', { fg: t.text, bold: true });
303
+ y++;
304
+ y = insetWrap(c, p, p.l, y, `Choose a provider, then choose a model for ${target?.label ?? targetLabelFor(picker.target)}.`, { fg: t.textMuted });
305
+ if (provider) {
306
+ const via = picker.configuredViaMap.get(provider);
307
+ y = kv(c, p, y + 1, 'Status', picker.configuredProviders.has(provider) ? `configured${via ? ` via ${via}` : ''}` : 'not configured', picker.configuredProviders.has(provider) ? t.success : t.warning);
308
+ y = kv(c, p, y, 'Models', String(picker.models.filter((m) => m.provider === provider).length));
309
+ }
310
+ } else if (picker.mode === 'embeddingProvider') {
311
+ const selected = picker.embeddingProviders[picker.selectedIndex];
312
+ y = insetWrap(c, p, p.l, y, 'Embedding provider', { fg: t.text, bold: true });
313
+ y = insetWrap(c, p, p.l, y + 1, 'The provider memory search and the code index use to embed content.', { fg: t.textMuted });
314
+ if (selected) {
315
+ y = kv(c, p, y + 1, 'Provider', selected.id);
316
+ y = kv(c, p, y, 'Dimensions', String(selected.dimensions));
317
+ y = kv(c, p, y, 'Status', selected.configured ? 'configured' : 'unconfigured', selected.configured ? t.success : t.warning);
318
+ if (selected.detail) y = insetWrap(c, p, p.l, y + 1, selected.detail, { fg: t.textMuted });
319
+ }
320
+ } else if (picker.mode === 'effort') {
321
+ const model = picker.pendingModel;
322
+ y = insetWrap(c, p, p.l, y, model ? (model.displayName || model.id) : 'Reasoning effort', { fg: t.text, bold: true });
323
+ if (model) y = insetWrap(c, p, p.l, y, modelKey(model), { fg: t.textFaint });
324
+ y = insetWrap(c, p, p.l, y + 1, 'Reasoning effort applies to the main chat model. Select the default effort for this model.', { fg: t.textMuted });
382
325
  }
383
- if (picker.mode === 'embeddingProvider') {
384
- writeText(line, startX + 1, width - 2, 'Embedding provider Dimensions Status', style);
385
- return;
326
+ drawTargetFooter(f, picker, p, y + 1);
327
+ }
328
+
329
+ /** The context-cap step: a small form in the list area. */
330
+ function drawContextCap(f: ModalFrame, picker: ModelPickerModal, top: number, x0: number, x1: number): void {
331
+ const t = activeTokens();
332
+ const model = picker.contextCapPendingModel;
333
+ const width = x1 - x0 + 1;
334
+ let y = top;
335
+ const line = (text: string, fg: string, bold = false): void => {
336
+ for (const l of wrapLines(text, width)) if (y <= f.bottom) f.canvas.put(x0, y++, l, { fg, bold });
337
+ };
338
+ line(`Model: ${model ? modelKey(model) : '(none)'}`, t.text);
339
+ line(`Detected context: ${formatContext(model?.contextWindow)}`, t.textMuted);
340
+ y++;
341
+ const input = picker.contextCapQuery.length > 0 ? picker.contextCapQuery : '';
342
+ if (y <= f.bottom) {
343
+ const end = f.canvas.put(x0, y, 'Override ', { fg: t.textMuted });
344
+ const typed = f.canvas.put(end, y, input, { fg: t.text, bold: true });
345
+ f.canvas.put(typed, y, '▏', { fg: t.brand });
346
+ if (!input) f.canvas.put(typed + 1, y, 'uses the detected context', { fg: t.textFaint });
347
+ y += 2;
386
348
  }
387
- const providerW = clamp(Math.floor(width * 0.14), 10, 18);
388
- const ctxW = 8;
389
- const tierW = 8;
390
- const capsW = 14;
391
- const nameW = clamp(Math.floor(width * 0.28), 16, 36);
392
- const keyW = Math.max(10, width - providerW - ctxW - tierW - capsW - nameW - 10);
393
- let x = startX + 3;
394
- writeText(line, x, keyW, padDisplay('Model key', keyW), style); x += keyW + 1;
395
- writeText(line, x, nameW, padDisplay('Display name', nameW), style); x += nameW + 1;
396
- writeText(line, x, providerW, padDisplay('Provider', providerW), style); x += providerW + 1;
397
- writeText(line, x, ctxW, padDisplay('Context', ctxW), style); x += ctxW + 1;
398
- writeText(line, x, tierW, padDisplay('Tier', tierW), style); x += tierW + 1;
399
- writeText(line, x, capsW, padDisplay('Caps', capsW), style);
349
+ if (picker.contextCapError) line(picker.contextCapError, t.error);
350
+ line('Type digits to set a cap. Enter confirms; Esc returns to the model list.', t.textFaint);
400
351
  }
401
352
 
402
- export function renderModelWorkspace(picker: ModelPickerModal, width: number, viewportHeight: number): Line[] {
403
- const cacheKey = getRenderCacheKey(picker, width, viewportHeight);
404
- const cached = renderCache.get(picker);
405
- if (cached?.key === cacheKey) return cached.lines;
406
-
407
- const safeWidth = Math.max(20, width);
408
- const safeHeight = Math.max(12, viewportHeight);
409
- const lines: Line[] = [];
410
- const targetW = clamp(Math.round(safeWidth * 0.18), 24, 34);
411
- const contentX = targetW + 1;
412
- const contentW = Math.max(1, safeWidth - contentX - 1);
413
-
414
- const top = borderLine(safeWidth, GLYPHS.frame.topLeft, GLYPHS.frame.horizontal, GLYPHS.frame.topRight);
415
- writeText(top, 2, safeWidth - 4, ' Model Workspace / Providers And Models ', { fg: PALETTE.title, bold: true });
416
- lines.push(top);
417
-
418
- const header = contentLine(safeWidth, PALETTE.footerBg);
419
- writeText(header, 2, targetW - 2, 'Targets', { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
420
- drawVertical(header, targetW, PALETTE.footerBg);
421
- const modeLabel = picker.mode === 'provider' ? 'Provider list' : picker.mode === 'model' ? 'Model list' : picker.mode === 'effort' ? 'Reasoning effort' : picker.mode === 'embeddingProvider' ? 'Embedding providers' : 'Context cap';
422
- writeText(header, contentX + 1, contentW - 2, `${modeLabel} • ${picker.getItemCount()} item(s)`, { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
423
- lines.push(header);
424
-
425
- const sep = borderLine(safeWidth, GLYPHS.frame.teeLeft, GLYPHS.frame.horizontal, GLYPHS.frame.teeRight);
426
- if (targetW > 0 && targetW < safeWidth - 1) sep[targetW] = createStyledCell(GLYPHS.frame.cross, { fg: PALETTE.border });
427
- lines.push(sep);
428
-
429
- const footerRows = 2;
430
- const bodyRows = safeHeight - 3 - footerRows;
431
- const maxDetailRows = Math.max(3, bodyRows - 2);
432
- const minDetailRows = Math.min(6, maxDetailRows);
433
- const details = detailLines(picker, contentW - 2);
434
- // Never drop detail lines (the modal rule: UI-authored descriptive text is
435
- // always shown in full). Size the detail band to the ACTUAL content,
436
- // growing past the old fixed proportional default up to maxDetailRows,
437
- // the ceiling that keeps at least a header row and one list row available,
438
- // rather than silently slicing off whatever didn't fit a 32% guess.
439
- const detailRows = clamp(Math.max(details.length, Math.round(bodyRows * 0.32)), minDetailRows, maxDetailRows);
440
- const listRows = Math.max(1, bodyRows - detailRows - 1);
441
-
442
- for (let row = 0; row < bodyRows; row += 1) {
443
- const inDetail = row < detailRows;
444
- const line = contentLine(safeWidth, inDetail ? PALETTE.detailBg : PALETTE.bodyBg);
445
- fillRange(line, 1, targetW - 1, PALETTE.targetBg);
446
- drawVertical(line, targetW, inDetail ? PALETTE.detailBg : PALETTE.bodyBg);
447
- renderTargets(picker, line, 1, targetW - 1, row);
448
- if (inDetail) {
449
- const text = details[row] ?? '';
450
- const fg = row === 0 ? PALETTE.title : row <= 2 ? PALETTE.text : PALETTE.muted;
451
- writeText(line, contentX + 1, contentW - 2, text, { fg, bg: PALETTE.detailBg, bold: row === 0 });
452
- }
453
- lines.push(line);
353
+ // ---------------------------------------------------------------------------
354
+ // Hints
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /** The filter chords of the model list (shown in the detail inset, or in the hints without one). */
358
+ const FILTER_HINTS: readonly KitHint[] = [['ctrl+t', 'price'], ['ctrl+k', 'capability'], ['ctrl+a', 'available'], ['ctrl+b', 'benchmark sort'], ['ctrl+g', 'group']];
359
+
360
+ function pickerHints(picker: ModelPickerModal, withFilters: boolean): KitHint[] {
361
+ const target = picker.getSelectedTargetInfo()?.label ?? targetLabelFor(picker.target);
362
+ switch (picker.mode) {
363
+ case 'effort': return [['↑↓', 'move'], ['⏎', 'use this effort'], ['esc', 'back to models']];
364
+ case 'contextCap': return [['0-9', 'cap'], ['⏎', 'confirm'], ['esc', 'back to models']];
365
+ case 'embeddingProvider': return [['↑↓', 'move'], ['⏎', 'use'], ['tab', 'next target']];
366
+ case 'provider': return [['↑↓', 'move'], ['⏎', 'choose provider'], ['tab', 'next target']];
367
+ default: return [
368
+ ['⏎', `use for ${target}`], ['tab', 'next target'], ['ctrl+f', 'pin'], ['ctrl+r', 'refresh catalog'],
369
+ ...(withFilters ? FILTER_HINTS : []),
370
+ ];
454
371
  }
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Renderer
376
+ // ---------------------------------------------------------------------------
455
377
 
456
- const listStart = 3 + detailRows + 1;
457
- if (listStart - 1 < lines.length) {
458
- const divider = lines[listStart - 1]!;
459
- writeTableHeader(divider, picker, contentX, contentW - 1);
378
+ const renderCache = new WeakMap<ModelPickerModal, { key: string; tokens: object; layer: SurfaceLayer }>();
379
+
380
+ export function renderModelWorkspace(picker: ModelPickerModal, screenWidth: number, screenHeight: number): SurfaceLayer {
381
+ const cacheKey = getRenderCacheKey(picker, screenWidth, screenHeight);
382
+ const cached = renderCache.get(picker);
383
+ if (cached?.key === cacheKey && cached.tokens === activeTokens()) return cached.layer;
384
+
385
+ const pending = picker.mode === 'effort' ? picker.pendingModel : picker.mode === 'contextCap' ? picker.contextCapPendingModel : null;
386
+ const crumbs = picker.mode === 'effort' ? [pending?.displayName ?? 'model', 'Reasoning effort']
387
+ : picker.mode === 'contextCap' ? [pending?.displayName ?? 'model', 'Context cap']
388
+ : picker.mode === 'provider' ? ['Providers']
389
+ : undefined;
390
+ const inner = modalInnerWidth(standardModalWidth(screenWidth));
391
+ const sideBySide = inner >= 60;
392
+ const f = beginModal(screenWidth, screenHeight, { title: 'Models', crumbs, hints: pickerHints(picker, !sideBySide) });
393
+
394
+ // Targets as tabs; the active tab carries the gradient.
395
+ const labels = picker.targetInfos.length > 0
396
+ ? picker.targetInfos.map((info) => `${targetLabelFor(info.target)}${info.enabled ? '' : ' (off)'}`)
397
+ : [targetLabelFor(picker.target)];
398
+ tabs(f.canvas, f.l, f.top, labels, picker.targetInfos.length > 0 ? picker.targetIndex : 0, f.r);
399
+
400
+ const searchable = picker.mode === 'model' || picker.mode === 'provider';
401
+ let body = f.top + 2;
402
+ if (searchable) {
403
+ const total = picker.mode === 'model' ? picker.models.length : picker.providers.length;
404
+ const shown = picker.getItemCount();
405
+ const counted = picker.query.length > 0 || shown !== total ? `${shown.toLocaleString('en-US')} of ${total.toLocaleString('en-US')}` : `${total.toLocaleString('en-US')} ${picker.mode === 'model' ? 'models' : 'providers'}`;
406
+ const filters = picker.mode === 'model' ? filterSummary(picker) : '';
407
+ const count = filters ? `${filters} · ${counted}` : counted;
408
+ searchRow(f, body, picker.query, picker.mode === 'model' ? 'Search models' : 'Search providers', count);
409
+ body += 2;
460
410
  }
461
411
 
462
- const listLines = lines.slice(listStart, listStart + listRows);
463
- if (picker.mode === 'provider') {
464
- renderProviderRows(picker, listLines, listLines.length, contentX, contentW - 1);
465
- } else if (picker.mode === 'model') {
466
- renderModelRows(picker, listLines, listLines.length, contentX, contentW - 1);
467
- } else if (picker.mode === 'contextCap') {
468
- renderContextCapRows(picker, listLines, listLines.length, contentX, contentW - 1);
469
- } else if (picker.mode === 'embeddingProvider') {
470
- renderEmbeddingProviderRows(picker, listLines, listLines.length, contentX, contentW - 1);
412
+ const x1 = sideBySide ? f.l + Math.floor(inner * 0.5) - 1 : f.r;
413
+ let result: KitListResult | null = null;
414
+ const scrollKey = { owner: picker, name: picker.mode };
415
+ if (picker.mode === 'contextCap') {
416
+ drawContextCap(f, picker, body, f.l, x1);
471
417
  } else {
472
- renderEffortRows(picker, listLines, listLines.length, contentX, contentW - 1);
418
+ const listed = picker.mode === 'model' ? modelRows(picker, !sideBySide)
419
+ : picker.mode === 'provider' ? providerRows(picker)
420
+ : picker.mode === 'embeddingProvider' ? embeddingRows(picker)
421
+ : effortRows(picker);
422
+ // The picker opens on its cached catalog; the rest fills in while this row shows.
423
+ const rows = picker.catalogLoading && picker.mode !== 'effort' ? [LOADING_ROW, ...listed] : listed;
424
+ result = drawList(f.canvas, { rows, top: body, bottom: f.bottom, x0: f.l, x1, scrollKey });
473
425
  }
474
-
475
- const footer = contentLine(safeWidth, PALETTE.footerBg);
476
- const targetHint = picker.focusPane === 'targets' ? 'Focus targets' : 'Focus list';
477
- // W3 Finding 2: while search is focused, every printable key (including
478
- // c/a/b/g) types into the query, handler-picker-routes.ts guards those
479
- // single-key shortcuts with `!searchFocused` (Up/Down blur search first).
480
- // Advertising them here regardless of focus state was a footer that lied
481
- // about what the keys currently do. Tab still cycles price in both states
482
- // (its handler has no searchFocused guard), so it stays in both hint sets.
483
- const hints = picker.searchFocused
484
- ? `${targetHint} • Typing filters search; Esc clears search • Down: list shortcuts • Tab price • Esc close`
485
- : `${targetHint} • Up/Down navigate • Left/Right pane • Enter select • / search • Tab price • C caps • A available • B benchmark • G group • Esc close`;
486
- writeText(footer, 2, safeWidth - 4, hints, { fg: PALETTE.muted, bg: PALETTE.footerBg });
487
- lines.push(footer);
488
- lines.push(borderLine(safeWidth, GLYPHS.frame.bottomLeft, GLYPHS.frame.horizontal, GLYPHS.frame.bottomRight));
489
-
490
- while (lines.length < safeHeight) lines.push(makeLine(safeWidth));
491
- const result = lines.slice(0, safeHeight);
492
- renderCache.set(picker, { key: cacheKey, lines: result });
493
- return result;
426
+ if (sideBySide) {
427
+ const p = inset(f.canvas, x1 + 3, body, f.r + 2 - (x1 + 3) + 1, f.bottom - body + 1);
428
+ if (picker.mode === 'model' || picker.mode === 'contextCap') drawModelDetail(f, picker, p);
429
+ else drawStepDetail(f, picker, p);
430
+ }
431
+ if (result) f.hintRight = scrollCountText(result.above, result.below);
432
+ const layer = finishModal(f);
433
+ renderCache.set(picker, { key: cacheKey, tokens: activeTokens(), layer });
434
+ return layer;
494
435
  }
495
436
 
496
- function getRenderCacheKey(picker: ModelPickerModal, width: number, viewportHeight: number): string {
437
+ // ---------------------------------------------------------------------------
438
+ // Render cache key (the catalog can hold thousands of models)
439
+ // ---------------------------------------------------------------------------
440
+
441
+ const objectIds = new WeakMap<object, number>();
442
+ let nextObjectId = 1;
443
+
444
+ function getRenderCacheKey(picker: ModelPickerModal, width: number, height: number): string {
497
445
  const base: Array<string | number> = [
498
446
  width,
499
- viewportHeight,
447
+ height,
500
448
  picker.mode,
501
449
  picker.target,
502
- picker.focusPane,
503
450
  picker.targetIndex,
504
451
  picker.query,
505
- picker.searchFocused ? 1 : 0,
506
452
  picker.selectedIndex,
507
- picker.scrollOffset,
508
453
  picker.categoryFilter,
509
454
  picker.capabilityFilter,
510
455
  picker.availableOnly ? 1 : 0,
511
456
  picker.benchmarkSort,
512
457
  picker.groupBy,
458
+ picker.contextCapError ?? '',
513
459
  keyForSet(picker.pinnedIds),
460
+ picker.recentIds.join('\u001f'),
514
461
  keyForSet(picker.configuredProviders),
515
462
  keyForMap(picker.configuredViaMap),
516
463
  keyForTargets(picker.targetInfos),
464
+ picker.catalogLoading ? 1 : 0,
517
465
  ];
518
-
519
466
  if (picker.mode === 'model') {
520
467
  const filtered = picker.getFilteredModels();
521
468
  const selected = filtered[picker.selectedIndex];
@@ -528,8 +475,7 @@ function getRenderCacheKey(picker: ModelPickerModal, width: number, viewportHeig
528
475
  objectId(picker.effortLevels),
529
476
  picker.effortLevels.join('\u001f'),
530
477
  picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '',
531
- // The headline and caveat are rendered content too: two models with the
532
- // same level list but different spec kinds must not share a cache entry.
478
+ // The headline and caveat are rendered content too.
533
479
  picker.effortPresentation?.headline ?? '',
534
480
  picker.effortPresentation?.caveat ?? '',
535
481
  );
@@ -538,7 +484,6 @@ function getRenderCacheKey(picker: ModelPickerModal, width: number, viewportHeig
538
484
  } else if (picker.mode === 'embeddingProvider') {
539
485
  base.push(objectId(picker.embeddingProviders), picker.embeddingProviders.length);
540
486
  }
541
-
542
487
  return base.join('\u001e');
543
488
  }
544
489