Skip to content

Commit 5f23f47

Browse files
jahoomaclaude
andcommitted
Simplify Freebuff limited-mode landing screen
Limited tier only ever sees one model, so the multi-row picker chrome (LIMITED section header, comparative tagline like "Most efficient", "Pick a model to start" copy) read as filler. Fork a LimitedLandingPanel that renders the model identity, data-collection caveat, session counter, and a single bordered "Start session Enter" CTA — confirm gate rather than picker. Also strip the word "limited" from the session counter and rate-limit copy across the waiting room and session-ended banner so the tier name doesn't leak into user-facing text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cdfae3 commit 5f23f47

4 files changed

Lines changed: 160 additions & 12 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import type { KeyEvent } from '@opentui/core'
3232
// (rendered by the parent), not the section header — this picker is purely a
3333
// list of choices grouped by tier. Empty sections are filtered so a model set
3434
// with no premium (or no unlimited) entries doesn't render an orphan header.
35+
//
36+
// `label` may be empty: limited-tier users only ever see one section, so the
37+
// "LIMITED" header would just leak the internal tier name without organizing
38+
// anything. Renderer treats an empty label as "no header row".
3539
type Section = {
3640
key: 'premium' | 'unlimited' | 'limited'
3741
label: string
@@ -83,6 +87,10 @@ export const FreebuffModelSelector: React.FC = () => {
8387
() => getFreebuffModelsForAccessTier(accessTier),
8488
[accessTier],
8589
)
90+
// Limited tier only ever surfaces one model, so a comparative tagline
91+
// ("Most efficient") reads as filler. Hide it; the warning (data-collection)
92+
// is the row's real content.
93+
const showTagline = accessTier !== 'limited'
8694
const availableModelIds = useMemo(
8795
() => availableModels.map((m) => m.id),
8896
[availableModels],
@@ -92,7 +100,7 @@ export const FreebuffModelSelector: React.FC = () => {
92100
return [
93101
{
94102
key: 'limited',
95-
label: 'LIMITED',
103+
label: '',
96104
models: availableModels,
97105
},
98106
] satisfies readonly Section[]
@@ -152,7 +160,8 @@ export const FreebuffModelSelector: React.FC = () => {
152160
const maxNameLen = Math.max(...availableModels.map(nameLen))
153161

154162
const detailsParts = (model: FreebuffModelOption): number[] => {
155-
const parts = [model.tagline.length]
163+
const parts: number[] = []
164+
if (showTagline) parts.push(model.tagline.length)
156165
if (model.warning) parts.push(model.warning.length)
157166
if (model.availability === 'deployment_hours') {
158167
parts.push(deploymentAvailabilityLabel.length)
@@ -181,9 +190,10 @@ export const FreebuffModelSelector: React.FC = () => {
181190

182191
// Narrow: line 1 = "indicator name · tagline", line 2 (if any) =
183192
// " warning · hours". Compute the max of both so all buttons stay the
184-
// same width.
193+
// same width. When taglines are hidden (limited tier), line 1 is just
194+
// "indicator name" with no separator.
185195
const labelLineLen = (m: FreebuffModelOption) =>
186-
2 + m.displayName.length + 3 + m.tagline.length
196+
2 + m.displayName.length + (showTagline ? 3 + m.tagline.length : 0)
187197
const detailsLineLen = (m: FreebuffModelOption) => {
188198
const parts: number[] = []
189199
if (m.warning) parts.push(m.warning.length)
@@ -205,7 +215,7 @@ export const FreebuffModelSelector: React.FC = () => {
205215
),
206216
nameColumnWidth: maxNameLen,
207217
}
208-
}, [availableModels, contentMaxWidth, deploymentAvailabilityLabel])
218+
}, [availableModels, contentMaxWidth, deploymentAvailabilityLabel, showTagline])
209219

210220
const isJoinable = useCallback(
211221
(modelId: string) => {
@@ -339,10 +349,12 @@ export const FreebuffModelSelector: React.FC = () => {
339349
{model.displayName}
340350
</span>
341351
{wrapDetails ? (
342-
<span fg={mutedColor}> · {model.tagline}</span>
352+
showTagline && <span fg={mutedColor}> · {model.tagline}</span>
343353
) : (
344354
<>
345-
<span fg={mutedColor}>{namePadding + model.tagline}</span>
355+
{showTagline && (
356+
<span fg={mutedColor}>{namePadding + model.tagline}</span>
357+
)}
346358
{hasWarning && <span fg={warningColor}> · {model.warning}</span>}
347359
{hasHours && (
348360
<span fg={mutedColor}> · {deploymentAvailabilityLabel}</span>
@@ -382,7 +394,9 @@ export const FreebuffModelSelector: React.FC = () => {
382394
marginTop: sectionIdx === 0 ? 0 : 1,
383395
}}
384396
>
385-
<text style={{ fg: theme.muted }}>{section.label}</text>
397+
{section.label && (
398+
<text style={{ fg: theme.muted }}>{section.label}</text>
399+
)}
386400
{section.models.map(renderModelButton)}
387401
</box>
388402
))}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
3+
import React, { useCallback, useState } from 'react'
4+
5+
import { Button } from './button'
6+
import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
7+
import { useTheme } from '../hooks/use-theme'
8+
import {
9+
getFreebuffModel,
10+
LIMITED_FREEBUFF_MODEL_ID,
11+
} from '@codebuff/common/constants/freebuff-models'
12+
13+
import type { KeyEvent } from '@opentui/core'
14+
15+
interface LimitedLandingPanelProps {
16+
/** Pre-composed session-counter line (e.g. "0 of 5 sessions used · resets
17+
* in 8h 21m"). Parent owns the colors so the "used" count can flip to
18+
* the warning color when exhausted without this component re-deriving the
19+
* quota math. */
20+
sessionCounter: React.ReactNode
21+
/** True when the shared per-day quota is fully spent. Disables the CTA. */
22+
isQuotaExhausted: boolean
23+
}
24+
25+
/**
26+
* Limited-tier landing screen.
27+
*
28+
* Limited users only ever see one model, so this screen is a confirm gate,
29+
* not a picker. Layout reads top-down as: model identity → caveat (data
30+
* collection) → quota → CTA — so the action and the thing being acted on
31+
* stay visually grouped.
32+
*/
33+
export const LimitedLandingPanel: React.FC<LimitedLandingPanelProps> = ({
34+
sessionCounter,
35+
isQuotaExhausted,
36+
}) => {
37+
const theme = useTheme()
38+
const model = getFreebuffModel(LIMITED_FREEBUFF_MODEL_ID)
39+
const [pending, setPending] = useState(false)
40+
41+
const interactable = !pending && !isQuotaExhausted
42+
43+
const start = useCallback(() => {
44+
if (!interactable) return
45+
setPending(true)
46+
joinFreebuffQueue(LIMITED_FREEBUFF_MODEL_ID).finally(() =>
47+
setPending(false),
48+
)
49+
}, [interactable])
50+
51+
useKeyboard(
52+
useCallback(
53+
(key: KeyEvent) => {
54+
const name = key.name ?? ''
55+
const isCommit =
56+
name === 'return' || name === 'enter' || name === 'space'
57+
if (!isCommit || !interactable) return
58+
key.preventDefault?.()
59+
key.stopPropagation?.()
60+
start()
61+
},
62+
[interactable, start],
63+
),
64+
)
65+
66+
return (
67+
<box
68+
style={{
69+
flexDirection: 'column',
70+
alignItems: 'flex-start',
71+
gap: 0,
72+
}}
73+
>
74+
<text style={{ wrapMode: 'word' }}>
75+
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
76+
{model.displayName}
77+
</span>
78+
</text>
79+
{model.warning && (
80+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
81+
{model.warning}
82+
</text>
83+
)}
84+
<text style={{ marginTop: 1, marginBottom: 1, wrapMode: 'word' }}>
85+
{sessionCounter}
86+
</text>
87+
<Button
88+
onClick={start}
89+
style={{
90+
borderStyle: 'single',
91+
borderColor: interactable ? theme.primary : theme.border,
92+
paddingLeft: 2,
93+
paddingRight: 2,
94+
}}
95+
border={['top', 'bottom', 'left', 'right']}
96+
>
97+
<text
98+
style={{ fg: interactable ? theme.primary : theme.muted }}
99+
attributes={TextAttributes.BOLD}
100+
>
101+
{pending ? (
102+
'Starting…'
103+
) : (
104+
<>
105+
Start session<span fg={theme.muted}>{' Enter'}</span>
106+
</>
107+
)}
108+
</text>
109+
</Button>
110+
</box>
111+
)
112+
}

cli/src/components/session-ended-banner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
4848
s.session && 'accessTier' in s.session ? s.session.accessTier : 'full',
4949
)
5050
const quotaLabel =
51-
accessTier === 'limited' ? 'limited sessions' : 'premium sessions'
51+
accessTier === 'limited' ? 'sessions' : 'premium sessions'
5252
const bannerTitle = premiumQuota
5353
? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} ${quotaLabel} used today`
5454
: 'Session ended'

cli/src/components/waiting-room-screen.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
55
import { Button } from './button'
66
import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner'
77
import { FreebuffModelSelector } from './freebuff-model-selector'
8+
import { LimitedLandingPanel } from './limited-landing-panel'
89
import { ShimmerText } from './shimmer-text'
910
import {
1011
refreshFreebuffLandingMetadata,
@@ -296,8 +297,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
296297
accessTier === 'limited'
297298
? FREEBUFF_LIMITED_SESSION_LIMIT
298299
: FREEBUFF_PREMIUM_SESSION_LIMIT
300+
// Limited-tier users don't see any premium models, so calling these "limited
301+
// sessions" leaks the tier name without informing the user — just "sessions"
302+
// reads naturally next to the count and reset countdown.
299303
const sessionLabel =
300-
accessTier === 'limited' ? 'limited sessions' : 'premium sessions'
304+
accessTier === 'limited' ? 'sessions' : 'premium sessions'
301305
const sessionUnitWidth = String(sessionLimit).length + 2
302306
const formattedSharedPremiumUsed =
303307
formatSessionUnits(sharedPremiumUsed).padStart(sessionUnitWidth)
@@ -395,7 +399,25 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
395399
</text>
396400
)}
397401

398-
{isLanding && (
402+
{isLanding && accessTier === 'limited' && (
403+
<LimitedLandingPanel
404+
isQuotaExhausted={isPremiumExhausted}
405+
sessionCounter={
406+
<>
407+
<span fg={premiumUsedColor}>
408+
{formattedSharedPremiumUsed} of {sessionLimit}{' '}
409+
{sessionLabel} used
410+
</span>
411+
<span fg={theme.muted}>
412+
{' · '}
413+
resets in {premiumResetCountdown}
414+
</span>
415+
</>
416+
}
417+
/>
418+
)}
419+
420+
{isLanding && accessTier !== 'limited' && (
399421
<box
400422
style={{
401423
flexDirection: 'column',
@@ -554,7 +576,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
554576
{formatSessionUnits(session.recentCount)} of {session.limit}
555577
</span>{' '}
556578
{session.accessTier === 'limited'
557-
? 'limited sessions'
579+
? 'sessions'
558580
: 'premium sessions'}{' '}
559581
today. Try again in{' '}
560582
<span fg={theme.foreground}>

0 commit comments

Comments
 (0)