mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(cli): Add /model command for interactive model selection (#8940)
Co-authored-by: Miguel Solorio <miguel.solorio07@gmail.com>
This commit is contained in:
@@ -59,6 +59,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
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/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||||
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
||||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||||
@@ -81,6 +84,7 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||||
|
getUseModelRouter: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
restoreCommandMock.mockReturnValue({
|
restoreCommandMock.mockReturnValue({
|
||||||
@@ -150,4 +154,26 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||||
expect(permissionsCmd).toBeUndefined();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ideCommand } from '../ui/commands/ideCommand.js';
|
|||||||
import { initCommand } from '../ui/commands/initCommand.js';
|
import { initCommand } from '../ui/commands/initCommand.js';
|
||||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||||
|
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||||
@@ -69,7 +70,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
initCommand,
|
initCommand,
|
||||||
mcpCommand,
|
mcpCommand,
|
||||||
memoryCommand,
|
memoryCommand,
|
||||||
this.config?.getFolderTrust() ? permissionsCommand : null,
|
...(this.config?.getUseModelRouter() ? [modelCommand] : []),
|
||||||
|
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||||
privacyCommand,
|
privacyCommand,
|
||||||
quitCommand,
|
quitCommand,
|
||||||
restoreCommand(this.config),
|
restoreCommand(this.config),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ vi.mock('./hooks/useThemeCommand.js');
|
|||||||
vi.mock('./auth/useAuth.js');
|
vi.mock('./auth/useAuth.js');
|
||||||
vi.mock('./hooks/useEditorSettings.js');
|
vi.mock('./hooks/useEditorSettings.js');
|
||||||
vi.mock('./hooks/useSettingsCommand.js');
|
vi.mock('./hooks/useSettingsCommand.js');
|
||||||
|
vi.mock('./hooks/useModelCommand.js');
|
||||||
vi.mock('./hooks/slashCommandProcessor.js');
|
vi.mock('./hooks/slashCommandProcessor.js');
|
||||||
vi.mock('./hooks/useConsoleMessages.js');
|
vi.mock('./hooks/useConsoleMessages.js');
|
||||||
vi.mock('./hooks/useTerminalSize.js', () => ({
|
vi.mock('./hooks/useTerminalSize.js', () => ({
|
||||||
@@ -86,6 +87,7 @@ import { useThemeCommand } from './hooks/useThemeCommand.js';
|
|||||||
import { useAuthCommand } from './auth/useAuth.js';
|
import { useAuthCommand } from './auth/useAuth.js';
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
@@ -116,6 +118,7 @@ describe('AppContainer State Management', () => {
|
|||||||
const mockedUseAuthCommand = useAuthCommand as Mock;
|
const mockedUseAuthCommand = useAuthCommand as Mock;
|
||||||
const mockedUseEditorSettings = useEditorSettings as Mock;
|
const mockedUseEditorSettings = useEditorSettings as Mock;
|
||||||
const mockedUseSettingsCommand = useSettingsCommand as Mock;
|
const mockedUseSettingsCommand = useSettingsCommand as Mock;
|
||||||
|
const mockedUseModelCommand = useModelCommand as Mock;
|
||||||
const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock;
|
const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock;
|
||||||
const mockedUseConsoleMessages = useConsoleMessages as Mock;
|
const mockedUseConsoleMessages = useConsoleMessages as Mock;
|
||||||
const mockedUseGeminiStream = useGeminiStream as Mock;
|
const mockedUseGeminiStream = useGeminiStream as Mock;
|
||||||
@@ -172,6 +175,11 @@ describe('AppContainer State Management', () => {
|
|||||||
openSettingsDialog: vi.fn(),
|
openSettingsDialog: vi.fn(),
|
||||||
closeSettingsDialog: vi.fn(),
|
closeSettingsDialog: vi.fn(),
|
||||||
});
|
});
|
||||||
|
mockedUseModelCommand.mockReturnValue({
|
||||||
|
isModelDialogOpen: false,
|
||||||
|
openModelDialog: vi.fn(),
|
||||||
|
closeModelDialog: vi.fn(),
|
||||||
|
});
|
||||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||||
handleSlashCommand: vi.fn(),
|
handleSlashCommand: vi.fn(),
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
@@ -765,4 +773,48 @@ describe('AppContainer State Management', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Model Dialog Integration', () => {
|
||||||
|
it('should provide isModelDialogOpen in the UIStateContext', () => {
|
||||||
|
mockedUseModelCommand.mockReturnValue({
|
||||||
|
isModelDialogOpen: true,
|
||||||
|
openModelDialog: vi.fn(),
|
||||||
|
closeModelDialog: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppContainer
|
||||||
|
config={mockConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version="1.0.0"
|
||||||
|
initializationResult={mockInitResult}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AppContainer
|
||||||
|
config={mockConfig}
|
||||||
|
settings={mockSettings}
|
||||||
|
version="1.0.0"
|
||||||
|
initializationResult={mockInitResult}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that the actions are correctly passed through context
|
||||||
|
capturedUIActions.closeModelDialog();
|
||||||
|
expect(mockCloseModelDialog).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { useAuthCommand } from './auth/useAuth.js';
|
|||||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
|
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
import { useVimMode } from './contexts/VimModeContext.js';
|
import { useVimMode } from './contexts/VimModeContext.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.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 } =
|
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||||
useSettingsCommand();
|
useSettingsCommand();
|
||||||
|
|
||||||
|
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||||
|
useModelCommand();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showWorkspaceMigrationDialog,
|
showWorkspaceMigrationDialog,
|
||||||
workspaceExtensions,
|
workspaceExtensions,
|
||||||
@@ -434,6 +438,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
openPrivacyNotice: () => setShowPrivacyNotice(true),
|
openPrivacyNotice: () => setShowPrivacyNotice(true),
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
|
openModelDialog,
|
||||||
openPermissionsDialog,
|
openPermissionsDialog,
|
||||||
quit: (messages: HistoryItem[]) => {
|
quit: (messages: HistoryItem[]) => {
|
||||||
setQuittingMessages(messages);
|
setQuittingMessages(messages);
|
||||||
@@ -451,6 +456,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
|
openModelDialog,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
setShowPrivacyNotice,
|
setShowPrivacyNotice,
|
||||||
@@ -997,6 +1003,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
!!loopDetectionConfirmationRequest ||
|
!!loopDetectionConfirmationRequest ||
|
||||||
isThemeDialogOpen ||
|
isThemeDialogOpen ||
|
||||||
isSettingsDialogOpen ||
|
isSettingsDialogOpen ||
|
||||||
|
isModelDialogOpen ||
|
||||||
isPermissionsDialogOpen ||
|
isPermissionsDialogOpen ||
|
||||||
isAuthenticating ||
|
isAuthenticating ||
|
||||||
isAuthDialogOpen ||
|
isAuthDialogOpen ||
|
||||||
@@ -1026,6 +1033,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
debugMessage,
|
debugMessage,
|
||||||
quittingMessages,
|
quittingMessages,
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
@@ -1102,6 +1110,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
debugMessage,
|
debugMessage,
|
||||||
quittingMessages,
|
quittingMessages,
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
|
isModelDialogOpen,
|
||||||
isPermissionsDialogOpen,
|
isPermissionsDialogOpen,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
@@ -1178,6 +1187,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
exitPrivacyNotice: () => setShowPrivacyNotice(false),
|
exitPrivacyNotice: () => setShowPrivacyNotice(false),
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
|
closeModelDialog,
|
||||||
closePermissionsDialog,
|
closePermissionsDialog,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
@@ -1201,6 +1211,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
|
|||||||
handleEditorSelect,
|
handleEditorSelect,
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
|
closeModelDialog,
|
||||||
closePermissionsDialog,
|
closePermissionsDialog,
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -115,6 +115,7 @@ export interface OpenDialogActionReturn {
|
|||||||
| 'editor'
|
| 'editor'
|
||||||
| 'privacy'
|
| 'privacy'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
| 'model'
|
||||||
| 'permissions';
|
| 'permissions';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
|||||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||||
|
import { ModelDialog } from './ModelDialog.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
@@ -147,6 +148,9 @@ export const DialogManager = ({ addItem }: DialogManagerProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (uiState.isModelDialogOpen) {
|
||||||
|
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
||||||
|
}
|
||||||
if (uiState.isAuthenticating) {
|
if (uiState.isAuthenticating) {
|
||||||
return (
|
return (
|
||||||
<AuthInProgress
|
<AuthInProgress
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, cleanup } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||||
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
|
DEFAULT_GEMINI_MODEL_AUTO,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { ModelDialog } from './ModelDialog.js';
|
||||||
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||||
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('../hooks/useKeypress.js', () => ({
|
||||||
|
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<React.ComponentProps<typeof ModelDialog>> = {},
|
||||||
|
contextValue: Partial<Config> | 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(
|
||||||
|
<ConfigContext.Provider value={mockConfig}>
|
||||||
|
<ModelDialog {...combinedProps} />
|
||||||
|
</ConfigContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...renderResult,
|
||||||
|
props: combinedProps,
|
||||||
|
mockConfig,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<ModelDialog />', () => {
|
||||||
|
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(
|
||||||
|
<ConfigContext.Provider
|
||||||
|
value={{ getModel: mockGetModel } as unknown as Config}
|
||||||
|
>
|
||||||
|
<ModelDialog onClose={vi.fn()} />
|
||||||
|
</ConfigContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ConfigContext.Provider value={newMockConfig}>
|
||||||
|
<ModelDialog onClose={vi.fn()} />
|
||||||
|
</ConfigContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={theme.border.default}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text bold>Select Model</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<DescriptiveRadioButtonSelect
|
||||||
|
items={MODEL_OPTIONS}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
initialIndex={initialIndex}
|
||||||
|
showNumbers={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{'> To use a specific Gemini model, use the --model flag.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof import('./BaseSelectionList.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
BaseSelectionList: vi.fn(({ children, ...props }) => (
|
||||||
|
<actual.BaseSelectionList {...props}>{children}</actual.BaseSelectionList>
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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<DescriptiveRadioSelectItem<string>> = [
|
||||||
|
{ 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<DescriptiveRadioButtonSelectProps<string>> = {},
|
||||||
|
) => {
|
||||||
|
const defaultProps: DescriptiveRadioButtonSelectProps<string> = {
|
||||||
|
items: ITEMS,
|
||||||
|
onSelect: mockOnSelect,
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
return renderWithProviders(
|
||||||
|
<DescriptiveRadioButtonSelect {...defaultProps} />,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T> {
|
||||||
|
value: T;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DescriptiveRadioButtonSelectProps<T> {
|
||||||
|
/** An array of items to display as descriptive radio options. */
|
||||||
|
items: Array<DescriptiveRadioSelectItem<T>>;
|
||||||
|
/** 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<T>({
|
||||||
|
items,
|
||||||
|
initialIndex = 0,
|
||||||
|
onSelect,
|
||||||
|
onHighlight,
|
||||||
|
isFocused = true,
|
||||||
|
showNumbers = false,
|
||||||
|
showScrollArrows = false,
|
||||||
|
maxItemsToShow = 10,
|
||||||
|
}: DescriptiveRadioButtonSelectProps<T>): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<BaseSelectionList<T, DescriptiveRadioSelectItem<T>>
|
||||||
|
items={items}
|
||||||
|
initialIndex={initialIndex}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHighlight={onHighlight}
|
||||||
|
isFocused={isFocused}
|
||||||
|
showNumbers={showNumbers}
|
||||||
|
showScrollArrows={showScrollArrows}
|
||||||
|
maxItemsToShow={maxItemsToShow}
|
||||||
|
renderItem={(item, { titleColor }) => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={titleColor}>{item.title}</Text>
|
||||||
|
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
@@ -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."
|
||||||
|
`;
|
||||||
@@ -31,6 +31,7 @@ export interface UIActions {
|
|||||||
exitEditorDialog: () => void;
|
exitEditorDialog: () => void;
|
||||||
exitPrivacyNotice: () => void;
|
exitPrivacyNotice: () => void;
|
||||||
closeSettingsDialog: () => void;
|
closeSettingsDialog: () => void;
|
||||||
|
closeModelDialog: () => void;
|
||||||
closePermissionsDialog: () => void;
|
closePermissionsDialog: () => void;
|
||||||
setShellModeActive: (value: boolean) => void;
|
setShellModeActive: (value: boolean) => void;
|
||||||
vimHandleInput: (key: Key) => boolean;
|
vimHandleInput: (key: Key) => boolean;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface UIState {
|
|||||||
debugMessage: string;
|
debugMessage: string;
|
||||||
quittingMessages: HistoryItem[] | null;
|
quittingMessages: HistoryItem[] | null;
|
||||||
isSettingsDialogOpen: boolean;
|
isSettingsDialogOpen: boolean;
|
||||||
|
isModelDialogOpen: boolean;
|
||||||
isPermissionsDialogOpen: boolean;
|
isPermissionsDialogOpen: boolean;
|
||||||
slashCommands: readonly SlashCommand[];
|
slashCommands: readonly SlashCommand[];
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
const mockLoadHistory = vi.fn();
|
const mockLoadHistory = vi.fn();
|
||||||
const mockOpenThemeDialog = vi.fn();
|
const mockOpenThemeDialog = vi.fn();
|
||||||
const mockOpenAuthDialog = vi.fn();
|
const mockOpenAuthDialog = vi.fn();
|
||||||
|
const mockOpenModelDialog = vi.fn();
|
||||||
const mockSetQuittingMessages = vi.fn();
|
const mockSetQuittingMessages = vi.fn();
|
||||||
|
|
||||||
const mockConfig = makeFakeConfig({});
|
const mockConfig = makeFakeConfig({});
|
||||||
@@ -147,6 +148,7 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
openEditorDialog: vi.fn(),
|
openEditorDialog: vi.fn(),
|
||||||
openPrivacyNotice: vi.fn(),
|
openPrivacyNotice: vi.fn(),
|
||||||
openSettingsDialog: vi.fn(),
|
openSettingsDialog: vi.fn(),
|
||||||
|
openModelDialog: mockOpenModelDialog,
|
||||||
quit: mockSetQuittingMessages,
|
quit: mockSetQuittingMessages,
|
||||||
setDebugMessage: vi.fn(),
|
setDebugMessage: vi.fn(),
|
||||||
toggleCorgiMode: vi.fn(),
|
toggleCorgiMode: vi.fn(),
|
||||||
@@ -391,6 +393,21 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
expect(mockOpenThemeDialog).toHaveBeenCalled();
|
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 () => {
|
it('should handle "load_history" action', async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
setHistory: vi.fn(),
|
setHistory: vi.fn(),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface SlashCommandProcessorActions {
|
|||||||
openEditorDialog: () => void;
|
openEditorDialog: () => void;
|
||||||
openPrivacyNotice: () => void;
|
openPrivacyNotice: () => void;
|
||||||
openSettingsDialog: () => void;
|
openSettingsDialog: () => void;
|
||||||
|
openModelDialog: () => void;
|
||||||
openPermissionsDialog: () => void;
|
openPermissionsDialog: () => void;
|
||||||
quit: (messages: HistoryItem[]) => void;
|
quit: (messages: HistoryItem[]) => void;
|
||||||
setDebugMessage: (message: string) => void;
|
setDebugMessage: (message: string) => void;
|
||||||
@@ -374,6 +375,9 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'settings':
|
case 'settings':
|
||||||
actions.openSettingsDialog();
|
actions.openSettingsDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'model':
|
||||||
|
actions.openModelDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
case 'permissions':
|
case 'permissions':
|
||||||
actions.openPermissionsDialog();
|
actions.openPermissionsDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user