@oh-my-pi/pi-tui 18.4.2 → 18.4.4

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 (463) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/types/apps/cleanse-board.d.ts +17 -0
  3. package/dist/types/apps/git/help.d.ts +16 -0
  4. package/dist/types/apps/git/sidebar.d.ts +18 -0
  5. package/dist/types/apps/live-visualizer.d.ts +10 -0
  6. package/dist/types/apps/ps-top.d.ts +15 -0
  7. package/dist/types/apps/standalone-picker.d.ts +3 -0
  8. package/dist/types/autocomplete.d.ts +8 -0
  9. package/dist/types/chat/advisor-message.d.ts +12 -2
  10. package/dist/types/chat/assistant-message.d.ts +20 -0
  11. package/dist/types/chat/bash-execution.d.ts +9 -0
  12. package/dist/types/chat/chat-transcript-builder.d.ts +6 -1
  13. package/dist/types/chat/collab-prompt-message.d.ts +4 -0
  14. package/dist/types/chat/compaction-summary-message.d.ts +15 -0
  15. package/dist/types/chat/eval-execution.d.ts +8 -0
  16. package/dist/types/chat/execution-shared.d.ts +58 -0
  17. package/dist/types/chat/late-diagnostics-message.d.ts +15 -1
  18. package/dist/types/chat/read-tool-group.d.ts +15 -0
  19. package/dist/types/chat/skill-message.d.ts +9 -0
  20. package/dist/types/chat/stripped-tool-calls-placeholder.d.ts +3 -0
  21. package/dist/types/chat/thinking-display.d.ts +2 -0
  22. package/dist/types/chat/todo-reminder.d.ts +4 -1
  23. package/dist/types/chat/tool-execution.d.ts +25 -1
  24. package/dist/types/chat/transcript-actions.d.ts +37 -0
  25. package/dist/types/chat/transcript-entry.d.ts +3 -1
  26. package/dist/types/chat/user-message.d.ts +25 -0
  27. package/dist/types/chrome/collab-qrcode.d.ts +7 -0
  28. package/dist/types/chrome/context-thresholds.d.ts +11 -0
  29. package/dist/types/chrome/countdown-timer.d.ts +9 -3
  30. package/dist/types/chrome/diff.d.ts +12 -0
  31. package/dist/types/chrome/dynamic-border.d.ts +3 -0
  32. package/dist/types/chrome/format.d.ts +6 -0
  33. package/dist/types/chrome/message-divider.d.ts +13 -0
  34. package/dist/types/chrome/message-frame.d.ts +10 -0
  35. package/dist/types/chrome/message-notice.d.ts +23 -0
  36. package/dist/types/chrome/overlay-box.d.ts +11 -2
  37. package/dist/types/chrome/qrcode.d.ts +7 -0
  38. package/dist/types/chrome/segment-track.d.ts +7 -0
  39. package/dist/types/chrome/selector-helpers.d.ts +0 -6
  40. package/dist/types/chrome/status-notice.d.ts +14 -0
  41. package/dist/types/chrome/streaming-panel.d.ts +3 -0
  42. package/dist/types/chrome/tool-activity.d.ts +3 -0
  43. package/dist/types/chrome/transcript-container.d.ts +13 -0
  44. package/dist/types/chrome/transcript-status.d.ts +15 -3
  45. package/dist/types/components/box.d.ts +7 -0
  46. package/dist/types/components/disclosure.d.ts +9 -0
  47. package/dist/types/components/editor.d.ts +42 -0
  48. package/dist/types/components/form.d.ts +14 -0
  49. package/dist/types/components/image.d.ts +7 -0
  50. package/dist/types/components/input.d.ts +20 -1
  51. package/dist/types/components/key-value-list.d.ts +3 -0
  52. package/dist/types/components/layout/geometry.d.ts +16 -0
  53. package/dist/types/components/layout/row.d.ts +7 -0
  54. package/dist/types/components/layout/split-pane.d.ts +8 -0
  55. package/dist/types/components/layout/stack.d.ts +6 -0
  56. package/dist/types/components/loader.d.ts +48 -0
  57. package/dist/types/components/markdown.d.ts +8 -0
  58. package/dist/types/components/metric.d.ts +7 -0
  59. package/dist/types/components/progress-bar.d.ts +3 -0
  60. package/dist/types/components/scroll-view.d.ts +6 -0
  61. package/dist/types/components/section.d.ts +3 -0
  62. package/dist/types/components/select-list.d.ts +33 -0
  63. package/dist/types/components/settings-list.d.ts +68 -0
  64. package/dist/types/components/spacer.d.ts +4 -0
  65. package/dist/types/components/tab-bar.d.ts +9 -0
  66. package/dist/types/components/table.d.ts +7 -0
  67. package/dist/types/components/text.d.ts +6 -0
  68. package/dist/types/components/tree-view.d.ts +9 -0
  69. package/dist/types/components/truncated-text.d.ts +3 -0
  70. package/dist/types/components/wizard-step.d.ts +6 -0
  71. package/dist/types/hotkeys-markdown.d.ts +24 -0
  72. package/dist/types/index.d.ts +5 -0
  73. package/dist/types/latex-block.d.ts +7 -0
  74. package/dist/types/loop-watchdog.d.ts +9 -0
  75. package/dist/types/native/apply.d.ts +39 -0
  76. package/dist/types/native/backend.d.ts +94 -0
  77. package/dist/types/native/blobs.d.ts +29 -0
  78. package/dist/types/native/describe.d.ts +56 -0
  79. package/dist/types/native/encode.d.ts +50 -0
  80. package/dist/types/native/icons.d.ts +18 -0
  81. package/dist/types/native/memo.d.ts +27 -0
  82. package/dist/types/native/node.d.ts +107 -0
  83. package/dist/types/native/overlay.d.ts +64 -0
  84. package/dist/types/native/picker.d.ts +124 -0
  85. package/dist/types/native/reconcile.d.ts +41 -0
  86. package/dist/types/native/settle.d.ts +11 -0
  87. package/dist/types/native/spans.d.ts +21 -0
  88. package/dist/types/native/state.d.ts +6 -0
  89. package/dist/types/native/tone.d.ts +24 -0
  90. package/dist/types/overlays/advisor-config.d.ts +20 -0
  91. package/dist/types/overlays/agent-hub-renderer.d.ts +27 -11
  92. package/dist/types/overlays/agent-hub.d.ts +10 -0
  93. package/dist/types/overlays/agent-transcript-viewer.d.ts +7 -0
  94. package/dist/types/overlays/agents-hub.d.ts +8 -0
  95. package/dist/types/overlays/annotation-overlay.d.ts +3 -0
  96. package/dist/types/overlays/ask-dialog.d.ts +23 -0
  97. package/dist/types/overlays/bordered-loader.d.ts +3 -0
  98. package/dist/types/overlays/btw-history-panel.d.ts +9 -0
  99. package/dist/types/overlays/btw-panel.d.ts +3 -0
  100. package/dist/types/overlays/cleanse-panel.d.ts +5 -2
  101. package/dist/types/overlays/composer-shape-preview.d.ts +7 -1
  102. package/dist/types/overlays/copy-selector.d.ts +38 -0
  103. package/dist/types/overlays/error-banner.d.ts +9 -0
  104. package/dist/types/overlays/extensions/extension-list.d.ts +1 -0
  105. package/dist/types/overlays/history-search.d.ts +18 -0
  106. package/dist/types/overlays/hook-editor.d.ts +28 -1
  107. package/dist/types/overlays/hook-input.d.ts +3 -0
  108. package/dist/types/overlays/hook-selector.d.ts +10 -0
  109. package/dist/types/overlays/hub-frame.d.ts +18 -0
  110. package/dist/types/overlays/jobs-panel.d.ts +24 -0
  111. package/dist/types/overlays/login-dialog.d.ts +10 -0
  112. package/dist/types/overlays/logout-account-selector.d.ts +4 -0
  113. package/dist/types/overlays/mcp-add-wizard.d.ts +4 -0
  114. package/dist/types/overlays/model-browser.d.ts +69 -0
  115. package/dist/types/overlays/model-hub.d.ts +7 -0
  116. package/dist/types/overlays/model-picker.d.ts +9 -1
  117. package/dist/types/overlays/move-overlay.d.ts +3 -0
  118. package/dist/types/overlays/oauth-selector.d.ts +4 -0
  119. package/dist/types/overlays/omfg-panel.d.ts +3 -0
  120. package/dist/types/overlays/pause-screen.d.ts +12 -0
  121. package/dist/types/overlays/plan-review-overlay.d.ts +5 -0
  122. package/dist/types/overlays/plan-save-overlay.d.ts +10 -0
  123. package/dist/types/overlays/plugin-selector.d.ts +4 -0
  124. package/dist/types/overlays/plugin-settings.d.ts +30 -0
  125. package/dist/types/overlays/queue-mode-selector.d.ts +4 -0
  126. package/dist/types/overlays/reset-usage-selector.d.ts +8 -0
  127. package/dist/types/overlays/rewind-selector.d.ts +5 -0
  128. package/dist/types/overlays/session-info-overlay.d.ts +19 -1
  129. package/dist/types/overlays/session-selector.d.ts +64 -1
  130. package/dist/types/overlays/settings-defs.d.ts +14 -0
  131. package/dist/types/overlays/settings-selector.d.ts +21 -0
  132. package/dist/types/overlays/show-images-selector.d.ts +4 -0
  133. package/dist/types/overlays/snapcompact-shape-preview.d.ts +2 -0
  134. package/dist/types/overlays/stats-notice.d.ts +8 -0
  135. package/dist/types/overlays/theme-selector.d.ts +11 -0
  136. package/dist/types/overlays/thinking-selector.d.ts +4 -0
  137. package/dist/types/overlays/tree-selector.d.ts +60 -2
  138. package/dist/types/overlays/usage-dashboard.d.ts +26 -3
  139. package/dist/types/overlays/usage-row.d.ts +36 -2
  140. package/dist/types/prompt/attachment-chips.d.ts +8 -0
  141. package/dist/types/prompt/composer-attachments.d.ts +15 -3
  142. package/dist/types/prompt/composer.d.ts +18 -1
  143. package/dist/types/prompt/custom-editor.d.ts +48 -2
  144. package/dist/types/prompt/editor-top-gap.d.ts +2 -0
  145. package/dist/types/prompt/macos-spelling.d.ts +6 -0
  146. package/dist/types/prompt/magic-keywords.d.ts +9 -0
  147. package/dist/types/prompt/queued-messages.d.ts +23 -0
  148. package/dist/types/prompt/skill-tokens.d.ts +4 -2
  149. package/dist/types/prompt/welcome.d.ts +16 -0
  150. package/dist/types/render/code-cell.d.ts +10 -0
  151. package/dist/types/render/file-list.d.ts +8 -0
  152. package/dist/types/render/hyperlink.d.ts +14 -0
  153. package/dist/types/render/output-block.d.ts +43 -0
  154. package/dist/types/render/output-pane.d.ts +17 -0
  155. package/dist/types/render/tool-card.d.ts +8 -0
  156. package/dist/types/render/tree-list.d.ts +8 -0
  157. package/dist/types/render/width-aware-text.d.ts +7 -0
  158. package/dist/types/setup/scenes/outro.d.ts +7 -0
  159. package/dist/types/setup/scenes/sign-in.d.ts +16 -9
  160. package/dist/types/setup/scenes/splash.d.ts +8 -0
  161. package/dist/types/setup/scenes/types.d.ts +6 -27
  162. package/dist/types/setup/wizard-overlay.d.ts +9 -0
  163. package/dist/types/status-line/component.d.ts +49 -5
  164. package/dist/types/status-line/context-usage.d.ts +20 -0
  165. package/dist/types/status-line/footer.d.ts +8 -0
  166. package/dist/types/status-line/metrics.d.ts +1 -2
  167. package/dist/types/status-line/segments.d.ts +10 -1
  168. package/dist/types/status-line/types.d.ts +40 -1
  169. package/dist/types/stdin-buffer.d.ts +10 -7
  170. package/dist/types/terminal-capabilities.d.ts +1 -1
  171. package/dist/types/terminal.d.ts +36 -0
  172. package/dist/types/theme/defaults/index.d.ts +100 -0
  173. package/dist/types/theme/schema.d.ts +2 -0
  174. package/dist/types/theme/shimmer.d.ts +8 -0
  175. package/dist/types/theme/theme-class.d.ts +2 -0
  176. package/dist/types/theme/theme.d.ts +33 -1
  177. package/dist/types/tools/ask.d.ts +3 -0
  178. package/dist/types/tools/ast-edit.d.ts +10 -1
  179. package/dist/types/tools/ast-grep.d.ts +10 -1
  180. package/dist/types/tools/autoresearch.d.ts +21 -4
  181. package/dist/types/tools/bash.d.ts +30 -1
  182. package/dist/types/tools/cfg-render.d.ts +5 -1
  183. package/dist/types/tools/debug.d.ts +3 -1
  184. package/dist/types/tools/default-renderer.d.ts +6 -1
  185. package/dist/types/tools/edit.d.ts +14 -1
  186. package/dist/types/tools/eval.d.ts +12 -1
  187. package/dist/types/tools/fetch.d.ts +16 -1
  188. package/dist/types/tools/find.d.ts +15 -1
  189. package/dist/types/tools/github.d.ts +3 -0
  190. package/dist/types/tools/glob.d.ts +14 -1
  191. package/dist/types/tools/goal.d.ts +3 -1
  192. package/dist/types/tools/grep.d.ts +14 -1
  193. package/dist/types/tools/grouped-file-output.d.ts +58 -0
  194. package/dist/types/tools/json-tree.d.ts +20 -0
  195. package/dist/types/tools/lsp.d.ts +3 -10
  196. package/dist/types/tools/mcp.d.ts +16 -1
  197. package/dist/types/tools/memory.d.ts +26 -0
  198. package/dist/types/tools/native-view.d.ts +90 -0
  199. package/dist/types/tools/proc-render.d.ts +7 -1
  200. package/dist/types/tools/read.d.ts +23 -1
  201. package/dist/types/tools/renderer.d.ts +57 -0
  202. package/dist/types/tools/report-tool-issue.d.ts +3 -0
  203. package/dist/types/tools/resolve.d.ts +14 -1
  204. package/dist/types/tools/task.d.ts +3 -0
  205. package/dist/types/tools/think.d.ts +3 -0
  206. package/dist/types/tools/todo.d.ts +12 -0
  207. package/dist/types/tools/vibe.d.ts +3 -1
  208. package/dist/types/tools/wait.d.ts +3 -0
  209. package/dist/types/tools/web-search-types.d.ts +90 -0
  210. package/dist/types/tools/web-search.d.ts +9 -168
  211. package/dist/types/tools/write.d.ts +14 -1
  212. package/dist/types/tools/xdev.d.ts +13 -0
  213. package/dist/types/tui.d.ts +49 -0
  214. package/package.json +9 -9
  215. package/src/apps/autoresearch-dashboard.ts +362 -4
  216. package/src/apps/cleanse-board.ts +219 -1
  217. package/src/apps/cleanse-picker.ts +9 -1
  218. package/src/apps/debug/log-viewer.ts +15 -15
  219. package/src/apps/debug/protocol-probe.ts +2 -20
  220. package/src/apps/git/avatar.ts +2 -22
  221. package/src/apps/git/git-tui.ts +406 -59
  222. package/src/apps/git/help.ts +130 -0
  223. package/src/apps/git/sidebar.ts +491 -0
  224. package/src/apps/live-visualizer.ts +65 -0
  225. package/src/apps/ps-top.ts +448 -17
  226. package/src/apps/session-picker.ts +1 -0
  227. package/src/apps/setup-model-picker.ts +5 -1
  228. package/src/apps/standalone-picker.ts +55 -1
  229. package/src/autocomplete.ts +44 -1
  230. package/src/chat/advisor-message.ts +72 -3
  231. package/src/chat/assistant-message.ts +538 -51
  232. package/src/chat/background-tan-message.ts +4 -4
  233. package/src/chat/bash-execution.ts +81 -0
  234. package/src/chat/cache-invalidation-marker.ts +8 -0
  235. package/src/chat/chat-transcript-builder.ts +37 -6
  236. package/src/chat/collab-prompt-message.ts +15 -3
  237. package/src/chat/compaction-summary-message.ts +74 -1
  238. package/src/chat/custom-message.ts +3 -1
  239. package/src/chat/eval-execution.ts +57 -0
  240. package/src/chat/execution-shared.ts +153 -1
  241. package/src/chat/hook-message.ts +1 -0
  242. package/src/chat/late-diagnostics-message.ts +85 -2
  243. package/src/chat/read-tool-group.ts +341 -1
  244. package/src/chat/served-model-marker.ts +8 -0
  245. package/src/chat/skill-message.ts +63 -0
  246. package/src/chat/stripped-tool-calls-placeholder.ts +18 -10
  247. package/src/chat/thinking-display.ts +73 -11
  248. package/src/chat/todo-reminder.ts +34 -4
  249. package/src/chat/tool-execution.ts +385 -10
  250. package/src/chat/transcript-actions.ts +38 -0
  251. package/src/chat/transcript-browser.ts +2 -9
  252. package/src/chat/transcript-entry.ts +10 -1
  253. package/src/chat/transcript-render-helpers.ts +16 -15
  254. package/src/chat/ttsr-notification.ts +38 -1
  255. package/src/chat/user-message.ts +167 -4
  256. package/src/chrome/collab-qrcode.ts +27 -2
  257. package/src/chrome/context-thresholds.ts +34 -0
  258. package/src/chrome/countdown-timer.ts +24 -1
  259. package/src/chrome/diff.ts +60 -1
  260. package/src/chrome/dynamic-border.ts +7 -0
  261. package/src/chrome/format.ts +12 -0
  262. package/src/chrome/message-divider.ts +33 -1
  263. package/src/chrome/message-frame.ts +53 -10
  264. package/src/chrome/message-notice.ts +71 -0
  265. package/src/chrome/overlay-box.ts +45 -2
  266. package/src/chrome/qrcode.ts +28 -0
  267. package/src/chrome/segment-track.ts +26 -0
  268. package/src/chrome/selector-helpers.ts +1 -17
  269. package/src/chrome/status-notice.ts +46 -0
  270. package/src/chrome/streaming-panel.ts +25 -1
  271. package/src/chrome/tool-activity.ts +20 -0
  272. package/src/chrome/transcript-container.ts +139 -17
  273. package/src/chrome/transcript-status.ts +40 -11
  274. package/src/components/box.ts +51 -0
  275. package/src/components/disclosure.ts +33 -0
  276. package/src/components/editor.ts +239 -36
  277. package/src/components/form.ts +93 -9
  278. package/src/components/image.ts +30 -0
  279. package/src/components/input.ts +77 -26
  280. package/src/components/key-value-list.ts +18 -0
  281. package/src/components/layout/geometry.ts +44 -0
  282. package/src/components/layout/row.ts +30 -0
  283. package/src/components/layout/split-pane.ts +65 -0
  284. package/src/components/layout/stack.ts +27 -0
  285. package/src/components/loader.ts +183 -1
  286. package/src/components/markdown.ts +131 -56
  287. package/src/components/metric.ts +39 -0
  288. package/src/components/progress-bar.ts +28 -0
  289. package/src/components/scroll-view.ts +23 -0
  290. package/src/components/section.ts +16 -0
  291. package/src/components/select-list.ts +174 -25
  292. package/src/components/settings-list.ts +264 -7
  293. package/src/components/spacer.ts +17 -0
  294. package/src/components/tab-bar.ts +42 -0
  295. package/src/components/table.ts +34 -0
  296. package/src/components/text.ts +23 -0
  297. package/src/components/tree-view.ts +65 -0
  298. package/src/components/truncated-text.ts +14 -0
  299. package/src/components/wizard-step.ts +18 -9
  300. package/src/debug-server.ts +14 -7
  301. package/src/hotkeys-markdown.ts +251 -69
  302. package/src/index.ts +6 -0
  303. package/src/latex-block.ts +11 -0
  304. package/src/loop-watchdog.ts +38 -10
  305. package/src/native/apply.ts +275 -0
  306. package/src/native/backend.ts +682 -0
  307. package/src/native/blobs.ts +70 -0
  308. package/src/native/describe.ts +124 -0
  309. package/src/native/encode.ts +233 -0
  310. package/src/native/icons.ts +184 -0
  311. package/src/native/memo.ts +64 -0
  312. package/src/native/node.ts +112 -0
  313. package/src/native/overlay.ts +131 -0
  314. package/src/native/picker.ts +333 -0
  315. package/src/native/reconcile.ts +1063 -0
  316. package/src/native/settle.ts +23 -0
  317. package/src/native/spans.ts +308 -0
  318. package/src/native/state.ts +28 -0
  319. package/src/native/tone.ts +79 -0
  320. package/src/overlays/advisor-config.ts +420 -0
  321. package/src/overlays/agent-hub-renderer.ts +155 -18
  322. package/src/overlays/agent-hub.ts +1037 -44
  323. package/src/overlays/agent-transcript-viewer.ts +129 -0
  324. package/src/overlays/agents-hub.ts +688 -53
  325. package/src/overlays/annotation-overlay.ts +391 -52
  326. package/src/overlays/ask-dialog.ts +435 -24
  327. package/src/overlays/bordered-loader.ts +17 -0
  328. package/src/overlays/btw-history-panel.ts +182 -0
  329. package/src/overlays/btw-panel.ts +68 -2
  330. package/src/overlays/cleanse-panel.ts +105 -15
  331. package/src/overlays/codex-reset-fireworks.ts +65 -5
  332. package/src/overlays/composer-shape-preview.ts +26 -1
  333. package/src/overlays/copy-selector.ts +545 -23
  334. package/src/overlays/error-banner.ts +44 -0
  335. package/src/overlays/extensions/extension-list.ts +23 -17
  336. package/src/overlays/history-search.ts +175 -33
  337. package/src/overlays/hook-editor.ts +113 -6
  338. package/src/overlays/hook-input.ts +22 -1
  339. package/src/overlays/hook-selector.ts +311 -22
  340. package/src/overlays/hub-frame.ts +80 -0
  341. package/src/overlays/jobs-panel.ts +112 -0
  342. package/src/overlays/login-dialog.ts +183 -1
  343. package/src/overlays/logout-account-selector.ts +102 -4
  344. package/src/overlays/mcp-add-wizard.ts +232 -53
  345. package/src/overlays/model-browser.ts +681 -22
  346. package/src/overlays/model-hub.ts +1402 -78
  347. package/src/overlays/model-picker.ts +184 -7
  348. package/src/overlays/move-overlay.ts +45 -0
  349. package/src/overlays/oauth-selector.ts +227 -23
  350. package/src/overlays/omfg-panel.ts +86 -2
  351. package/src/overlays/pause-screen.ts +55 -4
  352. package/src/overlays/plan-review-overlay.ts +352 -0
  353. package/src/overlays/plan-save-overlay.ts +45 -0
  354. package/src/overlays/plugin-selector.ts +21 -1
  355. package/src/overlays/plugin-settings.ts +195 -5
  356. package/src/overlays/queue-mode-selector.ts +19 -1
  357. package/src/overlays/reset-usage-selector.ts +110 -40
  358. package/src/overlays/rewind-selector.ts +458 -44
  359. package/src/overlays/session-account-selector.ts +1 -1
  360. package/src/overlays/session-info-overlay.ts +137 -1
  361. package/src/overlays/session-selector.ts +807 -46
  362. package/src/overlays/settings-defs.ts +41 -0
  363. package/src/overlays/settings-selector.ts +585 -33
  364. package/src/overlays/show-images-selector.ts +18 -1
  365. package/src/overlays/snapcompact-shape-preview.ts +48 -3
  366. package/src/overlays/stats-notice.ts +31 -0
  367. package/src/overlays/theme-selector.ts +31 -1
  368. package/src/overlays/thinking-selector.ts +22 -1
  369. package/src/overlays/tree-selector.ts +570 -110
  370. package/src/overlays/usage-dashboard.ts +587 -12
  371. package/src/overlays/usage-row.ts +161 -25
  372. package/src/prompt/attachment-chips.ts +60 -0
  373. package/src/prompt/composer-attachments.ts +31 -9
  374. package/src/prompt/composer.ts +55 -2
  375. package/src/prompt/custom-editor.ts +302 -4
  376. package/src/prompt/editor-top-gap.ts +8 -0
  377. package/src/prompt/macos-spelling.ts +24 -9
  378. package/src/prompt/magic-keywords.ts +22 -1
  379. package/src/prompt/model-mention-autocomplete.ts +1 -0
  380. package/src/prompt/queued-messages.ts +101 -0
  381. package/src/prompt/skill-tokens.ts +5 -3
  382. package/src/prompt/welcome.ts +213 -6
  383. package/src/render/code-cell.ts +103 -2
  384. package/src/render/file-list.ts +29 -0
  385. package/src/render/hyperlink.ts +32 -0
  386. package/src/render/output-block.ts +110 -0
  387. package/src/render/output-pane.ts +37 -0
  388. package/src/render/tool-card.ts +137 -0
  389. package/src/render/tree-list.ts +39 -0
  390. package/src/render/width-aware-text.ts +20 -0
  391. package/src/setup/scenes/composer.ts +29 -0
  392. package/src/setup/scenes/glyph.ts +15 -0
  393. package/src/setup/scenes/model.ts +36 -8
  394. package/src/setup/scenes/outro.ts +28 -1
  395. package/src/setup/scenes/sign-in.ts +138 -24
  396. package/src/setup/scenes/splash.ts +28 -1
  397. package/src/setup/scenes/theme.ts +88 -3
  398. package/src/setup/scenes/types.ts +7 -28
  399. package/src/setup/startup-splash.ts +46 -9
  400. package/src/setup/wizard-overlay.ts +140 -23
  401. package/src/setup/wizard.ts +1 -1
  402. package/src/status-line/component.ts +414 -18
  403. package/src/status-line/context-usage.ts +279 -50
  404. package/src/status-line/footer.ts +94 -1
  405. package/src/status-line/metrics.ts +3 -6
  406. package/src/status-line/segments.ts +463 -97
  407. package/src/status-line/separators.ts +3 -16
  408. package/src/status-line/types.ts +43 -1
  409. package/src/stdin-buffer.ts +27 -21
  410. package/src/terminal-capabilities.ts +7 -0
  411. package/src/terminal.ts +153 -11
  412. package/src/theme/defaults/dark-neon-noir.json +100 -0
  413. package/src/theme/defaults/index.ts +2 -0
  414. package/src/theme/defaults/light-prism.json +5 -5
  415. package/src/theme/defaults/onyx.json +62 -62
  416. package/src/theme/mermaid-cache.ts +10 -1
  417. package/src/theme/schema.ts +15 -0
  418. package/src/theme/shimmer.ts +33 -0
  419. package/src/theme/theme-class.ts +24 -19
  420. package/src/theme/theme.ts +111 -9
  421. package/src/theme/tui-adapters.ts +14 -1
  422. package/src/tools/ask.ts +157 -1
  423. package/src/tools/ast-edit.ts +174 -1
  424. package/src/tools/ast-grep.ts +82 -2
  425. package/src/tools/autoresearch.ts +149 -1
  426. package/src/tools/bash.ts +233 -62
  427. package/src/tools/cfg-render.ts +66 -1
  428. package/src/tools/debug.ts +78 -1
  429. package/src/tools/default-renderer.ts +35 -1
  430. package/src/tools/edit.ts +357 -31
  431. package/src/tools/eval.ts +257 -1
  432. package/src/tools/fetch.ts +102 -3
  433. package/src/tools/find.ts +127 -1
  434. package/src/tools/github.ts +119 -10
  435. package/src/tools/glob.ts +116 -1
  436. package/src/tools/goal.ts +56 -1
  437. package/src/tools/grep.ts +83 -2
  438. package/src/tools/grouped-file-output.ts +205 -0
  439. package/src/tools/json-tree.ts +116 -1
  440. package/src/tools/lsp.ts +318 -1
  441. package/src/tools/mcp.ts +80 -1
  442. package/src/tools/memory.ts +93 -11
  443. package/src/tools/native-view.ts +292 -0
  444. package/src/tools/proc-render.ts +221 -1
  445. package/src/tools/read.ts +199 -4
  446. package/src/tools/renderer.ts +61 -0
  447. package/src/tools/report-tool-issue.ts +7 -1
  448. package/src/tools/resolve.ts +63 -1
  449. package/src/tools/streaming-output.ts +79 -16
  450. package/src/tools/task.ts +406 -4
  451. package/src/tools/think.ts +19 -1
  452. package/src/tools/todo.ts +89 -0
  453. package/src/tools/vibe.ts +230 -2
  454. package/src/tools/wait.ts +181 -1
  455. package/src/tools/web-search-types.ts +99 -0
  456. package/src/tools/web-search.ts +111 -170
  457. package/src/tools/write.ts +144 -5
  458. package/src/tools/xdev.ts +64 -1
  459. package/src/tui.ts +295 -4
  460. package/dist/types/setup/scenes/providers.d.ts +0 -3
  461. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  462. package/src/setup/scenes/providers.ts +0 -110
  463. package/src/setup/scenes/web-search.ts +0 -165
