@@ -18,10 +18,7 @@ import { useFreebuffModelStore } from '../state/freebuff-model-store'
1818import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1919import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
2020import { 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
2623import 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 )
0 commit comments