mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Jacob314/add radio button keys (#10083)
This commit is contained in:
@@ -50,6 +50,7 @@ export function IdeIntegrationNudge({
|
||||
userSelection: 'yes',
|
||||
isExtensionPreInstalled,
|
||||
},
|
||||
key: 'Yes',
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
@@ -57,6 +58,7 @@ export function IdeIntegrationNudge({
|
||||
userSelection: 'no',
|
||||
isExtensionPreInstalled,
|
||||
},
|
||||
key: 'No (esc)',
|
||||
},
|
||||
{
|
||||
label: "No, don't ask again",
|
||||
@@ -64,6 +66,7 @@ export function IdeIntegrationNudge({
|
||||
userSelection: 'dismiss',
|
||||
isExtensionPreInstalled,
|
||||
},
|
||||
key: "No, don't ask again",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('AuthDialog', () => {
|
||||
expect(items).toContainEqual({
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
value: AuthType.CLOUD_SHELL,
|
||||
key: AuthType.CLOUD_SHELL,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,20 +40,27 @@ export function AuthDialog({
|
||||
{
|
||||
label: 'Login with Google',
|
||||
value: AuthType.LOGIN_WITH_GOOGLE,
|
||||
key: AuthType.LOGIN_WITH_GOOGLE,
|
||||
},
|
||||
...(process.env['CLOUD_SHELL'] === 'true'
|
||||
? [
|
||||
{
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
value: AuthType.CLOUD_SHELL,
|
||||
key: AuthType.CLOUD_SHELL,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Use Gemini API Key',
|
||||
value: AuthType.USE_GEMINI,
|
||||
key: AuthType.USE_GEMINI,
|
||||
},
|
||||
{
|
||||
label: 'Vertex AI',
|
||||
value: AuthType.USE_VERTEX_AI,
|
||||
key: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
{ label: 'Vertex AI', value: AuthType.USE_VERTEX_AI },
|
||||
];
|
||||
|
||||
if (settings.merged.security?.auth?.enforcedType) {
|
||||
|
||||
@@ -105,8 +105,8 @@ export const DialogManager = ({ addItem }: DialogManagerProps) => {
|
||||
<Box paddingY={1}>
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Yes', value: true, key: 'Yes' },
|
||||
{ label: 'No', value: false, key: 'No' },
|
||||
]}
|
||||
onSelect={(value: boolean) => {
|
||||
uiState.confirmationRequest!.onConfirm(value);
|
||||
|
||||
@@ -65,8 +65,16 @@ export function EditorSettingsDialog({
|
||||
}
|
||||
|
||||
const scopeItems = [
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
||||
{
|
||||
label: 'User Settings',
|
||||
value: SettingScope.User,
|
||||
key: SettingScope.User,
|
||||
},
|
||||
{
|
||||
label: 'Workspace Settings',
|
||||
value: SettingScope.Workspace,
|
||||
key: SettingScope.Workspace,
|
||||
},
|
||||
];
|
||||
|
||||
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
|
||||
@@ -127,6 +135,7 @@ export function EditorSettingsDialog({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
disabled: item.disabled,
|
||||
key: item.type,
|
||||
}))}
|
||||
initialIndex={editorIndex}
|
||||
onSelect={handleEditorSelect}
|
||||
|
||||
@@ -57,14 +57,17 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
{
|
||||
label: `Trust folder (${dirName})`,
|
||||
value: FolderTrustChoice.TRUST_FOLDER,
|
||||
key: `Trust folder (${dirName})`,
|
||||
},
|
||||
{
|
||||
label: `Trust parent folder (${parentFolder})`,
|
||||
value: FolderTrustChoice.TRUST_PARENT,
|
||||
key: `Trust parent folder (${parentFolder})`,
|
||||
},
|
||||
{
|
||||
label: "Don't trust (esc)",
|
||||
value: FolderTrustChoice.DO_NOT_TRUST,
|
||||
key: "Don't trust (esc)",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -38,12 +38,14 @@ export function LoopDetectionConfirmation({
|
||||
value: {
|
||||
userSelection: 'keep',
|
||||
},
|
||||
key: 'Keep loop detection enabled (esc)',
|
||||
},
|
||||
{
|
||||
label: 'Disable loop detection for this session',
|
||||
value: {
|
||||
userSelection: 'disable',
|
||||
},
|
||||
key: 'Disable loop detection for this session',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -29,21 +29,25 @@ const MODEL_OPTIONS = [
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: 'Auto (recommended)',
|
||||
description: 'Let the system choose the best model for your task',
|
||||
key: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: 'Pro',
|
||||
description: 'For complex tasks that require deep reasoning and creativity',
|
||||
key: DEFAULT_GEMINI_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: 'Flash',
|
||||
description: 'For tasks that need a balance of speed and reasoning',
|
||||
key: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: 'Flash-Lite',
|
||||
description: 'For simple tasks that need to be done quickly',
|
||||
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -23,14 +23,17 @@ const TRUST_LEVEL_ITEMS = [
|
||||
{
|
||||
label: 'Trust this folder',
|
||||
value: TrustLevel.TRUST_FOLDER,
|
||||
key: TrustLevel.TRUST_FOLDER,
|
||||
},
|
||||
{
|
||||
label: 'Trust parent folder',
|
||||
value: TrustLevel.TRUST_PARENT,
|
||||
key: TrustLevel.TRUST_PARENT,
|
||||
},
|
||||
{
|
||||
label: "Don't trust",
|
||||
value: TrustLevel.DO_NOT_TRUST,
|
||||
key: TrustLevel.DO_NOT_TRUST,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -38,10 +38,12 @@ describe('ProQuotaDialog', () => {
|
||||
{
|
||||
label: 'Change auth (executes the /auth command)',
|
||||
value: 'auth',
|
||||
key: 'auth',
|
||||
},
|
||||
{
|
||||
label: `Continue with gemini-2.5-flash`,
|
||||
value: 'continue',
|
||||
key: 'continue',
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -24,10 +24,12 @@ export function ProQuotaDialog({
|
||||
{
|
||||
label: 'Change auth (executes the /auth command)',
|
||||
value: 'auth' as const,
|
||||
key: 'auth',
|
||||
},
|
||||
{
|
||||
label: `Continue with ${fallbackModel}`,
|
||||
value: 'continue' as const,
|
||||
key: 'continue',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -358,7 +358,10 @@ export function SettingsDialog({
|
||||
};
|
||||
|
||||
// Scope selector items
|
||||
const scopeItems = getScopeItems();
|
||||
const scopeItems = getScopeItems().map((item) => ({
|
||||
...item,
|
||||
key: item.value,
|
||||
}));
|
||||
|
||||
const handleScopeHighlight = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
|
||||
@@ -53,14 +53,17 @@ export const ShellConfirmationDialog: React.FC<
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
},
|
||||
{
|
||||
label: 'Yes, allow always for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always for this session',
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No (esc)',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -63,12 +63,14 @@ export function ThemeDialog({
|
||||
value: theme.name,
|
||||
themeNameDisplay: theme.name,
|
||||
themeTypeDisplay: capitalize(theme.type),
|
||||
key: theme.name,
|
||||
})),
|
||||
...customThemeNames.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
themeNameDisplay: name,
|
||||
themeTypeDisplay: 'Custom',
|
||||
key: name,
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ export function WorkspaceMigrationDialog(props: {
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Install all', value: 'migrate' },
|
||||
{ label: 'Skip', value: 'skip' },
|
||||
{ label: 'Install all', value: 'migrate', key: 'migrate' },
|
||||
{ label: 'Skip', value: 'skip', key: 'skip' },
|
||||
]}
|
||||
onSelect={(value: string) => {
|
||||
if (value === 'migrate') {
|
||||
|
||||
@@ -150,23 +150,27 @@ export const ToolConfirmationMessage: React.FC<
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
if (!config.getIdeMode() || !isDiffingEnabled) {
|
||||
options.push({
|
||||
label: 'Modify with external editor',
|
||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
key: 'Modify with external editor',
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
bodyContent = (
|
||||
@@ -185,16 +189,19 @@ export const ToolConfirmationMessage: React.FC<
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: `Yes, allow always ...`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
@@ -225,16 +232,19 @@ export const ToolConfirmationMessage: React.FC<
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
bodyContent = (
|
||||
@@ -270,20 +280,24 @@ export const ToolConfirmationMessage: React.FC<
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,16 +35,20 @@ describe('BaseSelectionList', () => {
|
||||
const mockOnHighlight = vi.fn();
|
||||
const mockRenderItem = vi.fn();
|
||||
|
||||
// Define standard test items
|
||||
const items = [
|
||||
{ value: 'A', label: 'Item A' },
|
||||
{ value: 'B', label: 'Item B', disabled: true },
|
||||
{ value: 'C', label: 'Item C' },
|
||||
{ value: 'A', label: 'Item A', key: 'A' },
|
||||
{ value: 'B', label: 'Item B', disabled: true, key: 'B' },
|
||||
{ value: 'C', label: 'Item C', key: 'C' },
|
||||
];
|
||||
|
||||
// Helper to render the component with default props
|
||||
const renderComponent = (
|
||||
props: Partial<BaseSelectionListProps<string, { label: string }>> = {},
|
||||
props: Partial<
|
||||
BaseSelectionListProps<
|
||||
string,
|
||||
{ value: string; label: string; disabled?: boolean; key: string }
|
||||
>
|
||||
> = {},
|
||||
activeIndex: number = 0,
|
||||
) => {
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
@@ -53,12 +57,16 @@ describe('BaseSelectionList', () => {
|
||||
});
|
||||
|
||||
mockRenderItem.mockImplementation(
|
||||
(item: (typeof items)[0], context: RenderItemContext) => (
|
||||
<Text color={context.titleColor}>{item.label}</Text>
|
||||
),
|
||||
(
|
||||
item: { value: string; label: string; disabled?: boolean; key: string },
|
||||
context: RenderItemContext,
|
||||
) => <Text color={context.titleColor}>{item.label}</Text>,
|
||||
);
|
||||
|
||||
const defaultProps: BaseSelectionListProps<string, { label: string }> = {
|
||||
const defaultProps: BaseSelectionListProps<
|
||||
string,
|
||||
{ value: string; label: string; disabled?: boolean; key: string }
|
||||
> = {
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
@@ -216,6 +224,7 @@ describe('BaseSelectionList', () => {
|
||||
const longList = Array.from({ length: 15 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
label: `Item ${i + 1}`,
|
||||
key: `Item ${i + 1}`,
|
||||
}));
|
||||
|
||||
// We must increase maxItemsToShow (default 10) to see the 10th item and beyond
|
||||
@@ -249,19 +258,22 @@ describe('BaseSelectionList', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
label: `Item ${i + 1}`,
|
||||
key: `Item ${i + 1}`,
|
||||
}));
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
const renderScrollableList = (initialActiveIndex: number = 0) => {
|
||||
// Define the props used for the initial render and subsequent rerenders
|
||||
const componentProps: BaseSelectionListProps<string, { label: string }> =
|
||||
{
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
renderItem: mockRenderItem,
|
||||
};
|
||||
const componentProps: BaseSelectionListProps<
|
||||
string,
|
||||
{ value: string; label: string; key: string }
|
||||
> = {
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
renderItem: mockRenderItem,
|
||||
};
|
||||
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex: initialActiveIndex,
|
||||
@@ -428,6 +440,7 @@ describe('BaseSelectionList', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
label: `Item ${i + 1}`,
|
||||
key: `Item ${i + 1}`,
|
||||
}));
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
|
||||
@@ -10,14 +10,19 @@ import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface RenderItemContext {
|
||||
isSelected: boolean;
|
||||
titleColor: string;
|
||||
numberColor: string;
|
||||
}
|
||||
|
||||
export interface BaseSelectionListProps<T, TItem = Record<string, unknown>> {
|
||||
items: Array<TItem & { value: T; disabled?: boolean }>;
|
||||
export interface BaseSelectionListProps<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
> {
|
||||
items: TItem[];
|
||||
initialIndex?: number;
|
||||
onSelect: (value: T) => void;
|
||||
onHighlight?: (value: T) => void;
|
||||
@@ -25,10 +30,7 @@ export interface BaseSelectionListProps<T, TItem = Record<string, unknown>> {
|
||||
showNumbers?: boolean;
|
||||
showScrollArrows?: boolean;
|
||||
maxItemsToShow?: number;
|
||||
renderItem: (
|
||||
item: TItem & { value: T; disabled?: boolean },
|
||||
context: RenderItemContext,
|
||||
) => React.ReactNode;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +47,10 @@ export interface BaseSelectionListProps<T, TItem = Record<string, unknown>> {
|
||||
* Specific components should use this as a base and provide
|
||||
* their own renderItem implementation for custom content.
|
||||
*/
|
||||
export function BaseSelectionList<T, TItem = Record<string, unknown>>({
|
||||
export function BaseSelectionList<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
>({
|
||||
items,
|
||||
initialIndex = 0,
|
||||
onSelect,
|
||||
@@ -123,7 +128,7 @@ export function BaseSelectionList<T, TItem = Record<string, unknown>>({
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box key={itemIndex} alignItems="flex-start">
|
||||
<Box key={item.key} alignItems="flex-start">
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
|
||||
@@ -40,13 +40,24 @@ describe('DescriptiveRadioButtonSelect', () => {
|
||||
const mockOnHighlight = vi.fn();
|
||||
|
||||
const ITEMS: Array<DescriptiveRadioSelectItem<string>> = [
|
||||
{ title: 'Foo Title', description: 'This is Foo.', value: 'foo' },
|
||||
{ title: 'Bar Title', description: 'This is Bar.', value: 'bar' },
|
||||
{
|
||||
title: 'Foo Title',
|
||||
description: 'This is Foo.',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
},
|
||||
{
|
||||
title: 'Bar Title',
|
||||
description: 'This is Bar.',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
},
|
||||
{
|
||||
title: 'Baz Title',
|
||||
description: 'This is Baz.',
|
||||
value: 'baz',
|
||||
disabled: true,
|
||||
key: 'baz',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { BaseSelectionList } from './BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface DescriptiveRadioSelectItem<T> {
|
||||
value: T;
|
||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DescriptiveRadioButtonSelectProps<T> {
|
||||
@@ -61,7 +60,7 @@ export function DescriptiveRadioButtonSelect<T>({
|
||||
showScrollArrows={showScrollArrows}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" key={item.key}>
|
||||
<Text color={titleColor}>{item.title}</Text>
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -62,9 +62,9 @@ describe('RadioButtonSelect', () => {
|
||||
const mockOnHighlight = vi.fn();
|
||||
|
||||
const ITEMS: Array<RadioSelectItem<string>> = [
|
||||
{ label: 'Option 1', value: 'one' },
|
||||
{ label: 'Option 2', value: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true },
|
||||
{ label: 'Option 1', value: 'one', key: 'one' },
|
||||
{ label: 'Option 2', value: 'two', key: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true, key: 'three' },
|
||||
];
|
||||
|
||||
const renderComponent = (
|
||||
@@ -155,6 +155,7 @@ describe('RadioButtonSelect', () => {
|
||||
value: 'a-light',
|
||||
themeNameDisplay: 'Theme A',
|
||||
themeTypeDisplay: '(Light)',
|
||||
key: 'a-light',
|
||||
};
|
||||
|
||||
const result = renderItem(themeItem, mockContext);
|
||||
@@ -186,6 +187,7 @@ describe('RadioButtonSelect', () => {
|
||||
label: 'Incomplete Theme',
|
||||
value: 'incomplete',
|
||||
themeNameDisplay: 'Only Name',
|
||||
key: 'incomplete',
|
||||
};
|
||||
|
||||
const result = renderItem(partialThemeItem, mockContext);
|
||||
|
||||
@@ -8,15 +8,14 @@ import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { BaseSelectionList } from './BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
/**
|
||||
* Represents a single option for the RadioButtonSelect.
|
||||
* Requires a label for display and a value to be returned on selection.
|
||||
*/
|
||||
export interface RadioSelectItem<T> {
|
||||
export interface RadioSelectItem<T> extends SelectionListItem<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
themeNameDisplay?: string;
|
||||
themeTypeDisplay?: string;
|
||||
}
|
||||
@@ -74,7 +73,7 @@ export function RadioButtonSelect<T>({
|
||||
// Handle special theme display case for ThemeDialog compatibility
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate">
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={theme.text.secondary}>{item.themeTypeDisplay}</Text>
|
||||
</Text>
|
||||
|
||||
@@ -27,7 +27,10 @@ export function ScopeSelector({
|
||||
isFocused,
|
||||
initialScope,
|
||||
}: ScopeSelectorProps): React.JSX.Element {
|
||||
const scopeItems = getScopeItems();
|
||||
const scopeItems = getScopeItems().map((item) => ({
|
||||
...item,
|
||||
key: item.value,
|
||||
}));
|
||||
|
||||
const initialIndex = scopeItems.findIndex(
|
||||
(item) => item.value === initialScope,
|
||||
|
||||
@@ -25,10 +25,10 @@ describe('useSelectionList', () => {
|
||||
const mockOnHighlight = vi.fn();
|
||||
|
||||
const items: Array<SelectionListItem<string>> = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C' },
|
||||
{ value: 'D' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
{ value: 'D', key: 'D' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -105,9 +105,9 @@ describe('useSelectionList', () => {
|
||||
|
||||
it('should wrap around to find the next enabled item if initialIndex is disabled', () => {
|
||||
const wrappingItems = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C', disabled: true },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', disabled: true, key: 'C' },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useSelectionList({
|
||||
@@ -141,8 +141,8 @@ describe('useSelectionList', () => {
|
||||
|
||||
it('should stick to the initial index if all items are disabled', () => {
|
||||
const allDisabled = [
|
||||
{ value: 'A', disabled: true },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'A', disabled: true, key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useSelectionList({
|
||||
@@ -208,7 +208,7 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should not move or call onHighlight if navigation results in the same index (e.g., single item)', () => {
|
||||
const singleItem = [{ value: 'A' }];
|
||||
const singleItem = [{ value: 'A', key: 'A' }];
|
||||
const { result } = renderHook(() =>
|
||||
useSelectionList({
|
||||
items: singleItem,
|
||||
@@ -223,8 +223,8 @@ describe('useSelectionList', () => {
|
||||
|
||||
it('should not move or call onHighlight if all items are disabled', () => {
|
||||
const allDisabled = [
|
||||
{ value: 'A', disabled: true },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'A', disabled: true, key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useSelectionList({
|
||||
@@ -423,7 +423,7 @@ describe('useSelectionList', () => {
|
||||
const shortList = items;
|
||||
const longList: Array<SelectionListItem<string>> = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => ({ value: `Item ${i + 1}` }),
|
||||
(_, i) => ({ value: `Item ${i + 1}`, key: `Item ${i + 1}` }),
|
||||
);
|
||||
|
||||
const pressNumber = (num: string) => pressKey(num, num);
|
||||
@@ -585,7 +585,7 @@ describe('useSelectionList', () => {
|
||||
it('should highlight but not select a disabled item (timeout case)', () => {
|
||||
// Create a list where the ambiguous prefix points to a disabled item
|
||||
const disabledAmbiguousList = [
|
||||
{ value: 'Item 1 Disabled', disabled: true },
|
||||
{ value: 'Item 1 Disabled', disabled: true, key: 'Item 1 Disabled' },
|
||||
...longList.slice(1),
|
||||
];
|
||||
|
||||
@@ -670,6 +670,28 @@ describe('useSelectionList', () => {
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('should respect a new initialIndex even after user interaction', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ initialIndex }: { initialIndex: number }) =>
|
||||
useSelectionList({
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
initialIndex,
|
||||
}),
|
||||
{ initialProps: { initialIndex: 0 } },
|
||||
);
|
||||
|
||||
// User navigates, changing the active index
|
||||
pressKey('down');
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
|
||||
// The component re-renders with a new initial index
|
||||
rerender({ initialIndex: 3 });
|
||||
|
||||
// The hook should now respect the new initial index
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should validate index when initialIndex prop changes to a disabled item', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ initialIndex }: { initialIndex: number }) =>
|
||||
@@ -699,7 +721,10 @@ describe('useSelectionList', () => {
|
||||
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
|
||||
const shorterItems = [{ value: 'X' }, { value: 'Y' }];
|
||||
const shorterItems = [
|
||||
{ value: 'X', key: 'X' },
|
||||
{ value: 'Y', key: 'Y' },
|
||||
];
|
||||
rerender({ items: shorterItems }); // Length 2
|
||||
|
||||
// The useEffect syncs based on the initialIndex (3) which is now out of bounds. It defaults to 0.
|
||||
@@ -707,7 +732,11 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should adjust activeIndex if items change and the initialIndex becomes disabled', () => {
|
||||
const initialItems = [{ value: 'A' }, { value: 'B' }, { value: 'C' }];
|
||||
const initialItems = [
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ items: testItems }: { items: Array<SelectionListItem<string>> }) =>
|
||||
useSelectionList({
|
||||
@@ -721,9 +750,9 @@ describe('useSelectionList', () => {
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
|
||||
const newItems = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
rerender({ items: newItems });
|
||||
|
||||
@@ -747,10 +776,10 @@ describe('useSelectionList', () => {
|
||||
|
||||
it('should not reset activeIndex when items are deeply equal', () => {
|
||||
const initialItems = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C' },
|
||||
{ value: 'D' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
{ value: 'D', key: 'D' },
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -775,10 +804,10 @@ describe('useSelectionList', () => {
|
||||
|
||||
// Create new array with same content (deeply equal but not identical)
|
||||
const newItems = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C' },
|
||||
{ value: 'D' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
{ value: 'D', key: 'D' },
|
||||
];
|
||||
|
||||
rerender({ items: newItems });
|
||||
@@ -791,10 +820,10 @@ describe('useSelectionList', () => {
|
||||
|
||||
it('should update activeIndex when items change structurally', () => {
|
||||
const initialItems = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C' },
|
||||
{ value: 'D' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
{ value: 'D', key: 'D' },
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -812,7 +841,11 @@ describe('useSelectionList', () => {
|
||||
mockOnHighlight.mockClear();
|
||||
|
||||
// Change item values (not deeply equal)
|
||||
const newItems = [{ value: 'X' }, { value: 'Y' }, { value: 'Z' }];
|
||||
const newItems = [
|
||||
{ value: 'X', key: 'X' },
|
||||
{ value: 'Y', key: 'Y' },
|
||||
{ value: 'Z', key: 'Z' },
|
||||
];
|
||||
|
||||
rerender({ items: newItems });
|
||||
|
||||
@@ -821,7 +854,11 @@ describe('useSelectionList', () => {
|
||||
});
|
||||
|
||||
it('should handle partial changes in items array', () => {
|
||||
const initialItems = [{ value: 'A' }, { value: 'B' }, { value: 'C' }];
|
||||
const initialItems = [
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ items: testItems }: { items: Array<SelectionListItem<string>> }) =>
|
||||
@@ -837,9 +874,9 @@ describe('useSelectionList', () => {
|
||||
|
||||
// Change only one item's disabled status
|
||||
const newItems = [
|
||||
{ value: 'A' },
|
||||
{ value: 'B', disabled: true },
|
||||
{ value: 'C' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', disabled: true, key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
|
||||
rerender({ items: newItems });
|
||||
@@ -847,6 +884,37 @@ describe('useSelectionList', () => {
|
||||
// Should find next valid index since current became disabled
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('should update selection when a new item is added to the start of the list', () => {
|
||||
const initialItems = [
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ items: testItems }: { items: Array<SelectionListItem<string>> }) =>
|
||||
useSelectionList({
|
||||
onSelect: mockOnSelect,
|
||||
items: testItems,
|
||||
}),
|
||||
{ initialProps: { items: initialItems } },
|
||||
);
|
||||
|
||||
pressKey('down');
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
|
||||
const newItems = [
|
||||
{ value: 'D', key: 'D' },
|
||||
{ value: 'A', key: 'A' },
|
||||
{ value: 'B', key: 'B' },
|
||||
{ value: 'C', key: 'C' },
|
||||
];
|
||||
|
||||
rerender({ items: newItems });
|
||||
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manual Control', () => {
|
||||
@@ -879,7 +947,7 @@ describe('useSelectionList', () => {
|
||||
it('should clear timeout on unmount when timer is active', () => {
|
||||
const longList: Array<SelectionListItem<string>> = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => ({ value: `Item ${i + 1}` }),
|
||||
(_, i) => ({ value: `Item ${i + 1}`, key: `Item ${i + 1}` }),
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useReducer, useRef, useEffect } from 'react';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
|
||||
export interface SelectionListItem<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -70,27 +71,6 @@ type SelectionListAction<T> =
|
||||
|
||||
const NUMBER_INPUT_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
* Performs an equality check on two arrays of SelectionListItem<T>.
|
||||
*
|
||||
* It compares the length of the arrays and then the 'value' and 'disabled'
|
||||
* properties of each item.
|
||||
*/
|
||||
const areItemsEqual = <T>(
|
||||
a: Array<SelectionListItem<T>>,
|
||||
b: Array<SelectionListItem<T>>,
|
||||
): boolean => {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i]!.value !== b[i]!.value || a[i]!.disabled !== b[i]!.disabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to find the next enabled index in a given direction, supporting wrapping.
|
||||
*/
|
||||
@@ -122,11 +102,20 @@ const findNextValidIndex = <T>(
|
||||
const computeInitialIndex = <T>(
|
||||
initialIndex: number,
|
||||
items: Array<SelectionListItem<T>>,
|
||||
initialKey?: string,
|
||||
): number => {
|
||||
if (items.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (initialKey !== undefined) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i]!.key === initialKey && !items[i]!.disabled) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let targetIndex = initialIndex;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= items.length) {
|
||||
@@ -184,13 +173,17 @@ function selectionListReducer<T>(
|
||||
|
||||
case 'INITIALIZE': {
|
||||
const { initialIndex, items } = action.payload;
|
||||
if (
|
||||
state.initialIndex === initialIndex &&
|
||||
areItemsEqual(state.items, items)
|
||||
) {
|
||||
const activeKey =
|
||||
initialIndex === state.initialIndex &&
|
||||
state.activeIndex !== state.initialIndex
|
||||
? state.items[state.activeIndex]?.key
|
||||
: undefined;
|
||||
|
||||
if (items === state.items && initialIndex === state.initialIndex) {
|
||||
return state;
|
||||
}
|
||||
const targetIndex = computeInitialIndex(initialIndex, items);
|
||||
|
||||
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -68,8 +68,8 @@ export const CloudFreePrivacyNotice = ({
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Yes', value: true, key: 'true' },
|
||||
{ label: 'No', value: false, key: 'false' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user