Skip to content

Commit 11a6d6f

Browse files
authored
Merge branch 'google-gemini:main' into main
2 parents 760a648 + d89ccf2 commit 11a6d6f

File tree

2 files changed

+122
-99
lines changed

2 files changed

+122
-99
lines changed

packages/cli/src/ui/components/ThemeDialog.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import React, { useState } from 'react';
7+
import React, { useCallback, useState } from 'react';
88
import { Box, Text, useInput } from 'ink';
99
import { Colors } from '../colors.js';
1010
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
@@ -60,19 +60,25 @@ export function ThemeDialog({
6060
{ label: 'System Settings', value: SettingScope.System },
6161
];
6262

63-
const handleThemeSelect = (themeName: string) => {
64-
onSelect(themeName, selectedScope);
65-
};
63+
const handleThemeSelect = useCallback(
64+
(themeName: string) => {
65+
onSelect(themeName, selectedScope);
66+
},
67+
[onSelect, selectedScope],
68+
);
6669

67-
const handleScopeHighlight = (scope: SettingScope) => {
70+
const handleScopeHighlight = useCallback((scope: SettingScope) => {
6871
setSelectedScope(scope);
6972
setSelectInputKey(Date.now());
70-
};
73+
}, []);
7174

72-
const handleScopeSelect = (scope: SettingScope) => {
73-
handleScopeHighlight(scope);
74-
setFocusedSection('theme'); // Reset focus to theme section
75-
};
75+
const handleScopeSelect = useCallback(
76+
(scope: SettingScope) => {
77+
handleScopeHighlight(scope);
78+
setFocusedSection('theme'); // Reset focus to theme section
79+
},
80+
[handleScopeHighlight],
81+
);
7682

7783
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
7884
'theme',
@@ -196,6 +202,7 @@ export function ThemeDialog({
196202
onSelect={handleThemeSelect}
197203
onHighlight={onHighlight}
198204
isFocused={currenFocusedSection === 'theme'}
205+
maxItemsToShow={8}
199206
/>
200207

201208
{/* Scope Selection */}
@@ -210,6 +217,7 @@ export function ThemeDialog({
210217
onSelect={handleScopeSelect}
211218
onHighlight={handleScopeHighlight}
212219
isFocused={currenFocusedSection === 'scope'}
220+
showScrollArrows={false}
213221
/>
214222
</Box>
215223
)}

packages/cli/src/ui/components/shared/RadioButtonSelect.tsx

Lines changed: 104 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,8 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import React from 'react';
8-
import { Text, Box } from 'ink';
9-
import SelectInput, {
10-
type ItemProps as InkSelectItemProps,
11-
type IndicatorProps as InkSelectIndicatorProps,
12-
} from 'ink-select-input';
7+
import React, { useEffect, useState } from 'react';
8+
import { Text, Box, useInput } from 'ink';
139
import { Colors } from '../../colors.js';
1410

1511
/**
@@ -20,6 +16,8 @@ export interface RadioSelectItem<T> {
2016
label: string;
2117
value: T;
2218
disabled?: boolean;
19+
themeNameDisplay?: string;
20+
themeTypeDisplay?: string;
2321
}
2422

2523
/**
@@ -28,115 +26,132 @@ export interface RadioSelectItem<T> {
2826
*/
2927
export interface RadioButtonSelectProps<T> {
3028
/** An array of items to display as radio options. */
31-
items: Array<
32-
RadioSelectItem<T> & {
33-
themeNameDisplay?: string;
34-
themeTypeDisplay?: string;
35-
}
36-
>;
37-
29+
items: Array<RadioSelectItem<T>>;
3830
/** The initial index selected */
3931
initialIndex?: number;
40-
4132
/** Function called when an item is selected. Receives the `value` of the selected item. */
4233
onSelect: (value: T) => void;
43-
4434
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
4535
onHighlight?: (value: T) => void;
46-
4736
/** Whether this select input is currently focused and should respond to input. */
4837
isFocused?: boolean;
38+
/** Whether to show the scroll arrows. */
39+
showScrollArrows?: boolean;
40+
/** The maximum number of items to show at once. */
41+
maxItemsToShow?: number;
4942
}
5043

5144
/**
52-
* A specialized SelectInput component styled to look like radio buttons.
53-
* It uses '◉' for selected and '○' for unselected items.
45+
* A custom component that displays a list of items with radio buttons,
46+
* supporting scrolling and keyboard navigation.
5447
*
5548
* @template T The type of the value associated with each radio item.
5649
*/
5750
export function RadioButtonSelect<T>({
5851
items,
59-
initialIndex,
52+
initialIndex = 0,
6053
onSelect,
6154
onHighlight,
62-
isFocused, // This prop indicates if the current RadioButtonSelect group is focused
55+
isFocused,
56+
showScrollArrows = true,
57+
maxItemsToShow = 10,
6358
}: RadioButtonSelectProps<T>): React.JSX.Element {
64-
const handleSelect = (item: RadioSelectItem<T>) => {
65-
onSelect(item.value);
66-
};
67-
const handleHighlight = (item: RadioSelectItem<T>) => {
68-
if (onHighlight) {
69-
onHighlight(item.value);
70-
}
71-
};
59+
const [activeIndex, setActiveIndex] = useState(initialIndex);
60+
const [scrollOffset, setScrollOffset] = useState(0);
7261

73-
/**
74-
* Custom indicator component displaying radio button style (◉/○).
75-
* Color changes based on whether the item is selected and if its group is focused.
76-
*/
77-
function DynamicRadioIndicator({
78-
isSelected = false,
79-
}: InkSelectIndicatorProps): React.JSX.Element {
80-
return (
81-
<Box minWidth={2} flexShrink={0}>
82-
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
83-
{isSelected ? '●' : '○'}
84-
</Text>
85-
</Box>
62+
useEffect(() => {
63+
const newScrollOffset = Math.max(
64+
0,
65+
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
8666
);
87-
}
67+
if (activeIndex < scrollOffset) {
68+
setScrollOffset(activeIndex);
69+
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
70+
setScrollOffset(newScrollOffset);
71+
}
72+
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
8873

89-
/**
90-
* Custom item component for displaying the label.
91-
* Color changes based on whether the item is selected and if its group is focused.
92-
* Now also handles displaying theme type with custom color.
93-
*/
94-
function CustomThemeItemComponent(
95-
props: InkSelectItemProps,
96-
): React.JSX.Element {
97-
const { isSelected = false, label } = props;
98-
const itemWithThemeProps = props as typeof props & {
99-
themeNameDisplay?: string;
100-
themeTypeDisplay?: string;
101-
disabled?: boolean;
102-
};
74+
useInput(
75+
(input, key) => {
76+
if (input === 'k' || key.upArrow) {
77+
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
78+
setActiveIndex(newIndex);
79+
onHighlight?.(items[newIndex]!.value);
80+
}
81+
if (input === 'j' || key.downArrow) {
82+
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
83+
setActiveIndex(newIndex);
84+
onHighlight?.(items[newIndex]!.value);
85+
}
86+
if (key.return) {
87+
onSelect(items[activeIndex]!.value);
88+
}
10389

104-
let textColor = Colors.Foreground;
105-
if (isSelected) {
106-
textColor = Colors.AccentGreen;
107-
} else if (itemWithThemeProps.disabled === true) {
108-
textColor = Colors.Gray;
109-
}
90+
// Enable selection directly from number keys.
91+
if (/^[1-9]$/.test(input)) {
92+
const targetIndex = Number.parseInt(input, 10) - 1;
93+
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
94+
const selectedItem = visibleItems[targetIndex];
95+
if (selectedItem) {
96+
onSelect?.(selectedItem.value);
97+
}
98+
}
99+
}
100+
},
101+
{ isActive: isFocused && items.length > 0 },
102+
);
110103

111-
if (
112-
itemWithThemeProps.themeNameDisplay &&
113-
itemWithThemeProps.themeTypeDisplay
114-
) {
115-
return (
116-
<Text color={textColor} wrap="truncate">
117-
{itemWithThemeProps.themeNameDisplay}{' '}
118-
<Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
104+
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
105+
106+
return (
107+
<Box flexDirection="column">
108+
{showScrollArrows && (
109+
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
110+
119111
</Text>
120-
);
121-
}
112+
)}
113+
{visibleItems.map((item, index) => {
114+
const itemIndex = scrollOffset + index;
115+
const isSelected = activeIndex === itemIndex;
122116

123-
return (
124-
<Text color={textColor} wrap="truncate">
125-
{label}
126-
</Text>
127-
);
128-
}
117+
let textColor = Colors.Foreground;
118+
if (isSelected) {
119+
textColor = Colors.AccentGreen;
120+
} else if (item.disabled) {
121+
textColor = Colors.Gray;
122+
}
129123

130-
initialIndex = initialIndex ?? 0;
131-
return (
132-
<SelectInput
133-
indicatorComponent={DynamicRadioIndicator}
134-
itemComponent={CustomThemeItemComponent}
135-
items={items}
136-
initialIndex={initialIndex}
137-
onSelect={handleSelect}
138-
onHighlight={handleHighlight}
139-
isFocused={isFocused}
140-
/>
124+
return (
125+
<Box key={item.label}>
126+
<Box minWidth={2} flexShrink={0}>
127+
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
128+
{isSelected ? '●' : '○'}
129+
</Text>
130+
</Box>
131+
{item.themeNameDisplay && item.themeTypeDisplay ? (
132+
<Text color={textColor} wrap="truncate">
133+
{item.themeNameDisplay}{' '}
134+
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
135+
</Text>
136+
) : (
137+
<Text color={textColor} wrap="truncate">
138+
{item.label}
139+
</Text>
140+
)}
141+
</Box>
142+
);
143+
})}
144+
{showScrollArrows && (
145+
<Text
146+
color={
147+
scrollOffset + maxItemsToShow < items.length
148+
? Colors.Foreground
149+
: Colors.Gray
150+
}
151+
>
152+
153+
</Text>
154+
)}
155+
</Box>
141156
);
142157
}

0 commit comments

Comments
 (0)