@@ -11,6 +11,15 @@ import { parseModelString, splitUpstreamRouting, formatModelSelectorValue } from
11
11
  * in the compact alt+p picker ({@link ./model-picker}).
12
12
  */
13
13
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
14
+ import type {
15
+ TspPickerAction,
16
+ TspPickerColumn,
17
+ TspPickerGroup,
18
+ TspPickerItem,
19
+ TspPickerProps,
20
+ TspPickerScope,
21
+ TspSpan,
22
+ } from "@oh-my-pi/pi-wire";
14
23
  import type { KeysApi, Model } from "@oh-my-pi/pi-ai";
15
24
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
16
25
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -20,7 +29,14 @@ import type { Component, TUI } from "../tui";
20
29
  import { extractPrintableText, matchesKey } from "../keys";
21
30
  import { fuzzyFilter } from "../fuzzy";
22
31
  import { formatKeyHint, formatKeyHints } from "../app-keybindings";
23
- import { editorKey, editorKeys } from "../chrome/keybinding-hints";
32
+ import { boundKeys, editorKey, editorKeys } from "../chrome/keybinding-hints";
33
+ import type { KeyName } from "../key-hint-format";
34
+ import { col, compact, kbd, md, node, span, text } from "../native/describe";
35
+ import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../native/picker";
36
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
37
+ import { actionHint, hintsRow, type NativeHint } from "../native/overlay";
38
+ import { plainText } from "../native/spans";
39
+ import { isNativeRendering } from "../native/state";
24
40
  import { Input } from "../components/input";
