@@ -19,7 +19,7 @@ import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1919import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
2020import { useTheme } from '../hooks/use-theme'
2121import {
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 )
0 commit comments