Skip to content
Merged
25 changes: 23 additions & 2 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { CLAUDE_OAUTH_ENABLED } from '@codebuff/common/constants/claude-oauth'
import { safeOpen } from '../utils/open-url'

import { handleAdsEnable, handleAdsDisable } from './ads'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import { useThemeStore } from '../hooks/use-theme'
import { handleHelpCommand } from './help'
import { handleImageCommand } from './image'
import { handleInitializationFlowLocally } from './init'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import { runBashCommand } from './router'
import { handleUsageCommand } from './usage'
import { endAndRejoinFreebuffSession } from '../hooks/use-freebuff-session'
import { useThemeStore } from '../hooks/use-theme'
import { WEBSITE_URL } from '../login/constants'
import { useChatStore } from '../state/chat-store'
import { useFeedbackStore } from '../state/feedback-store'
Expand Down Expand Up @@ -178,6 +179,7 @@ const FREEBUFF_REMOVED_COMMANDS = new Set([
const FREEBUFF_ONLY_COMMANDS = new Set([
'connect',
'plan',
'end-session',
])

const ALL_COMMANDS: CommandDefinition[] = [
Expand Down Expand Up @@ -611,6 +613,25 @@ const ALL_COMMANDS: CommandDefinition[] = [
clearInput(params)
},
}),
// /end-session (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: 'end-session',
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
130 changes: 130 additions & 0 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { TextAttributes } from '@opentui/core'
import { useKeyboard } from '@opentui/react'
import React, { useCallback, useMemo, 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 { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { useTheme } from '../hooks/use-theme'

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

/**
* 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.
*
* Each row shows a live "N ahead" count sourced from the server's
* `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
* "12 ahead") rather than a blind preference toggle.
*/
export const FreebuffModelSelector: React.FC = () => {
const theme = useTheme()
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
const session = useFreebuffSessionStore((s) => s.session)
const [pending, setPending] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)

// For the user's current queue, "ahead" is `position - 1` (themselves don't
// count). For every other queue, switching would land them at the back, so
// it's that queue's full depth. Null before the first queued snapshot so
// the UI doesn't flash misleading zeros.
const aheadByModel = useMemo<Record<string, number> | null>(() => {
if (session?.status !== 'queued') return null
const depths = session.queueDepthByModel ?? {}
const out: Record<string, number> = {}
for (const { id } of FREEBUFF_MODELS) {
out[id] =
id === session.model ? Math.max(0, session.position - 1) : depths[id] ?? 0
}
return out
}, [session])

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

// Number-key shortcuts (1-9) so keyboard-only users can switch without
// hunting for a clickable region.
useKeyboard(
useCallback(
(key: KeyEvent) => {
if (pending) return
const name = key.name ?? ''
if (!/^[1-9]$/.test(name)) return
const digit = Number(name)
if (digit > FREEBUFF_MODELS.length) return
const target = FREEBUFF_MODELS[digit - 1]
if (target && target.id !== selectedModel) {
key.preventDefault?.()
pick(target.id)
}
},
[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 = !pending && !isSelected
const ahead = aheadByModel?.[model.id]
const hint =
ahead === undefined
? model.tagline
: ahead === 0
? 'No wait'
: `${ahead} ahead`
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}> {hint}</span>
{isPending && <span fg={theme.muted}> switching…</span>}
{isHovered && interactable && !isPending && (
<span fg={theme.muted}> ↵</span>
)}
</text>
</Button>
)
})}
</box>
)
}
8 changes: 7 additions & 1 deletion cli/src/components/status-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getFreebuffModel } from '@codebuff/common/constants/freebuff-models'
import { TextAttributes } from '@opentui/core'
import React, { useEffect, useState } from 'react'

Expand Down Expand Up @@ -143,9 +144,14 @@ export const StatusBar = ({
case 'idle':
if (sessionProgress !== null) {
const isUrgent = sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS
const modelName =
freebuffSession?.status === 'active'
? getFreebuffModel(freebuffSession.model).displayName
: null
return (
<span fg={isUrgent ? theme.warning : theme.secondary}>
Free session · {formatSessionRemaining(sessionProgress.remainingMs)}
{modelName ? `${modelName} · ` : ''}Free session ·{' '}
{formatSessionRemaining(sessionProgress.remainingMs)}
</span>
)
}
Expand Down
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
6 changes: 6 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',
'end-session',
])

const ALL_SLASH_COMMANDS: SlashCommand[] = [
Expand Down Expand Up @@ -184,6 +185,11 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
label: 'theme:toggle',
description: 'Toggle between light and dark mode',
},
{
id: 'end-session',
label: 'end-session',
description: 'End your free session and return to the waiting room (lets you switch model)',
},
{
id: 'logout',
label: 'logout',
Expand Down
Loading
Loading