Skip to content

Commit 91c1378

Browse files
[codex] overhaul Freebuff premium sessions (#589)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent 9dde8dd commit 91c1378

13 files changed

Lines changed: 3787 additions & 264 deletions

File tree

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

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
DEFAULT_FREEBUFF_MODEL_ID,
88
FALLBACK_FREEBUFF_MODEL_ID,
99
FREEBUFF_MODELS,
10+
FREEBUFF_PREMIUM_SESSION_LIMIT,
1011
getFreebuffDeploymentAvailabilityLabel,
1112
isFreebuffModelAvailable,
13+
isFreebuffPremiumModelId,
1214
} from '@codebuff/common/constants/freebuff-models'
1315

1416
import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
@@ -31,6 +33,10 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
3133
...FREEBUFF_MODELS.filter((model) => model.id !== DEFAULT_FREEBUFF_MODEL_ID),
3234
]
3335

36+
function formatSessionUnits(units: number): string {
37+
return Number.isInteger(units) ? String(units) : units.toFixed(1)
38+
}
39+
3440
/**
3541
* Dual-purpose model picker:
3642
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -45,11 +51,6 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
4551
* Always stacked vertically. On narrow terminals where the longest one-line
4652
* label wouldn't fit, the secondary details (warning / deployment hours)
4753
* spill onto an indented second line under the name.
48-
*
49-
* No queue-position hint: traffic doesn't reach the threshold where a wait
50-
* would form, so showing "N in line" everywhere just adds noise (and width).
51-
* The picker still surfaces "Closed" (outside deployment hours) and "Limit
52-
* used" (per-user quota) inline since those gate the actual click.
5354
*/
5455
export const FreebuffModelSelector: React.FC = () => {
5556
const theme = useTheme()
@@ -91,15 +92,30 @@ export const FreebuffModelSelector: React.FC = () => {
9192
}
9293
}, [now, selectedModel, session, setSelectedModel])
9394

95+
const committedModelId = session?.status === 'queued' ? session.model : null
96+
const rateLimitsByModel =
97+
session && 'rateLimitsByModel' in session
98+
? session.rateLimitsByModel
99+
: undefined
100+
101+
const getQuotaHint = useCallback(
102+
(modelId: string): string => {
103+
const rateLimit = rateLimitsByModel?.[modelId]
104+
if (rateLimit) {
105+
return `${formatSessionUnits(rateLimit.recentCount)}/${rateLimit.limit} used`
106+
}
107+
return isFreebuffPremiumModelId(modelId)
108+
? `0/${FREEBUFF_PREMIUM_SESSION_LIMIT} used`
109+
: 'Unlimited'
110+
},
111+
[rateLimitsByModel],
112+
)
113+
94114
const BUTTON_CHROME = 4 // 2 border + 2 padding
95115

96116
// Decide whether secondary details (warning / deployment hours) get their
97-
// own indented line under the name. Trigger: the widest one-line button
98-
// wouldn't fit in our content budget. All buttons share a uniform width so
99-
// the column reads as a clean stack of equal choices. We size to the
100-
// *label* — Closed / Limit used hints can transiently push the text past
101-
// this width, but they're rare (deployment hours closing, daily quota hit)
102-
// and a small one-time grow is fine.
117+
// own indented line under the name. All buttons share a uniform width so
118+
// the column reads as a clean stack of equal choices.
103119
const { wrapDetails, buttonOuterWidth } = useMemo(() => {
104120
const detailsTextLen = (model: FreebuffModelOption): number => {
105121
const parts: number[] = []
@@ -108,22 +124,34 @@ export const FreebuffModelSelector: React.FC = () => {
108124
}
109125
if (model.warning) parts.push(model.warning.length)
110126
if (parts.length === 0) return 0
111-
return parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 3 /* " · " */
127+
return (
128+
parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 3
129+
) /* " · " */
112130
}
113131

132+
const hintLen = (model: FreebuffModelOption): number =>
133+
Math.max(getQuotaHint(model.id).length, 'Closed'.length)
134+
114135
const oneLineLen = (model: FreebuffModelOption): number => {
115136
const inlineDetails = detailsTextLen(model)
116137
return (
117138
2 /* indicator + space */ +
118139
model.displayName.length +
119140
3 /* " · " */ +
120141
model.tagline.length +
121-
(inlineDetails > 0 ? 3 + inlineDetails : 0)
142+
(inlineDetails > 0 ? 3 + inlineDetails : 0) +
143+
1 /* space before hint */ +
144+
hintLen(model)
122145
)
123146
}
124147

125148
const labelLineLen = (model: FreebuffModelOption): number =>
126-
2 + model.displayName.length + 3 + model.tagline.length
149+
2 +
150+
model.displayName.length +
151+
3 +
152+
model.tagline.length +
153+
1 +
154+
hintLen(model)
127155

128156
const detailsLineLen = (model: FreebuffModelOption): number => {
129157
const len = detailsTextLen(model)
@@ -148,16 +176,8 @@ export const FreebuffModelSelector: React.FC = () => {
148176
contentMaxWidth,
149177
),
150178
}
151-
}, [contentMaxWidth, deploymentAvailabilityLabel])
179+
}, [contentMaxWidth, deploymentAvailabilityLabel, getQuotaHint])
152180

