@oh-my-pi/pi-tui 18.2.4 → 18.2.5

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 (748) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +43 -0
  3. package/dist/types/app-keybindings.d.ts +395 -0
  4. package/dist/types/apps/autoresearch-dashboard.d.ts +43 -0
  5. package/dist/types/apps/autoresearch-data.d.ts +15 -0
  6. package/dist/types/apps/cleanse-board.d.ts +66 -0
  7. package/dist/types/apps/cleanse-picker.d.ts +23 -0
  8. package/dist/types/apps/debug/log-formatting.d.ts +6 -0
  9. package/dist/types/apps/debug/log-viewer.d.ts +81 -0
  10. package/dist/types/apps/debug/protocol-probe.d.ts +42 -0
  11. package/dist/types/apps/debug/raw-sse-buffer.d.ts +58 -0
  12. package/dist/types/apps/debug/raw-sse.d.ts +26 -0
  13. package/dist/types/apps/debug/terminal-info.d.ts +34 -0
  14. package/dist/types/apps/debug/viewer-frame.d.ts +52 -0
  15. package/dist/types/apps/git/avatar.d.ts +9 -0
  16. package/dist/types/apps/git/colors.d.ts +49 -0
  17. package/dist/types/apps/git/diff-pane.d.ts +188 -0
  18. package/dist/types/apps/git/git-tui.d.ts +66 -0
  19. package/dist/types/apps/git/sidebar.d.ts +149 -0
  20. package/dist/types/apps/git/state.d.ts +117 -0
  21. package/dist/types/apps/if-bench-board.d.ts +75 -0
  22. package/dist/types/apps/live-visualizer.d.ts +33 -0
  23. package/dist/types/apps/ps-data.d.ts +51 -0
  24. package/dist/types/apps/ps-top.d.ts +33 -0
  25. package/dist/types/apps/session-picker.d.ts +26 -0
  26. package/dist/types/apps/setup-model-picker.d.ts +13 -0
  27. package/dist/types/apps/standalone-picker.d.ts +42 -0
  28. package/dist/types/autocomplete.d.ts +4 -0
  29. package/dist/types/chat/advisor-message.d.ts +11 -0
  30. package/dist/types/chat/assistant-message.d.ts +106 -0
  31. package/dist/types/chat/background-tan-message.d.ts +9 -0
  32. package/dist/types/chat/bash-execution.d.ts +47 -0
  33. package/dist/types/chat/cache-invalidation-marker.d.ts +50 -0
  34. package/dist/types/chat/chat-transcript-builder.d.ts +50 -0
  35. package/dist/types/chat/collab-prompt-message.d.ts +10 -0
  36. package/dist/types/chat/compaction-summary-message.d.ts +54 -0
  37. package/dist/types/chat/custom-message.d.ts +10 -0
  38. package/dist/types/chat/display-preferences.d.ts +13 -0
  39. package/dist/types/chat/eval-execution.d.ts +29 -0
  40. package/dist/types/chat/execution-shared.d.ts +51 -0
  41. package/dist/types/chat/extension-types.d.ts +28 -0
  42. package/dist/types/chat/hook-message.d.ts +10 -0
  43. package/dist/types/chat/image-loading.d.ts +46 -0
  44. package/dist/types/chat/late-diagnostics-message.d.ts +21 -0
  45. package/dist/types/chat/messages.d.ts +189 -0
  46. package/dist/types/chat/reaction.d.ts +30 -0
  47. package/dist/types/chat/read-target.d.ts +4 -0
  48. package/dist/types/chat/read-tool-group.d.ts +67 -0
  49. package/dist/types/chat/served-model-marker.d.ts +47 -0
  50. package/dist/types/chat/skill-message.d.ts +33 -0
  51. package/dist/types/chat/skill-title-input.d.ts +14 -0
  52. package/dist/types/chat/stripped-tool-calls-placeholder.d.ts +12 -0
  53. package/dist/types/chat/thinking-display.d.ts +12 -0
  54. package/dist/types/chat/todo-reminder.d.ts +13 -0
  55. package/dist/types/chat/tool-execution.d.ts +138 -0
  56. package/dist/types/chat/transcript-browser.d.ts +93 -0
  57. package/dist/types/chat/transcript-entry.d.ts +45 -0
  58. package/dist/types/chat/transcript-outline.d.ts +86 -0
  59. package/dist/types/chat/transcript-render-helpers.d.ts +92 -0
  60. package/dist/types/chat/ttsr-notification.d.ts +23 -0
  61. package/dist/types/chat/user-message.d.ts +52 -0
  62. package/dist/types/chrome/chat-block.d.ts +64 -0
  63. package/dist/types/chrome/collab-qrcode.d.ts +28 -0
  64. package/dist/types/chrome/context-thresholds.d.ts +10 -0
  65. package/dist/types/chrome/countdown-timer.d.ts +14 -0
  66. package/dist/types/chrome/diff.d.ts +14 -0
  67. package/dist/types/chrome/dynamic-border.d.ts +16 -0
  68. package/dist/types/chrome/error-block.d.ts +17 -0
  69. package/dist/types/chrome/form-theme.d.ts +3 -0
  70. package/dist/types/chrome/format.d.ts +12 -0
  71. package/dist/types/chrome/index.d.ts +20 -0
  72. package/dist/types/chrome/keybinding-hints.d.ts +40 -0
  73. package/dist/types/chrome/live-board.d.ts +23 -0
  74. package/dist/types/chrome/local-date.d.ts +4 -0
  75. package/dist/types/chrome/message-divider.d.ts +22 -0
  76. package/dist/types/chrome/message-frame.d.ts +52 -0
  77. package/dist/types/chrome/message-notice.d.ts +35 -0
  78. package/dist/types/chrome/overlay-box.d.ts +74 -0
  79. package/dist/types/chrome/qrcode.d.ts +48 -0
  80. package/dist/types/chrome/segment-track.d.ts +27 -0
  81. package/dist/types/chrome/select-list-mouse-routing.d.ts +4 -0
  82. package/dist/types/chrome/selector-helpers.d.ts +52 -0
  83. package/dist/types/chrome/shared.d.ts +5 -0
  84. package/dist/types/chrome/streaming-panel.d.ts +19 -0
  85. package/dist/types/chrome/tool-activity.d.ts +17 -0
  86. package/dist/types/chrome/transcript-container.d.ts +129 -0
  87. package/dist/types/chrome/transcript-status.d.ts +12 -0
  88. package/dist/types/chrome/visual-truncate.d.ts +19 -0
  89. package/dist/types/components/disclosure.d.ts +42 -0
  90. package/dist/types/components/form.d.ts +130 -0
  91. package/dist/types/components/key-value-list.d.ts +24 -0
  92. package/dist/types/components/layout/geometry.d.ts +73 -0
  93. package/dist/types/components/layout/row.d.ts +55 -0
  94. package/dist/types/components/layout/split-pane.d.ts +80 -0
  95. package/dist/types/components/layout/stack.d.ts +50 -0
  96. package/dist/types/components/menu-selection.d.ts +71 -0
  97. package/dist/types/components/metric.d.ts +44 -0
  98. package/dist/types/components/progress-bar.d.ts +36 -0
  99. package/dist/types/components/scroll-view.d.ts +53 -19
  100. package/dist/types/components/scroll-viewport.d.ts +46 -0
  101. package/dist/types/components/section.d.ts +19 -0
  102. package/dist/types/components/select-list.d.ts +46 -2
  103. package/dist/types/components/settings-list.d.ts +20 -0
  104. package/dist/types/components/table.d.ts +32 -0
  105. package/dist/types/components/tree-view.d.ts +130 -0
  106. package/dist/types/components/wizard-step.d.ts +60 -0
  107. package/dist/types/hotkeys-markdown.d.ts +7 -0
  108. package/dist/types/index.d.ts +17 -0
  109. package/dist/types/keybinding-matchers.d.ts +35 -0
  110. package/dist/types/keys.d.ts +2 -2
  111. package/dist/types/lang-from-path.d.ts +9 -0
  112. package/dist/types/overlays/advisor-config.d.ts +114 -0
  113. package/dist/types/overlays/agent-activity.d.ts +41 -0
  114. package/dist/types/overlays/agent-hub-projection.d.ts +33 -0
  115. package/dist/types/overlays/agent-hub-renderer.d.ts +53 -0
  116. package/dist/types/overlays/agent-hub-types.d.ts +99 -0
  117. package/dist/types/overlays/agent-hub.d.ts +126 -0
  118. package/dist/types/overlays/agent-transcript-viewer.d.ts +71 -0
  119. package/dist/types/overlays/agents-hub.d.ts +72 -0
  120. package/dist/types/overlays/ask-dialog.d.ts +88 -0
  121. package/dist/types/overlays/bordered-loader.d.ts +11 -0
  122. package/dist/types/overlays/btw-history-panel.d.ts +29 -0
  123. package/dist/types/overlays/btw-history.d.ts +17 -0
  124. package/dist/types/overlays/btw-panel.d.ts +26 -0
  125. package/dist/types/overlays/cleanse-panel.d.ts +36 -0
  126. package/dist/types/overlays/codex-reset-fireworks.d.ts +52 -0
  127. package/dist/types/overlays/composer-shape-preview.d.ts +47 -0
  128. package/dist/types/overlays/composer-shape-registry.d.ts +50 -0
  129. package/dist/types/overlays/copy-selector.d.ts +48 -0
  130. package/dist/types/overlays/copy-targets.d.ts +63 -0
  131. package/dist/types/overlays/error-banner.d.ts +13 -0
  132. package/dist/types/overlays/extensions/display-text.d.ts +10 -0
  133. package/dist/types/overlays/extensions/extension-dashboard.d.ts +63 -0
  134. package/dist/types/overlays/extensions/extension-list.d.ts +54 -0
  135. package/dist/types/overlays/extensions/inspector-model.d.ts +109 -0
  136. package/dist/types/overlays/extensions/inspector-panel.d.ts +17 -0
  137. package/dist/types/overlays/extensions/live-tool-session.d.ts +28 -0
  138. package/dist/types/overlays/extensions/mcp-runtime.d.ts +107 -0
  139. package/dist/types/overlays/extensions/state-manager.d.ts +42 -0
  140. package/dist/types/overlays/extensions/types.d.ts +165 -0
  141. package/dist/types/overlays/history-search.d.ts +16 -0
  142. package/dist/types/overlays/hook-editor.d.ts +44 -0
  143. package/dist/types/overlays/hook-input.d.ts +20 -0
  144. package/dist/types/overlays/hook-selector.d.ts +71 -0
  145. package/dist/types/overlays/hub-frame.d.ts +77 -0
  146. package/dist/types/overlays/login-dialog.d.ts +43 -0
  147. package/dist/types/overlays/logout-account-selector.d.ts +15 -0
  148. package/dist/types/overlays/mcp-add-wizard.d.ts +87 -0
  149. package/dist/types/overlays/model-browser.d.ts +213 -0
  150. package/dist/types/overlays/model-hub.d.ts +72 -0
  151. package/dist/types/overlays/model-picker.d.ts +74 -0
  152. package/dist/types/overlays/model-selector.d.ts +52 -0
  153. package/dist/types/overlays/move-overlay.d.ts +39 -0
  154. package/dist/types/overlays/oauth-selector.d.ts +41 -0
  155. package/dist/types/overlays/omfg-panel.d.ts +20 -0
  156. package/dist/types/overlays/pause-screen.d.ts +43 -0
  157. package/dist/types/overlays/plan-review-overlay.d.ts +89 -0
  158. package/dist/types/overlays/plan-save-overlay.d.ts +19 -0
  159. package/dist/types/overlays/plan-toc.d.ts +41 -0
  160. package/dist/types/overlays/plugin-selector.d.ts +28 -0
  161. package/dist/types/overlays/plugin-settings.d.ts +180 -0
  162. package/dist/types/overlays/queue-mode-selector.d.ts +11 -0
  163. package/dist/types/overlays/reset-usage-selector.d.ts +23 -0
  164. package/dist/types/overlays/rewind-selector.d.ts +61 -0
  165. package/dist/types/overlays/running-subagent-badge.d.ts +6 -0
  166. package/dist/types/overlays/session-account-selector.d.ts +24 -0
  167. package/dist/types/overlays/session-info-overlay.d.ts +17 -0
  168. package/dist/types/overlays/session-observer-registry.d.ts +77 -0
  169. package/dist/types/overlays/session-selector.d.ts +131 -0
  170. package/dist/types/overlays/settings-defs.d.ts +119 -0
  171. package/dist/types/overlays/settings-selector.d.ts +73 -0
  172. package/dist/types/overlays/show-images-selector.d.ts +11 -0
  173. package/dist/types/overlays/snapcompact-shape-preview.d.ts +31 -0
  174. package/dist/types/overlays/theme-selector.d.ts +12 -0
  175. package/dist/types/overlays/thinking-selector.d.ts +12 -0
  176. package/dist/types/overlays/tiny-title-download-progress.d.ts +21 -0
  177. package/dist/types/overlays/tree-selector.d.ts +92 -0
  178. package/dist/types/overlays/usage-dashboard.d.ts +94 -0
  179. package/dist/types/overlays/usage-display.d.ts +5 -0
  180. package/dist/types/overlays/usage-row.d.ts +17 -0
  181. package/dist/types/prompt/attachment-chips.d.ts +19 -0
  182. package/dist/types/prompt/composer-attachments.d.ts +71 -0
  183. package/dist/types/prompt/composer-cache.d.ts +37 -0
  184. package/dist/types/prompt/composer.d.ts +159 -0
  185. package/dist/types/prompt/custom-editor.d.ts +261 -0
  186. package/dist/types/prompt/editor-top-gap.d.ts +18 -0
  187. package/dist/types/prompt/emoji-autocomplete.d.ts +16 -0
  188. package/dist/types/prompt/github-ref-autocomplete.d.ts +35 -0
  189. package/dist/types/prompt/gradient-highlight.d.ts +32 -0
  190. package/dist/types/prompt/image-format.d.ts +4 -0
  191. package/dist/types/prompt/image-references.d.ts +26 -0
  192. package/dist/types/prompt/interactive-context-helpers.d.ts +43 -0
  193. package/dist/types/prompt/internal-url-autocomplete.d.ts +56 -0
  194. package/dist/types/prompt/macos-spelling.d.ts +44 -0
  195. package/dist/types/prompt/magic-keyword-boundary.d.ts +9 -0
  196. package/dist/types/prompt/magic-keywords.d.ts +26 -0
  197. package/dist/types/prompt/markdown-prose.d.ts +15 -0
  198. package/dist/types/prompt/model-mention-autocomplete.d.ts +25 -0
  199. package/dist/types/prompt/model-mention-syntax.d.ts +24 -0
  200. package/dist/types/prompt/orchestrate.d.ts +13 -0
  201. package/dist/types/prompt/prompt-action-autocomplete.d.ts +56 -0
  202. package/dist/types/prompt/queue-input.d.ts +8 -0
  203. package/dist/types/prompt/skill-tokens.d.ts +13 -0
  204. package/dist/types/prompt/tools-markdown.d.ts +12 -0
  205. package/dist/types/prompt/ultrathink.d.ts +13 -0
  206. package/dist/types/prompt/usage-amounts.d.ts +26 -0
  207. package/dist/types/prompt/video.d.ts +18 -0
  208. package/dist/types/prompt/welcome.d.ts +83 -0
  209. package/dist/types/prompt/workflow.d.ts +13 -0
  210. package/dist/types/render/code-cell.d.ts +51 -0
  211. package/dist/types/render/file-list.d.ts +22 -0
  212. package/dist/types/render/hyperlink.d.ts +80 -0
  213. package/dist/types/render/index.d.ts +16 -0
  214. package/dist/types/render/output-block.d.ts +54 -0
  215. package/dist/types/render/output-pane.d.ts +80 -0
  216. package/dist/types/render/render-utils.d.ts +312 -0
  217. package/dist/types/render/sixel.d.ts +21 -0
  218. package/dist/types/render/status-line.d.ts +23 -0
  219. package/dist/types/render/tool-card.d.ts +71 -0
  220. package/dist/types/render/tree-list.d.ts +29 -0
  221. package/dist/types/render/types.d.ts +17 -0
  222. package/dist/types/render/utils.d.ts +43 -0
  223. package/dist/types/render/width-aware-text.d.ts +23 -0
  224. package/dist/types/setup/lazy.d.ts +3 -0
  225. package/dist/types/setup/scenes/composer.d.ts +3 -0
  226. package/dist/types/setup/scenes/glyph.d.ts +3 -0
  227. package/dist/types/setup/scenes/model.d.ts +3 -0
  228. package/dist/types/setup/scenes/outro.d.ts +2 -0
  229. package/dist/types/setup/scenes/providers.d.ts +3 -0
  230. package/dist/types/setup/scenes/sign-in.d.ts +21 -0
  231. package/dist/types/setup/scenes/splash.d.ts +11 -0
  232. package/dist/types/setup/scenes/theme.d.ts +3 -0
  233. package/dist/types/setup/scenes/types.d.ts +110 -0
  234. package/dist/types/setup/scenes/web-search.d.ts +22 -0
  235. package/dist/types/setup/setup-version.d.ts +11 -0
  236. package/dist/types/setup/startup-splash.d.ts +9 -0
  237. package/dist/types/setup/wizard-overlay.d.ts +16 -0
  238. package/dist/types/setup/wizard.d.ts +25 -0
  239. package/dist/types/status-line/component.d.ts +182 -0
  240. package/dist/types/status-line/context-usage.d.ts +156 -0
  241. package/dist/types/status-line/footer.d.ts +30 -0
  242. package/dist/types/status-line/git-utils.d.ts +22 -0
  243. package/dist/types/status-line/host.d.ts +129 -0
  244. package/dist/types/status-line/index.d.ts +6 -0
  245. package/dist/types/status-line/loop.d.ts +18 -0
  246. package/dist/types/status-line/metrics.d.ts +21 -0
  247. package/dist/types/status-line/presets.d.ts +3 -0
  248. package/dist/types/status-line/schema.d.ts +15 -0
  249. package/dist/types/status-line/segments.d.ts +5 -0
  250. package/dist/types/status-line/separators.d.ts +3 -0
  251. package/dist/types/status-line/types.d.ts +236 -0
  252. package/dist/types/stdin-buffer.d.ts +4 -0
  253. package/dist/types/theme/color.d.ts +24 -0
  254. package/dist/types/theme/defaults/index.d.ts +9489 -0
  255. package/dist/types/theme/index.d.ts +4 -0
  256. package/dist/types/theme/loader.d.ts +23 -0
  257. package/dist/types/theme/mermaid-cache.d.ts +26 -0
  258. package/dist/types/theme/schema-validation.d.ts +3 -0
  259. package/dist/types/theme/schema.d.ts +25 -0
  260. package/dist/types/theme/session-color.d.ts +43 -0
  261. package/dist/types/theme/shimmer.d.ts +44 -0
  262. package/dist/types/theme/symbols.d.ts +34 -0
  263. package/dist/types/theme/theme-class.d.ts +299 -0
  264. package/dist/types/theme/theme.d.ts +111 -0
  265. package/dist/types/theme/tui-adapters.d.ts +30 -0
  266. package/dist/types/thinking.d.ts +165 -0
  267. package/dist/types/tools/agent-tree.d.ts +36 -0
  268. package/dist/types/tools/ask.d.ts +67 -0
  269. package/dist/types/tools/ast-edit.d.ts +53 -0
  270. package/dist/types/tools/ast-grep.d.ts +52 -0
  271. package/dist/types/tools/autoresearch.d.ts +156 -0
  272. package/dist/types/tools/bash-interactive.d.ts +28 -0
  273. package/dist/types/tools/bash.d.ts +103 -0
  274. package/dist/types/tools/conflict-detect.d.ts +43 -0
  275. package/dist/types/tools/debug.d.ts +60 -0
  276. package/dist/types/tools/default-renderer.d.ts +24 -0
  277. package/dist/types/tools/edit.d.ts +158 -0
  278. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +5 -0
  279. package/dist/types/tools/eval-format/index.d.ts +5 -0
  280. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  281. package/dist/types/tools/eval-format/python.d.ts +2 -0
  282. package/dist/types/tools/eval.d.ts +94 -0
  283. package/dist/types/tools/fetch.d.ts +42 -0
  284. package/dist/types/tools/gh-format.d.ts +8 -0
  285. package/dist/types/tools/github.d.ts +144 -0
  286. package/dist/types/tools/glob.d.ts +44 -0
  287. package/dist/types/tools/goal.d.ts +42 -0
  288. package/dist/types/tools/grep.d.ts +64 -0
  289. package/dist/types/tools/grouped-file-output.d.ts +71 -0
  290. package/dist/types/tools/hashline-format.d.ts +28 -0
  291. package/dist/types/tools/hub.d.ts +340 -0
  292. package/dist/types/tools/index.d.ts +4 -0
  293. package/dist/types/tools/json-tree.d.ts +59 -0
  294. package/dist/types/tools/line-ranges.d.ts +29 -0
  295. package/dist/types/tools/list-limit.d.ts +15 -0
  296. package/dist/types/tools/lsp.d.ts +72 -0
  297. package/dist/types/tools/match-line-format.d.ts +11 -0
  298. package/dist/types/tools/mcp.d.ts +81 -0
  299. package/dist/types/tools/memory.d.ts +67 -0
  300. package/dist/types/tools/output-meta.d.ts +151 -0
  301. package/dist/types/tools/read.d.ts +139 -0
  302. package/dist/types/tools/renderer.d.ts +98 -0
  303. package/dist/types/tools/report-tool-issue.d.ts +8 -0
  304. package/dist/types/tools/resolve.d.ts +47 -0
  305. package/dist/types/tools/streaming-output.d.ts +282 -0
  306. package/dist/types/tools/subprocess.d.ts +24 -0
  307. package/dist/types/tools/task-repair-args.d.ts +53 -0
  308. package/dist/types/tools/task-yield-assembly.d.ts +20 -0
  309. package/dist/types/tools/task.d.ts +425 -0
  310. package/dist/types/tools/terminal-output.d.ts +8 -0
  311. package/dist/types/tools/think.d.ts +13 -0
  312. package/dist/types/tools/todo.d.ts +118 -0
  313. package/dist/types/tools/tool-errors.d.ts +7 -0
  314. package/dist/types/tools/vibe.d.ts +120 -0
  315. package/dist/types/tools/web-search.d.ts +195 -0
  316. package/dist/types/tools/write.d.ts +76 -0
  317. package/dist/types/tools/xd-url.d.ts +13 -0
  318. package/dist/types/tools/xdev.d.ts +52 -0
  319. package/dist/types/tui.d.ts +18 -0
  320. package/dist/types/utils.d.ts +2 -0
  321. package/package.json +37 -3
  322. package/src/app-keybindings.ts +760 -0
  323. package/src/apps/autoresearch-dashboard.ts +500 -0
  324. package/src/apps/autoresearch-data.ts +64 -0
  325. package/src/apps/cleanse-board.ts +341 -0
  326. package/src/apps/cleanse-picker.ts +57 -0
  327. package/src/apps/debug/log-formatting.ts +53 -0
  328. package/src/apps/debug/log-viewer.ts +868 -0
  329. package/src/apps/debug/protocol-probe.ts +267 -0
  330. package/src/apps/debug/raw-sse-buffer.ts +449 -0
  331. package/src/apps/debug/raw-sse.ts +282 -0
  332. package/src/apps/debug/terminal-info.ts +115 -0
  333. package/src/apps/debug/viewer-frame.ts +174 -0
  334. package/src/apps/git/avatar.ts +52 -0
  335. package/src/apps/git/colors.ts +109 -0
  336. package/src/apps/git/diff-pane.ts +1512 -0
  337. package/src/apps/git/git-tui.ts +889 -0
  338. package/src/apps/git/sidebar.ts +1204 -0
  339. package/src/apps/git/state.ts +113 -0
  340. package/src/apps/if-bench-board.ts +324 -0
  341. package/src/apps/live-visualizer.ts +238 -0
  342. package/src/apps/ps-data.ts +116 -0
  343. package/src/apps/ps-top.ts +422 -0
  344. package/src/apps/session-picker.ts +95 -0
  345. package/src/apps/setup-model-picker.ts +21 -0
  346. package/src/apps/standalone-picker.ts +111 -0
  347. package/src/autocomplete.ts +9 -13
  348. package/src/chat/advisor-message.ts +175 -0
  349. package/src/chat/assistant-message.ts +1214 -0
  350. package/src/chat/background-tan-message.ts +35 -0
  351. package/src/chat/bash-execution.ts +394 -0
  352. package/src/chat/cache-invalidation-marker.ts +94 -0
  353. package/src/chat/chat-transcript-builder.ts +593 -0
  354. package/src/chat/collab-prompt-message.ts +34 -0
  355. package/src/chat/compaction-summary-message.ts +261 -0
  356. package/src/chat/custom-message.ts +23 -0
  357. package/src/chat/display-preferences.ts +24 -0
  358. package/src/chat/eval-execution.ts +172 -0
  359. package/src/chat/execution-shared.ts +113 -0
  360. package/src/chat/extension-types.ts +46 -0
  361. package/src/chat/hook-message.ts +20 -0
  362. package/src/chat/image-loading.ts +122 -0
  363. package/src/chat/late-diagnostics-message.ts +92 -0
  364. package/src/chat/messages.ts +305 -0
  365. package/src/chat/reaction.ts +65 -0
  366. package/src/chat/read-target.ts +7 -0
  367. package/src/chat/read-tool-group.ts +873 -0
  368. package/src/chat/served-model-marker.ts +91 -0
  369. package/src/chat/skill-message.ts +220 -0
  370. package/src/chat/skill-title-input.ts +41 -0
  371. package/src/chat/stripped-tool-calls-placeholder.ts +35 -0
  372. package/src/chat/thinking-display.ts +312 -0
  373. package/src/chat/todo-reminder.ts +33 -0
  374. package/src/chat/tool-execution.ts +1395 -0
  375. package/src/chat/transcript-browser.ts +268 -0
  376. package/src/chat/transcript-entry.ts +96 -0
  377. package/src/chat/transcript-outline.ts +260 -0
  378. package/src/chat/transcript-render-helpers.ts +315 -0
  379. package/src/chat/ttsr-notification.ts +123 -0
  380. package/src/chat/user-message.ts +287 -0
  381. package/src/chrome/chat-block.ts +110 -0
  382. package/src/chrome/collab-qrcode.ts +74 -0
  383. package/src/chrome/context-thresholds.ts +85 -0
  384. package/src/chrome/countdown-timer.ts +74 -0
  385. package/src/chrome/diff.ts +266 -0
  386. package/src/chrome/dynamic-border.ts +36 -0
  387. package/src/chrome/error-block.ts +63 -0
  388. package/src/chrome/form-theme.ts +10 -0
  389. package/src/chrome/format.ts +66 -0
  390. package/src/chrome/index.ts +21 -0
  391. package/src/chrome/keybinding-hints.ts +55 -0
  392. package/src/chrome/live-board.ts +123 -0
  393. package/src/chrome/local-date.ts +20 -0
  394. package/src/chrome/message-divider.ts +51 -0
  395. package/src/chrome/message-frame.ts +164 -0
  396. package/src/chrome/message-notice.ts +103 -0
  397. package/src/chrome/overlay-box.ts +301 -0
  398. package/src/chrome/qrcode.ts +539 -0
  399. package/src/chrome/segment-track.ts +86 -0
  400. package/src/chrome/select-list-mouse-routing.ts +11 -0
  401. package/src/chrome/selector-helpers.ts +119 -0
  402. package/src/chrome/shared.ts +27 -0
  403. package/src/chrome/streaming-panel.ts +72 -0
  404. package/src/chrome/tool-activity.ts +44 -0
  405. package/src/chrome/transcript-container.ts +902 -0
  406. package/src/chrome/transcript-status.ts +27 -0
  407. package/src/chrome/visual-truncate.ts +70 -0
  408. package/src/components/disclosure.ts +235 -0
  409. package/src/components/editor.ts +10 -12
  410. package/src/components/form.ts +566 -0
  411. package/src/components/input.ts +6 -27
  412. package/src/components/key-value-list.ts +88 -0
  413. package/src/components/layout/geometry.ts +202 -0
  414. package/src/components/layout/row.ts +283 -0
  415. package/src/components/layout/split-pane.ts +349 -0
  416. package/src/components/layout/stack.ts +261 -0
  417. package/src/components/markdown.ts +16 -12
  418. package/src/components/menu-selection.ts +248 -0
  419. package/src/components/metric.ts +132 -0
  420. package/src/components/progress-bar.ts +140 -0
  421. package/src/components/scroll-view.ts +372 -70
  422. package/src/components/scroll-viewport.ts +154 -0
  423. package/src/components/section.ts +77 -0
  424. package/src/components/select-list.ts +238 -159
  425. package/src/components/settings-list.ts +103 -80
  426. package/src/components/table.ts +127 -0
  427. package/src/components/tree-view.ts +481 -0
  428. package/src/components/wizard-step.ts +229 -0
  429. package/src/deccara.ts +1 -3
  430. package/src/hotkeys-markdown.ts +100 -0
  431. package/src/index.ts +18 -0
  432. package/src/keybinding-matchers.ts +99 -0
  433. package/src/keys.ts +2 -2
  434. package/src/lang-from-path.ts +252 -0
  435. package/src/overlays/advisor-config.ts +761 -0
  436. package/src/overlays/agent-activity.ts +113 -0
  437. package/src/overlays/agent-hub-projection.ts +250 -0
  438. package/src/overlays/agent-hub-renderer.ts +281 -0
  439. package/src/overlays/agent-hub-types.ts +74 -0
  440. package/src/overlays/agent-hub.ts +1562 -0
  441. package/src/overlays/agent-transcript-viewer.ts +669 -0
  442. package/src/overlays/agents-hub.ts +1203 -0
  443. package/src/overlays/ask-dialog.ts +1202 -0
  444. package/src/overlays/bordered-loader.ts +41 -0
  445. package/src/overlays/btw-history-panel.ts +591 -0
  446. package/src/overlays/btw-history.ts +33 -0
  447. package/src/overlays/btw-panel.ts +167 -0
  448. package/src/overlays/cleanse-panel.ts +167 -0
  449. package/src/overlays/codex-reset-fireworks.ts +375 -0
  450. package/src/overlays/composer-shape-preview.ts +137 -0
  451. package/src/overlays/composer-shape-registry.ts +82 -0
  452. package/src/overlays/copy-selector.ts +618 -0
  453. package/src/overlays/copy-targets.ts +210 -0
  454. package/src/overlays/error-banner.ts +39 -0
  455. package/src/overlays/extensions/display-text.ts +39 -0
  456. package/src/overlays/extensions/extension-dashboard.ts +645 -0
  457. package/src/overlays/extensions/extension-list.ts +667 -0
  458. package/src/overlays/extensions/inspector-model.ts +484 -0
  459. package/src/overlays/extensions/inspector-panel.ts +669 -0
  460. package/src/overlays/extensions/live-tool-session.ts +67 -0
  461. package/src/overlays/extensions/mcp-runtime.ts +289 -0
  462. package/src/overlays/extensions/state-manager.ts +337 -0
  463. package/src/overlays/extensions/types.ts +208 -0
  464. package/src/overlays/history-search.ts +268 -0
  465. package/src/overlays/hook-editor.ts +272 -0
  466. package/src/overlays/hook-input.ts +84 -0
  467. package/src/overlays/hook-selector.ts +635 -0
  468. package/src/overlays/hub-frame.ts +262 -0
  469. package/src/overlays/login-dialog.ts +231 -0
  470. package/src/overlays/logout-account-selector.ts +115 -0
  471. package/src/overlays/mcp-add-wizard.ts +1436 -0
  472. package/src/overlays/model-browser.ts +1176 -0
  473. package/src/overlays/model-hub.ts +2279 -0
  474. package/src/overlays/model-picker.ts +285 -0
  475. package/src/overlays/model-selector.ts +107 -0
  476. package/src/overlays/move-overlay.ts +197 -0
  477. package/src/overlays/oauth-selector.ts +446 -0
  478. package/src/overlays/omfg-panel.ts +136 -0
  479. package/src/overlays/pause-screen.ts +198 -0
  480. package/src/overlays/plan-review-overlay.ts +1227 -0
  481. package/src/overlays/plan-save-overlay.ts +74 -0
  482. package/src/overlays/plan-toc.ts +139 -0
  483. package/src/overlays/plugin-selector.ts +97 -0
  484. package/src/overlays/plugin-settings.ts +839 -0
  485. package/src/overlays/queue-mode-selector.ts +55 -0
  486. package/src/overlays/reset-usage-selector.ts +170 -0
  487. package/src/overlays/rewind-selector.ts +499 -0
  488. package/src/overlays/running-subagent-badge.ts +19 -0
  489. package/src/overlays/session-account-selector.ts +68 -0
  490. package/src/overlays/session-info-overlay.ts +96 -0
  491. package/src/overlays/session-observer-registry.ts +289 -0
  492. package/src/overlays/session-selector.ts +1137 -0
  493. package/src/overlays/settings-defs.ts +315 -0
  494. package/src/overlays/settings-selector.ts +1415 -0
  495. package/src/overlays/show-images-selector.ts +44 -0
  496. package/src/overlays/snapcompact-shape-preview-doc.md +14 -0
  497. package/src/overlays/snapcompact-shape-preview.ts +192 -0
  498. package/src/overlays/theme-selector.ts +62 -0
  499. package/src/overlays/thinking-selector.ts +51 -0
  500. package/src/overlays/tiny-title-download-progress.ts +102 -0
  501. package/src/overlays/tree-selector.ts +1121 -0
  502. package/src/overlays/usage-dashboard.ts +629 -0
  503. package/src/overlays/usage-display.ts +45 -0
  504. package/src/overlays/usage-row.ts +118 -0
  505. package/src/prompt/attachment-chips.ts +201 -0
  506. package/src/prompt/composer-attachments.ts +280 -0
  507. package/src/prompt/composer-cache.ts +304 -0
  508. package/src/prompt/composer.ts +934 -0
  509. package/src/prompt/custom-editor.ts +1392 -0
  510. package/src/prompt/data/emojis.json +1 -0
  511. package/src/prompt/editor-top-gap.ts +28 -0
  512. package/src/prompt/emoji-autocomplete.ts +285 -0
  513. package/src/prompt/github-ref-autocomplete.ts +75 -0
  514. package/src/prompt/gradient-highlight.ts +101 -0
  515. package/src/prompt/image-format.ts +28 -0
  516. package/src/prompt/image-references.ts +102 -0
  517. package/src/prompt/interactive-context-helpers.ts +126 -0
  518. package/src/prompt/internal-url-autocomplete.ts +155 -0
  519. package/src/prompt/macos-spelling.ts +456 -0
  520. package/src/prompt/magic-keyword-boundary.ts +23 -0
  521. package/src/prompt/magic-keywords.ts +42 -0
  522. package/src/prompt/markdown-prose.ts +245 -0
  523. package/src/prompt/model-mention-autocomplete.ts +94 -0
  524. package/src/prompt/model-mention-syntax.ts +41 -0
  525. package/src/prompt/orchestrate.ts +26 -0
  526. package/src/prompt/prompt-action-autocomplete.ts +337 -0
  527. package/src/prompt/queue-input.ts +132 -0
  528. package/src/prompt/skill-tokens.ts +36 -0
  529. package/src/prompt/tips.txt +27 -0
  530. package/src/prompt/tools-markdown.ts +29 -0
  531. package/src/prompt/ultrathink.ts +26 -0
  532. package/src/prompt/usage-amounts.ts +99 -0
  533. package/src/prompt/video.ts +47 -0
  534. package/src/prompt/welcome.ts +605 -0
  535. package/src/prompt/workflow.ts +26 -0
  536. package/src/render/code-cell.ts +244 -0
  537. package/src/render/file-list.ts +58 -0
  538. package/src/render/hyperlink.ts +192 -0
  539. package/src/render/index.ts +17 -0
  540. package/src/render/output-block.ts +267 -0
  541. package/src/render/output-pane.ts +299 -0
  542. package/src/render/render-utils.ts +1182 -0
  543. package/src/render/sixel.ts +69 -0
  544. package/src/render/status-line.ts +55 -0
  545. package/src/render/tool-card.ts +272 -0
  546. package/src/render/tree-list.ts +188 -0
  547. package/src/render/types.ts +19 -0
  548. package/src/render/utils.ts +109 -0
  549. package/src/render/width-aware-text.ts +60 -0
  550. package/src/setup/lazy.ts +17 -0
  551. package/src/setup/scenes/composer.ts +127 -0
  552. package/src/setup/scenes/glyph.ts +120 -0
  553. package/src/setup/scenes/model.ts +138 -0
  554. package/src/setup/scenes/outro.ts +24 -0
  555. package/src/setup/scenes/providers.ts +107 -0
  556. package/src/setup/scenes/sign-in.ts +373 -0
  557. package/src/setup/scenes/splash.ts +192 -0
  558. package/src/setup/scenes/theme.ts +343 -0
  559. package/src/setup/scenes/types.ts +111 -0
  560. package/src/setup/scenes/web-search.ts +165 -0
  561. package/src/setup/setup-version.ts +11 -0
  562. package/src/setup/startup-splash.ts +107 -0
  563. package/src/setup/wizard-overlay.ts +319 -0
  564. package/src/setup/wizard.ts +101 -0
  565. package/src/status-line/component.ts +3003 -0
  566. package/src/status-line/context-usage.ts +688 -0
  567. package/src/status-line/footer.ts +323 -0
  568. package/src/status-line/git-utils.ts +42 -0
  569. package/src/status-line/host.ts +97 -0
  570. package/src/status-line/index.ts +6 -0
  571. package/src/status-line/loop.ts +28 -0
  572. package/src/status-line/metrics.ts +94 -0
  573. package/src/status-line/presets.ts +107 -0
  574. package/src/status-line/schema.ts +57 -0
  575. package/src/status-line/segments.ts +934 -0
  576. package/src/status-line/separators.ts +55 -0
  577. package/src/status-line/types.ts +227 -0
  578. package/src/stdin-buffer.ts +4 -2
  579. package/src/theme/color.ts +95 -0
  580. package/src/theme/dark.json +95 -0
  581. package/src/theme/defaults/alabaster.json +93 -0
  582. package/src/theme/defaults/amethyst.json +96 -0
  583. package/src/theme/defaults/anthracite.json +93 -0
  584. package/src/theme/defaults/basalt.json +91 -0
  585. package/src/theme/defaults/birch.json +95 -0
  586. package/src/theme/defaults/dark-abyss.json +91 -0
  587. package/src/theme/defaults/dark-arctic.json +104 -0
  588. package/src/theme/defaults/dark-aurora.json +95 -0
  589. package/src/theme/defaults/dark-catppuccin.json +107 -0
  590. package/src/theme/defaults/dark-cavern.json +91 -0
  591. package/src/theme/defaults/dark-celestial.json +106 -0
  592. package/src/theme/defaults/dark-copper.json +95 -0
  593. package/src/theme/defaults/dark-cosmos.json +90 -0
  594. package/src/theme/defaults/dark-cyberpunk.json +102 -0
  595. package/src/theme/defaults/dark-dracula.json +98 -0
  596. package/src/theme/defaults/dark-eclipse.json +91 -0
  597. package/src/theme/defaults/dark-ember.json +95 -0
  598. package/src/theme/defaults/dark-equinox.json +90 -0
  599. package/src/theme/defaults/dark-forest.json +96 -0
  600. package/src/theme/defaults/dark-github.json +105 -0
  601. package/src/theme/defaults/dark-gruvbox.json +112 -0
  602. package/src/theme/defaults/dark-lavender.json +95 -0
  603. package/src/theme/defaults/dark-lunar.json +89 -0
  604. package/src/theme/defaults/dark-midnight.json +95 -0
  605. package/src/theme/defaults/dark-monochrome.json +94 -0
  606. package/src/theme/defaults/dark-monokai.json +98 -0
  607. package/src/theme/defaults/dark-nebula.json +90 -0
  608. package/src/theme/defaults/dark-nord.json +98 -0
  609. package/src/theme/defaults/dark-ocean.json +101 -0
  610. package/src/theme/defaults/dark-one.json +100 -0
  611. package/src/theme/defaults/dark-poimandres.json +115 -0
  612. package/src/theme/defaults/dark-rainforest.json +91 -0
  613. package/src/theme/defaults/dark-reef.json +91 -0
  614. package/src/theme/defaults/dark-retro.json +92 -0
  615. package/src/theme/defaults/dark-rose-pine.json +96 -0
  616. package/src/theme/defaults/dark-sakura.json +95 -0
  617. package/src/theme/defaults/dark-slate.json +95 -0
  618. package/src/theme/defaults/dark-solarized.json +97 -0
  619. package/src/theme/defaults/dark-solstice.json +90 -0
  620. package/src/theme/defaults/dark-starfall.json +91 -0
  621. package/src/theme/defaults/dark-sunset.json +99 -0
  622. package/src/theme/defaults/dark-swamp.json +90 -0
  623. package/src/theme/defaults/dark-synthwave.json +103 -0
  624. package/src/theme/defaults/dark-taiga.json +91 -0
  625. package/src/theme/defaults/dark-terminal.json +95 -0
  626. package/src/theme/defaults/dark-tokyo-night.json +101 -0
  627. package/src/theme/defaults/dark-tundra.json +91 -0
  628. package/src/theme/defaults/dark-twilight.json +91 -0
  629. package/src/theme/defaults/dark-volcanic.json +91 -0
  630. package/src/theme/defaults/graphite.json +92 -0
  631. package/src/theme/defaults/index.ts +201 -0
  632. package/src/theme/defaults/light-arctic.json +107 -0
  633. package/src/theme/defaults/light-aurora-day.json +91 -0
  634. package/src/theme/defaults/light-canyon.json +91 -0
  635. package/src/theme/defaults/light-catppuccin.json +106 -0
  636. package/src/theme/defaults/light-cirrus.json +90 -0
  637. package/src/theme/defaults/light-coral.json +95 -0
  638. package/src/theme/defaults/light-cyberpunk.json +96 -0
  639. package/src/theme/defaults/light-dawn.json +90 -0
  640. package/src/theme/defaults/light-dunes.json +91 -0
  641. package/src/theme/defaults/light-eucalyptus.json +95 -0
  642. package/src/theme/defaults/light-forest.json +100 -0
  643. package/src/theme/defaults/light-frost.json +95 -0
  644. package/src/theme/defaults/light-github.json +115 -0
  645. package/src/theme/defaults/light-glacier.json +91 -0
  646. package/src/theme/defaults/light-gruvbox.json +108 -0
  647. package/src/theme/defaults/light-haze.json +90 -0
  648. package/src/theme/defaults/light-honeycomb.json +95 -0
  649. package/src/theme/defaults/light-lagoon.json +91 -0
  650. package/src/theme/defaults/light-lavender.json +95 -0
  651. package/src/theme/defaults/light-meadow.json +91 -0
  652. package/src/theme/defaults/light-mint.json +95 -0
  653. package/src/theme/defaults/light-monochrome.json +101 -0
  654. package/src/theme/defaults/light-ocean.json +99 -0
  655. package/src/theme/defaults/light-one.json +99 -0
  656. package/src/theme/defaults/light-opal.json +91 -0
  657. package/src/theme/defaults/light-orchard.json +91 -0
  658. package/src/theme/defaults/light-paper.json +95 -0
  659. package/src/theme/defaults/light-poimandres.json +115 -0
  660. package/src/theme/defaults/light-prism.json +90 -0
  661. package/src/theme/defaults/light-retro.json +98 -0
  662. package/src/theme/defaults/light-sand.json +95 -0
  663. package/src/theme/defaults/light-savanna.json +91 -0
  664. package/src/theme/defaults/light-solarized.json +102 -0
  665. package/src/theme/defaults/light-soleil.json +90 -0
  666. package/src/theme/defaults/light-sunset.json +99 -0
  667. package/src/theme/defaults/light-synthwave.json +98 -0
  668. package/src/theme/defaults/light-tokyo-night.json +111 -0
  669. package/src/theme/defaults/light-wetland.json +91 -0
  670. package/src/theme/defaults/light-zenith.json +89 -0
  671. package/src/theme/defaults/limestone.json +94 -0
  672. package/src/theme/defaults/mahogany.json +97 -0
  673. package/src/theme/defaults/marble.json +93 -0
  674. package/src/theme/defaults/obsidian.json +91 -0
  675. package/src/theme/defaults/onyx.json +91 -0
  676. package/src/theme/defaults/pearl.json +93 -0
  677. package/src/theme/defaults/porcelain.json +91 -0
  678. package/src/theme/defaults/quartz.json +96 -0
  679. package/src/theme/defaults/sandstone.json +95 -0
  680. package/src/theme/defaults/titanium.json +90 -0
  681. package/src/theme/index.ts +5 -0
  682. package/src/theme/light.json +93 -0
  683. package/src/theme/loader.ts +200 -0
  684. package/src/theme/mermaid-cache.ts +92 -0
  685. package/src/theme/schema-validation.ts +119 -0
  686. package/src/theme/schema.ts +168 -0
  687. package/src/theme/session-color.ts +253 -0
  688. package/src/theme/shimmer.ts +307 -0
  689. package/src/theme/symbols.ts +1433 -0
  690. package/src/theme/theme-class.ts +787 -0
  691. package/src/theme/theme-schema.json +463 -0
  692. package/src/theme/theme.ts +810 -0
  693. package/src/theme/tui-adapters.ts +339 -0
  694. package/src/thinking.ts +380 -0
  695. package/src/tools/agent-tree.ts +128 -0
  696. package/src/tools/ask.ts +482 -0
  697. package/src/tools/ast-edit.ts +210 -0
  698. package/src/tools/ast-grep.ts +171 -0
  699. package/src/tools/autoresearch.ts +287 -0
  700. package/src/tools/bash-interactive.ts +295 -0
  701. package/src/tools/bash.ts +468 -0
  702. package/src/tools/conflict-detect.ts +77 -0
  703. package/src/tools/debug.ts +167 -0
  704. package/src/tools/default-renderer.ts +172 -0
  705. package/src/tools/edit.ts +1258 -0
  706. package/src/tools/eval-format/code-mode-declarations.ts +61 -0
  707. package/src/tools/eval-format/index.ts +16 -0
  708. package/src/tools/eval-format/javascript.ts +952 -0
  709. package/src/tools/eval-format/python.ts +544 -0
  710. package/src/tools/eval.ts +835 -0
  711. package/src/tools/fetch.ts +226 -0
  712. package/src/tools/gh-format.ts +18 -0
  713. package/src/tools/github.ts +686 -0
  714. package/src/tools/glob.ts +213 -0
  715. package/src/tools/goal.ts +153 -0
  716. package/src/tools/grep.ts +385 -0
  717. package/src/tools/grouped-file-output.ts +211 -0
  718. package/src/tools/hashline-format.ts +59 -0
  719. package/src/tools/hub.ts +1488 -0
  720. package/src/tools/index.ts +71 -0
  721. package/src/tools/json-tree.ts +388 -0
  722. package/src/tools/line-ranges.ts +98 -0
  723. package/src/tools/list-limit.ts +43 -0
  724. package/src/tools/lsp.ts +702 -0
  725. package/src/tools/match-line-format.ts +20 -0
  726. package/src/tools/mcp.ts +309 -0
  727. package/src/tools/memory.ts +220 -0
  728. package/src/tools/output-meta.ts +367 -0
  729. package/src/tools/read.ts +628 -0
  730. package/src/tools/renderer.ts +107 -0
  731. package/src/tools/report-tool-issue.ts +13 -0
  732. package/src/tools/resolve.ts +139 -0
  733. package/src/tools/streaming-output.ts +1576 -0
  734. package/src/tools/subprocess.ts +39 -0
  735. package/src/tools/task-repair-args.ts +118 -0
  736. package/src/tools/task-yield-assembly.ts +147 -0
  737. package/src/tools/task.ts +2110 -0
  738. package/src/tools/terminal-output.ts +153 -0
  739. package/src/tools/think.ts +28 -0
  740. package/src/tools/todo.ts +580 -0
  741. package/src/tools/tool-errors.ts +15 -0
  742. package/src/tools/vibe.ts +420 -0
  743. package/src/tools/web-search.ts +409 -0
  744. package/src/tools/write.ts +511 -0
  745. package/src/tools/xd-url.ts +28 -0
  746. package/src/tools/xdev.ts +201 -0
  747. package/src/tui.ts +57 -4
  748. package/src/utils.ts +8 -0
