diff --git a/cli/src/app.tsx b/cli/src/app.tsx
index a83214114..add3ce9f0 100644
--- a/cli/src/app.tsx
+++ b/cli/src/app.tsx
@@ -370,12 +370,11 @@ const AuthedSurface = ({
return
}
- // 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 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 : the agent may still be
// finishing work under the server-side grace period, and the chat surface
diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx
index 9ce4faa20..d4cb7b918 100644
--- a/cli/src/components/freebuff-model-selector.tsx
+++ b/cli/src/components/freebuff-model-selector.tsx
@@ -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(null)
@@ -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 | null>(() => {
- if (session?.status !== 'queued') return null
- const depths = session.queueDepthByModel ?? {}
- const out: Record = {}
- 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 = {}
+ 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 = {}
+ 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
@@ -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)
}
@@ -107,7 +150,7 @@ export const FreebuffModelSelector: React.FC = () => {
setFocusedId(target.id)
}
},
- [pending, pick, focusedId, selectedModel],
+ [pending, pick, focusedId, committedModelId],
),
)
@@ -121,18 +164,25 @@ export const FreebuffModelSelector: React.FC = () => {
>
{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`
diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx
index b9e76530b..2c2a65f5c 100644
--- a/cli/src/components/waiting-room-screen.tsx
+++ b/cli/src/components/waiting-room-screen.tsx
@@ -92,6 +92,11 @@ export const WaitingRoomScreen: React.FC = ({
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 (
= ({
)}
- {((!session && !error) || session?.status === 'none') && (
+ {!session && !error && (
-
+
)}
+ {isLanding && (
+ <>
+
+ Pick a model to start
+
+
+ >
+ )}
+
{isQueued && session && (
<>
diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts
index 077382009..9c006766a 100644
--- a/cli/src/hooks/use-freebuff-session.ts
+++ b/cli/src/hooks/use-freebuff-session.ts
@@ -132,7 +132,6 @@ interface PollController {
refresh: () => Promise
apply: (next: FreebuffSessionResponse) => void
abort: () => void
- setHasPosted: (value: boolean) => void
}
let controller: PollController | null = null
@@ -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 {
+export async function joinFreebuffQueue(model: string): Promise {
if (!IS_FREEBUFF) return
const { setSelectedModel } = useFreebuffModelStore.getState()
setSelectedModel(model)
@@ -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
*/
@@ -288,7 +295,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
let abortController = new AbortController()
let timer: ReturnType | 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)
@@ -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 {
@@ -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.
@@ -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()
}
@@ -374,7 +402,7 @@ 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,
@@ -382,9 +410,6 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
clearTimer()
abortController.abort()
},
- setHasPosted: (value) => {
- hasPosted = value
- },
}
tick()
diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts
index bb8936b41..363224d39 100644
--- a/common/src/types/freebuff-session.ts
+++ b/common/src/types/freebuff-session.ts
@@ -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
}
| {
status: 'queued'
diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json
index f84f7776b..1a98cb3e3 100644
--- a/freebuff/cli/release/package.json
+++ b/freebuff/cli/release/package.json
@@ -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": {
diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts
index 073e7522f..b1f1f4c93 100644
--- a/web/src/app/api/v1/freebuff/session/_handlers.ts
+++ b/web/src/app/api/v1/freebuff/session/_handlers.ts
@@ -166,7 +166,11 @@ export async function getFreebuffSession(
})
if (state.status === 'none') {
return NextResponse.json(
- { status: 'none', message: 'Call POST to join the waiting room.' },
+ {
+ status: 'none',
+ message: 'Call POST to join the waiting room.',
+ queueDepthByModel: state.queueDepthByModel,
+ },
{ status: 200 },
)
}
diff --git a/web/src/llm-api/fireworks-config.ts b/web/src/llm-api/fireworks-config.ts
index d7683afb1..fb6d59580 100644
--- a/web/src/llm-api/fireworks-config.ts
+++ b/web/src/llm-api/fireworks-config.ts
@@ -11,6 +11,6 @@ export const FIREWORKS_ACCOUNT_ID = 'james-65d217'
export const FIREWORKS_DEPLOYMENT_MAP: Record = {
// 'minimax/minimax-m2.5': 'accounts/james-65d217/deployments/lnfid5h9',
// 'moonshotai/kimi-k2.5': 'accounts/james-65d217/deployments/mx8l5rq2',
- 'minimax/minimax-m2.7': 'accounts/james-65d217/deployments/nrdudqxd',
+ // 'minimax/minimax-m2.7': 'accounts/james-65d217/deployments/nrdudqxd',
'z-ai/glm-5.1': 'accounts/james-65d217/deployments/mjb4i7ea',
}
diff --git a/web/src/server/free-session/__tests__/public-api.test.ts b/web/src/server/free-session/__tests__/public-api.test.ts
index 7585d8927..ca1dee539 100644
--- a/web/src/server/free-session/__tests__/public-api.test.ts
+++ b/web/src/server/free-session/__tests__/public-api.test.ts
@@ -206,9 +206,9 @@ describe('getSessionState', () => {
expect(state).toEqual({ status: 'disabled' })
})
- test('no row returns none', async () => {
+ test('no row returns none with empty queue-depth snapshot', async () => {
const state = await getSessionState({ userId: 'u1', deps })
- expect(state).toEqual({ status: 'none' })
+ expect(state).toEqual({ status: 'none', queueDepthByModel: {} })
})
test('active session with matching instance id returns active', async () => {
@@ -284,7 +284,7 @@ describe('getSessionState', () => {
claimedInstanceId: row.active_instance_id,
deps,
})
- expect(state).toEqual({ status: 'none' })
+ expect(state).toEqual({ status: 'none', queueDepthByModel: {} })
})
})
diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts
index be4506eb1..10150d8f1 100644
--- a/web/src/server/free-session/public-api.ts
+++ b/web/src/server/free-session/public-api.ts
@@ -181,7 +181,16 @@ export async function getSessionState(params: {
return { status: 'disabled' }
}
const row = await deps.getSessionRow(params.userId)
- if (!row) return { status: 'none' }
+
+ // Build a `none` response with live queue depths so the CLI's pre-join
+ // picker can show "N ahead" hints without first committing the user to a
+ // queue. Cheap snapshot — no user-scoped state.
+ const noneResponse = async (): Promise => ({
+ status: 'none',
+ queueDepthByModel: await deps.queueDepthsByModel(),
+ })
+
+ if (!row) return noneResponse()
if (
row.status === 'active' &&
@@ -192,7 +201,7 @@ export async function getSessionState(params: {
}
const view = await viewForRow(params.userId, deps, row)
- if (!view) return { status: 'none' }
+ if (!view) return noneResponse()
return view
}