feat(cli): add dynamic agent switcher and /agent subcommand

- Implemented /agent command with manage (opens dialog) and set subcommands.
- Created AgentDialog component for choosing between standard and enterprise agents.
- Integrated remember agent setting with persist toggle.
- Wired --agent cli argument for booting directly into specific agent.
- Handled clean session remounting on agent swap via React key-swapping of AppContainer, fully complying with Rules of Hooks.
- Added agent-name persistently to the status bar footer items.
- Resolved dynamic routing inside non-interactive headless loops to fully support Gemini Enterprise prompts.

TAG=agy
CONV=81e82460-f8cd-4c7b-a037-2cbedda4d3c0
This commit is contained in:
Michael Bleigh
2026-05-18 19:01:39 +00:00
parent fc8928c089
commit c22187d731
29 changed files with 785 additions and 18 deletions
+22
View File
@@ -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 {
@@ -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',
+7
View File
@@ -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];
+16
View File
@@ -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,
+21
View File
@@ -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',
+13
View File
@@ -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 (
<SettingsContext.Provider value={settings}>
@@ -113,6 +125,7 @@ export async function startInteractiveUI(
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider>
<AppContainer
key={agentKey}
config={config}
startupWarnings={startupWarnings}
version={version}
@@ -193,6 +193,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 = {
+1
View File
@@ -67,6 +67,7 @@ export async function runNonInteractive(
params: RunNonInteractiveParams,
): Promise<void> {
const useAgentSession =
params.config.getAgent() === 'gemini-enterprise' ||
params.config.getAgentSessionNoninteractiveEnabled() ||
process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (useAgentSession) {
@@ -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 = {
@@ -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 })
@@ -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<SlashCommand | null> = [
aboutCommand,
agentCommand,
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
+3
View File
@@ -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(),
+77 -2
View File
@@ -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(
<ConsentPrompt
prompt={`Switching agents will completely clear your active session history. Do you want to proceed to switch to ${agentName}?`}
onConfirm={(confirm: boolean) => {
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,
@@ -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 <agent-name>'),
}),
);
});
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');
});
});
@@ -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 <agent-name> [--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 <agent-name> [--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),
};
+1
View File
@@ -128,6 +128,7 @@ export interface OpenDialogActionReturn {
| 'settings'
| 'sessionBrowser'
| 'model'
| 'agent'
| 'voice-model'
| 'agentConfig'
| 'permissions';
@@ -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('<AgentDialog />', () => {
const mockSetAgent = vi.fn();
const mockGetAgent = vi.fn();
const mockOnClose = vi.fn();
const mockOnSelect = vi.fn();
interface MockConfig extends Partial<Config> {
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(
<AgentDialog onClose={mockOnClose} onSelect={mockOnSelect} />,
{
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();
});
});
@@ -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 (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>Select Agent</Text>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={options}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Remember agent for future sessions:{' '}
</Text>
<Text color={theme.status.success}>
{persistMode ? 'true' : 'false'}
</Text>
<Text color={theme.text.secondary}> (Press Tab to toggle)</Text>
</Box>
</Box>
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{'> To use a specific agent on startup, use the --agent flag.'}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
</Box>
</Box>
);
}
@@ -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 <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.isAgentDialogOpen) {
return (
<AgentDialog
onClose={uiActions.closeAgentDialog}
onSelect={uiActions.handleAgentSelect}
/>
);
}
if (uiState.isVoiceModelDialogOpen) {
return <VoiceModelDialog onClose={uiActions.closeVoiceModelDialog} />;
}
+11
View File
@@ -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,
() => <Text color={itemColor}>{activeAgent}</Text>,
activeAgent.length,
);
break;
}
case 'context-used': {
addCol(
id,
@@ -1,45 +1,49 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
" workspace (/directory) sandbox /model /agent quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro gemini-cli limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
" workspace (/directory) sandbox /model /agent quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro gemini-cli 85% used
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" workspace (/directory) sandbox /model context
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
" workspace (/directory) sandbox /model context /agent
...tories/to/make/it/long no sandbox gemini-pro 14% gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" workspace (/directory) sandbox /model context
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
" workspace (/directory) sandbox /model context /agent
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
" sandbox
no sandbox
" sandbox /agent
no sandbox gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `
" /agent
gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
" workspace (/directory) sandbox
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
" workspace (/directory) sandbox /agent
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-cli
"
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
" workspace (/directory) sandbox /model /agent quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro gemini-cli 15% used
"
`;
@@ -41,6 +41,8 @@ export interface UIActions {
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
closeAgentDialog: () => void;
handleAgentSelect: (agentName: string) => void;
openVoiceModelDialog: () => void;
closeVoiceModelDialog: () => void;
openAgentConfigDialog: (
@@ -114,6 +114,7 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
isAgentDialogOpen: boolean;
isVoiceModelDialogOpen: boolean;
isAgentConfigDialogOpen: boolean;
selectedAgentName?: string;
@@ -162,6 +163,7 @@ export interface UIState {
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
currentModel: string;
activeAgent: string;
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
@@ -205,6 +205,7 @@ describe('useSlashCommandProcessor', () => {
openSettingsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
openModelDialog: mockOpenModelDialog,
openAgentDialog: vi.fn(),
openVoiceModelDialog: vi.fn(),
openAgentConfigDialog,
openPermissionsDialog: vi.fn(),
@@ -72,6 +72,7 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void;
openSessionBrowser: () => void;
openModelDialog: () => void;
openAgentDialog: () => void;
openVoiceModelDialog: () => void;
openAgentConfigDialog: (
name: string,
@@ -506,6 +507,9 @@ export const useSlashCommandProcessor = (
case 'model':
actions.openModelDialog();
return { type: 'handled' };
case 'agent':
actions.openAgentDialog();
return { type: 'handled' };
case 'voice-model':
actions.openVoiceModelDialog();
return { type: 'handled' };
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
interface UseAgentCommandReturn {
isAgentDialogOpen: boolean;
openAgentDialog: () => void;
closeAgentDialog: () => void;
}
export const useAgentCommand = (): UseAgentCommandReturn => {
const [isAgentDialogOpen, setIsAgentDialogOpen] = useState(false);
const openAgentDialog = useCallback(() => {
setIsAgentDialogOpen(true);
}, []);
const closeAgentDialog = useCallback(() => {
setIsAgentDialogOpen(false);
}, []);
return {
isAgentDialogOpen,
openAgentDialog,
closeAgentDialog,
};
};
@@ -569,8 +569,31 @@ export const useAgentStream = ({
[pendingHistoryItem, pendingToolGroupItems],
);
const reset = useCallback(() => {
setStreamingState(StreamingState.Idle);
setThought(null);
setPendingHistoryItem(null);
setTrackedTools([]);
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
setHasEmittedBoxInTurn(false);
currentStreamIdRef.current = null;
userMessageTimestampRef.current = 0;
geminiMessageBufferRef.current = '';
}, [
setStreamingState,
setThought,
setPendingHistoryItem,
setTrackedTools,
setPushedToolCallIds,
setIsFirstToolInGroup,
setHasEmittedBoxInTurn,
]);
return {
streamingState,
reset,
submitQuery,
initError,
pendingHistoryItems,
+20
View File
@@ -643,6 +643,7 @@ export interface ConfigParameters {
includeDirectories?: string[];
bugCommand?: BugCommandSettings;
model: string;
agent?: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number;
acpMode?: boolean;
@@ -734,6 +735,7 @@ export interface ConfigParameters {
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
onAgentChange?: (agent: string) => void;
mcpEnabled?: boolean;
extensionsEnabled?: boolean;
agents?: AgentSettings;
@@ -831,6 +833,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly cwd: string;
private readonly bugCommand: BugCommandSettings | undefined;
private model: string;
private agent: string;
private readonly disableLoopDetection: boolean;
// null = unknown (quota not fetched); true = has access; false = definitively no access
private hasAccessToPreviewModel: boolean | null = null;
@@ -947,6 +950,7 @@ export class Config implements McpContext, AgentLoopContext {
private experimentsPromise: Promise<Experiments | undefined> | undefined;
private hookSystem?: HookSystem;
private readonly onModelChange: ((model: string) => void) | undefined;
private readonly onAgentChange: ((agent: string) => void) | undefined;
private readonly onReload:
| (() => Promise<{
disabledSkills?: string[];
@@ -1126,6 +1130,7 @@ export class Config implements McpContext, AgentLoopContext {
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
this.agent = params.agent ?? 'gemini-cli';
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? true;
@@ -1390,6 +1395,7 @@ export class Config implements McpContext, AgentLoopContext {
this.experiments = params.experiments;
this.onModelChange = params.onModelChange;
this.onAgentChange = params.onAgentChange;
this.onReload = params.onReload;
this.billing = {
@@ -1913,6 +1919,20 @@ export class Config implements McpContext, AgentLoopContext {
return this.model;
}
getAgent(): string {
return this.agent;
}
setAgent(newAgent: string, isTemporary = true): void {
if (this.agent !== newAgent) {
this.agent = newAgent;
coreEvents.emitAgentChanged(newAgent);
}
if (this.onAgentChange && !isTemporary) {
this.onAgentChange(newAgent);
}
}
getDisableLoopDetection(): boolean {
return this.disableLoopDetection ?? false;
}
+20
View File
@@ -53,6 +53,16 @@ export interface ModelChangedPayload {
model: string;
}
/**
* Payload for the 'agent-changed' event.
*/
export interface AgentChangedPayload {
/**
* The new active agent.
*/
agent: string;
}
/**
* Payload for the 'approval-mode-changed' event.
*/
@@ -196,6 +206,7 @@ export interface QuotaChangedPayload {
export enum CoreEvent {
UserFeedback = 'user-feedback',
ModelChanged = 'model-changed',
AgentChanged = 'agent-changed',
ApprovalModeChanged = 'approval-mode-changed',
ConsoleLog = 'console-log',
Output = 'output',
@@ -231,6 +242,7 @@ export interface EditorSelectedPayload {
export interface CoreEvents extends ExtensionEvents {
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
[CoreEvent.ModelChanged]: [ModelChangedPayload];
[CoreEvent.AgentChanged]: [AgentChangedPayload];
[CoreEvent.ApprovalModeChanged]: [ApprovalModeChangedPayload];
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
[CoreEvent.Output]: [OutputPayload];
@@ -344,6 +356,14 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.ModelChanged, payload);
}
/**
* Notifies subscribers that the active agent has changed.
*/
emitAgentChanged(agent: string): void {
const payload: AgentChangedPayload = { agent };
this.emit(CoreEvent.AgentChanged, payload);
}
/**
* Notifies subscribers that the approval mode has changed.
*/