@alumbwe/anvil 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (529) hide show
  1. package/README.md +84 -0
  2. package/bin/anvil +2 -0
  3. package/bin/tree-sitter.wasm +0 -0
  4. package/package.json +67 -0
  5. package/src/__tests__/README.md +256 -0
  6. package/src/__tests__/bash-mode.test.ts +442 -0
  7. package/src/__tests__/cli-args.test.ts +181 -0
  8. package/src/__tests__/cli-harness.test.ts +52 -0
  9. package/src/__tests__/e2e/first-time-login.test.ts +173 -0
  10. package/src/__tests__/e2e/logout-relogin-flow.test.ts +110 -0
  11. package/src/__tests__/e2e/returning-user-auth.test.ts +113 -0
  12. package/src/__tests__/e2e-cli.test.ts +183 -0
  13. package/src/__tests__/helpers/launcher-disconnect-fixture.cjs +176 -0
  14. package/src/__tests__/helpers/mock-api-client.ts +64 -0
  15. package/src/__tests__/helpers/renderer-cleanup-fixture.tsx +89 -0
  16. package/src/__tests__/helpers/terminal-watchdog-fixture.ts +75 -0
  17. package/src/__tests__/home-directory-detection.test.ts +213 -0
  18. package/src/__tests__/integration/api-integration.test.ts +288 -0
  19. package/src/__tests__/integration/credentials-storage.test.ts +486 -0
  20. package/src/__tests__/integration/local-agents.test.ts +1187 -0
  21. package/src/__tests__/integration/login-flow-instrumentation.test.ts +134 -0
  22. package/src/__tests__/integration/login-polling-working.test.ts +285 -0
  23. package/src/__tests__/integration/queue-editing.test.tsx +261 -0
  24. package/src/__tests__/integration/usage-refresh-on-completion.test.ts +168 -0
  25. package/src/__tests__/integration-tmux.test.ts +188 -0
  26. package/src/__tests__/launcher-avx2-fallback.test.ts +630 -0
  27. package/src/__tests__/launcher-disconnect.test.ts +104 -0
  28. package/src/__tests__/mocks/hover-toggle-controller.ts +71 -0
  29. package/src/__tests__/open-tui-native-bundle.test.ts +378 -0
  30. package/src/__tests__/path-completion.test.ts +243 -0
  31. package/src/__tests__/project-files-chat-id.test.ts +34 -0
  32. package/src/__tests__/release/proxy-http-get.test.ts +667 -0
  33. package/src/__tests__/release/wrapper-safety.test.ts +412 -0
  34. package/src/__tests__/renderer-cleanup.test.ts +174 -0
  35. package/src/__tests__/rerender-perf.integration.test.ts +283 -0
  36. package/src/__tests__/terminal-reset-sequences.test.ts +36 -0
  37. package/src/__tests__/terminal-watchdog.test.ts +388 -0
  38. package/src/__tests__/test-utils.ts +271 -0
  39. package/src/__tests__/tmux-poc.ts +150 -0
  40. package/src/__tests__/unit/agent-mode-toggle.test.ts +137 -0
  41. package/src/__tests__/unit/copy-button.test.ts +157 -0
  42. package/src/__tests__/unit/create-run-config.test.ts +66 -0
  43. package/src/__tests__/unit/publish-confirmation.test.ts +70 -0
  44. package/src/__tests__/unit/segmented-control.test.ts +152 -0
  45. package/src/__tests__/utils/env.test.ts +169 -0
  46. package/src/__tests__/utils/project-picker.test.ts +39 -0
  47. package/src/agents/bundled-agents.generated.d.ts +14 -0
  48. package/src/agents/bundled-agents.generated.ts +3692 -0
  49. package/src/app.tsx +399 -0
  50. package/src/chat.tsx +1698 -0
  51. package/src/cli-args.ts +134 -0
  52. package/src/commands/__tests__/bash-command.test.ts +445 -0
  53. package/src/commands/__tests__/command-args.test.ts +349 -0
  54. package/src/commands/__tests__/copy-conversation-clipboard.test.ts +108 -0
  55. package/src/commands/__tests__/copy-conversation.test.ts +226 -0
  56. package/src/commands/__tests__/diagnostics-command.test.ts +35 -0
  57. package/src/commands/__tests__/freebuff-command-aliases.test.ts +56 -0
  58. package/src/commands/__tests__/image.test.ts +99 -0
  59. package/src/commands/__tests__/init.test.ts +456 -0
  60. package/src/commands/__tests__/new-command-rotates-chat.test.ts +91 -0
  61. package/src/commands/__tests__/process-diagnostics.test.ts +54 -0
  62. package/src/commands/__tests__/prompt-builders.test.ts +28 -0
  63. package/src/commands/__tests__/router-input.test.ts +298 -0
  64. package/src/commands/ads.ts +44 -0
  65. package/src/commands/command-registry.ts +726 -0
  66. package/src/commands/copy-conversation.ts +392 -0
  67. package/src/commands/help.ts +13 -0
  68. package/src/commands/image.ts +20 -0
  69. package/src/commands/init.ts +121 -0
  70. package/src/commands/process-diagnostics.ts +120 -0
  71. package/src/commands/prompt-builders.ts +100 -0
  72. package/src/commands/publish.ts +203 -0
  73. package/src/commands/router-utils.ts +80 -0
  74. package/src/commands/router.ts +487 -0
  75. package/src/commands/usage.ts +26 -0
  76. package/src/components/__tests__/ad-banner.test.tsx +83 -0
  77. package/src/components/__tests__/chat-input-bar.test.tsx +130 -0
  78. package/src/components/__tests__/freebuff-landing-heading-row.test.tsx +91 -0
  79. package/src/components/__tests__/freebuff-model-selector.test.tsx +519 -0
  80. package/src/components/__tests__/freebuff-offer-invariants.test.ts +54 -0
  81. package/src/components/__tests__/freebuff-takeover-prompt.test.tsx +212 -0
  82. package/src/components/__tests__/grid-layout.integration.test.tsx +304 -0
  83. package/src/components/__tests__/grid-layout.test.tsx +1051 -0
  84. package/src/components/__tests__/message-block.completion.test.tsx +104 -0
  85. package/src/components/__tests__/message-block.streaming.test.tsx +78 -0
  86. package/src/components/__tests__/message-with-agents.test.tsx +579 -0
  87. package/src/components/__tests__/multiline-input.test.tsx +1132 -0
  88. package/src/components/__tests__/queue-panel.test.tsx +398 -0
  89. package/src/components/__tests__/selectable-list.test.ts +232 -0
  90. package/src/components/__tests__/status-bar.test.tsx +44 -0
  91. package/src/components/__tests__/status-indicator.test.tsx +204 -0
  92. package/src/components/__tests__/terminal-thinking-orb.test.tsx +111 -0
  93. package/src/components/__tests__/user-error-banner.test.tsx +102 -0
  94. package/src/components/ad-banner.tsx +323 -0
  95. package/src/components/agent-checklist.tsx +350 -0
  96. package/src/components/agent-mode-toggle.tsx +241 -0
  97. package/src/components/anvil-mark.tsx +52 -0
  98. package/src/components/ask-user/__tests__/multiple-choice-form.test.ts +432 -0
  99. package/src/components/ask-user/__tests__/validation.test.ts +213 -0
  100. package/src/components/ask-user/components/accordion-question.tsx +153 -0
  101. package/src/components/ask-user/components/custom-answer-input.tsx +66 -0
  102. package/src/components/ask-user/components/options-list.tsx +133 -0
  103. package/src/components/ask-user/components/question-header.tsx +80 -0
  104. package/src/components/ask-user/components/question-option.tsx +89 -0
  105. package/src/components/ask-user/constants.ts +38 -0
  106. package/src/components/ask-user/index.tsx +631 -0
  107. package/src/components/ask-user/utils/validation.ts +85 -0
  108. package/src/components/attachment-card.tsx +65 -0
  109. package/src/components/blocks/agent-block-grid.tsx +58 -0
  110. package/src/components/blocks/agent-branch-item.tsx +311 -0
  111. package/src/components/blocks/agent-branch-wrapper.tsx +493 -0
  112. package/src/components/blocks/agent-list-branch.tsx +77 -0
  113. package/src/components/blocks/ask-user-branch.tsx +81 -0
  114. package/src/components/blocks/block-helpers.ts +11 -0
  115. package/src/components/blocks/blocks-renderer.tsx +289 -0
  116. package/src/components/blocks/content-with-markdown.tsx +33 -0
  117. package/src/components/blocks/image-block.tsx +128 -0
  118. package/src/components/blocks/implementor-row.tsx +429 -0
  119. package/src/components/blocks/single-block.tsx +201 -0
  120. package/src/components/blocks/thinking-block.tsx +67 -0
  121. package/src/components/blocks/tool-block-group.tsx +57 -0
  122. package/src/components/blocks/tool-branch.tsx +206 -0
  123. package/src/components/blocks/user-content-copy.tsx +157 -0
  124. package/src/components/bottom-banner.tsx +136 -0
  125. package/src/components/build-mode-buttons.tsx +110 -0
  126. package/src/components/button.tsx +66 -0
  127. package/src/components/chat-header.tsx +77 -0
  128. package/src/components/chat-history-screen.tsx +413 -0
  129. package/src/components/chat-input-bar.tsx +523 -0
  130. package/src/components/clickable-title-box.tsx +66 -0
  131. package/src/components/clickable.tsx +119 -0
  132. package/src/components/collapse-button.tsx +34 -0
  133. package/src/components/copy-button.tsx +203 -0
  134. package/src/components/elapsed-timer.tsx +55 -0
  135. package/src/components/error-boundary.tsx +55 -0
  136. package/src/components/feedback-container.tsx +181 -0
  137. package/src/components/feedback-icon-button.tsx +89 -0
  138. package/src/components/feedback-input-mode.tsx +344 -0
  139. package/src/components/file-attachment-card.tsx +98 -0
  140. package/src/components/freebuff-active-session-summary.tsx +63 -0
  141. package/src/components/freebuff-landing-screen.tsx +865 -0
  142. package/src/components/freebuff-model-selector.tsx +1087 -0
  143. package/src/components/freebuff-referral-banner.tsx +604 -0
  144. package/src/components/freebuff-superseded-screen.tsx +62 -0
  145. package/src/components/grid-layout.tsx +80 -0
  146. package/src/components/help-banner.tsx +127 -0
  147. package/src/components/highlighted-text.tsx +52 -0
  148. package/src/components/image-card.tsx +167 -0
  149. package/src/components/image-thumbnail.tsx +114 -0
  150. package/src/components/input-cursor.tsx +77 -0
  151. package/src/components/input-mode-banner.tsx +57 -0
  152. package/src/components/load-previous-button.tsx +48 -0
  153. package/src/components/login-modal.tsx +475 -0
  154. package/src/components/message-block.tsx +346 -0
  155. package/src/components/message-footer.tsx +258 -0
  156. package/src/components/message-with-agents.tsx +506 -0
  157. package/src/components/mode-divider.tsx +43 -0
  158. package/src/components/multiline-input.tsx +1241 -0
  159. package/src/components/out-of-credits-banner.tsx +160 -0
  160. package/src/components/pending-attachments-banner.tsx +116 -0
  161. package/src/components/pending-bash-message.tsx +59 -0
  162. package/src/components/progress-bar.tsx +81 -0
  163. package/src/components/project-picker-screen.tsx +485 -0
  164. package/src/components/publish-confirmation.tsx +493 -0
  165. package/src/components/publish-container.tsx +687 -0
  166. package/src/components/queue-panel.tsx +310 -0
  167. package/src/components/raised-pill.tsx +95 -0
  168. package/src/components/renderers/plan-box.tsx +59 -0
  169. package/src/components/review-screen.tsx +112 -0
  170. package/src/components/scroll-to-bottom-button.tsx +34 -0
  171. package/src/components/segmented-control.tsx +187 -0
  172. package/src/components/selectable-list.tsx +248 -0
  173. package/src/components/selected-chips.tsx +80 -0
  174. package/src/components/separator.tsx +40 -0
  175. package/src/components/session-ended-banner.tsx +226 -0
  176. package/src/components/shimmer-text.tsx +248 -0
  177. package/src/components/status-bar.tsx +313 -0
  178. package/src/components/subscription-limit-banner.tsx +206 -0
  179. package/src/components/suggested-prompts.tsx +184 -0
  180. package/src/components/suggestion-menu.tsx +215 -0
  181. package/src/components/terminal-command-display.tsx +151 -0
  182. package/src/components/terminal-link.tsx +97 -0
  183. package/src/components/terminal-thinking-orb.tsx +95 -0
  184. package/src/components/text-attachment-card.tsx +77 -0
  185. package/src/components/thinking.tsx +109 -0
  186. package/src/components/tools/__tests__/apply-patch.test.tsx +84 -0
  187. package/src/components/tools/__tests__/code-search.test.tsx +45 -0
  188. package/src/components/tools/__tests__/gravity-index.test.ts +76 -0
  189. package/src/components/tools/__tests__/render-ui.test.tsx +92 -0
  190. package/src/components/tools/__tests__/run-terminal-command.test.ts +233 -0
  191. package/src/components/tools/apply-patch.tsx +95 -0
  192. package/src/components/tools/code-search.tsx +45 -0
  193. package/src/components/tools/composio.tsx +152 -0
  194. package/src/components/tools/diff-viewer.tsx +76 -0
  195. package/src/components/tools/glob.tsx +61 -0
  196. package/src/components/tools/gravity-index.tsx +77 -0
  197. package/src/components/tools/list-directory.tsx +61 -0
  198. package/src/components/tools/read-docs.tsx +37 -0
  199. package/src/components/tools/read-files.tsx +91 -0
  200. package/src/components/tools/read-subtree.tsx +44 -0
  201. package/src/components/tools/read-url.tsx +33 -0
  202. package/src/components/tools/registry.ts +126 -0
  203. package/src/components/tools/render-ui.tsx +122 -0
  204. package/src/components/tools/run-terminal-command.tsx +79 -0
  205. package/src/components/tools/skill.tsx +29 -0
  206. package/src/components/tools/str-replace.tsx +77 -0
  207. package/src/components/tools/suggest-followups.tsx +369 -0
  208. package/src/components/tools/task-completed.tsx +16 -0
  209. package/src/components/tools/tool-call-item.tsx +273 -0
  210. package/src/components/tools/types.ts +55 -0
  211. package/src/components/tools/web-search.tsx +33 -0
  212. package/src/components/tools/write-file.tsx +16 -0
  213. package/src/components/tools/write-todos.tsx +95 -0
  214. package/src/components/top-banner.tsx +178 -0
  215. package/src/components/usage-banner.tsx +244 -0
  216. package/src/components/user-error-banner.tsx +56 -0
  217. package/src/components/validation-error-popover.tsx +192 -0
  218. package/src/contexts/__tests__/chat-runtime-context.test.tsx +149 -0
  219. package/src/contexts/chat-runtime-context.tsx +240 -0
  220. package/src/data/slash-commands.test.ts +24 -0
  221. package/src/data/slash-commands.ts +249 -0
  222. package/src/entry.ts +12 -0
  223. package/src/hooks/__tests__/holds-live-freebuff-slot.test.ts +49 -0
  224. package/src/hooks/__tests__/session-fetch-signal.test.ts +63 -0
  225. package/src/hooks/__tests__/use-activity-query.test.ts +1000 -0
  226. package/src/hooks/__tests__/use-ask-user-bridge.test.ts +176 -0
  227. package/src/hooks/__tests__/use-auth-query.test.ts +21 -0
  228. package/src/hooks/__tests__/use-chat-input-remount.test.tsx +66 -0
  229. package/src/hooks/__tests__/use-clipboard.test.tsx +73 -0
  230. package/src/hooks/__tests__/use-connection-status.test.ts +112 -0
  231. package/src/hooks/__tests__/use-directory-browser.test.ts +203 -0
  232. package/src/hooks/__tests__/use-gravity-ad.test.ts +73 -0
  233. package/src/hooks/__tests__/use-grid-layout.test.ts +349 -0
  234. package/src/hooks/__tests__/use-input-history.test.ts +699 -0
  235. package/src/hooks/__tests__/use-message-queue-editing.test.tsx +209 -0
  236. package/src/hooks/__tests__/use-message-queue.test.tsx +71 -0
  237. package/src/hooks/__tests__/use-path-tab-completion.test.ts +315 -0
  238. package/src/hooks/__tests__/use-queue-controls.test.ts +68 -0
  239. package/src/hooks/__tests__/use-queue-ui.test.tsx +83 -0
  240. package/src/hooks/__tests__/use-searchable-list.test.ts +359 -0
  241. package/src/hooks/__tests__/use-send-message-timer.test.ts +146 -0
  242. package/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +361 -0
  243. package/src/hooks/__tests__/use-suggestion-engine.test.ts +485 -0
  244. package/src/hooks/__tests__/use-terminal-focus.test.ts +66 -0
  245. package/src/hooks/__tests__/use-terminal-layout.test.ts +675 -0
  246. package/src/hooks/__tests__/use-timeout.test.ts +330 -0
  247. package/src/hooks/__tests__/use-usage-query.test.ts +502 -0
  248. package/src/hooks/__tests__/use-user-details-query.test.ts +195 -0
  249. package/src/hooks/helpers/__tests__/send-message.test.ts +1918 -0
  250. package/src/hooks/helpers/send-message.ts +654 -0
  251. package/src/hooks/stream-state.ts +83 -0
  252. package/src/hooks/use-activity-query.ts +668 -0
  253. package/src/hooks/use-agent-validation.ts +74 -0
  254. package/src/hooks/use-ask-user-bridge.ts +92 -0
  255. package/src/hooks/use-auth-query.ts +253 -0
  256. package/src/hooks/use-auth-state.ts +152 -0
  257. package/src/hooks/use-chat-input.ts +105 -0
  258. package/src/hooks/use-chat-keyboard.ts +321 -0
  259. package/src/hooks/use-chat-messages.ts +254 -0
  260. package/src/hooks/use-chat-state.ts +165 -0
  261. package/src/hooks/use-chat-streaming.ts +180 -0
  262. package/src/hooks/use-chat-ui.ts +131 -0
  263. package/src/hooks/use-clipboard.ts +151 -0
  264. package/src/hooks/use-connection-status.ts +142 -0
  265. package/src/hooks/use-directory-browser.ts +88 -0
  266. package/src/hooks/use-elapsed-time.ts +116 -0
  267. package/src/hooks/use-event.ts +34 -0
  268. package/src/hooks/use-exit-handler.ts +66 -0
  269. package/src/hooks/use-fetch-login-url.ts +65 -0
  270. package/src/hooks/use-fingerprint.ts +61 -0
  271. package/src/hooks/use-freebuff-ctrl-c-exit.ts +23 -0
  272. package/src/hooks/use-freebuff-session-progress.ts +34 -0
  273. package/src/hooks/use-freebuff-session.ts +806 -0
  274. package/src/hooks/use-freebuff-streak-query.ts +61 -0
  275. package/src/hooks/use-gravity-ad.ts +656 -0
  276. package/src/hooks/use-grid-layout.ts +73 -0
  277. package/src/hooks/use-input-history.ts +173 -0
  278. package/src/hooks/use-login-keyboard-handlers.ts +75 -0
  279. package/src/hooks/use-login-polling.ts +114 -0
  280. package/src/hooks/use-logo.tsx +161 -0
  281. package/src/hooks/use-message-queue.ts +387 -0
  282. package/src/hooks/use-now.ts +20 -0
  283. package/src/hooks/use-path-tab-completion.ts +88 -0
  284. package/src/hooks/use-publish-mutation.ts +60 -0
  285. package/src/hooks/use-queue-controls.ts +68 -0
  286. package/src/hooks/use-queue-ui.ts +74 -0
  287. package/src/hooks/use-scroll-management.ts +184 -0
  288. package/src/hooks/use-searchable-list.ts +98 -0
  289. package/src/hooks/use-send-message.ts +773 -0
  290. package/src/hooks/use-sheen-animation.tsx +88 -0
  291. package/src/hooks/use-subscription-query.ts +71 -0
  292. package/src/hooks/use-suggestion-engine.ts +783 -0
  293. package/src/hooks/use-terminal-breakpoints.ts +77 -0
  294. package/src/hooks/use-terminal-dimensions.ts +46 -0
  295. package/src/hooks/use-terminal-focus.ts +29 -0
  296. package/src/hooks/use-terminal-layout.ts +189 -0
  297. package/src/hooks/use-theme.tsx +140 -0
  298. package/src/hooks/use-timeout.ts +85 -0
  299. package/src/hooks/use-update-preference.ts +66 -0
  300. package/src/hooks/use-usage-monitor.ts +55 -0
  301. package/src/hooks/use-usage-query.ts +124 -0
  302. package/src/hooks/use-user-details-query.ts +93 -0
  303. package/src/hooks/use-why-did-you-update.ts +177 -0
  304. package/src/index.tsx +441 -0
  305. package/src/init/__tests__/init-direnv.test.ts +526 -0
  306. package/src/init/init-app.ts +34 -0
  307. package/src/init/init-direnv.ts +133 -0
  308. package/src/login/constants.ts +60 -0
  309. package/src/login/login-flow.ts +204 -0
  310. package/src/login/plain-login.ts +108 -0
  311. package/src/login/utils.ts +183 -0
  312. package/src/native/ripgrep.ts +80 -0
  313. package/src/polyfills/bun-strip-ansi.ts +12 -0
  314. package/src/pre-init/tree-sitter-wasm.ts +96 -0
  315. package/src/project-files.ts +102 -0
  316. package/src/smoke/terminal-command-broker.ts +439 -0
  317. package/src/state/__tests__/chat-store-focus.test.ts +22 -0
  318. package/src/state/__tests__/feedback-store.test.ts +257 -0
  319. package/src/state/chat-history-store.ts +30 -0
  320. package/src/state/chat-store.ts +544 -0
  321. package/src/state/feedback-store.ts +160 -0
  322. package/src/state/freebuff-model-store.ts +42 -0
  323. package/src/state/freebuff-session-store.ts +53 -0
  324. package/src/state/login-store.ts +150 -0
  325. package/src/state/message-block-store.ts +149 -0
  326. package/src/state/publish-store.ts +146 -0
  327. package/src/state/queue-panel-store.ts +26 -0
  328. package/src/state/review-store.ts +24 -0
  329. package/src/testing/env.ts +46 -0
  330. package/src/types/chat-state.ts +15 -0
  331. package/src/types/chat.ts +241 -0
  332. package/src/types/contracts/send-message.ts +12 -0
  333. package/src/types/env.ts +98 -0
  334. package/src/types/freebuff-session.ts +17 -0
  335. package/src/types/function-params.ts +28 -0
  336. package/src/types/react19-compat.d.ts +19 -0
  337. package/src/types/store.ts +111 -0
  338. package/src/types/theme-system.ts +144 -0
  339. package/src/types/utils.ts +3 -0
  340. package/src/utils/__tests__/active-run.test.ts +140 -0
  341. package/src/utils/__tests__/activity-tracker.test.ts +440 -0
  342. package/src/utils/__tests__/agent-display.test.ts +139 -0
  343. package/src/utils/__tests__/analytics-client.test.ts +262 -0
  344. package/src/utils/__tests__/anonymous-id.test.ts +74 -0
  345. package/src/utils/__tests__/anvil-api.test.ts +578 -0
  346. package/src/utils/__tests__/arrays.test.ts +264 -0
  347. package/src/utils/__tests__/bash-context-processor.test.ts +38 -0
  348. package/src/utils/__tests__/block-processor.test.ts +810 -0
  349. package/src/utils/__tests__/chat-history.test.ts +203 -0
  350. package/src/utils/__tests__/chat-input-key-intercept.test.ts +40 -0
  351. package/src/utils/__tests__/chat-meta.test.ts +130 -0
  352. package/src/utils/__tests__/clipboard-linux.test.ts +359 -0
  353. package/src/utils/__tests__/clipboard.test.ts +910 -0
  354. package/src/utils/__tests__/code-search-summary.test.ts +84 -0
  355. package/src/utils/__tests__/collapse-helpers.test.ts +1109 -0
  356. package/src/utils/__tests__/error-handling.test.ts +658 -0
  357. package/src/utils/__tests__/exit-cleanly.test.ts +110 -0
  358. package/src/utils/__tests__/feedback-helpers.test.ts +444 -0
  359. package/src/utils/__tests__/feedback-submission.test.ts +26 -0
  360. package/src/utils/__tests__/fetch-usage.test.ts +355 -0
  361. package/src/utils/__tests__/fingerprint.test.ts +144 -0
  362. package/src/utils/__tests__/fixtures/terminal-command-broker-child.ts +7 -0
  363. package/src/utils/__tests__/fixtures/terminal-command-broker-entry.ts +3 -0
  364. package/src/utils/__tests__/fixtures/terminal-command-broker-owner.ts +33 -0
  365. package/src/utils/__tests__/format-elapsed-time.test.ts +103 -0
  366. package/src/utils/__tests__/format-timeout.test.ts +87 -0
  367. package/src/utils/__tests__/freebuff-instance-owner.test.ts +69 -0
  368. package/src/utils/__tests__/freebuff-model-navigation.test.ts +102 -0
  369. package/src/utils/__tests__/freebuff-premium-reset.test.ts +79 -0
  370. package/src/utils/__tests__/freebuff-referral-cache.test.ts +116 -0
  371. package/src/utils/__tests__/freebuff-session-api.test.ts +215 -0
  372. package/src/utils/__tests__/freebuff-session-display.test.ts +23 -0
  373. package/src/utils/__tests__/freebuff-streak-line.test.ts +217 -0
  374. package/src/utils/__tests__/image-dimensions.test.ts +249 -0
  375. package/src/utils/__tests__/image-processor.test.ts +209 -0
  376. package/src/utils/__tests__/implementor-helpers.test.ts +1458 -0
  377. package/src/utils/__tests__/keyboard-actions.test.ts +660 -0
  378. package/src/utils/__tests__/layout-helpers.test.ts +36 -0
  379. package/src/utils/__tests__/lazy-response-ads.test.ts +167 -0
  380. package/src/utils/__tests__/log-shipper.test.ts +53 -0
  381. package/src/utils/__tests__/markdown-renderer.test.tsx +421 -0
  382. package/src/utils/__tests__/message-block-helpers.test.ts +1263 -0
  383. package/src/utils/__tests__/message-updater.test.ts +645 -0
  384. package/src/utils/__tests__/osc-timeout-scenarios.test.ts +172 -0
  385. package/src/utils/__tests__/pending-attachments.test.ts +277 -0
  386. package/src/utils/__tests__/polling-backoff.test.ts +51 -0
  387. package/src/utils/__tests__/queue-panel-actions.test.ts +148 -0
  388. package/src/utils/__tests__/run-state-storage.test.ts +919 -0
  389. package/src/utils/__tests__/safe-json.test.ts +77 -0
  390. package/src/utils/__tests__/sdk-event-handlers.test.ts +685 -0
  391. package/src/utils/__tests__/send-message-helpers.test.ts +1898 -0
  392. package/src/utils/__tests__/send-message-timer.test.ts +64 -0
  393. package/src/utils/__tests__/settings.test.ts +99 -0
  394. package/src/utils/__tests__/strings.test.ts +192 -0
  395. package/src/utils/__tests__/terminal-color-detection.test.ts +425 -0
  396. package/src/utils/__tests__/terminal-command-broker.test.ts +528 -0
  397. package/src/utils/__tests__/terminal-enter-detection.test.ts +77 -0
  398. package/src/utils/__tests__/terminal-io.test.ts +137 -0
  399. package/src/utils/__tests__/terminal-protocol-controller.test.ts +119 -0
  400. package/src/utils/__tests__/text-layout.test.ts +82 -0
  401. package/src/utils/__tests__/theme-platform-detection.test.ts +125 -0
  402. package/src/utils/__tests__/think-tag-parser.test.ts +159 -0
  403. package/src/utils/__tests__/trace-writer.test.ts +223 -0
  404. package/src/utils/__tests__/trim-chat-logs.test.ts +78 -0
  405. package/src/utils/__tests__/usage-banner-state.test.ts +298 -0
  406. package/src/utils/__tests__/validation-error-formatting.test.ts +126 -0
  407. package/src/utils/__tests__/windows-terminal-health.test.ts +101 -0
  408. package/src/utils/__tests__/write-file-atomic.test.ts +127 -0
  409. package/src/utils/active-run.ts +131 -0
  410. package/src/utils/activity-tracker.ts +66 -0
  411. package/src/utils/agent-display.ts +87 -0
  412. package/src/utils/agent-helpers.ts +27 -0
  413. package/src/utils/agent-id-utils.ts +14 -0
  414. package/src/utils/ai-message-id.ts +12 -0
  415. package/src/utils/analytics.ts +361 -0
  416. package/src/utils/anonymous-id.ts +87 -0
  417. package/src/utils/anvil-api.ts +612 -0
  418. package/src/utils/anvil-client.ts +217 -0
  419. package/src/utils/arrays.ts +117 -0
  420. package/src/utils/auth.ts +258 -0
  421. package/src/utils/bash-context-processor.ts +47 -0
  422. package/src/utils/bash-messages.ts +108 -0
  423. package/src/utils/block-margins.ts +35 -0
  424. package/src/utils/block-operations.ts +523 -0
  425. package/src/utils/block-processor.ts +213 -0
  426. package/src/utils/chat-history.ts +253 -0
  427. package/src/utils/chat-input-key-intercept.ts +52 -0
  428. package/src/utils/chat-meta.ts +90 -0
  429. package/src/utils/chat-scroll-accel.ts +151 -0
  430. package/src/utils/clipboard-image.ts +588 -0
  431. package/src/utils/clipboard.ts +397 -0
  432. package/src/utils/code-search-summary.ts +70 -0
  433. package/src/utils/collapse-helpers.ts +256 -0
  434. package/src/utils/config-dir.ts +24 -0
  435. package/src/utils/constants.ts +190 -0
  436. package/src/utils/create-event-handler-state.ts +73 -0
  437. package/src/utils/create-run-config.ts +121 -0
  438. package/src/utils/detect-shell.ts +116 -0
  439. package/src/utils/directory-browser.ts +72 -0
  440. package/src/utils/engagement.ts +55 -0
  441. package/src/utils/env.ts +92 -0
  442. package/src/utils/error-handling.ts +255 -0
  443. package/src/utils/error-messages.ts +62 -0
  444. package/src/utils/exit-cleanly.ts +86 -0
  445. package/src/utils/feedback-helpers.ts +103 -0
  446. package/src/utils/feedback-submission.ts +22 -0
  447. package/src/utils/fetch-usage.ts +76 -0
  448. package/src/utils/fingerprint.ts +245 -0
  449. package/src/utils/format-elapsed-time.ts +33 -0
  450. package/src/utils/format-session-units.ts +6 -0
  451. package/src/utils/format-timeout.ts +28 -0
  452. package/src/utils/format-validation-errors-for-message.ts +84 -0
  453. package/src/utils/freebuff-agent-selection.ts +33 -0
  454. package/src/utils/freebuff-instance-owner.ts +66 -0
  455. package/src/utils/freebuff-model-navigation.ts +50 -0
  456. package/src/utils/freebuff-premium-reset.ts +55 -0
  457. package/src/utils/freebuff-referral-cache.ts +51 -0
  458. package/src/utils/freebuff-session-api.ts +243 -0
  459. package/src/utils/freebuff-session-display.ts +21 -0
  460. package/src/utils/freebuff-streak-line.ts +93 -0
  461. package/src/utils/git.ts +17 -0
  462. package/src/utils/helpers.ts +47 -0
  463. package/src/utils/image-display.ts +67 -0
  464. package/src/utils/image-handler.ts +325 -0
  465. package/src/utils/image-processor.ts +133 -0
  466. package/src/utils/image-thumbnail.ts +78 -0
  467. package/src/utils/implementor-helpers.ts +787 -0
  468. package/src/utils/input-modes.ts +174 -0
  469. package/src/utils/keyboard-actions.ts +392 -0
  470. package/src/utils/keypad-keys.ts +47 -0
  471. package/src/utils/layout-helpers.ts +33 -0
  472. package/src/utils/lazy-response-ads.ts +1 -0
  473. package/src/utils/local-agent-registry.ts +474 -0
  474. package/src/utils/log-shipper.ts +126 -0
  475. package/src/utils/logger.ts +327 -0
  476. package/src/utils/markdown-renderer.tsx +1090 -0
  477. package/src/utils/math.ts +3 -0
  478. package/src/utils/message-block-helpers.ts +684 -0
  479. package/src/utils/message-history.ts +143 -0
  480. package/src/utils/message-tree-utils.ts +67 -0
  481. package/src/utils/message-updater.ts +279 -0
  482. package/src/utils/open-file.ts +146 -0
  483. package/src/utils/open-url.ts +63 -0
  484. package/src/utils/path-completion.ts +111 -0
  485. package/src/utils/path-helpers.ts +38 -0
  486. package/src/utils/pending-attachments.ts +360 -0
  487. package/src/utils/polling-backoff.ts +59 -0
  488. package/src/utils/project-picker.ts +12 -0
  489. package/src/utils/queue-panel-actions.ts +70 -0
  490. package/src/utils/recent-projects.ts +156 -0
  491. package/src/utils/renderer-cleanup.ts +233 -0
  492. package/src/utils/response-ad-positions.ts +1 -0
  493. package/src/utils/run-state-storage.ts +603 -0
  494. package/src/utils/safe-json.ts +153 -0
  495. package/src/utils/sdk-event-handlers.ts +571 -0
  496. package/src/utils/send-message-helpers.ts +210 -0
  497. package/src/utils/send-message-timer.ts +110 -0
  498. package/src/utils/settings.ts +226 -0
  499. package/src/utils/skill-registry.ts +94 -0
  500. package/src/utils/spawn-agent-matcher.ts +55 -0
  501. package/src/utils/status-indicator-state.ts +107 -0
  502. package/src/utils/stream-chunk-processor.ts +57 -0
  503. package/src/utils/strings.ts +271 -0
  504. package/src/utils/subscription.ts +31 -0
  505. package/src/utils/syntax-highlighter.tsx +19 -0
  506. package/src/utils/terminal-color-detection.ts +474 -0
  507. package/src/utils/terminal-command-broker.ts +467 -0
  508. package/src/utils/terminal-enter-detection.ts +63 -0
  509. package/src/utils/terminal-images.ts +229 -0
  510. package/src/utils/terminal-io.ts +85 -0
  511. package/src/utils/terminal-protocol-controller.ts +173 -0
  512. package/src/utils/terminal-reset-sequences.ts +33 -0
  513. package/src/utils/terminal-title.ts +84 -0
  514. package/src/utils/terminal-watchdog.ts +366 -0
  515. package/src/utils/text-layout.ts +149 -0
  516. package/src/utils/theme-config.ts +141 -0
  517. package/src/utils/theme-system.ts +1206 -0
  518. package/src/utils/think-tag-parser.ts +95 -0
  519. package/src/utils/time-format.ts +21 -0
  520. package/src/utils/trace-writer.ts +162 -0
  521. package/src/utils/ui-constants.ts +69 -0
  522. package/src/utils/update-checker.ts +110 -0
  523. package/src/utils/usage-banner-state.ts +146 -0
  524. package/src/utils/validation-error-formatting.ts +86 -0
  525. package/src/utils/validation-error-helpers.ts +19 -0
  526. package/src/utils/windows-terminal-health.ts +124 -0
  527. package/src/utils/word-wrap-utils.ts +53 -0
  528. package/src/utils/write-file-atomic.ts +54 -0
  529. package/src/utils/yield-to-event-loop.ts +9 -0
