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, + }; +};