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

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,2279 @@
1
+ import { parseModelString, splitUpstreamRouting, formatModelSelectorValue } from "./model-selector";
2
+ /**
3
+ * Fullscreen /models hub, shown on the alternate screen like /settings.
4
+ *
5
+ * Layout: a sidebar of scopes (recently used, role management, all models,
6
+ * one entry per provider — locked providers included, dimmed) beside a
7
+ * {@link ModelBrowser} body. The Roles view manages assignments directly:
8
+ * pick a role, pick a model, adjust thinking in an inline strip, or clear the
9
+ * role back to auto-selection. Locked providers forward to the /login flow.
10
+ * Fully mouse-navigable (hover, wheel, click). Session-only switching lives
11
+ * in the compact alt+p picker ({@link ./model-picker}).
12
+ */
13
+ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
14
+ import type { Model } from "@oh-my-pi/pi-ai";
15
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
16
+ import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
17
+ import { providerEntry } from "@oh-my-pi/pi-catalog/compat/providers";
18
+ import type { Component, TUI } from "../tui";
19
+ import { extractPrintableText, matchesKey } from "../keys";
20
+ import { fuzzyFilter } from "../fuzzy";
21
+ import { getKeybindings } from "../keybindings";
22
+ import { Input } from "../components/input";
23
+ import { routeSgrMouseInput, type SgrMouseEvent } from "../mouse";
24
+ import { truncateToWidth, visibleWidth } from "../utils";
25
+ import type {
26
+ ModelBrowserSource,
27
+ ModelBrowserRegistry,
28
+ ModelRoleLookup,
29
+ ResolvedModelRoleValue,
30
+ } from "./model-browser";
31
+ import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../thinking";
32
+ import { thinkingLevelGlyph } from "../render/render-utils";
33
+ import { theme } from "../theme/theme";
34
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
35
+ import {
36
+ buildBrowserItems,
37
+ ModelBrowser,
38
+ type ModelBrowserItem,
39
+ modelSearchText,
40
+ type RoleAssignments,
41
+ resolveRoleAssignments,
42
+ sortModelItems,
43
+ } from "./model-browser";
44
+ import {
45
+ HubFrame,
46
+ moveStripSelection,
47
+ type SidebarEntry as HubSidebarEntry,
48
+ type SidebarStyle,
49
+ type StripChip as HubStripChip,
50
+ type StripState as HubStripState,
51
+ } from "./hub-frame";
52
+ import { renderSegmentTrack } from "../chrome/segment-track";
53
+
54
+ /**
55
+ * A row of the Roles view: a role, a model/wildcard chain-key header, one of a
56
+ * chain's fallback entries, or the trailing "+ New role…". Fallback rows under
57
+ * a chain-key header carry the key in `role` — `retry.fallbackChains` treats
58
+ * roles, `provider/model-id`, and `provider/*` keys uniformly.
59
+ */
60
+ type RolesRow =
61
+ | { kind: "role"; role: string }
62
+ | { kind: "chainKey"; role: string }
63
+ | { kind: "fallback"; role: string; chainIndex: number; selector: string }
64
+ | { kind: "separator" }
65
+ | { kind: "newFallback" }
66
+ | { kind: "newRole" };
67
+
68
+ /**
69
+ * What the model browser is currently picking for: a role's model, a slot in
70
+ * a fallback chain (`role` may be a role name, model selector, or `provider/*`
71
+ * key), or the primary model a brand-new fallback chain protects.
72
+ */
73
+ type AssignTarget =
74
+ | { kind: "role"; role: string }
75
+ | { kind: "fallback"; role: string; index: number | null }
76
+ | { kind: "fallbackKey" };
77
+
78
+ /** Live preferences and selector operations supplied by the host. */
79
+ export interface ModelHubSource extends ModelBrowserSource {
80
+ readonly disabledProviders: readonly string[];
81
+ readonly fallbackChains: Record<string, string[]>;
82
+ readonly modelRoleStorage: "global" | "project";
83
+ readonly cycleOrder: readonly string[];
84
+ getProjectModelRole(role: string): string | undefined;
85
+ getGlobalModelRole(role: string): string | undefined;
86
+ getModelRoleSource(role: string): "global" | "project" | "default";
87
+ }
88
+
89
+ /** Catalog capabilities required by the model hub. */
90
+ export interface ModelHubRegistry extends ModelBrowserRegistry {
91
+ readonly authStorage: { hasAuth(provider: string): boolean };
92
+ getDiscoverableProviders(): string[];
93
+ getProviderDiscoveryState(provider: string):
94
+ | {
95
+ optional: boolean;
96
+ status: "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
97
+ fetchedAt?: number;
98
+ error?: string;
99
+ }
100
+ | undefined;
101
+ find(provider: string, id: string): Model | undefined;
102
+ refresh(strategy: "online"): Promise<void>;
103
+ refreshProvider(
104
+ provider: string,
105
+ strategy: "online",
106
+ options?: { refreshCommandCredentials?: boolean },
107
+ ): Promise<void>;
108
+ }
109
+
110
+ /** A `--models` scope entry (mirrors the session's scoped model list). */
111
+ export interface ScopedModelItem {
112
+ model: Model;
113
+ thinkingLevel?: string;
114
+ }
115
+
116
+ export type ModelRoleSelectionScope = "global" | "project";
117
+
118
+ export interface ModelHubCallbacks {
119
+ /** Persist a role assignment. */
120
+ onAssign: (
121
+ model: Model,
122
+ role: string,
123
+ thinkingLevel: ConfiguredThinkingLevel | undefined,
124
+ selector: string,
125
+ scope?: ModelRoleSelectionScope,
126
+ ) => void | boolean | Promise<void | boolean>;
127
+ /** Clear a configured role back to auto-selection. */
128
+ onUnassign: (role: string, scope?: ModelRoleSelectionScope) => void;
129
+ /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
130
+ onFallbackChainChange?: (role: string, chain: string[]) => void;
131
+ /** Locked provider activation: forward to the /login flow. */
132
+ onLoginRequest?: (providerId: string) => void;
133
+ /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
134
+ onCycleOrderChange?: (order: string[]) => void;
135
+ onCancel: () => void;
136
+ }
137
+
138
+ export interface ModelHubOptions {
139
+ /** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
140
+ initialProviderId?: string;
141
+ }
142
+
143
+ interface SidebarEntry extends HubSidebarEntry<"recent" | "roles" | "all" | "separator" | "provider"> {
144
+ providerId?: string;
145
+ locked?: boolean;
146
+ oauth?: boolean;
147
+ catalogCount?: number;
148
+ }
149
+
150
+ /**
151
+ * The focused sidebar entry plus its screen row, captured before a rebuild so
152
+ * the viewport can be restored around it. `index` is the entry's position in
153
+ * `#entries` (−1 when absent); `offset` is its row relative to the scroll top.
154
+ */
155
+ interface SidebarAnchor {
156
+ id: string;
157
+ index: number;
158
+ offset: number;
159
+ }
160
+
161
+ interface StripChip extends HubStripChip<
162
+ "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "scope" | "thinking"
163
+ > {
164
+ role?: string;
165
+ thinkingLevel?: ConfiguredThinkingLevel;
166
+ scope?: ModelRoleSelectionScope;
167
+ }
168
+
169
+ type StripState =
170
+ | (HubStripState<StripChip> & {
171
+ kind: "role" | "scope" | "thinking";
172
+ item: ModelBrowserItem;
173
+ role?: string;
174
+ /** Set when a thinking strip edits a fallback-chain entry instead of a role assignment. */
175
+ fallbackIndex?: number;
176
+ scope?: ModelRoleSelectionScope;
177
+ /** Where to land when a scope or thinking strip closes. */
178
+ returnToRoles: boolean;
179
+ /** Thinking value already committed for this strip. */
180
+ initialThinkingLevel?: ConfiguredThinkingLevel;
181
+ })
182
+ | {
183
+ /** Footer text input naming a new custom role. */
184
+ kind: "roleName";
185
+ input: Input;
186
+ };
187
+
188
+ const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
189
+ const RECENT_LIMIT = 15;
190
+
191
+ /**
192
+ * Providers already auto-refreshed this process. Selecting a provider fetches
193
+ * its live model list at most once per application lifetime (surviving hub
194
+ * close/reopen); F5 re-fetches on demand.
195
+ */
196
+ const autoRefreshedProviders = new Set<string>();
197
+
198
+ /** Test hook: forget which providers were auto-refreshed this process. */
199
+ export function resetProviderAutoRefreshGuard(): void {
200
+ autoRefreshedProviders.clear();
201
+ }
202
+
203
+ /**
204
+ * The fullscreen model hub component. Hosted via `ui.showOverlay(..., { fullscreen: true })`;
205
+ * the host must call {@link ModelHubComponent.dispose} when the overlay closes.
206
+ */
207
+ export class ModelHubComponent implements Component {
208
+ #tui: TUI;
209
+ #settings: ModelHubSource;
210
+ #registry: ModelHubRegistry;
211
+ #scopedModels: ReadonlyArray<ScopedModelItem>;
212
+ #callbacks: ModelHubCallbacks;
213
+
214
+ #browser: ModelBrowser;
215
+ #roles: RoleAssignments = {};
216
+ #availableItems: ModelBrowserItem[] = [];
217
+ #recentItems: ModelBrowserItem[] = [];
218
+ #configError: string | undefined;
219
+
220
+ #entries: SidebarEntry[] = [];
221
+ // Sidebar sections from the last registry sync; #composeEntries assembles
222
+ // #entries from these (reordered while searching).
223
+ #fixedEntries: SidebarEntry[] = [];
224
+ #unlockedProviderEntries: SidebarEntry[] = [];
225
+ #lockedProviderEntries: SidebarEntry[] = [];
226
+ /** Fuzzy match totals while searching: recent-scope hits and overall hits. */
227
+ #recentSearchCount = 0;
228
+ #searchTotal = 0;
229
+ #activeEntryId = "all";
230
+ /** Snap the sidebar viewport to the active entry on the next render; wheel panning leaves it free. */
231
+ #sidebarFollowActive = true;
232
+ #sidebarHover: number | null = null;
233
+ /**
234
+ * Arrow-key ownership: `scope` (default) hops the sidebar; `list`
235
+ * navigates rows (browser models or role rows). Typing anywhere focuses
236
+ * the model list; Tab toggles; ←/→ switches between sidebar and list.
237
+ */
238
+ #focus: "scope" | "list" = "scope";
239
+
240
+ #rolesRows: RolesRow[] = [];
241
+ #roleIndex = 0;
242
+ #roleHover: number | null = null;
243
+ /** First roles row drawn in the scroll window; follows the cursor and clamps to the list. */
244
+ #roleScrollStart = 0;
245
+ /** Roles rows actually drawn this frame; bounds mouse hit-testing to the visible window. */
246
+ #rolesVisibleCount = 0;
247
+
248
+ #assigning: AssignTarget | null = null;
249
+ #strip: StripState | null = null;
250
+ #assignmentPending = false;
251
+ #disposed = false;
252
+ /** Per-provider fuzzy match counts while a query is active; null when not searching. */
253
+ #searchCounts: Map<string, number> | null = null;
254
+
255
+ // Provider discovery refresh (debounced per sidebar selection, with spinner).
256
+ #refreshingProviders = new Set<string>();
257
+ #scheduledProviderRefreshes = new Map<string, Timer>();
258
+ /** F5 while a catalog-only refresh is in flight: re-run with credentials after it settles. */
259
+ #pendingCredentialRefreshProviders = new Set<string>();
260
+ #refreshSpinnerFrame = 0;
261
+ #refreshSpinnerInterval?: Timer;
262
+ #renderBodyPane = (width: number, height: number | undefined): readonly string[] => {
263
+ const rows = Math.max(1, Math.floor(height ?? 10));
264
+ const lines: string[] = [this.#statusRow(width)];
265
+ const entry = this.#activeEntry();
266
+ if (entry.kind === "roles" && this.#assigning === null) {
267
+ lines.push(...this.#renderRolesView(width, rows - 1));
268
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
269
+ lines.push(...this.#renderLockedView(entry, width, rows - 1));
270
+ } else {
271
+ this.#browser.setMaxVisible(rows - 1 - 5);
272
+ this.#browser.setFocused(this.#focus === "list");
273
+ lines.push(...this.#browser.render(width));
274
+ }
275
+ while (lines.length < rows) lines.push("");
276
+ return lines.slice(0, rows);
277
+ };
278
+ readonly #frame: HubFrame = new HubFrame(
279
+ "Models",
280
+ { min: 18, max: 26 },
281
+ (width, rows) => this.#renderSidebar(width, rows),
282
+ this.#renderBodyPane,
283
+ );
284
+ #lockedLoginLine: number | null = null;
285
+ #rolesRowStart = 1;
286
+
287
+ constructor(
288
+ tui: TUI,
289
+ settings: ModelHubSource,
290
+ registry: ModelHubRegistry,
291
+ scopedModels: ReadonlyArray<ScopedModelItem>,
292
+ callbacks: ModelHubCallbacks,
293
+ options: ModelHubOptions = {},
294
+ ) {
295
+ this.#tui = tui;
296
+ this.#settings = settings;
297
+ this.#registry = registry;
298
+ this.#scopedModels = scopedModels;
299
+ this.#callbacks = callbacks;
300
+
301
+ this.#browser = new ModelBrowser(settings, {
302
+ emptyText: () => this.#emptyStateMessage(),
303
+ });
304
+ this.#browser.onActivate = item => this.#activateItem(item);
305
+ this.#browser.onCancel = () => this.#callbacks.onCancel();
306
+ this.#browser.onQueryChange = query => this.#onQueryChanged(query);
307
+
308
+ // Hydrate synchronously from the current registry snapshot so the first
309
+ // Enter after opening acts on cached models instead of being dropped
310
+ // while the offline refresh promise is still pending.
311
+ this.#syncFromRegistryState();
312
+
313
+ const initialProvider = options.initialProviderId;
314
+ if (initialProvider && this.#entries.some(entry => entry.providerId === initialProvider)) {
315
+ this.#setActiveEntry(`provider:${initialProvider}`);
316
+ } else {
317
+ this.#setActiveEntry("all");
318
+ }
319
+
320
+ // Reconcile catalogs in the background. This is online discovery only —
321
+ // it must not re-run `!command` credential helpers (F5 / `omp models
322
+ // refresh` pass refreshCommandCredentials for that). A --models scope is
323
+ // registry-independent, so the reload would only repeat the hydration
324
+ // above.
325
+ if (this.#scopedModels.length === 0) {
326
+ this.#registry
327
+ .refresh("online")
328
+ .then(() => this.#syncFromRegistryState())
329
+ .catch(error => {
330
+ this.#configError = error instanceof Error ? error.message : String(error);
331
+ })
332
+ .finally(() => this.#tui.requestRender());
333
+ }
334
+ }
335
+
336
+ /** Cancel pending provider refresh timers and the spinner. Host calls this on overlay close. */
337
+ dispose(): void {
338
+ this.#disposed = true;
339
+ for (const [, timer] of this.#scheduledProviderRefreshes) clearTimeout(timer);
340
+ this.#scheduledProviderRefreshes.clear();
341
+ this.#refreshingProviders.clear();
342
+ this.#pendingCredentialRefreshProviders.clear();
343
+ if (this.#refreshSpinnerInterval) {
344
+ clearInterval(this.#refreshSpinnerInterval);
345
+ this.#refreshSpinnerInterval = undefined;
346
+ }
347
+ }
348
+
349
+ invalidate(): void {
350
+ this.#frame.invalidate();
351
+ }
352
+
353
+ // ═══════════════════════════════════════════════════════════════════════
354
+ // Data pipeline
355
+ // ═══════════════════════════════════════════════════════════════════════
356
+
357
+ #visibleRoleIds(): string[] {
358
+ return this.#settings.knownRoleIds.filter(role => !this.#settings.getRoleInfo(role).hidden);
359
+ }
360
+
361
+ /** Resolve every known role: configured values first, auto-selection for the rest. */
362
+ #reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
363
+ const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll();
364
+ this.#roles = resolveRoleAssignments(this.#settings, allModels, autoCandidates);
365
+ }
366
+
367
+ /** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
368
+ #syncFromRegistryState(): void {
369
+ // A background rebuild (provider refresh, mutation) must not yank the
370
+ // sidebar viewport: remember the focused entry's screen row so it — or
371
+ // its nearest survivor — stays put after #buildSidebar reshuffles entries.
372
+ const anchor = this.#captureSidebarAnchor();
373
+ let allModels: ReadonlyArray<Model>;
374
+ let availableModels: ReadonlyArray<Model>;
375
+ if (this.#scopedModels.length > 0) {
376
+ allModels = this.#scopedModels.map(scoped => scoped.model);
377
+ availableModels = allModels;
378
+ this.#configError = undefined;
379
+ } else {
380
+ const loadError = this.#registry.getError();
381
+ this.#configError = loadError ? String(loadError) : undefined;
382
+ allModels = this.#registry.getAll();
383
+ try {
384
+ availableModels = this.#registry.getAvailable();
385
+ } catch (error) {
386
+ this.#configError = error instanceof Error ? error.message : String(error);
387
+ availableModels = [];
388
+ }
389
+ }
390
+
391
+ this.#reloadRoles(availableModels);
392
+ this.#buildRolesRows();
393
+
394
+ const mruOrder = this.#settings.mruOrder;
395
+ this.#availableItems = buildBrowserItems(availableModels);
396
+ sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder });
397
+ this.#browser.setRoles(this.#roles);
398
+ this.#browser.setMruOrder(mruOrder);
399
+ this.#browser.setPerfStats(this.#settings.modelPerf);
400
+
401
+ const bySelector = new Map(this.#availableItems.map(item => [item.selector, item]));
402
+ this.#recentItems = [];
403
+ for (const key of mruOrder) {
404
+ const item = bySelector.get(key);
405
+ if (item) this.#recentItems.push(item);
406
+ if (this.#recentItems.length >= RECENT_LIMIT) break;
407
+ }
408
+
409
+ this.#buildSidebar(allModels, availableModels);
410
+ this.#restoreSidebarAnchor(anchor);
411
+ this.#applyScope();
412
+ }
413
+
414
+ #buildSidebar(allModels: ReadonlyArray<Model>, availableModels: ReadonlyArray<Model>): void {
415
+ const scoped = this.#scopedModels.length > 0;
416
+ let disabledProviders: ReadonlySet<string>;
417
+ try {
418
+ disabledProviders = new Set(this.#settings.disabledProviders);
419
+ } catch {
420
+ disabledProviders = new Set();
421
+ }
422
+
423
+ const availableCounts = new Map<string, number>();
424
+ for (const model of availableModels) {
425
+ availableCounts.set(model.provider, (availableCounts.get(model.provider) ?? 0) + 1);
426
+ }
427
+ const catalogCounts = new Map<string, number>();
428
+ for (const model of allModels) {
429
+ catalogCounts.set(model.provider, (catalogCounts.get(model.provider) ?? 0) + 1);
430
+ }
431
+
432
+ const unlocked = new Set<string>(availableCounts.keys());
433
+ const locked = new Set<string>();
434
+ if (!scoped) {
435
+ const authStorage = this.#registry.authStorage;
436
+ for (const provider of catalogCounts.keys()) {
437
+ if (!unlocked.has(provider) && !disabledProviders.has(provider)) {
438
+ locked.add(provider);
439
+ }
440
+ }
441
+ for (const provider of this.#registry.getDiscoverableProviders()) {
442
+ if (unlocked.has(provider) || disabledProviders.has(provider)) continue;
443
+ // Discoverable without stored auth: catalog-backed providers stay
444
+ // locked; keyless/custom endpoints (ollama, vllm, …) surface as
445
+ // selectable so discovery can populate them.
446
+ if (authStorage.hasAuth(provider) || !locked.has(provider)) {
447
+ // #2761: implicit local endpoints (optional: true) stay hidden
448
+ // until discovery actually reaches a server. "idle" means never
449
+ // probed; "unavailable" means the endpoint is unreachable; both
450
+ // would render a dead tab for a provider the user never
451
+ // configured. models.yml discovery providers (optional: false)
452
+ // and providers with stored auth keep their entry so
453
+ // misconfigurations stay visible and diagnosable.
454
+ if (!authStorage.hasAuth(provider)) {
455
+ const discovery = this.#registry.getProviderDiscoveryState(provider);
456
+ if (discovery?.optional && (discovery.status === "idle" || discovery.status === "unavailable")) {
457
+ continue;
458
+ }
459
+ }
460
+ locked.delete(provider);
461
+ unlocked.add(provider);
462
+ }
463
+ }
464
+ }
465
+
466
+ const oauthIds = new Set(getOAuthProviders().map(provider => provider.id));
467
+ const providerEntry = (providerId: string, isLocked: boolean): SidebarEntry => ({
468
+ id: `provider:${providerId}`,
469
+ kind: "provider",
470
+ label: providerId,
471
+ providerId,
472
+ locked: isLocked,
473
+ annotation: isLocked ? undefined : String(availableCounts.get(providerId) ?? 0),
474
+ oauth: oauthIds.has(providerId),
475
+ catalogCount: catalogCounts.get(providerId) ?? 0,
476
+ });
477
+
478
+ const visibleRoles = this.#visibleRoleIds();
479
+ let assignedCount = 0;
480
+ for (const role of visibleRoles) {
481
+ const assignment = this.#roles[role];
482
+ if (assignment && !assignment.autoSelected) assignedCount++;
483
+ }
484
+
485
+ // Roles leads the fixed section so downward hops from Recent head into
486
+ // model scopes instead of being captured by the roles view.
487
+ const fixed: SidebarEntry[] = [
488
+ {
489
+ id: "roles",
490
+ kind: "roles",
491
+ label: "Roles",
492
+ annotation: `${assignedCount}/${visibleRoles.length}`,
493
+ },
494
+ { id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) },
495
+ ];
496
+
497
+ this.#fixedEntries = fixed;
498
+ this.#unlockedProviderEntries = [...unlocked]
499
+ .sort((a, b) => a.localeCompare(b))
500
+ .map(provider => providerEntry(provider, false));
501
+ this.#lockedProviderEntries = [...locked]
502
+ .sort((a, b) => a.localeCompare(b))
503
+ .map(provider => providerEntry(provider, true));
504
+ this.#composeEntries();
505
+ }
506
+
507
+ /**
508
+ * Assemble `#entries` from the stored sections. While a search is active,
509
+ * providers with matches float to the top of the provider section (each
510
+ * group stays alphabetical) so the hop order, mouse hit-testing, and the
511
+ * paint all agree.
512
+ */
513
+ #composeEntries(): void {
514
+ const counts = this.#searchCounts;
515
+ let providers = this.#unlockedProviderEntries;
516
+ if (counts) {
517
+ providers = [...providers].sort((a, b) => {
518
+ const aMatched = (counts.get(a.providerId ?? "") ?? 0) > 0;
519
+ const bMatched = (counts.get(b.providerId ?? "") ?? 0) > 0;
520
+ if (aMatched !== bMatched) return aMatched ? -1 : 1;
521
+ return a.label.localeCompare(b.label);
522
+ });
523
+ }
524
+
525
+ const entries: SidebarEntry[] = [...this.#fixedEntries];
526
+ if (providers.length > 0) {
527
+ entries.push({ id: "sep:providers", kind: "separator", label: "" }, ...providers);
528
+ }
529
+ if (this.#lockedProviderEntries.length > 0) {
530
+ entries.push({ id: "sep:locked", kind: "separator", label: "" }, ...this.#lockedProviderEntries);
531
+ }
532
+
533
+ this.#entries = entries;
534
+ if (!entries.some(entry => entry.id === this.#activeEntryId)) {
535
+ this.#activeEntryId = "all";
536
+ this.#sidebarFollowActive = true;
537
+ }
538
+ }
539
+
540
+ /** Snapshot the focused entry and its row within the sidebar viewport. */
541
+ #captureSidebarAnchor(): SidebarAnchor {
542
+ const index = this.#entries.findIndex(entry => entry.id === this.#activeEntryId);
543
+ return { id: this.#activeEntryId, index, offset: index - this.#frame.sidebarScroll };
544
+ }
545
+
546
+ /**
547
+ * Reposition the sidebar after {@link #buildSidebar} rebuilt `#entries`. A
548
+ * surviving focused entry keeps its screen row; if it vanished (a keyless
549
+ * provider flipping back to hidden mid-navigation), focus falls to the
550
+ * nearest surviving entry instead of snapping to the top.
551
+ */
552
+ #restoreSidebarAnchor(anchor: SidebarAnchor): void {
553
+ if (anchor.index < 0) return;
554
+ const survivor = this.#entries.findIndex(entry => entry.id === anchor.id);
555
+ if (survivor >= 0) {
556
+ this.#frame.sidebarScroll = Math.max(0, survivor - anchor.offset);
557
+ return;
558
+ }
559
+ const replacement = this.#nearestNavigableEntry(anchor.index);
560
+ if (!replacement) return;
561
+ this.#activeEntryId = replacement.id;
562
+ this.#frame.sidebarScroll = Math.max(0, this.#entries.indexOf(replacement) - anchor.offset);
563
+ }
564
+
565
+ /** The selectable entry nearest `preferredIndex` in the current `#entries`. */
566
+ #nearestNavigableEntry(preferredIndex: number): SidebarEntry | undefined {
567
+ const entries = this.#entries;
568
+ if (entries.length === 0) return undefined;
569
+ const start = Math.max(0, Math.min(preferredIndex, entries.length - 1));
570
+ for (let radius = 0; radius < entries.length; radius++) {
571
+ for (const index of radius === 0 ? [start] : [start + radius, start - radius]) {
572
+ const entry = entries[index];
573
+ if (entry && !this.#isHopSkipped(entry)) return entry;
574
+ }
575
+ }
576
+ return undefined;
577
+ }
578
+
579
+ #activeEntry(): SidebarEntry {
580
+ return this.#entries.find(entry => entry.id === this.#activeEntryId) ?? this.#entries[0];
581
+ }
582
+
583
+ #setActiveEntry(id: string): void {
584
+ if (!this.#entries.some(entry => entry.id === id)) return;
585
+ this.#activeEntryId = id;
586
+ this.#sidebarFollowActive = true;
587
+ this.#applyScope();
588
+ const entry = this.#activeEntry();
589
+ // Hops must never steal arrow focus: landing on a scope keeps provider
590
+ // navigation active. Diving into the roles rows is explicit (Enter, →,
591
+ // or a click on the Roles entry).
592
+ this.#focus = "scope";
593
+ if (entry.kind === "provider" && !entry.locked) {
594
+ this.#scheduleProviderRefresh(entry.providerId ?? "");
595
+ }
596
+ this.#cancelScheduledRefreshesExcept(entry.kind === "provider" ? entry.providerId : undefined);
597
+ }
598
+
599
+ /** Push the active scope's items into the browser. */
600
+ #applyScope(): void {
601
+ const entry = this.#activeEntry();
602
+ switch (entry.kind) {
603
+ case "recent":
604
+ this.#browser.setShowProvider(true);
605
+ this.#browser.setItems([...this.#recentItems]);
606
+ break;
607
+ case "provider": {
608
+ if (entry.locked) {
609
+ // Assign-mode renders the browser regardless of scope; a locked
610
+ // provider contributes nothing selectable.
611
+ this.#browser.setItems([]);
612
+ break;
613
+ }
614
+ const providerId = entry.providerId;
615
+ this.#browser.setShowProvider(false);
616
+ this.#browser.setItems(this.#availableItems.filter(item => item.provider === providerId));
617
+ break;
618
+ }
619
+ case "roles":
620
+ this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
621
+ break;
622
+ default:
623
+ this.#browser.setShowProvider(true);
624
+ this.#browser.setItems([...this.#availableItems]);
625
+ break;
626
+ }
627
+ }
628
+
629
+ /**
630
+ * The configured `retry.fallbackChains` record with malformed keys/entries
631
+ * dropped: non-array chains and non-string selectors never reach the rows
632
+ * or chain editors, so an edit through the hub replaces them wholesale.
633
+ */
634
+ #fallbackChains(): Record<string, string[]> {
635
+ try {
636
+ const chains = this.#settings.fallbackChains;
637
+ if (!chains || typeof chains !== "object" || Array.isArray(chains)) return {};
638
+ const sanitized: Record<string, string[]> = {};
639
+ for (const key in chains) {
640
+ const chain = (chains as Record<string, unknown>)[key];
641
+ if (!Array.isArray(chain)) continue;
642
+ sanitized[key] = chain.filter((entry): entry is string => typeof entry === "string");
643
+ }
644
+ return sanitized;
645
+ } catch {
646
+ return {};
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Rebuild the Roles view rows: each visible role followed by its
652
+ * fallback-chain entries, then model-oriented chains (`provider/model-id`
653
+ * and `provider/*` keys) as headed groups.
654
+ */
655
+ #buildRolesRows(): void {
656
+ const rows: RolesRow[] = [];
657
+ const chains = this.#fallbackChains();
658
+ for (const role of this.#visibleRoleIds()) {
659
+ rows.push({ kind: "role", role });
660
+ const chain = chains[role] ?? [];
661
+ for (let i = 0; i < chain.length; i++) {
662
+ rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] });
663
+ }
664
+ }
665
+ rows.push({ kind: "newRole" });
666
+ rows.push({ kind: "separator" });
667
+ const modelKeys = Object.keys(chains)
668
+ .filter(key => key.includes("/"))
669
+ .sort();
670
+ for (const key of modelKeys) {
671
+ const chain = chains[key] ?? [];
672
+ rows.push({ kind: "chainKey", role: key });
673
+ for (let i = 0; i < chain.length; i++) {
674
+ rows.push({ kind: "fallback", role: key, chainIndex: i, selector: chain[i] });
675
+ }
676
+ }
677
+ rows.push({ kind: "newFallback" });
678
+ this.#rolesRows = rows;
679
+ }
680
+
681
+ /** Refresh roles + dependent state after a settings mutation (assign/unassign). */
682
+ #refreshAfterMutation(): void {
683
+ this.#syncFromRegistryState();
684
+ this.#tui.requestRender();
685
+ }
686
+
687
+ /** Re-sync after an asynchronous callback finishes mutating settings. */
688
+ refreshAfterExternalMutation(): void {
689
+ this.#refreshAfterMutation();
690
+ }
691
+
692
+ /**
693
+ * Recompute per-provider match counts for the active query. Providers
694
+ * without matches gray out and the scope hop skips them; a provider scope
695
+ * that just lost its last match falls back to All models so the results
696
+ * never silently vanish.
697
+ */
698
+ #onQueryChanged(query: string): void {
699
+ if (!query.trim()) {
700
+ this.#searchCounts = null;
701
+ this.#composeEntries();
702
+ return;
703
+ }
704
+ const matches = fuzzyFilter(this.#availableItems, query, modelSearchText);
705
+ const counts = new Map<string, number>();
706
+ for (const item of matches) {
707
+ counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
708
+ }
709
+ const recentSelectors = new Set(this.#recentItems.map(item => item.selector));
710
+ this.#recentSearchCount = matches.reduce(
711
+ (total, item) => total + (recentSelectors.has(item.selector) ? 1 : 0),
712
+ 0,
713
+ );
714
+ this.#searchTotal = matches.length;
715
+ this.#searchCounts = counts;
716
+ this.#composeEntries();
717
+ const entry = this.#activeEntry();
718
+ if (
719
+ this.#assigning === null &&
720
+ entry.kind === "provider" &&
721
+ (entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0)
722
+ ) {
723
+ this.#setActiveEntry("all");
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Entries the scope hop skips: separators always; while searching, also
729
+ * the Roles view (not a model scope), an empty Recent, locked providers,
730
+ * and providers without matches.
731
+ */
732
+ #isHopSkipped(entry: SidebarEntry): boolean {
733
+ if (entry.kind === "separator") return true;
734
+ if (!this.#searchCounts) return false;
735
+ if (entry.kind === "roles") return true;
736
+ if (entry.kind === "recent") return this.#recentSearchCount === 0;
737
+ if (entry.kind === "provider") {
738
+ if (entry.locked) return true;
739
+ return (this.#searchCounts.get(entry.providerId ?? "") ?? 0) === 0;
740
+ }
741
+ return false;
742
+ }
743
+
744
+ // ═══════════════════════════════════════════════════════════════════════
745
+ // Provider discovery refresh
746
+ // ═══════════════════════════════════════════════════════════════════════
747
+
748
+ #startRefreshSpinner(): void {
749
+ if (this.#refreshSpinnerInterval) return;
750
+ this.#refreshSpinnerInterval = setInterval(() => {
751
+ const frameCount = theme.spinnerFrames.length;
752
+ if (frameCount > 0) {
753
+ this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
754
+ }
755
+ this.#tui.requestRender();
756
+ }, 80);
757
+ }
758
+
759
+ #stopRefreshSpinnerIfIdle(): void {
760
+ if (this.#refreshingProviders.size > 0) return;
761
+ if (this.#refreshSpinnerInterval) {
762
+ clearInterval(this.#refreshSpinnerInterval);
763
+ this.#refreshSpinnerInterval = undefined;
764
+ }
765
+ this.#refreshSpinnerFrame = 0;
766
+ }
767
+
768
+ #setProviderRefreshing(providerId: string, refreshing: boolean): void {
769
+ if (refreshing) {
770
+ this.#refreshingProviders.add(providerId);
771
+ this.#startRefreshSpinner();
772
+ } else {
773
+ this.#refreshingProviders.delete(providerId);
774
+ this.#stopRefreshSpinnerIfIdle();
775
+ }
776
+ }
777
+
778
+ #cancelScheduledRefreshesExcept(keepProviderId?: string): void {
779
+ // Hover debounce only. An explicit F5 queued behind an in-flight catalog
780
+ // fetch must still re-mint credentials after that fetch settles, even if
781
+ // the user has moved to All models or another provider.
782
+ for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
783
+ if (providerId === keepProviderId) continue;
784
+ clearTimeout(timer);
785
+ this.#scheduledProviderRefreshes.delete(providerId);
786
+ this.#setProviderRefreshing(providerId, false);
787
+ }
788
+ }
789
+
790
+ #scheduleProviderRefresh(providerId: string, options?: { force?: boolean }): void {
791
+ if (this.#scopedModels.length > 0 || !providerId) return;
792
+ const force = options?.force === true;
793
+ if (force) {
794
+ const pending = this.#scheduledProviderRefreshes.get(providerId);
795
+ if (pending) {
796
+ // Selection already queued a catalog-only fetch. F5 upgrades it
797
+ // instead of returning at the pending-guard and dropping force.
798
+ clearTimeout(pending);
799
+ this.#scheduledProviderRefreshes.delete(providerId);
800
+ autoRefreshedProviders.add(providerId);
801
+ void this.#refreshProviderInBackground(providerId, true);
802
+ return;
803
+ }
804
+ if (this.#refreshingProviders.has(providerId)) {
805
+ this.#pendingCredentialRefreshProviders.add(providerId);
806
+ return;
807
+ }
808
+ } else if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) {
809
+ return;
810
+ }
811
+ // Hovering a provider must not re-fetch on every visit: auto-refresh runs
812
+ // at most once per provider for the process lifetime. F5 forces a re-fetch.
813
+ if (!force && autoRefreshedProviders.has(providerId)) return;
814
+ this.#setProviderRefreshing(providerId, true);
815
+ const timer = setTimeout(() => {
816
+ // Consume the once-guard only when the fetch actually starts: hopping
817
+ // through a provider cancels the debounce and must not burn its slot.
818
+ autoRefreshedProviders.add(providerId);
819
+ this.#scheduledProviderRefreshes.delete(providerId);
820
+ void this.#refreshProviderInBackground(providerId, force);
821
+ }, PROVIDER_REFRESH_DEBOUNCE_MS);
822
+ this.#scheduledProviderRefreshes.set(providerId, timer);
823
+ }
824
+
825
+ async #refreshProviderInBackground(providerId: string, refreshCommandCredentials = false): Promise<void> {
826
+ try {
827
+ if (refreshCommandCredentials) {
828
+ await this.#registry.refreshProvider(providerId, "online", { refreshCommandCredentials: true });
829
+ } else {
830
+ await this.#registry.refreshProvider(providerId, "online");
831
+ }
832
+ // The provider refresh already updated the registry snapshot;
833
+ // re-reading it here stays purely in-memory.
834
+ this.#syncFromRegistryState();
835
+ } catch (error) {
836
+ this.#configError = error instanceof Error ? error.message : String(error);
837
+ } finally {
838
+ this.#setProviderRefreshing(providerId, false);
839
+ if (!this.#disposed && this.#pendingCredentialRefreshProviders.delete(providerId)) {
840
+ this.#setProviderRefreshing(providerId, true);
841
+ autoRefreshedProviders.add(providerId);
842
+ void this.#refreshProviderInBackground(providerId, true);
843
+ }
844
+ this.#tui.requestRender();
845
+ }
846
+ }
847
+
848
+ #formatDiscoveryAge(fetchedAt: number | undefined): string | undefined {
849
+ if (!fetchedAt) return undefined;
850
+ const ageMs = Math.max(0, Date.now() - fetchedAt);
851
+ if (ageMs < 60_000) return "less than a minute ago";
852
+ return `${Math.round(ageMs / 60_000)}m ago`;
853
+ }
854
+
855
+ #emptyStateMessage(): string | undefined {
856
+ if (this.#configError) return ` ${this.#configError}`;
857
+ const entry = this.#activeEntry();
858
+ if (entry.kind === "recent") return " No recently used models yet";
859
+ if (entry.kind !== "provider" || entry.locked) return undefined;
860
+ if (this.#browser.query.trim()) {
861
+ return ` No matching models in ${entry.label}. Switch to All models to search every provider.`;
862
+ }
863
+ const providerId = entry.providerId ?? "";
864
+ const state = this.#registry.getProviderDiscoveryState(providerId);
865
+ if (!state) return undefined;
866
+ const age = this.#formatDiscoveryAge(state.fetchedAt);
867
+ switch (state.status) {
868
+ case "cached":
869
+ return age
870
+ ? ` Using cached model list from ${age}. Live refresh is still pending.`
871
+ : " Using cached model list. Live refresh is still pending.";
872
+ case "unavailable": {
873
+ const httpMatch = state.error?.match(/^HTTP (\d+) from (.+)$/);
874
+ if (httpMatch?.[1] === "404") {
875
+ return ` Discovery endpoint ${httpMatch[2]} returned 404. Point baseUrl at the host that serves /models (usually .../v1).`;
876
+ }
877
+ if (state.error) return ` Discovery failed: ${state.error}`;
878
+ return age ? ` Provider unavailable. Using cached model list from ${age}.` : " Provider unavailable.";
879
+ }
880
+ case "unauthenticated":
881
+ return " Provider requires authentication before models can be discovered.";
882
+ case "idle":
883
+ return " Provider has not been refreshed yet.";
884
+ case "empty":
885
+ return " Discovery succeeded but returned 0 models. Check that /models returns { data: [{ id }] }.";
886
+ case "ok":
887
+ return undefined;
888
+ }
889
+ }
890
+
891
+ // ═══════════════════════════════════════════════════════════════════════
892
+ // Assignment flow
893
+ // ═══════════════════════════════════════════════════════════════════════
894
+
895
+ #activateItem(item: ModelBrowserItem): void {
896
+ if (this.#assigning) {
897
+ const target = this.#assigning;
898
+ this.#assigning = null;
899
+ if (target.kind === "role") {
900
+ this.#assignRole(item, target.role, true);
901
+ } else if (target.kind === "fallbackKey") {
902
+ this.#openFallbackKeyStrip(item);
903
+ } else {
904
+ this.#commitFallback(item, target);
905
+ }
906
+ return;
907
+ }
908
+ this.#openRoleStrip(item);
909
+ }
910
+
911
+ #roleForScope(role: string, scope: ModelRoleSelectionScope): ResolvedModelRoleValue {
912
+ const roleValue =
913
+ scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role);
914
+ const allModels =
915
+ this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll();
916
+ const roleLookup: ModelRoleLookup = {
917
+ getModelRole: scopedRole =>
918
+ scope === "project"
919
+ ? (this.#settings.getProjectModelRole(scopedRole) ?? this.#settings.getGlobalModelRole(scopedRole))
920
+ : this.#settings.getGlobalModelRole(scopedRole),
921
+ };
922
+ return this.#settings.resolveRoleValue(roleValue, allModels, roleLookup);
923
+ }
924
+
925
+ #thinkingLevelForScope(role: string, scope: ModelRoleSelectionScope): ConfiguredThinkingLevel {
926
+ const resolved = this.#roleForScope(role, scope);
927
+ return resolved.explicitThinkingLevel ? (resolved.thinkingLevel ?? ThinkingLevel.Inherit) : ThinkingLevel.Inherit;
928
+ }
929
+ #finishAssignment(result: void | boolean | Promise<void | boolean>, onSuccess: () => void): void {
930
+ if (!(result instanceof Promise)) {
931
+ if (result !== false) onSuccess();
932
+ else this.#tui.requestRender();
933
+ return;
934
+ }
935
+ this.#assignmentPending = true;
936
+ this.#tui.requestRender();
937
+ void result.then(
938
+ applied => {
939
+ this.#assignmentPending = false;
940
+ if (this.#disposed) return;
941
+ if (applied !== false) onSuccess();
942
+ else this.#tui.requestRender();
943
+ },
944
+ () => {
945
+ this.#assignmentPending = false;
946
+ if (!this.#disposed) this.#tui.requestRender();
947
+ },
948
+ );
949
+ }
950
+
951
+ /** Persist `role → item`, preserving a still-supported thinking level, then open the thinking strip. */
952
+ #assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void {
953
+ if (this.#settings.modelRoleStorage === "project" && scope === undefined) {
954
+ this.#openScopeStrip(item, role, returnToRoles);
955
+ return;
956
+ }
957
+
958
+ const current = this.#roles[role];
959
+ let level: ConfiguredThinkingLevel = ThinkingLevel.Inherit;
960
+ if (this.#settings.modelRoleStorage === "project" && scope !== undefined) {
961
+ level = this.#thinkingLevelForScope(role, scope);
962
+ } else if (current && !current.autoSelected) {
963
+ level = current.thinkingLevel;
964
+ }
965
+ const supported = this.#thinkingOptionsFor(item.model);
966
+ if (!supported.includes(level)) level = ThinkingLevel.Inherit;
967
+ const result = this.#callbacks.onAssign(item.model, role, level, item.selector, scope);
968
+ this.#finishAssignment(result, () => {
969
+ this.#refreshAfterMutation();
970
+ this.#openThinkingStrip(item, role, returnToRoles, scope, level);
971
+ });
972
+ }
973
+
974
+ #unassignRole(role: string): void {
975
+ const assignment = this.#roles[role];
976
+ if (!assignment || assignment.autoSelected) return;
977
+ if (this.#settings.modelRoleStorage === "project") {
978
+ const source = this.#settings.getModelRoleSource(role);
979
+ this.#callbacks.onUnassign(role, source === "default" ? undefined : source);
980
+ } else {
981
+ this.#callbacks.onUnassign(role);
982
+ }
983
+ this.#refreshAfterMutation();
984
+ }
985
+
986
+ #thinkingOptionsFor(model: Model): ConfiguredThinkingLevel[] {
987
+ return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
988
+ }
989
+
990
+ #openRoleStrip(item: ModelBrowserItem): void {
991
+ const chips: StripChip[] = [];
992
+ const scopedStorage = this.#settings.modelRoleStorage === "project";
993
+ const scopes: readonly ModelRoleSelectionScope[] = scopedStorage ? ["project", "global"] : ["global"];
994
+ for (const role of this.#visibleRoleIds()) {
995
+ const info = this.#settings.getRoleInfo(role);
996
+ const assignment = this.#roles[role];
997
+ for (const scope of scopes) {
998
+ const scopedModel = scopedStorage
999
+ ? this.#roleForScope(role, scope).model
1000
+ : assignment && !assignment.autoSelected
1001
+ ? assignment.model
1002
+ : undefined;
1003
+ const assignedHere =
1004
+ !!scopedModel && scopedModel.provider === item.model.provider && scopedModel.id === item.model.id;
1005
+ const roleLabel = (info.tag ?? info.name ?? role).toLowerCase();
1006
+ const label = scopedStorage ? `${scope} ${roleLabel}` : roleLabel;
1007
+ chips.push({
1008
+ label,
1009
+ styled: assignedHere
1010
+ ? // Separator required: under the `nerd` preset this glyph is a
1011
+ // two-cell-wide PUA icon that `visibleWidth` counts as one, so
1012
+ // without it the icon overhangs and eats `label`'s first char.
1013
+ theme.fg(info.color ?? "muted", `${theme.status.enabled} ${label}`) +
1014
+ theme.fg("dim", ` ${theme.status.success}`)
1015
+ : theme.fg(info.color ?? "muted", label),
1016
+ role,
1017
+ scope,
1018
+ action: assignedHere ? "unassign" : "assign",
1019
+ });
1020
+ }
1021
+ }
1022
+ chips.push({
1023
+ label: `fallbacks:${item.model.id}`,
1024
+ styled: theme.fg("muted", `fallbacks:${item.model.id}`),
1025
+ action: "fallbackModel",
1026
+ });
1027
+ chips.push({
1028
+ label: `fallbacks:${item.model.provider}/*`,
1029
+ styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`),
1030
+ action: "fallbackProvider",
1031
+ });
1032
+ chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
1033
+ this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
1034
+ }
1035
+
1036
+ #openScopeStrip(item: ModelBrowserItem, role: string, returnToRoles: boolean): void {
1037
+ const chips: StripChip[] = [
1038
+ { label: "project", styled: theme.fg("accent", "project"), action: "scope", scope: "project" },
1039
+ { label: "global", styled: theme.fg("muted", "global"), action: "scope", scope: "global" },
1040
+ ];
1041
+ this.#strip = { kind: "scope", item, role, chips, index: 0, returnToRoles };
1042
+ }
1043
+
1044
+ #openThinkingStrip(
1045
+ item: ModelBrowserItem,
1046
+ role: string,
1047
+ returnToRoles: boolean,
1048
+ scope?: ModelRoleSelectionScope,
1049
+ committedLevel?: ConfiguredThinkingLevel,
1050
+ ): void {
1051
+ const options = this.#thinkingOptionsFor(item.model);
1052
+ const current =
1053
+ committedLevel ??
1054
+ (this.#settings.modelRoleStorage === "project" && scope !== undefined
1055
+ ? this.#thinkingLevelForScope(role, scope)
1056
+ : (this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit));
1057
+ const chips = this.#thinkingChips(options);
1058
+ const preselect = options.indexOf(current);
1059
+ this.#strip = {
1060
+ kind: "thinking",
1061
+ item,
1062
+ role,
1063
+ scope,
1064
+ chips,
1065
+ index: preselect >= 0 ? preselect : 0,
1066
+ returnToRoles,
1067
+ initialThinkingLevel: current,
1068
+ };
1069
+ }
1070
+
1071
+ /** Build the footer chips for a thinking strip from its level options. */
1072
+ #thinkingChips(options: ConfiguredThinkingLevel[]): StripChip[] {
1073
+ return options.map(level => {
1074
+ const label = getConfiguredThinkingLevelMetadata(level).label;
1075
+ const glyph = thinkingLevelGlyph(level, theme);
1076
+ return {
1077
+ label,
1078
+ styled: glyph ? `${theme.fg("accent", glyph)} ${label}` : label,
1079
+ action: "thinking",
1080
+ thinkingLevel: level,
1081
+ };
1082
+ });
1083
+ }
1084
+
1085
+ /**
1086
+ * Fallback-entry model lookup through the registry — the same
1087
+ * case-insensitive, alias-aware resolution the runtime uses, so
1088
+ * `OpenAI/GPT-5.5` edits the model it runs. Covers locked providers too:
1089
+ * effort support is a catalog fact, not an auth fact, and the row already
1090
+ * exists, so the strip changes no scope.
1091
+ */
1092
+ #findFallbackModel(provider: string, id: string): ModelBrowserItem | undefined {
1093
+ const model = this.#registry.find(provider, id);
1094
+ if (!model) return undefined;
1095
+ return { provider: model.provider, id: model.id, model, selector: `${model.provider}/${model.id}` };
1096
+ }
1097
+
1098
+ /**
1099
+ * Split a fallback-chain entry into its model base, explicit effort, and
1100
+ * `@upstream` routing. An exact literal id wins over routing
1101
+ * (`google-vertex/claude-opus-4-8@default` is a real model, not a route —
1102
+ * mirroring the runtime's exact-first precedence); otherwise the routing
1103
+ * slug is kept verbatim so saves can re-attach it (`openrouter/id@fireworks`
1104
+ * looks up `openrouter/id` but persists with the route intact).
1105
+ */
1106
+ #parseFallbackEntry(raw: string):
1107
+ | {
1108
+ provider: string;
1109
+ id: string;
1110
+ thinkingLevel?: ConfiguredThinkingLevel;
1111
+ upstream: string | undefined;
1112
+ }
1113
+ | undefined {
1114
+ const trimmed = raw.trim();
1115
+ const parse = (pattern: string) =>
1116
+ parseModelString(pattern, {
1117
+ allowMaxSuffix: true,
1118
+ allowAutoAlias: true,
1119
+ isLiteralModelId: (provider, id) => this.#findFallbackModel(provider, id) !== undefined,
1120
+ });
1121
+ const literal = parse(trimmed);
1122
+ if (literal && this.#findFallbackModel(literal.provider, literal.id)) return { ...literal, upstream: undefined };
1123
+ const routing = splitUpstreamRouting(trimmed);
1124
+ if (!routing) {
1125
+ if (!literal) return undefined;
1126
+ return { ...literal, upstream: undefined };
1127
+ }
1128
+ const parsed = parse(routing.base.trim());
1129
+ if (!parsed) return undefined;
1130
+ return { ...parsed, upstream: routing.upstream };
1131
+ }
1132
+
1133
+ /**
1134
+ * Resolve a fallback-chain entry to its browser item, explicit effort, and
1135
+ * routing. Undefined when the row is inert: `provider/*` wildcards (always
1136
+ * inherit) or models known neither live nor from the catalog.
1137
+ */
1138
+ #resolveFallbackEntry(
1139
+ role: string,
1140
+ index: number,
1141
+ ):
1142
+ | { item: ModelBrowserItem; thinkingLevel: ConfiguredThinkingLevel | undefined; upstream: string | undefined }
1143
+ | undefined {
1144
+ const raw = this.#fallbackChains()[role]?.[index];
1145
+ if (!raw || raw.endsWith("/*")) return undefined;
1146
+ const parsed = this.#parseFallbackEntry(raw);
1147
+ if (!parsed) return undefined;
1148
+ const item = this.#findFallbackModel(parsed.provider, parsed.id);
1149
+ if (!item) return undefined;
1150
+ return { item, thinkingLevel: parsed.thinkingLevel, upstream: parsed.upstream };
1151
+ }
1152
+
1153
+ /**
1154
+ * Open the thinking strip for a fallback-chain entry (`t` on a `↳` row).
1155
+ * Wildcard entries (`provider/*`) always inherit by design, so `t` is inert on them.
1156
+ */
1157
+ #openFallbackThinkingStrip(row: { role: string; chainIndex: number }): void {
1158
+ const resolved = this.#resolveFallbackEntry(row.role, row.chainIndex);
1159
+ if (!resolved) return;
1160
+ const { item } = resolved;
1161
+ // No `auto` chip: a hand-written `:auto` suffix collapses to inherit at
1162
+ // apply time, so offering it would promise per-prompt classification the
1163
+ // fallback never performs. A primary running `auto` is inherited anyway
1164
+ // through the bare form.
1165
+ const options: ConfiguredThinkingLevel[] = [
1166
+ ThinkingLevel.Inherit,
1167
+ ThinkingLevel.Off,
1168
+ ...getSupportedEfforts(item.model),
1169
+ ];
1170
+ const current =
1171
+ resolved.thinkingLevel === undefined || resolved.thinkingLevel === AUTO_THINKING
1172
+ ? ThinkingLevel.Inherit
1173
+ : resolved.thinkingLevel;
1174
+ const chips = this.#thinkingChips(options);
1175
+ this.#strip = {
1176
+ kind: "thinking",
1177
+ item,
1178
+ role: row.role,
1179
+ fallbackIndex: row.chainIndex,
1180
+ chips,
1181
+ index: Math.max(0, options.indexOf(current)),
1182
+ returnToRoles: true,
1183
+ };
1184
+ }
1185
+
1186
+ /** Persist a fallback entry's thinking choice: an explicit effort is suffixed, inherit is stored bare. */
1187
+ #setFallbackThinking(role: string, index: number, level: ConfiguredThinkingLevel): void {
1188
+ const chain = [...(this.#fallbackChains()[role] ?? [])];
1189
+ if (index >= chain.length) return;
1190
+ const resolved = this.#resolveFallbackEntry(role, index);
1191
+ if (!resolved) return;
1192
+ // Save the registry-canonical spelling (`OpenAI/GPT-5.5` persists as
1193
+ // `openai/gpt-5.5:off`), re-attaching `@upstream` routing ahead of the
1194
+ // effort suffix (`id@up:low` is the canonical order).
1195
+ const base = `${resolved.item.provider}/${resolved.item.id}`;
1196
+ const routed = resolved.upstream ? `${base}@${resolved.upstream}` : base;
1197
+ const next = formatModelSelectorValue(routed, level);
1198
+ chain[index] = next;
1199
+ for (let i = chain.length - 1; i >= 0; i--) {
1200
+ if (i !== index && chain[i] === next) chain.splice(i, 1);
1201
+ }
1202
+ this.#setFallbackChain(role, chain);
1203
+ const rowIndex = this.#rolesRows.findIndex(
1204
+ row => row.kind === "fallback" && row.role === role && row.selector === next,
1205
+ );
1206
+ if (rowIndex >= 0) this.#roleIndex = rowIndex;
1207
+ }
1208
+
1209
+ #closeStrip(): void {
1210
+ const strip = this.#strip;
1211
+ this.#strip = null;
1212
+ this.#frame.chipRanges = [];
1213
+ if ((strip?.kind === "scope" || strip?.kind === "thinking") && strip.returnToRoles) {
1214
+ this.#setActiveEntry("roles");
1215
+ this.#focus = "list";
1216
+ }
1217
+ }
1218
+
1219
+ #activateStripChip(): void {
1220
+ const strip = this.#strip;
1221
+ if (!strip || strip.kind === "roleName") return;
1222
+ const chip = strip.chips[strip.index];
1223
+ if (!chip) return;
1224
+ switch (chip.action) {
1225
+ case "assign":
1226
+ if (chip.role) {
1227
+ this.#strip = null;
1228
+ this.#assignRole(strip.item, chip.role, false, chip.scope);
1229
+ }
1230
+ return;
1231
+ case "unassign":
1232
+ if (chip.role) {
1233
+ if (this.#settings.modelRoleStorage === "project") {
1234
+ this.#callbacks.onUnassign(chip.role, chip.scope);
1235
+ } else {
1236
+ this.#callbacks.onUnassign(chip.role);
1237
+ }
1238
+ this.#refreshAfterMutation();
1239
+ }
1240
+ this.#closeStrip();
1241
+ return;
1242
+ case "fallback":
1243
+ this.#appendFallback(strip.item, "default");
1244
+ this.#closeStrip();
1245
+ return;
1246
+ case "fallbackModel":
1247
+ this.#closeStrip();
1248
+ this.#startAssignFallback(strip.item.selector, null);
1249
+ return;
1250
+ case "fallbackProvider":
1251
+ this.#closeStrip();
1252
+ this.#startAssignFallback(`${strip.item.model.provider}/*`, null);
1253
+ return;
1254
+ case "scope":
1255
+ if (strip.role && chip.scope) {
1256
+ this.#strip = null;
1257
+ this.#assignRole(strip.item, strip.role, strip.returnToRoles, chip.scope);
1258
+ }
1259
+ return;
1260
+ case "thinking": {
1261
+ if (strip.role && chip.thinkingLevel !== undefined && strip.fallbackIndex !== undefined) {
1262
+ this.#setFallbackThinking(strip.role, strip.fallbackIndex, chip.thinkingLevel);
1263
+ this.#strip = null;
1264
+ this.#frame.chipRanges = [];
1265
+ return;
1266
+ }
1267
+ // The preselected level is confirmation, not a force-reapply action;
1268
+ // only a changed level should call setModel() again.
1269
+ const changed = chip.thinkingLevel !== strip.initialThinkingLevel;
1270
+ if (strip.role && chip.thinkingLevel !== undefined && changed) {
1271
+ const result = this.#callbacks.onAssign(
1272
+ strip.item.model,
1273
+ strip.role,
1274
+ chip.thinkingLevel,
1275
+ strip.item.selector,
1276
+ strip.scope,
1277
+ );
1278
+ this.#closeStrip();
1279
+ this.#finishAssignment(result, () => this.#refreshAfterMutation());
1280
+ } else {
1281
+ this.#closeStrip();
1282
+ }
1283
+ return;
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ /** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
1289
+ #startAssign(role: string): void {
1290
+ this.#assigning = { kind: "role", role };
1291
+ this.#focus = "scope";
1292
+ this.#browser.setShowProvider(true);
1293
+ this.#browser.setItems([...this.#availableItems]);
1294
+ this.#browser.setQuery("");
1295
+ const current = this.#roles[role];
1296
+ if (current) {
1297
+ this.#browser.selectSelector(`${current.model.provider}/${current.model.id}`);
1298
+ }
1299
+ }
1300
+
1301
+ /** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */
1302
+ #startAssignFallback(role: string, index: number | null): void {
1303
+ this.#assigning = { kind: "fallback", role, index };
1304
+ this.#focus = "scope";
1305
+ this.#browser.setShowProvider(true);
1306
+ this.#browser.setItems([...this.#availableItems]);
1307
+ this.#browser.setQuery("");
1308
+ if (index !== null) {
1309
+ const selector = this.#fallbackChains()[role]?.[index];
1310
+ // Suffixed entries (`provider/id:low`) carry effort the browser rows
1311
+ // don't show; match on the base so the current model preselects.
1312
+ if (selector) {
1313
+ const parsed = this.#parseFallbackEntry(selector);
1314
+ this.#browser.selectSelector(parsed ? `${parsed.provider}/${parsed.id}` : selector);
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ /** Browse the catalog for the primary model a brand-new fallback chain protects. */
1320
+ #startAssignFallbackKey(): void {
1321
+ this.#assigning = { kind: "fallbackKey" };
1322
+ this.#focus = "scope";
1323
+ this.#browser.setShowProvider(true);
1324
+ this.#browser.setItems([...this.#availableItems]);
1325
+ this.#browser.setQuery("");
1326
+ }
1327
+
1328
+ /** Second step of "+ New fallback…": key the chain by the picked model or its whole provider. */
1329
+ #openFallbackKeyStrip(item: ModelBrowserItem): void {
1330
+ const chips: StripChip[] = [
1331
+ {
1332
+ label: `for ${item.selector}`,
1333
+ styled: theme.fg("muted", `for ${item.selector}`),
1334
+ action: "fallbackModel",
1335
+ },
1336
+ {
1337
+ label: `for ${item.model.provider}/*`,
1338
+ styled: theme.fg("muted", `for ${item.model.provider}/*`),
1339
+ action: "fallbackProvider",
1340
+ },
1341
+ ];
1342
+ this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
1343
+ }
1344
+
1345
+ /** Write the picked model into the target chain slot, dedupe, and land back on its Roles row. */
1346
+ #commitFallback(item: ModelBrowserItem, target: { role: string; index: number | null }): void {
1347
+ const chain = [...(this.#fallbackChains()[target.role] ?? [])];
1348
+ // New picks are stored bare, i.e. inherit-the-primary; `t` on the row specializes the effort.
1349
+ const selector = item.selector;
1350
+ if (target.index !== null && target.index < chain.length) {
1351
+ chain[target.index] = selector;
1352
+ for (let i = chain.length - 1; i >= 0; i--) {
1353
+ if (i !== target.index && chain[i] === selector) chain.splice(i, 1);
1354
+ }
1355
+ } else if (!chain.includes(selector)) {
1356
+ chain.push(selector);
1357
+ }
1358
+ this.#setFallbackChain(target.role, chain);
1359
+ this.#browser.setQuery("");
1360
+ this.#setActiveEntry("roles");
1361
+ this.#focus = "list";
1362
+ const rowIndex = this.#rolesRows.findIndex(
1363
+ row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
1364
+ );
1365
+ if (rowIndex >= 0) this.#roleIndex = rowIndex;
1366
+ }
1367
+
1368
+ /** Persist `role`'s chain through the host callback and rebuild dependent state. */
1369
+ #setFallbackChain(role: string, chain: string[]): void {
1370
+ this.#callbacks.onFallbackChainChange?.(role, chain);
1371
+ this.#refreshAfterMutation();
1372
+ }
1373
+
1374
+ /** Append `item` to `role`'s fallback chain (no-op when already present). */
1375
+ #appendFallback(item: ModelBrowserItem, role: string): void {
1376
+ const chain = [...(this.#fallbackChains()[role] ?? [])];
1377
+ if (chain.includes(item.selector)) return;
1378
+ chain.push(item.selector);
1379
+ this.#setFallbackChain(role, chain);
1380
+ }
1381
+
1382
+ /** Remove one chain entry; the cursor stays on the nearest surviving row. */
1383
+ #removeFallback(row: { role: string; chainIndex: number }): void {
1384
+ const chain = [...(this.#fallbackChains()[row.role] ?? [])];
1385
+ if (row.chainIndex >= chain.length) return;
1386
+ chain.splice(row.chainIndex, 1);
1387
+ this.#setFallbackChain(row.role, chain);
1388
+ this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRows.length - 1));
1389
+ }
1390
+
1391
+ /** Move a chain entry one slot earlier/later; the cursor follows the moved entry. */
1392
+ #moveFallback(row: { role: string; chainIndex: number }, delta: -1 | 1): void {
1393
+ const chain = [...(this.#fallbackChains()[row.role] ?? [])];
1394
+ const target = row.chainIndex + delta;
1395
+ if (row.chainIndex >= chain.length || target < 0 || target >= chain.length) return;
1396
+ [chain[row.chainIndex], chain[target]] = [chain[target], chain[row.chainIndex]];
1397
+ this.#setFallbackChain(row.role, chain);
1398
+ this.#roleIndex += delta;
1399
+ }
1400
+
1401
+ #cancelAssign(): void {
1402
+ this.#assigning = null;
1403
+ this.#browser.setQuery("");
1404
+ this.#setActiveEntry("roles");
1405
+ this.#focus = "list";
1406
+ }
1407
+
1408
+ // ═══════════════════════════════════════════════════════════════════════
1409
+ // Quick-switch cycle (ctrl+p) editing
1410
+ // ═══════════════════════════════════════════════════════════════════════
1411
+
1412
+ #cycleOrder(): string[] {
1413
+ try {
1414
+ return [...this.#settings.cycleOrder];
1415
+ } catch {
1416
+ return [];
1417
+ }
1418
+ }
1419
+
1420
+ /** Toggle `role`'s membership in the quick-switch cycle (appended at the end). */
1421
+ #toggleCycleMembership(role: string): void {
1422
+ const order = this.#cycleOrder();
1423
+ const index = order.indexOf(role);
1424
+ if (index >= 0) {
1425
+ order.splice(index, 1);
1426
+ } else {
1427
+ order.push(role);
1428
+ }
1429
+ this.#callbacks.onCycleOrderChange?.(order);
1430
+ this.#refreshAfterMutation();
1431
+ }
1432
+
1433
+ /** Move `role` one slot earlier/later within the cycle order. */
1434
+ #moveCycleMembership(role: string, delta: -1 | 1): void {
1435
+ const order = this.#cycleOrder();
1436
+ const index = order.indexOf(role);
1437
+ const target = index + delta;
1438
+ if (index < 0 || target < 0 || target >= order.length) return;
1439
+ [order[index], order[target]] = [order[target], order[index]];
1440
+ this.#callbacks.onCycleOrderChange?.(order);
1441
+ this.#refreshAfterMutation();
1442
+ }
1443
+
1444
+ /** Open the footer name input that creates a new custom role. */
1445
+ #openRoleNameStrip(): void {
1446
+ this.#strip = { kind: "roleName", input: new Input() };
1447
+ }
1448
+
1449
+ /** Validate and commit the new-role name: jump straight into assigning it. */
1450
+ #submitRoleName(): void {
1451
+ const strip = this.#strip;
1452
+ if (strip?.kind !== "roleName") return;
1453
+ const name = strip.input.getValue().trim();
1454
+ if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
1455
+ if (this.#visibleRoleIds().includes(name)) return;
1456
+ this.#strip = null;
1457
+ this.#frame.chipRanges = [];
1458
+ this.#startAssign(name);
1459
+ }
1460
+
1461
+ // ═══════════════════════════════════════════════════════════════════════
1462
+ // Input
1463
+ // ═══════════════════════════════════════════════════════════════════════
1464
+
1465
+ handleInput(data: string): void {
1466
+ if (this.#assignmentPending) {
1467
+ if (matchesSelectCancel(data)) this.#callbacks.onCancel();
1468
+ return;
1469
+ }
1470
+ if (data.startsWith("\x1b[<")) {
1471
+ routeSgrMouseInput(data, event => this.#routeMouseEvent(event));
1472
+ return;
1473
+ }
1474
+
1475
+ if (this.#strip) {
1476
+ this.#handleStripInput(data);
1477
+ return;
1478
+ }
1479
+
1480
+ if (matchesSelectCancel(data)) {
1481
+ if (this.#assigning !== null) {
1482
+ this.#cancelAssign();
1483
+ return;
1484
+ }
1485
+ const entry = this.#activeEntry();
1486
+ if (this.#isBrowserView(entry) && this.#browser.query.length > 0) {
1487
+ this.#browser.handleCancel();
1488
+ return;
1489
+ }
1490
+ this.#callbacks.onCancel();
1491
+ return;
1492
+ }
1493
+
1494
+ const entry = this.#activeEntry();
1495
+ const rolesView = entry.kind === "roles" && this.#assigning === null;
1496
+ const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null;
1497
+
1498
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
1499
+ this.#focus = this.#focus === "scope" ? "list" : "scope";
1500
+ return;
1501
+ }
1502
+ if (matchesKey(data, "f5")) {
1503
+ if (entry.kind === "provider" && !entry.locked) {
1504
+ this.#scheduleProviderRefresh(entry.providerId ?? "", { force: true });
1505
+ }
1506
+ return;
1507
+ }
1508
+
1509
+ // ←/→ are spatial pane switches: the sidebar sits left of the rows.
1510
+ // They never reach the search caret — fuzzy queries don't need one.
1511
+ if (matchesKey(data, "left")) {
1512
+ this.#focus = "scope";
1513
+ return;
1514
+ }
1515
+ if (matchesKey(data, "right")) {
1516
+ // Only views with rows can take list focus (not the locked pane).
1517
+ if (rolesView || this.#isBrowserView(entry)) {
1518
+ this.#focus = "list";
1519
+ }
1520
+ return;
1521
+ }
1522
+
1523
+ // Arrow ownership: scope mode hops the sidebar; list mode navigates rows.
1524
+ if (this.#focus === "scope") {
1525
+ if (matchesSelectUp(data)) {
1526
+ this.#moveSidebar(-1);
1527
+ return;
1528
+ }
1529
+ if (matchesSelectDown(data)) {
1530
+ this.#moveSidebar(1);
1531
+ return;
1532
+ }
1533
+ }
1534
+
1535
+ if (rolesView) {
1536
+ const printable = extractPrintableText(data);
1537
+ if (this.#focus === "scope" && printable !== undefined && printable.trim().length > 0) {
1538
+ this.#setActiveEntry("all");
1539
+ this.#focus = "list";
1540
+ this.#browser.handleInput(data);
1541
+ return;
1542
+ }
1543
+ this.#handleRolesViewInput(data);
1544
+ return;
1545
+ }
1546
+ if (lockedView) {
1547
+ const printable = extractPrintableText(data);
1548
+ if (printable !== undefined && printable.trim().length > 0) {
1549
+ this.#setActiveEntry("all");
1550
+ this.#focus = "list";
1551
+ this.#browser.handleInput(data);
1552
+ return;
1553
+ }
1554
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1555
+ this.#requestLogin(entry);
1556
+ }
1557
+ return;
1558
+ }
1559
+
1560
+ const beforeQuery = this.#browser.query;
1561
+ const isPrintable = extractPrintableText(data) !== undefined;
1562
+ this.#browser.handleInput(data);
1563
+ if (isPrintable || this.#browser.query !== beforeQuery) {
1564
+ this.#focus = "list";
1565
+ }
1566
+ }
1567
+
1568
+ #isBrowserView(entry: SidebarEntry): boolean {
1569
+ if (this.#assigning !== null) return true;
1570
+ return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
1571
+ }
1572
+
1573
+ #handleStripInput(data: string): void {
1574
+ const strip = this.#strip;
1575
+ if (!strip) return;
1576
+ if (matchesSelectCancel(data)) {
1577
+ this.#closeStrip();
1578
+ return;
1579
+ }
1580
+ if (strip.kind === "roleName") {
1581
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1582
+ this.#submitRoleName();
1583
+ return;
1584
+ }
1585
+ strip.input.handleInput(data);
1586
+ return;
1587
+ }
1588
+ if (moveStripSelection(strip, data)) return;
1589
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1590
+ this.#activateStripChip();
1591
+ return;
1592
+ }
1593
+ }
1594
+
1595
+ #moveSidebar(delta: number): void {
1596
+ const count = this.#entries.length;
1597
+ if (count === 0) return;
1598
+ let index = this.#entries.findIndex(entry => entry.id === this.#activeEntryId);
1599
+ if (index < 0) index = 0;
1600
+ for (let step = 0; step < count; step++) {
1601
+ index = (index + delta + count) % count;
1602
+ const entry = this.#entries[index];
1603
+ if (entry && !this.#isHopSkipped(entry)) {
1604
+ // Scope changes keep an active assignment (scoping helps find the
1605
+ // model); landing on the Roles view cancels it.
1606
+ if (entry.kind === "roles") this.#assigning = null;
1607
+ this.#setActiveEntry(entry.id);
1608
+ return;
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ /** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */
1614
+ get #rolesRowCount(): number {
1615
+ return this.#rolesRows.length;
1616
+ }
1617
+
1618
+ /** Enter/click activation for a Roles-view row. */
1619
+ #activateRolesRow(row: RolesRow): void {
1620
+ switch (row.kind) {
1621
+ case "role":
1622
+ this.#startAssign(row.role);
1623
+ return;
1624
+ case "chainKey":
1625
+ this.#startAssignFallback(row.role, null);
1626
+ return;
1627
+ case "fallback":
1628
+ this.#startAssignFallback(row.role, row.chainIndex);
1629
+ return;
1630
+ case "newFallback":
1631
+ this.#startAssignFallbackKey();
1632
+ return;
1633
+ case "newRole":
1634
+ this.#openRoleNameStrip();
1635
+ return;
1636
+ case "separator":
1637
+ return;
1638
+ }
1639
+ }
1640
+
1641
+ /** Scroll `#roleScrollStart` just enough to keep `#roleIndex` inside a window of `viewHeight` rows, clamped to the list. */
1642
+ #ensureRoleVisible(viewHeight: number, total: number): number {
1643
+ if (viewHeight <= 0) return 0;
1644
+ let start = this.#roleScrollStart;
1645
+ if (this.#roleIndex < start) start = this.#roleIndex;
1646
+ else if (this.#roleIndex >= start + viewHeight) start = this.#roleIndex - viewHeight + 1;
1647
+ return Math.max(0, Math.min(start, Math.max(0, total - viewHeight)));
1648
+ }
1649
+
1650
+ /** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
1651
+ #stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
1652
+ const wrap = options.wrap ?? true;
1653
+ const count = this.#rolesRows.length;
1654
+ if (count === 0) return 0;
1655
+ let index = from;
1656
+ for (let i = 0; i < count; i++) {
1657
+ const next = index + delta;
1658
+ if (next < 0 || next >= count) {
1659
+ if (!wrap) return from;
1660
+ index = (next + count) % count;
1661
+ } else {
1662
+ index = next;
1663
+ }
1664
+ if (this.#rolesRows[index]?.kind !== "separator") return index;
1665
+ }
1666
+ return from;
1667
+ }
1668
+
1669
+ #handleRolesViewInput(data: string): void {
1670
+ // Scope focus treats the roles view as a preview: Enter/Space dives
1671
+ // into the rows, everything else is inert (arrows already hop).
1672
+ if (this.#focus === "scope") {
1673
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || matchesKey(data, "space")) {
1674
+ this.#focus = "list";
1675
+ }
1676
+ return;
1677
+ }
1678
+ if (matchesSelectUp(data)) {
1679
+ this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1);
1680
+ return;
1681
+ }
1682
+ if (matchesSelectDown(data)) {
1683
+ this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
1684
+ return;
1685
+ }
1686
+ const row = this.#rolesRows[this.#roleIndex];
1687
+ const role = row?.kind === "role" ? row.role : undefined;
1688
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1689
+ if (row) this.#activateRolesRow(row);
1690
+ return;
1691
+ }
1692
+ if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
1693
+ if (role) this.#unassignRole(role);
1694
+ else if (row?.kind === "fallback") this.#removeFallback(row);
1695
+ else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1696
+ return;
1697
+ }
1698
+ // Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later —
1699
+ // cycle order on a role row, chain order on a fallback row.
1700
+ if (matchesKey(data, "shift+up")) {
1701
+ if (role) this.#moveCycleMembership(role, -1);
1702
+ else if (row?.kind === "fallback") this.#moveFallback(row, -1);
1703
+ return;
1704
+ }
1705
+ if (matchesKey(data, "shift+down")) {
1706
+ if (role) this.#moveCycleMembership(role, 1);
1707
+ else if (row?.kind === "fallback") this.#moveFallback(row, 1);
1708
+ return;
1709
+ }
1710
+ const printable = extractPrintableText(data);
1711
+ if (printable === "x") {
1712
+ if (role) this.#unassignRole(role);
1713
+ else if (row?.kind === "fallback") this.#removeFallback(row);
1714
+ else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1715
+ return;
1716
+ }
1717
+ if (printable === "f") {
1718
+ if (row?.kind === "newFallback") this.#startAssignFallbackKey();
1719
+ else if (row && row.kind !== "newRole" && row.kind !== "separator") {
1720
+ this.#startAssignFallback(row.role, null);
1721
+ }
1722
+ return;
1723
+ }
1724
+ if (printable === "c") {
1725
+ if (role) this.#toggleCycleMembership(role);
1726
+ return;
1727
+ }
1728
+ if (printable === "[") {
1729
+ if (role) this.#moveCycleMembership(role, -1);
1730
+ else if (row?.kind === "fallback") this.#moveFallback(row, -1);
1731
+ return;
1732
+ }
1733
+ if (printable === "]") {
1734
+ if (role) this.#moveCycleMembership(role, 1);
1735
+ else if (row?.kind === "fallback") this.#moveFallback(row, 1);
1736
+ return;
1737
+ }
1738
+ if (printable === "n") {
1739
+ this.#openRoleNameStrip();
1740
+ return;
1741
+ }
1742
+ if (printable === "t") {
1743
+ const assignment = role ? this.#roles[role] : undefined;
1744
+ if (role && assignment) {
1745
+ const source =
1746
+ this.#settings.modelRoleStorage === "project" ? this.#settings.getModelRoleSource(role) : "default";
1747
+ const scope = source === "project" || source === "global" ? source : undefined;
1748
+ const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model;
1749
+ if (!scopedModel) return;
1750
+ const item: ModelBrowserItem = {
1751
+ provider: scopedModel.provider,
1752
+ id: scopedModel.id,
1753
+ model: scopedModel,
1754
+ selector: `${scopedModel.provider}/${scopedModel.id}`,
1755
+ };
1756
+ this.#openThinkingStrip(item, role, true, scope);
1757
+ } else if (row?.kind === "fallback") {
1758
+ this.#openFallbackThinkingStrip(row);
1759
+ }
1760
+ return;
1761
+ }
1762
+ }
1763
+
1764
+ #requestLogin(entry: SidebarEntry): void {
1765
+ if (!entry.providerId) return;
1766
+ if (entry.oauth) {
1767
+ this.#callbacks.onLoginRequest?.(entry.providerId);
1768
+ }
1769
+ }
1770
+
1771
+ // ═══════════════════════════════════════════════════════════════════════
1772
+ // Mouse
1773
+ // ═══════════════════════════════════════════════════════════════════════
1774
+
1775
+ #routeMouseEvent(event: SgrMouseEvent): boolean {
1776
+ if (this.#assignmentPending) return true;
1777
+ const { footerColumn, bodyHeight, contentLine, overSidebar, overBody, bodyLine } = this.#frame.locate(
1778
+ event.row,
1779
+ event.col,
1780
+ );
1781
+ const entry = this.#activeEntry();
1782
+
1783
+ // Footer strip chips (columns stay in frame coordinates).
1784
+ if (footerColumn !== undefined && this.#strip) {
1785
+ const strip = this.#strip;
1786
+ if (event.leftClick && strip.kind !== "roleName" && this.#frame.selectChipAt(strip, footerColumn)) {
1787
+ this.#activateStripChip();
1788
+ }
1789
+ return true;
1790
+ }
1791
+
1792
+ if (event.wheel !== null) {
1793
+ if (overSidebar) {
1794
+ // Wheel pans the sidebar viewport; picking a scope is click/keys only.
1795
+ this.#frame.scrollSidebar(event.wheel, bodyHeight, this.#entries.length);
1796
+ this.#sidebarHover = this.#sidebarEntryIndexAt(contentLine);
1797
+ } else if (overBody) {
1798
+ if (entry.kind === "roles" && this.#assigning === null) {
1799
+ this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false });
1800
+ } else if (this.#isBrowserView(entry)) {
1801
+ this.#browser.routeMouse(event, bodyLine);
1802
+ }
1803
+ }
1804
+ return true;
1805
+ }
1806
+
1807
+ if (event.motion) {
1808
+ this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
1809
+ if (overBody && entry.kind === "roles" && this.#assigning === null) {
1810
+ const roleLine = bodyLine - this.#rolesRowStart;
1811
+ this.#roleHover =
1812
+ roleLine >= 0 && roleLine < this.#rolesVisibleCount ? roleLine + this.#roleScrollStart : null;
1813
+ } else {
1814
+ this.#roleHover = null;
1815
+ if (overBody && this.#isBrowserView(entry)) {
1816
+ this.#browser.routeMouse(event, bodyLine);
1817
+ } else {
1818
+ // Pointer left the browser pane: without this, the last
1819
+ // hovered row keeps its band while the sidebar hovers too.
1820
+ this.#browser.clearHover();
1821
+ }
1822
+ }
1823
+ return true;
1824
+ }
1825
+
1826
+ if (!event.leftClick) return true;
1827
+
1828
+ if (overSidebar) {
1829
+ const index = this.#sidebarEntryIndexAt(contentLine);
1830
+ const clicked = index !== null ? this.#entries[index] : undefined;
1831
+ if (clicked && clicked.kind !== "separator") {
1832
+ const already = clicked.id === this.#activeEntryId;
1833
+ if (clicked.kind === "roles") this.#assigning = null;
1834
+ this.#setActiveEntry(clicked.id);
1835
+ // A click on Roles is a deliberate dive into the rows.
1836
+ if (clicked.kind === "roles") this.#focus = "list";
1837
+ if (already && clicked.kind === "provider" && clicked.locked) {
1838
+ this.#requestLogin(clicked);
1839
+ }
1840
+ }
1841
+ return true;
1842
+ }
1843
+
1844
+ if (overBody) {
1845
+ if (entry.kind === "roles" && this.#assigning === null) {
1846
+ this.#focus = "list";
1847
+ const listLine = bodyLine - this.#rolesRowStart;
1848
+ if (listLine >= 0 && listLine < this.#rolesVisibleCount) {
1849
+ const roleLine = listLine + this.#roleScrollStart;
1850
+ const rowDef = this.#rolesRows[roleLine];
1851
+ if (rowDef && rowDef.kind !== "separator") {
1852
+ if (roleLine === this.#roleIndex) {
1853
+ this.#activateRolesRow(rowDef);
1854
+ } else {
1855
+ this.#roleIndex = roleLine;
1856
+ }
1857
+ }
1858
+ }
1859
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
1860
+ if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
1861
+ this.#requestLogin(entry);
1862
+ }
1863
+ } else if (this.#isBrowserView(entry)) {
1864
+ this.#browser.routeMouse(event, bodyLine);
1865
+ }
1866
+ }
1867
+ return true;
1868
+ }
1869
+
1870
+ /** Map a content-line index to a sidebar entry index (accounting for scroll). */
1871
+ #sidebarEntryIndexAt(contentLine: number): number | null {
1872
+ const index = this.#frame.sidebarScroll + contentLine;
1873
+ if (index < 0 || index >= this.#entries.length) return null;
1874
+ return index;
1875
+ }
1876
+
1877
+ // ═══════════════════════════════════════════════════════════════════════
1878
+ // Rendering
1879
+ // ═══════════════════════════════════════════════════════════════════════
1880
+
1881
+ #renderSidebar(width: number, rows: number): string[] {
1882
+ const lines = this.#frame.renderSidebar(
1883
+ this.#entries,
1884
+ width,
1885
+ rows,
1886
+ { id: this.#activeEntryId, focused: this.#focus === "scope", follow: this.#sidebarFollowActive, clamp: true },
1887
+ this.#sidebarStyle,
1888
+ );
1889
+ this.#sidebarFollowActive = false;
1890
+ return lines;
1891
+ }
1892
+
1893
+ #sidebarStyle = (entry: SidebarEntry, index: number): SidebarStyle => {
1894
+ const searching = this.#searchCounts !== null;
1895
+ let matchCount: number | undefined;
1896
+ if (searching) {
1897
+ if (entry.kind === "provider" && !entry.locked) {
1898
+ matchCount = this.#searchCounts?.get(entry.providerId ?? "") ?? 0;
1899
+ } else if (entry.kind === "recent") {
1900
+ matchCount = this.#recentSearchCount;
1901
+ } else if (entry.kind === "all") {
1902
+ matchCount = this.#searchTotal;
1903
+ }
1904
+ }
1905
+ // Search-ineligible entries gray out, but keep their place in the viewport.
1906
+ const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles");
1907
+ let icon: string;
1908
+ if (entry.kind === "recent") {
1909
+ icon = theme.icon.time;
1910
+ } else if (entry.kind === "roles") {
1911
+ icon = theme.icon.extensionSkill;
1912
+ } else if (entry.kind === "all") {
1913
+ icon = theme.icon.model;
1914
+ } else {
1915
+ icon = muted ? theme.status.shadowed : theme.status.enabled;
1916
+ }
1917
+ const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false;
1918
+ const annotationText = matchCount !== undefined ? String(matchCount) : (entry.annotation ?? "");
1919
+ return {
1920
+ icon: theme.fg(muted ? "dim" : entry.kind === "provider" ? "success" : "accent", icon),
1921
+ annotation: refreshing
1922
+ ? theme.fg("warning", theme.spinnerFrames[this.#refreshSpinnerFrame % theme.spinnerFrames.length] ?? "")
1923
+ : theme.fg("dim", annotationText),
1924
+ muted,
1925
+ hovered: index === this.#sidebarHover,
1926
+ padTruncated: true,
1927
+ };
1928
+ };
1929
+
1930
+ #statusRow(width: number): string {
1931
+ if (this.#assignmentPending) {
1932
+ return truncateToWidth(theme.fg("accent", " Applying model…"), width);
1933
+ }
1934
+ if (this.#assigning !== null) {
1935
+ if (this.#assigning.kind === "fallbackKey") {
1936
+ return truncateToWidth(
1937
+ theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"),
1938
+ width,
1939
+ );
1940
+ }
1941
+ const info = this.#settings.getRoleInfo(this.#assigning.role);
1942
+ const label = info.tag ?? info.name ?? this.#assigning.role;
1943
+ if (this.#assigning.kind === "fallback") {
1944
+ const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
1945
+ return truncateToWidth(
1946
+ theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`),
1947
+ width,
1948
+ );
1949
+ }
1950
+ return truncateToWidth(
1951
+ theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
1952
+ width,
1953
+ );
1954
+ }
1955
+ const entry = this.#activeEntry();
1956
+ const scopedSuffix = this.#scopedModels.length > 0 ? " · --models scope" : "";
1957
+ let text: string;
1958
+ switch (entry.kind) {
1959
+ case "recent":
1960
+ text = `Recently used models${scopedSuffix}`;
1961
+ break;
1962
+ case "roles":
1963
+ text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
1964
+ break;
1965
+ case "provider":
1966
+ if (entry.locked) {
1967
+ text = `${entry.label} · not configured`;
1968
+ } else if (entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
1969
+ text = `${entry.label} · refreshing model list…`;
1970
+ } else {
1971
+ text = `${entry.label} · ${entry.annotation ?? "0"} models${scopedSuffix}`;
1972
+ }
1973
+ break;
1974
+ default:
1975
+ text = `All available models${scopedSuffix}`;
1976
+ break;
1977
+ }
1978
+ if (this.#configError && entry.kind !== "provider") {
1979
+ text = this.#configError;
1980
+ return truncateToWidth(theme.fg("error", ` ${text}`), width);
1981
+ }
1982
+ return truncateToWidth(theme.fg("muted", ` ${text}`), width);
1983
+ }
1984
+
1985
+ /** Clamp a roles row to `width`; the bg band is reserved for mouse hover. */
1986
+ #finishRolesRow(line: string, width: number, hovered: boolean): string {
1987
+ let out = truncateToWidth(line, width);
1988
+ if (hovered) {
1989
+ const w = visibleWidth(out);
1990
+ if (w < width) out += " ".repeat(width - w);
1991
+ return theme.bg("selectedBg", out);
1992
+ }
1993
+ return out;
1994
+ }
1995
+
1996
+ #renderRolesView(width: number, rows: number): string[] {
1997
+ const lines: string[] = [];
1998
+ lines.push("");
1999
+ // First row's offset in bodyLine coordinates: the mouse router's
2000
+ // `bodyLine` has already dropped the status row, so this is just the
2001
+ // leading blank line — no extra status-row offset here.
2002
+ this.#rolesRowStart = lines.length;
2003
+
2004
+ let tagWidth = 0;
2005
+ for (const rowDef of this.#rolesRows) {
2006
+ if (rowDef.kind !== "role") continue;
2007
+ const info = this.#settings.getRoleInfo(rowDef.role);
2008
+ tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role));
2009
+ }
2010
+
2011
+ const cycleOrder = this.#cycleOrder();
2012
+ const listFocused = this.#focus === "list";
2013
+ // Window the list around the cursor so entries past the panel height stay
2014
+ // reachable; the trailing indicator line steals one row when clipped.
2015
+ const total = this.#rolesRows.length;
2016
+ const capacity = Math.max(0, rows - 2 - this.#rolesRowStart);
2017
+ const overflow = total > capacity;
2018
+ const viewHeight = overflow ? Math.max(0, capacity - 1) : capacity;
2019
+ this.#roleScrollStart = this.#ensureRoleVisible(viewHeight, total);
2020
+ const endIndex = Math.min(this.#roleScrollStart + viewHeight, total);
2021
+ this.#rolesVisibleCount = Math.max(0, endIndex - this.#roleScrollStart);
2022
+ for (let i = this.#roleScrollStart; i < endIndex; i++) {
2023
+ const rowDef = this.#rolesRows[i];
2024
+ if (!rowDef) continue;
2025
+ const selected = i === this.#roleIndex;
2026
+ const hovered = i === this.#roleHover;
2027
+ // The unfocused pane draws no cursor; accent text still marks the row.
2028
+ const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " ";
2029
+
2030
+ if (rowDef.kind === "separator") {
2031
+ lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`);
2032
+ continue;
2033
+ }
2034
+
2035
+ if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") {
2036
+ const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…";
2037
+ let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`;
2038
+ line = this.#finishRolesRow(line, width, hovered);
2039
+ lines.push(line);
2040
+ continue;
2041
+ }
2042
+
2043
+ if (rowDef.kind === "chainKey") {
2044
+ const key = rowDef.role;
2045
+ const slash = key.lastIndexOf("/");
2046
+ const tail = key.slice(slash + 1);
2047
+ const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail);
2048
+ let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`;
2049
+ line = this.#finishRolesRow(line, width, hovered);
2050
+ lines.push(line);
2051
+ continue;
2052
+ }
2053
+
2054
+ if (rowDef.kind === "fallback") {
2055
+ const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`);
2056
+ const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector);
2057
+ let line = ` ${cursor} ${branch} ${selector}`;
2058
+ line = this.#finishRolesRow(line, width, hovered);
2059
+ lines.push(line);
2060
+ continue;
2061
+ }
2062
+
2063
+ const role = rowDef.role;
2064
+ const info = this.#settings.getRoleInfo(role);
2065
+ const assignment = this.#roles[role];
2066
+ const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
2067
+
2068
+ let dot: string;
2069
+ let tagStyled: string;
2070
+ let value: string;
2071
+ let levelStyled = "";
2072
+ if (assignment && !assignment.autoSelected) {
2073
+ dot = theme.fg(info.color ?? "muted", theme.status.enabled);
2074
+ tagStyled = theme.fg(info.color ?? "muted", tag);
2075
+ value = `${theme.fg("dim", `${assignment.model.provider}/`)}${selected ? theme.fg("accent", assignment.model.id) : assignment.model.id}`;
2076
+ const glyph = thinkingLevelGlyph(assignment.thinkingLevel, theme);
2077
+ const label = getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label;
2078
+ if (assignment.thinkingLevel !== ThinkingLevel.Inherit) {
2079
+ levelStyled = theme.fg("dim", glyph ? `${glyph} ${label}` : label);
2080
+ }
2081
+ } else if (assignment) {
2082
+ dot = theme.fg("dim", theme.status.shadowed);
2083
+ tagStyled = theme.fg("dim", tag);
2084
+ value = theme.fg("dim", `auto → ${assignment.model.provider}/${assignment.model.id}`);
2085
+ } else {
2086
+ dot = theme.fg("dim", theme.status.shadowed);
2087
+ tagStyled = theme.fg("dim", tag);
2088
+ value = theme.fg("dim", "—");
2089
+ }
2090
+
2091
+ // Quick-cycle membership badge (`⟳ 2` = second stop of the ctrl+p cycle).
2092
+ const cycleIndex = cycleOrder.indexOf(role);
2093
+ const cycleStyled = cycleIndex >= 0 ? theme.fg("accent", `${theme.icon.loop} ${cycleIndex + 1}`) : "";
2094
+
2095
+ let line = ` ${cursor} ${dot} ${tagStyled} ${value}`;
2096
+ const right = [levelStyled, cycleStyled].filter(part => part.length > 0).join(" ");
2097
+ const rightWidth = visibleWidth(right);
2098
+ const lineWidth = visibleWidth(line);
2099
+ if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) {
2100
+ line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`;
2101
+ }
2102
+ line = this.#finishRolesRow(line, width, hovered);
2103
+ lines.push(line);
2104
+ }
2105
+
2106
+ if (overflow) {
2107
+ const hiddenAbove = this.#roleScrollStart;
2108
+ const hiddenBelow = total - endIndex;
2109
+ const parts: string[] = [];
2110
+ if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} more`);
2111
+ if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} more`);
2112
+ lines.push(truncateToWidth(theme.fg("dim", ` ${parts.join(" ")}`), width));
2113
+ }
2114
+
2115
+ // Live preview of the quick-switch cycle, rendered with the exact
2116
+ // segment track the ctrl+p status uses; the selected role's chip fills.
2117
+ while (lines.length < rows - 1) lines.push("");
2118
+ if (rows >= 2) {
2119
+ const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
2120
+ if (cycleOrder.length > 0) {
2121
+ const selectedRow = this.#rolesRows[this.#roleIndex];
2122
+ const selectedRole =
2123
+ selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : "";
2124
+ const activeIndex = cycleOrder.indexOf(selectedRole);
2125
+ const track = renderSegmentTrack(
2126
+ cycleOrder.map(role => ({ label: role })),
2127
+ activeIndex,
2128
+ );
2129
+ lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
2130
+ } else {
2131
+ lines[rows - 1] = truncateToWidth(
2132
+ theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
2133
+ width,
2134
+ );
2135
+ }
2136
+ }
2137
+ return lines;
2138
+ }
2139
+
2140
+ #renderLockedView(entry: SidebarEntry, width: number, rows: number): string[] {
2141
+ const lines: string[] = [];
2142
+ this.#lockedLoginLine = null;
2143
+ lines.push("");
2144
+ lines.push(truncateToWidth(theme.fg("warning", ` ${entry.label} has no credentials configured`), width));
2145
+ lines.push("");
2146
+ const envVars = entry.providerId ? (providerEntry(entry.providerId)?.envVars ?? []) : [];
2147
+ if (envVars.length > 0) {
2148
+ lines.push(
2149
+ truncateToWidth(
2150
+ theme.fg("muted", ` Set ${envVars.join(" or ")} in your environment, or add a key in config.`),
2151
+ width,
2152
+ ),
2153
+ );
2154
+ } else {
2155
+ lines.push(truncateToWidth(theme.fg("muted", " Add an API key for this provider in config."), width));
2156
+ }
2157
+ if (entry.oauth) {
2158
+ this.#lockedLoginLine = lines.length + 1; // +1 for the status row offset handled by caller
2159
+ lines.push(truncateToWidth(theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (Enter)`), width));
2160
+ }
2161
+ lines.push("");
2162
+ const catalogCount = entry.catalogCount ?? 0;
2163
+ if (catalogCount > 0) {
2164
+ lines.push(truncateToWidth(theme.fg("dim", ` ${catalogCount} models in catalog:`), width));
2165
+ const preview = this.#scopedModels.length > 0 ? [] : this.#registry.getAll();
2166
+ for (const model of preview) {
2167
+ if (model.provider !== entry.providerId) continue;
2168
+ if (lines.length >= rows) break;
2169
+ lines.push(truncateToWidth(theme.fg("dim", ` ${model.id}`), width));
2170
+ }
2171
+ }
2172
+ while (lines.length < rows) lines.push("");
2173
+ return lines.slice(0, rows);
2174
+ }
2175
+
2176
+ #footerHint(): string {
2177
+ const strip = this.#strip;
2178
+ if (strip) {
2179
+ if (strip.kind === "roleName") {
2180
+ return "Enter create + pick model · Esc cancel";
2181
+ }
2182
+ if (strip.kind === "role") return "←/→ choose · Enter assign/clear · Esc cancel";
2183
+ if (strip.kind === "scope") return "←/→ save scope · Enter choose · Esc cancel";
2184
+ return "←/→ thinking level · Enter apply · Esc keep";
2185
+ }
2186
+ if (this.#assigning !== null) {
2187
+ switch (this.#assigning.kind) {
2188
+ case "fallback":
2189
+ return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel";
2190
+ case "fallbackKey":
2191
+ return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel";
2192
+ default:
2193
+ return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
2194
+ }
2195
+ }
2196
+ const entry = this.#activeEntry();
2197
+ if (entry.kind === "roles") {
2198
+ if (this.#focus !== "list") {
2199
+ return "↑/↓ providers · → roles · Esc close";
2200
+ }
2201
+ const row = this.#rolesRows[this.#roleIndex];
2202
+ if (row?.kind === "fallback") {
2203
+ // Advertise `t` only when the entry resolves: wildcards always
2204
+ // inherit and unknown models have no ladder to offer, so the
2205
+ // action would be inert there.
2206
+ const editable = this.#resolveFallbackEntry(row.role, row.chainIndex) !== undefined;
2207
+ const thinking = editable ? " · t thinking" : "";
2208
+ return `↑/↓ rows · Enter replace · f add another · x remove${thinking} · [/] reorder · ← providers`;
2209
+ }
2210
+ if (row?.kind === "chainKey") {
2211
+ return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers";
2212
+ }
2213
+ if (row?.kind === "newFallback") {
2214
+ return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
2215
+ }
2216
+ return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
2217
+ }
2218
+ if (entry.kind === "provider" && entry.locked) {
2219
+ return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
2220
+ }
2221
+ const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
2222
+ const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
2223
+ return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
2224
+ }
2225
+
2226
+ #renderFooter(width: number): string {
2227
+ const strip = this.#strip;
2228
+ return this.#frame.renderFooter(
2229
+ width,
2230
+ this.#footerHint(),
2231
+ strip ? () => this.#renderStrip(width, strip) : undefined,
2232
+ );
2233
+ }
2234
+
2235
+ #renderStrip(width: number, strip: StripState): string {
2236
+ if (strip.kind === "roleName") {
2237
+ const label = theme.fg("accent", "New role name:");
2238
+ const inputWidth = Math.max(8, Math.min(32, width - visibleWidth("New role name:") - 24));
2239
+ const inputLine = strip.input.render(inputWidth)[0] ?? "";
2240
+ return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
2241
+ }
2242
+
2243
+ const prefix =
2244
+ strip.kind === "role"
2245
+ ? `${theme.fg("accent", strip.item.id)}${theme.fg("dim", " →")} `
2246
+ : `${theme.fg(this.#settings.getRoleInfo(strip.role ?? "").color ?? "muted", (this.#settings.getRoleInfo(strip.role ?? "").tag ?? strip.role ?? "").toLowerCase())}${theme.fg("dim", ` · ${strip.item.id} →`)} `;
2247
+
2248
+ // Horizontal window: once the strip overflows, drop leading chips behind
2249
+ // a dim ellipsis so the selected chip (plus one chip of lookahead when it
2250
+ // fits) stays visible while cycling right.
2251
+ const prefixWidth = visibleWidth(prefix);
2252
+ const available = Math.max(1, width - prefixWidth);
2253
+ const chipWidths = strip.chips.map(
2254
+ (chip, i) => visibleWidth(` ${chip.styled} `) + (i === strip.index ? 2 : 0) + 1,
2255
+ );
2256
+ // Smallest start index whose window [start..target] (with its "… " lead-in
2257
+ // when start > 0) fits in the available width; `target` itself may still
2258
+ // overflow when a single chip is wider than the row.
2259
+ const startFor = (target: number): number => {
2260
+ let start = 0;
2261
+ while (start < target) {
2262
+ let sum = start > 0 ? 2 : 0;
2263
+ for (let i = start; i <= target; i++) sum += chipWidths[i] ?? 0;
2264
+ if (sum <= available) break;
2265
+ start++;
2266
+ }
2267
+ return start;
2268
+ };
2269
+ let start = startFor(Math.min(strip.index + 1, strip.chips.length - 1));
2270
+ if (start > strip.index) start = startFor(strip.index);
2271
+
2272
+ return this.#frame.renderChips(width, prefix, strip, start);
2273
+ }
2274
+
2275
+ render(width: number): readonly string[] {
2276
+ const height = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
2277
+ return this.#frame.render(width, height, this.#entries, this.#renderFooter(width - 4));
2278
+ }
2279
+ }