diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts
index cce3cb204f..81cd2dfb2c 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.test.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts
@@ -59,6 +59,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
}));
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
+vi.mock('../ui/commands/modelCommand.js', () => ({
+ modelCommand: { name: 'model' },
+}));
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
@@ -81,6 +84,7 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
+ getUseModelRouter: () => false,
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@@ -150,4 +154,26 @@ describe('BuiltinCommandLoader', () => {
const permissionsCmd = commands.find((c) => c.name === 'permissions');
expect(permissionsCmd).toBeUndefined();
});
+
+ it('should include modelCommand when getUseModelRouter is true', async () => {
+ const mockConfigWithModelRouter = {
+ ...mockConfig,
+ getUseModelRouter: () => true,
+ } as unknown as Config;
+ const loader = new BuiltinCommandLoader(mockConfigWithModelRouter);
+ const commands = await loader.loadCommands(new AbortController().signal);
+ const modelCmd = commands.find((c) => c.name === 'model');
+ expect(modelCmd).toBeDefined();
+ });
+
+ it('should not include modelCommand when getUseModelRouter is false', async () => {
+ const mockConfigWithoutModelRouter = {
+ ...mockConfig,
+ getUseModelRouter: () => false,
+ } as unknown as Config;
+ const loader = new BuiltinCommandLoader(mockConfigWithoutModelRouter);
+ const commands = await loader.loadCommands(new AbortController().signal);
+ const modelCmd = commands.find((c) => c.name === 'model');
+ expect(modelCmd).toBeUndefined();
+ });
});
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 08f9834403..fa688bea7f 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -24,6 +24,7 @@ import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
+import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
@@ -69,7 +70,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
initCommand,
mcpCommand,
memoryCommand,
- this.config?.getFolderTrust() ? permissionsCommand : null,
+ ...(this.config?.getUseModelRouter() ? [modelCommand] : []),
+ ...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
privacyCommand,
quitCommand,
restoreCommand(this.config),
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index cdca192ae4..dc7b5efe7e 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -54,6 +54,7 @@ vi.mock('./hooks/useThemeCommand.js');
vi.mock('./auth/useAuth.js');
vi.mock('./hooks/useEditorSettings.js');
vi.mock('./hooks/useSettingsCommand.js');
+vi.mock('./hooks/useModelCommand.js');
vi.mock('./hooks/slashCommandProcessor.js');
vi.mock('./hooks/useConsoleMessages.js');
vi.mock('./hooks/useTerminalSize.js', () => ({
@@ -86,6 +87,7 @@ import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
+import { useModelCommand } from './hooks/useModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
@@ -116,6 +118,7 @@ describe('AppContainer State Management', () => {
const mockedUseAuthCommand = useAuthCommand as Mock;
const mockedUseEditorSettings = useEditorSettings as Mock;
const mockedUseSettingsCommand = useSettingsCommand as Mock;
+ const mockedUseModelCommand = useModelCommand as Mock;
const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock;
const mockedUseConsoleMessages = useConsoleMessages as Mock;
const mockedUseGeminiStream = useGeminiStream as Mock;
@@ -172,6 +175,11 @@ describe('AppContainer State Management', () => {
openSettingsDialog: vi.fn(),
closeSettingsDialog: vi.fn(),
});
+ mockedUseModelCommand.mockReturnValue({
+ isModelDialogOpen: false,
+ openModelDialog: vi.fn(),
+ closeModelDialog: vi.fn(),
+ });
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand: vi.fn(),
slashCommands: [],
@@ -765,4 +773,48 @@ describe('AppContainer State Management', () => {
vi.useRealTimers();
});
});
+
+ describe('Model Dialog Integration', () => {
+ it('should provide isModelDialogOpen in the UIStateContext', () => {
+ mockedUseModelCommand.mockReturnValue({
+ isModelDialogOpen: true,
+ openModelDialog: vi.fn(),
+ closeModelDialog: vi.fn(),
+ });
+
+ render(
+ ,
+ );
+
+ expect(capturedUIState.isModelDialogOpen).toBe(true);
+ });
+
+ it('should provide model dialog actions in the UIActionsContext', () => {
+ const mockCloseModelDialog = vi.fn();
+
+ mockedUseModelCommand.mockReturnValue({
+ isModelDialogOpen: false,
+ openModelDialog: vi.fn(),
+ closeModelDialog: mockCloseModelDialog,
+ });
+
+ render(
+ ,
+ );
+
+ // Verify that the actions are correctly passed through context
+ capturedUIActions.closeModelDialog();
+ expect(mockCloseModelDialog).toHaveBeenCalled();
+ });
+ });
});
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 8d22baddb0..570f8cbf24 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -53,6 +53,7 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
+import { useModelCommand } from './hooks/useModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -418,6 +419,9 @@ Logging in with Google... Please restart Gemini CLI to continue.
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
+ const { isModelDialogOpen, openModelDialog, closeModelDialog } =
+ useModelCommand();
+
const {
showWorkspaceMigrationDialog,
workspaceExtensions,
@@ -434,6 +438,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
openEditorDialog,
openPrivacyNotice: () => setShowPrivacyNotice(true),
openSettingsDialog,
+ openModelDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
@@ -451,6 +456,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
openThemeDialog,
openEditorDialog,
openSettingsDialog,
+ openModelDialog,
setQuittingMessages,
setDebugMessage,
setShowPrivacyNotice,
@@ -997,6 +1003,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
+ isModelDialogOpen ||
isPermissionsDialogOpen ||
isAuthenticating ||
isAuthDialogOpen ||
@@ -1026,6 +1033,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
debugMessage,
quittingMessages,
isSettingsDialogOpen,
+ isModelDialogOpen,
isPermissionsDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
@@ -1102,6 +1110,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
debugMessage,
quittingMessages,
isSettingsDialogOpen,
+ isModelDialogOpen,
isPermissionsDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
@@ -1178,6 +1187,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
exitEditorDialog,
exitPrivacyNotice: () => setShowPrivacyNotice(false),
closeSettingsDialog,
+ closeModelDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@@ -1201,6 +1211,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
+ closeModelDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts
new file mode 100644
index 0000000000..8bab5962b9
--- /dev/null
+++ b/packages/cli/src/ui/commands/modelCommand.test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { modelCommand } from './modelCommand.js';
+import { type CommandContext } from './types.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+
+describe('modelCommand', () => {
+ let mockContext: CommandContext;
+
+ beforeEach(() => {
+ mockContext = createMockCommandContext();
+ });
+
+ it('should return a dialog action to open the model dialog', async () => {
+ if (!modelCommand.action) {
+ throw new Error('The model command must have an action.');
+ }
+
+ const result = await modelCommand.action(mockContext, '');
+
+ expect(result).toEqual({
+ type: 'dialog',
+ dialog: 'model',
+ });
+ });
+
+ it('should have the correct name and description', () => {
+ expect(modelCommand.name).toBe('model');
+ expect(modelCommand.description).toBe(
+ 'Opens a dialog to configure the model',
+ );
+ });
+});
diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts
new file mode 100644
index 0000000000..bfabbb4831
--- /dev/null
+++ b/packages/cli/src/ui/commands/modelCommand.ts
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CommandKind, type SlashCommand } from './types.js';
+
+export const modelCommand: SlashCommand = {
+ name: 'model',
+ description: 'Opens a dialog to configure the model',
+ kind: CommandKind.BUILT_IN,
+ action: async () => ({
+ type: 'dialog',
+ dialog: 'model',
+ }),
+};
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index cb12d62b39..1d40fe0e52 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -115,6 +115,7 @@ export interface OpenDialogActionReturn {
| 'editor'
| 'privacy'
| 'settings'
+ | 'model'
| 'permissions';
}
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index d65ad5f101..3117692c1b 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -19,6 +19,7 @@ import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
+import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -147,6 +148,9 @@ export const DialogManager = ({ addItem }: DialogManagerProps) => {
);
}
+ if (uiState.isModelDialogOpen) {
+ return ;
+ }
if (uiState.isAuthenticating) {
return (
({
+ useKeypress: vi.fn(),
+}));
+const mockedUseKeypress = vi.mocked(useKeypress);
+
+vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({
+ DescriptiveRadioButtonSelect: vi.fn(() => null),
+}));
+const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect);
+
+const renderComponent = (
+ props: Partial> = {},
+ contextValue: Partial | undefined = undefined,
+) => {
+ const defaultProps = {
+ onClose: vi.fn(),
+ };
+ const combinedProps = { ...defaultProps, ...props };
+
+ const mockConfig = contextValue
+ ? ({
+ getModel: vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO),
+ ...contextValue,
+ } as Config)
+ : undefined;
+
+ const renderResult = render(
+
+
+ ,
+ );
+
+ return {
+ ...renderResult,
+ props: combinedProps,
+ mockConfig,
+ };
+};
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('renders the title and help text', () => {
+ const { getByText } = renderComponent();
+ expect(getByText('Select Model')).toBeDefined();
+ expect(getByText('(Press Esc to close)')).toBeDefined();
+ expect(
+ getByText('> To use a specific Gemini model, use the --model flag.'),
+ ).toBeDefined();
+ });
+
+ it('passes all model options to DescriptiveRadioButtonSelect', () => {
+ renderComponent();
+ expect(mockedSelect).toHaveBeenCalledTimes(1);
+
+ const props = mockedSelect.mock.calls[0][0];
+ expect(props.items).toHaveLength(4);
+ expect(props.items[0].value).toBe(DEFAULT_GEMINI_MODEL_AUTO);
+ expect(props.items[1].value).toBe(DEFAULT_GEMINI_MODEL);
+ expect(props.items[2].value).toBe(DEFAULT_GEMINI_FLASH_MODEL);
+ expect(props.items[3].value).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
+ expect(props.showNumbers).toBe(true);
+ });
+
+ it('initializes with the model from ConfigContext', () => {
+ const mockGetModel = vi.fn(() => DEFAULT_GEMINI_FLASH_MODEL);
+ renderComponent({}, { getModel: mockGetModel });
+
+ expect(mockGetModel).toHaveBeenCalled();
+ expect(mockedSelect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ initialIndex: 2,
+ }),
+ undefined,
+ );
+ });
+
+ it('initializes with "auto" model if context is not provided', () => {
+ renderComponent({}, undefined);
+
+ expect(mockedSelect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ initialIndex: 0,
+ }),
+ undefined,
+ );
+ });
+
+ it('initializes with "auto" model if getModel returns undefined', () => {
+ const mockGetModel = vi.fn(() => undefined);
+ // @ts-expect-error This test validates component robustness when getModel
+ // returns an unexpected undefined value.
+ renderComponent({}, { getModel: mockGetModel });
+
+ expect(mockGetModel).toHaveBeenCalled();
+
+ // When getModel returns undefined, preferredModel falls back to DEFAULT_GEMINI_MODEL_AUTO
+ // which has index 0, so initialIndex should be 0
+ expect(mockedSelect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ initialIndex: 0,
+ }),
+ undefined,
+ );
+ expect(mockedSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
+ const mockSetModel = vi.fn();
+ const { props } = renderComponent({}, { setModel: mockSetModel });
+
+ const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
+ expect(childOnSelect).toBeDefined();
+
+ childOnSelect(DEFAULT_GEMINI_MODEL);
+
+ expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL);
+ expect(props.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => {
+ renderComponent();
+
+ const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight;
+ expect(childOnHighlight).toBeUndefined();
+ });
+
+ it('calls onClose prop when "escape" key is pressed', () => {
+ const { props } = renderComponent();
+
+ expect(mockedUseKeypress).toHaveBeenCalled();
+
+ const keyPressHandler = mockedUseKeypress.mock.calls[0][0];
+ const options = mockedUseKeypress.mock.calls[0][1];
+
+ expect(options).toEqual({ isActive: true });
+
+ keyPressHandler({
+ name: 'escape',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: false,
+ sequence: '',
+ });
+ expect(props.onClose).toHaveBeenCalledTimes(1);
+
+ keyPressHandler({
+ name: 'a',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: false,
+ sequence: '',
+ });
+ expect(props.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates initialIndex when config context changes', () => {
+ const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO);
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
+
+ mockGetModel.mockReturnValue(DEFAULT_GEMINI_FLASH_LITE_MODEL);
+ const newMockConfig = { getModel: mockGetModel } as unknown as Config;
+
+ rerender(
+
+
+ ,
+ );
+
+ // Should be called at least twice: initial render + re-render after context change
+ expect(mockedSelect).toHaveBeenCalledTimes(2);
+ expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(3);
+ });
+});
diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx
new file mode 100644
index 0000000000..6c47fb4c98
--- /dev/null
+++ b/packages/cli/src/ui/components/ModelDialog.tsx
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useCallback, useContext, useMemo } from 'react';
+import { Box, Text } from 'ink';
+import {
+ DEFAULT_GEMINI_FLASH_LITE_MODEL,
+ DEFAULT_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_MODEL,
+ DEFAULT_GEMINI_MODEL_AUTO,
+} from '@google/gemini-cli-core';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { theme } from '../semantic-colors.js';
+import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
+import { ConfigContext } from '../contexts/ConfigContext.js';
+
+interface ModelDialogProps {
+ onClose: () => void;
+}
+
+const MODEL_OPTIONS = [
+ {
+ value: DEFAULT_GEMINI_MODEL_AUTO,
+ title: 'Auto (recommended)',
+ description: 'Let the system choose the best model for your task',
+ },
+ {
+ value: DEFAULT_GEMINI_MODEL,
+ title: 'Pro',
+ description: 'For complex tasks that require deep reasoning and creativity',
+ },
+ {
+ value: DEFAULT_GEMINI_FLASH_MODEL,
+ title: 'Flash',
+ description: 'For tasks that need a balance of speed and reasoning',
+ },
+ {
+ value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
+ title: 'Flash-Lite',
+ description: 'For simple tasks that need to be done quickly',
+ },
+];
+
+export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
+ const config = useContext(ConfigContext);
+
+ // Determine the Preferred Model (read once when the dialog opens).
+ const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
+
+ useKeypress(
+ (key) => {
+ if (key.name === 'escape') {
+ onClose();
+ }
+ },
+ { isActive: true },
+ );
+
+ // Calculate the initial index based on the preferred model.
+ const initialIndex = useMemo(
+ () => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
+ [preferredModel],
+ );
+
+ // Handle selection internally (Autonomous Dialog).
+ const handleSelect = useCallback(
+ (model: string) => {
+ if (config) {
+ config.setModel(model);
+ }
+ onClose();
+ },
+ [config, onClose],
+ );
+
+ return (
+
+ Select Model
+
+
+
+
+
+ {'> To use a specific Gemini model, use the --model flag.'}
+
+
+
+ (Press Esc to close)
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
new file mode 100644
index 0000000000..55d763eee3
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import {
+ DescriptiveRadioButtonSelect,
+ type DescriptiveRadioSelectItem,
+ type DescriptiveRadioButtonSelectProps,
+} from './DescriptiveRadioButtonSelect.js';
+
+vi.mock('./BaseSelectionList.js', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ BaseSelectionList: vi.fn(({ children, ...props }) => (
+ {children}
+ )),
+ };
+});
+
+vi.mock('../../semantic-colors.js', () => ({
+ theme: {
+ text: {
+ primary: 'COLOR_PRIMARY',
+ secondary: 'COLOR_SECONDARY',
+ },
+ status: {
+ success: 'COLOR_SUCCESS',
+ },
+ },
+}));
+
+describe('DescriptiveRadioButtonSelect', () => {
+ const mockOnSelect = vi.fn();
+ const mockOnHighlight = vi.fn();
+
+ const ITEMS: Array> = [
+ { title: 'Foo Title', description: 'This is Foo.', value: 'foo' },
+ { title: 'Bar Title', description: 'This is Bar.', value: 'bar' },
+ {
+ title: 'Baz Title',
+ description: 'This is Baz.',
+ value: 'baz',
+ disabled: true,
+ },
+ ];
+
+ const renderComponent = (
+ props: Partial> = {},
+ ) => {
+ const defaultProps: DescriptiveRadioButtonSelectProps = {
+ items: ITEMS,
+ onSelect: mockOnSelect,
+ ...props,
+ };
+ return renderWithProviders(
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should render correctly with default props', () => {
+ const { lastFrame } = renderComponent();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('should render correctly with custom props', () => {
+ const { lastFrame } = renderComponent({
+ initialIndex: 1,
+ isFocused: false,
+ showScrollArrows: true,
+ maxItemsToShow: 5,
+ showNumbers: true,
+ onHighlight: mockOnHighlight,
+ });
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx
new file mode 100644
index 0000000000..a45daa30f9
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, Box } from 'ink';
+import { theme } from '../../semantic-colors.js';
+import { BaseSelectionList } from './BaseSelectionList.js';
+
+export interface DescriptiveRadioSelectItem {
+ value: T;
+ title: string;
+ description: string;
+ disabled?: boolean;
+}
+
+export interface DescriptiveRadioButtonSelectProps {
+ /** An array of items to display as descriptive radio options. */
+ items: Array>;
+ /** The initial index selected */
+ initialIndex?: number;
+ /** Function called when an item is selected. Receives the `value` of the selected item. */
+ onSelect: (value: T) => void;
+ /** Function called when an item is highlighted. Receives the `value` of the selected item. */
+ onHighlight?: (value: T) => void;
+ /** Whether this select input is currently focused and should respond to input. */
+ isFocused?: boolean;
+ /** Whether to show numbers next to items. */
+ showNumbers?: boolean;
+ /** Whether to show the scroll arrows. */
+ showScrollArrows?: boolean;
+ /** The maximum number of items to show at once. */
+ maxItemsToShow?: number;
+}
+
+/**
+ * A radio button select component that displays items with title and description.
+ *
+ * @template T The type of the value associated with each descriptive radio item.
+ */
+export function DescriptiveRadioButtonSelect({
+ items,
+ initialIndex = 0,
+ onSelect,
+ onHighlight,
+ isFocused = true,
+ showNumbers = false,
+ showScrollArrows = false,
+ maxItemsToShow = 10,
+}: DescriptiveRadioButtonSelectProps): React.JSX.Element {
+ return (
+ >
+ items={items}
+ initialIndex={initialIndex}
+ onSelect={onSelect}
+ onHighlight={onHighlight}
+ isFocused={isFocused}
+ showNumbers={showNumbers}
+ showScrollArrows={showScrollArrows}
+ maxItemsToShow={maxItemsToShow}
+ renderItem={(item, { titleColor }) => (
+
+ {item.title}
+ {item.description}
+
+ )}
+ />
+ );
+}
diff --git a/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap
new file mode 100644
index 0000000000..822b88b0c8
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
+"▲
+ 1. Foo Title
+ This is Foo.
+● 2. Bar Title
+ This is Bar.
+ 3. Baz Title
+ This is Baz.
+▼"
+`;
+
+exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
+"● Foo Title
+ This is Foo.
+ Bar Title
+ This is Bar.
+ Baz Title
+ This is Baz."
+`;
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index 1ef8c3420d..c9e7432fba 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -31,6 +31,7 @@ export interface UIActions {
exitEditorDialog: () => void;
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
+ closeModelDialog: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 8575dce3aa..5e3197ee1f 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -53,6 +53,7 @@ export interface UIState {
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
+ isModelDialogOpen: boolean;
isPermissionsDialogOpen: boolean;
slashCommands: readonly SlashCommand[];
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index 4cf5bce5ed..a59258a265 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -107,6 +107,7 @@ describe('useSlashCommandProcessor', () => {
const mockLoadHistory = vi.fn();
const mockOpenThemeDialog = vi.fn();
const mockOpenAuthDialog = vi.fn();
+ const mockOpenModelDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockConfig = makeFakeConfig({});
@@ -147,6 +148,7 @@ describe('useSlashCommandProcessor', () => {
openEditorDialog: vi.fn(),
openPrivacyNotice: vi.fn(),
openSettingsDialog: vi.fn(),
+ openModelDialog: mockOpenModelDialog,
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
@@ -391,6 +393,21 @@ describe('useSlashCommandProcessor', () => {
expect(mockOpenThemeDialog).toHaveBeenCalled();
});
+ it('should handle "dialog: model" action', async () => {
+ const command = createTestCommand({
+ name: 'modelcmd',
+ action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'model' }),
+ });
+ const result = setupProcessorHook([command]);
+ await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
+
+ await act(async () => {
+ await result.current.handleSlashCommand('/modelcmd');
+ });
+
+ expect(mockOpenModelDialog).toHaveBeenCalled();
+ });
+
it('should handle "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 962efb715f..2ba291d2ad 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -49,6 +49,7 @@ interface SlashCommandProcessorActions {
openEditorDialog: () => void;
openPrivacyNotice: () => void;
openSettingsDialog: () => void;
+ openModelDialog: () => void;
openPermissionsDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
@@ -374,6 +375,9 @@ export const useSlashCommandProcessor = (
case 'settings':
actions.openSettingsDialog();
return { type: 'handled' };
+ case 'model':
+ actions.openModelDialog();
+ return { type: 'handled' };
case 'permissions':
actions.openPermissionsDialog();
return { type: 'handled' };
diff --git a/packages/cli/src/ui/hooks/useModelCommand.test.ts b/packages/cli/src/ui/hooks/useModelCommand.test.ts
new file mode 100644
index 0000000000..30cbe7e56a
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useModelCommand.test.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useModelCommand } from './useModelCommand.js';
+
+describe('useModelCommand', () => {
+ it('should initialize with the model dialog closed', () => {
+ const { result } = renderHook(() => useModelCommand());
+ expect(result.current.isModelDialogOpen).toBe(false);
+ });
+
+ it('should open the model dialog when openModelDialog is called', () => {
+ const { result } = renderHook(() => useModelCommand());
+
+ act(() => {
+ result.current.openModelDialog();
+ });
+
+ expect(result.current.isModelDialogOpen).toBe(true);
+ });
+
+ it('should close the model dialog when closeModelDialog is called', () => {
+ const { result } = renderHook(() => useModelCommand());
+
+ // Open it first
+ act(() => {
+ result.current.openModelDialog();
+ });
+ expect(result.current.isModelDialogOpen).toBe(true);
+
+ // Then close it
+ act(() => {
+ result.current.closeModelDialog();
+ });
+ expect(result.current.isModelDialogOpen).toBe(false);
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useModelCommand.ts b/packages/cli/src/ui/hooks/useModelCommand.ts
new file mode 100644
index 0000000000..c26dcf95a7
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useModelCommand.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useCallback } from 'react';
+
+interface UseModelCommandReturn {
+ isModelDialogOpen: boolean;
+ openModelDialog: () => void;
+ closeModelDialog: () => void;
+}
+
+export const useModelCommand = (): UseModelCommandReturn => {
+ const [isModelDialogOpen, setIsModelDialogOpen] = useState(false);
+
+ const openModelDialog = useCallback(() => {
+ setIsModelDialogOpen(true);
+ }, []);
+
+ const closeModelDialog = useCallback(() => {
+ setIsModelDialogOpen(false);
+ }, []);
+
+ return {
+ isModelDialogOpen,
+ openModelDialog,
+ closeModelDialog,
+ };
+};