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
9 changes: 4 additions & 5 deletions cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,12 +370,11 @@ const AuthedSurface = ({
return <FreebuffSupersededScreen />
}

// Route every non-admitted state through the waiting room:
// null → initial POST in flight
// Route every non-admitted state through the pre-chat screen:
// null → initial GET in flight (brief)
// 'none' → no seat yet; show model-picker landing
// 'queued' → waiting our turn
// 'none' → server lost our row; hook is about to re-POST
// Falling through to <Chat> on 'none' would leave the user unable to send
// any free-mode request until the next poll cycle.
// 'country_blocked' → terminal region-gate message
//
// 'ended' deliberately falls through to <Chat>: the agent may still be
// finishing work under the server-side grace period, and the chat surface
Expand Down
120 changes: 85 additions & 35 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,33 @@ import React, { useCallback, useEffect, 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 { joinFreebuffQueue } from '../hooks/use-freebuff-session'
import { useFreebuffModelStore } from '../state/freebuff-model-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
import { useTheme } from '../hooks/use-theme'

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

/**
* Lets the user pick which model's queue they're in. Switching triggers a
* re-POST: the server moves them to the back of the new model's queue, which
* means switching is *not free* — they lose their place in the original line.
* Dual-purpose model picker:
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
* a model is their explicit commitment to enter — this triggers the POST.
* - In-queue switcher (session 'queued'): picking a *different* model moves
* the user to the back of that queue (lose place in original). Picking the
* model they're already in is a no-op.
*
* To prevent accidental queue loss, keyboard navigation is two-step: Tab /
* arrow keys move a focus highlight, and Enter commits the switch. Mouse
* clicks are still one-step (the click target is intentional).
* To prevent accidental queue loss while queued, keyboard navigation is
* two-step: Tab / arrow keys move a focus highlight, and Enter commits the
* switch. Mouse clicks are still one-step. On the landing screen, pressing
* Enter on the already-focused model also commits — there's nothing to lose.
*
* 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.
* `queueDepthByModel` snapshot so the choice is informed.
*/
export const FreebuffModelSelector: React.FC = () => {
const theme = useTheme()
const { terminalWidth } = useTerminalDimensions()
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
const session = useFreebuffSessionStore((s) => s.session)
const [pending, setPending] = useState<string | null>(null)
Expand All @@ -40,19 +45,30 @@ export const FreebuffModelSelector: React.FC = () => {
setFocusedId(selectedModel)
}, [selectedModel])

// 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.
// Landing ('none'): depths come from the server snapshot, no "self" to
// subtract. In-queue ('queued'): for the user's 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
// any 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
if (session?.status === 'none') {
const depths = session.queueDepthByModel ?? {}
const out: Record<string, number> = {}
for (const { id } of FREEBUFF_MODELS) out[id] = depths[id] ?? 0
return out
}
return out
if (session?.status === 'queued') {
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
}
return null
}, [session])

// Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
Expand All @@ -64,32 +80,59 @@ export const FreebuffModelSelector: React.FC = () => {
[],
)

// Decide row vs column layout based on whether both buttons actually fit
// side-by-side. Each button's inner text is "● {displayName} · {tagline} {hint}",
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
// gap of 2. If the total exceeds the terminal width, stack vertically.
const stackVertically = useMemo(() => {
const BUTTON_CHROME = 4 // 2 border + 2 padding
const GAP = 2
const total = FREEBUFF_MODELS.reduce((sum, model, idx) => {
const inner =
2 /* indicator + space */ +
model.displayName.length +
3 /* " · " */ +
model.tagline.length +
2 /* " " */ +
hintWidth
return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0)
}, 0)
// Leave a small margin for the surrounding padding on the waiting-room screen.
return total > terminalWidth - 4
}, [hintWidth, terminalWidth])

// "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 pick = useCallback(
(modelId: string) => {
if (pending) return
if (modelId === selectedModel) return
if (modelId === committedModelId) return
setPending(modelId)
switchFreebuffModel(modelId).finally(() => setPending(null))
joinFreebuffQueue(modelId).finally(() => setPending(null))
},
[pending, selectedModel],
[pending, committedModelId],
)

