Skip to content

Commit ac1f6be

Browse files
jahoomaclaude
andcommitted
Add model selector to freebuff with per-model queues
Lets users pick between glm-5.1 and minimax-m2.7 from the waiting room. Each model has its own FIFO queue so wait times scale independently. Selection persists locally; switching mid-queue moves you to the back of the new queue and switching mid-session is blocked. Adds /queue command to end the current session and rejoin (allowing model switch). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f5bbd99 commit ac1f6be

24 files changed

Lines changed: 3899 additions & 90 deletions

File tree

cli/src/commands/command-registry.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { safeOpen } from '../utils/open-url'
44

55
import { handleAdsEnable, handleAdsDisable } from './ads'
66
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
7+
import { endAndRejoinFreebuffSession } from '../hooks/use-freebuff-session'
78
import { useThemeStore } from '../hooks/use-theme'
89
import { handleHelpCommand } from './help'
910
import { handleImageCommand } from './image'
@@ -611,6 +612,26 @@ const ALL_COMMANDS: CommandDefinition[] = [
611612
clearInput(params)
612613
},
613614
}),
615+
// /queue (freebuff-only) — end the active session early and re-queue. The
616+
// hook flips status from 'active' → 'queued', which unmounts <Chat> and
617+
// mounts <WaitingRoomScreen>, where the user can pick a different model.
618+
defineCommand({
619+
name: 'queue',
620+
aliases: ['rejoin', 'switch'],
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: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 { 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 { useTheme } from '../hooks/use-theme'
11+
12+
import type { KeyEvent } from '@opentui/core'
13+
14+
interface FreebuffModelSelectorProps {
15+
/** Disables interaction while a switch / refresh is mid-flight so the user
16+
* can't queue up a second switch and double-bounce themselves to the back
17+
* of yet another queue. */
18+
disabled?: boolean
19+
}
20+
21+
/**
22+
* Lets the user pick which model's queue they're in. Tapping (or pressing the
23+
* row's number key) on a different model triggers a re-POST: the server moves
24+
* them to the back of the new model's queue.
25+
*/
26+
export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
27+
disabled = false,
28+
}) => {
29+
const theme = useTheme()
30+
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
31+
const [pending, setPending] = useState<string | null>(null)
32+
const [hoveredId, setHoveredId] = useState<string | null>(null)
33+
34+
const pick = useCallback(
35+
(modelId: string) => {
36+
if (disabled || pending) return
37+
if (modelId === selectedModel) return
38+
setPending(modelId)
39+
switchFreebuffModel(modelId).finally(() => setPending(null))
40+
},
41+
[disabled, pending, selectedModel],
42+
)
43+
44+
// Number-key shortcuts (1-9) so keyboard-only users can switch without
45+
// hunting for a clickable region.
46+
useKeyboard(
47+
useCallback(
48+
(key: KeyEvent) => {
49+
if (disabled || pending) return
50+
const digit = parseInt(key.name ?? '', 10)
51+
if (!Number.isFinite(digit) || digit < 1 || digit > FREEBUFF_MODELS.length) {
52+
return
53+
}
54+
const target = FREEBUFF_MODELS[digit - 1]
55+
if (target && target.id !== selectedModel) {
56+
key.preventDefault?.()
57+
pick(target.id)
58+
}
59+
},
60+
[disabled, pending, pick, selectedModel],
61+
),
62+
)
63+
64+
return (
65+
<box
66+
style={{
67+
flexDirection: 'column',
68+
alignItems: 'flex-start',
69+
gap: 0,
70+
}}
71+
>
72+
<text style={{ fg: theme.muted, marginBottom: 1 }}>
73+
Model — tap or press 1-{FREEBUFF_MODELS.length} to switch
74+
</text>
75+
{FREEBUFF_MODELS.map((model, idx) => {
76+
const isSelected = model.id === selectedModel
77+
const isPending = pending === model.id
78+
const isHovered = hoveredId === model.id
79+
const indicator = isSelected ? '●' : '○'
80+
const indicatorColor = isSelected ? theme.primary : theme.muted
81+
const labelColor = isSelected ? theme.foreground : theme.muted
82+
const interactable = !disabled && !pending && !isSelected
83+
return (
84+
<Button
85+
key={model.id}
86+
onClick={() => pick(model.id)}
87+
onMouseOver={() => interactable && setHoveredId(model.id)}
88+
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
89+
style={{ paddingLeft: 0, paddingRight: 1 }}
90+
>
91+
<text>
92+
<span fg={indicatorColor}>{indicator} </span>
93+
<span fg={theme.muted}>{idx + 1}. </span>
94+
<span
95+
fg={labelColor}
96+
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
97+
>
98+
{model.displayName}
99+
</span>
100+
<span fg={theme.muted}> {model.tagline}</span>
101+
{isPending && <span fg={theme.muted}> switching…</span>}
102+
{isHovered && interactable && !isPending && (
103+
<span fg={theme.muted}></span>
104+
)}
105+
</text>
106+
</Button>
107+
)
108+
})}
109+
</box>
110+
)
111+
}

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: 7 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+
'queue',
5051
])
5152

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

