11import { TextAttributes } from '@opentui/core'
22import { useKeyboard } from '@opentui/react'
3- import React , { useCallback , useMemo , useState } from 'react'
3+ import React , { useCallback , useEffect , useMemo , useState } from 'react'
44
55import { Button } from './button'
66import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
@@ -13,9 +13,13 @@ import { useTheme } from '../hooks/use-theme'
1313import type { KeyEvent } from '@opentui/core'
1414
1515/**
16- * Lets the user pick which model's queue they're in. Tapping a different model
17- * (or cycling to it via Tab / arrow keys) triggers a re-POST: the server moves
18- * them to the back of the new model's queue.
16+ * Lets the user pick which model's queue they're in. Switching triggers a
17+ * re-POST: the server moves them to the back of the new model's queue, which
18+ * means switching is *not free* — they lose their place in the original line.
19+ *
20+ * To prevent accidental queue loss, keyboard navigation is two-step: Tab /
21+ * arrow keys move a focus highlight, and Enter commits the switch. Mouse
22+ * clicks are still one-step (the click target is intentional).
1923 *
2024 * Each row shows a live "N ahead" count sourced from the server's
2125 * `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
@@ -27,6 +31,14 @@ export const FreebuffModelSelector: React.FC = () => {
2731 const session = useFreebuffSessionStore ( ( s ) => s . session )
2832 const [ pending , setPending ] = useState < string | null > ( null )
2933 const [ hoveredId , setHoveredId ] = useState < string | null > ( null )
34+ // Keyboard cursor — separate from the actually-selected model so that
35+ // Tab/arrow navigation can preview without committing. Re-syncs to the
36+ // selected model whenever the selection changes (after a successful switch
37+ // or an external selectedModel update).
38+ const [ focusedId , setFocusedId ] = useState < string > ( selectedModel )
39+ useEffect ( ( ) => {
40+ setFocusedId ( selectedModel )
41+ } , [ selectedModel ] )
3042
3143 // For the user's current queue, "ahead" is `position - 1` (themselves don't
3244 // count). For every other queue, switching would land them at the back, so
@@ -66,29 +78,40 @@ export const FreebuffModelSelector: React.FC = () => {
6678 [ pending , selectedModel ] ,
6779 )
6880
69- // Tab / Shift+Tab and Left/Right arrow keys cycle through the model buttons.
70- // Up/Down intentionally do nothing so they don't fight other vertical UI.
81+ // Tab / Shift+Tab and Left/Right arrow keys move the focus highlight only;
82+ // Enter or Space commits the switch. Two-step navigation prevents the user
83+ // from accidentally giving up their place in line by tabbing past their
84+ // queue. Up/Down intentionally do nothing so they don't fight other
85+ // vertical UI.
7186 useKeyboard (
7287 useCallback (
7388 ( key : KeyEvent ) => {
7489 if ( pending ) return
7590 const name = key . name ?? ''
7691 const isForward = name === 'right' || ( name === 'tab' && ! key . shift )
7792 const isBackward = name === 'left' || ( name === 'tab' && key . shift )
78- if ( ! isForward && ! isBackward ) return
79- const currentIdx = FREEBUFF_MODELS . findIndex ( ( m ) => m . id === selectedModel )
93+ const isCommit = name === 'return' || name === 'enter' || name === 'space'
94+ if ( ! isForward && ! isBackward && ! isCommit ) return
95+ if ( isCommit ) {
96+ if ( focusedId !== selectedModel ) {
97+ key . preventDefault ?.( )
98+ pick ( focusedId )
99+ }
100+ return
101+ }
102+ const currentIdx = FREEBUFF_MODELS . findIndex ( ( m ) => m . id === focusedId )
80103 if ( currentIdx === - 1 ) return
81104 const len = FREEBUFF_MODELS . length
82105 const nextIdx = isForward
83106 ? ( currentIdx + 1 ) % len
84107 : ( currentIdx - 1 + len ) % len
85108 const target = FREEBUFF_MODELS [ nextIdx ]
86- if ( target && target . id !== selectedModel ) {
109+ if ( target ) {
87110 key . preventDefault ?.( )
88- pick ( target . id )
111+ setFocusedId ( target . id )
89112 }
90113 } ,
91- [ pending , pick , selectedModel ] ,
114+ [ pending , pick , focusedId , selectedModel ] ,
92115 ) ,
93116 )
94117
@@ -109,6 +132,7 @@ export const FreebuffModelSelector: React.FC = () => {
109132 { FREEBUFF_MODELS . map ( ( model ) => {
110133 const isSelected = model . id === selectedModel
111134 const isHovered = hoveredId === model . id
135+ const isFocused = focusedId === model . id && ! isSelected
112136 const indicator = isSelected ? '●' : '○'
113137 const indicatorColor = isSelected ? theme . primary : theme . muted
114138 const labelColor = isSelected ? theme . foreground : theme . muted
@@ -123,14 +147,17 @@ export const FreebuffModelSelector: React.FC = () => {
123147
124148 const borderColor = isSelected
125149 ? theme . primary
126- : isHovered && interactable
150+ : ( isFocused || isHovered ) && interactable
127151 ? theme . foreground
128152 : theme . border
129153
130154 return (
131155 < Button
132156 key = { model . id }
133- onClick = { ( ) => pick ( model . id ) }
157+ onClick = { ( ) => {
158+ setFocusedId ( model . id )
159+ pick ( model . id )
160+ } }
134161 onMouseOver = { ( ) => interactable && setHoveredId ( model . id ) }
135162 onMouseOut = { ( ) => setHoveredId ( ( curr ) => ( curr === model . id ? null : curr ) ) }
136163 style = { {
0 commit comments