Skip to content

Commit 5b1cbe9

Browse files
Improve freebuff model picker UX (#570)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent b5d6411 commit 5b1cbe9

8 files changed

Lines changed: 208 additions & 136 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ import { useFreebuffModelStore } from '../state/freebuff-model-store'
1818
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1919
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
2020
import { useTheme } from '../hooks/use-theme'
21-
import {
22-
nextSelectableFreebuffModelId,
23-
resolveFreebuffModelCommitTarget,
24-
} from '../utils/freebuff-model-navigation'
21+
import { nextFreebuffModelId } from '../utils/freebuff-model-navigation'
2522

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

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

131-
// Decide row vs column layout based on whether both buttons actually fit
134+
// Decide row vs column layout based on whether the buttons actually fit
132135
// side-by-side. Each button's inner text is
133136
// "● {displayName} · {tagline} · {hours} {hint}",
134137
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
@@ -157,16 +160,28 @@ export const FreebuffModelSelector: React.FC = () => {
157160
// on it. On the landing screen (status 'none'), nothing is committed yet,
158161
// so picking the focused model is always a real action (first join).
159162
const committedModelId = session?.status === 'queued' ? session.model : null
163+
const rateLimitsByModel =
164+
session && 'rateLimitsByModel' in session
165+
? session.rateLimitsByModel
166+
: undefined
167+
const isJoinable = useCallback(
168+
(modelId: string) => {
169+
if (!isFreebuffModelAvailable(modelId, new Date(now))) return false
170+
const rateLimit = rateLimitsByModel?.[modelId]
171+
return !rateLimit || rateLimit.recentCount < rateLimit.limit
172+
},
173+
[now, rateLimitsByModel],
174+
)
160175

161176
const pick = useCallback(
162177
(modelId: string) => {
163178
if (pending) return
164179
if (modelId === committedModelId) return
165-
if (!isFreebuffModelAvailable(modelId, new Date(now))) return
180+
if (!isJoinable(modelId)) return
166181
setPending(modelId)
167182
joinFreebuffQueue(modelId).finally(() => setPending(null))
168183
},
169-
[pending, committedModelId, now],
184+
[pending, committedModelId, isJoinable],
170185
)
171186

172187
// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
@@ -185,32 +200,23 @@ export const FreebuffModelSelector: React.FC = () => {
185200
name === 'return' || name === 'enter' || name === 'space'
186201
if (!isForward && !isBackward && !isCommit) return
187202
if (isCommit) {
188-
const targetId = resolveFreebuffModelCommitTarget({
189-
focusedId,
190-
selectedId: selectedModel,
191-
committedId: committedModelId,
192-
isSelectable: (modelId) =>
193-
isFreebuffModelAvailable(modelId, new Date(now)),
194-
})
195-
if (targetId) {
203+
if (isJoinable(focusedId) && focusedId !== committedModelId) {
196204
key.preventDefault?.()
197-
pick(targetId)
205+
pick(focusedId)
198206
}
199207
return
200208
}
201-
const targetId = nextSelectableFreebuffModelId({
209+
const targetId = nextFreebuffModelId({
202210
modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id),
203211
focusedId,
204212
direction: isForward ? 'forward' : 'backward',
205-
isSelectable: (modelId) =>
206-
isFreebuffModelAvailable(modelId, new Date(now)),
207213
})
208214
if (targetId) {
209215
key.preventDefault?.()
210216
setFocusedId(targetId)
211217
}
212218
},
213-
[pending, pick, focusedId, selectedModel, committedModelId, now],
219+
[pending, pick, focusedId, committedModelId, isJoinable],
214220
),
215221
)
216222

@@ -233,32 +239,47 @@ export const FreebuffModelSelector: React.FC = () => {
233239
// 'Selected' means the dot is filled and the label is bold. On the
234240
// landing screen ('none') this tracks the pre-focused pick; on the
235241
// queued screen it tracks the model the server has us on. Either
236-
// way, selectedModel is the safe fallback if focus ever lands on a
237-
// closed row (for example when deployment hours change).
242+
// way, selectedModel marks the user's current preference even if
243+
// focus has moved to a different row.
238244
const isSelected = model.id === selectedModel
239245
const isHovered = hoveredId === model.id
240246
const isFocused = focusedId === model.id && !isSelected
241247
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
242-
const indicator = isSelected ? '●' : '○'
243-
const indicatorColor = isSelected ? theme.primary : theme.muted
248+
const rateLimit = rateLimitsByModel?.[model.id]
249+
const isQuotaExhausted =
250+
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
251+
const canJoin = isAvailable && !isQuotaExhausted
252+
const indicator = isSelected ? '●' : isFocused ? '›' : '○'
253+
const indicatorColor = isSelected
254+
? theme.primary
255+
: isFocused
256+
? theme.foreground
257+
: theme.muted
244258
const labelColor =
245-
isSelected && isAvailable ? theme.foreground : theme.muted
259+
(isSelected || isFocused) && canJoin
260+
? theme.foreground
261+
: theme.muted
246262
// Clickable whenever picking would actually do something — i.e.
247263
// anything except re-picking the queue we're already in.
248264
const interactable =
249-
!pending && isAvailable && model.id !== committedModelId
265+
!pending && canJoin && model.id !== committedModelId
250266
const ahead = aheadByModel?.[model.id]
251267
const hint = !isAvailable
252268
? 'Closed'
253-
: ahead === undefined
254-
? ''
255-
: ahead === 0
256-
? 'No wait'
257-
: `${ahead} ahead`
269+
: isQuotaExhausted
270+
? model.id === FREEBUFF_GEMINI_PRO_MODEL_ID
271+
? 'Used today'
272+
: 'Limit used'
273+
: ahead === undefined
274+
? ''
275+
: ahead === 0
276+
? 'No wait'
277+
: `${ahead} ahead`
278+
const hintColor = canJoin ? theme.muted : theme.secondary
258279

259280
const borderColor = isSelected
260281
? theme.primary
261-
: (isFocused || isHovered) && interactable
282+
: isFocused || isHovered
262283
? theme.foreground
263284
: theme.border
264285

@@ -267,7 +288,7 @@ export const FreebuffModelSelector: React.FC = () => {
267288
key={model.id}
268289
onClick={() => {
269290
setFocusedId(model.id)
270-
if (isAvailable) pick(model.id)
291+
if (canJoin) pick(model.id)
271292
}}
272293
onMouseOver={() => interactable && setHoveredId(model.id)}
273294
onMouseOut={() =>
@@ -286,7 +307,9 @@ export const FreebuffModelSelector: React.FC = () => {
286307
<span
287308
fg={labelColor}
288309
attributes={
289-
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
310+
isSelected || isFocused
311+
? TextAttributes.BOLD
312+
: TextAttributes.NONE
290313
}
291314
>
292315
{model.displayName}
@@ -295,7 +318,7 @@ export const FreebuffModelSelector: React.FC = () => {
295318
{model.availability === 'deployment_hours' && (
296319
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
297320
)}
298-
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
321+
<span fg={hintColor}> {hint.padEnd(hintWidth)}</span>
299322
</text>
300323
</Button>
301324
)

cli/src/hooks/use-freebuff-session.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -516,11 +516,11 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
516516
// tick/apply path because a server-side row that hasn't been
517517
// swept yet would trip the startup-takeover branch into an
518518
// auto-POST — the exact silent-rejoin this mode exists to
519-
// prevent. But the picker still needs live queue depths for its
520-
// "N ahead" hints, so kick off a fire-and-forget GET and extract
521-
// just queueDepthByModel from the response, ignoring whatever
522-
// status it claims. Polling resumes when the user commits to a
523-
// model via joinFreebuffQueue.
519+
// prevent. But the picker still needs live queue depths and quota
520+
// snapshots, so kick off a fire-and-forget GET and extract only
521+
// picker metadata from the response, ignoring whatever status it
522+
// claims. Polling resumes when the user commits to a model via
523+
// joinFreebuffQueue.
524524
apply({ status: 'none' })
525525
const fetchController = abortController
526526
callSession('GET', token, { signal: fetchController.signal })
@@ -532,11 +532,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
532532
) {
533533
return
534534
}
535-
const depths =
536-
response.status === 'none' || response.status === 'queued'
537-
? response.queueDepthByModel
538-
: undefined
539-
if (depths) apply({ status: 'none', queueDepthByModel: depths })
535+
if (response.status === 'none' || response.status === 'queued') {
536+
apply({
537+
status: 'none',
538+
queueDepthByModel: response.queueDepthByModel,
539+
rateLimitsByModel: response.rateLimitsByModel,
540+
})
541+
}
540542
})
541543
.catch(() => {
542544
// Silent — blank hints are acceptable if the fetch fails.

cli/src/utils/__tests__/freebuff-model-navigation.test.ts

Lines changed: 13 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,50 @@
11
import { describe, expect, test } from 'bun:test'
22

3-
import {
4-
nextSelectableFreebuffModelId,
5-
resolveFreebuffModelCommitTarget,
6-
} from '../freebuff-model-navigation'
3+
import { nextFreebuffModelId } from '../freebuff-model-navigation'
74

8-
describe('nextSelectableFreebuffModelId', () => {
9-
test('skips unavailable models when moving forward', () => {
5+
describe('nextFreebuffModelId', () => {
6+
test('moves to the next model when moving forward', () => {
107
const modelIds = ['glm', 'minimax']
118

129
expect(
13-
nextSelectableFreebuffModelId({
10+
nextFreebuffModelId({
1411
modelIds,
1512
focusedId: 'minimax',
1613
direction: 'forward',
17-
isSelectable: (id) => id !== 'glm',
1814
}),
19-
).toBe('minimax')
15+
).toBe('glm')
2016
})
2117

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

2521
expect(
26-
nextSelectableFreebuffModelId({
22+
nextFreebuffModelId({
2723
modelIds,
2824
focusedId: 'minimax',
2925
direction: 'backward',
30-
isSelectable: (id) => id !== 'glm',
3126
}),
32-
).toBe('minimax')
27+
).toBe('glm')
3328
})
3429

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

3833
expect(
39-
nextSelectableFreebuffModelId({
34+
nextFreebuffModelId({
4035
modelIds,
4136
focusedId: 'minimax',
4237
direction: 'forward',
43-
isSelectable: (id) => id !== 'glm',
4438
}),
4539
).toBe('other')
4640
})
4741

48-
test('returns null when no selectable model exists', () => {
42+
test('returns null when no model exists', () => {
4943
expect(
50-
nextSelectableFreebuffModelId({
51-
modelIds: ['glm'],
44+
nextFreebuffModelId({
45+
modelIds: [],
5246
focusedId: 'glm',
5347
direction: 'forward',
54-
isSelectable: () => false,
55-
}),
56-
).toBeNull()
57-
})
58-
})
59-
60-
describe('resolveFreebuffModelCommitTarget', () => {
61-
test('falls back to the selected model when focus is on a closed model', () => {
62-
expect(
63-
resolveFreebuffModelCommitTarget({
64-
focusedId: 'glm',
65-
selectedId: 'minimax',
66-
committedId: null,
67-
isSelectable: (id) => id !== 'glm',
68-
}),
69-
).toBe('minimax')
70-
})
71-
72-
test('commits the focused model when it is selectable', () => {
73-
expect(
74-
resolveFreebuffModelCommitTarget({
75-
focusedId: 'minimax',
76-
selectedId: 'glm',
77-
committedId: null,
78-
isSelectable: (id) => id === 'minimax',
79-
}),
80-
).toBe('minimax')
81-
})
82-
83-
test('returns null when the target is already committed', () => {
84-
expect(
85-
resolveFreebuffModelCommitTarget({
86-
focusedId: 'minimax',
87-
selectedId: 'minimax',
88-
committedId: 'minimax',
89-
isSelectable: () => true,
9048
}),
9149
).toBeNull()
9250
})
Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,14 @@
1-
export function nextSelectableFreebuffModelId(params: {
1+
export function nextFreebuffModelId(params: {
22
modelIds: readonly string[]
33
focusedId: string
44
direction: 'forward' | 'backward'
5-
isSelectable: (modelId: string) => boolean
65
}): string | null {
7-
const { modelIds, focusedId, direction, isSelectable } = params
6+
const { modelIds, focusedId, direction } = params
87
if (modelIds.length === 0) return null
98

109
const currentIdx = modelIds.indexOf(focusedId)
11-
if (currentIdx === -1) return null
10+
if (currentIdx === -1) return modelIds[0] ?? null
1211

1312
const step = direction === 'forward' ? 1 : -1
14-
// Include a full wrap back to the current item so arrows stay on the same
15-
// selectable model when every peer is unavailable.
16-
for (let offset = 1; offset <= modelIds.length; offset++) {
17-
const idx =
18-
(currentIdx + step * offset + modelIds.length) % modelIds.length
19-
const candidate = modelIds[idx]
20-
if (isSelectable(candidate)) return candidate
21-
}
22-
23-
return null
24-
}
25-
26-
export function resolveFreebuffModelCommitTarget(params: {
27-
focusedId: string
28-
selectedId: string
29-
committedId: string | null
30-
isSelectable: (modelId: string) => boolean
31-
}): string | null {
32-
const { focusedId, selectedId, committedId, isSelectable } = params
33-
const targetId = isSelectable(focusedId) ? focusedId : selectedId
34-
35-
if (!isSelectable(targetId) || targetId === committedId) return null
36-
return targetId
13+
return modelIds[(currentIdx + step + modelIds.length) % modelIds.length]
3714
}

0 commit comments

Comments
 (0)