Skip to content

Commit e1529ba

Browse files
[codex] Fix freebuff model picker enter (#545)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent 2bd8f2a commit e1529ba

5 files changed

Lines changed: 166 additions & 20 deletions

File tree

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

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { useFreebuffModelStore } from '../state/freebuff-model-store'
1717
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1818
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1919
import { useTheme } from '../hooks/use-theme'
20+
import {
21+
nextSelectableFreebuffModelId,
22+
resolveFreebuffModelCommitTarget,
23+
} from '../utils/freebuff-model-navigation'
2024

2125
import type { KeyEvent } from '@opentui/core'
2226

@@ -173,30 +177,32 @@ export const FreebuffModelSelector: React.FC = () => {
173177
name === 'return' || name === 'enter' || name === 'space'
174178
if (!isForward && !isBackward && !isCommit) return
175179
if (isCommit) {
176-
if (
177-
focusedId !== committedModelId &&
178-
isFreebuffModelAvailable(focusedId, new Date(now))
179-
) {
180+
const targetId = resolveFreebuffModelCommitTarget({
181+
focusedId,
182+
selectedId: selectedModel,
183+
committedId: committedModelId,
184+
isSelectable: (modelId) =>
185+
isFreebuffModelAvailable(modelId, new Date(now)),
186+
})
187+
if (targetId) {
180188
key.preventDefault?.()
181-
pick(focusedId)
189+
pick(targetId)
182190
}
183191
return
184192
}
185-
const currentIdx = FREEBUFF_MODEL_SELECTOR_MODELS.findIndex(
186-
(m) => m.id === focusedId,
187-
)
188-
if (currentIdx === -1) return
189-
const len = FREEBUFF_MODEL_SELECTOR_MODELS.length
190-
const nextIdx = isForward
191-
? (currentIdx + 1) % len
192-
: (currentIdx - 1 + len) % len
193-
const target = FREEBUFF_MODEL_SELECTOR_MODELS[nextIdx]
194-
if (target) {
193+
const targetId = nextSelectableFreebuffModelId({
194+
modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id),
195+
focusedId,
196+
direction: isForward ? 'forward' : 'backward',
197+
isSelectable: (modelId) =>
198+
isFreebuffModelAvailable(modelId, new Date(now)),
199+
})
200+
if (targetId) {
195201
key.preventDefault?.()
196-
setFocusedId(target.id)
202+
setFocusedId(targetId)
197203
}
198204
},
199-
[pending, pick, focusedId, committedModelId, now],
205+
[pending, pick, focusedId, selectedModel, committedModelId, now],
200206
),
201207
)
202208

