From 902e5d6dae95dc34939eb466ffba10f8d648eeec Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 22 Jan 2026 10:30:44 -0800 Subject: [PATCH] feat(cli): Add state management and plumbing for agent configuration dialog (#17259) --- packages/cli/src/test-utils/render.tsx | 2 + packages/cli/src/ui/AppContainer.test.tsx | 72 +++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 44 +++++++++++ packages/cli/src/ui/commands/types.ts | 7 ++ .../cli/src/ui/contexts/UIActionsContext.tsx | 12 ++- .../cli/src/ui/contexts/UIStateContext.tsx | 5 ++ .../ui/hooks/slashCommandProcessor.test.tsx | 77 +++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 27 +++++++ .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + 9 files changed, 246 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 55444cf694..7472d89c3c 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -151,6 +151,8 @@ const mockUIActions: UIActions = { exitPrivacyNotice: vi.fn(), closeSettingsDialog: vi.fn(), closeModelDialog: vi.fn(), + openAgentConfigDialog: vi.fn(), + closeAgentConfigDialog: vi.fn(), openPermissionsDialog: vi.fn(), openSessionBrowser: vi.fn(), closeSessionBrowser: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 7223e8c96b..431c78ab48 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -28,6 +28,7 @@ import { type UserFeedbackPayload, type ResumedSessionData, AuthType, + type AgentDefinition, } from '@google/gemini-cli-core'; // Mock coreEvents @@ -2147,6 +2148,77 @@ describe('AppContainer State Management', () => { }); }); + describe('Agent Configuration Dialog Integration', () => { + it('should initialize with dialog closed and no agent selected', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); + expect(capturedUIState.selectedAgentName).toBeUndefined(); + expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); + expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); + unmount!(); + }); + + it('should update state when openAgentConfigDialog is called', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const agentDefinition = { name: 'test-agent' }; + act(() => { + capturedUIActions.openAgentConfigDialog( + 'test-agent', + 'Test Agent', + agentDefinition as unknown as AgentDefinition, + ); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); + expect(capturedUIState.selectedAgentName).toBe('test-agent'); + expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); + expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); + unmount!(); + }); + + it('should clear state when closeAgentConfigDialog is called', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + const agentDefinition = { name: 'test-agent' }; + act(() => { + capturedUIActions.openAgentConfigDialog( + 'test-agent', + 'Test Agent', + agentDefinition as unknown as AgentDefinition, + ); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); + + act(() => { + capturedUIActions.closeAgentConfigDialog(); + }); + + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); + expect(capturedUIState.selectedAgentName).toBeUndefined(); + expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); + expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); + unmount!(); + }); + }); + describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { let unmount: () => void; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 22a43ed392..9b9897309b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -37,6 +37,7 @@ import { type IdeContext, type UserTierId, type UserFeedbackPayload, + type AgentDefinition, IdeClient, ideContextStore, getErrorMessage, @@ -253,6 +254,34 @@ export const AppContainer = (props: AppContainerProps) => { setPermissionsDialogProps(null); }, []); + const [isAgentConfigDialogOpen, setIsAgentConfigDialogOpen] = useState(false); + const [selectedAgentName, setSelectedAgentName] = useState< + string | undefined + >(); + const [selectedAgentDisplayName, setSelectedAgentDisplayName] = useState< + string | undefined + >(); + const [selectedAgentDefinition, setSelectedAgentDefinition] = useState< + AgentDefinition | undefined + >(); + + const openAgentConfigDialog = useCallback( + (name: string, displayName: string, definition: AgentDefinition) => { + setSelectedAgentName(name); + setSelectedAgentDisplayName(displayName); + setSelectedAgentDefinition(definition); + setIsAgentConfigDialogOpen(true); + }, + [], + ); + + const closeAgentConfigDialog = useCallback(() => { + setIsAgentConfigDialogOpen(false); + setSelectedAgentName(undefined); + setSelectedAgentDisplayName(undefined); + setSelectedAgentDefinition(undefined); + }, []); + const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), [], @@ -679,6 +708,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openSettingsDialog, openSessionBrowser, openModelDialog, + openAgentConfigDialog, openPermissionsDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); @@ -701,6 +731,7 @@ Logging in with Google... Restarting Gemini CLI to continue. openSettingsDialog, openSessionBrowser, openModelDialog, + openAgentConfigDialog, setQuittingMessages, setDebugMessage, setShowPrivacyNotice, @@ -1477,6 +1508,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || + isAgentConfigDialogOpen || isPermissionsDialogOpen || isAuthenticating || isAuthDialogOpen || @@ -1570,6 +1602,10 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, + isAgentConfigDialogOpen, + selectedAgentName, + selectedAgentDisplayName, + selectedAgentDefinition, isPermissionsDialogOpen, permissionsDialogProps, slashCommands, @@ -1662,6 +1698,10 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, + isAgentConfigDialogOpen, + selectedAgentName, + selectedAgentDisplayName, + selectedAgentDefinition, isPermissionsDialogOpen, permissionsDialogProps, slashCommands, @@ -1761,6 +1801,8 @@ Logging in with Google... Restarting Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + openAgentConfigDialog, + closeAgentConfigDialog, openPermissionsDialog, closePermissionsDialog, setShellModeActive, @@ -1802,6 +1844,8 @@ Logging in with Google... Restarting Gemini CLI to continue. exitPrivacyNotice, closeSettingsDialog, closeModelDialog, + openAgentConfigDialog, + closeAgentConfigDialog, openPermissionsDialog, closePermissionsDialog, setShellModeActive, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 613175c1be..9f5ca8eb41 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -15,6 +15,7 @@ import type { GitService, Logger, CommandActionReturn, + AgentDefinition, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -74,6 +75,11 @@ export interface CommandContext { toggleDebugProfiler: () => void; toggleVimEnabled: () => Promise; reloadCommands: () => void; + openAgentConfigDialog: ( + name: string, + displayName: string, + definition: AgentDefinition, + ) => void; extensionsUpdateState: Map; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; @@ -111,6 +117,7 @@ export interface OpenDialogActionReturn { | 'settings' | 'sessionBrowser' | 'model' + | 'agentConfig' | 'permissions'; } diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 1ba8c7dfe3..c8abf33236 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -8,7 +8,11 @@ import { createContext, useContext } from 'react'; import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; -import { type AuthType, type EditorType } from '@google/gemini-cli-core'; +import { + type AuthType, + type EditorType, + type AgentDefinition, +} from '@google/gemini-cli-core'; import { type LoadableSettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js'; @@ -32,6 +36,12 @@ export interface UIActions { exitPrivacyNotice: () => void; closeSettingsDialog: () => void; closeModelDialog: () => void; + openAgentConfigDialog: ( + name: string, + displayName: string, + definition: AgentDefinition, + ) => void; + closeAgentConfigDialog: () => void; openPermissionsDialog: (props?: PermissionsDialogProps) => void; closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index feb3157e1a..893ee80c07 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -24,6 +24,7 @@ import type { IdeInfo, FallbackIntent, ValidationIntent, + AgentDefinition, } from '@google/gemini-cli-core'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; @@ -70,6 +71,10 @@ export interface UIState { isSettingsDialogOpen: boolean; isSessionBrowserOpen: boolean; isModelDialogOpen: boolean; + isAgentConfigDialogOpen: boolean; + selectedAgentName?: string; + selectedAgentDisplayName?: string; + selectedAgentDefinition?: AgentDefinition; isPermissionsDialogOpen: boolean; permissionsDialogProps: { targetDirectory?: string } | null; slashCommands: readonly SlashCommand[] | undefined; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 717da57805..ab00d55210 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -188,6 +188,7 @@ describe('useSlashCommandProcessor', () => { openSettingsDialog: vi.fn(), openSessionBrowser: vi.fn(), openModelDialog: mockOpenModelDialog, + openAgentConfigDialog: vi.fn(), openPermissionsDialog: vi.fn(), quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), @@ -520,6 +521,82 @@ describe('useSlashCommandProcessor', () => { expect(mockFn).toHaveBeenCalled(); }, ); + + it('should handle "dialog: agentConfig" action with props', async () => { + const mockOpenAgentConfigDialog = vi.fn(); + const agentDefinition = { name: 'test-agent' }; + const commandName = 'agentconfigcmd'; + const command = createTestCommand({ + name: commandName, + action: vi.fn().mockResolvedValue({ + type: 'dialog', + dialog: 'agentConfig', + props: { + name: 'test-agent', + displayName: 'Test Agent', + definition: agentDefinition, + }, + }), + }); + + // Re-setup the hook with the mock action that we can inspect + mockBuiltinLoadCommands.mockResolvedValue(Object.freeze([command])); + mockFileLoadCommands.mockResolvedValue(Object.freeze([])); + mockMcpLoadCommands.mockResolvedValue(Object.freeze([])); + + let result!: { current: ReturnType }; + await act(async () => { + const hook = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockSettings, + mockAddItem, + mockClearItems, + mockLoadHistory, + vi.fn(), + vi.fn(), + vi.fn(), + { + openAuthDialog: vi.fn(), + openThemeDialog: vi.fn(), + openEditorDialog: vi.fn(), + openPrivacyNotice: vi.fn(), + openSettingsDialog: vi.fn(), + openSessionBrowser: vi.fn(), + openModelDialog: vi.fn(), + openAgentConfigDialog: mockOpenAgentConfigDialog, + openPermissionsDialog: vi.fn(), + quit: vi.fn(), + setDebugMessage: vi.fn(), + toggleCorgiMode: vi.fn(), + toggleDebugProfiler: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + setText: vi.fn(), + }, + new Map(), + true, + vi.fn(), + vi.fn(), + ), + ); + result = hook.result; + }); + + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); + + await act(async () => { + await result.current.handleSlashCommand(`/${commandName}`); + }); + + expect(mockOpenAgentConfigDialog).toHaveBeenCalledWith( + 'test-agent', + 'Test Agent', + agentDefinition, + ); + }); }); it('should handle "load_history" action', async () => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c2bb7ebcee..9ef6349af7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -19,6 +19,7 @@ import type { ExtensionsStartingEvent, ExtensionsStoppingEvent, ToolCallConfirmationDetails, + AgentDefinition, } from '@google/gemini-cli-core'; import { GitService, @@ -69,6 +70,11 @@ interface SlashCommandProcessorActions { openSettingsDialog: () => void; openSessionBrowser: () => void; openModelDialog: () => void; + openAgentConfigDialog: ( + name: string, + displayName: string, + definition: AgentDefinition, + ) => void; openPermissionsDialog: (props?: { targetDirectory?: string }) => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; @@ -224,6 +230,7 @@ export const useSlashCommandProcessor = ( toggleDebugProfiler: actions.toggleDebugProfiler, toggleVimEnabled, reloadCommands, + openAgentConfigDialog: actions.openAgentConfigDialog, extensionsUpdateState, dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest: @@ -452,6 +459,26 @@ export const useSlashCommandProcessor = ( case 'model': actions.openModelDialog(); return { type: 'handled' }; + case 'agentConfig': { + const props = result.props as Record; + if ( + !props || + typeof props['name'] !== 'string' || + typeof props['displayName'] !== 'string' || + !props['definition'] + ) { + throw new Error( + 'Received invalid properties for agentConfig dialog action.', + ); + } + + actions.openAgentConfigDialog( + props['name'], + props['displayName'], + props['definition'] as AgentDefinition, + ); + return { type: 'handled' }; + } case 'permissions': actions.openPermissionsDialog( result.props as { targetDirectory?: string }, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 542ed16bdb..6632583223 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -24,6 +24,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { toggleDebugProfiler: () => {}, toggleVimEnabled: async () => false, reloadCommands: () => {}, + openAgentConfigDialog: () => {}, extensionsUpdateState: new Map(), dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {},