Skip to content
Merged
21 changes: 21 additions & 0 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { safeOpen } from '../utils/open-url'

import { handleAdsEnable, handleAdsDisable } from './ads'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import { endAndRejoinFreebuffSession } from '../hooks/use-freebuff-session'
import { useThemeStore } from '../hooks/use-theme'
import { handleHelpCommand } from './help'
import { handleImageCommand } from './image'
Expand Down Expand Up @@ -611,6 +612,26 @@ const ALL_COMMANDS: CommandDefinition[] = [
clearInput(params)
},
}),
// /queue (freebuff-only) — end the active session early and re-queue. The
// hook flips status from 'active' → 'queued', which unmounts <Chat> and
// mounts <WaitingRoomScreen>, where the user can pick a different model.
defineCommand({
name: 'queue',
aliases: ['rejoin', 'switch'],
handler: (params) => {
params.setMessages((prev) => [
...prev,
getUserMessage(params.inputValue.trim()),
getSystemMessage('Ending session and returning to the waiting room…'),
])
params.saveToHistory(params.inputValue.trim())
clearInput(params)
endAndRejoinFreebuffSession().catch(() => {
// The hook surfaces poll errors via the session store; nothing to do
// here beyond letting the chat history reflect the attempt.
})
},
}),
]

export const COMMAND_REGISTRY: CommandDefinition[] = IS_FREEBUFF
Expand Down
111 changes: 111 additions & 0 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { TextAttributes } from '@opentui/core'
import { useKeyboard } from '@opentui/react'
import React, { useCallback, useState } from 'react'

import { Button } from './button'
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'

import { switchFreebuffModel } from '../hooks/use-freebuff-session'
import { useFreebuffModelStore } from '../state/freebuff-model-store'
import { useTheme } from '../hooks/use-theme'

import type { KeyEvent } from '@opentui/core'

interface FreebuffModelSelectorProps {
/** Disables interaction while a switch / refresh is mid-flight so the user
* can't queue up a second switch and double-bounce themselves to the back
* of yet another queue. */
disabled?: boolean
}

/**
* Lets the user pick which model's queue they're in. Tapping (or pressing the
* row's number key) on a different model triggers a re-POST: the server moves
* them to the back of the new model's queue.
*/
export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
disabled = false,
}) => {
const theme = useTheme()
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
const [pending, setPending] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)

const pick = useCallback(
(modelId: string) => {
if (disabled || pending) return
if (modelId === selectedModel) return
setPending(modelId)
switchFreebuffModel(modelId).finally(() => setPending(null))
},
[disabled, pending, selectedModel],
)

// Number-key shortcuts (1-9) so keyboard-only users can switch without
// hunting for a clickable region.
useKeyboard(
useCallback(
(key: KeyEvent) => {
if (disabled || pending) return
const digit = parseInt(key.name ?? '', 10)
if (!Number.isFinite(digit) || digit < 1 || digit > FREEBUFF_MODELS.length) {
return
}
const target = FREEBUFF_MODELS[digit - 1]
if (target && target.id !== selectedModel) {
key.preventDefault?.()
pick(target.id)
}
},
[disabled, pending, pick, selectedModel],
),
)

return (
<box
style={{
flexDirection: 'column',
alignItems: 'flex-start',
gap: 0,
}}
>
<text style={{ fg: theme.muted, marginBottom: 1 }}>
Model — tap or press 1-{FREEBUFF_MODELS.length} to switch
</text>
{FREEBUFF_MODELS.map((model, idx) => {
const isSelected = model.id === selectedModel
const isPending = pending === model.id
const isHovered = hoveredId === model.id
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const labelColor = isSelected ? theme.foreground : theme.muted
const interactable = !disabled && !pending && !isSelected
return (
<Button
key={model.id}
onClick={() => pick(model.id)}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
style={{ paddingLeft: 0, paddingRight: 1 }}
>
<text>
<span fg={indicatorColor}>{indicator} </span>
<span fg={theme.muted}>{idx + 1}. </span>
<span
fg={labelColor}
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
>
{model.displayName}
</span>
<span fg={theme.muted}> {model.tagline}</span>
{isPending && <span fg={theme.muted}> switching…</span>}
{isHovered && interactable && !isPending && (
<span fg={theme.muted}> ↵</span>
)}
</text>
</Button>
)
})}
</box>
)
}
5 changes: 5 additions & 0 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, { useMemo, useState } from 'react'
import { AdBanner } from './ad-banner'
import { Button } from './button'
import { ChoiceAdBanner } from './choice-ad-banner'
import { FreebuffModelSelector } from './freebuff-model-selector'
import { ShimmerText } from './shimmer-text'
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
import { useGravityAd } from '../hooks/use-gravity-ad'
Expand Down Expand Up @@ -200,6 +201,10 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
{formatElapsed(elapsedMs)}
</text>
</box>

