diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e35d7abf6a..4675ce7f0d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -51,6 +51,7 @@ import { type Settings, type MergedSettings, saveModelChange, + saveAgentChange, loadSettings, isWorktreeEnabled, type LoadedSettings, @@ -77,6 +78,7 @@ import { runExitCleanup } from '../utils/cleanup.js'; export interface CliArgs { query: string | undefined; model: string | undefined; + agent?: string; sandbox: boolean | string | undefined; debug: boolean | undefined; prompt: string | undefined; @@ -289,6 +291,12 @@ export async function parseArguments( nargs: 1, description: `Model`, }) + .option('agent', { + alias: 'a', + type: 'string', + nargs: 1, + description: `Agent to use: gemini-cli or gemini-enterprise`, + }) .option('prompt', { alias: 'p', type: 'string', @@ -840,6 +848,18 @@ export async function loadCliConfig( specifiedModel === GEMINI_MODEL_ALIAS_AUTO ? defaultModel : specifiedModel || defaultModel; + + const defaultAgent = 'gemini-cli'; + const rawAgent = + argv.agent || process.env['GEMINI_CLI_AGENT'] || settings.agent?.name; + + const specifiedAgent = Array.isArray(rawAgent) + ? String(rawAgent.at(-1) ?? '').trim() || '' + : rawAgent === undefined + ? undefined + : String(rawAgent ?? '').trim() || ''; + + const resolvedAgent = specifiedAgent || defaultAgent; const sandboxConfig = await loadSandboxConfig(settings, argv); if (sandboxConfig) { const existingPaths = sandboxConfig.allowedPaths || []; @@ -1014,6 +1034,7 @@ export async function loadCliConfig( fileDiscoveryService: fileService, bugCommand: settings.advanced?.bugCommand, model: resolvedModel, + agent: resolvedAgent, maxSessionTurns: settings.model?.maxSessionTurns, listExtensions: argv.listExtensions || false, @@ -1093,6 +1114,7 @@ export async function loadCliConfig( disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), + onAgentChange: (agent: string) => saveAgentChange(loadSettings(cwd), agent), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { diff --git a/packages/cli/src/config/footerItems.test.ts b/packages/cli/src/config/footerItems.test.ts index a5b758bfb2..ced2501c0a 100644 --- a/packages/cli/src/config/footerItems.test.ts +++ b/packages/cli/src/config/footerItems.test.ts @@ -23,6 +23,7 @@ describe('footerItems', () => { 'git-branch', 'sandbox', 'model-name', + 'agent-name', 'quota', ]); }); @@ -91,6 +92,7 @@ describe('footerItems', () => { 'git-branch', 'sandbox', 'context-used', + 'agent-name', 'memory-usage', ]); }); @@ -117,6 +119,7 @@ describe('footerItems', () => { 'git-branch', 'sandbox', 'context-used', + 'agent-name', 'quota', 'memory-usage', 'session-id', diff --git a/packages/cli/src/config/footerItems.ts b/packages/cli/src/config/footerItems.ts index faed6efd21..07e2d55acf 100644 --- a/packages/cli/src/config/footerItems.ts +++ b/packages/cli/src/config/footerItems.ts @@ -27,6 +27,11 @@ export const ALL_ITEMS = [ header: '/model', description: 'Current model identifier', }, + { + id: 'agent-name', + header: '/agent', + description: 'Current active agent name', + }, { id: 'context-used', header: 'context', @@ -76,6 +81,7 @@ export const DEFAULT_ORDER = [ 'git-branch', 'sandbox', 'model-name', + 'agent-name', 'context-used', 'quota', 'memory-usage', @@ -94,6 +100,7 @@ export function deriveItemsFromLegacySettings( 'git-branch', 'sandbox', 'model-name', + 'agent-name', 'quota', ]; const items = [...defaults]; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index fd064533c6..b4c51015ea 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -1197,6 +1197,22 @@ export function saveModelChange( } } +export function saveAgentChange( + loadedSettings: LoadedSettings, + agent: string, +): void { + try { + loadedSettings.setValue(SettingScope.User, 'agent.name', agent); + } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); + coreEvents.emitFeedback( + 'error', + `Failed to save preferred agent: ${detailedErrorMessage}`, + error, + ); + } +} + function migrateExperimentalSettings( settings: Settings, loadedSettings: LoadedSettings, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b18ca324be..441ff17a4e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1043,6 +1043,27 @@ const SETTINGS_SCHEMA = { }, }, + agent: { + type: 'object', + label: 'Agent', + category: 'Agent', + requiresRestart: false, + default: {}, + description: 'Settings related to the active AI agent.', + showInDialog: false, + properties: { + name: { + type: 'string', + label: 'Agent Name', + category: 'Agent', + requiresRestart: false, + default: 'gemini-cli', + description: 'The active AI agent to use (gemini-cli or gemini-enterprise).', + showInDialog: true, + }, + }, + }, + model: { type: 'object', label: 'Model', diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index fd8d71f57f..ea6a48f9ec 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -19,6 +19,7 @@ import { type Config, type ResumedSessionData, coreEvents, + CoreEvent, createWorkingStdio, disableMouseEvents, enableMouseEvents, @@ -101,6 +102,17 @@ export async function startInteractiveUI( // Create wrapper component to use hooks inside render const AppWrapper = () => { useKittyKeyboardProtocol(); + const [agentKey, setAgentKey] = React.useState(() => config.getAgent()); + + React.useEffect(() => { + const handleAgentChanged = (payload: { agent: string }) => { + setAgentKey(payload.agent); + }; + coreEvents.on(CoreEvent.AgentChanged, handleAgentChanged); + return () => { + coreEvents.off(CoreEvent.AgentChanged, handleAgentChanged); + }; + }, []); return ( @@ -113,6 +125,7 @@ export async function startInteractiveUI( { getRawOutput: vi.fn().mockReturnValue(false), getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false), + getAgent: vi.fn().mockReturnValue('gemini-cli'), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 97bc4904e9..90fdaf1041 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -67,6 +67,7 @@ export async function runNonInteractive( params: RunNonInteractiveParams, ): Promise { const useAgentSession = + params.config.getAgent() === 'gemini-enterprise' || params.config.getAgentSessionNoninteractiveEnabled() || process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true'; if (useAgentSession) { diff --git a/packages/cli/src/nonInteractiveCliAgentSession.test.ts b/packages/cli/src/nonInteractiveCliAgentSession.test.ts index 77920f1879..2b847d0c9a 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.test.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.test.ts @@ -199,6 +199,7 @@ describe('runNonInteractive', () => { getRawOutput: vi.fn().mockReturnValue(false), getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false), + getAgent: vi.fn().mockReturnValue('gemini-cli'), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index af62bef962..005140e779 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -296,7 +296,9 @@ export async function runNonInteractive({ }); } - const useEnterprise = process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true'; + const useEnterprise = + config.getAgent() === 'gemini-enterprise' || + process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true'; // Create AgentSession — owns the agentic loop const session = useEnterprise ? new EnterpriseAgentSession({ config, promptId: prompt_id }) diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 67b0861eb5..e96541a4bf 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -19,6 +19,7 @@ import { AuthType, } from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; +import { agentCommand } from '../ui/commands/agentCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; @@ -122,6 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader { const allDefinitions: Array = [ aboutCommand, + agentCommand, ...(this.config?.isAgentsEnabled() ? [agentsCommand] : []), authCommand, bugCommand, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 5f5ae4b8dc..33ffffe5bd 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -509,6 +509,7 @@ const baseMockUiState = { terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', + activeAgent: 'gemini-cli', terminalBackgroundColor: 'black' as const, cleanUiDetailsVisible: false, allowPlanMode: true, @@ -552,6 +553,8 @@ const mockUIActions: UIActions = { exitPrivacyNotice: vi.fn(), closeSettingsDialog: vi.fn(), closeModelDialog: vi.fn(), + closeAgentDialog: vi.fn(), + handleAgentSelect: vi.fn(), openVoiceModelDialog: vi.fn(), closeVoiceModelDialog: vi.fn(), openAgentConfigDialog: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fb98afe4cc..fd9ea7fe18 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -103,6 +103,7 @@ 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 { useAgentCommand } from './hooks/useAgentCommand.js'; import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; @@ -147,6 +148,7 @@ import { relaunchApp } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; +import { ConsentPrompt } from './components/ConsentPrompt.js'; import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; @@ -938,6 +940,9 @@ Logging in with Google... Restarting Gemini CLI to continue. const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); + const { isAgentDialogOpen, openAgentDialog, closeAgentDialog } = + useAgentCommand(); + const activeAgent = config?.getAgent() || 'gemini-cli'; const { isVoiceModelDialogOpen, @@ -968,6 +973,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openSettingsDialog, openSessionBrowser, openModelDialog, + openAgentDialog, openVoiceModelDialog, openAgentConfigDialog, openPermissionsDialog, @@ -1007,6 +1013,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openSettingsDialog, openSessionBrowser, openModelDialog, + openAgentDialog, openVoiceModelDialog, openAgentConfigDialog, setQuittingMessages, @@ -1175,14 +1182,14 @@ Logging in with Google... Restarting Gemini CLI to continue. const streamAgent = useMemo( () => { - if (process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true') { + if (activeAgent === 'gemini-enterprise') { return new EnterpriseAgentProtocol({ config }); } return config?.getAgentSessionInteractiveEnabled() ? new LegacyAgentProtocol({ config, getPreferredEditor }) : undefined; }, - [config, getPreferredEditor], + [config, getPreferredEditor, activeAgent], ); const activeStream = streamAgent @@ -1238,6 +1245,65 @@ Logging in with Google... Restarting Gemini CLI to continue. retryStatus, } = activeStream; + const handleAgentSelect = useCallback( + (agentName: string) => { + const doSwap = () => { + config.setAgent(agentName, false); + + // 1. Reset UI history + historyManager.clearItems(); + refreshStatic(); + setBannerVisible(false); + + // 2. Reset streaming loop state + if (activeStream && 'reset' in activeStream) { + (activeStream as { reset: () => void }).reset(); + } + }; + + const isSessionNonEmpty = historyManager.history.some( + (item) => item.type === 'user' || item.type === 'gemini', + ); + + if (isSessionNonEmpty) { + setCustomDialog( + { + setCustomDialog(null); + if (confirm) { + doSwap(); + } + }} + terminalWidth={terminalWidth} + />, + ); + } else { + doSwap(); + } + }, + [ + config, + historyManager, + refreshStatic, + setBannerVisible, + activeStream, + setCustomDialog, + terminalWidth, + ], + ); + + useEffect(() => { + const handleAgentChanged = (payload: { agent: string }) => { + handleAgentSelect(payload.agent); + }; + + coreEvents.on(CoreEvent.AgentChanged, handleAgentChanged); + return () => { + coreEvents.off(CoreEvent.AgentChanged, handleAgentChanged); + }; + }, [handleAgentSelect]); + const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], @@ -2215,6 +2281,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || + isAgentDialogOpen || isVoiceModelDialogOpen || isAgentConfigDialogOpen || isPermissionsDialogOpen || @@ -2476,6 +2543,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, + isAgentDialogOpen, isVoiceModelDialogOpen, isAgentConfigDialogOpen, selectedAgentName, @@ -2526,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue. showApprovalModeIndicator, allowPlanMode, currentModel, + activeAgent, contextFileNames, errorCount, availableTerminalHeight, @@ -2589,6 +2658,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, + isAgentDialogOpen, isVoiceModelDialogOpen, isAgentConfigDialogOpen, selectedAgentName, @@ -2660,6 +2730,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ideTrustRestartReason, isRestarting, currentModel, + activeAgent, extensionsUpdateState, activePtyId, backgroundTaskCount, @@ -2705,6 +2776,8 @@ Logging in with Google... Restarting Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + closeAgentDialog, + handleAgentSelect, openVoiceModelDialog, closeVoiceModelDialog, openAgentConfigDialog, @@ -2807,6 +2880,8 @@ Logging in with Google... Restarting Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + closeAgentDialog, + handleAgentSelect, openVoiceModelDialog, closeVoiceModelDialog, openAgentConfigDialog, diff --git a/packages/cli/src/ui/commands/agentCommand.test.ts b/packages/cli/src/ui/commands/agentCommand.test.ts new file mode 100644 index 0000000000..e5c1318bd0 --- /dev/null +++ b/packages/cli/src/ui/commands/agentCommand.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { agentCommand } from './agentCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { Config } from '@google/gemini-cli-core'; +import { MessageType } from '../types.js'; + +describe('agentCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the agent dialog when no args', async () => { + if (!agentCommand.action) { + throw new Error('The agent command must have an action.'); + } + + const result = await agentCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'agent', + }); + }); + + describe('manage subcommand', () => { + it('should return a dialog action to open the agent dialog', async () => { + const manageCommand = agentCommand.subCommands?.find( + (c) => c.name === 'manage', + ); + expect(manageCommand).toBeDefined(); + + const result = await manageCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'agent', + }); + }); + }); + + describe('set subcommand', () => { + it('should set the agent and log the command', async () => { + const setCommand = agentCommand.subCommands?.find( + (c) => c.name === 'set', + ); + expect(setCommand).toBeDefined(); + + const mockSetAgent = vi.fn(); + mockContext.services.agentContext = { + setAgent: mockSetAgent, + get config() { + return this; + }, + } as unknown as Config; + + await setCommand!.action!(mockContext, 'gemini-enterprise'); + + expect(mockSetAgent).toHaveBeenCalledWith('gemini-enterprise', true); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Agent set to gemini-enterprise'), + }), + ); + }); + + it('should set the agent with persistence when --persist is used', async () => { + const setCommand = agentCommand.subCommands?.find( + (c) => c.name === 'set', + ); + const mockSetAgent = vi.fn(); + mockContext.services.agentContext = { + setAgent: mockSetAgent, + get config() { + return this; + }, + } as unknown as Config; + + await setCommand!.action!(mockContext, 'gemini-cli --persist'); + + expect(mockSetAgent).toHaveBeenCalledWith('gemini-cli', false); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Agent set to gemini-cli (persisted)'), + }), + ); + }); + + it('should show error if no agent name is provided', async () => { + const setCommand = agentCommand.subCommands?.find( + (c) => c.name === 'set', + ); + await setCommand!.action!(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Usage: /agent set '), + }), + ); + }); + + it('should show error if invalid agent name is provided', async () => { + const setCommand = agentCommand.subCommands?.find( + (c) => c.name === 'set', + ); + mockContext.services.agentContext = { + setAgent: vi.fn(), + get config() { + return this; + }, + } as unknown as Config; + + await setCommand!.action!(mockContext, 'invalid-agent'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Unknown agent: invalid-agent'), + }), + ); + }); + }); + + it('should have the correct name and description', () => { + expect(agentCommand.name).toBe('agent'); + expect(agentCommand.description).toBe('Manage active AI agent configuration'); + }); +}); diff --git a/packages/cli/src/ui/commands/agentCommand.ts b/packages/cli/src/ui/commands/agentCommand.ts new file mode 100644 index 0000000000..dc8d92429a --- /dev/null +++ b/packages/cli/src/ui/commands/agentCommand.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + CommandKind, + type SlashCommand, +} from './types.js'; +import { MessageType } from '../types.js'; + +const setAgentCommand: SlashCommand = { + name: 'set', + description: + 'Set the active agent to use. Usage: /agent set [--persist]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: async (context: CommandContext, args: string) => { + const parts = args.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /agent set [--persist]', + }); + return; + } + + const agentName = parts[0]; + const persist = parts.includes('--persist'); + + if (context.services.agentContext?.config) { + if (agentName !== 'gemini-cli' && agentName !== 'gemini-enterprise') { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Unknown agent: ${agentName}. Valid agents are 'gemini-cli' and 'gemini-enterprise'.`, + }); + return; + } + + context.services.agentContext.config.setAgent(agentName, !persist); + + context.ui.addItem({ + type: MessageType.INFO, + text: `Agent set to ${agentName}${persist ? ' (persisted)' : ''}`, + }); + } + }, +}; + +const manageAgentCommand: SlashCommand = { + name: 'manage', + description: 'Opens a dialog to select the agent', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context: CommandContext) => { + return { + type: 'dialog', + dialog: 'agent', + }; + }, +}; + +export const agentCommand: SlashCommand = { + name: 'agent', + description: 'Manage active AI agent configuration', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [manageAgentCommand, setAgentCommand], + action: async (context: CommandContext, args: string) => + manageAgentCommand.action!(context, args), +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 266a3bcf02..89d79f9fde 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -128,6 +128,7 @@ export interface OpenDialogActionReturn { | 'settings' | 'sessionBrowser' | 'model' + | 'agent' | 'voice-model' | 'agentConfig' | 'permissions'; diff --git a/packages/cli/src/ui/components/AgentDialog.test.tsx b/packages/cli/src/ui/components/AgentDialog.test.tsx new file mode 100644 index 0000000000..c2a24bdd79 --- /dev/null +++ b/packages/cli/src/ui/components/AgentDialog.test.tsx @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { AgentDialog } from './AgentDialog.js'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import type { Config } from '@google/gemini-cli-core'; + +describe('', () => { + const mockSetAgent = vi.fn(); + const mockGetAgent = vi.fn(); + const mockOnClose = vi.fn(); + const mockOnSelect = vi.fn(); + + interface MockConfig extends Partial { + setAgent: (agent: string, isTemporary?: boolean) => void; + getAgent: () => string; + getSessionId: () => string; + getIdeMode: () => boolean; + } + + const mockConfig: MockConfig = { + setAgent: mockSetAgent, + getAgent: mockGetAgent, + getSessionId: () => 'test-session-id', + getIdeMode: () => false, + }; + + beforeEach(() => { + vi.resetAllMocks(); + mockGetAgent.mockReturnValue('gemini-cli'); + }); + + const renderComponent = async (configValue = mockConfig as Config) => { + const settings = createMockSettings({}); + + const result = await renderWithProviders( + , + { + config: configValue, + settings, + }, + ); + return result; + }; + + it('renders the agent selection view correctly', async () => { + const { lastFrame, unmount } = await renderComponent(); + const output = lastFrame(); + expect(output).toContain('Select Agent'); + expect(output).toContain('Remember agent for future sessions: false'); + expect(output).toContain('Gemini CLI (Standard)'); + expect(output).toContain('Gemini Enterprise'); + unmount(); + }); + + it('selects agent and closes when an option is selected', async () => { + const { stdin, waitUntilReady, unmount } = await renderComponent(); + + // Select Gemini CLI (default selection is first item) by pressing Enter + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(mockSetAgent).toHaveBeenCalledWith('gemini-cli', true); // Ephemeral by default + expect(mockOnSelect).toHaveBeenCalledWith('gemini-cli'); + expect(mockOnClose).toHaveBeenCalled(); + }); + unmount(); + }); + + it('selects the second agent when navigating and pressing enter', async () => { + const { stdin, waitUntilReady, unmount } = await renderComponent(); + + // Press arrow down to move selection to index 1 (gemini-enterprise) + await act(async () => { + stdin.write('\u001B[B'); // Arrow Down + }); + await waitUntilReady(); + + // Press enter to select + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(mockSetAgent).toHaveBeenCalledWith('gemini-enterprise', true); + expect(mockOnSelect).toHaveBeenCalledWith('gemini-enterprise'); + expect(mockOnClose).toHaveBeenCalled(); + }); + unmount(); + }); + + it('toggles persist mode with Tab key', async () => { + const { lastFrame, stdin, waitUntilReady, unmount } = await renderComponent(); + + expect(lastFrame()).toContain('Remember agent for future sessions: false'); + + // Press Tab to toggle persist mode + await act(async () => { + stdin.write('\t'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(lastFrame()).toContain('Remember agent for future sessions: true'); + }); + + // Select first item (gemini-cli) with persist mode active + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(mockSetAgent).toHaveBeenCalledWith('gemini-cli', false); // Persist enabled (isTemporary = false) + expect(mockOnSelect).toHaveBeenCalledWith('gemini-cli'); + expect(mockOnClose).toHaveBeenCalled(); + }); + unmount(); + }); + + it('closes dialog on escape key press', async () => { + const { stdin, waitUntilReady, unmount } = await renderComponent(); + + await act(async () => { + stdin.write('\u001B'); // Escape + }); + await act(async () => { + await waitUntilReady(); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/AgentDialog.tsx b/packages/cli/src/ui/components/AgentDialog.tsx new file mode 100644 index 0000000000..f5cb397ba2 --- /dev/null +++ b/packages/cli/src/ui/components/AgentDialog.tsx @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +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 AgentDialogProps { + onClose: () => void; + onSelect: (agentName: string) => void; +} + +export function AgentDialog({ onClose, onSelect }: AgentDialogProps): React.JSX.Element { + const config = useContext(ConfigContext); + const [persistMode, setPersistMode] = useState(false); + + const preferredAgent = config?.getAgent() || 'gemini-cli'; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onClose(); + return true; + } + if (key.name === 'tab') { + setPersistMode((prev) => !prev); + return true; + } + return false; + }, + { isActive: true }, + ); + + const options = useMemo(() => { + return [ + { + value: 'gemini-cli', + title: 'Gemini CLI (Standard)', + description: 'Standard model conversational chat with local tools execution.', + key: 'gemini-cli', + }, + { + value: 'gemini-enterprise', + title: 'Gemini Enterprise', + description: 'Connected assistant grounded by your business data.', + key: 'gemini-enterprise', + }, + ]; + }, []); + + const initialIndex = useMemo(() => { + const idx = options.findIndex((option) => option.value === preferredAgent); + return idx !== -1 ? idx : 0; + }, [preferredAgent, options]); + + const handleSelect = useCallback( + (agentName: string) => { + if (config) { + config.setAgent(agentName, persistMode ? false : true); + } + onSelect(agentName); + onClose(); + }, + [config, onSelect, onClose, persistMode], + ); + + return ( + + Select Agent + + + + + + + + Remember agent for future sessions:{' '} + + + {persistMode ? 'true' : 'false'} + + (Press Tab to toggle) + + + + + {'> To use a specific agent on startup, use the --agent flag.'} + + + + (Press Esc to close) + + + ); +} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index acd2f3472f..3b6a3e12dc 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -25,6 +25,7 @@ import { relaunchApp } from '../../utils/processUtils.js'; import { SessionBrowser } from './SessionBrowser.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; +import { AgentDialog } from './AgentDialog.js'; import { VoiceModelDialog } from './VoiceModelDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -240,6 +241,14 @@ export const DialogManager = ({ if (uiState.isModelDialogOpen) { return ; } + if (uiState.isAgentDialogOpen) { + return ( + + ); + } if (uiState.isVoiceModelDialogOpen) { return ; } diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 378e89e453..95267c01a4 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -198,6 +198,7 @@ export const Footer: React.FC = () => { const { model, + activeAgent, targetDir, debugMode, branchName, @@ -210,6 +211,7 @@ export const Footer: React.FC = () => { terminalWidth, } = { model: uiState.currentModel, + activeAgent: uiState.activeAgent, targetDir: config.getTargetDir(), debugMode: config.getDebugMode(), branchName: uiState.branchName, @@ -332,6 +334,15 @@ export const Footer: React.FC = () => { ); break; } + case 'agent-name': { + addCol( + id, + header, + () => {activeAgent}, + activeAgent.length, + ); + break; + } case 'context-used': { addCol( id, diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap index 77fe519c64..6ebe0e2efb 100644 --- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap @@ -1,45 +1,49 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`