@@ -219,7 +225,8 @@ export const FreebuffModelSelector: React.FC = () => {
219225
// 'Selected' means the dot is filled and the label is bold. On the
220226
// landing screen ('none') this tracks the pre-focused pick; on the
221227
// queued screen it tracks the model the server has us on. Either
222-
// way, selectedModel reflects the intent of "what Enter commits to."
228+
// way, selectedModel is the safe fallback if focus ever lands on a
229+
// closed row (for example when deployment hours change).
223230
const isSelected = model.id === selectedModel
224231
const isHovered = hoveredId === model.id
225232
const isFocused = focusedId === model.id && !isSelected

cli/src/components/waiting-room-screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
173173
maxWidth: contentMaxWidth,
174174
}}
175175
>
176-
{error && !session && (
176+
{error && (!session || session.status === 'none') && (
177177
<text style={{ fg: theme.secondary, wrapMode: 'word' }}>
178178
{error}
179179
</text>

cli/src/hooks/use-freebuff-session.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
376376
let abortController = new AbortController()
377377
let timer: ReturnType<typeof setTimeout> | null = null
378378
let previousStatus: FreebuffSessionResponse['status'] | null = null
379+
let restartGeneration = 0
379380
// Method for the NEXT tick. GET is read-only; POST claims/rotates a seat.
380381
// Startup is GET (probe before committing). After any POST completes we
381382
// flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin;
@@ -489,6 +490,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
489490

490491
controller = {
491492
restart: async (mode) => {
493+
const generation = ++restartGeneration
492494
clearTimer()
493495
// Abort any in-flight fetch so it can't race us and overwrite state.
494496
abortController.abort()
@@ -498,6 +500,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
498500
// doesn't bounce a 'landing' restart straight back to 'ended'.
499501
previousStatus = null
500502
if (mode === 'landing') {
503+
nextMethod = 'GET'
501504
// Land on the picker immediately. We can't go through the normal
502505
// tick/apply path because a server-side row that hasn't been
503506
// swept yet would trip the startup-takeover branch into an
@@ -511,7 +514,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
511514
const fetchController = abortController
512515
callSession('GET', token, { signal: fetchController.signal })
513516
.then((response) => {
514-
if (cancelled || fetchController.signal.aborted) return
517+
if (
518+
cancelled ||
519+
fetchController.signal.aborted ||
520+
generation !== restartGeneration
521+
) {
522+
return
523+
}
515524
const depths =
516525
response.status === 'none' || response.status === 'queued'
517526
? response.queueDepthByModel
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import {
4+
nextSelectableFreebuffModelId,
5+
resolveFreebuffModelCommitTarget,
6+
} from '../freebuff-model-navigation'
7+
8+
describe('nextSelectableFreebuffModelId', () => {
9+
test('skips unavailable models when moving forward', () => {
10+
const modelIds = ['glm', 'minimax']
11+
12+
expect(
13+
nextSelectableFreebuffModelId({
14+
modelIds,
15+
focusedId: 'minimax',
16+
direction: 'forward',
17+
isSelectable: (id) => id !== 'glm',
18+
}),
19+
).toBe('minimax')
20+
})
21+
22+
test('skips unavailable models when moving backward', () => {
23+
const modelIds = ['glm', 'minimax']
24+
25+
expect(
26+
nextSelectableFreebuffModelId({
27+
modelIds,
28+
focusedId: 'minimax',
29+
direction: 'backward',
30+
isSelectable: (id) => id !== 'glm',
31+
}),
32+
).toBe('minimax')
33+
})
34+
35+
test('moves to the next available model when more than one is selectable', () => {
36+
const modelIds = ['glm', 'minimax', 'other']
37+
38+
expect(
39+
nextSelectableFreebuffModelId({
40+
modelIds,
41+
focusedId: 'minimax',
42+
direction: 'forward',
43+
isSelectable: (id) => id !== 'glm',
44+
}),
45+
).toBe('other')
46+
})
47+
48+
test('returns null when no selectable model exists', () => {
49+
expect(
50+
nextSelectableFreebuffModelId({
51+
modelIds: ['glm'],
52+
focusedId: 'glm',
53+
direction: 'forward',
54+
isSelectable: () => false,
55+
}),
56+
).toBeNull()
57+
})
58+
})
59+
60+
describe('resolveFreebuffModelCommitTarget', () => {
61+
test('falls back to the selected model when focus is on a closed model', () => {
62+
expect(
63+
resolveFreebuffModelCommitTarget({
64+
focusedId: 'glm',
65+
selectedId: 'minimax',
66+
committedId: null,
67+
isSelectable: (id) => id !== 'glm',
68+
}),
69+
).toBe('minimax')
70+
})
71+
72+
test('commits the focused model when it is selectable', () => {
73+
expect(
74+
resolveFreebuffModelCommitTarget({
75+
focusedId: 'minimax',
76+
selectedId: 'glm',
77+
committedId: null,
78+
isSelectable: (id) => id === 'minimax',
79+
}),
80+
).toBe('minimax')
81+
})
82+
83+
test('returns null when the target is already committed', () => {
84+
expect(
85+
resolveFreebuffModelCommitTarget({
86+
focusedId: 'minimax',
87+
selectedId: 'minimax',
88+
committedId: 'minimax',
89+
isSelectable: () => true,
90+
}),
91+
).toBeNull()
92+
})
93+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export function nextSelectableFreebuffModelId(params: {
2+
modelIds: readonly string[]
3+
focusedId: string
4+
direction: 'forward' | 'backward'
5+
isSelectable: (modelId: string) => boolean
6+
}): string | null {
7+
const { modelIds, focusedId, direction, isSelectable } = params
8+
if (modelIds.length === 0) return null
9+
10+
const currentIdx = modelIds.indexOf(focusedId)
11+
if (currentIdx === -1) return null
12+
13+
const step = direction === 'forward' ? 1 : -1
14+
// Include a full wrap back to the current item so arrows stay on the same
15+
// selectable model when every peer is unavailable.
16+
for (let offset = 1; offset <= modelIds.length; offset++) {
17+
const idx =
18+
(currentIdx + step * offset + modelIds.length) % modelIds.length
19+
const candidate = modelIds[idx]
20+
if (isSelectable(candidate)) return candidate
21+
}
22+
23+
return null
24+
}
25+
26+
export function resolveFreebuffModelCommitTarget(params: {
27+
focusedId: string
28+
selectedId: string
29+
committedId: string | null
30+
isSelectable: (modelId: string) => boolean
31+
}): string | null {
32+
const { focusedId, selectedId, committedId, isSelectable } = params
33+
const targetId = isSelectable(focusedId) ? focusedId : selectedId
34+
35+
if (!isSelectable(targetId) || targetId === committedId) return null
36+
return targetId
37+
}

0 commit comments

Comments
 (0)