diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index 2850762f..b737b2f7 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -165,7 +165,7 @@ describe('AuthDialog', () => { ); // This is a bit brittle, but it's the best way to check which item is selected. - expect(lastFrame()).toContain('● Login with Google'); + expect(lastFrame()).toContain('● 1. Login with Google'); }); it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => { @@ -188,7 +188,7 @@ describe('AuthDialog', () => { ); // Default is LOGIN_WITH_GOOGLE - expect(lastFrame()).toContain('● Login with Google'); + expect(lastFrame()).toContain('● 1. Login with Google'); }); it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => { @@ -217,7 +217,7 @@ describe('AuthDialog', () => { ); // Default is LOGIN_WITH_GOOGLE - expect(lastFrame()).toContain('● Login with Google'); + expect(lastFrame()).toContain('● 1. Login with Google'); }); }); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 0ca176cb..7d386dca 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -204,6 +204,7 @@ export function ThemeDialog({ isFocused={currenFocusedSection === 'theme'} maxItemsToShow={8} showScrollArrows={true} + showNumbers={currenFocusedSection === 'theme'} /> {/* Scope Selection */} @@ -218,6 +219,7 @@ export function ThemeDialog({ onSelect={handleScopeSelect} onHighlight={handleScopeHighlight} isFocused={currenFocusedSection === 'scope'} + showNumbers={currenFocusedSection === 'scope'} /> )} diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx new file mode 100644 index 00000000..4b36fe3c --- /dev/null +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { + RadioButtonSelect, + type RadioSelectItem, +} from './RadioButtonSelect.js'; +import { describe, it, expect } from 'vitest'; + +const ITEMS: Array> = [ + { label: 'Option 1', value: 'one' }, + { label: 'Option 2', value: 'two' }, + { label: 'Option 3', value: 'three', disabled: true }, +]; + +describe('', () => { + it('renders a list of items and matches snapshot', () => { + const { lastFrame } = render( + {}} isFocused={true} />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with the second item selected and matches snapshot', () => { + const { lastFrame } = render( + {}} + isFocused={true} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with numbers hidden and matches snapshot', () => { + const { lastFrame } = render( + {}} + isFocused={true} + showNumbers={false} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with scroll arrows and matches snapshot', () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + const { lastFrame } = render( + {}} + isFocused={true} + showScrollArrows={true} + maxItemsToShow={5} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with special theme display and matches snapshot', () => { + const themeItems: Array> = [ + { + label: 'Theme A (Light)', + value: 'a-light', + themeNameDisplay: 'Theme A', + themeTypeDisplay: '(Light)', + }, + { + label: 'Theme B (Dark)', + value: 'b-dark', + themeNameDisplay: 'Theme B', + themeTypeDisplay: '(Dark)', + }, + ]; + const { lastFrame } = render( + {}} + isFocused={true} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders a list with >10 items and matches snapshot', () => { + const manyItems = Array.from({ length: 12 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + const { lastFrame } = render( + {}} + isFocused={true} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders nothing when no items are provided', () => { + const { lastFrame } = render( + {}} isFocused={true} />, + ); + expect(lastFrame()).toBe(''); + }); +}); diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index 499c136a..8b0057ca 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Text, Box, useInput } from 'ink'; import { Colors } from '../../colors.js'; @@ -39,6 +39,8 @@ export interface RadioButtonSelectProps { showScrollArrows?: boolean; /** The maximum number of items to show at once. */ maxItemsToShow?: number; + /** Whether to show numbers next to items. */ + showNumbers?: boolean; } /** @@ -55,9 +57,12 @@ export function RadioButtonSelect({ isFocused, showScrollArrows = false, maxItemsToShow = 10, + showNumbers = true, }: RadioButtonSelectProps): React.JSX.Element { const [activeIndex, setActiveIndex] = useState(initialIndex); const [scrollOffset, setScrollOffset] = useState(0); + const [numberInput, setNumberInput] = useState(''); + const numberInputTimer = useRef(null); useEffect(() => { const newScrollOffset = Math.max( @@ -71,30 +76,81 @@ export function RadioButtonSelect({ } }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); + useEffect( + () => () => { + if (numberInputTimer.current) { + clearTimeout(numberInputTimer.current); + } + }, + [], + ); + useInput( (input, key) => { + const isNumeric = showNumbers && /^[0-9]$/.test(input); + + // Any key press that is not a digit should clear the number input buffer. + if (!isNumeric && numberInputTimer.current) { + clearTimeout(numberInputTimer.current); + setNumberInput(''); + } + if (input === 'k' || key.upArrow) { const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); + return; } + if (input === 'j' || key.downArrow) { const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); - } - if (key.return) { - onSelect(items[activeIndex]!.value); + return; } - // Enable selection directly from number keys. - if (/^[1-9]$/.test(input)) { - const targetIndex = Number.parseInt(input, 10) - 1; - if (targetIndex >= 0 && targetIndex < visibleItems.length) { - const selectedItem = visibleItems[targetIndex]; - if (selectedItem) { - onSelect?.(selectedItem.value); + if (key.return) { + onSelect(items[activeIndex]!.value); + return; + } + + // Handle numeric input for selection. + if (isNumeric) { + if (numberInputTimer.current) { + clearTimeout(numberInputTimer.current); + } + + const newNumberInput = numberInput + input; + setNumberInput(newNumberInput); + + const targetIndex = Number.parseInt(newNumberInput, 10) - 1; + + // A single '0' is not a valid selection since items are 1-indexed. + if (newNumberInput === '0') { + numberInputTimer.current = setTimeout(() => setNumberInput(''), 350); + return; + } + + if (targetIndex >= 0 && targetIndex < items.length) { + const targetItem = items[targetIndex]!; + setActiveIndex(targetIndex); + onHighlight?.(targetItem.value); + + // If the typed number can't be a prefix for another valid number, + // select it immediately. Otherwise, wait for more input. + const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10); + if (potentialNextNumber > items.length) { + onSelect(targetItem.value); + setNumberInput(''); + } else { + numberInputTimer.current = setTimeout(() => { + onSelect(targetItem.value); + setNumberInput(''); + }, 350); // Debounce time for multi-digit input. } + } else { + // The typed number is out of bounds, clear the buffer + setNumberInput(''); } } }, @@ -115,19 +171,38 @@ export function RadioButtonSelect({ const isSelected = activeIndex === itemIndex; let textColor = Colors.Foreground; + let numberColor = Colors.Foreground; if (isSelected) { textColor = Colors.AccentGreen; + numberColor = Colors.AccentGreen; } else if (item.disabled) { textColor = Colors.Gray; + numberColor = Colors.Gray; } + if (!showNumbers) { + numberColor = Colors.Gray; + } + + const numberColumnWidth = String(items.length).length; + const itemNumberText = `${String(itemIndex + 1).padStart( + numberColumnWidth, + )}.`; + return ( - + - {isSelected ? '●' : '○'} + {isSelected ? '●' : ' '} + + {itemNumberText} + {item.themeNameDisplay && item.themeTypeDisplay ? ( {item.themeNameDisplay}{' '} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/RadioButtonSelect.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/RadioButtonSelect.test.tsx.snap new file mode 100644 index 00000000..aeb4ac16 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/RadioButtonSelect.test.tsx.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders a list of items and matches snapshot 1`] = ` +"● 1. Option 1 + 2. Option 2 + 3. Option 3" +`; + +exports[` > renders a list with >10 items and matches snapshot 1`] = ` +"● 1. Item 1 + 2. Item 2 + 3. Item 3 + 4. Item 4 + 5. Item 5 + 6. Item 6 + 7. Item 7 + 8. Item 8 + 9. Item 9 + 10. Item 10" +`; + +exports[` > renders with numbers hidden and matches snapshot 1`] = ` +"● 1. Option 1 + 2. Option 2 + 3. Option 3" +`; + +exports[` > renders with scroll arrows and matches snapshot 1`] = ` +"▲ +● 1. Item 1 + 2. Item 2 + 3. Item 3 + 4. Item 4 + 5. Item 5 +▼" +`; + +exports[` > renders with special theme display and matches snapshot 1`] = ` +"● 1. Theme A (Light) + 2. Theme B (Dark)" +`; + +exports[` > renders with the second item selected and matches snapshot 1`] = ` +" 1. Option 1 +● 2. Option 2 + 3. Option 3" +`;