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
94 changes: 55 additions & 39 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from './button'
import {
FALLBACK_FREEBUFF_MODEL_ID,
FREEBUFF_DEPLOYMENT_HOURS_LABEL,
FREEBUFF_GLM_MODEL_ID,
FREEBUFF_MODELS,
getFreebuffDeploymentAvailabilityLabel,
isFreebuffModelAvailable,
} from '@codebuff/common/constants/freebuff-models'

Expand All @@ -17,6 +17,10 @@ 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 type { KeyEvent } from '@opentui/core'

Expand Down Expand Up @@ -48,6 +52,10 @@ export const FreebuffModelSelector: React.FC = () => {
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
const session = useFreebuffSessionStore((s) => s.session)
const now = useNow(60_000)
const deploymentAvailabilityLabel = useMemo(
() => getFreebuffDeploymentAvailabilityLabel(new Date(now)),
[now],
)
const [pending, setPending] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Keyboard cursor — separate from the actually-selected model so that
Expand Down Expand Up @@ -96,7 +104,7 @@ export const FreebuffModelSelector: React.FC = () => {
out[id] =
id === session.model
? Math.max(0, session.position - 1)
: depths[id] ?? 0
: (depths[id] ?? 0)
}
return out
}
Expand Down Expand Up @@ -127,21 +135,20 @@ export const FreebuffModelSelector: React.FC = () => {
3 /* " · " */ +
model.tagline.length +
(model.availability === 'deployment_hours'
? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL.length
? 3 + deploymentAvailabilityLabel.length
: 0) +
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])
}, [deploymentAvailabilityLabel, 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 committedModelId = session?.status === 'queued' ? session.model : null

const pick = useCallback(
(modelId: string) => {
Expand All @@ -166,33 +173,36 @@ export const FreebuffModelSelector: React.FC = () => {
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'
const isCommit =
name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
if (
focusedId !== committedModelId &&
isFreebuffModelAvailable(focusedId, new Date(now))
) {
const targetId = resolveFreebuffModelCommitTarget({
focusedId,
selectedId: selectedModel,
committedId: committedModelId,
isSelectable: (modelId) =>
isFreebuffModelAvailable(modelId, new Date(now)),
})
if (targetId) {
key.preventDefault?.()
pick(focusedId)
pick(targetId)
}
return
}
const currentIdx = FREEBUFF_MODEL_SELECTOR_MODELS.findIndex(
(m) => m.id === focusedId,
)
if (currentIdx === -1) return
const len = FREEBUFF_MODEL_SELECTOR_MODELS.length
const nextIdx = isForward
? (currentIdx + 1) % len
: (currentIdx - 1 + len) % len
const target = FREEBUFF_MODEL_SELECTOR_MODELS[nextIdx]
if (target) {
const targetId = nextSelectableFreebuffModelId({
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(target.id)
setFocusedId(targetId)
}
},
[pending, pick, focusedId, committedModelId, now],
[pending, pick, focusedId, selectedModel, committedModelId, now],
),
)

Expand All @@ -215,26 +225,28 @@ 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 reflects the intent of "what Enter commits to."
// way, selectedModel is the safe fallback if focus ever lands on a
// closed row (for example when deployment hours change).
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 labelColor = isSelected && isAvailable ? theme.foreground : theme.muted
const labelColor =
isSelected && isAvailable ? 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
const interactable =
!pending && isAvailable && model.id !== committedModelId
const ahead = aheadByModel?.[model.id]
const hint =
!isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`
const hint = !isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`

const borderColor = isSelected
? theme.primary
Expand All @@ -250,7 +262,9 @@ export const FreebuffModelSelector: React.FC = () => {
if (isAvailable) pick(model.id)
}}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
onMouseOut={() =>
setHoveredId((curr) => (curr === model.id ? null : curr))
}
style={{
borderStyle: 'single',
borderColor,
Expand All @@ -263,15 +277,17 @@ export const FreebuffModelSelector: React.FC = () => {
<span fg={indicatorColor}>{indicator} </span>
<span
fg={labelColor}
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
attributes={
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
}
>
{model.displayName}
</span>
<span fg={theme.muted}> · {model.tagline}</span>
{model.availability === 'deployment_hours' && (
<span fg={theme.muted}> · {FREEBUFF_DEPLOYMENT_HOURS_LABEL}</span>
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
)}
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
</text>
</Button>
)
Expand Down
2 changes: 1 addition & 1 deletion cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
maxWidth: contentMaxWidth,
}}
>
{error && !session && (
{error && (!session || session.status === 'none') && (
<text style={{ fg: theme.secondary, wrapMode: 'word' }}>
⚠ {error}
</text>
Expand Down
11 changes: 10 additions & 1 deletion cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
let abortController = new AbortController()
let timer: ReturnType<typeof setTimeout> | null = null
let previousStatus: FreebuffSessionResponse['status'] | null = null
let restartGeneration = 0
// 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;
Expand Down Expand Up @@ -489,6 +490,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {

controller = {
restart: async (mode) => {
const generation = ++restartGeneration
clearTimer()
// Abort any in-flight fetch so it can't race us and overwrite state.
abortController.abort()
Expand All @@ -498,6 +500,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
// doesn't bounce a 'landing' restart straight back to 'ended'.
previousStatus = null
if (mode === 'landing') {
nextMethod = 'GET'
// Land on the picker immediately. We can't go through the normal
// tick/apply path because a server-side row that hasn't been
// swept yet would trip the startup-takeover branch into an
Expand All @@ -511,7 +514,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
const fetchController = abortController
callSession('GET', token, { signal: fetchController.signal })
.then((response) => {
if (cancelled || fetchController.signal.aborted) return
if (
cancelled ||
fetchController.signal.aborted ||
generation !== restartGeneration
) {
return
}
const depths =
response.status === 'none' || response.status === 'queued'
? response.queueDepthByModel
Expand Down
93 changes: 93 additions & 0 deletions cli/src/utils/__tests__/freebuff-model-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test'

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

describe('nextSelectableFreebuffModelId', () => {
test('skips unavailable models when moving forward', () => {
const modelIds = ['glm', 'minimax']

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

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

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

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

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

test('returns null when no selectable model exists', () => {
expect(
nextSelectableFreebuffModelId({
modelIds: ['glm'],
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()
})
})
37 changes: 37 additions & 0 deletions cli/src/utils/freebuff-model-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export function nextSelectableFreebuffModelId(params: {
modelIds: readonly string[]
focusedId: string
direction: 'forward' | 'backward'
isSelectable: (modelId: string) => boolean
}): string | null {
const { modelIds, focusedId, direction, isSelectable } = params
if (modelIds.length === 0) return null

const currentIdx = modelIds.indexOf(focusedId)
if (currentIdx === -1) return 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
}
Loading
Loading