@@ -39,17 +39,25 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [
3939 * the user to the back of that queue (lose place in original). Picking the
4040 * model they're already in is a no-op.
4141 *
42- * To prevent accidental queue loss while queued, keyboard navigation is
43- * two-step: Tab / arrow keys move a focus highlight, and Enter commits the
44- * switch. Mouse clicks are still one-step. On the landing screen, pressing
45- * Enter on the already-focused model also commits — there's nothing to lose.
42+ * Keyboard navigation: Tab / arrow keys move the green highlight; Enter (or
43+ * Space) commits the focused row. Mouse click commits in one step.
4644 *
47- * Each row shows a live "N ahead" count sourced from the server's
48- * `queueDepthByModel` snapshot so the choice is informed.
45+ * Always stacked vertically. On narrow terminals where the longest one-line
46+ * label wouldn't fit, the secondary details (warning / deployment hours)
47+ * spill onto an indented second line under the name.
48+ *
49+ * No queue-position hint: traffic doesn't reach the threshold where a wait
50+ * would form, so showing "N in line" everywhere just adds noise (and width).
51+ * The picker still surfaces "Closed" (outside deployment hours) and "Limit
52+ * used" (per-user quota) inline since those gate the actual click.
4953 */
5054export const FreebuffModelSelector : React . FC = ( ) => {
5155 const theme = useTheme ( )
52- const { terminalWidth } = useTerminalDimensions ( )
56+ // contentMaxWidth (not terminalWidth) is the real budget — the parent
57+ // waiting-room screen wraps this picker in a `maxWidth: contentMaxWidth`
58+ // box (capped at 80 cols), so a wide terminal doesn't actually let us
59+ // sprawl the buttons across it.
60+ const { contentMaxWidth } = useTerminalDimensions ( )
5361 const selectedModel = useFreebuffModelStore ( ( s ) => s . selectedModel )
5462 const setSelectedModel = useFreebuffModelStore ( ( s ) => s . setSelectedModel )
5563 const session = useFreebuffSessionStore ( ( s ) => s . session )
@@ -83,70 +91,64 @@ export const FreebuffModelSelector: React.FC = () => {
8391 }
8492 } , [ now , selectedModel , session , setSelectedModel ] )
8593
86- // Landing ('none'): depths come from the server snapshot, no "self" to
87- // subtract. In-queue ('queued'): for the user's queue, "ahead" is
88- // `position - 1` (themselves don't count); for every other queue, switching
89- // would land them at the back, so it's that queue's full depth. Null before
90- // any snapshot so the UI doesn't flash misleading zeros — in particular,
91- // landing mode after a session ends initially sets status='none' with no
92- // queueDepthByModel; returning null here keeps the hint blank until the
93- // fetch lands, instead of showing "No wait" on every row.
94- const aheadByModel = useMemo < Record < string , number > | null > ( ( ) => {
95- if ( session ?. status === 'none' ) {
96- if ( ! session . queueDepthByModel ) return null
97- const depths = session . queueDepthByModel
98- const out : Record < string , number > = { }
99- for ( const { id } of FREEBUFF_MODELS ) out [ id ] = depths [ id ] ?? 0
100- return out
101- }
102- if ( session ?. status === 'queued' ) {
103- const depths = session . queueDepthByModel ?? { }
104- const out : Record < string , number > = { }
105- for ( const { id } of FREEBUFF_MODELS ) {
106- out [ id ] =
107- id === session . model
108- ? Math . max ( 0 , session . position - 1 )
109- : ( depths [ id ] ?? 0 )
94+ const BUTTON_CHROME = 4 // 2 border + 2 padding
95+
96+ // Decide whether secondary details (warning / deployment hours) get their
97+ // own indented line under the name. Trigger: the widest one-line button
98+ // wouldn't fit in our content budget. All buttons share a uniform width so
99+ // the column reads as a clean stack of equal choices. We size to the
100+ // *label* — Closed / Limit used hints can transiently push the text past
101+ // this width, but they're rare (deployment hours closing, daily quota hit)
102+ // and a small one-time grow is fine.
103+ const { wrapDetails, buttonOuterWidth } = useMemo ( ( ) => {
104+ const detailsTextLen = ( model : FreebuffModelOption ) : number => {
105+ const parts : number [ ] = [ ]
106+ if ( model . availability === 'deployment_hours' ) {
107+ parts . push ( deploymentAvailabilityLabel . length )
110108 }
111- return out
109+ if ( model . warning ) parts . push ( model . warning . length )
110+ if ( parts . length === 0 ) return 0
111+ return parts . reduce ( ( a , b ) => a + b , 0 ) + ( parts . length - 1 ) * 3 /* " · " */
112112 }
113- return null
114- } , [ session ] )
115113
116- // Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
117- // buttons don't visibly resize when the queue depth ticks down (12 → 9) or
118- // when the user's selection moves between queues. The tagline is shown
119- // inline with the name now, so it's no longer part of this slot.
120- const hintWidth = useMemo (
121- ( ) => Math . max ( 'No wait' . length , '999 ahead' . length , 'Limit used' . length ) ,
122- [ ] ,
123- )
124-
125- // Decide row vs column layout based on whether the buttons actually fit
126- // side-by-side. Each button's inner text is
127- // "● {displayName} · {tagline} · {hours/warning} {hint}",
128- // plus 2 cols of border and 2 cols of padding. Buttons are separated by a
129- // gap of 2. If the total exceeds the terminal width, stack vertically.
130- const stackVertically = useMemo ( ( ) => {
131- const BUTTON_CHROME = 4 // 2 border + 2 padding
132- const GAP = 2
133- const total = FREEBUFF_MODEL_SELECTOR_MODELS . reduce ( ( sum , model , idx ) => {
134- const inner =
114+ const oneLineLen = ( model : FreebuffModelOption ) : number => {
115+ const inlineDetails = detailsTextLen ( model )
116+ return (
135117 2 /* indicator + space */ +
136118 model . displayName . length +
137119 3 /* " · " */ +
138120 model . tagline . length +
139- ( model . availability === 'deployment_hours'
140- ? 3 + deploymentAvailabilityLabel . length
141- : 0 ) +
142- ( model . warning ? 3 + model . warning . length : 0 ) +
143- 2 /* " " */ +
144- hintWidth
145- return sum + inner + BUTTON_CHROME + ( idx > 0 ? GAP : 0 )
146- } , 0 )
147- // Leave a small margin for the surrounding padding on the waiting-room screen.
148- return total > terminalWidth - 4
149- } , [ deploymentAvailabilityLabel , hintWidth , terminalWidth ] )
121+ ( inlineDetails > 0 ? 3 + inlineDetails : 0 )
122+ )
123+ }
124+
125+ const labelLineLen = ( model : FreebuffModelOption ) : number =>
126+ 2 + model . displayName . length + 3 + model . tagline . length
127+
128+ const detailsLineLen = ( model : FreebuffModelOption ) : number => {
129+ const len = detailsTextLen ( model )
130+ return len === 0 ? 0 : 2 /* indent */ + len
131+ }
132+
133+ const maxOneLineOuter =
134+ Math . max ( ...FREEBUFF_MODEL_SELECTOR_MODELS . map ( oneLineLen ) ) +
135+ BUTTON_CHROME
136+ if ( maxOneLineOuter <= contentMaxWidth ) {
137+ return { wrapDetails : false , buttonOuterWidth : maxOneLineOuter }
138+ }
139+ const maxTwoLineInner = Math . max (
140+ ...FREEBUFF_MODEL_SELECTOR_MODELS . map ( ( m ) =>
141+ Math . max ( labelLineLen ( m ) , detailsLineLen ( m ) ) ,
142+ ) ,
143+ )
144+ return {
145+ wrapDetails : true ,
146+ buttonOuterWidth : Math . min (
147+ maxTwoLineInner + BUTTON_CHROME ,
148+ contentMaxWidth ,
149+ ) ,
150+ }
151+ } , [ contentMaxWidth , deploymentAvailabilityLabel ] )
150152
151153 // "Already committed to this model" — only when the server has us queued
152154 // on it. On the landing screen (status 'none'), nothing is committed yet,
@@ -177,8 +179,8 @@ export const FreebuffModelSelector: React.FC = () => {
177179 )
178180
179181 // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
180- // Space commits the switch . Two-step navigation prevents the user from
181- // accidentally giving up their place in line by tabbing past their queue .
182+ // Space commits the focused row . Two-step navigation lets the user preview
183+ // the highlight before committing .
182184 useKeyboard (
183185 useCallback (
184186 ( key : KeyEvent ) => {
@@ -220,103 +222,109 @@ export const FreebuffModelSelector: React.FC = () => {
220222 gap : 0 ,
221223 } }
222224 >
223- < box
224- style = { {
225- flexDirection : stackVertically ? 'column' : 'row' ,
226- gap : stackVertically ? 0 : 2 ,
227- alignItems : 'flex-start' ,
228- } }
229- >
230- { FREEBUFF_MODEL_SELECTOR_MODELS . map ( ( model ) => {
231- // 'Selected' means the dot is filled and the label is bold. On the
232- // landing screen ('none') this tracks the pre-focused pick; on the
233- // queued screen it tracks the model the server has us on. Either
234- // way, selectedModel marks the user's current preference even if
235- // focus has moved to a different row.
236- const isSelected = model . id === selectedModel
237- const isHovered = hoveredId === model . id
238- const isFocused = focusedId === model . id && ! isSelected
239- const isAvailable = isFreebuffModelAvailable ( model . id , new Date ( now ) )
240- const rateLimit = rateLimitsByModel ?. [ model . id ]
241- const isQuotaExhausted =
242- rateLimit !== undefined && rateLimit . recentCount >= rateLimit . limit
243- const canJoin = isAvailable && ! isQuotaExhausted
244- const indicator = isSelected ? '●' : isFocused ? '›' : '○'
245- const indicatorColor = isSelected
246- ? theme . primary
247- : isFocused
248- ? theme . foreground
249- : theme . muted
250- const labelColor =
251- ( isSelected || isFocused ) && canJoin
252- ? theme . foreground
253- : theme . muted
254- // Clickable whenever picking would actually do something — i.e.
255- // anything except re-picking the queue we're already in.
256- const interactable =
257- ! pending && canJoin && model . id !== committedModelId
258- const ahead = aheadByModel ?. [ model . id ]
259- const hint = ! isAvailable
260- ? 'Closed'
261- : isQuotaExhausted
262- ? 'Limit used'
263- : ahead === undefined
264- ? ''
265- : ahead === 0
266- ? 'No wait'
267- : `${ ahead } ahead`
268- const hintColor = canJoin ? theme . muted : theme . secondary
225+ { FREEBUFF_MODEL_SELECTOR_MODELS . map ( ( model ) => {
226+ // Single visual state: the focused row IS the highlight. The user's
227+ // saved/committed pick is not shown separately — it just sets where
228+ // focus lands when the picker opens. Pressing Enter on the focused
229+ // row commits it.
230+ const isHovered = hoveredId === model . id
231+ const isFocused = focusedId === model . id
232+ const isAvailable = isFreebuffModelAvailable ( model . id , new Date ( now ) )
233+ const rateLimit = rateLimitsByModel ?. [ model . id ]
234+ const isQuotaExhausted =
235+ rateLimit !== undefined && rateLimit . recentCount >= rateLimit . limit
236+ const canJoin = isAvailable && ! isQuotaExhausted
237+ // Clickable whenever picking would actually do something — i.e.
238+ // anything except re-picking the queue we're already in.
239+ const interactable =
240+ ! pending && canJoin && model . id !== committedModelId
241+ const hint = ! isAvailable
242+ ? 'Closed'
243+ : isQuotaExhausted
244+ ? 'Limit used'
245+ : ''
246+
247+ // Focused row: green border + green name to tie back to the border.
248+ // The rest of the row keeps the normal muted/secondary palette so
249+ // the highlight stays subtle. Off-focus rows are entirely default.
250+ const indicator = isFocused ? '›' : ' '
251+ const fgColor = isFocused
252+ ? theme . primary
253+ : canJoin
254+ ? theme . foreground
255+ : theme . muted
256+ const mutedColor = theme . muted
257+ const warningColor = theme . secondary
258+ const hintColor = theme . secondary
259+
260+ const borderColor = isFocused
261+ ? theme . primary
262+ : isHovered
263+ ? theme . foreground
264+ : theme . border
269265
270- const borderColor = isSelected
271- ? theme . primary
272- : isFocused || isHovered
273- ? theme . foreground
274- : theme . border
266+ const showInlineHours =
267+ ! wrapDetails && model . availability === 'deployment_hours'
268+ const showInlineWarning = ! wrapDetails && ! ! model . warning
269+ const showWrappedDetails =
270+ wrapDetails &&
271+ ( model . availability === 'deployment_hours' || ! ! model . warning )
275272
276- return (
277- < Button
278- key = { model . id }
279- onClick = { ( ) => {
280- setFocusedId ( model . id )
281- if ( canJoin ) pick ( model . id )
282- } }
283- onMouseOver = { ( ) => interactable && setHoveredId ( model . id ) }
284- onMouseOut = { ( ) =>
285- setHoveredId ( ( curr ) => ( curr === model . id ? null : curr ) )
286- }
287- style = { {
288- borderStyle : 'single' ,
289- borderColor,
290- paddingLeft : 1 ,
291- paddingRight : 1 ,
292- } }
293- border = { [ 'top' , 'bottom' , 'left' , 'right' ] }
294- >
273+ return (
274+ < Button
275+ key = { model . id }
276+ onClick = { ( ) => {
277+ setFocusedId ( model . id )
278+ if ( canJoin ) pick ( model . id )
279+ } }
280+ onMouseOver = { ( ) => interactable && setHoveredId ( model . id ) }
281+ onMouseOut = { ( ) =>
282+ setHoveredId ( ( curr ) => ( curr === model . id ? null : curr ) )
283+ }
284+ style = { {
285+ borderStyle : 'single' ,
286+ borderColor,
287+ paddingLeft : 1 ,
288+ paddingRight : 1 ,
289+ width : buttonOuterWidth ,
290+ } }
291+ border = { [ 'top' , 'bottom' , 'left' , 'right' ] }
292+ >
293+ < text >
294+ < span fg = { fgColor } > { indicator } </ span >
295+ < span
296+ fg = { fgColor }
297+ attributes = {
298+ isFocused ? TextAttributes . BOLD : TextAttributes . NONE
299+ }
300+ >
301+ { model . displayName }
302+ </ span >
303+ < span fg = { mutedColor } > · { model . tagline } </ span >
304+ { showInlineHours && (
305+ < span fg = { mutedColor } > · { deploymentAvailabilityLabel } </ span >
306+ ) }
307+ { showInlineWarning && (
308+ < span fg = { warningColor } > · { model . warning } </ span >
309+ ) }
310+ { hint && < span fg = { hintColor } > { hint } </ span > }
311+ </ text >
312+ { showWrappedDetails && (
295313 < text >
296- < span fg = { indicatorColor } > { indicator } </ span >
297- < span
298- fg = { labelColor }
299- attributes = {
300- isSelected || isFocused
301- ? TextAttributes . BOLD
302- : TextAttributes . NONE
303- }
304- >
305- { model . displayName }
306- </ span >
307- < span fg = { theme . muted } > · { model . tagline } </ span >
314+ < span > </ span >
308315 { model . availability === 'deployment_hours' && (
309- < span fg = { theme . muted } > · { deploymentAvailabilityLabel } </ span >
316+ < span fg = { mutedColor } > { deploymentAvailabilityLabel } </ span >
310317 ) }
318+ { model . availability === 'deployment_hours' &&
319+ model . warning && < span fg = { mutedColor } > · </ span > }
311320 { model . warning && (
312- < span fg = { theme . secondary } > · { model . warning } </ span >
321+ < span fg = { warningColor } > { model . warning } </ span >
313322 ) }
314- < span fg = { hintColor } > { hint . padEnd ( hintWidth ) } </ span >
315323 </ text >
316- </ Button >
317- )
318- } ) }
319- </ box >
324+ ) }
325+ </ Button >
326+ )
327+ } ) }
320328 </ box >
321329 )
322330}
0 commit comments