Skip to content

Commit 176968e

Browse files
committed
Improve freebuff model picker UX
1 parent b5d6411 commit 176968e

8 files changed

Lines changed: 183 additions & 82 deletions

File tree

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

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1919
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
2020
import { useTheme } from '../hooks/use-theme'
2121
import {
22-
nextSelectableFreebuffModelId,
22+
nextFreebuffModelId,
2323
resolveFreebuffModelCommitTarget,
2424
} from '../utils/freebuff-model-navigation'
2525

@@ -124,11 +124,17 @@ export const FreebuffModelSelector: React.FC = () => {
124124
// when the user's selection moves between queues. The tagline is shown
125125
// inline with the name now, so it's no longer part of this slot.
126126
const hintWidth = useMemo(
127-
() => Math.max('No wait'.length, '999 ahead'.length),
127+
() =>
128+
Math.max(
129+
'No wait'.length,
130+
'999 ahead'.length,
131+
'Used today'.length,
132+
'Limit used'.length,
133+
),
128134
[],
129135
)
130136

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

161179
const pick = useCallback(
162180
(modelId: string) => {
163181
if (pending) return
164182
if (modelId === committedModelId) return
165-
if (!isFreebuffModelAvailable(modelId, new Date(now))) return
183+
if (!isJoinable(modelId)) return
166184
setPending(modelId)
167185
joinFreebuffQueue(modelId).finally(() => setPending(null))
168186
},
169-
[pending, committedModelId, now],
187+
[pending, committedModelId, isJoinable],
170188
)
171189

172190
// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
@@ -187,30 +205,26 @@ export const FreebuffModelSelector: React.FC = () => {
187205
if (isCommit) {
188206
const targetId = resolveFreebuffModelCommitTarget({
189207
focusedId,
190-
selectedId: selectedModel,
191208
committedId: committedModelId,
192-
isSelectable: (modelId) =>
193-
isFreebuffModelAvailable(modelId, new Date(now)),
209+
isSelectable: isJoinable,
194210
})
195211
if (targetId) {
196212
key.preventDefault?.()
197213
pick(targetId)
198214
}
199215
return
200216
}
201-
const targetId = nextSelectableFreebuffModelId({
217+
const targetId = nextFreebuffModelId({
202218
modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id),
203219
focusedId,
204220
direction: isForward ? 'forward' : 'backward',
205-
isSelectable: (modelId) =>
206-
isFreebuffModelAvailable(modelId, new Date(now)),
207221
})
208222
if (targetId) {
209223
key.preventDefault?.()
210224
setFocusedId(targetId)
211225
}
212226
},
213-
[pending, pick, focusedId, selectedModel, committedModelId, now],
227+
[pending, pick, focusedId, committedModelId, isJoinable],
214228
),
215229
)
216230