<box style={{ marginTop: 1 }}>
<FreebuffModelSelector />
</box>
</>
)}

Expand Down
7 changes: 7 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const FREEBUFF_REMOVED_COMMAND_IDS = new Set([
const FREEBUFF_ONLY_COMMAND_IDS = new Set([
'connect',
'plan',
'queue',
])

const ALL_SLASH_COMMANDS: SlashCommand[] = [
Expand Down Expand Up @@ -184,6 +185,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
label: 'theme:toggle',
description: 'Toggle between light and dark mode',
},
{
id: 'queue',
label: 'queue',
description: 'End your free session and return to the waiting room (lets you switch model)',
aliases: ['rejoin', 'switch'],
},
{
id: 'logout',
label: 'logout',
Expand Down
61 changes: 60 additions & 1 deletion cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { env } from '@codebuff/common/env'
import { useEffect } from 'react'

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

/** Header sent on POST/GET telling the server which model's queue we want.
* POST uses it to (re-)join that model's queue; GET uses it only for the
* rare GET-before-POST edge where there's no row yet. */
const FREEBUFF_MODEL_HEADER = 'x-freebuff-model'

/** Play the terminal bell so users get an audible notification on admission. */
const playAdmissionSound = () => {
try {
Expand All @@ -33,12 +42,15 @@ const sessionEndpoint = (): string => {
async function callSession(
method: 'POST' | 'GET' | 'DELETE',
token: string,
opts: { instanceId?: string; signal?: AbortSignal } = {},
opts: { instanceId?: string; model?: string; signal?: AbortSignal } = {},
): Promise<FreebuffSessionResponse> {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
if (method === 'GET' && opts.instanceId) {
headers[FREEBUFF_INSTANCE_HEADER] = opts.instanceId
}
if ((method === 'POST' || method === 'GET') && opts.model) {
headers[FREEBUFF_MODEL_HEADER] = opts.model
}
const resp = await fetch(sessionEndpoint(), {
method,
headers,
Expand All @@ -64,6 +76,17 @@ async function callSession(
return body
}
}
// 409 from POST means the user picked a different model than their active
// session is bound to. Surface as a non-throw `model_locked` so the UI can
// show a confirmation prompt (DELETE then re-POST to switch).
if (resp.status === 409 && method === 'POST') {
const body = (await resp.json().catch(() => null)) as
| FreebuffSessionResponse
| null
if (body && body.status === 'model_locked') {
return body
}
}
if (!resp.ok) {
const text = await resp.text().catch(() => '')
throw new Error(
Expand Down Expand Up @@ -95,6 +118,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
case 'disabled':
case 'superseded':
case 'country_blocked':
case 'model_locked':
return null
}
}
Expand Down Expand Up @@ -145,6 +169,39 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {})
await controller?.refresh()
}

/**
* User picked a different model in the waiting room. Persist the choice and
* re-POST so the server moves them to the back of the new model's queue. If
* the user has an active session bound to a different model, the server
* responds with `model_locked` and the UI prompts them to end first.
*/
export async function switchFreebuffModel(model: string): Promise<void> {
if (!IS_FREEBUFF) return
const { setSelectedModel } = useFreebuffModelStore.getState()
setSelectedModel(model)
await controller?.refresh()
}

/**
* End the current session and immediately rejoin the queue. Used by the
* "switch model" confirmation flow when the server returned `model_locked`,
* and by any UI that lets the user exit an active session early.
*/
export async function endAndRejoinFreebuffSession(): Promise<void> {
if (!IS_FREEBUFF) return
const { token } = getAuthTokenDetails()
if (!token) return
try {
await callSession('DELETE', token)
} catch {
// Best-effort — even if DELETE fails the re-POST below will eventually
// succeed once the server-side sweep catches up.
}
const { useChatStore } = await import('../state/chat-store')
useChatStore.getState().reset()
await controller?.refresh()
}

export function markFreebuffSessionSuperseded(): void {
if (!IS_FREEBUFF) return
controller?.abort()
Expand Down Expand Up @@ -250,10 +307,12 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
// re-POST out from under an in-flight agent.
const method: 'POST' | 'GET' = hasPosted ? 'GET' : 'POST'
const instanceId = getFreebuffInstanceId()
const model = getSelectedFreebuffModel()
try {
const next = await callSession(method, token, {
signal: abortController.signal,
instanceId,
model,
})
if (cancelled) return
hasPosted = true
Expand Down
41 changes: 41 additions & 0 deletions cli/src/state/freebuff-model-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
DEFAULT_FREEBUFF_MODEL_ID,
resolveFreebuffModel,
} from '@codebuff/common/constants/freebuff-models'
import { create } from 'zustand'

import {
loadFreebuffModelPreference,
saveFreebuffModelPreference,
} from '../utils/settings'

/**
* Holds the user's currently-selected freebuff model. Initialized from the
* persisted settings file so freebuff defaults to whatever model the user
* last picked. Writing through `setSelectedModel` also persists to disk so
* the next launch picks it up without an explicit save call.
*
* Components in the waiting room read this to highlight the current row in
* the model picker; the session hook reads it to decide which queue to join.
*/
interface FreebuffModelStore {
selectedModel: string
setSelectedModel: (model: string) => void
}

export const useFreebuffModelStore = create<FreebuffModelStore>((set) => ({
selectedModel: resolveFreebuffModel(
loadFreebuffModelPreference() ?? DEFAULT_FREEBUFF_MODEL_ID,
),
setSelectedModel: (model) => {
const resolved = resolveFreebuffModel(model)
saveFreebuffModelPreference(resolved)
set({ selectedModel: resolved })
},
}))

/** Imperative read for non-React callers (the session hook's tick loop and
* the chat-completions metadata builder). */
export function getSelectedFreebuffModel(): string {
return useFreebuffModelStore.getState().selectedModel
}
28 changes: 27 additions & 1 deletion cli/src/utils/local-agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ import { loadLocalAgents as sdkLoadLocalAgents, loadMCPConfigSync } from '@codeb

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

import { getSelectedFreebuffModel } from '../state/freebuff-model-store'
import { getProjectRoot } from '../project-files'
import { AGENT_MODE_TO_ID, type AgentMode } from './constants'
import { AGENT_MODE_TO_ID, IS_FREEBUFF, type AgentMode } from './constants'
import { logger } from './logger'
import * as bundledAgentsModule from '../agents/bundled-agents.generated'

/** Agents whose hardcoded model gets swapped out for the user's currently
* selected freebuff model. Each entry must also be allowlisted under the
* matching id in `FREE_MODE_AGENT_MODELS` (server-side check) for both
* glm-5.1 and minimax-m2.7 — otherwise the chat-completions endpoint will
* reject the request with `free_mode_invalid_agent_model`. */
const FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS = new Set([
'base2-free',
'editor-lite',
'code-reviewer-lite',
])

import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition'

// ============================================================================
Expand Down Expand Up @@ -354,6 +366,20 @@ export const loadAgentDefinitions = (): AgentDefinition[] => {
}
}

// Override the model of free-mode agents to match the user's pick from the
// freebuff waiting room. Bundled definitions hardcode glm-5.1; we swap in
// whatever the user chose so the chat-completions request body carries the
// matching model and the server-side session gate doesn't reject it as a
// model mismatch.
if (IS_FREEBUFF) {
const selectedModel = getSelectedFreebuffModel()
for (const def of definitions) {
if (FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS.has(def.id)) {
def.model = selectedModel
}
}
}

return definitions
}

Expand Down
Loading
Loading