Skip to content

Commit c81f568

Browse files
committed
Freebuff: add two-step keyboard navigation to model selector
1 parent 0c2d84e commit c81f568

1 file changed

Lines changed: 40 additions & 13 deletions

File tree

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

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { TextAttributes } from '@opentui/core'
22
import { useKeyboard } from '@opentui/react'
3-
import React, { useCallback, useMemo, useState } from 'react'
3+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
44

55
import { Button } from './button'
66
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
@@ -13,9 +13,13 @@ import { useTheme } from '../hooks/use-theme'
1313
import 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

Comments
 (0)