@@ -0,0 +1,1176 @@
1
+ /**
2
+ * Frameless, reusable model browser: a fuzzy search row, a windowed model
3
+ * list with role chips and metadata columns, and a selection detail block.
4
+ *
5
+ * Hosts own the surrounding chrome and the data scope — the fullscreen
6
+ * /models hub ({@link ./model-hub}) feeds it scope-filtered items plus role
7
+ * state, while the advisor config overlay embeds it as a plain "pick one
8
+ * model" list.
9
+ */
10
+ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
+ import type { Model } from "@oh-my-pi/pi-ai";
12
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
13
+ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
+ import type { Component } from "../tui";
15
+ import { fuzzyRank } from "../fuzzy";
16
+ import { Input } from "../components/input";
17
+ import { ScrollView } from "../components/scroll-view";
18
+ import { matchesKey } from "../keys";
19
+ import type { SgrMouseEvent } from "../mouse";
20
+ import { replaceTabs, truncateToWidth, visibleWidth } from "../utils";
21
+ import { formatNumber, sanitizeText } from "@oh-my-pi/pi-utils";
22
+ import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
23
+ import { thinkingLevelGlyph } from "../render/render-utils";
24
+ import { type ThemeColor, theme } from "../theme/theme";
25
+ import {
26
+ matchesSelectCancel,
27
+ matchesSelectDown,
28
+ matchesSelectPageDown,
29
+ matchesSelectPageUp,
30
+ matchesSelectUp,
31
+ } from "../keybinding-matchers";
32
+ import { MenuSelection } from "../components/menu-selection";
33
+ import { clampScrollOffset, scrollOffsetForRow } from "../components/scroll-viewport";
34
+
35
+ /** Canonical display ordering of built-in model roles. */
36
+ export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "commit" | "tiny" | "task" | "advisor";
37
+ export const MODEL_ROLE_IDS: ModelRole[] = [
38
+ "default",
39
+ "smol",
40
+ "slow",
41
+ "vision",
42
+ "plan",
43
+ "commit",
44
+ "tiny",
45
+ "task",
46
+ "advisor",
47
+ ];
48
+
49
+ /** Measured model performance shown in browser rows. */
50
+ export interface ModelBrowserPerf {
51
+ samples: number;
52
+ tps: number;
53
+ ttftMs: number | null;
54
+ }
55
+
56
+ /** Role metadata shown in model selectors. */
57
+ export interface ModelBrowserRoleInfo {
58
+ tag?: string;
59
+ name: string;
60
+ color?: ThemeColor;
61
+ hidden?: boolean;
62
+ }
63
+
64
+ /** Role lookup used for scoped model resolution. */
65
+ export interface ModelRoleLookup {
66
+ getModelRole(role: string): string | undefined;
67
+ }
68
+
69
+ /** Resolved selector metadata required by model overlays. */
70
+ export interface ResolvedModelRoleValue {
71
+ model: Model | undefined;
72
+ thinkingLevel?: ConfiguredThinkingLevel;
73
+ explicitThinkingLevel: boolean;
74
+ warning?: string;
75
+ }
76
+
77
+ /** Host-provided preferences and model-role resolution for the browser. */
78
+ export interface ModelBrowserSource extends ModelRoleLookup {
79
+ readonly defaultThinkingLevel: string;
80
+ readonly modelProviderOrder: readonly string[];
81
+ readonly knownRoleIds: readonly string[];
82
+ readonly mruOrder: readonly string[];
83
+ readonly modelPerf: ReadonlyMap<string, ModelBrowserPerf>;
84
+ getRoleInfo(role: string): ModelBrowserRoleInfo;
85
+ resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
86
+ }
87
+
88
+ /** Read-only catalog surface consumed by model browsers. */
89
+ export interface ModelBrowserRegistry {
90
+ getError(): unknown;
91
+ getAvailable(): Model[];
92
+ getAll(): Model[];
93
+ }
94
+
95
+ /** One selectable row. `selector` is a canonical model key or host-specific virtual key. */
96
+ export interface ModelBrowserItem {
97
+ provider: string;
98
+ id: string;
99
+ model: Model;
100
+ selector: string;
101
+ /** Optional foreground color for the row label. */
102
+ labelColor?: ThemeColor;
103
+ }
104
+
105
+ /** Resolved role assignment as displayed by the browser and the hub. */
106
+ export interface RoleAssignment {
107
+ model: Model;
108
+ thinkingLevel: ConfiguredThinkingLevel;
109
+ /** True when the role has no configured value and fell back to auto-selection. */
110
+ autoSelected: boolean;
111
+ }
112
+
113
+ /** Map of role id to its resolved assignment (absent roles are unresolved). */
114
+ export type RoleAssignments = Record<string, RoleAssignment | undefined>;
115
+
116
+ /**
117
+ * Resolve every known role to its display assignment: configured role values
118
+ * resolve against `allModels`; unconfigured roles fall back to auto-selection
119
+ * over `autoCandidates` (skipped when empty). Shared by the /models hub and
120
+ * the alt+p session picker.
121
+ */
122
+ export function resolveRoleAssignments(
123
+ settings: ModelBrowserSource,
124
+ allModels: ReadonlyArray<Model>,
125
+ autoCandidates: ReadonlyArray<Model>,
126
+ ): RoleAssignments {
127
+ const resolvedThinkingLevel = (
128
+ role: string,
129
+ resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
130
+ ): ConfiguredThinkingLevel => {
131
+ if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
132
+ return resolved.thinkingLevel;
133
+ }
134
+ if (role === "default") {
135
+ return parseConfiguredThinkingLevel(settings.defaultThinkingLevel) ?? ThinkingLevel.Inherit;
136
+ }
137
+ return ThinkingLevel.Inherit;
138
+ };
139
+
140
+ const roles: RoleAssignments = {};
141
+ const knownRoles = settings.knownRoleIds;
142
+ const configuredRoles = new Set<string>();
143
+ const catalog = [...allModels];
144
+
145
+ for (const role of knownRoles) {
146
+ const roleValue = settings.getModelRole(role);
147
+ if (!roleValue) continue;
148
+ configuredRoles.add(role);
149
+ const resolved = settings.resolveRoleValue(roleValue, catalog);
150
+ if (resolved.model) {
151
+ roles[role] = {
152
+ model: resolved.model,
153
+ thinkingLevel: resolvedThinkingLevel(role, resolved),
154
+ autoSelected: false,
155
+ };
156
+ }
157
+ }
158
+
159
+ if (autoCandidates.length > 0) {
160
+ const candidates = [...autoCandidates];
161
+ for (const role of knownRoles) {
162
+ if (configuredRoles.has(role)) continue;
163
+ const resolved = settings.resolveRoleValue(`pi/${role}`, candidates);
164
+ if (!resolved.model) continue;
165
+ roles[role] = {
166
+ model: resolved.model,
167
+ thinkingLevel: resolvedThinkingLevel(role, resolved),
168
+ autoSelected: true,
169
+ };
170
+ }
171
+ }
172
+
173
+ return roles;
174
+ }
175
+
176
+ /** Wrap raw models into browser items. */
177
+ export function buildBrowserItems(models: ReadonlyArray<Model>): ModelBrowserItem[] {
178
+ return models.map(model => ({
179
+ provider: model.provider,
180
+ id: model.id,
181
+ model,
182
+ selector: `${model.provider}/${model.id}`,
183
+ }));
184
+ }
185
+
186
+ /** Extract the first version number from a model ID (e.g. "gemini-2.5-pro" → 2.5, "claude-sonnet-4-6" → 4.6). */
187
+ function extractVersionNumber(id: string): number {
188
+ // Dot-separated version: "gemini-2.5-pro" → 2.5
189
+ const dotMatch = id.match(/(?:^|[-_])(\d+\.\d+)/);
190
+ if (dotMatch) return Number.parseFloat(dotMatch[1]);
191
+ // Dash-separated short segments: "claude-sonnet-4-6" → 4.6, "llama-3-1-8b" → 3.1
192
+ const dashMatch = id.match(/(?:^|[-_])(\d{1,2})-(\d{1,2})(?=-|$)/);
193
+ if (dashMatch) return Number.parseFloat(`${dashMatch[1]}.${dashMatch[2]}`);
194
+ // Single number after separator: "gpt-4o" → 4
195
+ const singleMatch = id.match(/(?:^|[-_])(\d+)/);
196
+ if (singleMatch) return Number.parseFloat(singleMatch[1]);
197
+ return 0;
198
+ }
199
+
200
+ /** Rank a model by the first built-in role it is assigned to (lower = earlier role). */
201
+ function computeModelRank(model: Model, roles: RoleAssignments): number {
202
+ let i = 0;
203
+ while (i < MODEL_ROLE_IDS.length) {
204
+ const assigned = roles[MODEL_ROLE_IDS[i]];
205
+ if (assigned && modelsAreEqual(assigned.model, model)) {
206
+ break;
207
+ }
208
+ i++;
209
+ }
210
+ return i;
211
+ }
212
+
213
+ /** Options for {@link sortModelItems}. */
214
+ export interface SortModelItemsOptions {
215
+ roles?: RoleAssignments;
216
+ mruOrder?: ReadonlyArray<string>;
217
+ /**
218
+ * When a search query is narrowing the list, role assignments should NOT
219
+ * promote a weakly-matching default model above a perfect text match —
220
+ * defer to MRU/version instead so user affinity drives the order.
221
+ */
222
+ skipRoleRank?: boolean;
223
+ }
224
+
225
+ /**
226
+ * Order models for display: role-assigned first, then most-recently-used,
227
+ * then per provider by priority, version, and recency.
228
+ */
229
+ export function sortModelItems(items: ModelBrowserItem[], options: SortModelItemsOptions = {}): void {
230
+ const { roles = {}, mruOrder = [], skipRoleRank = false } = options;
231
+ const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
232
+
233
+ const dateRe = /-(\d{8})$/;
234
+ const latestRe = /-latest$/;
235
+
236
+ items.sort((a, b) => {
237
+ if (!skipRoleRank) {
238
+ const aRank = computeModelRank(a.model, roles);
239
+ const bRank = computeModelRank(b.model, roles);
240
+ if (aRank !== bRank) return aRank - bRank;
241
+ }
242
+
243
+ // Then MRU order (models in mruIndex come before those not in it)
244
+ const aMru = mruIndex.get(a.selector) ?? Number.MAX_SAFE_INTEGER;
245
+ const bMru = mruIndex.get(b.selector) ?? Number.MAX_SAFE_INTEGER;
246
+ if (aMru !== bMru) return aMru - bMru;
247
+
248
+ // By provider, then recency within provider
249
+ const providerCmp = a.provider.localeCompare(b.provider);
250
+ if (providerCmp !== 0) return providerCmp;
251
+
252
+ // Priority field (lower = better, e.g. Codex priority values)
253
+ const aPri = a.model.priority ?? Number.MAX_SAFE_INTEGER;
254
+ const bPri = b.model.priority ?? Number.MAX_SAFE_INTEGER;
255
+ if (aPri !== bPri) return aPri - bPri;
256
+
257
+ // Version number descending (higher version = better model)
258
+ const aVer = extractVersionNumber(a.id);
259
+ const bVer = extractVersionNumber(b.id);
260
+ if (aVer !== bVer) return bVer - aVer;
261
+
262
+ const aIsLatest = latestRe.test(a.id);
263
+ const bIsLatest = latestRe.test(b.id);
264
+ const aDate = a.id.match(dateRe)?.[1] ?? "";
265
+ const bDate = b.id.match(dateRe)?.[1] ?? "";
266
+
267
+ // Models with recency info come before those without
268
+ const aHasRecency = aIsLatest || aDate !== "";
269
+ const bHasRecency = bIsLatest || bDate !== "";
270
+ if (aHasRecency !== bHasRecency) return aHasRecency ? -1 : 1;
271
+
272
+ // If neither has recency info, fall back to alphabetical
273
+ if (!aHasRecency) return a.id.localeCompare(b.id);
274
+
275
+ // -latest always sorts first within recency group
276
+ if (aIsLatest !== bIsLatest) return aIsLatest ? -1 : 1;
277
+
278
+ // Both have dates — descending (newest first)
279
+ if (aDate && bDate) return bDate.localeCompare(aDate);
280
+
281
+ // One has date, other is latest — latest first
282
+ return aIsLatest ? -1 : bIsLatest ? 1 : a.id.localeCompare(b.id);
283
+ });
284
+ }
285
+
286
+ /** Picker candidates and ordering inputs shared with composer model mentions. */
287
+ export interface SessionModelScope {
288
+ items: ModelBrowserItem[];
289
+ roles: RoleAssignments;
290
+ mruOrder: ReadonlyArray<string>;
291
+ error: string | undefined;
292
+ }
293
+
294
+ /** Build the session picker's current scope without creating an interactive browser. */
295
+ export function buildSessionModelScope(
296
+ settings: ModelBrowserSource,
297
+ registry: ModelBrowserRegistry,
298
+ scopedModels: ReadonlyArray<Model>,
299
+ ): SessionModelScope {
300
+ let models: ReadonlyArray<Model>;
301
+ let error: string | undefined;
302
+ if (scopedModels.length > 0) {
303
+ models = scopedModels;
304
+ } else {
305
+ const loadError = registry.getError();
306
+ error = loadError ? String(loadError) : undefined;
307
+ try {
308
+ models = registry.getAvailable();
309
+ } catch (cause) {
310
+ error = cause instanceof Error ? cause.message : String(cause);
311
+ models = [];
312
+ }
313
+ }
314
+ const allModels = scopedModels.length > 0 ? models : registry.getAll();
315
+ const roles = resolveRoleAssignments(settings, allModels, models);
316
+ const mruOrder = settings.mruOrder;
317
+ const items = buildBrowserItems(models);
318
+ sortModelItems(items, { roles, mruOrder });
319
+ return { items, roles, mruOrder, error };
320
+ }
321
+
322
+ interface RoleProviderStats {
323
+ count: number;
324
+ firstRole: number;
325
+ }
326
+
327
+ /** User affinity used to order search matches within one relevance tier. */
328
+ export interface SearchAffinity {
329
+ /** `provider/id` (lowercased) → rank; configured-role models first, then MRU. */
330
+ models: Map<string, number>;
331
+ /** provider (lowercased) → rank; explicit order, then role providers, then MRU providers. */
332
+ providers: Map<string, number>;
333
+ }
334
+
335
+ /**
336
+ * Build model and provider affinity from explicit configuration, configured
337
+ * role assignments, and recent model use. Auto-selected roles are catalog
338
+ * policy, not evidence of user preference.
339
+ */
340
+ export function buildSearchAffinity(
341
+ providerOrder: ReadonlyArray<string>,
342
+ roles: RoleAssignments,
343
+ mruOrder: ReadonlyArray<string>,
344
+ ): SearchAffinity {
345
+ const models: string[] = [];
346
+ const seenModels = new Set<string>();
347
+ const addModel = (selector: string) => {
348
+ const key = selector.toLowerCase();
349
+ if (seenModels.has(key)) return;
350
+ seenModels.add(key);
351
+ models.push(key);
352
+ };
353
+
354
+ const providers: string[] = [];
355
+ const seenProviders = new Set<string>();
356
+ const addProvider = (provider: string) => {
357
+ const key = provider.trim().toLowerCase();
358
+ if (!key || seenProviders.has(key)) return;
359
+ seenProviders.add(key);
360
+ providers.push(key);
361
+ };
362
+
363
+ for (const provider of providerOrder) addProvider(provider);
364
+
365
+ const roleStats = new Map<string, RoleProviderStats>();
366
+ const seenRoles = new Set<string>();
367
+ let roleIndex = 0;
368
+ const recordRole = (role: string) => {
369
+ if (seenRoles.has(role)) return;
370
+ seenRoles.add(role);
371
+ const assignment = roles[role];
372
+ if (assignment && !assignment.autoSelected) {
373
+ addModel(`${assignment.model.provider}/${assignment.model.id}`);
374
+ const provider = assignment.model.provider.toLowerCase();
375
+ const current = roleStats.get(provider);
376
+ if (current) {
377
+ current.count++;
378
+ } else {
379
+ roleStats.set(provider, { count: 1, firstRole: roleIndex });
380
+ }
381
+ }
382
+ roleIndex++;
383
+ };
384
+ for (const role of MODEL_ROLE_IDS) recordRole(role);
385
+ for (const role in roles) recordRole(role);
386
+ for (const selector of mruOrder) addModel(selector);
387
+
388
+ const preferredByRole = [...roleStats.entries()].sort(
389
+ ([, a], [, b]) => b.count - a.count || a.firstRole - b.firstRole,
390
+ );
391
+ for (const [provider] of preferredByRole) addProvider(provider);
392
+
393
+ for (const selector of mruOrder) {
394
+ const slash = selector.indexOf("/");
395
+ if (slash > 0) addProvider(selector.slice(0, slash));
396
+ }
397
+
398
+ return {
399
+ models: new Map(models.map((selector, index) => [selector, index])),
400
+ providers: new Map(providers.map((provider, index) => [provider, index])),
401
+ };
402
+ }
403
+
404
+ /** Collapse punctuation so exact and contiguous model-name matches form stable relevance tiers. */
405
+ function compactModelSearchText(value: string): string {
406
+ return value.toLowerCase().replace(/[^\p{Letter}\p{Mark}\p{Number}]+/gu, "");
407
+ }
408
+
409
+ /** Exact id/selector → contiguous literal → fuzzy-only. */
410
+ function modelSearchTier(query: string, item: ModelBrowserItem): number {
411
+ if (!query) return 2;
412
+ const id = compactModelSearchText(item.id);
413
+ const selector = compactModelSearchText(item.selector);
414
+ if (query === id || query === selector) return 0;
415
+ if (id.includes(query) || selector.includes(query)) return 1;
416
+ return 2;
417
+ }
418
+
419
+ /** Rank picker and mention candidates by text relevance, user affinity, and MRU/version order. */
420
+ export function rankModelItems(
421
+ query: string,
422
+ items: ReadonlyArray<ModelBrowserItem>,
423
+ options: { roles: RoleAssignments; mruOrder: ReadonlyArray<string>; affinity: SearchAffinity },
424
+ ): ModelBrowserItem[] {
425
+ if (!query.trim()) return [...items];
426
+ const ranked = fuzzyRank(items, query, modelSearchText);
427
+ const matches = ranked.map(result => result.item);
428
+ // Exact and contiguous matches stay ahead of fuzzy-only candidates; affinity
429
+ // breaks ties before fuzzy quality and the normal MRU/version ordering.
430
+ sortModelItems(matches, { roles: options.roles, mruOrder: options.mruOrder, skipRoleRank: true });
431
+ const fallbackRanks = new Map(matches.map((item, index) => [item, index]));
432
+ const queryKey = compactModelSearchText(query);
433
+ const searchRanks = new Map<ModelBrowserItem, { tier: number; bucket: number }>();
434
+ for (const result of ranked) {
435
+ searchRanks.set(result.item, {
436
+ tier: modelSearchTier(queryKey, result.item),
437
+ bucket: Math.round(result.score / 10),
438
+ });
439
+ }
440
+ matches.sort((a, b) => {
441
+ const aSearch = searchRanks.get(a);
442
+ const bSearch = searchRanks.get(b);
443
+ const tierCmp = (aSearch?.tier ?? Number.MAX_SAFE_INTEGER) - (bSearch?.tier ?? Number.MAX_SAFE_INTEGER);
444
+ if (tierCmp !== 0) return tierCmp;
445
+
446
+ const modelCmp =
447
+ (options.affinity.models.get(a.selector.toLowerCase()) ?? Number.MAX_SAFE_INTEGER) -
448
+ (options.affinity.models.get(b.selector.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
449
+ if (modelCmp !== 0) return modelCmp;
450
+
451
+ const providerCmp =
452
+ (options.affinity.providers.get(a.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER) -
453
+ (options.affinity.providers.get(b.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
454
+ if (providerCmp !== 0) return providerCmp;
455
+
456
+ const bucketCmp = (aSearch?.bucket ?? Number.MAX_SAFE_INTEGER) - (bSearch?.bucket ?? Number.MAX_SAFE_INTEGER);
457
+ if (bucketCmp !== 0) return bucketCmp;
458
+ return (fallbackRanks.get(a) ?? Number.MAX_SAFE_INTEGER) - (fallbackRanks.get(b) ?? Number.MAX_SAFE_INTEGER);
459
+ });
460
+ return matches;
461
+ }
462
+
463
+ /**
464
+ * A slim role chip: `● default ◉` — solid dot for configured assignments,
465
+ * hollow for auto-selected fallbacks, thinking glyph attached when set.
466
+ *
467
+ * The space after the status glyph is load-bearing. Under the `nerd` preset
468
+ * these are Nerd Font private-use icons (U+F111 / U+F10C) whose glyphs are
469
+ * drawn two cells wide, while `visibleWidth` counts them as one
470
+ * (`ambiguousIsNarrow: true` in tui/utils.ts — the PUA block is
471
+ * East_Asian_Width=Ambiguous). Without a separator the icon overhangs and
472
+ * eats the label's first character (`● default` renders as `●efault`).
473
+ * Mirrors the spacing already used for `status.success` in model-hub.
474
+ */
475
+ export function formatRoleChip(role: string, assignment: RoleAssignment, settings: ModelBrowserSource): string {
476
+ const info = settings.getRoleInfo(role);
477
+ const label = (info.tag ?? info.name ?? role).toLowerCase();
478
+ const glyph = thinkingLevelGlyph(assignment.thinkingLevel, theme);
479
+ const suffix = glyph ? ` ${theme.fg("dim", glyph)}` : "";
480
+ if (assignment.autoSelected) {
481
+ return theme.fg("dim", `${theme.status.shadowed} ${label}`) + suffix;
482
+ }
483
+ return theme.fg(info.color ?? "muted", `${theme.status.enabled} ${label}`) + suffix;
484
+ }
485
+
486
+ /** Both token legs at zero cost — the condition {@link formatCostPair} renders as `free`. */
487
+ function isFreeModel(model: Model): boolean {
488
+ const cost = model.cost;
489
+ return !cost || (cost.input === 0 && cost.output === 0);
490
+ }
491
+
492
+ /** `$in/out` per-million cost pair; `free` when both legs are zero. */
493
+ function formatCostPair(model: Model): string {
494
+ if (isFreeModel(model)) return "free";
495
+ const cost = model.cost;
496
+
497
+ const fmt = (n: number): string => {
498
+ if (!Number.isFinite(n) || n < 0) return "?";
499
+ if (n > 0 && n < 0.01) {
500
+ return n.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 });
501
+ }
502
+ const s = n >= 100 ? String(Math.round(n)) : n >= 10 ? n.toFixed(1) : n.toFixed(2);
503
+ return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
504
+ };
505
+ return `$${fmt(cost.input)}/${fmt(cost.output)}`;
506
+ }
507
+
508
+ /**
509
+ * The fuzzy haystack for a model row: the displayed `provider/id`, plus `free`
510
+ * for zero-cost models so the cost column's own word is searchable even when
511
+ * the id never says it (openrouter suffixes `:free`; nvidia does not).
512
+ *
513
+ * Must stay a pure function of the item — never of the query. `fuzzyRank`
514
+ * caches match indices keyed on this string and stops admitting new entries
515
+ * past its cap, so a query-dependent haystack would thrash that cache.
516
+ */
517
+ export function modelSearchText({ provider, id, model }: ModelBrowserItem): string {
518
+ const base = `${provider}/${id}`;
519
+ return isFreeModel(model) ? `${base} free` : base;
520
+ }
521
+
522
+ /** Provider-supplied blurb, flattened to a single renderable detail-line cell. */
523
+ function formatDescription(description: string): string {
524
+ return replaceTabs(sanitizeText(description))
525
+ .replace(/[\r\n]+/g, " ")
526
+ .trim();
527
+ }
528
+
529
+ /**
530
+ * `400k ◫` context-window column; empty when the model does not report one.
531
+ * The icon trails the number so right-alignment pins it to a fixed column
532
+ * instead of drifting with the number's width. The ascii preset's `ctx:`
533
+ * label is a prefix form — strip the colon for suffix placement.
534
+ */
535
+ function formatContext(model: Model): string {
536
+ const ctx = model.contextWindow ?? 0;
537
+ if (ctx <= 0) return "";
538
+ return `${formatNumber(ctx).toLowerCase()} ${theme.icon.context.replace(/:$/, "")}`;
539
+ }
540
+
541
+ /** `118t/s` average output speed; one decimal below 10 t/s. */
542
+ function formatTps(tps: number): string {
543
+ const value = tps >= 10 ? String(Math.round(tps)) : tps.toFixed(1);
544
+ return `${value}t/s`;
545
+ }
546
+
547
+ /** Brain-icon intelligence score delivered with the model catalog. */
548
+ function formatIntelligence(model: Model): string {
549
+ if (model.int == null || !Number.isFinite(model.int)) return "";
550
+ return `${theme.symbol("icon.intelligence")} ${Math.round(model.int)}`;
551
+ }
552
+
553
+ /** `0.9s` average time-to-first-token; whole seconds from 10s up. */
554
+ function formatTtft(ms: number): string {
555
+ const seconds = ms / 1000;
556
+ return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`;
557
+ }
558
+
559
+ /** Pad `text` on the left to `width` terminal columns (ANSI/emoji aware). */
560
+ function padLeftVisible(text: string, width: number): string {
561
+ const missing = width - visibleWidth(text);
562
+ return missing > 0 ? " ".repeat(missing) + text : text;
563
+ }
564
+
565
+ /** Behavior switches for {@link ModelBrowser}. */
566
+ export interface ModelBrowserOptions {
567
+ /** Render the dim `provider/` prefix before model ids. Default true. */
568
+ showProvider?: boolean;
569
+ /** Session token count used to flag models whose context window is exceeded. */
570
+ currentContextTokens?: number;
571
+ /** When true, over-context rows render grayed; picking one compacts first (session-switch mode). */
572
+ markOverContext?: boolean;
573
+ /** Host-provided empty-state text (e.g. provider discovery status). */
574
+ emptyText?: () => string | undefined;
575
+ }
576
+
577
+ /** Rendered rows before the list window: search row + blank. */
578
+ const LIST_ROW_START = 2;
579
+ /** Rendered rows after the list window: blank + two detail rows. */
580
+ const DETAIL_ROWS = 3;
581
+ /** Row width from which the measured-perf column appears (TPS only). */
582
+ const PERF_TPS_MIN_WIDTH = 76;
583
+ /** Row width from which the perf column also includes TTFT. */
584
+ const PERF_FULL_MIN_WIDTH = 96;
585
+ /** What the per-row perf column shows at the current width. */
586
+ type PerfMode = "off" | "tps" | "full";
587
+
588
+ /**
589
+ * The reusable browser component. Renders a fixed-height block
590
+ * (`maxVisible + LIST_ROW_START + DETAIL_ROWS` rows) so host mouse geometry
591
+ * stays stable across renders.
592
+ */
593
+ export class ModelBrowser implements Component {
594
+ #settings: ModelBrowserSource;
595
+ #searchInput = new Input();
596
+ #menu = new MenuSelection<ModelBrowserItem>([], {
597
+ getKey: item => item.selector,
598
+ getSearchText: modelSearchText,
599
+ isDisabled: item => item.id === "separator",
600
+ filter: (items, query) => this.#filterItems(items, query),
601
+ });
602
+ #roles: RoleAssignments = {};
603
+ #mruOrder: ReadonlyArray<string> = [];
604
+ #affinity: SearchAffinity = { models: new Map(), providers: new Map() };
605
+ #perf: ReadonlyMap<string, ModelBrowserPerf> = new Map();
606
+ #hoveredIndex: number | null = null;
607
+ #maxVisible = 10;
608
+ #showProvider: boolean;
609
+ #currentContextTokens: number;
610
+ #markOverContext: boolean;
611
+ #emptyText?: () => string | undefined;
612
+ /** Keep role-like virtual rows in their host-defined order during search. */
613
+ #preserveQueryOrder = false;
614
+ /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */
615
+ #windowStart = 0;
616
+ #windowCount = 0;
617
+ /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
618
+ #focused = true;
619
+ /** `provider/id` of the session's active model; marked in rows and detail. */
620
+ #currentSelector: string | undefined;
621
+
622
+ /** Enter or click-on-selected. */
623
+ onActivate?: (item: ModelBrowserItem) => void;
624
+ onSelectionChange?: (item: ModelBrowserItem | undefined) => void;
625
+ onQueryChange?: (query: string) => void;
626
+ /** Cancel key with an empty query (a non-empty query is cleared first). */
627
+ onCancel?: () => void;
628
+
629
+ constructor(settings: ModelBrowserSource, options: ModelBrowserOptions = {}) {
630
+ this.#settings = settings;
631
+ this.#showProvider = options.showProvider ?? true;
632
+ const tokens = options.currentContextTokens ?? 0;
633
+ this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0;
634
+ this.#markOverContext = options.markOverContext ?? false;
635
+ this.#emptyText = options.emptyText;
636
+ this.#syncAffinity();
637
+ }
638
+
639
+ /** Mark `selector` as the session's active model (undefined clears the mark). */
640
+ setCurrentSelector(selector: string | undefined): void {
641
+ this.#currentSelector = selector;
642
+ }
643
+
644
+ /** Replace the scope's base items; the live query re-applies and selection is pinned by selector. */
645
+ setItems(items: ModelBrowserItem[]): void {
646
+ const selectedKey = this.getSelected()?.selector;
647
+ this.#menu.setItems(this.#insertSeparator(items), selectedKey);
648
+ this.onSelectionChange?.(this.getSelected());
649
+ if (selectedKey) {
650
+ this.selectSelector(selectedKey);
651
+ }
652
+ }
653
+
654
+ setRoles(roles: RoleAssignments): void {
655
+ this.#roles = roles;
656
+ this.#syncAffinity();
657
+ }
658
+
659
+ setMruOrder(order: ReadonlyArray<string>): void {
660
+ this.#mruOrder = order;
661
+ this.#syncAffinity();
662
+ }
663
+
664
+ #syncAffinity(): void {
665
+ this.#affinity = buildSearchAffinity(this.#settings.modelProviderOrder, this.#roles, this.#mruOrder);
666
+ }
667
+
668
+ /** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */
669
+ setPerfStats(perf: ReadonlyMap<string, ModelBrowserPerf>): void {
670
+ this.#perf = perf;
671
+ }
672
+
673
+ setMaxVisible(rows: number): void {
674
+ // No selection snap here: hosts call this on every render, and it must
675
+ // not undo wheel panning. render() re-clamps the window.
676
+ this.#maxVisible = Math.max(1, rows);
677
+ }
678
+
679
+ setShowProvider(show: boolean): void {
680
+ this.#showProvider = show;
681
+ }
682
+ /** Keep the source order after fuzzy filtering instead of applying model-specific ranking. */
683
+ setPreserveQueryOrder(preserve: boolean): void {
684
+ this.#preserveQueryOrder = preserve;
685
+ }
686
+ /** Allow hosts to toggle context-window flagging between browser modes. */
687
+ setMarkOverContext(mark: boolean): void {
688
+ this.#markOverContext = mark;
689
+ }
690
+ /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
691
+ setFocused(focused: boolean): void {
692
+ this.#focused = focused;
693
+ }
694
+
695
+ /** Total rendered height for the current `maxVisible` (host layout budgeting). */
696
+ get renderedRows(): number {
697
+ return LIST_ROW_START + this.#maxVisible + DETAIL_ROWS;
698
+ }
699
+
700
+ get query(): string {
701
+ return this.#searchInput.getValue();
702
+ }
703
+
704
+ setQuery(query: string): void {
705
+ this.#searchInput.setValue(query);
706
+ this.#applyQuery("reset-changed-prefix");
707
+ }
708
+
709
+ getSelected(): ModelBrowserItem | undefined {
710
+ return this.#menu.selectedItem;
711
+ }
712
+
713
+ get visibleCount(): number {
714
+ return this.#menu.visibleItems.length;
715
+ }
716
+
717
+ /** Move selection to `selector`; false when it is not in the current view. */
718
+ selectSelector(selector: string): boolean {
719
+ if (!this.#menu.setSelectedKey(selector)) return false;
720
+ this.#ensureSelectedVisible();
721
+ return true;
722
+ }
723
+
724
+ #isDisabled(item: ModelBrowserItem): boolean {
725
+ return item.id === "separator";
726
+ }
727
+
728
+ /**
729
+ * Rank base items for the live query and seat the recent/role separator.
730
+ * Runs inside the menu filter for non-blank queries; the blank-query path
731
+ * re-seats via {@link #applyQuery} because the menu passes items through
732
+ * unfiltered when the query is blank.
733
+ */
734
+ #filterItems(items: readonly ModelBrowserItem[], query: string): readonly ModelBrowserItem[] {
735
+ const base = items.filter(item => !this.#isDisabled(item));
736
+ const ranked = this.#preserveQueryOrder
737
+ ? query.trim()
738
+ ? fuzzyRank(base, query, modelSearchText).map(result => result.item)
739
+ : base
740
+ : rankModelItems(query, base, {
741
+ roles: this.#roles,
742
+ mruOrder: this.#mruOrder,
743
+ affinity: this.#affinity,
744
+ });
745
+ return this.#insertSeparator(ranked);
746
+ }
747
+
748
+ /** True when `item`'s context window is smaller than the live session token count (grayed row; hosts compact before switching). */
749
+ isOverContext(item: ModelBrowserItem): boolean {
750
+ if (item.id === "separator") return false;
751
+ if (!this.#markOverContext || this.#currentContextTokens <= 0) return false;
752
+ const contextWindow = item.model.contextWindow ?? 0;
753
+ return contextWindow > 0 && this.#currentContextTokens > contextWindow;
754
+ }
755
+
756
+ /** Clamp a window start into `[0, total - maxVisible]`. */
757
+ #clampWindowStart(start: number): number {
758
+ return clampScrollOffset(start, this.#menu.visibleItems.length, this.#maxVisible);
759
+ }
760
+
761
+ /** Scroll just enough to keep the selected row inside the window. */
762
+ #ensureSelectedVisible(): void {
763
+ this.#windowStart = scrollOffsetForRow(
764
+ this.#windowStart,
765
+ this.#menu.selectedIndex,
766
+ this.#menu.visibleItems.length,
767
+ this.#maxVisible,
768
+ "nearest",
769
+ );
770
+ }
771
+
772
+ /**
773
+ * Move the selection by `delta` rows, skipping disabled rows. Single steps
774
+ * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead.
775
+ */
776
+ moveSelection(delta: number, options: { wrap?: boolean } = {}): void {
777
+ if (this.#menu.move(delta, options.wrap ?? true)) {
778
+ this.#ensureSelectedVisible();
779
+ this.onSelectionChange?.(this.getSelected());
780
+ }
781
+ }
782
+
783
+ #isRecentOrRole(item: ModelBrowserItem): boolean {
784
+ if (this.#mruOrder.includes(item.selector)) return true;
785
+ for (const role in this.#roles) {
786
+ const r = this.#roles[role];
787
+ if (r && modelsAreEqual(r.model, item.model)) return true;
788
+ }
789
+ return false;
790
+ }
791
+ #insertSeparator(items: ModelBrowserItem[]): ModelBrowserItem[] {
792
+ const filtered = items.filter(item => item.id !== "separator");
793
+ const firstNonRecentIndex = filtered.findIndex(item => !this.#isRecentOrRole(item));
794
+ if (firstNonRecentIndex > 0 && firstNonRecentIndex < filtered.length) {
795
+ const separatorItem: ModelBrowserItem = {
796
+ id: "separator",
797
+ provider: "",
798
+ selector: "separator",
799
+ model: buildModel({
800
+ id: "separator",
801
+ name: "separator",
802
+ api: "ollama-chat",
803
+ provider: "",
804
+ baseUrl: "",
805
+ reasoning: false,
806
+ input: ["text"],
807
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
808
+ contextWindow: 0,
809
+ maxTokens: 0,
810
+ }),
811
+ };
812
+ return [...filtered.slice(0, firstNonRecentIndex), separatorItem, ...filtered.slice(firstNonRecentIndex)];
813
+ }
814
+ return filtered;
815
+ }
816
+
817
+ /** Whether the new result list keeps every selectable choice through the previous selection unchanged. */
818
+ #hasStableChoicePrefix(previousItems: ReadonlyArray<ModelBrowserItem>, previousSelectedIndex: number): boolean {
819
+ const current = this.#menu.visibleItems;
820
+ let currentIndex = 0;
821
+ for (let previousIndex = 0; previousIndex <= previousSelectedIndex; previousIndex++) {
822
+ const previous = previousItems[previousIndex];
823
+ if (!previous || this.#isDisabled(previous)) continue;
824
+
825
+ let found: ModelBrowserItem | undefined;
826
+ while (currentIndex < current.length) {
827
+ const candidate = current[currentIndex++];
828
+ if (candidate && !this.#isDisabled(candidate)) {
829
+ found = candidate;
830
+ break;
831
+ }
832
+ }
833
+ if (found?.selector !== previous.selector) return false;
834
+ }
835
+ return true;
836
+ }
837
+
838
+ #applyQuery(selection: "clamp" | "reset-changed-prefix" = "clamp"): void {
839
+ const query = this.#searchInput.getValue();
840
+ const previousItems = this.#menu.visibleItems;
841
+ const previousSelectedIndex = this.#menu.selectedIndex;
842
+ const previousSelected = previousItems[previousSelectedIndex];
843
+ if (!query.trim()) {
844
+ // The menu passes items through unfiltered on a blank query, so
845
+ // re-seat the separator on the fresh base order here.
846
+ const base = this.#menu.items.filter(item => !this.#isDisabled(item));
847
+ this.#menu.setQuery("", false);
848
+ this.#menu.setItems(
849
+ this.#insertSeparator(base),
850
+ selection === "clamp" ? previousSelected?.selector : undefined,
851
+ );
852
+ } else if (selection === "reset-changed-prefix") {
853
+ this.#menu.setQuery(query, false);
854
+ } else {
855
+ this.#menu.setQuery(query, true);
856
+ }
857
+ if (
858
+ selection === "reset-changed-prefix" &&
859
+ previousSelected &&
860
+ this.#hasStableChoicePrefix(previousItems, previousSelectedIndex)
861
+ ) {
862
+ this.#menu.setSelectedKey(previousSelected.selector);
863
+ }
864
+ this.#ensureSelectedVisible();
865
+ this.onSelectionChange?.(this.getSelected());
866
+ }
867
+
868
+ handleInput(data: string): void {
869
+ if (matchesSelectCancel(data)) {
870
+ this.handleCancel();
871
+ return;
872
+ }
873
+ if (matchesSelectUp(data)) {
874
+ this.moveSelection(-1);
875
+ return;
876
+ }
877
+ if (matchesSelectDown(data)) {
878
+ this.moveSelection(1);
879
+ return;
880
+ }
881
+ if (matchesSelectPageUp(data)) {
882
+ this.moveSelection(-this.#maxVisible, { wrap: false });
883
+ return;
884
+ }
885
+ if (matchesSelectPageDown(data)) {
886
+ this.moveSelection(this.#maxVisible, { wrap: false });
887
+ return;
888
+ }
889
+ if (matchesKey(data, "home")) {
890
+ this.moveSelection(-this.#menu.visibleItems.length, { wrap: false });
891
+ return;
892
+ }
893
+ if (matchesKey(data, "end")) {
894
+ this.moveSelection(this.#menu.visibleItems.length, { wrap: false });
895
+ return;
896
+ }
897
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
898
+ const selected = this.getSelected();
899
+ if (selected && !this.#isDisabled(selected)) {
900
+ this.onActivate?.(selected);
901
+ }
902
+ return;
903
+ }
904
+ // Everything else edits the query like a regular single-line editor.
905
+ const before = this.#searchInput.getValue();
906
+ this.#searchInput.handleInput(data);
907
+ const after = this.#searchInput.getValue();
908
+ if (after !== before) {
909
+ this.#applyQuery("reset-changed-prefix");
910
+ this.onQueryChange?.(after);
911
+ }
912
+ }
913
+
914
+ /** Cancel-key ladder: clear a non-empty query first, then bubble to the host. */
915
+ handleCancel(): void {
916
+ if (this.#searchInput.getValue().length > 0) {
917
+ this.setQuery("");
918
+ this.onQueryChange?.("");
919
+ return;
920
+ }
921
+ this.onCancel?.();
922
+ }
923
+
924
+ /**
925
+ * Route a mouse event. `line` is relative to the browser's first rendered
926
+ * row (the search row).
927
+ */
928
+ routeMouse(event: SgrMouseEvent, line: number): void {
929
+ if (event.wheel !== null) {
930
+ // Wheel pans the window; it never moves the selection and never wraps.
931
+ this.#windowStart = this.#clampWindowStart(this.#windowStart + event.wheel);
932
+ this.#hoveredIndex = this.#hoverIndexAt(line);
933
+ return;
934
+ }
935
+ if (event.motion) {
936
+ this.#hoveredIndex = this.#hoverIndexAt(line);
937
+ return;
938
+ }
939
+ if (!event.leftClick) return;
940
+ const index = this.#hoverIndexAt(line);
941
+ const item = index !== null ? this.#menu.visibleItems[index] : undefined;
942
+ if (index === null || !item) return;
943
+ // ModelBrowserSource idiom: click selects, click-again activates.
944
+ if (index === this.#menu.selectedIndex) {
945
+ this.onActivate?.(item);
946
+ } else if (this.#menu.setSelectedIndex(index)) {
947
+ this.#ensureSelectedVisible();
948
+ this.onSelectionChange?.(this.getSelected());
949
+ }
950
+ }
951
+ /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */
952
+ clearHover(): void {
953
+ this.#hoveredIndex = null;
954
+ }
955
+
956
+ /** List index under a frame-local row, or null when off-list or on a disabled row. */
957
+ #hoverIndexAt(line: number): number | null {
958
+ const listLine = line - LIST_ROW_START;
959
+ if (listLine < 0 || listLine >= this.#windowCount) return null;
960
+ const index = this.#windowStart + listLine;
961
+ const item = this.#menu.visibleItems[index];
962
+ if (!item || this.#isDisabled(item)) return null;
963
+ return index;
964
+ }
965
+
966
+ /** Measured TPS/TTFT, falling back to the catalog TPS as an estimated `~118t/s`. */
967
+ #perfCell(item: ModelBrowserItem, mode: PerfMode): string {
968
+ if (mode === "off") return "";
969
+ const perf = this.#perf.get(item.selector);
970
+ if (perf) {
971
+ const tps = formatTps(perf.tps);
972
+ if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`;
973
+ return tps;
974
+ }
975
+ const tps = item.model.tps;
976
+ return tps != null && Number.isFinite(tps) && tps > 0 ? `~${formatTps(tps)}` : "";
977
+ }
978
+
979
+ #renderRow(
980
+ item: ModelBrowserItem,
981
+ width: number,
982
+ selected: boolean,
983
+ hovered: boolean,
984
+ ctxWidth: number,
985
+ costWidth: number,
986
+ intelligenceWidth: number,
987
+ perfWidth: number,
988
+ perfMode: PerfMode,
989
+ ): string {
990
+ if (item.id === "separator") {
991
+ const dashCount = Math.max(0, width - 4);
992
+ const line = theme.fg("muted", "─".repeat(dashCount));
993
+ return ` ${line} `;
994
+ }
995
+ const overContext = this.isOverContext(item);
996
+ const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
997
+ const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
998
+ const name = item.labelColor
999
+ ? theme.fg(item.labelColor, item.id)
1000
+ : selected
1001
+ ? theme.fg("accent", item.id)
1002
+ : item.id;
1003
+ const currentMark =
1004
+ item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : "";
1005
+ const overLimit = overContext
1006
+ ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
1007
+ : "";
1008
+ let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`;
1009
+
1010
+ // Metric columns collapse independently when no visible row has data.
1011
+ const intelligenceCol =
1012
+ intelligenceWidth > 0
1013
+ ? `${theme.fg("dim", padLeftVisible(formatIntelligence(item.model), intelligenceWidth))} `
1014
+ : "";
1015
+ const perfCol =
1016
+ perfWidth > 0 ? `${theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth))} ` : "";
1017
+ const meta = `${intelligenceCol}${perfCol}${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`;
1018
+ const metaWidth =
1019
+ ctxWidth +
1020
+ costWidth +
1021
+ 2 +
1022
+ (intelligenceWidth > 0 ? intelligenceWidth + 2 : 0) +
1023
+ (perfWidth > 0 ? perfWidth + 2 : 0);
1024
+ const available = Math.max(1, width - metaWidth - 1);
1025
+ left = truncateToWidth(left, available);
1026
+ const gap = Math.max(0, available - visibleWidth(left));
1027
+
1028
+ let line = `${left}${" ".repeat(gap)} ${meta}`;
1029
+ if (overContext) {
1030
+ // Gray the whole row but keep the selection cursor visible: over-context
1031
+ // models stay selectable (the host compacts before switching).
1032
+ const plainPrefix = Bun.stripANSI(prefix);
1033
+ line = `${prefix}${theme.fg("dim", Bun.stripANSI(line).slice(plainPrefix.length))}`;
1034
+ }
1035
+ // The bg band is reserved for the mouse: it marks hover, nothing else.
1036
+ // Keyboard selection is the cursor glyph + accent name.
1037
+ if (hovered) {
1038
+ line = theme.bg("selectedBg", line);
1039
+ }
1040
+ return line;
1041
+ }
1042
+
1043
+ #detailLines(width: number): [string, string] {
1044
+ const selected = this.getSelected();
1045
+ if (!selected) return ["", ""];
1046
+ const model = selected.model;
1047
+
1048
+ const facts: string[] = [model.name];
1049
+ // Upstream badges sit next to the name; the provider blurb goes last so
1050
+ // width truncation eats prose before context, cost, or perf facts.
1051
+ if (model.isNew) facts.push("new");
1052
+ if (model.isBeta) facts.push("beta");
1053
+ if (model.isRecommended) facts.push("recommended");
1054
+ if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
1055
+ if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
1056
+ facts.push(`${formatCostPair(model)} per M`);
1057
+ if (model.reasoning) facts.push("reasoning");
1058
+ if (model.input.includes("image")) facts.push("vision");
1059
+ const intelligence = formatIntelligence(model);
1060
+ if (intelligence) facts.push(intelligence);
1061
+ const perf = this.#perf.get(selected.selector);
1062
+ if (perf) {
1063
+ facts.push(`~${formatTps(perf.tps)}`);
1064
+ if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`);
1065
+ } else if (model.tps != null && Number.isFinite(model.tps) && model.tps > 0) {
1066
+ facts.push(`~${formatTps(model.tps)}`);
1067
+ }
1068
+ if (model.description) {
1069
+ const description = formatDescription(model.description);
1070
+ if (description) facts.push(description);
1071
+ }
1072
+ const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width);
1073
+
1074
+ if (this.isOverContext(selected)) {
1075
+ const warning = ` ${theme.status.disabled} context ${formatNumber(this.#currentContextTokens).toLowerCase()} exceeds ${formatNumber(model.contextWindow ?? 0).toLowerCase()} limit · compacts with current model, then switches`;
1076
+ return [line1, truncateToWidth(theme.fg("warning", warning), width)];
1077
+ }
1078
+
1079
+ const chips: string[] = [];
1080
+ if (selected.selector === this.#currentSelector) {
1081
+ chips.push(theme.fg("success", `${theme.status.enabled} current`));
1082
+ }
1083
+ const seen = new Set<string>();
1084
+ const pushRole = (role: string) => {
1085
+ if (seen.has(role)) return;
1086
+ seen.add(role);
1087
+ const assignment = this.#roles[role];
1088
+ if (!assignment || !modelsAreEqual(assignment.model, model)) return;
1089
+ if (this.#settings.getRoleInfo(role).hidden) return;
1090
+ chips.push(formatRoleChip(role, assignment, this.#settings));
1091
+ };
1092
+ for (const role of MODEL_ROLE_IDS) pushRole(role);
1093
+ for (const role in this.#roles) pushRole(role);
1094
+ const line2 = chips.length > 0 ? truncateToWidth(` ${chips.join(theme.fg("dim", " · "))}`, width) : "";
1095
+ return [line1, line2];
1096
+ }
1097
+
1098
+ render(width: number): string[] {
1099
+ const lines: string[] = [];
1100
+
1101
+ const searchIcon = theme.fg("accent", theme.symbol("icon.search"));
1102
+ const inputWidth = Math.max(4, width - visibleWidth(theme.symbol("icon.search")) - 2);
1103
+ lines.push(` ${searchIcon} ${this.#searchInput.render(inputWidth)[0] ?? ""}`);
1104
+ lines.push("");
1105
+
1106
+ const total = this.#menu.visibleItems.length;
1107
+ // The window is persistent state: wheel scrolling panned it, keyboard
1108
+ // navigation snapped it to the selection. Re-clamp here because items
1109
+ // or maxVisible may have changed since.
1110
+ this.#windowStart = this.#clampWindowStart(this.#windowStart);
1111
+ const startIndex = this.#windowStart;
1112
+ const endIndex = Math.min(startIndex + this.#maxVisible, total);
1113
+ this.#windowCount = Math.max(0, endIndex - startIndex);
1114
+
1115
+ if (total === 0) {
1116
+ const message =
1117
+ this.#emptyText?.() ?? (this.query.trim() ? " No matching models" : " No models available in this scope");
1118
+ lines.push(truncateToWidth(theme.fg("muted", message), width));
1119
+ for (let i = 1; i < this.#maxVisible; i++) lines.push("");
1120
+ } else {
1121
+ // Per-window column widths keep the metadata block aligned without
1122
+ // scanning the entire catalog on every render.
1123
+ let ctxWidth = 0;
1124
+ let costWidth = 0;
1125
+ const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off";
1126
+ let intelligenceWidth = 0;
1127
+ let perfWidth = 0;
1128
+ for (let i = startIndex; i < endIndex; i++) {
1129
+ const item = this.#menu.visibleItems[i];
1130
+ if (!item) continue;
1131
+ ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model)));
1132
+ costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model)));
1133
+ if (perfMode !== "off") {
1134
+ intelligenceWidth = Math.max(intelligenceWidth, visibleWidth(formatIntelligence(item.model)));
1135
+ }
1136
+ perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode)));
1137
+ }
1138
+
1139
+ const rows: string[] = [];
1140
+ for (let i = startIndex; i < endIndex; i++) {
1141
+ const item = this.#menu.visibleItems[i];
1142
+ if (!item) continue;
1143
+ rows.push(
1144
+ this.#renderRow(
1145
+ item,
1146
+ width - 1,
1147
+ i === this.#menu.selectedIndex,
1148
+ i === this.#hoveredIndex,
1149
+ ctxWidth,
1150
+ costWidth,
1151
+ intelligenceWidth,
1152
+ perfWidth,
1153
+ perfMode,
1154
+ ),
1155
+ );
1156
+ }
1157
+ const scrollView = new ScrollView(rows, {
1158
+ height: rows.length,
1159
+ scrollbar: "auto",
1160
+ totalRows: total,
1161
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
1162
+ });
1163
+ scrollView.setScrollOffset(startIndex);
1164
+ lines.push(...scrollView.render(width));
1165
+ for (let i = rows.length; i < this.#maxVisible; i++) lines.push("");
1166
+ }
1167
+
1168
+ lines.push("");
1169
+ const [detail1, detail2] = this.#detailLines(width);
1170
+ lines.push(detail1);
1171
+ lines.push(detail2);
1172
+ return lines;
1173
+ }
1174
+
1175
+ invalidate(): void {}
1176
+ }