153-
// "Already committed to this model" — only when the server has us queued
154-
// on it. On the landing screen (status 'none'), nothing is committed yet,
155-
// so picking the focused model is always a real action (first join).
156-
const committedModelId = session?.status === 'queued' ? session.model : null
157-
const rateLimitsByModel =
158-
session && 'rateLimitsByModel' in session
159-
? session.rateLimitsByModel
160-
: undefined
161181
const isJoinable = useCallback(
162182
(modelId: string) => {
163183
if (!isFreebuffModelAvailable(modelId, new Date(now))) return false
@@ -230,19 +250,13 @@ export const FreebuffModelSelector: React.FC = () => {
230250
const isHovered = hoveredId === model.id
231251
const isFocused = focusedId === model.id
232252
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
233-
const rateLimit = rateLimitsByModel?.[model.id]
234-
const isQuotaExhausted =
235-
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
236-
const canJoin = isAvailable && !isQuotaExhausted
253+
const canJoin = isJoinable(model.id)
237254
// Clickable whenever picking would actually do something — i.e.
238255
// anything except re-picking the queue we're already in.
239256
const interactable =
240257
!pending && canJoin && model.id !== committedModelId
241-
const hint = !isAvailable
242-
? 'Closed'
243-
: isQuotaExhausted
244-
? 'Limit used'
245-
: ''
258+
const quotaHint = getQuotaHint(model.id)
259+
const hint = isAvailable ? quotaHint : 'Closed'
246260

247261
// Focused row: green border + arrow indicator + bold name. The name
248262
// itself stays the normal foreground color so it doesn't shout — the
@@ -251,7 +265,7 @@ export const FreebuffModelSelector: React.FC = () => {
251265
const fgColor = canJoin ? theme.foreground : theme.muted
252266
const mutedColor = theme.muted
253267
const warningColor = theme.secondary
254-
const hintColor = theme.secondary
268+
const hintColor = canJoin ? theme.muted : theme.secondary
255269

256270
const borderColor = isFocused
257271
? theme.primary
@@ -303,16 +317,17 @@ export const FreebuffModelSelector: React.FC = () => {
303317
{showInlineWarning && (
304318
<span fg={warningColor}> · {model.warning}</span>
305319
)}
306-
{hint && <span fg={hintColor}> {hint}</span>}
320+
<span fg={hintColor}> {hint}</span>
307321
</text>
308322
{showWrappedDetails && (
309323
<text>
310-
<span> </span>
324+
<span> </span>
311325
{model.availability === 'deployment_hours' && (
312326
<span fg={mutedColor}>{deploymentAvailabilityLabel}</span>
313327
)}
314-
{model.availability === 'deployment_hours' &&
315-
model.warning && <span fg={mutedColor}> · </span>}
328+
{model.availability === 'deployment_hours' && model.warning && (
329+
<span fg={mutedColor}> · </span>
330+
)}
316331
{model.warning && (
317332
<span fg={warningColor}>{model.warning}</span>
318333
)}

cli/src/components/status-bar.tsx

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ const formatSessionRemaining = (ms: number): string => {
6666
return minutes === 0 ? `${hours}h left` : `${hours}h ${minutes}m left`
6767
}
6868

69+
const formatSessionUnits = (units: number): string =>
70+
Number.isInteger(units) ? String(units) : units.toFixed(1)
71+
6972
interface StatusBarProps {
7073
timerStartTime: number | null
7174
isAtBottom: boolean
@@ -131,7 +134,8 @@ export const StatusBar = ({
131134

132135
case 'clipboard':
133136
// Use green color for feedback success messages
134-
const isFeedbackSuccess = statusIndicatorState.message.includes('Feedback sent')
137+
const isFeedbackSuccess =
138+
statusIndicatorState.message.includes('Feedback sent')
135139
return (
136140
<span fg={isFeedbackSuccess ? theme.success : theme.primary}>
137141
{statusIndicatorState.message}
@@ -142,12 +146,7 @@ export const StatusBar = ({
142146
return <span fg={theme.success}>Reconnected</span>
143147

144148
case 'retrying':
145-
return (
146-
<ShimmerText
147-
text="retrying..."
148-
primaryColor={theme.warning}
149-
/>
150-
)
149+
return <ShimmerText text="retrying..." primaryColor={theme.warning} />
151150

152151
case 'connecting':
153152
return <ShimmerText text="connecting..." />
@@ -180,8 +179,17 @@ export const StatusBar = ({
180179
freebuffSession?.status === 'active'
181180
? getFreebuffModel(freebuffSession.model).displayName
182181
: null
182+
const quotaText =
183+
freebuffSession?.status === 'active' && freebuffSession.rateLimit
184+
? `Premium ${formatSessionUnits(freebuffSession.rateLimit.recentCount)}/${freebuffSession.rateLimit.limit} used · `
185+
: freebuffSession?.status === 'active'
186+
? 'Unlimited · '
187+
: ''
183188
return (
184-
<span fg={isUrgent ? theme.warning : theme.secondary}>{modelName ? `${modelName} · ` : ''}{formatSessionRemaining(sessionProgress.remainingMs)}
189+
<span fg={isUrgent ? theme.warning : theme.secondary}>
190+
{modelName ? `${modelName} · ` : ''}
191+
{quotaText}Free session ·{' '}
192+
{formatSessionRemaining(sessionProgress.remainingMs)}
185193
</span>
186194
)
187195
}
@@ -258,12 +266,18 @@ export const StatusBar = ({
258266
}}
259267
>
260268
<text style={{ wrapMode: 'none' }}>{elapsedTimeContent}</text>
261-
{onStop && (statusIndicatorState.kind === 'waiting' || statusIndicatorState.kind === 'streaming') && (
262-
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
263-
)}
264-
{onEndSession && statusIndicatorState.kind === 'idle' && freebuffSession?.status === 'active' && (
265-
<StatusActionButton onClick={onEndSession}>✕ End session</StatusActionButton>
266-
)}
269+
{onStop &&
270+
(statusIndicatorState.kind === 'waiting' ||
271+
statusIndicatorState.kind === 'streaming') && (
272+
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
273+
)}
274+
{onEndSession &&
275+
statusIndicatorState.kind === 'idle' &&
276+
freebuffSession?.status === 'active' && (
277+
<StatusActionButton onClick={onEndSession}>
278+
✕ End session
279+
</StatusActionButton>
280+
)}
267281
{sessionProgress !== null &&
268282
sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS &&
269283
statusIndicatorState.kind !== 'idle' && (

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

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import { useRenderer } from '@opentui/react'
33
import React, { useMemo, useState } from 'react'
44

55
import { Button } from './button'
6-
import {
7-
ChoiceAdBanner,
8-
CHOICE_AD_BANNER_HEIGHT,
9-
} from './choice-ad-banner'
6+
import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner'
107
import { FreebuffModelSelector } from './freebuff-model-selector'
118
import { ShimmerText } from './shimmer-text'
129
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
@@ -59,6 +56,9 @@ const formatRetryAfter = (ms: number): string => {
5956
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
6057
}
6158

59+
const formatSessionUnits = (units: number): string =>
60+
Number.isInteger(units) ? String(units) : units.toFixed(1)
61+
6262
const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
6363
{
6464
anonymous: 'anonymized network',
@@ -263,17 +263,16 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
263263
<span>Elapsed </span>
264264
{formatElapsed(elapsedMs)}
265265
</text>
266-
{/* Per-model session quota (e.g. DeepSeek V4 Pro caps at 5/12h).
267-
Only rendered for rate-limited models so the Minimax queue
268-
stays clutter-free. */}
266+
{/* Premium session quota. Minimax is unlimited, so it has no
267+
rateLimit payload and skips this line. */}
269268
{session.rateLimit && (
270269
<text style={{ fg: theme.muted, alignSelf: 'flex-start' }}>
271-
<span>Sessions </span>
270+
<span>Premium sessions </span>
272271
<span fg={theme.foreground}>
273-
{session.rateLimit.recentCount} /{' '}
272+
{formatSessionUnits(session.rateLimit.recentCount)} /{' '}
274273
{session.rateLimit.limit}
275274
</span>
276-
<span> used in last {session.rateLimit.windowHours}h</span>
275+
<span> used in the last 20 hours</span>
277276
</text>
278277
)}
279278
</box>
@@ -346,8 +345,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
346345
</>
347346
)}
348347

349-
{/* Per-model session quota exhausted (e.g. 5+ DeepSeek sessions in
350-
the last 12h). Terminal for this run — the user can exit and come
348+
{/* Shared premium-session quota exhausted. Terminal for this run —
349+
the user can exit and come
351350
back once the oldest session in the window rolls off. */}
352351
{session?.status === 'rate_limited' && (
353352
<>
@@ -357,10 +356,9 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
357356
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
358357
You've used{' '}
359358
<span fg={theme.foreground}>
360-
{session.recentCount} of {session.limit}
359+
{formatSessionUnits(session.recentCount)} of {session.limit}
361360
</span>{' '}
362-
hour-long sessions on {session.model} in the last{' '}
363-
{session.windowHours}h. Try again in{' '}
361+
premium sessions in the last 20 hours. Try again in{' '}
364362
<span fg={theme.foreground}>
365363
{formatRetryAfter(session.retryAfterMs)}
366364
</span>

common/src/constants/freebuff-models.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export const FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID = 'deepseek/deepseek-v4-pro'
3030
export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
3131
export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6'
3232
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
33+
export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5
34+
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 20
3335
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
3436
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'
3537

@@ -78,7 +80,7 @@ export const FREEBUFF_MODELS = [
7880
{
7981
id: FREEBUFF_MINIMAX_MODEL_ID,
8082
displayName: 'MiniMax M2.7',
81-
tagline: 'Fastest',
83+
tagline: 'Fastest, unlimited',
8284
availability: 'always',
8385
},
8486
] as const satisfies readonly FreebuffModelOption[]
@@ -92,6 +94,12 @@ export const LEGACY_FREEBUFF_MODELS = [
9294
},
9395
] as const satisfies readonly FreebuffModelOption[]
9496

97+
export const FREEBUFF_PREMIUM_MODEL_IDS = [
98+
FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID,
99+
FREEBUFF_KIMI_MODEL_ID,
100+
FREEBUFF_GLM_MODEL_ID,
101+
] as const
102+
95103
export const SUPPORTED_FREEBUFF_MODELS = [
96104
...FREEBUFF_MODELS,
97105
...LEGACY_FREEBUFF_MODELS,
@@ -100,6 +108,7 @@ export const SUPPORTED_FREEBUFF_MODELS = [
100108
export type FreebuffModelId = (typeof FREEBUFF_MODELS)[number]['id']
101109
export type SupportedFreebuffModelId =
102110
(typeof SUPPORTED_FREEBUFF_MODELS)[number]['id']
111+
export type FreebuffPremiumModelId = (typeof FREEBUFF_PREMIUM_MODEL_IDS)[number]
103112

104113
/** What new freebuff users see selected in the picker. DeepSeek is the
105114
* smartest of the free options; the picker surfaces its data-collection
@@ -136,6 +145,13 @@ export function isSupportedFreebuffModelId(
136145
return SUPPORTED_FREEBUFF_MODELS.some((m) => m.id === id)
137146
}
138147

148+
export function isFreebuffPremiumModelId(
149+
id: string | null | undefined,
150+
): id is FreebuffPremiumModelId {
151+
if (!id) return false
152+
return FREEBUFF_PREMIUM_MODEL_IDS.some((modelId) => modelId === id)
153+
}
154+
139155
export function resolveSupportedFreebuffModel(
140156
id: string | null | undefined,
141157
): SupportedFreebuffModelId {

0 commit comments

Comments
 (0)