@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.
- package/README.md +84 -0
- package/bin/anvil +2 -0
- package/bin/tree-sitter.wasm +0 -0
- package/package.json +67 -0
- package/src/__tests__/README.md +256 -0
- package/src/__tests__/bash-mode.test.ts +442 -0
- package/src/__tests__/cli-args.test.ts +181 -0
- package/src/__tests__/cli-harness.test.ts +52 -0
- package/src/__tests__/e2e/first-time-login.test.ts +173 -0
- package/src/__tests__/e2e/logout-relogin-flow.test.ts +110 -0
- package/src/__tests__/e2e/returning-user-auth.test.ts +113 -0
- package/src/__tests__/e2e-cli.test.ts +183 -0
- package/src/__tests__/helpers/launcher-disconnect-fixture.cjs +176 -0
- package/src/__tests__/helpers/mock-api-client.ts +64 -0
- package/src/__tests__/helpers/renderer-cleanup-fixture.tsx +89 -0
- package/src/__tests__/helpers/terminal-watchdog-fixture.ts +75 -0
- package/src/__tests__/home-directory-detection.test.ts +213 -0
- package/src/__tests__/integration/api-integration.test.ts +288 -0
- package/src/__tests__/integration/credentials-storage.test.ts +486 -0
- package/src/__tests__/integration/local-agents.test.ts +1187 -0
- package/src/__tests__/integration/login-flow-instrumentation.test.ts +134 -0
- package/src/__tests__/integration/login-polling-working.test.ts +285 -0
- package/src/__tests__/integration/queue-editing.test.tsx +261 -0
- package/src/__tests__/integration/usage-refresh-on-completion.test.ts +168 -0
- package/src/__tests__/integration-tmux.test.ts +188 -0
- package/src/__tests__/launcher-avx2-fallback.test.ts +630 -0
- package/src/__tests__/launcher-disconnect.test.ts +104 -0
- package/src/__tests__/mocks/hover-toggle-controller.ts +71 -0
- package/src/__tests__/open-tui-native-bundle.test.ts +378 -0
- package/src/__tests__/path-completion.test.ts +243 -0
- package/src/__tests__/project-files-chat-id.test.ts +34 -0
- package/src/__tests__/release/proxy-http-get.test.ts +667 -0
- package/src/__tests__/release/wrapper-safety.test.ts +412 -0
- package/src/__tests__/renderer-cleanup.test.ts +174 -0
- package/src/__tests__/rerender-perf.integration.test.ts +283 -0
- package/src/__tests__/terminal-reset-sequences.test.ts +36 -0
- package/src/__tests__/terminal-watchdog.test.ts +388 -0
- package/src/__tests__/test-utils.ts +271 -0
- package/src/__tests__/tmux-poc.ts +150 -0
- package/src/__tests__/unit/agent-mode-toggle.test.ts +137 -0
- package/src/__tests__/unit/copy-button.test.ts +157 -0
- package/src/__tests__/unit/create-run-config.test.ts +66 -0
- package/src/__tests__/unit/publish-confirmation.test.ts +70 -0
- package/src/__tests__/unit/segmented-control.test.ts +152 -0
- package/src/__tests__/utils/env.test.ts +169 -0
- package/src/__tests__/utils/project-picker.test.ts +39 -0
- package/src/agents/bundled-agents.generated.d.ts +14 -0
- package/src/agents/bundled-agents.generated.ts +3692 -0
- package/src/app.tsx +399 -0
- package/src/chat.tsx +1698 -0
- package/src/cli-args.ts +134 -0
- package/src/commands/__tests__/bash-command.test.ts +445 -0
- package/src/commands/__tests__/command-args.test.ts +349 -0
- package/src/commands/__tests__/copy-conversation-clipboard.test.ts +108 -0
- package/src/commands/__tests__/copy-conversation.test.ts +226 -0
- package/src/commands/__tests__/diagnostics-command.test.ts +35 -0
- package/src/commands/__tests__/freebuff-command-aliases.test.ts +56 -0
- package/src/commands/__tests__/image.test.ts +99 -0
- package/src/commands/__tests__/init.test.ts +456 -0
- package/src/commands/__tests__/new-command-rotates-chat.test.ts +91 -0
- package/src/commands/__tests__/process-diagnostics.test.ts +54 -0
- package/src/commands/__tests__/prompt-builders.test.ts +28 -0
- package/src/commands/__tests__/router-input.test.ts +298 -0
- package/src/commands/ads.ts +44 -0
- package/src/commands/command-registry.ts +726 -0
- package/src/commands/copy-conversation.ts +392 -0
- package/src/commands/help.ts +13 -0
- package/src/commands/image.ts +20 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/process-diagnostics.ts +120 -0
- package/src/commands/prompt-builders.ts +100 -0
- package/src/commands/publish.ts +203 -0
- package/src/commands/router-utils.ts +80 -0
- package/src/commands/router.ts +487 -0
- package/src/commands/usage.ts +26 -0
- package/src/components/__tests__/ad-banner.test.tsx +83 -0
- package/src/components/__tests__/chat-input-bar.test.tsx +130 -0
- package/src/components/__tests__/freebuff-landing-heading-row.test.tsx +91 -0
- package/src/components/__tests__/freebuff-model-selector.test.tsx +519 -0
- package/src/components/__tests__/freebuff-offer-invariants.test.ts +54 -0
- package/src/components/__tests__/freebuff-takeover-prompt.test.tsx +212 -0
- package/src/components/__tests__/grid-layout.integration.test.tsx +304 -0
- package/src/components/__tests__/grid-layout.test.tsx +1051 -0
- package/src/components/__tests__/message-block.completion.test.tsx +104 -0
- package/src/components/__tests__/message-block.streaming.test.tsx +78 -0
- package/src/components/__tests__/message-with-agents.test.tsx +579 -0
- package/src/components/__tests__/multiline-input.test.tsx +1132 -0
- package/src/components/__tests__/queue-panel.test.tsx +398 -0
- package/src/components/__tests__/selectable-list.test.ts +232 -0
- package/src/components/__tests__/status-bar.test.tsx +44 -0
- package/src/components/__tests__/status-indicator.test.tsx +204 -0
- package/src/components/__tests__/terminal-thinking-orb.test.tsx +111 -0
- package/src/components/__tests__/user-error-banner.test.tsx +102 -0
- package/src/components/ad-banner.tsx +323 -0
- package/src/components/agent-checklist.tsx +350 -0
- package/src/components/agent-mode-toggle.tsx +241 -0
- package/src/components/anvil-mark.tsx +52 -0
- package/src/components/ask-user/__tests__/multiple-choice-form.test.ts +432 -0
- package/src/components/ask-user/__tests__/validation.test.ts +213 -0
- package/src/components/ask-user/components/accordion-question.tsx +153 -0
- package/src/components/ask-user/components/custom-answer-input.tsx +66 -0
- package/src/components/ask-user/components/options-list.tsx +133 -0
- package/src/components/ask-user/components/question-header.tsx +80 -0
- package/src/components/ask-user/components/question-option.tsx +89 -0
- package/src/components/ask-user/constants.ts +38 -0
- package/src/components/ask-user/index.tsx +631 -0
- package/src/components/ask-user/utils/validation.ts +85 -0
- package/src/components/attachment-card.tsx +65 -0
- package/src/components/blocks/agent-block-grid.tsx +58 -0
- package/src/components/blocks/agent-branch-item.tsx +311 -0
- package/src/components/blocks/agent-branch-wrapper.tsx +493 -0
- package/src/components/blocks/agent-list-branch.tsx +77 -0
- package/src/components/blocks/ask-user-branch.tsx +81 -0
- package/src/components/blocks/block-helpers.ts +11 -0
- package/src/components/blocks/blocks-renderer.tsx +289 -0
- package/src/components/blocks/content-with-markdown.tsx +33 -0
- package/src/components/blocks/image-block.tsx +128 -0
- package/src/components/blocks/implementor-row.tsx +429 -0
- package/src/components/blocks/single-block.tsx +201 -0
- package/src/components/blocks/thinking-block.tsx +67 -0
- package/src/components/blocks/tool-block-group.tsx +57 -0
- package/src/components/blocks/tool-branch.tsx +206 -0
- package/src/components/blocks/user-content-copy.tsx +157 -0
- package/src/components/bottom-banner.tsx +136 -0
- package/src/components/build-mode-buttons.tsx +110 -0
- package/src/components/button.tsx +66 -0
- package/src/components/chat-header.tsx +77 -0
- package/src/components/chat-history-screen.tsx +413 -0
- package/src/components/chat-input-bar.tsx +523 -0
- package/src/components/clickable-title-box.tsx +66 -0
- package/src/components/clickable.tsx +119 -0
- package/src/components/collapse-button.tsx +34 -0
- package/src/components/copy-button.tsx +203 -0
- package/src/components/elapsed-timer.tsx +55 -0
- package/src/components/error-boundary.tsx +55 -0
- package/src/components/feedback-container.tsx +181 -0
- package/src/components/feedback-icon-button.tsx +89 -0
- package/src/components/feedback-input-mode.tsx +344 -0
- package/src/components/file-attachment-card.tsx +98 -0
- package/src/components/freebuff-active-session-summary.tsx +63 -0
- package/src/components/freebuff-landing-screen.tsx +865 -0
- package/src/components/freebuff-model-selector.tsx +1087 -0
- package/src/components/freebuff-referral-banner.tsx +604 -0
- package/src/components/freebuff-superseded-screen.tsx +62 -0
- package/src/components/grid-layout.tsx +80 -0
- package/src/components/help-banner.tsx +127 -0
- package/src/components/highlighted-text.tsx +52 -0
- package/src/components/image-card.tsx +167 -0
- package/src/components/image-thumbnail.tsx +114 -0
- package/src/components/input-cursor.tsx +77 -0
- package/src/components/input-mode-banner.tsx +57 -0
- package/src/components/load-previous-button.tsx +48 -0
- package/src/components/login-modal.tsx +475 -0
- package/src/components/message-block.tsx +346 -0
- package/src/components/message-footer.tsx +258 -0
- package/src/components/message-with-agents.tsx +506 -0
- package/src/components/mode-divider.tsx +43 -0
- package/src/components/multiline-input.tsx +1241 -0
- package/src/components/out-of-credits-banner.tsx +160 -0
- package/src/components/pending-attachments-banner.tsx +116 -0
- package/src/components/pending-bash-message.tsx +59 -0
- package/src/components/progress-bar.tsx +81 -0
- package/src/components/project-picker-screen.tsx +485 -0
- package/src/components/publish-confirmation.tsx +493 -0
- package/src/components/publish-container.tsx +687 -0
- package/src/components/queue-panel.tsx +310 -0
- package/src/components/raised-pill.tsx +95 -0
- package/src/components/renderers/plan-box.tsx +59 -0
- package/src/components/review-screen.tsx +112 -0
- package/src/components/scroll-to-bottom-button.tsx +34 -0
- package/src/components/segmented-control.tsx +187 -0
- package/src/components/selectable-list.tsx +248 -0
- package/src/components/selected-chips.tsx +80 -0
- package/src/components/separator.tsx +40 -0
- package/src/components/session-ended-banner.tsx +226 -0
- package/src/components/shimmer-text.tsx +248 -0
- package/src/components/status-bar.tsx +313 -0
- package/src/components/subscription-limit-banner.tsx +206 -0
- package/src/components/suggested-prompts.tsx +184 -0
- package/src/components/suggestion-menu.tsx +215 -0
- package/src/components/terminal-command-display.tsx +151 -0
- package/src/components/terminal-link.tsx +97 -0
- package/src/components/terminal-thinking-orb.tsx +95 -0
- package/src/components/text-attachment-card.tsx +77 -0
- package/src/components/thinking.tsx +109 -0
- package/src/components/tools/__tests__/apply-patch.test.tsx +84 -0
- package/src/components/tools/__tests__/code-search.test.tsx +45 -0
- package/src/components/tools/__tests__/gravity-index.test.ts +76 -0
- package/src/components/tools/__tests__/render-ui.test.tsx +92 -0
- package/src/components/tools/__tests__/run-terminal-command.test.ts +233 -0
- package/src/components/tools/apply-patch.tsx +95 -0
- package/src/components/tools/code-search.tsx +45 -0
- package/src/components/tools/composio.tsx +152 -0
- package/src/components/tools/diff-viewer.tsx +76 -0
- package/src/components/tools/glob.tsx +61 -0
- package/src/components/tools/gravity-index.tsx +77 -0
- package/src/components/tools/list-directory.tsx +61 -0
- package/src/components/tools/read-docs.tsx +37 -0
- package/src/components/tools/read-files.tsx +91 -0
- package/src/components/tools/read-subtree.tsx +44 -0
- package/src/components/tools/read-url.tsx +33 -0
- package/src/components/tools/registry.ts +126 -0
- package/src/components/tools/render-ui.tsx +122 -0
- package/src/components/tools/run-terminal-command.tsx +79 -0
- package/src/components/tools/skill.tsx +29 -0
- package/src/components/tools/str-replace.tsx +77 -0
- package/src/components/tools/suggest-followups.tsx +369 -0
- package/src/components/tools/task-completed.tsx +16 -0
- package/src/components/tools/tool-call-item.tsx +273 -0
- package/src/components/tools/types.ts +55 -0
- package/src/components/tools/web-search.tsx +33 -0
- package/src/components/tools/write-file.tsx +16 -0
- package/src/components/tools/write-todos.tsx +95 -0
- package/src/components/top-banner.tsx +178 -0
- package/src/components/usage-banner.tsx +244 -0
- package/src/components/user-error-banner.tsx +56 -0
- package/src/components/validation-error-popover.tsx +192 -0
- package/src/contexts/__tests__/chat-runtime-context.test.tsx +149 -0
- package/src/contexts/chat-runtime-context.tsx +240 -0
- package/src/data/slash-commands.test.ts +24 -0
- package/src/data/slash-commands.ts +249 -0
- package/src/entry.ts +12 -0
- package/src/hooks/__tests__/holds-live-freebuff-slot.test.ts +49 -0
- package/src/hooks/__tests__/session-fetch-signal.test.ts +63 -0
- package/src/hooks/__tests__/use-activity-query.test.ts +1000 -0
- package/src/hooks/__tests__/use-ask-user-bridge.test.ts +176 -0
- package/src/hooks/__tests__/use-auth-query.test.ts +21 -0
- package/src/hooks/__tests__/use-chat-input-remount.test.tsx +66 -0
- package/src/hooks/__tests__/use-clipboard.test.tsx +73 -0
- package/src/hooks/__tests__/use-connection-status.test.ts +112 -0
- package/src/hooks/__tests__/use-directory-browser.test.ts +203 -0
- package/src/hooks/__tests__/use-gravity-ad.test.ts +73 -0
- package/src/hooks/__tests__/use-grid-layout.test.ts +349 -0
- package/src/hooks/__tests__/use-input-history.test.ts +699 -0
- package/src/hooks/__tests__/use-message-queue-editing.test.tsx +209 -0
- package/src/hooks/__tests__/use-message-queue.test.tsx +71 -0
- package/src/hooks/__tests__/use-path-tab-completion.test.ts +315 -0
- package/src/hooks/__tests__/use-queue-controls.test.ts +68 -0
- package/src/hooks/__tests__/use-queue-ui.test.tsx +83 -0
- package/src/hooks/__tests__/use-searchable-list.test.ts +359 -0
- package/src/hooks/__tests__/use-send-message-timer.test.ts +146 -0
- package/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +361 -0
- package/src/hooks/__tests__/use-suggestion-engine.test.ts +485 -0
- package/src/hooks/__tests__/use-terminal-focus.test.ts +66 -0
- package/src/hooks/__tests__/use-terminal-layout.test.ts +675 -0
- package/src/hooks/__tests__/use-timeout.test.ts +330 -0
- package/src/hooks/__tests__/use-usage-query.test.ts +502 -0
- package/src/hooks/__tests__/use-user-details-query.test.ts +195 -0
- package/src/hooks/helpers/__tests__/send-message.test.ts +1918 -0
- package/src/hooks/helpers/send-message.ts +654 -0
- package/src/hooks/stream-state.ts +83 -0
- package/src/hooks/use-activity-query.ts +668 -0
- package/src/hooks/use-agent-validation.ts +74 -0
- package/src/hooks/use-ask-user-bridge.ts +92 -0
- package/src/hooks/use-auth-query.ts +253 -0
- package/src/hooks/use-auth-state.ts +152 -0
- package/src/hooks/use-chat-input.ts +105 -0
- package/src/hooks/use-chat-keyboard.ts +321 -0
- package/src/hooks/use-chat-messages.ts +254 -0
- package/src/hooks/use-chat-state.ts +165 -0
- package/src/hooks/use-chat-streaming.ts +180 -0
- package/src/hooks/use-chat-ui.ts +131 -0
- package/src/hooks/use-clipboard.ts +151 -0
- package/src/hooks/use-connection-status.ts +142 -0
- package/src/hooks/use-directory-browser.ts +88 -0
- package/src/hooks/use-elapsed-time.ts +116 -0
- package/src/hooks/use-event.ts +34 -0
- package/src/hooks/use-exit-handler.ts +66 -0
- package/src/hooks/use-fetch-login-url.ts +65 -0
- package/src/hooks/use-fingerprint.ts +61 -0
- package/src/hooks/use-freebuff-ctrl-c-exit.ts +23 -0
- package/src/hooks/use-freebuff-session-progress.ts +34 -0
- package/src/hooks/use-freebuff-session.ts +806 -0
- package/src/hooks/use-freebuff-streak-query.ts +61 -0
- package/src/hooks/use-gravity-ad.ts +656 -0
- package/src/hooks/use-grid-layout.ts +73 -0
- package/src/hooks/use-input-history.ts +173 -0
- package/src/hooks/use-login-keyboard-handlers.ts +75 -0
- package/src/hooks/use-login-polling.ts +114 -0
- package/src/hooks/use-logo.tsx +161 -0
- package/src/hooks/use-message-queue.ts +387 -0
- package/src/hooks/use-now.ts +20 -0
- package/src/hooks/use-path-tab-completion.ts +88 -0
- package/src/hooks/use-publish-mutation.ts +60 -0
- package/src/hooks/use-queue-controls.ts +68 -0
- package/src/hooks/use-queue-ui.ts +74 -0
- package/src/hooks/use-scroll-management.ts +184 -0
- package/src/hooks/use-searchable-list.ts +98 -0
- package/src/hooks/use-send-message.ts +773 -0
- package/src/hooks/use-sheen-animation.tsx +88 -0
- package/src/hooks/use-subscription-query.ts +71 -0
- package/src/hooks/use-suggestion-engine.ts +783 -0
- package/src/hooks/use-terminal-breakpoints.ts +77 -0
- package/src/hooks/use-terminal-dimensions.ts +46 -0
- package/src/hooks/use-terminal-focus.ts +29 -0
- package/src/hooks/use-terminal-layout.ts +189 -0
- package/src/hooks/use-theme.tsx +140 -0
- package/src/hooks/use-timeout.ts +85 -0
- package/src/hooks/use-update-preference.ts +66 -0
- package/src/hooks/use-usage-monitor.ts +55 -0
- package/src/hooks/use-usage-query.ts +124 -0
- package/src/hooks/use-user-details-query.ts +93 -0
- package/src/hooks/use-why-did-you-update.ts +177 -0
- package/src/index.tsx +441 -0
- package/src/init/__tests__/init-direnv.test.ts +526 -0
- package/src/init/init-app.ts +34 -0
- package/src/init/init-direnv.ts +133 -0
- package/src/login/constants.ts +60 -0
- package/src/login/login-flow.ts +204 -0
- package/src/login/plain-login.ts +108 -0
- package/src/login/utils.ts +183 -0
- package/src/native/ripgrep.ts +80 -0
- package/src/polyfills/bun-strip-ansi.ts +12 -0
- package/src/pre-init/tree-sitter-wasm.ts +96 -0
- package/src/project-files.ts +102 -0
- package/src/smoke/terminal-command-broker.ts +439 -0
- package/src/state/__tests__/chat-store-focus.test.ts +22 -0
- package/src/state/__tests__/feedback-store.test.ts +257 -0
- package/src/state/chat-history-store.ts +30 -0
- package/src/state/chat-store.ts +544 -0
- package/src/state/feedback-store.ts +160 -0
- package/src/state/freebuff-model-store.ts +42 -0
- package/src/state/freebuff-session-store.ts +53 -0
- package/src/state/login-store.ts +150 -0
- package/src/state/message-block-store.ts +149 -0
- package/src/state/publish-store.ts +146 -0
- package/src/state/queue-panel-store.ts +26 -0
- package/src/state/review-store.ts +24 -0
- package/src/testing/env.ts +46 -0
- package/src/types/chat-state.ts +15 -0
- package/src/types/chat.ts +241 -0
- package/src/types/contracts/send-message.ts +12 -0
- package/src/types/env.ts +98 -0
- package/src/types/freebuff-session.ts +17 -0
- package/src/types/function-params.ts +28 -0
- package/src/types/react19-compat.d.ts +19 -0
- package/src/types/store.ts +111 -0
- package/src/types/theme-system.ts +144 -0
- package/src/types/utils.ts +3 -0
- package/src/utils/__tests__/active-run.test.ts +140 -0
- package/src/utils/__tests__/activity-tracker.test.ts +440 -0
- package/src/utils/__tests__/agent-display.test.ts +139 -0
- package/src/utils/__tests__/analytics-client.test.ts +262 -0
- package/src/utils/__tests__/anonymous-id.test.ts +74 -0
- package/src/utils/__tests__/anvil-api.test.ts +578 -0
- package/src/utils/__tests__/arrays.test.ts +264 -0
- package/src/utils/__tests__/bash-context-processor.test.ts +38 -0
- package/src/utils/__tests__/block-processor.test.ts +810 -0
- package/src/utils/__tests__/chat-history.test.ts +203 -0
- package/src/utils/__tests__/chat-input-key-intercept.test.ts +40 -0
- package/src/utils/__tests__/chat-meta.test.ts +130 -0
- package/src/utils/__tests__/clipboard-linux.test.ts +359 -0
- package/src/utils/__tests__/clipboard.test.ts +910 -0
- package/src/utils/__tests__/code-search-summary.test.ts +84 -0
- package/src/utils/__tests__/collapse-helpers.test.ts +1109 -0
- package/src/utils/__tests__/error-handling.test.ts +658 -0
- package/src/utils/__tests__/exit-cleanly.test.ts +110 -0
- package/src/utils/__tests__/feedback-helpers.test.ts +444 -0
- package/src/utils/__tests__/feedback-submission.test.ts +26 -0
- package/src/utils/__tests__/fetch-usage.test.ts +355 -0
- package/src/utils/__tests__/fingerprint.test.ts +144 -0
- package/src/utils/__tests__/fixtures/terminal-command-broker-child.ts +7 -0
- package/src/utils/__tests__/fixtures/terminal-command-broker-entry.ts +3 -0
- package/src/utils/__tests__/fixtures/terminal-command-broker-owner.ts +33 -0
- package/src/utils/__tests__/format-elapsed-time.test.ts +103 -0
- package/src/utils/__tests__/format-timeout.test.ts +87 -0
- package/src/utils/__tests__/freebuff-instance-owner.test.ts +69 -0
- package/src/utils/__tests__/freebuff-model-navigation.test.ts +102 -0
- package/src/utils/__tests__/freebuff-premium-reset.test.ts +79 -0
- package/src/utils/__tests__/freebuff-referral-cache.test.ts +116 -0
- package/src/utils/__tests__/freebuff-session-api.test.ts +215 -0
- package/src/utils/__tests__/freebuff-session-display.test.ts +23 -0
- package/src/utils/__tests__/freebuff-streak-line.test.ts +217 -0
- package/src/utils/__tests__/image-dimensions.test.ts +249 -0
- package/src/utils/__tests__/image-processor.test.ts +209 -0
- package/src/utils/__tests__/implementor-helpers.test.ts +1458 -0
- package/src/utils/__tests__/keyboard-actions.test.ts +660 -0
- package/src/utils/__tests__/layout-helpers.test.ts +36 -0
- package/src/utils/__tests__/lazy-response-ads.test.ts +167 -0
- package/src/utils/__tests__/log-shipper.test.ts +53 -0
- package/src/utils/__tests__/markdown-renderer.test.tsx +421 -0
- package/src/utils/__tests__/message-block-helpers.test.ts +1263 -0
- package/src/utils/__tests__/message-updater.test.ts +645 -0
- package/src/utils/__tests__/osc-timeout-scenarios.test.ts +172 -0
- package/src/utils/__tests__/pending-attachments.test.ts +277 -0
- package/src/utils/__tests__/polling-backoff.test.ts +51 -0
- package/src/utils/__tests__/queue-panel-actions.test.ts +148 -0
- package/src/utils/__tests__/run-state-storage.test.ts +919 -0
- package/src/utils/__tests__/safe-json.test.ts +77 -0
- package/src/utils/__tests__/sdk-event-handlers.test.ts +685 -0
- package/src/utils/__tests__/send-message-helpers.test.ts +1898 -0
- package/src/utils/__tests__/send-message-timer.test.ts +64 -0
- package/src/utils/__tests__/settings.test.ts +99 -0
- package/src/utils/__tests__/strings.test.ts +192 -0
- package/src/utils/__tests__/terminal-color-detection.test.ts +425 -0
- package/src/utils/__tests__/terminal-command-broker.test.ts +528 -0
- package/src/utils/__tests__/terminal-enter-detection.test.ts +77 -0
- package/src/utils/__tests__/terminal-io.test.ts +137 -0
- package/src/utils/__tests__/terminal-protocol-controller.test.ts +119 -0
- package/src/utils/__tests__/text-layout.test.ts +82 -0
- package/src/utils/__tests__/theme-platform-detection.test.ts +125 -0
- package/src/utils/__tests__/think-tag-parser.test.ts +159 -0
- package/src/utils/__tests__/trace-writer.test.ts +223 -0
- package/src/utils/__tests__/trim-chat-logs.test.ts +78 -0
- package/src/utils/__tests__/usage-banner-state.test.ts +298 -0
- package/src/utils/__tests__/validation-error-formatting.test.ts +126 -0
- package/src/utils/__tests__/windows-terminal-health.test.ts +101 -0
- package/src/utils/__tests__/write-file-atomic.test.ts +127 -0
- package/src/utils/active-run.ts +131 -0
- package/src/utils/activity-tracker.ts +66 -0
- package/src/utils/agent-display.ts +87 -0
- package/src/utils/agent-helpers.ts +27 -0
- package/src/utils/agent-id-utils.ts +14 -0
- package/src/utils/ai-message-id.ts +12 -0
- package/src/utils/analytics.ts +361 -0
- package/src/utils/anonymous-id.ts +87 -0
- package/src/utils/anvil-api.ts +612 -0
- package/src/utils/anvil-client.ts +217 -0
- package/src/utils/arrays.ts +117 -0
- package/src/utils/auth.ts +258 -0
- package/src/utils/bash-context-processor.ts +47 -0
- package/src/utils/bash-messages.ts +108 -0
- package/src/utils/block-margins.ts +35 -0
- package/src/utils/block-operations.ts +523 -0
- package/src/utils/block-processor.ts +213 -0
- package/src/utils/chat-history.ts +253 -0
- package/src/utils/chat-input-key-intercept.ts +52 -0
- package/src/utils/chat-meta.ts +90 -0
- package/src/utils/chat-scroll-accel.ts +151 -0
- package/src/utils/clipboard-image.ts +588 -0
- package/src/utils/clipboard.ts +397 -0
- package/src/utils/code-search-summary.ts +70 -0
- package/src/utils/collapse-helpers.ts +256 -0
- package/src/utils/config-dir.ts +24 -0
- package/src/utils/constants.ts +190 -0
- package/src/utils/create-event-handler-state.ts +73 -0
- package/src/utils/create-run-config.ts +121 -0
- package/src/utils/detect-shell.ts +116 -0
- package/src/utils/directory-browser.ts +72 -0
- package/src/utils/engagement.ts +55 -0
- package/src/utils/env.ts +92 -0
- package/src/utils/error-handling.ts +255 -0
- package/src/utils/error-messages.ts +62 -0
- package/src/utils/exit-cleanly.ts +86 -0
- package/src/utils/feedback-helpers.ts +103 -0
- package/src/utils/feedback-submission.ts +22 -0
- package/src/utils/fetch-usage.ts +76 -0
- package/src/utils/fingerprint.ts +245 -0
- package/src/utils/format-elapsed-time.ts +33 -0
- package/src/utils/format-session-units.ts +6 -0
- package/src/utils/format-timeout.ts +28 -0
- package/src/utils/format-validation-errors-for-message.ts +84 -0
- package/src/utils/freebuff-agent-selection.ts +33 -0
- package/src/utils/freebuff-instance-owner.ts +66 -0
- package/src/utils/freebuff-model-navigation.ts +50 -0
- package/src/utils/freebuff-premium-reset.ts +55 -0
- package/src/utils/freebuff-referral-cache.ts +51 -0
- package/src/utils/freebuff-session-api.ts +243 -0
- package/src/utils/freebuff-session-display.ts +21 -0
- package/src/utils/freebuff-streak-line.ts +93 -0
- package/src/utils/git.ts +17 -0
- package/src/utils/helpers.ts +47 -0
- package/src/utils/image-display.ts +67 -0
- package/src/utils/image-handler.ts +325 -0
- package/src/utils/image-processor.ts +133 -0
- package/src/utils/image-thumbnail.ts +78 -0
- package/src/utils/implementor-helpers.ts +787 -0
- package/src/utils/input-modes.ts +174 -0
- package/src/utils/keyboard-actions.ts +392 -0
- package/src/utils/keypad-keys.ts +47 -0
- package/src/utils/layout-helpers.ts +33 -0
- package/src/utils/lazy-response-ads.ts +1 -0
- package/src/utils/local-agent-registry.ts +474 -0
- package/src/utils/log-shipper.ts +126 -0
- package/src/utils/logger.ts +327 -0
- package/src/utils/markdown-renderer.tsx +1090 -0
- package/src/utils/math.ts +3 -0
- package/src/utils/message-block-helpers.ts +684 -0
- package/src/utils/message-history.ts +143 -0
- package/src/utils/message-tree-utils.ts +67 -0
- package/src/utils/message-updater.ts +279 -0
- package/src/utils/open-file.ts +146 -0
- package/src/utils/open-url.ts +63 -0
- package/src/utils/path-completion.ts +111 -0
- package/src/utils/path-helpers.ts +38 -0
- package/src/utils/pending-attachments.ts +360 -0
- package/src/utils/polling-backoff.ts +59 -0
- package/src/utils/project-picker.ts +12 -0
- package/src/utils/queue-panel-actions.ts +70 -0
- package/src/utils/recent-projects.ts +156 -0
- package/src/utils/renderer-cleanup.ts +233 -0
- package/src/utils/response-ad-positions.ts +1 -0
- package/src/utils/run-state-storage.ts +603 -0
- package/src/utils/safe-json.ts +153 -0
- package/src/utils/sdk-event-handlers.ts +571 -0
- package/src/utils/send-message-helpers.ts +210 -0
- package/src/utils/send-message-timer.ts +110 -0
- package/src/utils/settings.ts +226 -0
- package/src/utils/skill-registry.ts +94 -0
- package/src/utils/spawn-agent-matcher.ts +55 -0
- package/src/utils/status-indicator-state.ts +107 -0
- package/src/utils/stream-chunk-processor.ts +57 -0
- package/src/utils/strings.ts +271 -0
- package/src/utils/subscription.ts +31 -0
- package/src/utils/syntax-highlighter.tsx +19 -0
- package/src/utils/terminal-color-detection.ts +474 -0
- package/src/utils/terminal-command-broker.ts +467 -0
- package/src/utils/terminal-enter-detection.ts +63 -0
- package/src/utils/terminal-images.ts +229 -0
- package/src/utils/terminal-io.ts +85 -0
- package/src/utils/terminal-protocol-controller.ts +173 -0
- package/src/utils/terminal-reset-sequences.ts +33 -0
- package/src/utils/terminal-title.ts +84 -0
- package/src/utils/terminal-watchdog.ts +366 -0
- package/src/utils/text-layout.ts +149 -0
- package/src/utils/theme-config.ts +141 -0
- package/src/utils/theme-system.ts +1206 -0
- package/src/utils/think-tag-parser.ts +95 -0
- package/src/utils/time-format.ts +21 -0
- package/src/utils/trace-writer.ts +162 -0
- package/src/utils/ui-constants.ts +69 -0
- package/src/utils/update-checker.ts +110 -0
- package/src/utils/usage-banner-state.ts +146 -0
- package/src/utils/validation-error-formatting.ts +86 -0
- package/src/utils/validation-error-helpers.ts +19 -0
- package/src/utils/windows-terminal-health.ts +124 -0
- package/src/utils/word-wrap-utils.ts +53 -0
- package/src/utils/write-file-atomic.ts +54 -0
- 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
|
+
}
|