From ee91d4532a5828be2c80064274b73c2bb68b754b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 5 May 2026 16:03:07 -0700 Subject: [PATCH 1/2] Fix freebuff model tab navigation --- .../components/freebuff-model-selector.tsx | 17 +++--- .../freebuff-model-navigation.test.ts | 53 ++++++++++++++++++- cli/src/utils/freebuff-model-navigation.ts | 30 ++++++++++- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 24f87350e..1416157e1 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -19,7 +19,10 @@ import { useFreebuffModelStore } from '../state/freebuff-model-store' import { useFreebuffSessionStore } from '../state/freebuff-session-store' import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' -import { nextFreebuffModelId } from '../utils/freebuff-model-navigation' +import { + freebuffModelNavigationDirectionForKey, + nextFreebuffModelId, +} from '../utils/freebuff-model-navigation' import type { FreebuffModelOption } from '@codebuff/common/constants/freebuff-models' import type { KeyEvent } from '@opentui/core' @@ -213,27 +216,27 @@ export const FreebuffModelSelector: React.FC = () => { (key: KeyEvent) => { if (pending) return const name = key.name ?? '' - const isForward = - name === 'right' || name === 'down' || (name === 'tab' && !key.shift) - const isBackward = - name === 'left' || name === 'up' || (name === 'tab' && key.shift) + const direction = freebuffModelNavigationDirectionForKey(key) const isCommit = name === 'return' || name === 'enter' || name === 'space' - if (!isForward && !isBackward && !isCommit) return + if (!direction && !isCommit) return if (isCommit) { if (isJoinable(focusedId) && focusedId !== committedModelId) { key.preventDefault?.() + key.stopPropagation?.() pick(focusedId) } return } + if (!direction) return const targetId = nextFreebuffModelId({ modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id), focusedId, - direction: isForward ? 'forward' : 'backward', + direction, }) if (targetId) { key.preventDefault?.() + key.stopPropagation?.() setFocusedId(targetId) } }, diff --git a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts index 0df2a19a1..68157d71a 100644 --- a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts +++ b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { nextFreebuffModelId } from '../freebuff-model-navigation' +import { + freebuffModelNavigationDirectionForKey, + nextFreebuffModelId, +} from '../freebuff-model-navigation' describe('nextFreebuffModelId', () => { test('moves to the next model when moving forward', () => { @@ -49,3 +52,51 @@ describe('nextFreebuffModelId', () => { ).toBeNull() }) }) + +describe('freebuffModelNavigationDirectionForKey', () => { + test('maps arrow keys to model navigation directions', () => { + expect(freebuffModelNavigationDirectionForKey({ name: 'down' })).toBe( + 'forward', + ) + expect(freebuffModelNavigationDirectionForKey({ name: 'right' })).toBe( + 'forward', + ) + expect(freebuffModelNavigationDirectionForKey({ name: 'up' })).toBe( + 'backward', + ) + expect(freebuffModelNavigationDirectionForKey({ name: 'left' })).toBe( + 'backward', + ) + }) + + test('maps tab and shift-tab to model navigation directions', () => { + expect(freebuffModelNavigationDirectionForKey({ name: 'tab' })).toBe( + 'forward', + ) + expect( + freebuffModelNavigationDirectionForKey({ name: 'tab', shift: true }), + ).toBe('backward') + }) + + test('maps terminal tab sequences to model navigation directions', () => { + expect(freebuffModelNavigationDirectionForKey({ sequence: '\t' })).toBe( + 'forward', + ) + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[9u' }), + ).toBe('forward') + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[Z' }), + ).toBe('backward') + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[9;2u' }), + ).toBe('backward') + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[27;2;9~' }), + ).toBe('backward') + }) + + test('ignores non-navigation keys', () => { + expect(freebuffModelNavigationDirectionForKey({ name: 'enter' })).toBeNull() + }) +}) diff --git a/cli/src/utils/freebuff-model-navigation.ts b/cli/src/utils/freebuff-model-navigation.ts index d1f748d8c..1d535b1b1 100644 --- a/cli/src/utils/freebuff-model-navigation.ts +++ b/cli/src/utils/freebuff-model-navigation.ts @@ -1,7 +1,9 @@ +export type FreebuffModelNavigationDirection = 'forward' | 'backward' + export function nextFreebuffModelId(params: { modelIds: readonly string[] focusedId: string - direction: 'forward' | 'backward' + direction: FreebuffModelNavigationDirection }): string | null { const { modelIds, focusedId, direction } = params if (modelIds.length === 0) return null @@ -12,3 +14,29 @@ export function nextFreebuffModelId(params: { const step = direction === 'forward' ? 1 : -1 return modelIds[(currentIdx + step + modelIds.length) % modelIds.length] } + +export function freebuffModelNavigationDirectionForKey(key: { + name?: string + shift?: boolean + sequence?: string + raw?: string +}): FreebuffModelNavigationDirection | null { + const name = (key.name ?? '').toLowerCase() + const sequence = key.sequence ?? key.raw ?? '' + + if (name === 'right' || name === 'down') return 'forward' + if (name === 'left' || name === 'up') return 'backward' + + const isShiftTab = + (name === 'tab' && Boolean(key.shift)) || + sequence === '\x1b[Z' || + sequence === '\x1b[9;2u' || + sequence === '\x1b[27;2;9~' + if (isShiftTab) return 'backward' + + if (name === 'tab' || sequence === '\t' || sequence === '\x1b[9u') { + return 'forward' + } + + return null +} From d0f53d37a6d571a275d5201afc87cabb6598c428 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 5 May 2026 16:15:38 -0700 Subject: [PATCH 2/2] Simplify freebuff model navigation handling --- .../components/freebuff-model-selector.tsx | 6 +++-- cli/src/utils/freebuff-model-navigation.ts | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 1416157e1..2552a1107 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -35,6 +35,9 @@ const FREEBUFF_MODEL_SELECTOR_MODELS: readonly FreebuffModelOption[] = [ ...FREEBUFF_MODELS.filter((model) => model.id === DEFAULT_FREEBUFF_MODEL_ID), ...FREEBUFF_MODELS.filter((model) => model.id !== DEFAULT_FREEBUFF_MODEL_ID), ] +const FREEBUFF_MODEL_SELECTOR_MODEL_IDS = FREEBUFF_MODEL_SELECTOR_MODELS.map( + (model) => model.id, +) function formatSessionUnits(units: number): string { return Number.isInteger(units) ? String(units) : units.toFixed(1) @@ -219,7 +222,6 @@ export const FreebuffModelSelector: React.FC = () => { const direction = freebuffModelNavigationDirectionForKey(key) const isCommit = name === 'return' || name === 'enter' || name === 'space' - if (!direction && !isCommit) return if (isCommit) { if (isJoinable(focusedId) && focusedId !== committedModelId) { key.preventDefault?.() @@ -230,7 +232,7 @@ export const FreebuffModelSelector: React.FC = () => { } if (!direction) return const targetId = nextFreebuffModelId({ - modelIds: FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => model.id), + modelIds: FREEBUFF_MODEL_SELECTOR_MODEL_IDS, focusedId, direction, }) diff --git a/cli/src/utils/freebuff-model-navigation.ts b/cli/src/utils/freebuff-model-navigation.ts index 1d535b1b1..a866ae16a 100644 --- a/cli/src/utils/freebuff-model-navigation.ts +++ b/cli/src/utils/freebuff-model-navigation.ts @@ -1,5 +1,14 @@ export type FreebuffModelNavigationDirection = 'forward' | 'backward' +const FORWARD_KEY_NAMES = new Set(['right', 'down']) +const BACKWARD_KEY_NAMES = new Set(['left', 'up']) +const FORWARD_TAB_SEQUENCES = new Set(['\t', '\x1b[9u']) +const BACKWARD_TAB_SEQUENCES = new Set([ + '\x1b[Z', + '\x1b[9;2u', + '\x1b[27;2;9~', +]) + export function nextFreebuffModelId(params: { modelIds: readonly string[] focusedId: string @@ -24,17 +33,16 @@ export function freebuffModelNavigationDirectionForKey(key: { const name = (key.name ?? '').toLowerCase() const sequence = key.sequence ?? key.raw ?? '' - if (name === 'right' || name === 'down') return 'forward' - if (name === 'left' || name === 'up') return 'backward' + if (FORWARD_KEY_NAMES.has(name)) return 'forward' + if (BACKWARD_KEY_NAMES.has(name)) return 'backward' - const isShiftTab = + if ( (name === 'tab' && Boolean(key.shift)) || - sequence === '\x1b[Z' || - sequence === '\x1b[9;2u' || - sequence === '\x1b[27;2;9~' - if (isShiftTab) return 'backward' - - if (name === 'tab' || sequence === '\t' || sequence === '\x1b[9u') { + BACKWARD_TAB_SEQUENCES.has(sequence) + ) { + return 'backward' + } + if (name === 'tab' || FORWARD_TAB_SEQUENCES.has(sequence)) { return 'forward' }