Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 52 additions & 37 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
DEFAULT_FREEBUFF_MODEL_ID,
FALLBACK_FREEBUFF_MODEL_ID,
FREEBUFF_MODELS,
FREEBUFF_PREMIUM_SESSION_LIMIT,
getFreebuffDeploymentAvailabilityLabel,
isFreebuffModelAvailable,
isFreebuffPremiumModelId,
} from '@codebuff/common/constants/freebuff-models'

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

function formatSessionUnits(units: number): string {
return Number.isInteger(units) ? String(units) : units.toFixed(1)
}

/**
* Dual-purpose model picker:
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
Expand All @@ -45,11 +51,6 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
* Always stacked vertically. On narrow terminals where the longest one-line
* label wouldn't fit, the secondary details (warning / deployment hours)
* spill onto an indented second line under the name.
*
* No queue-position hint: traffic doesn't reach the threshold where a wait
* would form, so showing "N in line" everywhere just adds noise (and width).
* The picker still surfaces "Closed" (outside deployment hours) and "Limit
* used" (per-user quota) inline since those gate the actual click.
*/
export const FreebuffModelSelector: React.FC = () => {
const theme = useTheme()
Expand Down Expand Up @@ -91,15 +92,30 @@ export const FreebuffModelSelector: React.FC = () => {
}
}, [now, selectedModel, session, setSelectedModel])

const committedModelId = session?.status === 'queued' ? session.model : null
const rateLimitsByModel =
session && 'rateLimitsByModel' in session
? session.rateLimitsByModel
: undefined

const getQuotaHint = useCallback(
(modelId: string): string => {
const rateLimit = rateLimitsByModel?.[modelId]
if (rateLimit) {
return `${formatSessionUnits(rateLimit.recentCount)}/${rateLimit.limit} used`
}
return isFreebuffPremiumModelId(modelId)
? `0/${FREEBUFF_PREMIUM_SESSION_LIMIT} used`
: 'Unlimited'
},
[rateLimitsByModel],
)

const BUTTON_CHROME = 4 // 2 border + 2 padding

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

const hintLen = (model: FreebuffModelOption): number =>
Math.max(getQuotaHint(model.id).length, 'Closed'.length)

const oneLineLen = (model: FreebuffModelOption): number => {
const inlineDetails = detailsTextLen(model)
return (
2 /* indicator + space */ +
model.displayName.length +
3 /* " · " */ +
model.tagline.length +
(inlineDetails > 0 ? 3 + inlineDetails : 0)
(inlineDetails > 0 ? 3 + inlineDetails : 0) +
1 /* space before hint */ +
hintLen(model)
)
}

const labelLineLen = (model: FreebuffModelOption): number =>
2 + model.displayName.length + 3 + model.tagline.length
2 +
model.displayName.length +
3 +
model.tagline.length +
1 +
hintLen(model)

const detailsLineLen = (model: FreebuffModelOption): number => {
const len = detailsTextLen(model)
Expand All @@ -148,16 +176,8 @@ export const FreebuffModelSelector: React.FC = () => {
contentMaxWidth,
),
}
}, [contentMaxWidth, deploymentAvailabilityLabel])
}, [contentMaxWidth, deploymentAvailabilityLabel, getQuotaHint])