@@ -0,0 +1,1087 @@
1
+ import { TextAttributes } from '@opentui/core'
2
+ import { useKeyboard } from '@opentui/react'
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react'
11
+
12
+ import { Button } from './button'
13
+ import { FreebuffReferralBanner } from './freebuff-referral-banner'
14
+ import {
15
+ FREEBUFF_GLM_V52_MODEL_ID,
16
+ FREEBUFF_PREMIUM_SESSION_LIMIT,
17
+ getFreebuffDeploymentAvailabilityLabel,
18
+ getFreebuffModel,
19
+ getFreebuffModelSupersededBy,
20
+ getFreebuffModelsForAccessTier,
21
+ getRecommendedFreebuffModelId,
22
+ isFreebuffGlmV52ModelId,
23
+ isFreebuffModelAvailable,
24
+ isFreebuffPremiumModelId,
25
+ isSupportedFreebuffModelId,
26
+ } from '@anvil/common/constants/freebuff-models'
27
+ import {
28
+ getLimitedModelOffers,
29
+ getRateLimitsByModel,
30
+ getGlmPromo,
31
+ getReferralInfo,
32
+ } from '@anvil/common/types/freebuff-session'
33
+
34
+ import { startFreebuffSession } from '../hooks/use-freebuff-session'
35
+ import { useNow } from '../hooks/use-now'
36
+ import { useFreebuffModelStore } from '../state/freebuff-model-store'
37
+ import { useFreebuffSessionStore } from '../state/freebuff-session-store'
38
+ import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
39
+ import { useTheme } from '../hooks/use-theme'
40
+ import {
41
+ freebuffModelNavigationDirectionForKey,
42
+ nextFreebuffModelId,
43
+ } from '../utils/freebuff-model-navigation'
44
+ import { formatSessionUnits } from '../utils/format-session-units'
45
+ import {
46
+ formatFreebuffPremiumResetCountdown,
47
+ getFreebuffPremiumResetAt,
48
+ } from '../utils/freebuff-premium-reset'
49
+ import { isPlainEnterKey } from '../utils/terminal-enter-detection'
50
+
51
+ import type {
52
+ FreebuffAccessTier,
53
+ FreebuffModelOption,
54
+ } from '@anvil/common/constants/freebuff-models'
55
+ import type { FreebuffReferralFocusTarget } from './freebuff-referral-banner'
56
+ import type {
57
+ BoxRenderable,
58
+ KeyEvent,
59
+ ScrollBoxRenderable,
60
+ } from '@opentui/core'
61
+
62
+ // The picker opens collapsed to a single recommended hero so a new user can
63
+ // start with one Enter press without reading six boxes. The "see all models"
64
+ // toggle reveals the rest, grouped into the same product/availability tiers.
65
+ //
66
+ // Section grouping (expanded view): every model row, including the recommended
67
+ // one, keeps its tier so it is obvious which quota it consumes. The premium
68
+ // models share one daily session quota while the unlimited ones have none.
69
+ // Putting the tier on a section header lets each row drop its redundant
70
+ // "Premium"/"Unlimited" chip. The PREMIUM header carries the shared quota
71
+ // inline — "N of M used · resets in …" — once any session is spent (turning
72
+ // amber when exhausted, the moment its rows grey out). When collapsed there's
73
+ // no PREMIUM header, so the parent keeps a below-picker counter for the
74
+ // collapsed state (and for the limited tier, which has no premium section).
75
+ // Since 2026-08-12 the full-access hero is the PREMIUM DeepSeek V4 Pro 08/13
76
+ // again (DEFAULT_FREEBUFF_MODEL_ID), so it draws on that same pool and flips to
77
+ // the unlimited Flash once the pool empties — the hero must always be joinable.
78
+ // The limited tier's hero stays the always-available Flash. UNLIMITED needs no
79
+ // annotation. Empty sections are filtered so a model set with no premium (or no
80
+ // unlimited) entries doesn't render an orphan header.
81
+ //
82
+ // `label` may be empty: limited-tier users only see the constrained model set,
83
+ // so the "LIMITED" header would just leak the internal tier name without
84
+ // organizing anything. Renderer treats an empty label as "no header row".
85
+ type Section = {
86
+ key: 'premium' | 'unlimited' | 'limited' | 'offer'
87
+ label: string
88
+ models: readonly FreebuffModelOption[]
89
+ }
90
+
91
+ // Sentinel id for the expand/collapse toggle so it can ride the same
92
+ // keyboard-navigation list as the model rows (Tab/arrow to it, Enter to fire).
93
+ const TOGGLE_ID = '__freebuff_toggle__'
94
+
95
+ // There used to be a right-aligned "Press Enter ↵" cue on the focused row, with
96
+ // its width reserved in the line-1 budget below. Both are gone: the cue was
97
+ // redundant next to the green focus border, and its reserved gutter widened
98
+ // every card by ~17 columns of dead space.
99
+
100
+ /**
101
+ * Pre-chat model picker (session 'none'): user hasn't started a session yet.
102
+ * Picking a model is their explicit commitment to enter — this triggers the
103
+ * POST, which admits them straight to an active session. Opens collapsed to
104
+ * the recommended hero; Enter starts immediately.
105
+ *
106
+ * Keyboard navigation: Tab / arrow keys move the green highlight; Enter (or
107
+ * Space) commits the focused row — or, on the toggle, expands/collapses the
108
+ * list. Mouse click commits in one step.
109
+ *
110
+ * Layout: the recommended model renders as a titled "RECOMMENDED" card. When
111
+ * expanded, every full-access row is grouped into PREMIUM / UNLIMITED sections
112
+ * so the recommended row's tier is explicit without a per-row chip; the shared
113
+ * premium-session quota rides the PREMIUM header. Names align in a column
114
+ * so taglines line up across rows, and the secondary details (warning /
115
+ * deployment hours) always sit on their own centered line under the name —
116
+ * keeping a row with a warning from stretching into one very long line. On
117
+ * terminals too narrow for the name column, line 1 compacts to "name · tagline".
118
+ *
119
+ * On short terminals the parent passes `maxHeight`: the model rows and the
120
+ * referral/GLM controls live in one scrollbox capped at that many rows. A
121
+ * scrollbar appears when the whole menu doesn't fit, and Tab/arrow navigation
122
+ * keeps the focused control scrolled into view.
123
+ */
124
+ interface FreebuffModelSelectorProps {
125
+ /** Max vertical rows the picker may occupy. When the rendered rows exceed
126
+ * this, the list scrolls (scrollbar shown, focused row kept in view);
127
+ * otherwise the scrollbox shrinks to fit and no scrollbar appears. */
128
+ maxHeight: number
129
+ /** Notifies the parent whenever the picker expands/collapses. The landing
130
+ * screen uses it to promote the wordmark to the full ASCII logo while the
131
+ * picker is collapsed (the freed rows make room). */
132
+ onExpandedChange?: (expanded: boolean) => void
133
+ /** Rendered between the expand/collapse toggle and the referral banner. The
134
+ * landing screen passes its session counter here so the quota sits with the
135
+ * models it describes, leaving the referral pitch and its copy control
136
+ * adjacent as one unit. Lives inside the scrollbox, so it scrolls with the
137
+ * rest of the content rather than being pinned below it. */
138
+ belowToggle?: React.ReactNode
139
+ }
140
+
141
+ /** The rows the grid shows a tier. GLM 5.2 is a referral reward, not a freely-pickable
142
+ * model, so it reaches the user through FreebuffReferralBanner instead. */
143
+ function gridModels(
144
+ accessTier: FreebuffAccessTier,
145
+ ): readonly FreebuffModelOption[] {
146
+ return getFreebuffModelsForAccessTier(accessTier).filter(
147
+ (m) => !isFreebuffGlmV52ModelId(m.id),
148
+ )
149
+ }
150
+
151
+ /** Every model id this screen can offer a tier: the grid, plus the banner's earned GLM
152
+ * action. Exported so the offer→gate invariant test reads the real set rather than a
153
+ * copy of it.
154
+ *
155
+ * GLM is offered on BOTH tiers. It used to be full-access only, which was correct while
156
+ * GLM was a referral reward and referrals paid limited users in something else. Bounties
157
+ * changed that: a bounty grant is minted redeemable at limited access so the reward is
158
+ * worth the same in every region. The balance is what gates the row — the banner only
159
+ * renders the action when the server reports sessions left — and the tier never was. */
160
+ export function freebuffCliOfferedModelIds(
161
+ accessTier: FreebuffAccessTier,
162
+ ): readonly string[] {
163
+ return [...gridModels(accessTier).map((m) => m.id), FREEBUFF_GLM_V52_MODEL_ID]
164
+ }
165
+
166
+ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
167
+ maxHeight,
168
+ onExpandedChange,
169
+ belowToggle,
170
+ }) => {
171
+ const theme = useTheme()
172
+ // contentMaxWidth (not terminalWidth) is the real budget — the parent
173
+ // landing screen wraps this picker in a `maxWidth: contentMaxWidth`
174
+ // box (capped at 80 cols), so a wide terminal doesn't actually let us
175
+ // sprawl the buttons across it.
176
+ const { contentMaxWidth } = useTerminalDimensions()
177
+ const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
178
+ const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
179
+ const session = useFreebuffSessionStore((s) => s.session)
180
+ const accessTier =
181
+ (session && 'accessTier' in session ? session.accessTier : undefined) ??
182
+ 'full'
183
+ const now = useNow(60_000)
184
+ const deploymentAvailabilityLabel = useMemo(
185
+ () => getFreebuffDeploymentAvailabilityLabel(new Date(now)),
186
+ [now],
187
+ )
188
+ const [pending, setPending] = useState<string | null>(null)
189
+ const [hoveredId, setHoveredId] = useState<string | null>(null)
190
+
191
+ const availableModels = useMemo(() => gridModels(accessTier), [accessTier])
192
+ // Capacity-limited models the SERVER decided to offer on this response. The
193
+ // client has no catalog of its own for these on purpose: when the wave's pool
194
+ // empties (or the offer is switched off) the payload stops arriving and every
195
+ // derived value below collapses to empty, so the picker renders exactly what
196
+ // it rendered before the offer existed — no stale row, no greyed-out tease.
197
+ //
198
+ // An unknown model id is dropped rather than rendered from the wire payload:
199
+ // display name and data-use warning must come from the shared catalog, so a
200
+ // server that advertises a model this build has never heard of is a no-op
201
+ // instead of a half-labelled row.
202
+ const offers = useMemo(
203
+ () =>
204
+ getLimitedModelOffers(session).filter((offer) =>
205
+ isSupportedFreebuffModelId(offer.model),
206
+ ),
207
+ [session],
208
+ )
209
+ const offerModels = useMemo(
210
+ () => offers.map((offer) => getFreebuffModel(offer.model)),
211
+ [offers],
212
+ )
213
+ const offerByModelId = useMemo(
214
+ () => new Map(offers.map((offer) => [offer.model, offer])),
215
+ [offers],
216
+ )
217
+ // No queued state any more: there's never a model the user is "already in"
218
+ // the queue for, so re-picking is always meaningful.
219
+ const committedModelId: string | null = null
220
+ const rateLimitsByModel = getRateLimitsByModel(session)
221
+ const referral = getReferralInfo(session)
222
+ // Present only while a promo runs; absent renders the banner exactly as it
223
+ // rendered before promos existed.
224
+ const glmPromo = getGlmPromo(session)
225
+
226
+ // Premium-session quota, surfaced on the PREMIUM header itself: "N of M used
227
+ // · resets in …". All premium models share one pool; the server replicates
228
+ // the same snapshot under every model id, so any entry has the right count.
229
+ // The count shows from the start — even at "0 of M" — so full-access users
230
+ // can see the daily pool and reset cadence before they spend anything. The
231
+ // limit is the server-sent one (base + streak bonus, falling back to the
232
+ // static base before any snapshot arrives) so the label, the amber
233
+ // exhausted cue, and isJoinable below can never disagree. Exhaustion is
234
+ // also the moment the recommended hero flips to the unlimited fallback (when
235
+ // the recommendation is premium) — the hero must always be joinable. (The PREMIUM
236
+ // section only renders for the full-access tier, so this is scoped to it.)
237
+ const sharedRateLimit = rateLimitsByModel
238
+ ? Object.values(rateLimitsByModel)[0]
239
+ : undefined
240
+ const premiumUsed = sharedRateLimit?.recentCount ?? 0
241
+ const premiumLimit = sharedRateLimit?.limit ?? FREEBUFF_PREMIUM_SESSION_LIMIT
242
+ const premiumExhausted = premiumUsed >= premiumLimit
243
+ // The pool resets daily on a Pacific-day boundary regardless of usage, so the
244
+ // countdown is meaningful even at zero used — getFreebuffPremiumResetAt falls
245
+ // back to the next day boundary when the server hasn't sent a resetAt yet.
246
+ const premiumResetCountdown = formatFreebuffPremiumResetCountdown(
247
+ getFreebuffPremiumResetAt({ rateLimitsByModel, nowMs: now }),
248
+ now,
249
+ )
250
+
251
+ const recommendedModel = useMemo(() => {
252
+ const id = getRecommendedFreebuffModelId(accessTier, { premiumExhausted })
253
+ return availableModels.find((m) => m.id === id) ?? availableModels[0]!
254
+ }, [accessTier, availableModels, premiumExhausted])
255
+
256
+ // "A better model exists" footnote for a row. The CLI has no in-row button to
257
+ // switch with, so it shows the notice only — the replacement is always
258
+ // reachable as a row in this same picker (and is usually the RECOMMENDED hero
259
+ // one Enter away), which is what getFreebuffModelSupersededBy guarantees by
260
+ // resolving against the models actually on screen.
261
+ const supersededNoticeFor = useCallback(
262
+ (model: FreebuffModelOption): string | undefined =>
263
+ // Only on the row the user is actually on — the nudge is about THEIR
264
+ // pick, and the list ordering already steers everyone else to the
265
+ // replacement. Gated here rather than at the render so the width math and
266
+ // the height estimate below stay in agreement with what is drawn.
267
+ model.id === selectedModel
268
+ ? getFreebuffModelSupersededBy(
269
+ model.id,
270
+ availableModels.map((m) => m.id),
271
+ )?.notice
272
+ : undefined,
273
+ [availableModels, selectedModel],
274
+ )
275
+ const otherModels = useMemo(
276
+ () => availableModels.filter((m) => m.id !== recommendedModel.id),
277
+ [availableModels, recommendedModel],
278
+ )
279
+ // Only worth collapsing when the toggle actually hides something. With a
280
+ // single "other" model (limited tier) we just show both — a "see 1 more
281
+ // model" toggle is noise.
282
+ const canCollapse = otherModels.length >= 2
283
+
284
+ const isJoinable = useCallback(
285
+ (modelId: string) => {
286
+ if (!isFreebuffModelAvailable(modelId, new Date(now))) return false
287
+ // An offer row is on screen only while the shared pool has capacity, so
288
+ // what's left to check is the caller's own daily ceiling. It travels on
289
+ // the offer payload rather than in `rateLimitsByModel`, which the server
290
+ // deliberately keeps free of these models so the 30s poll doesn't pay for
291
+ // a quota nobody is using.
292
+ const offer = offerByModelId.get(modelId)
293
+ if (offer) return offer.userRemaining > 0
294
+ const rateLimit = rateLimitsByModel?.[modelId]
295
+ return !rateLimit || rateLimit.recentCount < rateLimit.limit
296
+ },
297
+ [now, offerByModelId, rateLimitsByModel],
298
+ )
299
+
300
+ // Default collapsed only on the landing screen and only when the saved/active
301
+ // selection IS the recommended model — a returning user whose preference is a
302
+ // different model gets the expanded list so their pick is visible and focused.
303
+ // STARTABLE, not merely different: the effect below is about to replace an
304
+ // unstartable pick with the recommendation, and expanding to show a spent row
305
+ // "focused" buries the hero under rows the user cannot press — which is what
306
+ // a spent premium pool did once the default became premium (2026-08-12).
307
+ const isLanding = session?.status === 'none' || !session
308
+ const [expanded, setExpanded] = useState(
309
+ () =>
310
+ !canCollapse ||
311
+ !isLanding ||
312
+ (selectedModel !== recommendedModel.id && isJoinable(selectedModel)),
313
+ )
314
+ // Limited mode has no labeled tier section, so moving its recommendation
315
+ // inside that section would only move the existing inter-card spacing above
316
+ // the entire list. Keep its original standalone recommendation; full-access
317
+ // expanded views put every row beneath a quota-bearing section header.
318
+ const showStandaloneRecommended = !expanded || accessTier === 'limited'
319
+ // The session snapshot arrives asynchronously. If it changes the picker
320
+ // from full access (collapsible) to limited access (only two rows), force
321
+ // the list open before notifying the parent; otherwise the toggle disappears
322
+ // while the second limited model remains hidden.
323
+ //
324
+ // Mirror the settled state up to the landing screen (collapsed → it promotes
325
+ // the wordmark to the full ASCII logo). useLayoutEffect keeps both corrections
326
+ // ahead of paint.
327
+ useLayoutEffect(() => {
328
+ if (!canCollapse && !expanded) {
329
+ setExpanded(true)
330
+ return
331
+ }
332
+ onExpandedChange?.(expanded)
333
+ }, [canCollapse, expanded, onExpandedChange])
334
+
335
+ // Keyboard cursor — separate from the actually-selected model so that
336
+ // Tab/arrow navigation can preview without committing. Starts on the user's
337
+ // saved/active pick (the recommended hero for a new user, since that's the
338
+ // default selection; their own model when expanded for a returning user).
339
+ const [focusedId, setFocusedId] = useState<string>(() => selectedModel)
340
+
341
+ // The referral banner contributes its GLM/copy actions to the selector's
342
+ // navigation order. Keeping them local avoids a global focus bridge now that
343
+ // the banner renders inside this selector.
344
+ const [extraTargets, setExtraTargets] = useState<
345
+ FreebuffReferralFocusTarget[]
346
+ >([])
347
+ const extraTargetIds = useMemo(
348
+ () => extraTargets.map((t) => t.id),
349
+ [extraTargets],
350
+ )
351
+ const contentRef = useRef<BoxRenderable | null>(null)
352
+ const [measuredContentHeight, setMeasuredContentHeight] = useState<
353
+ number | null
354
+ >(null)
355
+ const syncContentHeight = useCallback(() => {
356
+ const nextHeight = contentRef.current?.height
357
+ if (!nextHeight) return
358
+ setMeasuredContentHeight((current) =>
359
+ current === nextHeight ? current : nextHeight,
360
+ )
361
+ }, [])
362
+ // The standing catalog's tier sections. Expanded-only; the offer section
363
+ // below is added on top and is visible in both states.
364
+ const catalogSections = useMemo(() => {
365
+ if (!expanded) return [] as readonly Section[]
366
+ if (accessTier === 'limited') {
367
+ return [
368
+ { key: 'limited', label: '', models: otherModels },
369
+ ] satisfies readonly Section[]
370
+ }
371
+ return (
372
+ [
373
+ {
374
+ key: 'premium',
375
+ label: 'PREMIUM',
376
+ models: availableModels.filter((m) => isFreebuffPremiumModelId(m.id)),
377
+ },
378
+ {
379
+ key: 'unlimited',
380
+ label: 'UNLIMITED',
381
+ models: availableModels.filter(
382
+ (m) => !isFreebuffPremiumModelId(m.id),
383
+ ),
384
+ },
385
+ ] satisfies readonly Section[]
386
+ ).filter((section) => section.models.length > 0)
387
+ }, [expanded, accessTier, availableModels, otherModels])
388
+
389
+ // Every section that gets drawn, in draw order. THE single source for the
390
+ // render, the navigation order and the height estimate — those three must
391
+ // agree on which sections exist or the focused-row auto-scroll desyncs, so
392
+ // they all read this rather than each rebuilding the list.
393
+ //
394
+ // The offer section leads and is drawn in BOTH the collapsed and expanded
395
+ // views, unlike every other section. A time-boxed frontier model that only a
396
+ // few dozen people get is the one row worth spending a collapsed-view line
397
+ // on — hiding it behind "see all models" would mean most users never learn
398
+ // the offer happened. It still sits after the recommended hero (drawn
399
+ // separately, above), so the collapsed view reads "recommended first, special
400
+ // second".
401
+ const renderedSections = useMemo(
402
+ () =>
403
+ offerModels.length > 0
404
+ ? [
405
+ {
406
+ key: 'offer' as const,
407
+ label: 'LIMITED TRIAL',
408
+ models: offerModels,
409
+ },
410
+ ...catalogSections,
411
+ ]
412
+ : catalogSections,
413
+ [offerModels, catalogSections],
414
+ )
415
+
416
+ // Model rows in render order: a standalone recommendation in collapsed and
417
+ // limited views, followed by all models belonging to rendered sections.
418
+ const renderedModelIds = useMemo(
419
+ () => [
420
+ ...(showStandaloneRecommended ? [recommendedModel.id] : []),
421
+ ...renderedSections.flatMap((section) => section.models.map((m) => m.id)),
422
+ ],
423
+ [recommendedModel, renderedSections, showStandaloneRecommended],
424
+ )
425
+ // Keyboard-navigable ids: the model rows, then the toggle, then any focus
426
+ // targets the referral banner registered (so arrowing down past "see all
427
+ // models" reaches its buttons; nextFreebuffModelId wraps back to the top).
428
+ const navIds = useMemo(
429
+ () => [
430
+ ...renderedModelIds,
431
+ ...(canCollapse ? [TOGGLE_ID] : []),
432
+ ...extraTargetIds,
433
+ ],
434
+ [canCollapse, renderedModelIds, extraTargetIds],
435
+ )
436
+
437
+ // Keep focus valid as the list expands/collapses or the selection changes
438
+ // server-side. An explicit, still-valid focus (e.g. just set by the toggle)
439
+ // is preserved; only an out-of-range focus snaps back to the selection.
440
+ useEffect(() => {
441
+ setFocusedId((curr) =>
442
+ navIds.includes(curr)
443
+ ? curr
444
+ : navIds.includes(selectedModel)
445
+ ? selectedModel
446
+ : recommendedModel.id,
447
+ )
448
+ }, [navIds, recommendedModel.id, selectedModel])
449
+
450
+ useEffect(() => {
451
+ // Landing-screen safety net: the selection has to be one the user can
452
+ // actually start, or Enter POSTs a model the server rejects — or, worse,
453
+ // `pick` refuses it here and Enter does nothing at all. Two rules, because
454
+ // GLM is not an ordinary row:
455
+ //
456
+ // - GLM 5.2 is judged by its referral BALANCE and nothing else. It is
457
+ // selectable from the banner but is not in FREEBUFF_MODELS, so it never
458
+ // reaches `renderedModelIds`, and its quota is not in this surface's
459
+ // snapshot, so `isJoinable` cannot see it either. With
460
+ // `accessTier === 'full'` in here instead, a limited-tier user who
461
+ // pressed "Use GLM 5.2" had their pick judged invalid one render later
462
+ // and silently swapped for the recommendation — the bounty session they
463
+ // had earned was spendable on the server and unreachable from this
464
+ // screen.
465
+ // - every other model must be on screen AND startable. Rendered is not
466
+ // enough: a spent premium row stays on screen, greyed, and `pick`
467
+ // refuses it. That was unreachable while the default was unlimited;
468
+ // since it became premium (2026-08-12) it is the ordinary state of a
469
+ // returning user who has spent their pool. isJoinable runs the
470
+ // deployment-hours check too, so closing hours are covered here.
471
+ //
472
+ // In-memory only — `setSelectedModel` doesn't persist, so the user's saved
473
+ // preference survives for their next launch.
474
+ const selectionIsStartable = isFreebuffGlmV52ModelId(selectedModel)
475
+ ? (referral?.weeklySessionsRemaining ?? 0) > 0
476
+ : renderedModelIds.includes(selectedModel) && isJoinable(selectedModel)
477
+ if (isLanding && !selectionIsStartable) {
478
+ setSelectedModel(recommendedModel.id)
479
+ // The cursor moves too: the focus effect above only rescues an
480
+ // out-of-RANGE focus, and the row we just refused is still in range.
481
+ setFocusedId(recommendedModel.id)
482
+ }
483
+ }, [
484
+ referral?.weeklySessionsRemaining,
485
+ renderedModelIds,
486
+ isLanding,
487
+ isJoinable,
488
+ recommendedModel.id,
489
+ selectedModel,
490
+ setSelectedModel,
491
+ ])
492
+
493
+ const BUTTON_CHROME = 4 // 2 border + 2 padding
494
+ const NAME_GAP = 2 // spaces between name column and details column
495
+
496
+ // Rows are two lines: line 1 is the identity (name + tagline), line 2 carries
497
+ // the secondary details (AI-training warning · deployment hours), centered.
498
+ // The warning ALWAYS gets its own line rather than being appended to line 1 —
499
+ // the recommended hero carries the training notice, and inlining it made that
500
+ // row one very long line that dominated the landing screen.
501
+ //
502
+ // Line 1 is normally two columns: a fixed name column (padded to the longest
503
+ // displayName across all rows) followed by the tagline, so taglines align
504
+ // down the list. On terminals too narrow for that it falls back to a compact
505
+ // "name · tagline". Computed across ALL models (not just the expanded ones)
506
+ // so the recommended hero and the revealed rows share one width and nothing
507
+ // reflows on toggle.
508
+ const { compactNames, buttonOuterWidth, buttonInnerWidth, nameColumnWidth } =
509
+ useMemo(() => {
510
+ // Every row that can appear, offer rows included: their row is visible
511
+ // while collapsed, so leaving them out would let the card jump width the
512
+ // moment an offer arrives. The offer's own counts ride its section header
513
+ // (like PREMIUM's quota), so only name and tagline enter the column math.
514
+ const widthModels = [...availableModels, ...offerModels]
515
+ const maxNameLen = Math.max(
516
+ ...widthModels.map((m) => m.displayName.length),
517
+ )
518
+
519
+ const joinedLen = (parts: number[]): number =>
520
+ parts.reduce((a, b) => a + b, 0) + Math.max(0, parts.length - 1) * 3 // " · "
521
+
522
+ const detailsParts = (model: FreebuffModelOption): number[] => {
523
+ const parts: number[] = []
524
+ if (model.warning) parts.push(model.warning.length)
525
+ if (model.availability === 'deployment_hours') {
526
+ parts.push(deploymentAvailabilityLabel.length)
527
+ }
528
+ return parts
529
+ }
530
+
531
+ // Line 3, when a better model exists. Its own line: the notice is a full
532
+ // sentence, so appending it to line 2 would stretch the card past any
533
+ // reasonable terminal width.
534
+ const noticeLineLen = (m: FreebuffModelOption) =>
535
+ supersededNoticeFor(m)?.length ?? 0
536
+
537
+ // Compact image indicator (" · Images", 9 chars) appended to the tagline on
538
+ // line 1 so it never occupies its own line. Only NATIVELY multimodal models
539
+ // carry it — text-only ones read an image as a vision-model description
540
+ // substituted server-side, which is a real fallback but not a capability
541
+ // worth advertising as a per-row badge.
542
+ const multimodalSuffixLen = (m: FreebuffModelOption) =>
543
+ m.multimodal ? 9 : 0
544
+ // Same treatment for the " · Reasoning: high" effort suffix.
545
+ const reasoningSuffixLen = (m: FreebuffModelOption) =>
546
+ m.reasoningEffort ? 14 + m.reasoningEffort.length : 0
547
+ // Same treatment for the " · NEW" badge (6 chars).
548
+ const newSuffixLen = 6
549
+
550
+ // Line 1, in each mode.
551
+ const columnLabelLen = (m: FreebuffModelOption) =>
552
+ 2 /* indicator + space */ +
553
+ maxNameLen +
554
+ NAME_GAP +
555
+ m.tagline.length +
556
+ reasoningSuffixLen(m) +
557
+ multimodalSuffixLen(m) +
558
+ (m.isNew ? newSuffixLen : 0)
559
+ const compactLabelLen = (m: FreebuffModelOption) =>
560
+ 2 +
561
+ m.displayName.length +
562
+ 3 /* " · " */ +
563
+ m.tagline.length +
564
+ reasoningSuffixLen(m) +
565
+ multimodalSuffixLen(m) +
566
+ (m.isNew ? newSuffixLen : 0)
567
+
568
+ // Line 2, or 0 for a row with neither warning nor hours. Centered in the
569
+ // card rather than indented under line 1's details column — the notice is
570
+ // a footnote about the row as a whole, and right-flushing it against the
571
+ // border (which the old indent did on the widest row) read as ragged.
572
+ // Centering means it only needs its own length to fit, so it no longer
573
+ // stretches the card.
574
+ const detailsLineLen = (m: FreebuffModelOption) =>
575
+ joinedLen(detailsParts(m))
576
+
577
+ // Cards are exactly as wide as their widest line. Nothing is reserved
578
+ // beyond that — the removed "Press Enter ↵" gutter used to pad every card
579
+ // out to the hero's line plus 17 columns of empty space.
580
+ const innerWidth = (labelLen: (m: FreebuffModelOption) => number) =>
581
+ Math.max(
582
+ ...widthModels.map((m) =>
583
+ Math.max(labelLen(m), detailsLineLen(m), noticeLineLen(m)),
584
+ ),
585
+ )
586
+
587
+ const columnInner = innerWidth(columnLabelLen)
588
+ const columnOuter = columnInner + BUTTON_CHROME
589
+ if (columnOuter <= contentMaxWidth) {
590
+ return {
591
+ compactNames: false,
592
+ buttonOuterWidth: columnOuter,
593
+ buttonInnerWidth: columnInner,
594
+ nameColumnWidth: maxNameLen,
595
+ }
596
+ }
597
+
598
+ // Narrow: drop the name padding so line 1 reads "name · tagline".
599
+ const compactOuter = Math.min(
600
+ innerWidth(compactLabelLen) + BUTTON_CHROME,
601
+ contentMaxWidth,
602
+ )
603
+ return {
604
+ compactNames: true,
605
+ buttonOuterWidth: compactOuter,
606
+ buttonInnerWidth: compactOuter - BUTTON_CHROME,
607
+ nameColumnWidth: maxNameLen,
608
+ }
609
+ }, [
610
+ availableModels,
611
+ offerModels,
612
+ contentMaxWidth,
613
+ deploymentAvailabilityLabel,
614
+ supersededNoticeFor,
615
+ ])
616
+
617
+ // A row spends a second line whenever it has details to put there — no longer
618
+ // conditional on the terminal width, since the warning never inlines.
619
+ const rowHasDetailsLine = useCallback(
620
+ (m: FreebuffModelOption) =>
621
+ !!m.warning || m.availability === 'deployment_hours',
622
+ [],
623
+ )
624
+
625
+ // Initial model-only height estimate. The content wrapper below reports its
626
+ // actual laid-out height, including wrapped referral copy and responsive
627
+ // action rows; this estimate only avoids a zero-height first frame.
628
+ // Headers add 1 row; sections after the first add 1 row of marginTop; the
629
+ // toggle adds its marginTop + 1.
630
+ const SECTION_GAP = 1
631
+ const TOGGLE_MARGIN = 1
632
+ const estimatedModelHeight = useMemo(() => {
633
+ let y = 0
634
+ const rowHeight = (m: FreebuffModelOption) =>
635
+ 2 + (rowHasDetailsLine(m) ? 2 : 1) + (supersededNoticeFor(m) ? 1 : 0)
636
+ if (showStandaloneRecommended) {
637
+ y += rowHeight(recommendedModel)
638
+ }
639
+ renderedSections.forEach((section) => {
640
+ y += SECTION_GAP
641
+ if (section.label) y += 1
642
+ section.models.forEach((m) => {
643
+ y += rowHeight(m)
644
+ })
645
+ })
646
+ if (canCollapse) {
647
+ y += TOGGLE_MARGIN
648
+ y += 1
649
+ }
650
+ return y
651
+ }, [
652
+ renderedSections,
653
+ rowHasDetailsLine,
654
+ recommendedModel,
655
+ canCollapse,
656
+ showStandaloneRecommended,
657
+ supersededNoticeFor,
658
+ ])
659
+
660
+ // When a referral exists, start at the parent's full allowance until the
661
+ // wrapper reports its intrinsic height. The model estimate remains a lower
662
+ // bound after measurement: expansion and an asynchronously arriving access
663
+ // tier can grow the list before OpenTUI reports the wrapper's new height, and
664
+ // reusing the smaller collapsed measurement would clip the newly added rows.
665
+ const contentHeight = Math.max(
666
+ estimatedModelHeight,
667
+ measuredContentHeight ?? (referral ? maxHeight : 0),
668
+ )
669
+
670
+ const needsScroll = contentHeight > maxHeight
671
+ const scrollViewportHeight = Math.max(1, Math.min(contentHeight, maxHeight))
672
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
673
+
674
+ // Keep the keyboard-focused element inside the viewport as the user
675
+ // Tabs/arrows through a list taller than the available rows. Child ids let
676
+ // OpenTUI use the real post-wrap geometry instead of a second hand-maintained
677
+ // row model. Reset a stale offset when a resize makes everything fit.
678
+ useLayoutEffect(() => {
679
+ const sb = scrollRef.current
680
+ if (!sb) return
681
+ if (!needsScroll) {
682
+ sb.scrollTop = 0
683
+ return
684
+ }
685
+ sb.scrollChildIntoView(focusedId)
686
+ // The final referral action has explanatory/footer content after it. When
687
+ // it is focused, reveal the real bottom of the measured content as well as
688
+ // the button itself so the card does not look cut off.
689
+ if (focusedId === extraTargetIds.at(-1)) {
690
+ sb.scrollTop = Math.max(0, sb.scrollHeight - sb.viewport.height)
691
+ }
692
+ }, [focusedId, contentHeight, needsScroll, extraTargetIds])
693
+
694
+ const pick = useCallback(
695
+ (modelId: string) => {
696
+ if (pending) return
697
+ if (modelId === committedModelId) return
698
+ if (!isJoinable(modelId)) return
699
+ setPending(modelId)
700
+ startFreebuffSession(modelId).finally(() => setPending(null))
701
+ },
702
+ [pending, committedModelId, isJoinable],
703
+ )
704
+
705
+ const toggleExpanded = useCallback(() => {
706
+ // Expanding is informational: keep Enter bound to the recommendation
707
+ // instead of silently moving focus to a different model. Collapsing
708
+ // returns to the same recommendation.
709
+ setFocusedId(recommendedModel.id)
710
+ setExpanded((prev) => !prev)
711
+ }, [recommendedModel.id])
712
+
713
+ // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
714
+ // Space commits the focused row (or fires the toggle). Two-step navigation
715
+ // lets the user preview the highlight before committing.
716
+ useKeyboard(
717
+ useCallback(
718
+ (key: KeyEvent) => {
719
+ if (pending) return
720
+ const name = key.name ?? ''
721
+ const direction = freebuffModelNavigationDirectionForKey(key)
722
+ // Use the shared Enter detector so the keypad Enter and the niche
723
+ // Linux terminals that send \n (linefeed) for Enter also commit; a
724
+ // raw name === 'return' check silently ignores those, which looks
725
+ // like a frozen menu (arrows move the highlight, Enter does nothing).
726
+ const isCommit = isPlainEnterKey(key) || name === 'space'
727
+ if (isCommit) {
728
+ if (focusedId === TOGGLE_ID) {
729
+ key.preventDefault?.()
730
+ key.stopPropagation?.()
731
+ toggleExpanded()
732
+ return
733
+ }
734
+ // A referral-banner button (copy invite link / use GLM) is focused —
735
+ // fire its registered action instead of joining a queue.
736
+ const extraTarget = extraTargets.find((t) => t.id === focusedId)
737
+ if (extraTarget) {
738
+ key.preventDefault?.()
739
+ key.stopPropagation?.()
740
+ extraTarget.activate()
741
+ return
742
+ }
743
+ if (isJoinable(focusedId) && focusedId !== committedModelId) {
744
+ key.preventDefault?.()
745
+ key.stopPropagation?.()
746
+ pick(focusedId)
747
+ }
748
+ return
749
+ }
750
+ if (!direction) return
751
+ const targetId = nextFreebuffModelId({
752
+ modelIds: navIds,
753
+ focusedId,
754
+ direction,
755
+ })
756
+ if (targetId) {
757
+ key.preventDefault?.()
758
+ key.stopPropagation?.()
759
+ setFocusedId(targetId)
760
+ }
761
+ },
762
+ [
763
+ pending,
764
+ pick,
765
+ toggleExpanded,
766
+ focusedId,
767
+ committedModelId,
768
+ isJoinable,
769
+ navIds,
770
+ extraTargets,
771
+ ],
772
+ ),
773
+ )
774
+
775
+ const renderModelButton = (
776
+ model: FreebuffModelOption,
777
+ options: { recommended?: boolean } = {},
778
+ ) => {
779
+ // Single visual state: the focused row IS the highlight. The user's
780
+ // saved/committed pick is not shown separately — it just sets where
781
+ // focus lands when the picker opens. Pressing Enter on the focused
782
+ // row commits it.
783
+ const { recommended = false } = options
784
+ const isHovered = hoveredId === model.id
785
+ const isFocused = focusedId === model.id
786
+ const canJoin = isJoinable(model.id)
787
+ // Clickable whenever picking would actually do something — i.e.
788
+ // anything except re-picking the queue we're already in.
789
+ const interactable = !pending && canJoin && model.id !== committedModelId
790
+
791
+ // Focused row: green border + arrow indicator + bold name. The name
792
+ // itself stays the normal foreground color so it doesn't shout — the
793
+ // border and arrow do the highlighting. Off-focus rows are default.
794
+ const indicator = isFocused ? '›' : ' '
795
+ const fgColor = canJoin ? theme.foreground : theme.muted
796
+ const mutedColor = theme.muted
797
+ const warningColor = theme.secondary
798
+
799
+ // Focused row gets the bright primary border (and arrow). Every other row —
800
+ // including the recommended card when the cursor has moved elsewhere — stays
801
+ // quiet (gray border, brightening only on hover) so it never competes with
802
+ // the user's current selection. The recommended card still reads as special
803
+ // via its "RECOMMENDED" border title, which the border color carries.
804
+ const borderColor = isFocused
805
+ ? theme.primary
806
+ : isHovered
807
+ ? theme.foreground
808
+ : theme.border
809
+
810
+ // Deployment-hours rows show "until 5pm PT" while open and "opens 9am ET"
811
+ // while closed (the label flips inside getFreebuffDeploymentAvailabilityLabel),
812
+ // so the same string carries both the in-hours and out-of-hours signals
813
+ // without a separate "Closed" chip. Greyed-out fgColor handles the rest.
814
+ const hasHours = model.availability === 'deployment_hours'
815
+ const hasWarning = !!model.warning
816
+
817
+ // Line 2 (warning · hours) is centered in the card. Spaces render verbatim,
818
+ // so center by hand-padding the left. Clamped at 0 for the narrow mode,
819
+ // where buttonInnerWidth is capped by contentMaxWidth and the line may be
820
+ // wider than the card.
821
+ const detailsLen =
822
+ (hasWarning ? model.warning!.length : 0) +
823
+ (hasWarning && hasHours ? 3 : 0) +
824
+ (hasHours ? deploymentAvailabilityLabel.length : 0)
825
+ const detailsPad = Math.max(
826
+ 0,
827
+ Math.floor((buttonInnerWidth - detailsLen) / 2),
828
+ )
829
+
830
+ const supersededNotice = supersededNoticeFor(model)
831
+ const supersededPad = Math.max(
832
+ 0,
833
+ Math.floor((buttonInnerWidth - (supersededNotice?.length ?? 0)) / 2),
834
+ )
835
+
836
+ // Spaces inside <span>s render verbatim, so we hand-pad the name to align
837
+ // taglines into a column. nameColumnWidth is the longest name across all
838
+ // rows, so the diff is >= 0; +NAME_GAP guarantees breathing room even on
839
+ // the widest row.
840
+ const namePadding = ' '.repeat(
841
+ nameColumnWidth - model.displayName.length + NAME_GAP,
842
+ )
843
+
844
+ // Only natively multimodal models advertise image input. Text-only models
845
+ // still accept a pasted image (it is substituted server-side as a
846
+ // vision-model description), but badging every row "Images" made the label
847
+ // meaningless — and it is what stretched line 1 on rows that can't actually
848
+ // see pixels.
849
+ const imagesSuffix = model.multimodal ? ' · Images' : ''
850
+
851
+ // The effort the server runs this model at (the same catalog field the
852
+ // completions layer sends) — see FreebuffModelOption.reasoningEffort.
853
+ const reasoningSuffix = model.reasoningEffort
854
+ ? ` · Reasoning: ${model.reasoningEffort}`
855
+ : ''
856
+
857
+ return (
858
+ <Button
859
+ key={model.id}
860
+ id={model.id}
861
+ title={recommended ? ' RECOMMENDED ' : undefined}
862
+ titleAlignment={recommended ? 'left' : undefined}
863
+ onClick={() => {
864
+ setFocusedId(model.id)
865
+ if (canJoin) pick(model.id)
866
+ }}
867
+ onMouseOver={() => interactable && setHoveredId(model.id)}
868
+ onMouseOut={() =>
869
+ setHoveredId((curr) => (curr === model.id ? null : curr))
870
+ }
871
+ style={{
872
+ borderStyle: 'single',
873
+ borderColor,
874
+ paddingLeft: 1,
875
+ paddingRight: 1,
876
+ width: buttonOuterWidth,
877
+ }}
878
+ border={['top', 'bottom', 'left', 'right']}
879
+ >
880
+ <text>
881
+ <span fg={fgColor}>{indicator} </span>
882
+ <span
883
+ fg={fgColor}
884
+ attributes={isFocused ? TextAttributes.BOLD : TextAttributes.NONE}
885
+ >
886
+ {model.displayName}
887
+ </span>
888
+ {compactNames ? (
889
+ <span fg={mutedColor}>
890
+ {' · ' + model.tagline + reasoningSuffix + imagesSuffix}
891
+ </span>
892
+ ) : (
893
+ <span fg={mutedColor}>
894
+ {namePadding + model.tagline + reasoningSuffix + imagesSuffix}
895
+ </span>
896
+ )}
897
+ {model.isNew && (
898
+ <span fg={theme.primary} attributes={TextAttributes.BOLD}>
899
+ {' · NEW'}
900
+ </span>
901
+ )}
902
+ </text>
903
+ {(hasWarning || hasHours) && (
904
+ <text>
905
+ <span>{' '.repeat(detailsPad)}</span>
906
+ {hasWarning && <span fg={warningColor}>{model.warning}</span>}
907
+ {hasWarning && hasHours && <span fg={mutedColor}> · </span>}
908
+ {hasHours && (
909
+ <span fg={mutedColor}>{deploymentAvailabilityLabel}</span>
910
+ )}
911
+ </text>
912
+ )}
913
+ {supersededNotice && (
914
+ <text>
915
+ <span>{' '.repeat(supersededPad)}</span>
916
+ <span fg={mutedColor}>{supersededNotice}</span>
917
+ </text>
918
+ )}
919
+ </Button>
920
+ )
921
+ }
922
+
923
+ // Scarcity, on the LIMITED TRIAL header rather than on the row — same
924
+ // treatment the shared premium quota gets, so counts live in one predictable
925
+ // place and the rows stay narrow. Two facts, in the order they matter: how
926
+ // much of the wave is left for everyone, and (only once the user has spent
927
+ // theirs) when they personally get another. `offers` is homogeneous — one
928
+ // pool, one per-user ceiling — so the first entry speaks for all of them.
929
+ const offerSummary = offers[0]
930
+ const offerUserExhausted = !!offerSummary && offerSummary.userRemaining <= 0
931
+ const offerUserResetAt = offerSummary
932
+ ? new Date(offerSummary.userResetAt)
933
+ : null
934
+ const offerUserResetCountdown =
935
+ offerUserResetAt && Number.isFinite(offerUserResetAt.getTime())
936
+ ? formatFreebuffPremiumResetCountdown(offerUserResetAt, now)
937
+ : null
938
+
939
+ const sectionsContent = renderedSections.map((section) => (
940
+ <box
941
+ key={section.key}
942
+ style={{
943
+ flexDirection: 'column',
944
+ alignItems: 'flex-start',
945
+ gap: 0,
946
+ marginTop: SECTION_GAP,
947
+ }}
948
+ >
949
+ {/* wrapMode 'none' pins headers to one row — the offset math above
950
+ assumes exactly 1 row per header, so a wrap would desync the
951
+ focused-row auto-scroll. */}
952
+ {section.label && (
953
+ <text style={{ fg: theme.muted, wrapMode: 'none' }}>
954
+ {section.label}
955
+ {section.key === 'premium' && (
956
+ <span fg={premiumExhausted ? theme.secondary : theme.muted}>
957
+ {' '}
958
+ · {formatSessionUnits(premiumUsed)} of {premiumLimit} used
959
+ </span>
960
+ )}
961
+ {section.key === 'premium' && premiumResetCountdown && (
962
+ <span fg={theme.muted}> · resets in {premiumResetCountdown}</span>
963
+ )}
964
+ {section.key === 'offer' && offerSummary && (
965
+ <span fg={theme.primary}>
966
+ {' '}
967
+ · {offerSummary.remaining} of {offerSummary.total} sessions left
968
+ </span>
969
+ )}
970
+ {section.key === 'offer' && offerUserExhausted && (
971
+ <span fg={theme.secondary}>
972
+ {' '}
973
+ · you've used yours
974
+ {offerUserResetCountdown
975
+ ? `, resets in ${offerUserResetCountdown}`
976
+ : ''}
977
+ </span>
978
+ )}
979
+ </text>
980
+ )}
981
+ {section.models.map((m) =>
982
+ renderModelButton(m, { recommended: m.id === recommendedModel.id }),
983
+ )}
984
+ </box>
985
+ ))
986
+
987
+ // Expand/collapse affordance. Collapsed: "see all N models" invites the user
988
+ // to browse past the recommended pick. Expanded: a quiet way back to the
989
+ // single-card view.
990
+ const toggleFocused = focusedId === TOGGLE_ID
991
+ const toggleHovered = hoveredId === TOGGLE_ID
992
+ // Same treatment as the referral banner's inline copy control, the other
993
+ // borderless action on this screen: white at rest so it reads as a control
994
+ // rather than body copy, accent green once focused or hovered.
995
+ const toggleColor =
996
+ toggleFocused || toggleHovered ? theme.primary : theme.foreground
997
+ const toggleLabel = expanded
998
+ ? '↑ Show fewer'
999
+ : `↓ See all ${availableModels.length} models`
1000
+ const toggleContent = canCollapse ? (
1001
+ <Button
1002
+ id={TOGGLE_ID}
1003
+ onClick={toggleExpanded}
1004
+ onMouseOver={() => setHoveredId(TOGGLE_ID)}
1005
+ onMouseOut={() =>
1006
+ setHoveredId((curr) => (curr === TOGGLE_ID ? null : curr))
1007
+ }
1008
+ style={{ marginTop: TOGGLE_MARGIN }}
1009
+ >
1010
+ <text style={{ wrapMode: 'none' }}>
1011
+ <span
1012
+ fg={toggleColor}
1013
+ attributes={toggleFocused ? TextAttributes.BOLD : TextAttributes.NONE}
1014
+ >
1015
+ {toggleLabel}
1016
+ </span>
1017
+ </text>
1018
+ </Button>
1019
+ ) : null
1020
+
1021
+ // Scrollbox clamped to the rows the parent can spare. When everything fits
1022
+ // it shrinks to the content height and no scrollbar shows, so tall
1023
+ // terminals look exactly like a plain column.
1024
+ return (
1025
+ <scrollbox
1026
+ ref={scrollRef}
1027
+ scrollX={false}
1028
+ scrollbarOptions={{ visible: false }}
1029
+ verticalScrollbarOptions={{
1030
+ visible: needsScroll,
1031
+ trackOptions: { width: 1 },
1032
+ }}
1033
+ style={{
1034
+ height: scrollViewportHeight,
1035
+ // A scrollbox stretches to fill its parent, which would left-align
1036
+ // the picker; pin it to the button column width (plus a gutter for
1037
+ // the scrollbar) so the landing block stays content-sized and the
1038
+ // parent can center it as it did before this was a scrollbox.
1039
+ width: buttonOuterWidth + (needsScroll ? 1 : 0),
1040
+ flexShrink: 0,
1041
+ rootOptions: {
1042
+ flexDirection: 'row',
1043
+ backgroundColor: 'transparent',
1044
+ },
1045
+ wrapperOptions: {
1046
+ border: false,
1047
+ backgroundColor: 'transparent',
1048
+ flexDirection: 'column',
1049
+ },
1050
+ contentOptions: {
1051
+ flexDirection: 'column',
1052
+ alignItems: 'flex-start',
1053
+ gap: 0,
1054
+ backgroundColor: 'transparent',
1055
+ },
1056
+ }}
1057
+ >
1058
+ <box
1059
+ ref={contentRef}
1060
+ onSizeChange={syncContentHeight}
1061
+ style={{
1062
+ flexDirection: 'column',
1063
+ alignItems: 'flex-start',
1064
+ gap: 0,
1065
+ width: buttonOuterWidth,
1066
+ flexShrink: 0,
1067
+ }}
1068
+ >
1069
+ {showStandaloneRecommended &&
1070
+ renderModelButton(recommendedModel, { recommended: true })}
1071
+ {sectionsContent}
1072
+ {toggleContent}
1073
+ {belowToggle}
1074
+ {referral && (
1075
+ <FreebuffReferralBanner
1076
+ width={buttonOuterWidth}
1077
+ referral={referral}
1078
+ glmPromo={glmPromo}
1079
+ accessTier={accessTier}
1080
+ focusedId={focusedId}
1081
+ onFocusTargetsChange={setExtraTargets}
1082
+ />
1083
+ )}
1084
+ </box>
1085
+ </scrollbox>
1086
+ )
1087
+ }