// Tab / Shift+Tab and Left/Right arrow keys move the focus highlight only;
// Enter or Space commits the switch. Two-step navigation prevents the user
// from accidentally giving up their place in line by tabbing past their
// queue. Up/Down intentionally do nothing so they don't fight other
// vertical UI.
// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
// Space commits the switch. Two-step navigation prevents the user from
// accidentally giving up their place in line by tabbing past their queue.
useKeyboard(
useCallback(
(key: KeyEvent) => {
if (pending) return
const name = key.name ?? ''
const isForward = name === 'right' || (name === 'tab' && !key.shift)
const isBackward = name === 'left' || (name === 'tab' && key.shift)
const isForward =
name === 'right' || name === 'down' || (name === 'tab' && !key.shift)
const isBackward =
name === 'left' || name === 'up' || (name === 'tab' && key.shift)
const isCommit = name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
if (focusedId !== selectedModel) {
if (focusedId !== committedModelId) {
key.preventDefault?.()
pick(focusedId)
}
Expand All @@ -107,7 +150,7 @@ export const FreebuffModelSelector: React.FC = () => {
setFocusedId(target.id)
}
},
[pending, pick, focusedId, selectedModel],
[pending, pick, focusedId, committedModelId],
),
)

Expand All @@ -121,18 +164,25 @@ export const FreebuffModelSelector: React.FC = () => {
>
<box
style={{
flexDirection: 'row',
gap: 2,
flexDirection: stackVertically ? 'column' : 'row',
gap: stackVertically ? 0 : 2,
alignItems: 'flex-start',
}}
>
{FREEBUFF_MODELS.map((model) => {
// 'Selected' means the dot is filled and the label is bold. On the
// landing screen ('none') this tracks the pre-focused pick; on the
// queued screen it tracks the model the server has us on. Either
// way, selectedModel reflects the intent of "what Enter commits to."
const isSelected = model.id === selectedModel
const isHovered = hoveredId === model.id
const isFocused = focusedId === model.id && !isSelected
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const labelColor = isSelected ? theme.foreground : theme.muted
const interactable = !pending && !isSelected
// Clickable whenever picking would actually do something — i.e.
// anything except re-picking the queue we're already in.
const interactable = !pending && model.id !== committedModelId
const ahead = aheadByModel?.[model.id]
const hint =
ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ahead} ahead`
Expand Down
18 changes: 16 additions & 2 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0

const isQueued = session?.status === 'queued'
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
// state: show the picker with live N-ahead hints and a prompt. Picking a
// model triggers joinFreebuffQueue, which POSTs and transitions us to
// 'queued' (waiting room) or straight to 'active' (chat) if no wait.
const isLanding = session?.status === 'none'

return (
<box
Expand Down Expand Up @@ -160,12 +165,21 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
</text>
)}

{((!session && !error) || session?.status === 'none') && (
{!session && !error && (
<text style={{ fg: theme.muted }}>
<ShimmerText text="Joining the waiting room…" />
<ShimmerText text="Connecting…" />
</text>
)}

{isLanding && (
<>
<text style={{ fg: theme.foreground, marginBottom: 1 }}>
Pick a model to start
</text>
<FreebuffModelSelector />
</>
)}

{isQueued && session && (
<>
<text style={{ fg: theme.foreground, marginBottom: 1 }}>
Expand Down
59 changes: 42 additions & 17 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ interface PollController {
refresh: () => Promise<void>
apply: (next: FreebuffSessionResponse) => void
abort: () => void
setHasPosted: (value: boolean) => void
}

let controller: PollController | null = null
Expand Down Expand Up @@ -168,14 +167,18 @@ export async function refreshFreebuffSession(opts: { resetChat?: boolean } = {})
}

/**
* 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 server has already admitted them on a different model, it responds
* Join (or re-queue for) `model`. Dual-purpose:
* - First join: called from the pre-chat landing picker. The session starts
* at `none` (GET-only); this is the user's explicit commitment to enter.
* - Switch: called when the user picks a different model from within the
* waiting room. Server moves them to the back of the new model's queue.
*
* If the server has already admitted them on a different model, it responds
* with `model_locked`; the tick loop silently reverts the local selection to
* the locked model so the active session stays intact. Users who really want
* to switch can /end-session deliberately.
*/
export async function switchFreebuffModel(model: string): Promise<void> {
export async function joinFreebuffQueue(model: string): Promise<void> {
if (!IS_FREEBUFF) return
const { setSelectedModel } = useFreebuffModelStore.getState()
setSelectedModel(model)
Expand Down Expand Up @@ -256,9 +259,13 @@ interface UseFreebuffSessionResult {

/**
* Manages the freebuff waiting-room session lifecycle:
* - POST on mount to join the queue / rotate instance id
* - GET on mount to probe state (no auto-join; the user picks a model in
* the landing screen, which calls joinFreebuffQueue)
* - if the probe sees an existing seat, POSTs once to take over (rotates
* the instance id so any other CLI on the same account is superseded)
* - polls GET while queued (fast) or active (slow) to keep state fresh
* - re-POSTs on explicit refresh (chat gate rejected us)
* - re-POSTs on explicit refresh (chat gate rejected us, user switched
* models, user rejoined after ending)
* - DELETE on unmount so the slot frees up for the next user
* - plays a bell on transition from queued → active
*/
Expand Down Expand Up @@ -288,7 +295,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
let abortController = new AbortController()
let timer: ReturnType<typeof setTimeout> | null = null
let previousStatus: FreebuffSessionResponse['status'] | null = null
let hasPosted = false
// Method for the NEXT tick. GET is read-only; POST claims/rotates a seat.
// Startup is GET (probe before committing). After any POST completes we
// flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin;
// the startup takeover branch does the same when the probe finds a seat.
let nextMethod: 'GET' | 'POST' = 'GET'

const apply = (next: FreebuffSessionResponse) => {
setSession(next)
Expand All @@ -311,10 +322,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {

const tick = async () => {
if (cancelled) return
// POST when we don't yet hold a seat; thereafter GET. The
// active|ended → none edge is special-cased below so we don't silently
// re-POST out from under an in-flight agent.
const method: 'POST' | 'GET' = hasPosted ? 'GET' : 'POST'
const method = nextMethod
const instanceId = getFreebuffInstanceId()
const model = getSelectedFreebuffModel()
try {
Expand All @@ -324,7 +332,10 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
model,
})
if (cancelled) return
hasPosted = true
// After any successful call, default back to GET polling. The
// takeover and model_locked branches below override this when they
// need another POST.
nextMethod = 'GET'

// Race recovery: user picked a different model in the waiting room at
// the exact moment the server admitted them with the original model.
Expand All @@ -337,6 +348,23 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
return
}

// Startup takeover: the initial probe GET saw we already hold a seat
// (from a prior CLI instance). POST now to rotate our instance id so
// any other CLI on this account is superseded on its next poll.
// `previousStatus === null` fences this to the very first tick only.
// Pin the selected model to whatever the server thinks we're on so
// the POST preserves our queue position instead of switching queues.
if (
method === 'GET' &&
previousStatus === null &&
(next.status === 'queued' || next.status === 'active')
) {
useFreebuffModelStore.getState().setSelectedModel(next.model)
nextMethod = 'POST'
schedule(0)
return
}

if (previousStatus === 'queued' && next.status === 'active') {
playAdmissionSound()
}
Expand Down Expand Up @@ -374,17 +402,14 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
// Reset previousStatus so the queued→active bell still fires after
// a forced re-POST.
previousStatus = null
hasPosted = false
nextMethod = 'POST'
await tick()
},
apply,
abort: () => {
clearTimer()
abortController.abort()
},
setHasPosted: (value) => {
hasPosted = value
},
}

tick()
Expand Down
5 changes: 5 additions & 0 deletions common/src/types/freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export type FreebuffSessionServerResponse =
* grace window. */
status: 'none'
message?: string
/** Snapshot of every model's queue depth so the CLI can render live
* "N ahead" hints on the pre-join model picker without first
* committing the user to a queue. Present on GET responses; not
* returned from POST (POST never produces `none`). */
queueDepthByModel?: Record<string, number>
}
| {
status: 'queued'
Expand Down
2 changes: 1 addition & 1 deletion freebuff/cli/release/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freebuff",
"version": "0.0.42",
"version": "0.0.43",
"description": "The world's strongest free coding agent",
"license": "MIT",
"bin": {
Expand Down
Loading
Loading