@@ -233,32 +247,47 @@ export const FreebuffModelSelector: React.FC = () => {
233247
// 'Selected' means the dot is filled and the label is bold. On the
234248
// landing screen ('none') this tracks the pre-focused pick; on the
235249
// 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).
250+
// way, selectedModel marks the user's current preference even if
251+
// focus has moved to a different row.
238252
const isSelected = model.id === selectedModel
239253
const isHovered = hoveredId === model.id
240254
const isFocused = focusedId === model.id && !isSelected
241255
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
242-
const indicator = isSelected ? '●' : '○'
243-
const indicatorColor = isSelected ? theme.primary : theme.muted
256+
const rateLimit = rateLimitsByModel?.[model.id]
257+
const isQuotaExhausted =
258+
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
259+
const canJoin = isAvailable && !isQuotaExhausted
260+
const indicator = isSelected ? '●' : isFocused ? '›' : '○'
261+
const indicatorColor = isSelected
262+
? theme.primary
263+
: isFocused
264+
? theme.foreground
265+
: theme.muted
244266
const labelColor =
245-
isSelected && isAvailable ? theme.foreground : theme.muted
267+
(isSelected || isFocused) && canJoin
268+
? theme.foreground
269+
: theme.muted
246270
// Clickable whenever picking would actually do something — i.e.
247271
// anything except re-picking the queue we're already in.
248272
const interactable =
249-
!pending && isAvailable && model.id !== committedModelId
273+
!pending && canJoin && model.id !== committedModelId
250274
const ahead = aheadByModel?.[model.id]
251275
const hint = !isAvailable
252276
? 'Closed'
253-
: ahead === undefined
254-
? ''
255-
: ahead === 0
256-
? 'No wait'
257-
: `${ahead} ahead`
277+
: isQuotaExhausted
278+
? model.id === FREEBUFF_GEMINI_PRO_MODEL_ID
279+
? 'Used today'
280+
: 'Limit used'
281+
: ahead === undefined
282+
? ''
283+
: ahead === 0
284+
? 'No wait'
285+
: `${ahead} ahead`
286+
const hintColor = canJoin ? theme.muted : theme.secondary
258287

259288
const borderColor = isSelected
260289
? theme.primary
261-
: (isFocused || isHovered) && interactable
290+
: isFocused || isHovered
262291
? theme.foreground
263292
: theme.border
264293

@@ -267,7 +296,7 @@ export const FreebuffModelSelector: React.FC = () => {
267296
key={model.id}
268297
onClick={() => {
269298
setFocusedId(model.id)
270-
if (isAvailable) pick(model.id)
299+
if (canJoin) pick(model.id)
271300
}}
272301
onMouseOver={() => interactable && setHoveredId(model.id)}
273302
onMouseOut={() =>
@@ -286,7 +315,9 @@ export const FreebuffModelSelector: React.FC = () => {
286315
<span
287316
fg={labelColor}
288317
attributes={
289-
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
318+
isSelected || isFocused
319+
? TextAttributes.BOLD
320+
: TextAttributes.NONE
290321
}
291322
>
292323
{model.displayName}
@@ -295,7 +326,7 @@ export const FreebuffModelSelector: React.FC = () => {
295326
{model.availability === 'deployment_hours' && (
296327
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
297328
)}
298-
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
329+
<span fg={hintColor}> {hint.padEnd(hintWidth)}</span>
299330
</text>
300331
</Button>
301332
)

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

Lines changed: 16 additions & 6 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 })
@@ -536,7 +536,17 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
536536
response.status === 'none' || response.status === 'queued'
537537
? response.queueDepthByModel
538538
: undefined
539-
if (depths) apply({ status: 'none', queueDepthByModel: depths })
539+
const rateLimits =
540+
'rateLimitsByModel' in response
541+
? response.rateLimitsByModel
542+
: undefined
543+
if (depths || rateLimits) {
544+
apply({
545+
status: 'none',
546+
...(depths ? { queueDepthByModel: depths } : {}),
547+
...(rateLimits ? { rateLimitsByModel: rateLimits } : {}),
548+
})
549+
}
540550
})
541551
.catch(() => {
542552
// Silent — blank hints are acceptable if the fetch fails.

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

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,73 @@
11
import { describe, expect, test } from 'bun:test'
22

33
import {
4-
nextSelectableFreebuffModelId,
4+
nextFreebuffModelId,
55
resolveFreebuffModelCommitTarget,
66
} from '../freebuff-model-navigation'
77

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

1212
expect(
13-
nextSelectableFreebuffModelId({
13+
nextFreebuffModelId({
1414
modelIds,
1515
focusedId: 'minimax',
1616
direction: 'forward',
17-
isSelectable: (id) => id !== 'glm',
1817
}),
19-
).toBe('minimax')
18+
).toBe('glm')
2019
})
2120

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

2524
expect(
26-
nextSelectableFreebuffModelId({
25+
nextFreebuffModelId({
2726
modelIds,
2827
focusedId: 'minimax',
2928
direction: 'backward',
30-
isSelectable: (id) => id !== 'glm',
3129
}),
32-
).toBe('minimax')
30+
).toBe('glm')
3331
})
3432

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

3836
expect(
39-
nextSelectableFreebuffModelId({
37+
nextFreebuffModelId({
4038
modelIds,
4139
focusedId: 'minimax',
4240
direction: 'forward',
43-
isSelectable: (id) => id !== 'glm',
4441
}),
4542
).toBe('other')
4643
})
4744

48-
test('returns null when no selectable model exists', () => {
45+
test('returns null when no model exists', () => {
4946
expect(
50-
nextSelectableFreebuffModelId({
51-
modelIds: ['glm'],
47+
nextFreebuffModelId({
48+
modelIds: [],
5249
focusedId: 'glm',
5350
direction: 'forward',
54-
isSelectable: () => false,
5551
}),
5652
).toBeNull()
5753
})
5854
})
5955

6056
describe('resolveFreebuffModelCommitTarget', () => {
61-
test('falls back to the selected model when focus is on a closed model', () => {
57+
test('returns null when focus is on a closed model', () => {
6258
expect(
6359
resolveFreebuffModelCommitTarget({
6460
focusedId: 'glm',
65-
selectedId: 'minimax',
6661
committedId: null,
6762
isSelectable: (id) => id !== 'glm',
6863
}),
69-
).toBe('minimax')
64+
).toBeNull()
7065
})
7166

7267
test('commits the focused model when it is selectable', () => {
7368
expect(
7469
resolveFreebuffModelCommitTarget({
7570
focusedId: 'minimax',
76-
selectedId: 'glm',
7771
committedId: null,
7872
isSelectable: (id) => id === 'minimax',
7973
}),
@@ -84,7 +78,6 @@ describe('resolveFreebuffModelCommitTarget', () => {
8478
expect(
8579
resolveFreebuffModelCommitTarget({
8680
focusedId: 'minimax',
87-
selectedId: 'minimax',
8881
committedId: 'minimax',
8982
isSelectable: () => true,
9083
}),
Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,25 @@
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
13+
return modelIds[(currentIdx + step + modelIds.length) % modelIds.length]
2414
}
2515

2616
export function resolveFreebuffModelCommitTarget(params: {
2717
focusedId: string
28-
selectedId: string
2918
committedId: string | null
3019
isSelectable: (modelId: string) => boolean
3120
}): string | null {
32-
const { focusedId, selectedId, committedId, isSelectable } = params
33-
const targetId = isSelectable(focusedId) ? focusedId : selectedId
21+
const { focusedId, committedId, isSelectable } = params
3422

35-
if (!isSelectable(targetId) || targetId === committedId) return null
36-
return targetId
23+
if (!isSelectable(focusedId) || focusedId === committedId) return null
24+
return focusedId
3725
}

0 commit comments

Comments
 (0)