From 36d618f72ac6b61f8b5e1bd299db8d75992444d1 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 27 Jan 2026 13:30:44 -0500 Subject: [PATCH] feat: wire up `AskUserTool` with dialog (#17411) --- packages/cli/src/test-utils/render.tsx | 37 +++-- packages/cli/src/ui/AppContainer.tsx | 79 ++++++++++- .../cli/src/ui/components/DialogManager.tsx | 18 +++ .../components/messages/ToolGroupMessage.tsx | 18 ++- .../src/ui/components/messages/ToolShared.tsx | 20 ++- .../src/ui/contexts/AskUserActionsContext.tsx | 77 ++++++++++ packages/core/src/config/config.ts | 2 + packages/core/src/confirmation-bus/types.ts | 8 +- packages/core/src/tools/ask-user.test.ts | 131 +++++++++++++++++- packages/core/src/tools/ask-user.ts | 94 ++++++++++--- packages/core/src/tools/tool-names.ts | 1 + 11 files changed, 441 insertions(+), 44 deletions(-) create mode 100644 packages/cli/src/ui/contexts/AskUserActionsContext.tsx diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index f1e87c0f15..97891067fe 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -26,6 +26,7 @@ import { } from '../ui/contexts/UIActionsContext.js'; import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; +import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; @@ -300,20 +301,28 @@ export const renderWithProviders = ( config={config} toolCalls={allToolCalls} > - - - - - {component} - - - - + + + + + + {component} + + + + + diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f08d947eca..333c5b4cc3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -30,6 +30,10 @@ import { } from './types.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; +import { + AskUserActionsProvider, + type AskUserState, +} from './contexts/AskUserActionsContext.js'; import { type EditorType, type Config, @@ -63,6 +67,8 @@ import { SessionStartSource, SessionEndReason, generateSummary, + MessageBusType, + type AskUserRequest, type AgentsDiscoveredPayload, ChangeAuthRequestedError, } from '@google/gemini-cli-core'; @@ -282,6 +288,11 @@ export const AppContainer = (props: AppContainerProps) => { AgentDefinition | undefined >(); + // AskUser dialog state + const [askUserRequest, setAskUserRequest] = useState( + null, + ); + const openAgentConfigDialog = useCallback( (name: string, displayName: string, definition: AgentDefinition) => { setSelectedAgentName(name); @@ -299,6 +310,56 @@ export const AppContainer = (props: AppContainerProps) => { setSelectedAgentDefinition(undefined); }, []); + // Subscribe to ASK_USER_REQUEST messages from the message bus + useEffect(() => { + const messageBus = config.getMessageBus(); + + const handler = (msg: AskUserRequest) => { + setAskUserRequest({ + questions: msg.questions, + correlationId: msg.correlationId, + }); + }; + + messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler); + + return () => { + messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler); + }; + }, [config]); + + // Handler to submit ask_user answers + const handleAskUserSubmit = useCallback( + async (answers: { [questionIndex: string]: string }) => { + if (!askUserRequest) return; + + const messageBus = config.getMessageBus(); + await messageBus.publish({ + type: MessageBusType.ASK_USER_RESPONSE, + correlationId: askUserRequest.correlationId, + answers, + }); + + setAskUserRequest(null); + }, + [config, askUserRequest], + ); + + // Handler to cancel ask_user dialog + const handleAskUserCancel = useCallback(async () => { + if (!askUserRequest) return; + + const messageBus = config.getMessageBus(); + await messageBus.publish({ + type: MessageBusType.ASK_USER_RESPONSE, + correlationId: askUserRequest.correlationId, + answers: {}, + cancelled: true, + }); + + setAskUserRequest(null); + }, [config, askUserRequest]); + const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), [], @@ -1355,6 +1416,10 @@ Logging in with Google... Restarting Gemini CLI to continue. } if (keyMatchers[Command.QUIT](key)) { + // Skip when ask_user dialog is open (use Esc to cancel instead) + if (askUserRequest) { + return; + } // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. cancelOngoingRequest?.(); @@ -1442,6 +1507,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setCtrlDPressCount, handleSlashCommand, cancelOngoingRequest, + askUserRequest, activePtyId, embeddedShellFocused, settings.merged.general.debugKeystrokeLogging, @@ -1554,6 +1620,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const nightly = props.version.includes('nightly'); const dialogsVisible = + !!askUserRequest || shouldShowIdePrompt || isFolderTrustDialogOpen || adminSettingsChanged || @@ -1988,9 +2055,15 @@ Logging in with Google... Restarting Gemini CLI to continue. }} > - - - + + + + + diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index b8bf51a81e..b1a159c93e 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,6 +32,8 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; +import { AskUserDialog } from './AskUserDialog.js'; +import { useAskUserActions } from '../contexts/AskUserActionsContext.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; @@ -57,6 +59,22 @@ export const DialogManager = ({ terminalWidth: uiTerminalWidth, } = uiState; + const { + request: askUserRequest, + submit: askUserSubmit, + cancel: askUserCancel, + } = useAskUserActions(); + + if (askUserRequest) { + return ( + + ); + } + if (uiState.adminSettingsChanged) { return ; } diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 6c865640c3..a43c67dadd 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,6 +15,7 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { isShellTool, isThisShellFocused } from './ToolShared.js'; +import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core'; interface ToolGroupMessageProps { groupId: number; @@ -27,15 +28,30 @@ interface ToolGroupMessageProps { onShellInputSubmit?: (input: string) => void; } +// Helper to identify Ask User tools that are in progress (have their own dialog UI) +const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean => + t.name === ASK_USER_DISPLAY_NAME && + [ + ToolCallStatus.Pending, + ToolCallStatus.Executing, + ToolCallStatus.Confirming, + ].includes(t.status); + // Main component renders the border and maps the tools using ToolMessage export const ToolGroupMessage: React.FC = ({ - toolCalls, + toolCalls: allToolCalls, availableTerminalHeight, terminalWidth, isFocused = true, activeShellPtyId, embeddedShellFocused, }) => { + // Filter out in-progress Ask User tools (they have their own AskUserDialog UI) + const toolCalls = useMemo( + () => allToolCalls.filter((t) => !isAskUserInProgress(t)), + [allToolCalls], + ); + const config = useConfig(); const isEventDriven = config.isEventDrivenSchedulerEnabled(); diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx index ccd38f6f77..46065fe59e 100644 --- a/packages/cli/src/ui/components/messages/ToolShared.tsx +++ b/packages/cli/src/ui/components/messages/ToolShared.tsx @@ -18,6 +18,7 @@ import { theme } from '../../semantic-colors.js'; import { type Config, SHELL_TOOL_NAME, + ASK_USER_DISPLAY_NAME, type ToolResultDisplay, } from '@google/gemini-cli-core'; import { useInactivityTimer } from '../../hooks/useInactivityTimer.js'; @@ -198,13 +199,28 @@ export const ToolInfo: React.FC = ({ } } }, [emphasis]); + + // Hide description for completed Ask User tools (the result display speaks for itself) + const isCompletedAskUser = + name === ASK_USER_DISPLAY_NAME && + [ + ToolCallStatus.Success, + ToolCallStatus.Error, + ToolCallStatus.Canceled, + ].includes(status); + return ( {name} - {' '} - {description} + + {!isCompletedAskUser && ( + <> + {' '} + {description} + + )} ); diff --git a/packages/cli/src/ui/contexts/AskUserActionsContext.tsx b/packages/cli/src/ui/contexts/AskUserActionsContext.tsx new file mode 100644 index 0000000000..5e77fce3e5 --- /dev/null +++ b/packages/cli/src/ui/contexts/AskUserActionsContext.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { createContext, useContext, useMemo } from 'react'; +import type { Question } from '@google/gemini-cli-core'; + +export interface AskUserState { + questions: Question[]; + correlationId: string; +} + +interface AskUserActionsContextValue { + /** Current ask_user request, or null if no dialog should be shown */ + request: AskUserState | null; + + /** Submit answers - publishes ASK_USER_RESPONSE to message bus */ + submit: (answers: { [questionIndex: string]: string }) => Promise; + + /** Cancel the dialog - clears request state */ + cancel: () => void; +} + +const AskUserActionsContext = createContext( + null, +); + +export const useAskUserActions = () => { + const context = useContext(AskUserActionsContext); + if (!context) { + throw new Error( + 'useAskUserActions must be used within an AskUserActionsProvider', + ); + } + return context; +}; + +interface AskUserActionsProviderProps { + children: React.ReactNode; + /** Current ask_user request state (managed by AppContainer) */ + request: AskUserState | null; + /** Handler to submit answers */ + onSubmit: (answers: { [questionIndex: string]: string }) => Promise; + /** Handler to cancel the dialog */ + onCancel: () => void; +} + +/** + * Provides ask_user dialog state and actions to child components. + * + * State is managed by AppContainer (which subscribes to the message bus) + * and passed here as props. This follows the same pattern as ToolActionsProvider. + */ +export const AskUserActionsProvider: React.FC = ({ + children, + request, + onSubmit, + onCancel, +}) => { + const value = useMemo( + () => ({ + request, + submit: onSubmit, + cancel: onCancel, + }), + [request, onSubmit, onCancel], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b4be2cbdfc..ab83dc595d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -32,6 +32,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { WebSearchTool } from '../tools/web-search.js'; +import { AskUserTool } from '../tools/ask-user.js'; import { GeminiClient } from '../core/client.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; import type { HookDefinition, HookEventName } from '../hooks/types.js'; @@ -2005,6 +2006,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(WebSearchTool, this); + registerCoreTool(AskUserTool); if (this.getUseWriteTodos()) { registerCoreTool(WriteTodosTool); } diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 9279485986..5a27b08d40 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -134,11 +134,11 @@ export interface Question { header: string; /** Question type: 'choice' renders selectable options, 'text' renders free-form input, 'yesno' renders a binary Yes/No choice. Defaults to 'choice'. */ type?: QuestionType; - /** Available choices. Required when type is 'choice' (or omitted), ignored for 'text'. */ + /** Selectable choices. REQUIRED when type='choice' or omitted. IGNORED for 'text' and 'yesno'. */ options?: QuestionOption[]; - /** Allow multiple selections. Only applies to 'choice' type. */ + /** Allow multiple selections. Only applies when type='choice'. */ multiSelect?: boolean; - /** Placeholder hint text for 'text' type input field. */ + /** Placeholder hint text. Only applies when type='text'. */ placeholder?: string; } @@ -152,6 +152,8 @@ export interface AskUserResponse { type: MessageBusType.ASK_USER_RESPONSE; correlationId: string; answers: { [questionIndex: string]: string }; + /** When true, indicates the user cancelled the dialog without submitting answers */ + cancelled?: boolean; } export type Message = diff --git a/packages/core/src/tools/ask-user.test.ts b/packages/core/src/tools/ask-user.test.ts index 05b64313b9..01dfefb2ee 100644 --- a/packages/core/src/tools/ask-user.test.ts +++ b/packages/core/src/tools/ask-user.test.ts @@ -87,7 +87,9 @@ describe('AskUserTool', () => { }, ], }); - expect(result).toContain('must NOT have fewer than 2 items'); + expect(result).toContain( + "type='choice' requires 'options' array with 2-4 items", + ); }); it('should return error if options has more than 4 items', () => { @@ -106,7 +108,7 @@ describe('AskUserTool', () => { }, ], }); - expect(result).toContain('must NOT have more than 4 items'); + expect(result).toContain("'options' array must have at most 4 items"); }); it('should return null for valid params', () => { @@ -124,6 +126,91 @@ describe('AskUserTool', () => { }); expect(result).toBeNull(); }); + + it('should return error if choice type has no options', () => { + const result = tool.validateToolParams({ + questions: [ + { + question: 'Pick one?', + header: 'Choice', + type: QuestionType.CHOICE, + }, + ], + }); + expect(result).toContain("type='choice' requires 'options'"); + }); + + it('should return error if type is omitted and options missing (defaults to choice)', () => { + const result = tool.validateToolParams({ + questions: [ + { + question: 'Pick one?', + header: 'Choice', + // type omitted, defaults to 'choice' + // options missing + }, + ], + }); + expect(result).toContain("type='choice' requires 'options'"); + }); + + it('should accept text type without options', () => { + const result = tool.validateToolParams({ + questions: [ + { + question: 'Enter your name?', + header: 'Name', + type: QuestionType.TEXT, + }, + ], + }); + expect(result).toBeNull(); + }); + + it('should accept yesno type without options', () => { + const result = tool.validateToolParams({ + questions: [ + { + question: 'Do you want to proceed?', + header: 'Confirm', + type: QuestionType.YESNO, + }, + ], + }); + expect(result).toBeNull(); + }); + + it('should return error if option has empty label', () => { + const result = tool.validateToolParams({ + questions: [ + { + question: 'Pick one?', + header: 'Choice', + options: [ + { label: '', description: 'Empty label' }, + { label: 'B', description: 'Option B' }, + ], + }, + ], + }); + expect(result).toContain("'label' is required"); + }); + + it('should return error if option is missing description', () => { + const result = tool.validateToolParams({ + questions: [ + { + question: 'Pick one?', + header: 'Choice', + options: [ + { label: 'A' } as { label: string; description: string }, + { label: 'B', description: 'Option B' }, + ], + }, + ], + }); + expect(result).toContain("must have required property 'description'"); + }); }); it('should publish ASK_USER_REQUEST and wait for response', async () => { @@ -195,6 +282,46 @@ describe('AskUserTool', () => { expect(JSON.parse(result.llmContent as string)).toEqual({ answers }); }); + it('should display message when user submits without answering', async () => { + const questions = [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'Option A', description: 'First option' }, + { label: 'Option B', description: 'Second option' }, + ], + }, + ]; + + const invocation = tool.build({ questions }); + const executePromise = invocation.execute(new AbortController().signal); + + // Get the correlation ID from the published message + const publishCall = vi.mocked(mockMessageBus.publish).mock.calls[0][0] as { + correlationId: string; + }; + const correlationId = publishCall.correlationId; + + // Simulate response with empty answers + const subscribeCall = vi + .mocked(mockMessageBus.subscribe) + .mock.calls.find((call) => call[0] === MessageBusType.ASK_USER_RESPONSE); + const handler = subscribeCall![1]; + + handler({ + type: MessageBusType.ASK_USER_RESPONSE, + correlationId, + answers: {}, + }); + + const result = await executePromise; + expect(result.returnDisplay).toBe( + 'User submitted without answering questions.', + ); + expect(JSON.parse(result.llmContent as string)).toEqual({ answers: {} }); + }); + it('should handle cancellation', async () => { const invocation = tool.build({ questions: [ diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts index 7d0fb8ef3a..81d62d021c 100644 --- a/packages/core/src/tools/ask-user.ts +++ b/packages/core/src/tools/ask-user.ts @@ -20,7 +20,7 @@ import { type AskUserResponse, } from '../confirmation-bus/types.js'; import { randomUUID } from 'node:crypto'; -import { ASK_USER_TOOL_NAME } from './tool-names.js'; +import { ASK_USER_TOOL_NAME, ASK_USER_DISPLAY_NAME } from './tool-names.js'; export interface AskUserParams { questions: Question[]; @@ -33,7 +33,7 @@ export class AskUserTool extends BaseDeclarativeTool< constructor(messageBus: MessageBus) { super( ASK_USER_TOOL_NAME, - 'Ask User', + ASK_USER_DISPLAY_NAME, 'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.', Kind.Communicate, { @@ -62,15 +62,14 @@ export class AskUserTool extends BaseDeclarativeTool< type: { type: 'string', enum: ['choice', 'text', 'yesno'], + default: 'choice', description: - "Question type. 'choice' (default) shows selectable options, 'text' shows a free-form text input, 'yesno' shows a binary Yes/No choice.", + "Question type: 'choice' (default) for multiple-choice with options, 'text' for free-form input, 'yesno' for Yes/No confirmation.", }, options: { type: 'array', description: - "Required for 'choice' type, ignored for 'text' and 'yesno'. The available choices (2-4 options). Do NOT include an 'Other' option - one is automatically added for 'choice' type.", - minItems: 2, - maxItems: 4, + "The selectable choices for 'choice' type questions. Provide 2-4 options. An 'Other' option is automatically added. Not needed for 'text' or 'yesno' types.", items: { type: 'object', required: ['label', 'description'], @@ -78,12 +77,12 @@ export class AskUserTool extends BaseDeclarativeTool< label: { type: 'string', description: - 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + 'The display text for this option (1-5 words). Example: "OAuth 2.0"', }, description: { type: 'string', description: - 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + 'Brief explanation of this option. Example: "Industry standard, supports SSO"', }, }, }, @@ -91,12 +90,12 @@ export class AskUserTool extends BaseDeclarativeTool< multiSelect: { type: 'boolean', description: - "Only applies to 'choice' type. Set to true to allow multiple selections.", + "Only applies when type='choice'. Set to true to allow selecting multiple options.", }, placeholder: { type: 'string', description: - "Optional hint text for 'text' type input field.", + "Only applies when type='text'. Hint text shown in the input field.", }, }, }, @@ -107,6 +106,51 @@ export class AskUserTool extends BaseDeclarativeTool< ); } + protected override validateToolParamValues( + params: AskUserParams, + ): string | null { + if (!params.questions || params.questions.length === 0) { + return 'At least one question is required.'; + } + + for (let i = 0; i < params.questions.length; i++) { + const q = params.questions[i]; + const questionType = q.type ?? QuestionType.CHOICE; + + // Validate that 'choice' type has options + if (questionType === QuestionType.CHOICE) { + if (!q.options || q.options.length < 2) { + return `Question ${i + 1}: type='choice' requires 'options' array with 2-4 items.`; + } + if (q.options.length > 4) { + return `Question ${i + 1}: 'options' array must have at most 4 items.`; + } + } + + // Validate option structure if provided + if (q.options) { + for (let j = 0; j < q.options.length; j++) { + const opt = q.options[j]; + if ( + !opt.label || + typeof opt.label !== 'string' || + !opt.label.trim() + ) { + return `Question ${i + 1}, option ${j + 1}: 'label' is required and must be a non-empty string.`; + } + if ( + opt.description === undefined || + typeof opt.description !== 'string' + ) { + return `Question ${i + 1}, option ${j + 1}: 'description' is required and must be a string.`; + } + } + } + } + + return null; + } + protected createInvocation( params: AskUserParams, messageBus: MessageBus, @@ -148,16 +192,28 @@ export class AskUserInvocation extends BaseToolInvocation< if (response.correlationId === correlationId) { cleanup(); - // Build formatted key-value display - const formattedAnswers = Object.entries(response.answers) - .map(([index, answer]) => { - const question = this.params.questions[parseInt(index, 10)]; - const category = question?.header ?? `Q${index}`; - return ` ${category} → ${answer}`; - }) - .join('\n'); + // Handle user cancellation + if (response.cancelled) { + resolve({ + llmContent: 'User dismissed ask user dialog without answering.', + returnDisplay: 'User dismissed dialog', + }); + return; + } - const returnDisplay = `User answered:\n${formattedAnswers}`; + // Build formatted key-value display + const answerEntries = Object.entries(response.answers); + const hasAnswers = answerEntries.length > 0; + + const returnDisplay = hasAnswers + ? `**User answered:**\n${answerEntries + .map(([index, answer]) => { + const question = this.params.questions[parseInt(index, 10)]; + const category = question?.header ?? `Q${index}`; + return ` ${category} → ${answer}`; + }) + .join('\n')}` + : 'User submitted without answering questions.'; resolve({ llmContent: JSON.stringify({ answers: response.answers }), diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 897c846c57..e00b626579 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -24,6 +24,7 @@ export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs'; export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill'; export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); export const ASK_USER_TOOL_NAME = 'ask_user'; +export const ASK_USER_DISPLAY_NAME = 'Ask User'; /** Prefix used for tools discovered via the toolDiscoveryCommand. */ export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';