summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/components/AuthDialog.test.tsx6
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx2
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx115
-rw-r--r--packages/cli/src/ui/components/shared/RadioButtonSelect.tsx95
-rw-r--r--packages/cli/src/ui/components/shared/__snapshots__/RadioButtonSelect.test.tsx.snap47
5 files changed, 252 insertions, 13 deletions
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'}
/>
</Box>
)}
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<RadioSelectItem<string>> = [
+ { label: 'Option 1', value: 'one' },
+ { label: 'Option 2', value: 'two' },
+ { label: 'Option 3', value: 'three', disabled: true },
+];
+
+describe('<RadioButtonSelect />', () => {
+ it('renders a list of items and matches snapshot', () => {
+ const { lastFrame } = render(
+ <RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders with the second item selected and matches snapshot', () => {
+ const { lastFrame } = render(
+ <RadioButtonSelect
+ items={ITEMS}
+ initialIndex={1}
+ onSelect={() => {}}
+ isFocused={true}
+ />,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders with numbers hidden and matches snapshot', () => {
+ const { lastFrame } = render(
+ <RadioButtonSelect
+ items={ITEMS}
+ onSelect={() => {}}
+ 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(
+ <RadioButtonSelect
+ items={manyItems}
+ onSelect={() => {}}
+ isFocused={true}
+ showScrollArrows={true}
+ maxItemsToShow={5}
+ />,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders with special theme display and matches snapshot', () => {
+ const themeItems: Array<RadioSelectItem<string>> = [
+ {
+ 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(
+ <RadioButtonSelect
+ items={themeItems}
+ onSelect={() => {}}
+ 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(
+ <RadioButtonSelect
+ items={manyItems}
+ onSelect={() => {}}
+ isFocused={true}
+ />,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders nothing when no items are provided', () => {
+ const { lastFrame } = render(
+ <RadioButtonSelect items={[]} onSelect={() => {}} 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<T> {
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<T>({
isFocused,
showScrollArrows = false,
maxItemsToShow = 10,
+ showNumbers = true,
}: RadioButtonSelectProps<T>): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollOffset, setScrollOffset] = useState(0);
+ const [numberInput, setNumberInput] = useState('');
+ const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const newScrollOffset = Math.max(
@@ -71,30 +76,81 @@ export function RadioButtonSelect<T>({
}
}, [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);
+ return;
}
+
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);
+ // 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<T>({
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 (
- <Box key={item.label}>
+ <Box key={item.label} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
- {isSelected ? '●' : '○'}
+ {isSelected ? '●' : ' '}
</Text>
</Box>
+ <Box
+ marginRight={1}
+ flexShrink={0}
+ minWidth={itemNumberText.length}
+ >
+ <Text color={numberColor}>{itemNumberText}</Text>
+ </Box>
{item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate">
{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[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
+"● 1. Option 1
+ 2. Option 2
+ 3. Option 3"
+`;
+
+exports[`<RadioButtonSelect /> > 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[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
+"● 1. Option 1
+ 2. Option 2
+ 3. Option 3"
+`;
+
+exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
+"▲
+● 1. Item 1
+ 2. Item 2
+ 3. Item 3
+ 4. Item 4
+ 5. Item 5
+▼"
+`;
+
+exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
+"● 1. Theme A (Light)
+ 2. Theme B (Dark)"
+`;
+
+exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
+" 1. Option 1
+● 2. Option 2
+ 3. Option 3"
+`;