cli/src/hooks/use-freebuff-session.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { env } from '@codebuff/common/env'
22
import { useEffect } from 'react'
33

4+
import {
5+
getSelectedFreebuffModel,
6+
useFreebuffModelStore,
7+
} from '../state/freebuff-model-store'
48
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
59
import { getAuthTokenDetails } from '../utils/auth'
610
import { IS_FREEBUFF } from '../utils/constants'
@@ -16,6 +20,11 @@ const POLL_INTERVAL_ERROR_MS = 10_000
1620
* account has rotated the id and respond with `{ status: 'superseded' }`. */
1721
const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
1822

23+
/** Header sent on POST/GET telling the server which model's queue we want.
24+
* POST uses it to (re-)join that model's queue; GET uses it only for the
25+
* rare GET-before-POST edge where there's no row yet. */
26+
const FREEBUFF_MODEL_HEADER = 'x-freebuff-model'
27+
1928
/** Play the terminal bell so users get an audible notification on admission. */
2029
const playAdmissionSound = () => {
2130
try {
@@ -33,12 +42,15 @@ const sessionEndpoint = (): string => {
3342
async function callSession(
3443
method: 'POST' | 'GET' | 'DELETE',
3544
token: string,
36-
opts: { instanceId?: string; signal?: AbortSignal } = {},
45+
opts: { instanceId?: string; model?: string; signal?: AbortSignal } = {},
3746
): Promise<FreebuffSessionResponse> {
3847
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
3948
if (method === 'GET' && opts.instanceId) {
4049
headers[FREEBUFF_INSTANCE_HEADER] = opts.instanceId
4150
}
51+
if ((method === 'POST' || method === 'GET') && opts.model) {
52+
headers[FREEBUFF_MODEL_HEADER] = opts.model
53+
}
4254
const resp = await fetch(sessionEndpoint(), {
4355
method,
4456
headers,
@@ -64,6 +76,17 @@ async function callSession(
6476
return body
6577
}
6678
}
79+
// 409 from POST means the user picked a different model than their active
80+
// session is bound to. Surface as a non-throw `model_locked` so the UI can
81+
// show a confirmation prompt (DELETE then re-POST to switch).
82+
if (resp.status === 409 && method === 'POST') {
83+
const body = (await resp.json().catch(() => null)) as
84+
| FreebuffSessionResponse
85+
| null
86+
if (body && body.status === 'model_locked') {
87+
return body
88+
}
89+
}
6790
if (!resp.ok) {
6891
const text = await resp.text().catch(() => '')
6992
throw new Error(
@@ -95,6 +118,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
95118
case 'disabled':
96119
case 'superseded':
97120
case 'country_blocked':
121+
case 'model_locked':
98122
return null
99123
}
100124
}
@@ -145,6 +169,39 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {})
145169
await controller?.refresh()
146170
}
147171

172+
/**
173+
* User picked a different model in the waiting room. Persist the choice and
174+
* re-POST so the server moves them to the back of the new model's queue. If
175+
* the user has an active session bound to a different model, the server
176+
* responds with `model_locked` and the UI prompts them to end first.
177+
*/
178+
export async function switchFreebuffModel(model: string): Promise<void> {
179+
if (!IS_FREEBUFF) return
180+
const { setSelectedModel } = useFreebuffModelStore.getState()
181+
setSelectedModel(model)
182+
await controller?.refresh()
183+
}
184+
185+
/**
186+
* End the current session and immediately rejoin the queue. Used by the
187+
* "switch model" confirmation flow when the server returned `model_locked`,
188+
* and by any UI that lets the user exit an active session early.
189+
*/
190+
export async function endAndRejoinFreebuffSession(): Promise<void> {
191+
if (!IS_FREEBUFF) return
192+
const { token } = getAuthTokenDetails()
193+
if (!token) return
194+
try {
195+
await callSession('DELETE', token)
196+
} catch {
197+
// Best-effort — even if DELETE fails the re-POST below will eventually
198+
// succeed once the server-side sweep catches up.
199+
}
200+
const { useChatStore } = await import('../state/chat-store')
201+
useChatStore.getState().reset()
202+
await controller?.refresh()
203+
}
204+
148205
export function markFreebuffSessionSuperseded(): void {
149206
if (!IS_FREEBUFF) return
150207
controller?.abort()
@@ -250,10 +307,12 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
250307
// re-POST out from under an in-flight agent.
251308
const method: 'POST' | 'GET' = hasPosted ? 'GET' : 'POST'
252309
const instanceId = getFreebuffInstanceId()
310+
const model = getSelectedFreebuffModel()
253311
try {
254312
const next = await callSession(method, token, {
255313
signal: abortController.signal,
256314
instanceId,
315+
model,
257316
})
258317
if (cancelled) return
259318
hasPosted = true
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {
2+
DEFAULT_FREEBUFF_MODEL_ID,
3+
resolveFreebuffModel,
4+
} from '@codebuff/common/constants/freebuff-models'
5+
import { create } from 'zustand'
6+
7+
import {
8+
loadFreebuffModelPreference,
9+
saveFreebuffModelPreference,
10+
} from '../utils/settings'
11+
12+
/**
13+
* Holds the user's currently-selected freebuff model. Initialized from the
14+
* persisted settings file so freebuff defaults to whatever model the user
15+
* last picked. Writing through `setSelectedModel` also persists to disk so
16+
* the next launch picks it up without an explicit save call.
17+
*
18+
* Components in the waiting room read this to highlight the current row in
19+
* the model picker; the session hook reads it to decide which queue to join.
20+
*/
21+
interface FreebuffModelStore {
22+
selectedModel: string
23+
setSelectedModel: (model: string) => void
24+
}
25+
26+
export const useFreebuffModelStore = create<FreebuffModelStore>((set) => ({
27+
selectedModel: resolveFreebuffModel(
28+
loadFreebuffModelPreference() ?? DEFAULT_FREEBUFF_MODEL_ID,
29+
),
30+
setSelectedModel: (model) => {
31+
const resolved = resolveFreebuffModel(model)
32+
saveFreebuffModelPreference(resolved)
33+
set({ selectedModel: resolved })
34+
},
35+
}))
36+
37+
/** Imperative read for non-React callers (the session hook's tick loop and
38+
* the chat-completions metadata builder). */
39+
export function getSelectedFreebuffModel(): string {
40+
return useFreebuffModelStore.getState().selectedModel
41+
}

cli/src/utils/local-agent-registry.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,23 @@ import { loadLocalAgents as sdkLoadLocalAgents, loadMCPConfigSync } from '@codeb
77

88
import type { MCPConfig } from '@codebuff/common/types/mcp'
99

10+
import { getSelectedFreebuffModel } from '../state/freebuff-model-store'
1011
import { getProjectRoot } from '../project-files'
11-
import { AGENT_MODE_TO_ID, type AgentMode } from './constants'
12+
import { AGENT_MODE_TO_ID, IS_FREEBUFF, type AgentMode } from './constants'
1213
import { logger } from './logger'
1314
import * as bundledAgentsModule from '../agents/bundled-agents.generated'
1415

16+
/** Agents whose hardcoded model gets swapped out for the user's currently
17+
* selected freebuff model. Each entry must also be allowlisted under the
18+
* matching id in `FREE_MODE_AGENT_MODELS` (server-side check) for both
19+
* glm-5.1 and minimax-m2.7 — otherwise the chat-completions endpoint will
20+
* reject the request with `free_mode_invalid_agent_model`. */
21+
const FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS = new Set([
22+
'base2-free',
23+
'editor-lite',
24+
'code-reviewer-lite',
25+
])
26+
1527
import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition'
1628

1729
// ============================================================================
@@ -354,6 +366,20 @@ export const loadAgentDefinitions = (): AgentDefinition[] => {
354366
}
355367
}
356368

369+
// Override the model of free-mode agents to match the user's pick from the
370+
// freebuff waiting room. Bundled definitions hardcode glm-5.1; we swap in
371+
// whatever the user chose so the chat-completions request body carries the
372+
// matching model and the server-side session gate doesn't reject it as a
373+
// model mismatch.
374+
if (IS_FREEBUFF) {
375+
const selectedModel = getSelectedFreebuffModel()
376+
for (const def of definitions) {
377+
if (FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS.has(def.id)) {
378+
def.model = selectedModel
379+
}
380+
}
381+
}
382+
357383
return definitions
358384
}
359385

0 commit comments

Comments
 (0)