@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,865 @@
1
+ import { TextAttributes } from '@opentui/core'
2
+ import { useKeyboard, useRenderer } from '@opentui/react'
3
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
4
+
5
+ import { Button } from './button'
6
+ import { ChoiceAdBanner, AD_CARD_HEIGHT } from './ad-banner'
7
+ import { FreebuffModelSelector } from './freebuff-model-selector'
8
+ import { ShimmerText } from './shimmer-text'
9
+ import {
10
+ refreshFreebuffLandingMetadata,
11
+ takeOverFreebuffSession,
12
+ } from '../hooks/use-freebuff-session'
13
+ import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
14
+ import { useFreebuffStreakQuery } from '../hooks/use-freebuff-streak-query'
15
+ import { useGravityAd } from '../hooks/use-gravity-ad'
16
+ import { useLogo } from '../hooks/use-logo'
17
+ import { useNow } from '../hooks/use-now'
18
+ import { useSheenAnimation } from '../hooks/use-sheen-animation'
19
+ import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
20
+ import { useTheme } from '../hooks/use-theme'
21
+ import { exitCliCleanly } from '../utils/exit-cleanly'
22
+ import {
23
+ formatFreebuffPremiumResetCountdown,
24
+ getFreebuffPremiumResetAt,
25
+ } from '../utils/freebuff-premium-reset'
26
+ import {
27
+ FREEBUFF_STREAK_INLINE_GAP,
28
+ FREEBUFF_STREAK_LABEL_GAP,
29
+ fitsFreebuffStreakOnHeadingRow,
30
+ getFreebuffStreakBonusNoteForLayout,
31
+ getFreebuffStreakLine,
32
+ } from '../utils/freebuff-streak-line'
33
+ import { formatSessionUnits } from '../utils/format-session-units'
34
+ import { isPlainEnterKey } from '../utils/terminal-enter-detection'
35
+ import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
36
+ import { INVERTED_CTA_FG } from '../utils/ui-constants'
37
+ import {
38
+ FREEBUFF_ENABLE_STREAK_IN_UI,
39
+ FREEBUFF_LIMITED_SESSION_LIMIT,
40
+ FREEBUFF_PREMIUM_SESSION_LIMIT,
41
+ } from '@anvil/common/constants/freebuff-models'
42
+ import {
43
+ getRateLimitsByModel,
44
+ getReferralInfo,
45
+ } from '@anvil/common/types/freebuff-session'
46
+ import { getFreebuffModelAvailabilityNotice } from '@anvil/common/util/freebuff-model-availability'
47
+ import { formatFreebuffHardBlockedPrivacySignals } from '@anvil/common/util/freebuff-privacy'
48
+
49
+ import type { FreebuffStreakLine } from '../utils/freebuff-streak-line'
50
+ import type { FreebuffSessionFailure } from '../state/freebuff-session-store'
51
+ import type { FreebuffSessionResponse } from '../types/freebuff-session'
52
+ import type { KeyEvent } from '@opentui/core'
53
+
54
+ interface FreebuffLandingScreenProps {
55
+ session: FreebuffSessionResponse | null
56
+ failure: FreebuffSessionFailure | null
57
+ }
58
+
59
+ /** Landing-screen heading. Referenced both as rendered text and by the
60
+ * picker's height-budget math (wrappedRows), so it lives in one place to keep
61
+ * the two from drifting. */
62
+ const LANDING_HEADING = 'Start coding for free'
63
+ const COLLAPSED_LOGO_MIN_HEIGHT = 26
64
+
65
+ /** "in ~3h 20m" / "in ~45 min" / "in under a minute". Used on the
66
+ * rate-limited screen so users know when they can try again. */
67
+ const formatRetryAfter = (ms: number): string => {
68
+ if (!Number.isFinite(ms) || ms <= 0) return 'any moment now'
69
+ const minutes = Math.round(ms / 60_000)
70
+ if (minutes < 1) return 'under a minute'
71
+ if (minutes < 60) return `${minutes} min`
72
+ const hours = Math.floor(minutes / 60)
73
+ const rem = minutes % 60
74
+ return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
75
+ }
76
+
77
+ // Rendered directly under the model list — that's where "why these models?"
78
+ // gets asked. The copy itself is shared with Freebuff Desktop's model menu; see
79
+ // `getFreebuffModelAvailabilityNotice` for the tone rules it follows.
80
+ const getLimitedModeNotice = (
81
+ session: FreebuffSessionResponse | null,
82
+ ): string | null =>
83
+ getFreebuffModelAvailabilityNotice(
84
+ session && 'countryBlockReason' in session ? session : null,
85
+ )
86
+
87
+ function getTakeoverErrorMessage(failure: FreebuffSessionFailure): string {
88
+ if (failure.type === 'http' && failure.statusCode === 503) {
89
+ return "Freebuff is busy and couldn't complete the takeover yet."
90
+ }
91
+ if (failure.type === 'timeout') {
92
+ return failure.outcomeUnknown
93
+ ? 'The takeover request timed out and may have succeeded. Check the warning, then retry if you still want to take over.'
94
+ : failure.retry
95
+ ? 'The takeover request timed out while Freebuff was busy.'
96
+ : 'The takeover request timed out.'
97
+ }
98
+ if (failure.outcomeUnknown) {
99
+ return "Freebuff couldn't confirm whether the takeover succeeded. Check the warning, then retry if you still want to take over."
100
+ }
101
+ return failure.message.trim()
102
+ ? `Takeover failed: ${failure.message}`
103
+ : 'The takeover failed unexpectedly.'
104
+ }
105
+
106
+ export const TakeoverPrompt: React.FC<{
107
+ failure: FreebuffSessionFailure | null
108
+ onTakeOver?: () => Promise<void>
109
+ }> = ({ failure, onTakeOver = takeOverFreebuffSession }) => {
110
+ const theme = useTheme()
111
+ const [pending, setPending] = useState(false)
112
+ const [focusedIndex, setFocusedIndex] = useState(0) // 0 = Take over, 1 = Exit
113
+ const takeoverInFlightRef = useRef(false)
114
+ const retry = failure?.retry ?? null
115
+ const retryTick = useNow(1_000, retry !== null)
116
+ // `useNow` freezes while disabled. Use the current time when a retry first
117
+ // appears so a long-open prompt cannot render a stale countdown for a frame.
118
+ const retryNow = retry ? Math.max(retryTick, Date.now()) : retryTick
119
+ const retrySeconds = retry
120
+ ? Math.max(0, Math.ceil((retry.retryAtMs - retryNow) / 1_000))
121
+ : 0
122
+ const outcomeUnknown = failure?.outcomeUnknown ?? false
123
+ const blocked = pending
124
+ const displayError = failure ? getTakeoverErrorMessage(failure) : null
125
+
126
+ const handleTakeover = useCallback(async () => {
127
+ // `pending` updates on the next render. The ref closes the gap where two
128
+ // keyboard/mouse events arrive in the same frame and would both POST.
129
+ if (takeoverInFlightRef.current) return
130
+ takeoverInFlightRef.current = true
131
+ setPending(true)
132
+ try {
133
+ await onTakeOver()
134
+ } finally {
135
+ takeoverInFlightRef.current = false
136
+ setPending(false)
137
+ }
138
+ }, [onTakeOver])
139
+
140
+ useKeyboard(
141
+ useCallback(
142
+ (key: KeyEvent) => {
143
+ const name = key.name ?? ''
144
+ const isConfirm = isPlainEnterKey(key)
145
+ const isExit = name === 'escape' || name === 'esc'
146
+ const isTab = name === 'tab'
147
+ const isShiftTab = key.shift === true && isTab
148
+ const isRight = name === 'right'
149
+ const isLeft = name === 'left'
150
+
151
+ if (isExit) {
152
+ key.preventDefault?.()
153
+ void exitCliCleanly()
154
+ return
155
+ }
156
+
157
+ if (isConfirm) {
158
+ key.preventDefault?.()
159
+ if (focusedIndex === 0) {
160
+ void handleTakeover()
161
+ } else {
162
+ void exitCliCleanly()
163
+ }
164
+ return
165
+ }
166
+
167
+ if (isRight || isTab) {
168
+ key.preventDefault?.()
169
+ setFocusedIndex((prev) => (prev + 1) % 2)
170
+ return
171
+ }
172
+
173
+ if (isLeft || isShiftTab) {
174
+ key.preventDefault?.()
175
+ setFocusedIndex((prev) => (prev - 1 + 2) % 2)
176
+ return
177
+ }
178
+ },
179
+ [focusedIndex, handleTakeover],
180
+ ),
181
+ )
182
+
183
+ const isTakeoverFocused = focusedIndex === 0
184
+ const isExitFocused = focusedIndex === 1
185
+ const takeoverLabel = pending
186
+ ? 'Taking over...'
187
+ : outcomeUnknown
188
+ ? 'Try takeover again'
189
+ : retry
190
+ ? 'Retry now'
191
+ : 'Take over'
192
+ const takeoverForeground = blocked
193
+ ? theme.muted
194
+ : isTakeoverFocused
195
+ ? INVERTED_CTA_FG
196
+ : theme.foreground
197
+
198
+ return (
199
+ <box
200
+ style={{
201
+ flexDirection: 'column',
202
+ alignItems: 'center',
203
+ gap: 1,
204
+ width: '100%',
205
+ }}
206
+ >
207
+ <text style={{ fg: theme.foreground }} attributes={TextAttributes.BOLD}>
208
+ Freebuff is already running
209
+ </text>
210
+
211
+ <text style={{ fg: theme.muted }}>
212
+ Only one freebuff instance is allowed at a time.
213
+ </text>
214
+
215
+ {displayError && (
216
+ <text style={{ fg: theme.secondary, wrapMode: 'word' }}>
217
+ ⚠ {displayError}
218
+ </text>
219
+ )}
220
+
221
+ {retry && (
222
+ <text style={{ fg: theme.muted }}>
223
+ {retrySeconds > 0
224
+ ? `Retrying automatically in ${retrySeconds}s (attempt ${retry.attempt}).`
225
+ : `Retrying automatically now (attempt ${retry.attempt}).`}
226
+ </text>
227
+ )}
228
+
229
+ <box style={{ flexDirection: 'row', gap: 2, marginTop: 1 }}>
230
+ <Button
231
+ onClick={blocked ? undefined : handleTakeover}
232
+ onMouseOver={() => setFocusedIndex(0)}
233
+ style={{ paddingLeft: 1, paddingRight: 1 }}
234
+ border={['top', 'bottom', 'left', 'right']}
235
+ borderStyle="single"
236
+ borderColor={blocked ? theme.muted : theme.primary}
237
+ >
238
+ <text
239
+ style={{
240
+ // theme.background is 'transparent' and can't serve as inverted
241
+ // text — on the green fill it renders the label invisible.
242
+ fg: takeoverForeground,
243
+ bg: isTakeoverFocused && !blocked ? theme.primary : undefined,
244
+ }}
245
+ attributes={TextAttributes.BOLD}
246
+ >
247
+ {takeoverLabel}
248
+ </text>
249
+ </Button>
250
+ <Button
251
+ onClick={() => exitCliCleanly()}
252
+ onMouseOver={() => setFocusedIndex(1)}
253
+ style={{ paddingLeft: 1, paddingRight: 1 }}
254
+ border={['top', 'bottom', 'left', 'right']}
255
+ borderStyle="single"
256
+ borderColor={isExitFocused ? theme.foreground : theme.muted}
257
+ >
258
+ <text
259
+ style={{ fg: isExitFocused ? theme.foreground : theme.muted }}
260
+ attributes={
261
+ isExitFocused ? TextAttributes.BOLD : TextAttributes.NONE
262
+ }
263
+ >
264
+ Exit
265
+ </text>
266
+ </Button>
267
+ </box>
268
+ </box>
269
+ )
270
+ }
271
+
272
+ /** "N day streak" then its progress dots, as spans so both placements (beside
273
+ * the heading, or its own line under it) draw the streak identically. */
274
+ const streakSpans = (
275
+ line: FreebuffStreakLine,
276
+ theme: ReturnType<typeof useTheme>,
277
+ ) => [
278
+ <span key="label" fg={theme.foreground}>
279
+ {line.label}
280
+ </span>,
281
+ <span key="dots" fg={theme.primary}>
282
+ {`${' '.repeat(FREEBUFF_STREAK_LABEL_GAP)}${line.dots}`}
283
+ </span>,
284
+ ]
285
+
286
+ /** Streak on its own line under the heading, for terminals too narrow to
287
+ * share the row. For streak === 0 the line is rendered blank so new / lapsed
288
+ * users are nudged to start using the product rather than shown an empty
289
+ * streak (and so the picker doesn't jump once they earn their first day). */
290
+ const StreakInlineLine: React.FC<{
291
+ line: FreebuffStreakLine | null
292
+ }> = ({ line }) => {
293
+ const theme = useTheme()
294
+
295
+ if (!line) {
296
+ return <text style={{ flexShrink: 0 }}> </text>
297
+ }
298
+
299
+ return (
300
+ <text style={{ flexShrink: 0, wrapMode: 'none' }}>
301
+ {streakSpans(line, theme)}
302
+ </text>
303
+ )
304
+ }
305
+
306
+ /** Heading row: the title, with the streak beside it when the caller passes
307
+ * one (only when it fits — see fitsFreebuffStreakOnHeadingRow).
308
+ *
309
+ * Deliberately ONE text rather than a space-between flex row. The row this
310
+ * replaces put the two in a stretched row inside a shrink-to-fit column,
311
+ * which had two problems: with nothing wider on screen there was no free
312
+ * space to distribute, so the two rendered flush ("Start coding for
313
+ * free18 day streak"), and a space-between row whose content overflows its
314
+ * container segfaults the renderer — reachable by dragging the terminal
315
+ * narrower, since the native layout runs against the old tree before React
316
+ * re-renders. A single text just wraps, and the gap is explicit.
317
+ *
318
+ * Exported for the layout test. */
319
+ export const LandingHeadingRow: React.FC<{
320
+ streakLine: FreebuffStreakLine | null
321
+ marginBottom: number
322
+ }> = ({ streakLine, marginBottom }) => {
323
+ const theme = useTheme()
324
+
325
+ return (
326
+ <text style={{ marginBottom, wrapMode: 'word' }}>
327
+ <span fg={theme.foreground} attributes={TextAttributes.BOLD}>
328
+ {LANDING_HEADING}
329
+ </span>
330
+ {streakLine && [
331
+ <span key="gap">{' '.repeat(FREEBUFF_STREAK_INLINE_GAP)}</span>,
332
+ ...streakSpans(streakLine, theme),
333
+ ]}
334
+ </text>
335
+ )
336
+ }
337
+
338
+ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
339
+ session,
340
+ failure,
341
+ }) => {
342
+ const theme = useTheme()
343
+ const renderer = useRenderer()
344
+ const { terminalWidth, terminalHeight, contentMaxWidth } =
345
+ useTerminalDimensions()
346
+
347
+ // Progressive disclosure as the terminal gets shorter. The picker is the
348
+ // only thing the user must be able to reach, so chrome is shed first:
349
+ // tall (>=40): full 6-line ASCII logo + roomy spacing, content anchored low
350
+ // shorter : no logo — the heading already identifies the experience
351
+ // tiny (<18) : also drop the ad banner
352
+ // The big logo is reserved for genuinely tall windows; at the common ~30-row
353
+ // height we hide the branding so more models fit without scrolling.
354
+ // Section headers always show — the picker scrolls within whatever rows
355
+ // remain (see selectorMaxHeight below), so there's no need to hide them.
356
+ //
357
+ // Exception: when the picker is collapsed it shrinks to ~5 rows, freeing the
358
+ // ~6 rows the big logo needs. So on a mid-height window with a collapsed,
359
+ // referral-free picker we still show the full ASCII logo — it fills what
360
+ // would otherwise be dead space above the card. A referral card or expanded
361
+ // list gives those rows back to the scrollable menu. 26 is the smallest
362
+ // window where the logo block,
363
+ // heading, collapsed picker, streak, and ad all coexist without scrolling.
364
+ //
365
+ // The picker (rendered below) owns this and reports it via onExpandedChange;
366
+ // we default to collapsed so the first paint reserves logo space correctly.
367
+ const [selectorExpanded, setSelectorExpanded] = useState(false)
368
+ const hasReferralMenu =
369
+ session?.status === 'none' && Boolean(getReferralInfo(session))
370
+ const logoHeightFits =
371
+ terminalHeight >= 40 ||
372
+ (!selectorExpanded &&
373
+ !hasReferralMenu &&
374
+ terminalHeight >= COLLAPSED_LOGO_MIN_HEIGHT)
375
+ const compact = terminalHeight < 22
376
+ const showAds = terminalHeight >= 18
377
+ const textMarginBottom = 1
378
+
379
+ const [sheenPosition, setSheenPosition] = useState(0)
380
+ const blockColor = getLogoBlockColor(theme.name)
381
+ const accentColor = getLogoAccentColor(theme.name)
382
+ const { applySheenToChar } = useSheenAnimation({
383
+ logoColor: theme.foreground,
384
+ accentColor,
385
+ blockColor,
386
+ terminalWidth: renderer?.width ?? terminalWidth,
387
+ sheenPosition,
388
+ setSheenPosition,
389
+ })
390
+ const { component: logoComponent, textBlock: logoTextBlock } = useLogo({
391
+ availableWidth: contentMaxWidth,
392
+ accentColor,
393
+ blockColor,
394
+ applySheenToChar,
395
+ })
396
+ // useLogo falls back to a one-line brand label when the ASCII variants do
397
+ // not fit horizontally. This landing screen intentionally hides that label.
398
+ const showFullLogo = logoHeightFits && logoTextBlock.length > 0
399
+
400
+ // Always enable ads on the landing screen — this is where monetization lives.
401
+ // forceStart bypasses the "wait for first user message" gate inside the hook,
402
+ // which would otherwise block ads here since no conversation exists yet.
403
+ // The server tries Gravity first, then falls back to ZeroClick and Carbon.
404
+ const { ads, recordClick, recordImpression } = useGravityAd({
405
+ enabled: true,
406
+ forceStart: true,
407
+ provider: 'gravity',
408
+ // Legacy wire name for this surface — the ads API maps it to placements,
409
+ // so it must not change with the component rename.
410
+ surface: 'waiting_room',
411
+ })
412
+
413
+ useFreebuffCtrlCExit()
414
+
415
+ const [exitHover, setExitHover] = useState(false)
416
+
417
+ const accessTier =
418
+ session && 'accessTier' in session ? session.accessTier : 'full'
419
+ // Hidden in compact terminals: the notice is nice-to-have context, and
420
+ // below 22 rows every line competes with the picker itself.
421
+ const limitedModeNotice =
422
+ accessTier === 'limited' && !compact ? getLimitedModeNotice(session) : null
423
+ // 'none' = user hasn't started a session yet. We're in the pre-chat landing
424
+ // state: show the picker with a prompt. Picking a model triggers
425
+ // startFreebuffSession, which POSTs and transitions straight to 'active' (chat).
426
+ const isLanding = session?.status === 'none'
427
+ const streakQuery = useFreebuffStreakQuery({
428
+ enabled: FREEBUFF_ENABLE_STREAK_IN_UI && isLanding,
429
+ })
430
+ const streak = streakQuery.data?.streak ?? 0
431
+ // The indicator normally shares the heading row, so keep its slot even in
432
+ // compact-height terminals. It renders blank space until a streak is active.
433
+ const showStreakIndicator = FREEBUFF_ENABLE_STREAK_IN_UI && isLanding
434
+ // Once a full week is earned, explain the recurring perk under the picker so
435
+ // the streak reads as worth keeping. Pre-milestone countdown copy stays
436
+ // hidden; this dedicated row is only for a bonus the user has already earned.
437
+ const streakBonusNote = showStreakIndicator
438
+ ? getFreebuffStreakBonusNoteForLayout({
439
+ streak,
440
+ accessTier: accessTier === 'limited' ? 'limited' : 'full',
441
+ terminalHeight,
442
+ availableWidth: contentMaxWidth,
443
+ })
444
+ : null
445
+ // On the landing screen the streak rides on the heading row, a fixed gap
446
+ // after the title. Measured against the real strings (the label grows with
447
+ // the day count), so
448
+ // a streak that can't clear the inline gap drops to its own line under the
449
+ // heading instead of colliding with it. With no streak yet, the day-one line
450
+ // stands in, reserving the slot where the streak will land.
451
+ const streakLine = showStreakIndicator ? getFreebuffStreakLine(streak) : null
452
+ const streakOnHeadingRow =
453
+ showStreakIndicator &&
454
+ fitsFreebuffStreakOnHeadingRow({
455
+ line: streakLine,
456
+ headingWidth: LANDING_HEADING.length,
457
+ availableWidth: contentMaxWidth,
458
+ })
459
+ // On the landing picker we tick once a minute so the session reset countdown
460
+ // stays fresh.
461
+ const now = useNow(60_000, isLanding)
462
+
463
+ // Free-session quota counter for the title line. All free models share one
464
+ // pool; the server replicates the same snapshot under each free model
465
+ // id, so any entry has the right count. Renders amber when exhausted so
466
+ // the limit reads as "you've hit it" rather than just another count.
467
+ const rateLimitsByModel = getRateLimitsByModel(session)
468
+ const sessionRateLimit = rateLimitsByModel
469
+ ? Object.values(rateLimitsByModel)[0]
470
+ : undefined
471
+ const sharedSessionUsed = sessionRateLimit?.recentCount ?? 0
472
+ // Hide the "0 of N … used" line entirely for a fresh user — a zeroed counter
473
+ // is noise on the landing screen. It appears once any session is consumed.
474
+ //
475
+ // For the regular tiers the PREMIUM section header inside the expanded
476
+ // picker carries this quota inline, so the below-picker line survives for
477
+ // the limited tier (which has no premium section to host it) and for the
478
+ // collapsed picker. When the collapsed recommended hero is a premium model
479
+ // (getRecommendedFreebuffModelId, while the pool has sessions left) the count
480
+ // is exactly what Enter is about to spend.
481
+ const showSessionCounter = sharedSessionUsed > 0
482
+ const showBelowPickerCounter =
483
+ showSessionCounter && (accessTier === 'limited' || !selectorExpanded)
484
+ // Prefer the server-sent limit (base + streak/referral bonuses) so the
485
+ // counter and its amber exhausted cue match what admission will enforce;
486
+ // the static constants only cover the pre-snapshot fallback.
487
+ const sessionLimit =
488
+ sessionRateLimit?.limit ??
489
+ (accessTier === 'limited'
490
+ ? FREEBUFF_LIMITED_SESSION_LIMIT
491
+ : FREEBUFF_PREMIUM_SESSION_LIMIT)
492
+ const isSessionExhausted = sharedSessionUsed >= sessionLimit
493
+ const sessionUsedColor = isSessionExhausted ? theme.secondary : theme.muted
494
+ const sessionLabel =
495
+ accessTier === 'limited' ? 'sessions' : 'premium sessions'
496
+ const formattedSharedSessionUsed = formatSessionUnits(sharedSessionUsed)
497
+ const sessionResetAt = getFreebuffPremiumResetAt({
498
+ rateLimitsByModel,
499
+ nowMs: now,
500
+ })
501
+ const sessionResetAtMs = sessionResetAt.getTime()
502
+ const sessionResetCountdown = formatFreebuffPremiumResetCountdown(
503
+ sessionResetAt,
504
+ now,
505
+ )
506
+
507
+ // Rows the picker may occupy = terminal height minus the fixed chrome
508
+ // around it. Each term mirrors the real layout exactly (no padded
509
+ // estimate, no blanket safety row) so the scrollbox fills the available
510
+ // space with no dead band below it:
511
+ // - top bar: paddingTop 1 + the ✕ row = 2
512
+ // - ad banner: AD_CARD_HEIGHT, only when shown
513
+ // - main box: paddingBottom 1
514
+ // - logo block: lines + marginBottom 1 (always, when shown) + gap (full)
515
+ // - the prompt/counter (landing)
516
+ // Line wrapping is derived from the actual strings vs contentMaxWidth, so
517
+ // a wrapped counter is accounted for precisely instead of guessed at.
518
+ const wrappedRows = (text: string) =>
519
+ Math.max(1, Math.ceil(text.length / contentMaxWidth))
520
+ const logoBlockRows = showFullLogo
521
+ ? 8 /* 6 logo lines + marginBottom + gap */
522
+ : 0
523
+ const adRows = showAds ? AD_CARD_HEIGHT : 0
524
+ // Status lines render below the picker, each with marginTop 1: the
525
+ // limited-mode notice, then the streak. They still eat into the picker's
526
+ // height budget regardless of being above or below it. (The session counter
527
+ // used to sit here too; it now rides inside the selector — see below.)
528
+ // Placement varies: on a wide landing screen the streak shares
529
+ // the heading row (0 extra rows, already counted in landingTextRows); on a
530
+ // narrow landing screen it drops to its own line under the heading (1 row,
531
+ // no top margin).
532
+ const streakRows = !showStreakIndicator ? 0 : streakOnHeadingRow ? 0 : 1
533
+ const noticeRows = limitedModeNotice
534
+ ? 1 /* marginTop */ + wrappedRows(limitedModeNotice)
535
+ : 0
536
+ // Earned streak perk note: one marginTop row + wrap.
537
+ const streakBonusRows = streakBonusNote
538
+ ? 1 /* marginTop */ + wrappedRows(streakBonusNote)
539
+ : 0
540
+ // The referral/GLM card and the session counter both live inside the model
541
+ // selector's scrollbox now (the counter rides `belowToggle`, between the
542
+ // expand toggle and the referral pitch), so only genuinely fixed status
543
+ // lines below the selector reduce its viewport. Anything inside the
544
+ // scrollbox is measured by the selector itself and must NOT be reserved
545
+ // here as well, or the viewport shrinks while the content grows.
546
+ const belowPickerRows = streakRows + noticeRows + streakBonusRows
547
+ const reservedChrome = 2 + adRows + 1 /* main paddingBottom */ + logoBlockRows
548
+ const landingTextRows =
549
+ wrappedRows(LANDING_HEADING) + textMarginBottom + belowPickerRows
550
+ // Floor = one whole recommended card: 2 border rows + its 2 text lines (name
551
+ // + tagline, then the AI-training warning on its own line). Rows grew from
552
+ // one text line to two when the warning stopped inlining, so the old floor of
553
+ // 3 left the card's bottom border clipped on a very short terminal. (The
554
+ // warning itself stays visible either way — scrollTop starts at 0, so it is
555
+ // the last row that gets cut, not the first.)
556
+ const MIN_SELECTOR_ROWS = 4
557
+ const selectorMaxHeight = Math.max(
558
+ MIN_SELECTOR_ROWS,
559
+ terminalHeight - reservedChrome - landingTextRows,
560
+ )
561
+
562
+ useEffect(() => {
563
+ if (!isLanding || !sessionRateLimit) return
564
+
565
+ const delayMs = Math.max(0, sessionResetAtMs - Date.now() + 1_000)
566
+ const timer = setTimeout(() => {
567
+ refreshFreebuffLandingMetadata().catch(() => {})
568
+ }, delayMs)
569
+
570
+ return () => clearTimeout(timer)
571
+ }, [isLanding, sessionRateLimit, sessionResetAtMs])
572
+
573
+ return (
574
+ <box
575
+ style={{
576
+ width: '100%',
577
+ height: '100%',
578
+ flexDirection: 'column',
579
+ backgroundColor: theme.background,
580
+ }}
581
+ >
582
+ {/* Top-right exit affordance so mouse users have a clear way out even
583
+ when they don't know Ctrl+C works. width: '100%' is required for
584
+ justifyContent to actually push the X to the right. */}
585
+ <box
586
+ style={{
587
+ width: '100%',
588
+ flexDirection: 'row',
589
+ justifyContent: 'space-between',
590
+ paddingTop: 1,
591
+ paddingLeft: 2,
592
+ paddingRight: 2,
593
+ flexShrink: 0,
594
+ }}
595
+ >
596
+ {/* Empty spacer: justifyContent space-between needs a left sibling to
597
+ keep the ✕ pushed to the right. */}
598
+ <box />
599
+ <Button
600
+ onClick={() => exitCliCleanly()}
601
+ onMouseOver={() => setExitHover(true)}
602
+ onMouseOut={() => setExitHover(false)}
603
+ style={{ paddingLeft: 1, paddingRight: 1 }}
604
+ >
605
+ <text
606
+ style={{ fg: exitHover ? theme.foreground : theme.muted }}
607
+ attributes={TextAttributes.BOLD}
608
+ >
609
+
610
+ </text>
611
+ </Button>
612
+ </box>
613
+
614
+ <box
615
+ style={{
616
+ flexGrow: 1,
617
+ flexDirection: 'column',
618
+ alignItems: 'center',
619
+ // Full logo: anchor the clump low (flex-end), matching how chat pins
620
+ // its header/messages to the input bar. Without a logo, hug the top
621
+ // so the freed rows remain available to the picker.
622
+ justifyContent: showFullLogo ? 'flex-end' : 'flex-start',
623
+ paddingLeft: 2,
624
+ paddingRight: 2,
625
+ paddingBottom: 1,
626
+ gap: showFullLogo ? 1 : 0,
627
+ }}
628
+ >
629
+ {showFullLogo && (
630
+ <box style={{ marginBottom: 1, flexShrink: 0 }}>{logoComponent}</box>
631
+ )}
632
+
633
+ <box
634
+ style={{
635
+ flexDirection: 'column',
636
+ alignItems: 'center',
637
+ gap: 0,
638
+ maxWidth: contentMaxWidth,
639
+ }}
640
+ >
641
+ {failure && (!session || session.status === 'none') && (
642
+ <text style={{ fg: theme.secondary, wrapMode: 'word' }}>
643
+ ⚠ {failure.message}
644
+ </text>
645
+ )}
646
+
647
+ {!session && !failure && (
648
+ <text style={{ fg: theme.muted }}>
649
+ <ShimmerText text="Connecting…" />
650
+ </text>
651
+ )}
652
+
653
+ {isLanding && (
654
+ <box
655
+ style={{
656
+ flexDirection: 'column',
657
+ alignItems: 'flex-start',
658
+ gap: 0,
659
+ }}
660
+ >
661
+ <LandingHeadingRow
662
+ streakLine={streakOnHeadingRow ? streakLine : null}
663
+ marginBottom={textMarginBottom}
664
+ />
665
+ {showStreakIndicator && !streakOnHeadingRow && (
666
+ <StreakInlineLine line={streakLine} />
667
+ )}
668
+ <FreebuffModelSelector
669
+ maxHeight={selectorMaxHeight}
670
+ onExpandedChange={setSelectorExpanded}
671
+ belowToggle={
672
+ showBelowPickerCounter ? (
673
+ <text
674
+ style={{
675
+ fg: theme.muted,
676
+ marginTop: 1,
677
+ wrapMode: 'word',
678
+ }}
679
+ >
680
+ <span fg={sessionUsedColor}>
681
+ {formattedSharedSessionUsed} of {sessionLimit}{' '}
682
+ {sessionLabel} used
683
+ </span>
684
+ <span fg={theme.muted}>
685
+ {', '}
686
+ resets in {sessionResetCountdown}
687
+ </span>
688
+ </text>
689
+ ) : null
690
+ }
691
+ />
692
+ {limitedModeNotice && (
693
+ <text
694
+ style={{ fg: theme.muted, wrapMode: 'word', marginTop: 1 }}
695
+ >
696
+ {limitedModeNotice}
697
+ </text>
698
+ )}
699
+ {streakBonusNote && (
700
+ <text
701
+ style={{ fg: theme.primary, wrapMode: 'word', marginTop: 1 }}
702
+ >
703
+ {streakBonusNote}
704
+ </text>
705
+ )}
706
+ </box>
707
+ )}
708
+
709
+ {session?.status === 'takeover_prompt' && (
710
+ <TakeoverPrompt failure={failure} />
711
+ )}
712
+
713
+ {/* Country outside the free-mode allowlist. Terminal — polling has
714
+ stopped. Tell the user up front rather than letting them send a
715
+ request that the chat/completions gate would reject. */}
716
+ {session?.status === 'country_blocked' && (
717
+ <>
718
+ <text style={{ fg: theme.secondary, marginBottom: 1 }}>
719
+ ⚠ Free mode isn't available in your region
720
+ </text>
721
+ <text style={{ fg: theme.muted, wrapMode: 'word' }}>
722
+ {session.countryBlockReason === 'anonymous_network' ? (
723
+ <>
724
+ We detected{' '}
725
+ {formatFreebuffHardBlockedPrivacySignals(
726
+ session.ipPrivacySignals,
727
+ )}{' '}
728
+ traffic
729
+ {session.countryCode === 'UNKNOWN' ? (
730
+ ''
731
+ ) : (
732
+ <>
733
+ {' '}
734
+ from{' '}
735
+ <span fg={theme.foreground}>{session.countryCode}</span>
736
+ </>
737
+ )}
738
+ . Freebuff can't be used from VPN, proxy, or Tor traffic.
739
+ Disable it and restart Freebuff to try again.
740
+ </>
741
+ ) : session.countryCode === 'UNKNOWN' ? (
742
+ <>
743
+ We couldn't verify an eligible location for this request.
744
+ VPN, Tor, proxy, or unknown-location traffic can't use
745
+ freebuff. Press Ctrl+C to exit.
746
+ </>
747
+ ) : (
748
+ <>
749
+ We detected your location as{' '}
750
+ <span fg={theme.foreground}>{session.countryCode}</span>,
751
+ which is outside the countries where freebuff is currently
752
+ offered. Press Ctrl+C to exit.
753
+ </>
754
+ )}
755
+ </text>
756
+ </>
757
+ )}
758
+
759
+ {/* Account banned. Terminal — polling has stopped. Blocking here
760
+ stops banned bots from re-entering free mode. */}
761
+ {session?.status === 'banned' && (
762
+ <>
763
+ <text style={{ fg: theme.secondary, marginBottom: 1 }}>
764
+ ⚠ Account unavailable
765
+ </text>
766
+ <text style={{ fg: theme.muted, wrapMode: 'word' }}>
767
+ This account has been suspended and can't use freebuff. If you
768
+ think this is a mistake, contact support@codebuff.com. Press
769
+ Ctrl+C to exit.
770
+ </text>
771
+ </>
772
+ )}
773
+
774
+ {/* Shared free-session quota exhausted. Terminal for this run —
775
+ the user can exit and return after its daily or weekly reset. */}
776
+ {session?.status === 'rate_limited' && (
777
+ <>
778
+ <text style={{ fg: theme.secondary, marginBottom: 1 }}>
779
+ ⚠ Session limit reached
780
+ </text>
781
+ <text style={{ fg: theme.muted, wrapMode: 'word' }}>
782
+ You've used{' '}
783
+ <span fg={theme.foreground}>
784
+ {formatSessionUnits(session.recentCount)} of {session.limit}
785
+ </span>{' '}
786
+ sessions{' '}
787
+ {session.period === 'pacific_week' ? 'this week' : 'today'}.
788
+ Try again in{' '}
789
+ <span fg={theme.foreground}>
790
+ {formatRetryAfter(session.retryAfterMs)}
791
+ </span>
792
+ . Press Ctrl+C to exit.
793
+ </text>
794
+ </>
795
+ )}
796
+
797
+ {/* Daily provider-spend admission budget reached. Existing sessions
798
+ are never interrupted; this screen only follows a rejected fresh
799
+ admission and gives the user a friendly, concrete return time. */}
800
+ {session?.status === 'spend_limited' && (
801
+ <>
802
+ <text style={{ fg: theme.secondary, marginBottom: 1 }}>
803
+ ☕ Daily Freebuff limit reached
804
+ </text>
805
+ <text style={{ fg: theme.muted, wrapMode: 'word' }}>
806
+ {session.message} Come back in{' '}
807
+ <span fg={theme.foreground}>
808
+ {formatRetryAfter(session.retryAfterMs)}
809
+ </span>
810
+ {' — '}your free usage resets automatically at midnight Pacific.
811
+ Press Ctrl+C to exit.
812
+ </text>
813
+ </>
814
+ )}
815
+
816
+ {/* Too many distinct users hold a free session on this egress IP.
817
+ Unlike the quota screens above there is no reset time — a slot
818
+ frees as soon as any session on the IP ends — so this offers a
819
+ short retry rather than a "come back tomorrow". */}
820
+ {session?.status === 'ip_capped' && (
821
+ <>
822
+ <text style={{ fg: theme.secondary, marginBottom: 1 }}>
823
+ 🚦 Too many Freebuff sessions on this network
824
+ </text>
825
+ <text style={{ fg: theme.muted, wrapMode: 'word' }}>
826
+ {session.activeUsersForIp} other people are already using
827
+ Freebuff from your network, which is the most we allow at once.
828
+ Try again in{' '}
829
+ <span fg={theme.foreground}>
830
+ {formatRetryAfter(session.retryAfterMs)}
831
+ </span>
832
+ {' — '}a slot opens as soon as one of them finishes. Press
833
+ Ctrl+C to exit.
834
+ </text>
835
+ </>
836
+ )}
837
+ </box>
838
+ </box>
839
+
840
+ {/* Reserve the ad banner slot before the async ad fetch resolves so the
841
+ landing content does not jump when the banner fills. On very
842
+ short terminals the banner is dropped entirely to give the picker
843
+ back its 5 rows. */}
844
+ {showAds && (
845
+ <box
846
+ style={{
847
+ width: '100%',
848
+ flexShrink: 0,
849
+ height: AD_CARD_HEIGHT,
850
+ }}
851
+ >
852
+ {ads ? (
853
+ <ChoiceAdBanner
854
+ ads={ads}
855
+ onClick={recordClick}
856
+ onImpression={recordImpression}
857
+ />
858
+ ) : (
859
+ <text style={{ fg: theme.muted }}>{'─'.repeat(terminalWidth)}</text>
860
+ )}
861
+ </box>
862
+ )}
863
+ </box>
864
+ )
865
+ }