Skip to content

Commit be87083

Browse files
jahoomaclaude
andauthored
Add model selector to freebuff with per-model queues (#524)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f5bbd99 commit be87083

29 files changed

Lines changed: 4214 additions & 227 deletions

cli/src/commands/command-registry.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { CLAUDE_OAUTH_ENABLED } from '@codebuff/common/constants/claude-oauth'
33
import { safeOpen } from '../utils/open-url'
44

55
import { handleAdsEnable, handleAdsDisable } from './ads'
6-
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
7-
import { useThemeStore } from '../hooks/use-theme'
86
import { handleHelpCommand } from './help'
97
import { handleImageCommand } from './image'
108
import { handleInitializationFlowLocally } from './init'
9+
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
1110
import { runBashCommand } from './router'
1211
import { handleUsageCommand } from './usage'
12+
import { endAndRejoinFreebuffSession } from '../hooks/use-freebuff-session'
13+
import { useThemeStore } from '../hooks/use-theme'
1314
import { WEBSITE_URL } from '../login/constants'
1415
import { useChatStore } from '../state/chat-store'
1516
import { useFeedbackStore } from '../state/feedback-store'
@@ -178,6 +179,7 @@ const FREEBUFF_REMOVED_COMMANDS = new Set([
178179
const FREEBUFF_ONLY_COMMANDS = new Set([
179180
'connect',
180181
'plan',
182+
'end-session',
181183
])
182184

183185
const ALL_COMMANDS: CommandDefinition[] = [
@@ -611,6 +613,25 @@ const ALL_COMMANDS: CommandDefinition[] = [
611613
clearInput(params)
612614
},
613615
}),
616+
// /end-session (freebuff-only) — end the active session early and re-queue. The
617+
// hook flips status from 'active' → 'queued', which unmounts <Chat> and
618+
// mounts <WaitingRoomScreen>, where the user can pick a different model.
619+
defineCommand({
620+
name: 'end-session',
621+
handler: (params) => {
622+
params.setMessages((prev) => [
623+
...prev,
624+
getUserMessage(params.inputValue.trim()),
625+
getSystemMessage('Ending session and returning to the waiting room…'),
626+
])
627+
params.saveToHistory(params.inputValue.trim())
628+
clearInput(params)
629+
endAndRejoinFreebuffSession().catch(() => {
630+
// The hook surfaces poll errors via the session store; nothing to do
631+
// here beyond letting the chat history reflect the attempt.
632+
})
633+
},
634+
}),
614635
]
615636

616637
export const COMMAND_REGISTRY: CommandDefinition[] = IS_FREEBUFF
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
3+
import React, { useCallback, useMemo, useState } from 'react'
4+
5+
import { Button } from './button'
6+
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
7+
8+
import { switchFreebuffModel } from '../hooks/use-freebuff-session'
9+
import { useFreebuffModelStore } from '../state/freebuff-model-store'
10+
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
11+
import { useTheme } from '../hooks/use-theme'
12+
13+
import type { KeyEvent } from '@opentui/core'
14+
15+
/**
16+
* Lets the user pick which model's queue they're in. Tapping (or pressing the
17+
* row's number key) on a different model triggers a re-POST: the server moves
18+
* them to the back of the new model's queue.
19+
*
20+
* Each row shows a live "N ahead" count sourced from the server's
21+
* `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
22+
* "12 ahead") rather than a blind preference toggle.
23+
*/
24+
export const FreebuffModelSelector: React.FC = () => {
25+
const theme = useTheme()
26+
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
27+
const session = useFreebuffSessionStore((s) => s.session)
28+
const [pending, setPending] = useState<string | null>(null)
29+
const [hoveredId, setHoveredId] = useState<string | null>(null)
30+
31+
// For the user's current queue, "ahead" is `position - 1` (themselves don't
32+
// count). For every other queue, switching would land them at the back, so
33+
// it's that queue's full depth. Null before the first queued snapshot so
34+
// the UI doesn't flash misleading zeros.
35+
const aheadByModel = useMemo<Record<string, number> | null>(() => {
36+
if (session?.status !== 'queued') return null
37+
const depths = session.queueDepthByModel ?? {}
38+
const out: Record<string, number> = {}
39+
for (const { id } of FREEBUFF_MODELS) {
40+
out[id] =
41+
id === session.model ? Math.max(0, session.position - 1) : depths[id] ?? 0
42+
}
43+
return out
44+
}, [session])
45+
46+
const pick = useCallback(
47+
(modelId: string) => {
48+
if (pending) return
49+
if (modelId === selectedModel) return
50+
setPending(modelId)
51+
switchFreebuffModel(modelId).finally(() => setPending(null))
52+
},
53+
[pending, selectedModel],
54+
)
55+
56+
// Number-key shortcuts (1-9) so keyboard-only users can switch without
57+
// hunting for a clickable region.
58+
useKeyboard(
59+
useCallback(
60+
(key: KeyEvent) => {
61+
if (pending) return
62+
const name = key.name ?? ''
63+
if (!/^[1-9]$/.test(name)) return
64+
const digit = Number(name)
65+
if (digit > FREEBUFF_MODELS.length) return
66+
const target = FREEBUFF_MODELS[digit - 1]
67+
if (target && target.id !== selectedModel) {
68+
key.preventDefault?.()
69+
pick(target.id)
70+
}
71+
},
72+
[pending, pick, selectedModel],
73+
),
74+
)
75+
76+
return (
77+
<box
78+
style={{
79+
flexDirection: 'column',
80+
alignItems: 'flex-start',
81+
gap: 0,
82+
}}
83+
>
84+
<text style={{ fg: theme.muted, marginBottom: 1 }}>
85+
Model — tap or press 1-{FREEBUFF_MODELS.length} to switch
86+
</text>
87+
{FREEBUFF_MODELS.map((model, idx) => {
88+
const isSelected = model.id === selectedModel
89+
const isPending = pending === model.id
90+
const isHovered = hoveredId === model.id
91+
const indicator = isSelected ? '●' : '○'
92+
const indicatorColor = isSelected ? theme.primary : theme.muted
93+
const labelColor = isSelected ? theme.foreground : theme.muted
94+
const interactable = !pending && !isSelected
95+
const ahead = aheadByModel?.[model.id]
96+
const hint =
97+
ahead === undefined
98+
? model.tagline
99+
: ahead === 0
100+
? 'No wait'
101+
: `${ahead} ahead`
102+
return (
103+
<Button
104+
key={model.id}
105+
onClick={() => pick(model.id)}
106+
onMouseOver={() => interactable && setHoveredId(model.id)}
107+
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
108+
style={{ paddingLeft: 0, paddingRight: 1 }}
109+
>
110+
<text>
111+
<span fg={indicatorColor}>{indicator} </span>
112+
<span fg={theme.muted}>{idx + 1}. </span>
113+
<span
114+
fg={labelColor}
115+
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
116+
>
117+
{model.displayName}
118+
</span>
119+
<span fg={theme.muted}> {hint}</span>
120+
{isPending && <span fg={theme.muted}> switching…</span>}
121+
{isHovered && interactable && !isPending && (
122+
<span fg={theme.muted}></span>
123+
)}
124+
</text>
125+
</Button>
126+
)
127+
})}
128+
</box>
129+
)
130+
}

cli/src/components/status-bar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getFreebuffModel } from '@codebuff/common/constants/freebuff-models'
12
import { TextAttributes } from '@opentui/core'
23
import React, { useEffect, useState } from 'react'
34