25
41
  import { routeSgrMouseInput, type SgrMouseEvent } from "../mouse";
26
42
  import { truncateToWidth, visibleWidth } from "../utils";
@@ -36,14 +52,18 @@ import { theme } from "../theme/theme";
36
52
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
37
53
  import {
38
54
  buildBrowserItems,
55
+ MODEL_PICKER_COLUMNS,
39
56
  ModelBrowser,
40
57
  type ModelBrowserItem,
41
58
  modelSearchText,
59
+ thinkingDotToken,
42
60
  type RoleAssignments,
43
61
  resolveRoleAssignments,
44
62
  sortModelItems,
45
63
  } from "./model-browser";
46
64
  import {
65
+ describeHubFrame,
66
+ describeHubSidebar,
47
67
  HubFrame,
48
68
  moveStripSelection,
49
69
  type SidebarEntry as HubSidebarEntry,
@@ -140,6 +160,8 @@ export interface ModelHubCallbacks {
140
160
  export interface ModelHubOptions {
141
161
  /** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
142
162
  initialProviderId?: string;
163
+ /** `provider/id` of the session's model, marked current in the native picker. */
164
+ currentSelector?: string;
143
165
  }
144
166
 
145
167
  interface SidebarEntry extends HubSidebarEntry<"recent" | "roles" | "all" | "separator" | "provider"> {
@@ -187,12 +209,72 @@ type StripState =
187
209
  input: Input;
188
210
  };
189
211
 
212
+ /** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
213
+ type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking";
214
+
215
+ /** Printable keys of the Roles view and the command each runs. */
216
+ const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
217
+ x: "clear",
218
+ f: "fallback",
219
+ c: "cycle",
220
+ "[": "earlier",
221
+ "]": "later",
222
+ n: "new",
223
+ t: "thinking",
224
+ };
225
+
226
+ /** Picker fact columns of the Roles view. */
227
+ const ROLE_PICKER_COLUMNS: readonly TspPickerColumn[] = [
228
+ { id: "model", head: "Model", format: "text", priority: 2 },
229
+ { id: "thinking", head: "Thinking", format: "dim", priority: 1 },
230
+ ];
231
+
232
+ /** Kind tab labels (sentence case; acronyms stay upper). */
233
+ const MODEL_KIND_LABELS: Record<"all" | ModelKind, string> = {
234
+ all: "All",
235
+ chat: "Chat",
236
+ tiny: "Tiny",
237
+ image: "Image",
238
+ tts: "TTS",
239
+ stt: "STT",
240
+ search: "Search",
241
+ judge: "Judge",
242
+ embedding: "Embedding",
243
+ rerank: "Rerank",
244
+ video: "Video",
245
+ };
246
+
247
+ /** A provider mark's initials: `amazon-bedrock` → `AB`, `anthropic` → `An`. */
248
+ function providerInitials(providerId: string): string {
249
+ const words = providerId.split(/[-_\s.]+/).filter(word => word.length > 0);
250
+ if (words.length >= 2) return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase();
251
+ const word = words[0] ?? providerId;
252
+ return `${word.charAt(0).toUpperCase()}${word.charAt(1)}`;
253
+ }
254
+
190
255
  const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
191
256
  const RECENT_LIMIT = 15;
192
257
  const MODEL_KIND_TABS: ReadonlyArray<"all" | ModelKind> = ["all", ...MODEL_KINDS];
193
258
  const ROLE_TABS = ["all", "chat", "kind"] as const;
194
259
  type RoleTab = (typeof ROLE_TABS)[number];
195
260
 
261
+ /** Stable native list-item key of a Roles-view row. */
262
+ function rolesRowKey(row: RolesRow, index: number): string {
263
+ switch (row.kind) {
264
+ case "role":
265
+ return `role:${row.role}`;
266
+ case "chainKey":
267
+ return `chain:${row.role}`;
268
+ case "fallback":
269
+ return `fallback:${row.role}:${row.chainIndex}`;
270
+ case "separator":
271
+ return `sep:${index}`;
272
+ case "newRole":
273
+ case "newFallback":
274
+ return row.kind;
275
+ }
276
+ }
277
+
196
278
  /**
197
279
  * Providers already auto-refreshed this process. Selecting a provider fetches
198
280
  * its live model list at most once per application lifetime (surviving hub
@@ -292,6 +374,24 @@ export class ModelHubComponent implements Component {
292
374
  );
293
375
  #lockedLoginLine: number | null = null;
294
376
  #rolesRowStart = 1;
377
+ /** Bumped on every visible-state change; the described node is rebuilt when it moves. */
378
+ #nativeVersion = 0;
379
+ #nativeCache: { version: number; picker: boolean; node: NativeNode } | undefined;
380
+ #currentSelector: string | undefined;
381
+ /** The opening online catalog refresh is still in flight (an empty scope then shows as loading). */
382
+ #catalogRefreshing = false;
383
+ #kindTabsMemo: { candidates: readonly ModelBrowserItem[]; tabs: TspPickerProps["tabs"] } | undefined;
384
+ #pickerRoleItems:
385
+ | { rows: readonly RolesRow[]; roles: RoleAssignments; cycle: string; items: readonly TspPickerItem[] }
386
+ | undefined;
387
+ #pickerRolePreview:
388
+ | {
389
+ row: RolesRow | undefined;
390
+ roles: RoleAssignments;
391
+ rows: readonly RolesRow[];
392
+ children: readonly NativeChild[];
393
+ }
394
+ | undefined;
295
395
 
296
396
  constructor(
297
397
  tui: TUI,
@@ -306,6 +406,7 @@ export class ModelHubComponent implements Component {
306
406
  this.#registry = registry;
307
407
  this.#scopedModels = scopedModels;
308
408
  this.#callbacks = callbacks;
409
+ this.#currentSelector = options.currentSelector;
309
410
 
310
411
  this.#browser = new ModelBrowser(settings, {
311
412
  emptyText: () => this.#emptyStateMessage(),
@@ -332,13 +433,17 @@ export class ModelHubComponent implements Component {
332
433
  // registry-independent, so the reload would only repeat the hydration
333
434
  // above.
334
435
  if (this.#scopedModels.length === 0) {
436
+ this.#catalogRefreshing = true;
335
437
  this.#registry
336
438
  .refresh("online")
337
439
  .then(() => this.#syncFromRegistryState())
338
440
  .catch(error => {
339
441
  this.#configError = error instanceof Error ? error.message : String(error);
340
442
  })
341
- .finally(() => this.#tui.requestRender());
443
+ .finally(() => {
444
+ this.#catalogRefreshing = false;
445
+ this.#requestRender();
446
+ });
342
447
  }
343
448
  }
344
449
 
@@ -356,9 +461,16 @@ export class ModelHubComponent implements Component {
356
461
  }
357
462
 
358
463
  invalidate(): void {
464
+ this.#nativeVersion++;
359
465
  this.#frame.invalidate();
360
466
  }
361
467
 
468
+ /** Request a repaint after a state change, invalidating the described node. */
469
+ #requestRender(): void {
470
+ this.#nativeVersion++;
471
+ this.#tui.requestRender();
472
+ }
473
+
362
474
  // ═══════════════════════════════════════════════════════════════════════
363
475
  // Data pipeline
364
476
  // ═══════════════════════════════════════════════════════════════════════
@@ -375,6 +487,7 @@ export class ModelHubComponent implements Component {
375
487
 
376
488
  /** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
377
489
  #syncFromRegistryState(): void {
490
+ this.#nativeVersion++;
378
491
  // A background rebuild (provider refresh, mutation) must not yank the
379
492
  // sidebar viewport: remember the focused entry's screen row so it — or
380
493
  // its nearest survivor — stays put after #buildSidebar reshuffles entries.
@@ -714,10 +827,23 @@ export class ModelHubComponent implements Component {
714
827
  this.#rolesRows = rows;
715
828
  }
716
829
 
830
+ /**
831
+ * Rows the Roles view lists on the All tab, separators aside: the picker's
832
+ * unfiltered `total`, counted in the same rows Tern counts as shown.
833
+ */
834
+ #allRolesRowCount(): number {
835
+ const chains = this.#fallbackChains();
836
+ // "New role…" and "New fallback chain…", then each role and model chain with its fallback entries.
837
+ let count = 2;
838
+ for (const role of this.#visibleRoleIds()) count += 1 + (chains[role]?.length ?? 0);
839
+ for (const key in chains) if (key.includes("/")) count += 1 + chains[key].length;
840
+ return count;
841
+ }
842
+
717
843
  /** Refresh roles + dependent state after a settings mutation (assign/unassign). */
718
844
  #refreshAfterMutation(): void {
719
845
  this.#syncFromRegistryState();
720
- this.#tui.requestRender();
846
+ this.#requestRender();
721
847
  }
722
848
 
723
849
  /** Re-sync after an asynchronous callback finishes mutating settings. */
@@ -732,6 +858,7 @@ export class ModelHubComponent implements Component {
732
858
  * never silently vanish.
733
859
  */
734
860
  #onQueryChanged(query: string): void {
861
+ this.#nativeVersion++;
735
862
  if (!query.trim()) {
736
863
  this.#searchCounts = null;
737
864
  this.#composeEntries();
@@ -782,13 +909,14 @@ export class ModelHubComponent implements Component {
782
909
  // ═══════════════════════════════════════════════════════════════════════
783
910
 
784
911
  #startRefreshSpinner(): void {
785
- if (this.#refreshSpinnerInterval) return;
912
+ // Native surfaces clock the refresh spinner themselves.
913
+ if (this.#refreshSpinnerInterval || isNativeRendering()) return;
786
914
  this.#refreshSpinnerInterval = setInterval(() => {
787
915
  const frameCount = theme.spinnerFrames.length;
788
916
  if (frameCount > 0) {
789
917
  this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
790
918
  }
791
- this.#tui.requestRender();
919
+ this.#requestRender();
792
920
  }, 80);
793
921
  }
794
922
 
@@ -877,7 +1005,7 @@ export class ModelHubComponent implements Component {
877
1005
  autoRefreshedProviders.add(providerId);
878
1006
  void this.#refreshProviderInBackground(providerId, true);
879
1007
  }
880
- this.#tui.requestRender();
1008
+ this.#requestRender();
881
1009
  }
882
1010
  }
883
1011
 
@@ -929,6 +1057,8 @@ export class ModelHubComponent implements Component {
929
1057
  // ═══════════════════════════════════════════════════════════════════════
930
1058
 
931
1059
  #activateItem(item: ModelBrowserItem): void {
1060
+ // Also reached from the browser's own native list events, which bypass handleInput.
1061
+ this.#nativeVersion++;
932
1062
  if (this.#assigning) {
933
1063
  const target = this.#assigning;
934
1064
  this.#assigning = null;
@@ -965,21 +1095,21 @@ export class ModelHubComponent implements Component {
965
1095
  #finishAssignment(result: void | boolean | Promise<void | boolean>, onSuccess: () => void): void {
966
1096
  if (!(result instanceof Promise)) {
967
1097
  if (result !== false) onSuccess();
968
- else this.#tui.requestRender();
1098
+ else this.#requestRender();
969
1099
  return;
970
1100
  }
971
1101
  this.#assignmentPending = true;
972
- this.#tui.requestRender();
1102
+ this.#requestRender();
973
1103
  void result.then(
974
1104
  applied => {
975
1105
  this.#assignmentPending = false;
976
1106
  if (this.#disposed) return;
977
1107
  if (applied !== false) onSuccess();
978
- else this.#tui.requestRender();
1108
+ else this.#requestRender();
979
1109
  },
980
1110
  () => {
981
1111
  this.#assignmentPending = false;
982
- if (!this.#disposed) this.#tui.requestRender();
1112
+ if (!this.#disposed) this.#requestRender();
983
1113
  },
984
1114
  );
985
1115
  }
@@ -1562,6 +1692,7 @@ export class ModelHubComponent implements Component {
1562
1692
  }
1563
1693
 
1564
1694
  handleInput(data: string): void {
1695
+ this.#nativeVersion++;
1565
1696
  if (this.#assignmentPending) {
1566
1697
  if (matchesSelectCancel(data)) this.#callbacks.onCancel();
1567
1698
  return;
@@ -1577,16 +1708,7 @@ export class ModelHubComponent implements Component {
1577
1708
  }
1578
1709
 
1579
1710
  if (matchesSelectCancel(data)) {
1580
- if (this.#assigning !== null) {
1581
- this.#cancelAssign();
1582
- return;
1583
- }
1584
- const entry = this.#activeEntry();
1585
- if (this.#isBrowserView(entry) && this.#browser.query.length > 0) {
1586
- this.#browser.handleCancel();
1587
- return;
1588
- }
1589
- this.#callbacks.onCancel();
1711
+ this.#cancel();
1590
1712
  return;
1591
1713
  }
1592
1714
 
@@ -1686,6 +1808,27 @@ export class ModelHubComponent implements Component {
1686
1808
  }
1687
1809
  }
1688
1810
 
1811
+ /** The cancel key's ladder: close a strip, leave assign mode, clear the query, then close the hub. */
1812
+ #cancel(): void {
1813
+ if (this.#assignmentPending) {
1814
+ this.#callbacks.onCancel();
1815
+ return;
1816
+ }
1817
+ if (this.#strip) {
1818
+ this.#closeStrip();
1819
+ return;
1820
+ }
1821
+ if (this.#assigning !== null) {
1822
+ this.#cancelAssign();
1823
+ return;
1824
+ }
1825
+ if (this.#isBrowserView(this.#activeEntry()) && this.#browser.query.length > 0) {
1826
+ this.#browser.handleCancel();
1827
+ return;
1828
+ }
1829
+ this.#callbacks.onCancel();
1830
+ }
1831
+
1689
1832
  #isBrowserView(entry: SidebarEntry): boolean {
1690
1833
  if (this.#assigning !== null) return true;
1691
1834
  return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
@@ -1804,70 +1947,70 @@ export class ModelHubComponent implements Component {
1804
1947
  this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
1805
1948
  return;
1806
1949
  }
1807
- const row = this.#rolesRows[this.#roleIndex];
1808
- const role = row?.kind === "role" ? row.role : undefined;
1809
1950
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
1810
- if (row) this.#activateRolesRow(row);
1951
+ this.#runRolesAction("pick");
1811
1952
  return;
1812
1953
  }
1813
1954
  if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
1814
- if (role) this.#unassignRole(role);
1815
- else if (row?.kind === "fallback") this.#removeFallback(row);
1816
- else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1955
+ this.#runRolesAction("clear");
1817
1956
  return;
1818
1957
  }
1819
1958
  // Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later —
1820
1959
  // cycle order on a role row, chain order on a fallback row.
1821
1960
  if (matchesKey(data, "shift+up")) {
1822
- if (role) this.#moveCycleMembership(role, -1);
1823
- else if (row?.kind === "fallback") this.#moveFallback(row, -1);
1961
+ this.#runRolesAction("earlier");
1824
1962
  return;
1825
1963
  }
1826
1964
  if (matchesKey(data, "shift+down")) {
1827
- if (role) this.#moveCycleMembership(role, 1);
1828
- else if (row?.kind === "fallback") this.#moveFallback(row, 1);
1965
+ this.#runRolesAction("later");
1829
1966
  return;
1830
1967
  }
1831
1968
  const printable = extractPrintableText(data);
1832
- if (printable === "x") {
1833
- if (role) this.#unassignRole(role);
1834
- else if (row?.kind === "fallback") this.#removeFallback(row);
1835
- else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1836
- return;
1837
- }
1838
- if (printable === "f") {
1839
- if (row?.kind === "newFallback") this.#startAssignFallbackKey();
1840
- else if (row && row.kind !== "newRole" && row.kind !== "separator") {
1841
- this.#startAssignFallback(row.role, null);
1842
- }
1843
- return;
1969
+ if (printable !== undefined && Object.hasOwn(ROLES_ACTION_KEYS, printable)) {
1970
+ this.#runRolesAction(ROLES_ACTION_KEYS[printable]!);
1844
1971
  }
1845
- if (printable === "c") {
1846
- if (role) this.#toggleCycleMembership(role);
1847
- return;
1848
- }
1849
- if (printable === "[") {
1850
- if (role) this.#moveCycleMembership(role, -1);
1851
- else if (row?.kind === "fallback") this.#moveFallback(row, -1);
1852
- return;
1853
- }
1854
- if (printable === "]") {
1855
- if (role) this.#moveCycleMembership(role, 1);
1856
- else if (row?.kind === "fallback") this.#moveFallback(row, 1);
1857
- return;
1858
- }
1859
- if (printable === "n") {
1860
- this.#openRoleNameStrip();
1861
- return;
1862
- }
1863
- if (printable === "t") {
1864
- if (role) {
1865
- const target = this.#roleThinkingTarget(role);
1866
- if (target) this.#openThinkingStrip(target.item, role, true, target.scope);
1867
- } else if (row?.kind === "fallback") {
1868
- this.#openFallbackThinkingStrip(row);
1972
+ }
1973
+
1974
+ /** One Roles-view command on the selected row (keys and action-bar buttons share it). */
1975
+ #runRolesAction(action: RolesAction): void {
1976
+ const row = this.#rolesRows[this.#roleIndex];
1977
+ const role = row?.kind === "role" ? row.role : undefined;
1978
+ switch (action) {
1979
+ case "pick":
1980
+ if (row) this.#activateRolesRow(row);
1981
+ return;
1982
+ case "clear":
1983
+ if (role) this.#unassignRole(role);
1984
+ else if (row?.kind === "fallback") this.#removeFallback(row);
1985
+ else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
1986
+ return;
1987
+ case "fallback":
1988
+ if (row?.kind === "newFallback") this.#startAssignFallbackKey();
1989
+ else if (row && row.kind !== "newRole" && row.kind !== "separator") {
1990
+ this.#startAssignFallback(row.role, null);
1991
+ }
1992
+ return;
1993
+ case "cycle":
1994
+ if (role) this.#toggleCycleMembership(role);
1995
+ return;
1996
+ case "earlier":
1997
+ case "later": {
1998
+ const delta = action === "earlier" ? -1 : 1;
1999
+ if (role) this.#moveCycleMembership(role, delta);
2000
+ else if (row?.kind === "fallback") this.#moveFallback(row, delta);
2001
+ return;
1869
2002
  }
1870
- return;
2003
+ case "new":
2004
+ this.#openRoleNameStrip();
2005
+ return;
2006
+ case "thinking":
2007
+ if (role) {
2008
+ const target = this.#roleThinkingTarget(role);
2009
+ if (target) this.#openThinkingStrip(target.item, role, true, target.scope);
2010
+ } else if (row?.kind === "fallback") {
2011
+ this.#openFallbackThinkingStrip(row);
2012
+ }
2013
+ return;
1871
2014
  }
1872
2015
  }
1873
2016
 
@@ -1937,17 +2080,7 @@ export class ModelHubComponent implements Component {
1937
2080
 
1938
2081
  if (overSidebar) {
1939
2082
  const index = this.#sidebarEntryIndexAt(contentLine);
1940
- const clicked = index !== null ? this.#entries[index] : undefined;
1941
- if (clicked && clicked.kind !== "separator") {
1942
- const already = clicked.id === this.#activeEntryId;
1943
- if (clicked.kind === "roles") this.#assigning = null;
1944
- this.#setActiveEntry(clicked.id);
1945
- // A click on Roles is a deliberate dive into the rows.
1946
- if (clicked.kind === "roles") this.#focus = "list";
1947
- if (already && clicked.kind === "provider" && clicked.locked) {
1948
- this.#requestLogin(clicked);
1949
- }
1950
- }
2083
+ this.#clickSidebarEntry(index !== null ? this.#entries[index] : undefined);
1951
2084
  return true;
1952
2085
  }
1953
2086
 
@@ -1977,6 +2110,19 @@ export class ModelHubComponent implements Component {
1977
2110
  return true;
1978
2111
  }
1979
2112
 
2113
+ /** Pointer activation of a sidebar entry: pick the scope; a second click on a locked provider logs in. */
2114
+ #clickSidebarEntry(clicked: SidebarEntry | undefined): void {
2115
+ if (!clicked || clicked.kind === "separator") return;
2116
+ const already = clicked.id === this.#activeEntryId;
2117
+ if (clicked.kind === "roles") this.#assigning = null;
2118
+ this.#setActiveEntry(clicked.id);
2119
+ // A click on Roles is a deliberate dive into the rows.
2120
+ if (clicked.kind === "roles") this.#focus = "list";
2121
+ if (already && clicked.kind === "provider" && clicked.locked) {
2122
+ this.#requestLogin(clicked);
2123
+ }
2124
+ }
2125
+
1980
2126
  /** Map a content-line index to a sidebar entry index (accounting for scroll). */
1981
2127
  #sidebarEntryIndexAt(contentLine: number): number | null {
1982
2128
  const index = this.#frame.sidebarScroll + contentLine;
@@ -2438,4 +2584,1182 @@ export class ModelHubComponent implements Component {
2438
2584
  const height = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
2439
2585
  return this.#frame.render(width, height, this.#entries, this.#renderFooter(width - 4));
2440
2586
  }
2587
+
2588
+ // ═══════════════════════════════════════════════════════════════════════
2589
+ // Native (TSP) description
2590
+ // ═══════════════════════════════════════════════════════════════════════
2591
+
2592
+ /** A `picker` is its own sheet: the backend mounts it in `layer` without an `overlay` wrapper. */
2593
+ nativeSheet(cx: DescribeContext): boolean {
2594
+ return cx.supports("picker");
2595
+ }
2596
+
2597
+ describe(cx: DescribeContext): NativeNode {
2598
+ const usePicker = cx.supports("picker");
2599
+ const cached = this.#nativeCache;
2600
+ if (cached?.version === this.#nativeVersion && cached.picker === usePicker) return cached.node;
2601
+ if (usePicker) {
2602
+ const described = this.#describePicker();
2603
+ this.#nativeCache = { version: this.#nativeVersion, picker: true, node: described };
2604
+ return described;
2605
+ }
2606
+ const footer: NativeChild[] = [];
2607
+ const strip = this.#describeStrip();
2608
+ if (strip) footer.push(strip);
2609
+ footer.push(hintsRow(this.#footerHints()));
2610
+ const described = describeHubFrame(
2611
+ "omp.overlay.model-hub",
2612
+ "Models",
2613
+ describeHubSidebar(this.#entries, this.#activeEntryId, this.#nativeSidebarStyle, "scopes"),
2614
+ this.#describeBody(),
2615
+ node("col", { gap: "xs" }, footer, "footer"),
2616
+ );
2617
+ this.#nativeCache = { version: this.#nativeVersion, picker: false, node: described };
2618
+ return described;
2619
+ }
2620
+
2621
+ handleNativeEvent(event: NativeUiEvent): void {
2622
+ const picked = pickerEvent(event);
2623
+ if (picked) {
2624
+ this.#handlePickerEvent(picked);
2625
+ this.#requestRender();
2626
+ return;
2627
+ }
2628
+ // Same gate as the mouse router: nothing is interactive while an assignment applies.
2629
+ if (this.#assignmentPending) return;
2630
+ const target = event.key.slice(event.key.lastIndexOf("/") + 1);
2631
+ if (event.type === "action") {
2632
+ const entry = this.#activeEntry();
2633
+ if (target !== "login" || event.act !== "login") return;
2634
+ if (entry.kind !== "provider" || !entry.locked || this.#assigning !== null) return;
2635
+ this.#requestLogin(entry);
2636
+ } else if (event.type === "select" || event.type === "activate") {
2637
+ switch (target) {
2638
+ case "scopes":
2639
+ this.#clickSidebarEntry(this.#entries.find(entry => entry.id === event.item));
2640
+ break;
2641
+ case "roles": {
2642
+ if (this.#activeEntry().kind !== "roles" || this.#assigning !== null) return;
2643
+ const index = this.#rolesRows.findIndex((rowDef, i) => rolesRowKey(rowDef, i) === event.item);
2644
+ const rowDef = this.#rolesRows[index];
2645
+ if (!rowDef || rowDef.kind === "separator") return;
2646
+ this.#focus = "list";
2647
+ this.#roleIndex = index;
2648
+ this.#activateRolesRow(rowDef);
2649
+ break;
2650
+ }
2651
+ case "roleTabs": {
2652
+ const tab = ROLE_TABS.find(candidate => candidate === event.item);
2653
+ if (!tab) return;
2654
+ this.#roleTab = tab;
2655
+ this.#roleIndex = 0;
2656
+ this.#roleScrollStart = 0;
2657
+ this.#buildRolesRows();
2658
+ break;
2659
+ }
2660
+ case "kinds": {
2661
+ const kind = MODEL_KIND_TABS.find(candidate => candidate === event.item);
2662
+ if (!kind) return;
2663
+ this.#modelKindTab = kind;
2664
+ this.#applyModelKind();
2665
+ break;
2666
+ }
2667
+ case "strip": {
2668
+ // A chip click picks and applies it, like the footer mouse path.
2669
+ const strip = this.#strip;
2670
+ const index = Number(event.item);
2671
+ if (!strip || strip.kind === "roleName" || !Number.isInteger(index) || !strip.chips[index]) return;
2672
+ strip.index = index;
2673
+ this.#activateStripChip();
2674
+ break;
2675
+ }
2676
+ default:
2677
+ return;
2678
+ }
2679
+ } else {
2680
+ return;
2681
+ }
2682
+ this.#requestRender();
2683
+ }
2684
+
2685
+ /** Sidebar decoration for the native list: refreshing providers show an ellipsis, the status row spins. */
2686
+ #nativeSidebarStyle = (entry: SidebarEntry, index: number): Pick<SidebarStyle, "icon" | "annotation" | "muted"> => {
2687
+ const style = this.#sidebarStyle(entry, index);
2688
+ const refreshing = entry.providerId !== undefined && this.#refreshingProviders.has(entry.providerId);
2689
+ return { ...style, annotation: refreshing ? "…" : plainText(style.annotation) };
2690
+ };
2691
+
2692
+ #describeBody(): NativeNode {
2693
+ const children: NativeChild[] = [this.#describeStatus()];
2694
+ const entry = this.#activeEntry();
2695
+ if (entry.kind === "roles" && this.#assigning === null) {
2696
+ children.push(...this.#describeRolesView());
2697
+ } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
2698
+ children.push(this.#describeLockedView(entry));
2699
+ } else {
2700
+ children.push(
2701
+ node(
2702
+ "row",
2703
+ { gap: "sm", align: "center" },
2704
+ [
2705
+ text([span("Kind:", "dim")]),
2706
+ node(
2707
+ "tabs",
2708
+ { items: MODEL_KIND_TABS.map(kind => ({ id: kind, label: kind })), active: this.#modelKindTab },
2709
+ undefined,
2710
+ "kinds",
2711
+ ),
2712
+ ],
2713
+ "kindTabs",
2714
+ ),
2715
+ this.#browser,
2716
+ );
2717
+ }
2718
+ return node("col", { gap: "sm", grow: 1 }, children, "body");
2719
+ }
2720
+
2721
+ #describeStatus(): NativeNode {
2722
+ if (this.#assignmentPending) {
2723
+ return node("spinner", { label: [span("Applying model…", "accent")] }, undefined, "status");
2724
+ }
2725
+ let spans: TspSpan[];
2726
+ const assigning = this.#assigning;
2727
+ if (assigning !== null) {
2728
+ if (assigning.kind === "fallbackKey") {
2729
+ spans = [span("New fallback chain — pick the model it protects", "accent")];
2730
+ } else {
2731
+ const info = this.#settings.getRoleInfo(assigning.role);
2732
+ const label = info.tag ?? info.name ?? assigning.role;
2733
+ const verb =
2734
+ assigning.kind === "role"
2735
+ ? "Assigning"
2736
+ : assigning.index === null
2737
+ ? "Adding fallback for"
2738
+ : "Replacing fallback of";
2739
+ spans = [span(`${verb} `, "accent"), span(label, "accent strong")];
2740
+ }
2741
+ return node("text", { spans, truncate: "end" }, undefined, "status");
2742
+ }
2743
+ const entry = this.#activeEntry();
2744
+ if (this.#configError && entry.kind !== "provider") {
2745
+ return node("text", { spans: [span(this.#configError, "error")], wrap: "word" }, undefined, "status");
2746
+ }
2747
+ const scopedSuffix = this.#scopedModels.length > 0 ? " · --models scope" : "";
2748
+ switch (entry.kind) {
2749
+ case "recent":
2750
+ spans = [span(`Recently used models${scopedSuffix}`, "muted")];
2751
+ break;
2752
+ case "roles":
2753
+ spans = [
2754
+ span("Model roles — ", "muted"),
2755
+ span("f", "key"),
2756
+ span(" adds a retry fallback, cleared roles fall back to auto-selection", "muted"),
2757
+ ];
2758
+ break;
2759
+ case "provider":
2760
+ if (entry.locked) {
2761
+ spans = [span(`${entry.label} · not configured`, "muted")];
2762
+ } else if (entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
2763
+ return node(
2764
+ "spinner",
2765
+ { label: [span(`${entry.label} · refreshing model list…`, "muted")] },
2766
+ undefined,
2767
+ "status",
2768
+ );
2769
+ } else {
2770
+ spans = [span(`${entry.label} · ${entry.annotation ?? "0"} models${scopedSuffix}`, "muted")];
2771
+ }
2772
+ break;
2773
+ default:
2774
+ spans = [span(`All available models${scopedSuffix}`, "muted")];
2775
+ break;
2776
+ }
2777
+ return node("text", { spans, truncate: "end" }, undefined, "status");
2778
+ }
2779
+
2780
+ #describeRolesView(): NativeNode[] {
2781
+ const tabs = node(
2782
+ "row",
2783
+ { gap: "sm", align: "center" },
2784
+ [
2785
+ text([span("Roles:", "dim")]),
2786
+ node(
2787
+ "tabs",
2788
+ {
2789
+ items: ROLE_TABS.map(tab => ({ id: tab, label: tab === "kind" ? "kinds" : tab })),
2790
+ active: this.#roleTab,
2791
+ },
2792
+ undefined,
2793
+ "roleTabs",
2794
+ ),
2795
+ ],
2796
+ "roleTabsRow",
2797
+ );
2798
+ const cycleOrder = this.#cycleOrder();
2799
+ const items = this.#rolesRows.map((rowDef, index) => this.#describeRolesRow(rowDef, index, cycleOrder));
2800
+ const selectedRow = this.#rolesRows[this.#roleIndex];
2801
+ const list = node(
2802
+ "list",
2803
+ {
2804
+ selected: selectedRow ? rolesRowKey(selectedRow, this.#roleIndex) : null,
2805
+ virtual: true,
2806
+ grow: 1,
2807
+ tone: this.#focus === "list" ? "accent" : undefined,
2808
+ aria: "Roles",
2809
+ },
2810
+ items,
2811
+ "roles",
2812
+ );
2813
+ return [tabs, list, this.#describeCycle(cycleOrder)];
2814
+ }
2815
+
2816
+ #describeRolesRow(rowDef: RolesRow, index: number, cycleOrder: readonly string[]): NativeNode {
2817
+ const key = rolesRowKey(rowDef, index);
2818
+ switch (rowDef.kind) {
2819
+ case "separator":
2820
+ return node("rule", undefined, undefined, key);
2821
+ case "newRole":
2822
+ return node("item", { label: [span("+ New role…", "dim")] }, undefined, key);
2823
+ case "newFallback":
2824
+ return node("item", { label: [span("+ New fallback…", "dim")] }, undefined, key);
2825
+ case "chainKey": {
2826
+ const slash = rowDef.role.lastIndexOf("/");
2827
+ return node(
2828
+ "item",
2829
+ {
2830
+ label: [
2831
+ span(`${theme.status.shadowed} `, "dim"),
2832
+ span(rowDef.role.slice(0, slash + 1), "dim"),
2833
+ span(rowDef.role.slice(slash + 1)),
2834
+ ],
2835
+ },
2836
+ undefined,
2837
+ key,
2838
+ );
2839
+ }
2840
+ case "fallback":
2841
+ return node("item", { label: [span(" ↳ ", "dim"), span(rowDef.selector, "muted")] }, undefined, key);
2842
+ case "role":
2843
+ break;
2844
+ }
2845
+ const role = rowDef.role;
2846
+ const info = this.#settings.getRoleInfo(role);
2847
+ const assignment = this.#roles[role];
2848
+ const tag = info.tag ?? info.name ?? role;
2849
+ let label: TspSpan[];
2850
+ let detail: TspSpan[];
2851
+ const value: TspSpan[] = [];
2852
+ if (assignment && !assignment.autoSelected) {
2853
+ const color = info.color ?? "muted";
2854
+ label = [span(`${theme.status.enabled} `, color), span(tag, color)];
2855
+ detail = [span(`${assignment.model.provider}/`, "dim"), span(assignment.model.id)];
2856
+ if (assignment.thinkingLevel !== ThinkingLevel.Inherit) {
2857
+ const glyph = thinkingLevelGlyph(assignment.thinkingLevel, theme);
2858
+ const levelLabel = getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label;
2859
+ value.push(span(glyph ? `${glyph} ${levelLabel}` : levelLabel, "dim"));
2860
+ }
2861
+ } else {
2862
+ label = [span(`${theme.status.shadowed} `, "dim"), span(tag, "dim")];
2863
+ detail = assignment
2864
+ ? [span(`auto → ${assignment.model.provider}/${assignment.model.id}`, "dim")]
2865
+ : [span("—", "dim")];
2866
+ }
2867
+ // Quick-cycle membership badge (`⟳ 2` = second stop of the ctrl+p cycle).
2868
+ const cycleIndex = cycleOrder.indexOf(role);
2869
+ if (cycleIndex >= 0) {
2870
+ if (value.length > 0) value.push(span(" "));
2871
+ value.push(span(`${theme.icon.loop} ${cycleIndex + 1}`, "accent"));
2872
+ }
2873
+ return node("item", { label, detail, value: value.length > 0 ? value : undefined }, undefined, key);
2874
+ }
2875
+
2876
+ /** Live preview of the quick-switch cycle; the selected row's role is emphasized. */
2877
+ #describeCycle(cycleOrder: readonly string[]): NativeNode {
2878
+ const [cycleKey] = boundKeys("app.model.cycleForward", ["ctrl+p"]);
2879
+ const keycap: NativeChild[] = cycleKey ? [kbd(cycleKey)] : [];
2880
+ if (cycleOrder.length === 0) {
2881
+ return node(
2882
+ "row",
2883
+ { gap: "xs", align: "center", wrap: true },
2884
+ [
2885
+ ...keycap,
2886
+ text([span("cycle is empty — press ", "dim"), span("c", "key"), span(" on a role to add it", "dim")]),
2887
+ ],
2888
+ "cycle",
2889
+ );
2890
+ }
2891
+ const selectedRow = this.#rolesRows[this.#roleIndex];
2892
+ const selectedRole =
2893
+ selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : "";
2894
+ const track: TspSpan[] = [];
2895
+ for (const role of cycleOrder) {
2896
+ if (track.length > 0) track.push(span(" › ", "dim"));
2897
+ track.push(span(role, role === selectedRole ? "accent strong" : "muted"));
2898
+ }
2899
+ return node(
2900
+ "row",
2901
+ { gap: "xs", align: "center", wrap: true },
2902
+ [...keycap, text([span("cycle:", "dim")]), text(track, { wrap: "word" })],
2903
+ "cycle",
2904
+ );
2905
+ }
2906
+
2907
+ #describeLockedView(entry: SidebarEntry): NativeNode {
2908
+ const children: NativeChild[] = [
2909
+ text([span(`${entry.label} has no credentials configured`, "warning")], { wrap: "word" }),
2910
+ ];
2911
+ const envVars = entry.providerId ? (providerEntry(entry.providerId)?.envVars ?? []) : [];
2912
+ children.push(
2913
+ text(
2914
+ [
2915
+ span(
2916
+ envVars.length > 0
2917
+ ? `Set ${envVars.join(" or ")} in your environment, or add a key in config.`
2918
+ : "Add an API key for this provider in config.",
2919
+ "muted",
2920
+ ),
2921
+ ],
2922
+ { wrap: "word" },
2923
+ ),
2924
+ );
2925
+ if (entry.oauth) {
2926
+ children.push(
2927
+ node(
2928
+ "row",
2929
+ { gap: "sm", align: "center", actions: { click: "login" } },
2930
+ [text([span("Log in with OAuth", "accent")]), node("kbd", { keys: ["enter"] })],
2931
+ "login",
2932
+ ),
2933
+ );
2934
+ }
2935
+ const catalogCount = entry.catalogCount ?? 0;
2936
+ if (catalogCount > 0) {
2937
+ const models = this.#scopedModels.length > 0 ? [] : this.#registry.getAll("all");
2938
+ const items: NativeNode[] = [];
2939
+ for (const model of models) {
2940
+ if (model.provider !== entry.providerId) continue;
2941
+ items.push(node("item", { label: model.id, disabled: true }, undefined, model.id));
2942
+ }
2943
+ children.push(
2944
+ node(
2945
+ "section",
2946
+ { head: [span(`${catalogCount} models in catalog`, "dim")] },
2947
+ [node("list", { selected: null, virtual: true }, items, "catalog")],
2948
+ "catalog",
2949
+ ),
2950
+ );
2951
+ }
2952
+ return node("col", { gap: "sm", grow: 1 }, children, "locked");
2953
+ }
2954
+
2955
+ // ─── Picker (data-first `picker` kind, NATIVE_REDESIGN.md §4.7) ─────────
2956
+
2957
+ #describePicker(): NativeNode {
2958
+ const entry = this.#activeEntry();
2959
+ const rolesView = entry.kind === "roles" && this.#assigning === null;
2960
+ const lockedView = entry.kind === "provider" && entry.locked === true && this.#assigning === null;
2961
+ const strip = this.#strip;
2962
+ const props: TspPickerProps = {
2963
+ title: "Models",
2964
+ subtitle: this.#pickerSubtitle(entry, rolesView),
2965
+ icon: "cpu",
2966
+ noun: rolesView ? "roles" : "models",
2967
+ size: "lg",
2968
+ layout: "rows",
2969
+ preview: "side",
2970
+ query: this.#browser.query,
2971
+ cursor: this.#browser.cursor,
2972
+ placeholder: "Search models…",
2973
+ scopes: this.#pickerScopes(),
2974
+ scope: entry.id,
2975
+ actions: this.#pickerActions(entry, rolesView, lockedView),
2976
+ strip: strip ? this.#pickerStrip(strip) : null,
2977
+ focus: strip ? "strip" : this.#focus === "scope" ? "scopes" : "list",
2978
+ };
2979
+ if (rolesView) {
2980
+ const row = this.#rolesRows[this.#roleIndex];
2981
+ return picker(
2982
+ {
2983
+ ...props,
2984
+ tabs: ROLE_TABS.map(tab => ({
2985
+ id: tab,
2986
+ label: tab === "all" ? "All" : tab === "chat" ? "Chat" : "Kinds",
2987
+ count: this.#visibleRoleIds().filter(
2988
+ role => tab === "all" || this.#settings.getRoleInfo(role).section === tab,
2989
+ ).length,
2990
+ })),
2991
+ tab: this.#roleTab,
2992
+ columns: ROLE_PICKER_COLUMNS,
2993
+ items: this.#rolePickerItems(),
2994
+ order: this.#rolePickerOrder(),
2995
+ selected: row && row.kind !== "separator" ? rolesRowKey(row, this.#roleIndex) : null,
2996
+ total: this.#allRolesRowCount(),
2997
+ },
2998
+ this.#rolePreview(row),
2999
+ );
3000
+ }
3001
+ const catalogue = this.#browser.pickerItems(this.#availableItems);
3002
+ const current = this.#currentSelector ? [this.#currentSelector] : undefined;
3003
+ if (lockedView) {
3004
+ return picker(
3005
+ {
3006
+ ...props,
3007
+ columns: MODEL_PICKER_COLUMNS,
3008
+ ...catalogue,
3009
+ order: [],
3010
+ selected: null,
3011
+ total: entry.catalogCount ?? 0,
3012
+ empty: this.#lockedMessage(entry),
3013
+ ...(current ? { current } : {}),
3014
+ },
3015
+ this.#lockedPreview(entry),
3016
+ );
3017
+ }
3018
+ const query = this.#browser.query.trim();
3019
+ const view = this.#browser.pickerOrder({
3020
+ providers: entry.kind === "all" && query.length === 0,
3021
+ rest: entry.kind === "provider" ? entry.label : query ? "Matches" : "All models",
3022
+ });
3023
+ const refreshing =
3024
+ this.#catalogRefreshing ||
3025
+ (entry.kind === "provider" && this.#refreshingProviders.has(entry.providerId ?? ""));
3026
+ const state =
3027
+ view.count > 0 || query
3028
+ ? "ready"
3029
+ : refreshing
3030
+ ? "loading"
3031
+ : this.#configError && !this.#assigning
3032
+ ? "error"
3033
+ : "ready";
3034
+ return picker(
3035
+ {
3036
+ ...props,
3037
+ tabs: this.#pickerKindTabs(),
3038
+ tab: this.#modelKindTab,
3039
+ columns: MODEL_PICKER_COLUMNS,
3040
+ ...catalogue,
3041
+ order: view.order,
3042
+ ...(view.hits ? { hits: view.hits } : {}),
3043
+ selected: this.#browser.pickerSelected,
3044
+ ...(current ? { current } : {}),
3045
+ total: this.#browser.baseCount,
3046
+ state,
3047
+ ...(state === "error" && this.#configError ? { message: this.#configError } : {}),
3048
+ empty:
3049
+ this.#emptyStateMessage()?.trim() ??
3050
+ (entry.kind === "provider" ? `No models from ${entry.label} yet` : "No models available in this scope"),
3051
+ },
3052
+ this.#browser.pickerPreview("full", this.#currentSelector),
3053
+ );
3054
+ }
3055
+
3056
+ /** What the hub is doing when it is more than browsing: applying, assigning, refreshing, a config error. */
3057
+ #pickerSubtitle(entry: SidebarEntry, rolesView: boolean): TspPickerProps["subtitle"] {
3058
+ if (this.#assignmentPending) return "Applying model…";
3059
+ const assigning = this.#assigning;
3060
+ if (assigning !== null) {
3061
+ if (assigning.kind === "fallbackKey") return "New fallback chain — pick the model it protects";
3062
+ const info = this.#settings.getRoleInfo(assigning.role);
3063
+ const label = info.tag ?? info.name ?? assigning.role;
3064
+ const verb =
3065
+ assigning.kind === "role"
3066
+ ? "Assigning"
3067
+ : assigning.index === null
3068
+ ? "Adding fallback for"
3069
+ : "Replacing fallback of";
3070
+ return [span(`${verb} `), span(label, "strong")];
3071
+ }
3072
+ if (this.#configError && entry.kind !== "provider") return [span(this.#configError, "error")];
3073
+ if (entry.kind === "provider" && entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
3074
+ return `${entry.label} · refreshing model list…`;
3075
+ }
3076
+ if (this.#scopedModels.length > 0) return "--models scope";
3077
+ if (rolesView) return "Cleared roles fall back to auto-selection";
3078
+ return undefined;
3079
+ }
3080
+
3081
+ /** The sidebar as picker scopes: Roles, All models, then signed-in and signed-out providers. */
3082
+ #pickerScopes(): TspPickerScope[] {
3083
+ const counts = this.#searchCounts;
3084
+ const oauthIds = new Set(getOAuthProviders().map(provider => provider.id));
3085
+ const scopes: TspPickerScope[] = [];
3086
+ for (const entry of this.#entries) {
3087
+ switch (entry.kind) {
3088
+ case "separator":
3089
+ break;
3090
+ case "roles":
3091
+ scopes.push({ id: entry.id, label: "Roles", icon: "sparkles", count: this.#visibleRoleIds().length });
3092
+ break;
3093
+ case "all":
3094
+ scopes.push({
3095
+ id: entry.id,
3096
+ label: "All models",
3097
+ icon: "list",
3098
+ count: counts ? this.#searchTotal : this.#availableItems.length,
3099
+ });
3100
+ break;
3101
+ case "recent":
3102
+ scopes.push({
3103
+ id: entry.id,
3104
+ label: "Recent",
3105
+ icon: "clock",
3106
+ count: counts ? this.#recentSearchCount : this.#recentItems.length,
3107
+ });
3108
+ break;
3109
+ case "provider": {
3110
+ const providerId = entry.providerId ?? entry.label;
3111
+ const mark = { text: providerInitials(providerId), seed: providerId };
3112
+ if (entry.locked) {
3113
+ const envVars = providerEntry(providerId)?.envVars ?? [];
3114
+ scopes.push({
3115
+ id: entry.id,
3116
+ label: entry.label,
3117
+ mark,
3118
+ group: "Not signed in",
3119
+ disabled: oauthIds.has(providerId)
3120
+ ? "Sign in with /login"
3121
+ : envVars.length > 0
3122
+ ? `Set ${envVars.join(" or ")} to sign in`
3123
+ : "Add an API key in config to sign in",
3124
+ dot: "muted",
3125
+ });
3126
+ break;
3127
+ }
3128
+ const dot = this.#providerDot(providerId);
3129
+ scopes.push({
3130
+ id: entry.id,
3131
+ label: entry.label,
3132
+ mark,
3133
+ group: "Providers",
3134
+ count: counts ? (counts.get(providerId) ?? 0) : Number(entry.annotation ?? 0),
3135
+ ...(dot ? { dot } : {}),
3136
+ });
3137
+ break;
3138
+ }
3139
+ }
3140
+ }
3141
+ return scopes;
3142
+ }
3143
+
3144
+ /** Discovery state as a scope dot: refreshing, ok, cached/empty, unavailable, signed out. */
3145
+ #providerDot(providerId: string): TspPickerScope["dot"] {
3146
+ if (this.#refreshingProviders.has(providerId)) return "pending";
3147
+ switch (this.#registry.getProviderDiscoveryState(providerId)?.status) {
3148
+ case "ok":
3149
+ return "success";
3150
+ case "cached":
3151
+ case "empty":
3152
+ return "warning";
3153
+ case "unavailable":
3154
+ return "error";
3155
+ case "unauthenticated":
3156
+ return "muted";
3157
+ default:
3158
+ return undefined;
3159
+ }
3160
+ }
3161
+
3162
+ /** Kind tabs with their counts in the active scope; the same array while the scope's models are unchanged. */
3163
+ #pickerKindTabs(): TspPickerProps["tabs"] {
3164
+ const candidates = this.#candidateItems;
3165
+ if (this.#kindTabsMemo?.candidates === candidates) return this.#kindTabsMemo.tabs;
3166
+ const counts = new Map<string, number>();
3167
+ for (const item of candidates) {
3168
+ const kind = modelKind(item.model);
3169
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
3170
+ }
3171
+ const tabs = MODEL_KIND_TABS.map(kind => ({
3172
+ id: kind,
3173
+ label: MODEL_KIND_LABELS[kind],
3174
+ count: kind === "all" ? candidates.length : (counts.get(kind) ?? 0),
3175
+ }));
3176
+ this.#kindTabsMemo = { candidates, tabs };
3177
+ return tabs;
3178
+ }
3179
+
3180
+ /** The action bar for the current view; every button runs the path of the key it shows. */
3181
+ #pickerActions(entry: SidebarEntry, rolesView: boolean, lockedView: boolean): TspPickerAction[] {
3182
+ const strip = this.#strip;
3183
+ const cancel = (label: string): TspPickerAction => ({ ...CLOSE_ACTION, label });
3184
+ if (this.#assignmentPending) return [CLOSE_ACTION];
3185
+ if (strip) {
3186
+ const apply =
3187
+ strip.kind === "roleName"
3188
+ ? pickerAction("roleName", "Create role", "enter", { primary: true })
3189
+ : pickerAction(
3190
+ "stripApply",
3191
+ strip.kind === "thinking" ? "Apply" : strip.kind === "scope" ? "Save to scope" : "Assign / clear",
3192
+ "enter",
3193
+ { primary: true },
3194
+ );
3195
+ return [apply, cancel(strip.kind === "thinking" ? "Keep" : "Cancel")];
3196
+ }
3197
+ const refresh =
3198
+ entry.kind === "provider" && !entry.locked ? pickerAction("refresh", "Refresh provider", "f5") : undefined;
3199
+ if (this.#assigning !== null) {
3200
+ const label =
3201
+ this.#assigning.kind === "fallback"
3202
+ ? "Pick fallback"
3203
+ : this.#assigning.kind === "fallbackKey"
3204
+ ? "Pick protected model"
3205
+ : "Assign";
3206
+ return compact([pickerAction("assign", label, "enter", { primary: true }), refresh, cancel("Cancel")]);
3207
+ }
3208
+ if (rolesView) {
3209
+ const row = this.#rolesRows[this.#roleIndex];
3210
+ const roleAction = (action: RolesAction, label: string, key: string, primary = false) =>
3211
+ pickerAction(`roles:${action}`, label, key, primary ? { primary: true } : undefined);
3212
+ const actions: (TspPickerAction | undefined)[] = [];
3213
+ switch (row?.kind) {
3214
+ case "role": {
3215
+ const assigned = this.#roles[row.role];
3216
+ actions.push(
3217
+ roleAction("pick", "Pick model", "enter", true),
3218
+ roleAction("fallback", "Add fallback", "f"),
3219
+ assigned && !assigned.autoSelected ? roleAction("clear", "Clear", "x") : undefined,
3220
+ this.#roleThinkingTarget(row.role) ? roleAction("thinking", "Thinking", "t") : undefined,
3221
+ roleAction("cycle", this.#cycleOrder().includes(row.role) ? "Leave cycle" : "Add to cycle", "c"),
3222
+ roleAction("new", "New role", "n"),
3223
+ );
3224
+ break;
3225
+ }
3226
+ case "fallback":
3227
+ actions.push(
3228
+ roleAction("pick", "Replace", "enter", true),
3229
+ roleAction("fallback", "Add another", "f"),
3230
+ roleAction("clear", "Remove", "x"),
3231
+ this.#resolveFallbackEntry(row.role, row.chainIndex)
3232
+ ? roleAction("thinking", "Thinking", "t")
3233
+ : undefined,
3234
+ roleAction("earlier", "Earlier", "["),
3235
+ roleAction("later", "Later", "]"),
3236
+ );
3237
+ break;
3238
+ case "chainKey":
3239
+ actions.push(roleAction("pick", "Add fallback", "enter", true), roleAction("clear", "Clear chain", "x"));
3240
+ break;
3241
+ case "newFallback":
3242
+ actions.push(roleAction("pick", "New fallback chain", "enter", true));
3243
+ break;
3244
+ case "newRole":
3245
+ actions.push(roleAction("pick", "New role", "enter", true));
3246
+ break;
3247
+ }
3248
+ return compact([...actions, CLOSE_ACTION]);
3249
+ }
3250
+ if (lockedView) {
3251
+ return entry.oauth
3252
+ ? [pickerAction("login", "Log in", "enter", { primary: true }), CLOSE_ACTION]
3253
+ : [CLOSE_ACTION];
3254
+ }
3255
+ return compact([
3256
+ pickerAction(
3257
+ "assign",
3258
+ "Assign role",
3259
+ "enter",
3260
+ this.#browser.pickerSelected ? { primary: true } : { primary: true, disabled: "No model selected" },
3261
+ ),
3262
+ refresh,
3263
+ this.#browser.query.length > 0 ? cancel("Clear search") : CLOSE_ACTION,
3264
+ ]);
3265
+ }
3266
+
3267
+ /** The open strip as the picker's chip strip (role assignment, save scope, thinking level, new role name). */
3268
+ #pickerStrip(strip: StripState): NonNullable<TspPickerProps["strip"]> {
3269
+ if (strip.kind === "roleName") {
3270
+ return {
3271
+ label: [span("New role name ", "muted"), span(strip.input.getValue(), "mono"), span("▏", "accent")],
3272
+ items: [],
3273
+ };
3274
+ }
3275
+ let label: TspSpan[];
3276
+ if (strip.kind === "role") {
3277
+ // The second step of "New fallback chain…" reuses the role strip with key chips only.
3278
+ const keyStrip = strip.chips.every(chip => chip.label.startsWith("for "));
3279
+ label = keyStrip
3280
+ ? [span("New fallback chain", "muted")]
3281
+ : [span("Assign ", "muted"), span(strip.item.id, "mono"), span(" to", "muted")];
3282
+ } else {
3283
+ const info = this.#settings.getRoleInfo(strip.role ?? "");
3284
+ const roleLabel = (info.tag ?? strip.role ?? "").toLowerCase();
3285
+ label = [
3286
+ span(strip.kind === "thinking" ? "Thinking for " : "Save ", "muted"),
3287
+ span(roleLabel, "strong"),
3288
+ span(" · ", "muted"),
3289
+ span(strip.item.id, "mono"),
3290
+ ...(strip.kind === "scope" ? [span(" to", "muted")] : []),
3291
+ ];
3292
+ }
3293
+ return {
3294
+ label,
3295
+ items: strip.chips.map((chip, index) => {
3296
+ const assigned = chip.role ? this.#roles[chip.role] : undefined;
3297
+ const level =
3298
+ chip.action === "thinking"
3299
+ ? chip.thinkingLevel
3300
+ : chip.action === "unassign" && assigned && !assigned.autoSelected
3301
+ ? assigned.thinkingLevel
3302
+ : undefined;
3303
+ const dot = level !== undefined ? thinkingDotToken(level) : undefined;
3304
+ const on =
3305
+ chip.action === "unassign" ||
3306
+ (strip.kind === "thinking" &&
3307
+ chip.thinkingLevel !== undefined &&
3308
+ chip.thinkingLevel === strip.initialThinkingLevel);
3309
+ return { id: String(index), label: chip.label, ...(on ? { on: true } : {}), ...(dot ? { dot } : {}) };
3310
+ }),
3311
+ selected: String(strip.index),
3312
+ };
3313
+ }
3314
+
3315
+ /** Roles-view rows as picker items; rebuilt only when the rows, roles or cycle change. */
3316
+ #rolePickerItems(): readonly TspPickerItem[] {
3317
+ const cycleOrder = this.#cycleOrder();
3318
+ const cycle = cycleOrder.join("\0");
3319
+ const memo = this.#pickerRoleItems;
3320
+ if (memo?.rows === this.#rolesRows && memo.roles === this.#roles && memo.cycle === cycle) return memo.items;
3321
+ const items: TspPickerItem[] = [];
3322
+ this.#rolesRows.forEach((row, index) => {
3323
+ const id = rolesRowKey(row, index);
3324
+ switch (row.kind) {
3325
+ case "separator":
3326
+ return;
3327
+ case "newRole":
3328
+ items.push({ id, label: "New role…", icon: "plus", tone: "muted" });
3329
+ return;
3330
+ case "newFallback":
3331
+ items.push({ id, label: "New fallback chain…", icon: "plus", tone: "muted" });
3332
+ return;
3333
+ case "chainKey":
3334
+ items.push({ id, label: row.role, mono: true, icon: "git-branch", detail: "fallback chain" });
3335
+ return;
3336
+ case "fallback":
3337
+ items.push({ id, label: row.selector, mono: true, depth: 1, detail: `fallback ${row.chainIndex + 1}` });
3338
+ return;
3339
+ case "role":
3340
+ break;
3341
+ }
3342
+ const info = this.#settings.getRoleInfo(row.role);
3343
+ const tag = info.tag ?? info.name ?? row.role;
3344
+ const assignment = this.#roles[row.role];
3345
+ const facts: Record<string, string> = {};
3346
+ if (assignment) {
3347
+ const selector = `${assignment.model.provider}/${assignment.model.id}`;
3348
+ facts.model = assignment.autoSelected ? `auto → ${selector}` : selector;
3349
+ if (assignment.thinkingLevel !== ThinkingLevel.Inherit) {
3350
+ facts.thinking = getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label;
3351
+ }
3352
+ } else {
3353
+ facts.model = "—";
3354
+ }
3355
+ const cycleIndex = cycleOrder.indexOf(row.role);
3356
+ items.push({
3357
+ id,
3358
+ label: tag,
3359
+ ...(info.name && info.name !== tag ? { detail: info.name } : {}),
3360
+ facts,
3361
+ ...(assignment && !assignment.autoSelected ? {} : { tone: "muted" as const }),
3362
+ ...(cycleIndex >= 0
3363
+ ? {
3364
+ badges: [
3365
+ {
3366
+ text: `cycle ${cycleIndex + 1}`,
3367
+ tone: "accent" as const,
3368
+ title: `Stop ${cycleIndex + 1} of the quick-switch cycle`,
3369
+ },
3370
+ ],
3371
+ }
3372
+ : {}),
3373
+ });
3374
+ });
3375
+ this.#pickerRoleItems = { rows: this.#rolesRows, roles: this.#roles, cycle, items };
3376
+ return items;
3377
+ }
3378
+
3379
+ /** Roles-view order: the separators become group heads (chat roles, kind roles, model fallback chains). */
3380
+ #rolePickerOrder(): (string | TspPickerGroup)[] {
3381
+ const order: (string | TspPickerGroup)[] = [];
3382
+ let head: { group: string; label: string; count: number } | undefined;
3383
+ const open = (group: string, label: string) => {
3384
+ head = { group, label, count: 0 };
3385
+ order.push(head);
3386
+ };
3387
+ open(this.#roleTab === "kind" ? "kind" : "chat", this.#roleTab === "kind" ? "Kind roles" : "Chat roles");
3388
+ this.#rolesRows.forEach((row, index) => {
3389
+ if (row.kind === "separator") {
3390
+ const previous = this.#rolesRows[index - 1];
3391
+ if (previous?.kind === "newRole") open("chains", "Model fallback chains");
3392
+ else open("kind", "Kind roles");
3393
+ return;
3394
+ }
3395
+ order.push(rolesRowKey(row, index));
3396
+ // Heads count roles and chains, not their fallback entries or the "new" rows.
3397
+ if (head && (row.kind === "role" || row.kind === "chainKey")) head.count++;
3398
+ });
3399
+ return order;
3400
+ }
3401
+
3402
+ /** The selected Roles-view row's preview: its model's facts and its fallback chain. */
3403
+ #rolePreview(row: RolesRow | undefined): readonly NativeChild[] {
3404
+ const memo = this.#pickerRolePreview;
3405
+ if (memo !== undefined && memo.row === row && memo.roles === this.#roles && memo.rows === this.#rolesRows) {
3406
+ return memo.children;
3407
+ }
3408
+ const children: NativeChild[] = [];
3409
+ const chainList = (key: string): NativeChild | undefined => {
3410
+ const chain = this.#fallbackChains()[key] ?? [];
3411
+ if (chain.length === 0) return undefined;
3412
+ return node("section", { head: "Fallback chain" }, [
3413
+ md(chain.map((selector, index) => `${index + 1}. \`${selector}\``).join("\n")),
3414
+ ]);
3415
+ };
3416
+ const modelItem = (model: Model): ModelBrowserItem => ({
3417
+ provider: model.provider,
3418
+ id: model.id,
3419
+ model,
3420
+ selector: `${model.provider}/${model.id}`,
3421
+ });
3422
+ switch (row?.kind) {
3423
+ case "role": {
3424
+ const info = this.#settings.getRoleInfo(row.role);
3425
+ const assignment = this.#roles[row.role];
3426
+ if (assignment) {
3427
+ children.push(...this.#browser.modelPreview(modelItem(assignment.model), "full", this.#currentSelector));
3428
+ children.push(
3429
+ node("kv", {
3430
+ items: [
3431
+ { k: [span("Role", "muted")], v: info.name },
3432
+ {
3433
+ k: [span("Thinking", "muted")],
3434
+ v: getConfiguredThinkingLevelMetadata(assignment.thinkingLevel).label,
3435
+ },
3436
+ { k: [span("Source", "muted")], v: assignment.autoSelected ? "auto-selected" : "configured" },
3437
+ ],
3438
+ }),
3439
+ );
3440
+ } else {
3441
+ children.push(
3442
+ text(info.name, { role: "omp.picker.title" }),
3443
+ text([span("Not assigned; no available model fits this role.", "muted")], { wrap: "word" }),
3444
+ );
3445
+ }
3446
+ const chain = chainList(row.role);
3447
+ if (chain) children.push(chain);
3448
+ break;
3449
+ }
3450
+ case "fallback": {
3451
+ const resolved = this.#resolveFallbackEntry(row.role, row.chainIndex);
3452
+ if (resolved) children.push(...this.#browser.modelPreview(resolved.item, "full", this.#currentSelector));
3453
+ else children.push(text([span(row.selector, "mono")], { role: "omp.picker.title" }));
3454
+ const chain = chainList(row.role);
3455
+ if (chain) children.push(chain);
3456
+ break;
3457
+ }
3458
+ case "chainKey": {
3459
+ children.push(text([span(row.role, "mono")], { role: "omp.picker.title" }));
3460
+ const chain = chainList(row.role);
3461
+ if (chain) children.push(chain);
3462
+ break;
3463
+ }
3464
+ case "newRole":
3465
+ children.push(
3466
+ text("New role", { role: "omp.picker.title" }),
3467
+ text([span("Name a custom role, then pick the model it runs on.", "muted")], { wrap: "word" }),
3468
+ );
3469
+ break;
3470
+ case "newFallback":
3471
+ children.push(
3472
+ text("New fallback chain", { role: "omp.picker.title" }),
3473
+ text([span("Pick the model (or provider) a new retry fallback chain protects.", "muted")], {
3474
+ wrap: "word",
3475
+ }),
3476
+ );
3477
+ break;
3478
+ }
3479
+ this.#pickerRolePreview = { row, roles: this.#roles, rows: this.#rolesRows, children };
3480
+ return children;
3481
+ }
3482
+
3483
+ #lockedMessage(entry: SidebarEntry): string {
3484
+ const envVars = entry.providerId ? (providerEntry(entry.providerId)?.envVars ?? []) : [];
3485
+ const how =
3486
+ envVars.length > 0
3487
+ ? `Set ${envVars.join(" or ")} in your environment, or add a key in config.`
3488
+ : "Add an API key for this provider in config.";
3489
+ return `${entry.label} has no credentials configured. ${how}`;
3490
+ }
3491
+
3492
+ /** A signed-out provider's preview: how to sign in and what its catalog holds. */
3493
+ #lockedPreview(entry: SidebarEntry): readonly NativeChild[] {
3494
+ const children: NativeChild[] = [
3495
+ text(entry.label, { role: "omp.picker.title" }),
3496
+ text([span(this.#lockedMessage(entry), "muted")], { wrap: "word" }),
3497
+ ];
3498
+ const catalogCount = entry.catalogCount ?? 0;
3499
+ if (catalogCount > 0 && this.#scopedModels.length === 0) {
3500
+ const ids: string[] = [];
3501
+ for (const model of this.#registry.getAll("all")) {
3502
+ if (model.provider === entry.providerId) ids.push(`- \`${model.id}\``);
3503
+ }
3504
+ children.push(node("section", { head: `${catalogCount} models in catalog` }, [md(ids.join("\n"))]));
3505
+ }
3506
+ return children;
3507
+ }
3508
+
3509
+ /** Picker pointer events, each on the path of the key it stands for. */
3510
+ #handlePickerEvent(event: PickerEvent): void {
3511
+ if (event.kind === "action" && event.act === CLOSE_ACTION.id) {
3512
+ this.#cancel();
3513
+ return;
3514
+ }
3515
+ // Same gate as the keys: only cancel works while an assignment applies.
3516
+ if (this.#assignmentPending) return;
3517
+ const entry = this.#activeEntry();
3518
+ const rolesView = entry.kind === "roles" && this.#assigning === null;
3519
+ if (event.kind !== "action") {
3520
+ if (this.#strip) return;
3521
+ if (rolesView) {
3522
+ const index = this.#rolesRows.findIndex((row, i) => rolesRowKey(row, i) === event.item);
3523
+ const row = this.#rolesRows[index];
3524
+ if (!row || row.kind === "separator") return;
3525
+ this.#focus = "list";
3526
+ this.#roleIndex = index;
3527
+ if (event.kind === "activate") this.#activateRolesRow(row);
3528
+ return;
3529
+ }
3530
+ if (this.#isBrowserView(entry) && this.#browser.routePickerItem(event.item, event.kind === "activate")) {
3531
+ this.#focus = "list";
3532
+ }
3533
+ return;
3534
+ }
3535
+ const { act, value } = event;
3536
+ if (act.startsWith("roles:")) {
3537
+ const action = act.slice("roles:".length) as RolesAction;
3538
+ if (!rolesView || this.#strip) return;
3539
+ this.#focus = "list";
3540
+ this.#runRolesAction(action);
3541
+ return;
3542
+ }
3543
+ switch (act) {
3544
+ case "scope":
3545
+ this.#clickSidebarEntry(this.#entries.find(candidate => candidate.id === value));
3546
+ return;
3547
+ case "tab":
3548
+ if (rolesView) {
3549
+ const tab = ROLE_TABS.find(candidate => candidate === value);
3550
+ if (!tab) return;
3551
+ this.#roleTab = tab;
3552
+ this.#roleIndex = 0;
3553
+ this.#roleScrollStart = 0;
3554
+ this.#buildRolesRows();
3555
+ } else {
3556
+ const kind = MODEL_KIND_TABS.find(candidate => candidate === value);
3557
+ if (!kind) return;
3558
+ this.#modelKindTab = kind;
3559
+ this.#applyModelKind();
3560
+ }
3561
+ return;
3562
+ case "strip": {
3563
+ const strip = this.#strip;
3564
+ const index = Number(value);
3565
+ if (!strip || strip.kind === "roleName" || !Number.isInteger(index) || !strip.chips[index]) return;
3566
+ strip.index = index;
3567
+ this.#activateStripChip();
3568
+ return;
3569
+ }
3570
+ case "stripApply":
3571
+ this.#activateStripChip();
3572
+ return;
3573
+ case "roleName":
3574
+ this.#submitRoleName();
3575
+ return;
3576
+ case "cancel":
3577
+ if (this.#strip) this.#closeStrip();
3578
+ return;
3579
+ case "clear":
3580
+ if (this.#browser.query.length > 0) this.#browser.handleCancel();
3581
+ return;
3582
+ case "assign": {
3583
+ if (this.#strip || !this.#isBrowserView(entry)) return;
3584
+ const selected = this.#browser.pickerSelected;
3585
+ if (!selected) return;
3586
+ this.#focus = "list";
3587
+ this.#browser.routePickerItem(selected, true);
3588
+ return;
3589
+ }
3590
+ case "refresh":
3591
+ if (entry.kind === "provider" && !entry.locked) {
3592
+ this.#scheduleProviderRefresh(entry.providerId ?? "", { force: true });
3593
+ }
3594
+ return;
3595
+ case "login":
3596
+ if (entry.kind === "provider" && entry.locked && this.#assigning === null) this.#requestLogin(entry);
3597
+ return;
3598
+ }
3599
+ }
3600
+
3601
+ /** Footer chip labels, built from each chip's action instead of its ANSI rendering. */
3602
+ #chipSpans(chip: StripChip): TspSpan[] {
3603
+ switch (chip.action) {
3604
+ case "assign":
3605
+ case "unassign": {
3606
+ const color = this.#settings.getRoleInfo(chip.role ?? "").color ?? "muted";
3607
+ return chip.action === "unassign"
3608
+ ? [span(`${theme.status.enabled} ${chip.label}`, color), span(` ${theme.status.success}`, "dim")]
3609
+ : [span(chip.label, color)];
3610
+ }
3611
+ case "scope":
3612
+ return [span(chip.label, chip.scope === "project" ? "accent" : "muted")];
3613
+ case "thinking": {
3614
+ const glyph = chip.thinkingLevel === undefined ? "" : thinkingLevelGlyph(chip.thinkingLevel, theme);
3615
+ return glyph ? [span(glyph, "accent"), span(` ${chip.label}`)] : [span(chip.label)];
3616
+ }
3617
+ default:
3618
+ return [span(plainText(chip.styled), "muted")];
3619
+ }
3620
+ }
3621
+
3622
+ #describeStrip(): NativeNode | undefined {
3623
+ const strip = this.#strip;
3624
+ if (!strip) return undefined;
3625
+ if (strip.kind === "roleName") {
3626
+ return node(
3627
+ "row",
3628
+ { gap: "sm", align: "center" },
3629
+ [
3630
+ text([span("New role name:", "accent")]),
3631
+ col([strip.input], { grow: 1 }),
3632
+ text([span("(letters, digits, - and _)", "dim")]),
3633
+ ],
3634
+ "roleName",
3635
+ );
3636
+ }
3637
+ let prefix: TspSpan[];
3638
+ if (strip.kind === "role") {
3639
+ prefix = [span(strip.item.id, "accent"), span(" →", "dim")];
3640
+ } else {
3641
+ const info = this.#settings.getRoleInfo(strip.role ?? "");
3642
+ prefix = [
3643
+ span((info.tag ?? strip.role ?? "").toLowerCase(), info.color ?? "muted"),
3644
+ span(` · ${strip.item.id} →`, "dim"),
3645
+ ];
3646
+ }
3647
+ return node(
3648
+ "row",
3649
+ { gap: "sm", align: "center" },
3650
+ [
3651
+ text(prefix),
3652
+ node(
3653
+ "tabs",
3654
+ {
3655
+ items: strip.chips.map((chip, index) => ({ id: String(index), label: this.#chipSpans(chip) })),
3656
+ active: String(strip.index),
3657
+ actions: { click: "activate" },
3658
+ },
3659
+ undefined,
3660
+ "strip",
3661
+ ),
3662
+ ],
3663
+ "chips",
3664
+ );
3665
+ }
3666
+
3667
+ #footerHints(): (NativeHint | undefined)[] {
3668
+ const keys = (label: string, ...ids: KeyName[]): NativeHint => ({ keys: ids, label });
3669
+ const cancel = (label: string) => actionHint("tui.select.cancel", label);
3670
+ const upDown = (label: string) => actionHint(["tui.select.up", "tui.select.down"], label);
3671
+ const search = keys("type to search");
3672
+ const kind = keys("kind", "alt+left", "alt+right");
3673
+ const reorder = keys("reorder", "[", "]");
3674
+ const strip = this.#strip;
3675
+ if (strip) {
3676
+ switch (strip.kind) {
3677
+ case "roleName":
3678
+ return [keys("create + pick model", "enter"), cancel("cancel")];
3679
+ case "role":
3680
+ return [keys("choose", "left", "right"), keys("assign/clear", "enter"), cancel("cancel")];
3681
+ case "scope":
3682
+ return [keys("save scope", "left", "right"), keys("choose", "enter"), cancel("cancel")];
3683
+ case "thinking":
3684
+ return [keys("thinking level", "left", "right"), keys("apply", "enter"), cancel("keep")];
3685
+ }
3686
+ }
3687
+ if (this.#assigning !== null) {
3688
+ if (this.#focus === "scope") {
3689
+ return [keys("models", "enter", "right"), upDown("providers"), search, kind, cancel("cancel")];
3690
+ }
3691
+ const pick =
3692
+ this.#assigning.kind === "fallback"
3693
+ ? "pick fallback"
3694
+ : this.#assigning.kind === "fallbackKey"
3695
+ ? "pick the protected model"
3696
+ : "assign";
3697
+ return [keys(pick, "enter"), upDown("models"), keys("providers", "left"), search, kind, cancel("cancel")];
3698
+ }
3699
+ const entry = this.#activeEntry();
3700
+ if (entry.kind === "roles") {
3701
+ if (this.#focus !== "list") {
3702
+ return [
3703
+ upDown("providers"),
3704
+ keys("roles", "enter", "right"),
3705
+ keys("tabs", "alt+left", "alt+right"),
3706
+ cancel("close"),
3707
+ ];
3708
+ }
3709
+ const row = this.#rolesRows[this.#roleIndex];
3710
+ if (row?.kind === "fallback") {
3711
+ // Advertise `t` only where a strip would open, as the ANSI footer does.
3712
+ const editable = this.#resolveFallbackEntry(row.role, row.chainIndex) !== undefined;
3713
+ return [
3714
+ upDown("rows"),
3715
+ keys("replace", "enter"),
3716
+ keys("add another", "f"),
3717
+ keys("remove", "x"),
3718
+ editable ? keys("thinking", "t") : undefined,
3719
+ reorder,
3720
+ keys("providers", "left"),
3721
+ ];
3722
+ }
3723
+ if (row?.kind === "chainKey") {
3724
+ return [
3725
+ upDown("rows"),
3726
+ keys("add fallback", "enter", "f"),
3727
+ keys("clear chain", "x"),
3728
+ keys("providers", "left"),
3729
+ ];
3730
+ }
3731
+ if (row?.kind === "newFallback") {
3732
+ return [upDown("rows"), keys("new model/provider fallback chain", "enter"), keys("providers", "left")];
3733
+ }
3734
+ const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
3735
+ return [
3736
+ upDown("rows"),
3737
+ keys("pick", "enter"),
3738
+ keys("fallback", "f"),
3739
+ keys("clear", "x"),
3740
+ editable ? keys("thinking", "t") : undefined,
3741
+ keys("cycle", "c"),
3742
+ reorder,
3743
+ keys("new", "n"),
3744
+ ];
3745
+ }
3746
+ if (entry.kind === "provider" && entry.locked) {
3747
+ return entry.oauth
3748
+ ? [keys("log in", "enter"), upDown("providers"), cancel("close")]
3749
+ : [upDown("providers"), cancel("close")];
3750
+ }
3751
+ const refresh = entry.kind === "provider" ? keys("refresh", "f5") : undefined;
3752
+ if (this.#focus === "scope") {
3753
+ return [keys("models", "enter", "right"), upDown("providers"), search, kind, refresh, cancel("close")];
3754
+ }
3755
+ return [
3756
+ keys("assign roles", "enter"),
3757
+ upDown("models"),
3758
+ keys("providers", "left"),
3759
+ search,
3760
+ kind,
3761
+ refresh,
3762
+ cancel("close"),
3763
+ ];
3764
+ }
2441
3765
  }