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
95 changes: 59 additions & 36 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ 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 {
nextSelectableFreebuffModelId,
resolveFreebuffModelCommitTarget,
} from '../utils/freebuff-model-navigation'
import { nextFreebuffModelId } from '../utils/freebuff-model-navigation'

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

Expand Down Expand Up @@ -124,11 +121,17 @@ export const FreebuffModelSelector: React.FC = () => {
// when the user's selection moves between queues. The tagline is shown
// inline with the name now, so it's no longer part of this slot.
const hintWidth = useMemo(
() => Math.max('No wait'.length, '999 ahead'.length),
() =>
Math.max(
'No wait'.length,
'999 ahead'.length,
'Used today'.length,
'Limit used'.length,
),
[],
)

// Decide row vs column layout based on whether both buttons actually fit
// Decide row vs column layout based on whether the buttons actually fit
// side-by-side. Each button's inner text is
// "● {displayName} · {tagline} · {hours} {hint}",
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
Expand Down Expand Up @@ -157,16 +160,28 @@ export const FreebuffModelSelector: React.FC = () => {
// 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 rateLimitsByModel =
session && 'rateLimitsByModel' in session
? session.rateLimitsByModel
: undefined
const isJoinable = useCallback(
(modelId: string) => {
if (!isFreebuffModelAvailable(modelId, new Date(now))) return false
const rateLimit = rateLimitsByModel?.[modelId]
return !rateLimit || rateLimit.recentCount < rateLimit.limit
},
[now, rateLimitsByModel],
)

const pick = useCallback(
(modelId: string) => {
if (pending) return
if (modelId === committedModelId) return
if (!isFreebuffModelAvailable(modelId, new Date(now))) return
if (!isJoinable(modelId)) return
setPending(modelId)
joinFreebuffQueue(modelId).finally(() => setPending(null))
},
[pending, committedModelId, now],
[pending, committedModelId, isJoinable],
)

// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
Expand All @@ -185,32 +200,23 @@ export const FreebuffModelSelector: React.FC = () => {
name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
const targetId = resolveFreebuffModelCommitTarget({
focusedId,
selectedId: selectedModel,
committedId: committedModelId,
isSelectable: (modelId) =>
isFreebuffModelAvailable(modelId, new Date(now)),
})
if (targetId) {
if (isJoinable(focusedId) && focusedId !== committedModelId) {
key.preventDefault?.()
pick(targetId)
pick(focusedId)
}
return
}
const targetId = nextSelectableFreebuffModelId({
const targetId = nextFreebuffModelId({
modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id),
focusedId,
direction: isForward ? 'forward' : 'backward',
isSelectable: (modelId) =>
isFreebuffModelAvailable(modelId, new Date(now)),
})
if (targetId) {
key.preventDefault?.()
setFocusedId(targetId)
}
},
[pending, pick, focusedId, selectedModel, committedModelId, now],
[pending, pick, focusedId, committedModelId, isJoinable],
),
)

Expand All @@ -233,32 +239,47 @@ export const FreebuffModelSelector: React.FC = () => {
// '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 is the safe fallback if focus ever lands on a
// closed row (for example when deployment hours change).
// way, selectedModel marks the user's current preference even if
// focus has moved to a different row.
const isSelected = model.id === selectedModel
const isHovered = hoveredId === model.id
const isFocused = focusedId === model.id && !isSelected
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const rateLimit = rateLimitsByModel?.[model.id]
const isQuotaExhausted =
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
const canJoin = isAvailable && !isQuotaExhausted
const indicator = isSelected ? '●' : isFocused ? '›' : '○'
const indicatorColor = isSelected
? theme.primary
: isFocused
? theme.foreground
: theme.muted
const labelColor =
isSelected && isAvailable ? theme.foreground : theme.muted
(isSelected || isFocused) && canJoin
? theme.foreground
: theme.muted
// Clickable whenever picking would actually do something — i.e.
// anything except re-picking the queue we're already in.
const interactable =
!pending && isAvailable && model.id !== committedModelId
!pending && canJoin && model.id !== committedModelId
const ahead = aheadByModel?.[model.id]
const hint = !isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`
: isQuotaExhausted
? model.id === FREEBUFF_GEMINI_PRO_MODEL_ID
? 'Used today'
: 'Limit used'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`
const hintColor = canJoin ? theme.muted : theme.secondary

const borderColor = isSelected
? theme.primary
: (isFocused || isHovered) && interactable
: isFocused || isHovered
? theme.foreground
: theme.border

Expand All @@ -267,7 +288,7 @@ export const FreebuffModelSelector: React.FC = () => {
key={model.id}
onClick={() => {
setFocusedId(model.id)
if (isAvailable) pick(model.id)
if (canJoin) pick(model.id)
}}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() =>
Expand All @@ -286,7 +307,9 @@ export const FreebuffModelSelector: React.FC = () => {
<span
fg={labelColor}
attributes={
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
isSelected || isFocused
? TextAttributes.BOLD
: TextAttributes.NONE
}
>
{model.displayName}
Expand All @@ -295,7 +318,7 @@ export const FreebuffModelSelector: React.FC = () => {
{model.availability === 'deployment_hours' && (
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
)}
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
<span fg={hintColor}> {hint.padEnd(hintWidth)}</span>
</text>
</Button>
)
Expand Down
22 changes: 12 additions & 10 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,11 +516,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
// tick/apply path because a server-side row that hasn't been
// swept yet would trip the startup-takeover branch into an
// auto-POST — the exact silent-rejoin this mode exists to
// prevent. But the picker still needs live queue depths for its
// "N ahead" hints, so kick off a fire-and-forget GET and extract
// just queueDepthByModel from the response, ignoring whatever
// status it claims. Polling resumes when the user commits to a
// model via joinFreebuffQueue.
// prevent. But the picker still needs live queue depths and quota
// snapshots, so kick off a fire-and-forget GET and extract only
// picker metadata from the response, ignoring whatever status it
// claims. Polling resumes when the user commits to a model via
// joinFreebuffQueue.
apply({ status: 'none' })
const fetchController = abortController
callSession('GET', token, { signal: fetchController.signal })
Expand All @@ -532,11 +532,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
) {
return
}
const depths =
response.status === 'none' || response.status === 'queued'
? response.queueDepthByModel
: undefined
if (depths) apply({ status: 'none', queueDepthByModel: depths })
if (response.status === 'none' || response.status === 'queued') {
apply({
status: 'none',
queueDepthByModel: response.queueDepthByModel,
rateLimitsByModel: response.rateLimitsByModel,
})
}
})
.catch(() => {
// Silent — blank hints are acceptable if the fetch fails.
Expand Down
68 changes: 13 additions & 55 deletions cli/src/utils/__tests__/freebuff-model-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,50 @@
import { describe, expect, test } from 'bun:test'

import {
nextSelectableFreebuffModelId,
resolveFreebuffModelCommitTarget,
} from '../freebuff-model-navigation'
import { nextFreebuffModelId } from '../freebuff-model-navigation'

describe('nextSelectableFreebuffModelId', () => {
test('skips unavailable models when moving forward', () => {
describe('nextFreebuffModelId', () => {
test('moves to the next model when moving forward', () => {
const modelIds = ['glm', 'minimax']

expect(
nextSelectableFreebuffModelId({
nextFreebuffModelId({
modelIds,
focusedId: 'minimax',
direction: 'forward',
isSelectable: (id) => id !== 'glm',
}),
).toBe('minimax')
).toBe('glm')
})

test('skips unavailable models when moving backward', () => {
test('moves to the previous model when moving backward', () => {
const modelIds = ['glm', 'minimax']

expect(
nextSelectableFreebuffModelId({
nextFreebuffModelId({
modelIds,
focusedId: 'minimax',
direction: 'backward',
isSelectable: (id) => id !== 'glm',
}),
).toBe('minimax')
).toBe('glm')
})

test('moves to the next available model when more than one is selectable', () => {
test('wraps through every model regardless of selectability', () => {
const modelIds = ['glm', 'minimax', 'other']

expect(
nextSelectableFreebuffModelId({
nextFreebuffModelId({
modelIds,
focusedId: 'minimax',
direction: 'forward',
isSelectable: (id) => id !== 'glm',
}),
).toBe('other')
})

test('returns null when no selectable model exists', () => {
test('returns null when no model exists', () => {
expect(
nextSelectableFreebuffModelId({
modelIds: ['glm'],
nextFreebuffModelId({
modelIds: [],
focusedId: 'glm',
direction: 'forward',
isSelectable: () => false,
}),
).toBeNull()
})
})

describe('resolveFreebuffModelCommitTarget', () => {
test('falls back to the selected model when focus is on a closed model', () => {
expect(
resolveFreebuffModelCommitTarget({
focusedId: 'glm',
selectedId: 'minimax',
committedId: null,
isSelectable: (id) => id !== 'glm',
}),
).toBe('minimax')
})

test('commits the focused model when it is selectable', () => {
expect(
resolveFreebuffModelCommitTarget({
focusedId: 'minimax',
selectedId: 'glm',
committedId: null,
isSelectable: (id) => id === 'minimax',
}),
).toBe('minimax')
})

test('returns null when the target is already committed', () => {
expect(
resolveFreebuffModelCommitTarget({
focusedId: 'minimax',
selectedId: 'minimax',
committedId: 'minimax',
isSelectable: () => true,
}),
).toBeNull()
})
Expand Down
31 changes: 4 additions & 27 deletions cli/src/utils/freebuff-model-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
export function nextSelectableFreebuffModelId(params: {
export function nextFreebuffModelId(params: {
modelIds: readonly string[]
focusedId: string
direction: 'forward' | 'backward'
isSelectable: (modelId: string) => boolean
}): string | null {
const { modelIds, focusedId, direction, isSelectable } = params
const { modelIds, focusedId, direction } = params
if (modelIds.length === 0) return null

const currentIdx = modelIds.indexOf(focusedId)
if (currentIdx === -1) return null
if (currentIdx === -1) return modelIds[0] ?? null

const step = direction === 'forward' ? 1 : -1
// Include a full wrap back to the current item so arrows stay on the same
// selectable model when every peer is unavailable.
for (let offset = 1; offset <= modelIds.length; offset++) {
const idx =
(currentIdx + step * offset + modelIds.length) % modelIds.length
const candidate = modelIds[idx]
if (isSelectable(candidate)) return candidate
}

return null
}

export function resolveFreebuffModelCommitTarget(params: {
focusedId: string
selectedId: string
committedId: string | null
isSelectable: (modelId: string) => boolean
}): string | null {
const { focusedId, selectedId, committedId, isSelectable } = params
const targetId = isSelectable(focusedId) ? focusedId : selectedId

if (!isSelectable(targetId) || targetId === committedId) return null
return targetId
return modelIds[(currentIdx + step + modelIds.length) % modelIds.length]
}
Loading
Loading