@@ -143,9 +144,14 @@ export const StatusBar = ({
143144
case 'idle':
144145
if (sessionProgress !== null) {
145146
const isUrgent = sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS
147+
const modelName =
148+
freebuffSession?.status === 'active'
149+
? getFreebuffModel(freebuffSession.model).displayName
150+
: null
146151
return (
147152
<span fg={isUrgent ? theme.warning : theme.secondary}>
148-
Free session · {formatSessionRemaining(sessionProgress.remainingMs)}
153+
{modelName ? `${modelName} · ` : ''}Free session ·{' '}
154+
{formatSessionRemaining(sessionProgress.remainingMs)}
149155
</span>
150156
)
151157
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react'
55
import { AdBanner } from './ad-banner'
66
import { Button } from './button'
77
import { ChoiceAdBanner } from './choice-ad-banner'
8+
import { FreebuffModelSelector } from './freebuff-model-selector'
89
import { ShimmerText } from './shimmer-text'
910
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
1011
import { useGravityAd } from '../hooks/use-gravity-ad'
@@ -200,6 +201,10 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
200201
{formatElapsed(elapsedMs)}
201202
</text>
202203
</box>
204+
205+
<box style={{ marginTop: 1 }}>
206+
<FreebuffModelSelector />
207+
</box>
203208
</>
204209
)}
205210

cli/src/data/slash-commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const FREEBUFF_REMOVED_COMMAND_IDS = new Set([
4747
const FREEBUFF_ONLY_COMMAND_IDS = new Set([
4848
'connect',
4949
'plan',
50+
'end-session',
5051
])
5152

5253
const ALL_SLASH_COMMANDS: SlashCommand[] = [
@@ -184,6 +185,11 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
184185
label: 'theme:toggle',
185186
description: 'Toggle between light and dark mode',
186187
},
188+
{
189+
id: 'end-session',
190+
label: 'end-session',
191+
description: 'End your free session and return to the waiting room (lets you switch model)',
192+
},
187193
{
188194
id: 'logout',
189195
label: 'logout',

0 commit comments

Comments
 (0)