// "Already committed to this model" — only when the server has us queued
// on it. On the landing screen (status 'none'), nothing is committed yet,
// so picking the focused model is always a real action (first join).
const committedModelId = session?.status === 'queued' ? session.model : null
const rateLimitsByModel =
session && 'rateLimitsByModel' in session
? session.rateLimitsByModel
: undefined
const isJoinable = useCallback(
(modelId: string) => {
if (!isFreebuffModelAvailable(modelId, new Date(now))) return false
Expand Down Expand Up @@ -230,19 +250,13 @@ export const FreebuffModelSelector: React.FC = () => {
const isHovered = hoveredId === model.id
const isFocused = focusedId === model.id
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
const rateLimit = rateLimitsByModel?.[model.id]
const isQuotaExhausted =
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
const canJoin = isAvailable && !isQuotaExhausted
const canJoin = isJoinable(model.id)
// Clickable whenever picking would actually do something — i.e.
// anything except re-picking the queue we're already in.
const interactable =
!pending && canJoin && model.id !== committedModelId
const hint = !isAvailable
? 'Closed'
: isQuotaExhausted
? 'Limit used'
: ''
const quotaHint = getQuotaHint(model.id)
const hint = isAvailable ? quotaHint : 'Closed'

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

const borderColor = isFocused
? theme.primary
Expand Down Expand Up @@ -303,16 +317,17 @@ export const FreebuffModelSelector: React.FC = () => {
{showInlineWarning && (
<span fg={warningColor}> · {model.warning}</span>
)}
{hint && <span fg={hintColor}> {hint}</span>}
<span fg={hintColor}> {hint}</span>
</text>
{showWrappedDetails && (
<text>
<span> </span>
<span> </span>
{model.availability === 'deployment_hours' && (
<span fg={mutedColor}>{deploymentAvailabilityLabel}</span>
)}
{model.availability === 'deployment_hours' &&
model.warning && <span fg={mutedColor}> · </span>}
{model.availability === 'deployment_hours' && model.warning && (
<span fg={mutedColor}> · </span>
)}
{model.warning && (
<span fg={warningColor}>{model.warning}</span>
)}
Expand Down
42 changes: 28 additions & 14 deletions cli/src/components/status-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const formatSessionRemaining = (ms: number): string => {
return minutes === 0 ? `${hours}h left` : `${hours}h ${minutes}m left`
}

const formatSessionUnits = (units: number): string =>
Number.isInteger(units) ? String(units) : units.toFixed(1)

interface StatusBarProps {
timerStartTime: number | null
isAtBottom: boolean
Expand Down Expand Up @@ -131,7 +134,8 @@ export const StatusBar = ({

case 'clipboard':
// Use green color for feedback success messages
const isFeedbackSuccess = statusIndicatorState.message.includes('Feedback sent')
const isFeedbackSuccess =
statusIndicatorState.message.includes('Feedback sent')
return (
<span fg={isFeedbackSuccess ? theme.success : theme.primary}>
{statusIndicatorState.message}
Expand All @@ -142,12 +146,7 @@ export const StatusBar = ({
return <span fg={theme.success}>Reconnected</span>

case 'retrying':
return (
<ShimmerText
text="retrying..."
primaryColor={theme.warning}
/>
)
return <ShimmerText text="retrying..." primaryColor={theme.warning} />

case 'connecting':
return <ShimmerText text="connecting..." />
Expand Down Expand Up @@ -180,8 +179,17 @@ export const StatusBar = ({
freebuffSession?.status === 'active'
? getFreebuffModel(freebuffSession.model).displayName
: null
const quotaText =
freebuffSession?.status === 'active' && freebuffSession.rateLimit
? `Premium ${formatSessionUnits(freebuffSession.rateLimit.recentCount)}/${freebuffSession.rateLimit.limit} used · `
: freebuffSession?.status === 'active'
? 'Unlimited · '
: ''
return (
<span fg={isUrgent ? theme.warning : theme.secondary}>{modelName ? `${modelName} · ` : ''}{formatSessionRemaining(sessionProgress.remainingMs)}
<span fg={isUrgent ? theme.warning : theme.secondary}>
{modelName ? `${modelName} · ` : ''}
{quotaText}Free session ·{' '}
{formatSessionRemaining(sessionProgress.remainingMs)}
</span>
)
}
Expand Down Expand Up @@ -258,12 +266,18 @@ export const StatusBar = ({
}}
>
<text style={{ wrapMode: 'none' }}>{elapsedTimeContent}</text>
{onStop && (statusIndicatorState.kind === 'waiting' || statusIndicatorState.kind === 'streaming') && (
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
)}
{onEndSession && statusIndicatorState.kind === 'idle' && freebuffSession?.status === 'active' && (
<StatusActionButton onClick={onEndSession}>✕ End session</StatusActionButton>
)}
{onStop &&
(statusIndicatorState.kind === 'waiting' ||
statusIndicatorState.kind === 'streaming') && (
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
)}
{onEndSession &&
statusIndicatorState.kind === 'idle' &&
freebuffSession?.status === 'active' && (
<StatusActionButton onClick={onEndSession}>
✕ End session
</StatusActionButton>
)}
{sessionProgress !== null &&
sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS &&
statusIndicatorState.kind !== 'idle' && (
Expand Down
28 changes: 13 additions & 15 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { useRenderer } from '@opentui/react'
import React, { useMemo, useState } from 'react'

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

const formatSessionUnits = (units: number): string =>
Number.isInteger(units) ? String(units) : units.toFixed(1)

const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
{
anonymous: 'anonymized network',
Expand Down Expand Up @@ -263,17 +263,16 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
<span>Elapsed </span>
{formatElapsed(elapsedMs)}
</text>
{/* Per-model session quota (e.g. DeepSeek V4 Pro caps at 5/12h).
Only rendered for rate-limited models so the Minimax queue
stays clutter-free. */}
{/* Premium session quota. Minimax is unlimited, so it has no
rateLimit payload and skips this line. */}
{session.rateLimit && (
<text style={{ fg: theme.muted, alignSelf: 'flex-start' }}>
<span>Sessions </span>
<span>Premium sessions </span>
<span fg={theme.foreground}>
{session.rateLimit.recentCount} /{' '}
{formatSessionUnits(session.rateLimit.recentCount)} /{' '}
{session.rateLimit.limit}
</span>
<span> used in last {session.rateLimit.windowHours}h</span>
<span> used in the last 20 hours</span>
</text>
)}
</box>
Expand Down Expand Up @@ -346,8 +345,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
</>
)}

{/* Per-model session quota exhausted (e.g. 5+ DeepSeek sessions in
the last 12h). Terminal for this run — the user can exit and come
{/* Shared premium-session quota exhausted. Terminal for this run —
the user can exit and come
back once the oldest session in the window rolls off. */}
{session?.status === 'rate_limited' && (
<>
Expand All @@ -357,10 +356,9 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
You've used{' '}
<span fg={theme.foreground}>
{session.recentCount} of {session.limit}
{formatSessionUnits(session.recentCount)} of {session.limit}
</span>{' '}
hour-long sessions on {session.model} in the last{' '}
{session.windowHours}h. Try again in{' '}
premium sessions in the last 20 hours. Try again in{' '}
<span fg={theme.foreground}>
{formatRetryAfter(session.retryAfterMs)}
</span>
Expand Down
18 changes: 17 additions & 1 deletion common/src/constants/freebuff-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID = 'deepseek/deepseek-v4-pro'
export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6'
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 20
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'

Expand Down Expand Up @@ -78,7 +80,7 @@ export const FREEBUFF_MODELS = [
{
id: FREEBUFF_MINIMAX_MODEL_ID,
displayName: 'MiniMax M2.7',
tagline: 'Fastest',
tagline: 'Fastest, unlimited',
availability: 'always',
},
] as const satisfies readonly FreebuffModelOption[]
Expand All @@ -92,6 +94,12 @@ export const LEGACY_FREEBUFF_MODELS = [
},
] as const satisfies readonly FreebuffModelOption[]

export const FREEBUFF_PREMIUM_MODEL_IDS = [
FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID,
FREEBUFF_KIMI_MODEL_ID,
FREEBUFF_GLM_MODEL_ID,
] as const

export const SUPPORTED_FREEBUFF_MODELS = [
...FREEBUFF_MODELS,
...LEGACY_FREEBUFF_MODELS,
Expand All @@ -100,6 +108,7 @@ export const SUPPORTED_FREEBUFF_MODELS = [
export type FreebuffModelId = (typeof FREEBUFF_MODELS)[number]['id']
export type SupportedFreebuffModelId =
(typeof SUPPORTED_FREEBUFF_MODELS)[number]['id']
export type FreebuffPremiumModelId = (typeof FREEBUFF_PREMIUM_MODEL_IDS)[number]

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

export function isFreebuffPremiumModelId(
id: string | null | undefined,
): id is FreebuffPremiumModelId {
if (!id) return false
return FREEBUFF_PREMIUM_MODEL_IDS.some((modelId) => modelId === id)
}

export function resolveSupportedFreebuffModel(
id: string | null | undefined,
): SupportedFreebuffModelId {
Expand Down
Loading
Loading