diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 952ad0bd5..3d771f6ea 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -377,6 +377,7 @@ export type SessionUpdateMeta = z.infer; export const requestPermissionResponseSchema = z.object({ outcome: requestPermissionOutcomeSchema, + answers: z.record(z.string()).optional(), }); export const fileSystemCapabilitySchema = z.object({ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 329e9fc5a..010427a04 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -552,6 +552,7 @@ export class Session implements SessionContext { content, locations: invocation.toolLocations(), kind: mappedKind, + rawInput: args, }, }; @@ -563,7 +564,9 @@ export class Session implements SessionContext { .nativeEnum(ToolConfirmationOutcome) .parse(output.outcome.optionId); - await confirmationDetails.onConfirm(outcome); + await confirmationDetails.onConfirm(outcome, { + answers: output.answers, + }); // After exit_plan_mode confirmation, send current_mode_update notification if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) { diff --git a/packages/core/src/tools/askUserQuestion.test.ts b/packages/core/src/tools/askUserQuestion.test.ts index 865150864..f9aabc2d9 100644 --- a/packages/core/src/tools/askUserQuestion.test.ts +++ b/packages/core/src/tools/askUserQuestion.test.ts @@ -20,6 +20,8 @@ describe('AskUserQuestionTool', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getTargetDir: vi.fn().mockReturnValue('/mock/dir'), getChatRecordingService: vi.fn(), + getExperimentalZedIntegration: vi.fn().mockReturnValue(false), + getInputFormat: vi.fn().mockReturnValue(undefined), } as unknown as Config; tool = new AskUserQuestionTool(mockConfig); diff --git a/packages/core/src/tools/askUserQuestion.ts b/packages/core/src/tools/askUserQuestion.ts index be908881d..2b92f24e8 100644 --- a/packages/core/src/tools/askUserQuestion.ts +++ b/packages/core/src/tools/askUserQuestion.ts @@ -19,6 +19,7 @@ import type { FunctionDeclaration } from '@google/genai'; import type { Config } from '../config/config.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { InputFormat } from '../output/types.js'; const debugLogger = createDebugLogger('ASK_USER_QUESTION'); @@ -167,8 +168,14 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation< override async shouldConfirmExecute( _abortSignal: AbortSignal, ): Promise { - if (!this._config.isInteractive()) { - // In non-interactive mode, we cannot collect user input + // Check if we're in a mode that supports user interaction + // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input + const isAcpMode = + this._config.getExperimentalZedIntegration() || + this._config.getInputFormat() === InputFormat.STREAM_JSON; + + if (!this._config.isInteractive() && !isAcpMode) { + // In non-interactive mode without ACP support, we cannot collect user input return false; } @@ -203,10 +210,16 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation< async execute(_signal: AbortSignal): Promise { try { - // In non-interactive mode, we cannot collect user input - if (!this._config.isInteractive()) { + // Check if we're in a mode that supports user interaction + // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input + const isAcpMode = + this._config.getExperimentalZedIntegration() || + this._config.getInputFormat() === InputFormat.STREAM_JSON; + + // In non-interactive mode without ACP support, we cannot collect user input + if (!this._config.isInteractive() && !isAcpMode) { const errorMessage = - 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.'; + 'Cannot ask user questions in non-interactive mode without ACP support. Please run in interactive mode or enable ACP mode to use this tool.'; return { llmContent: errorMessage, returnDisplay: errorMessage, diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 0a5aec02c..929bb0b16 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -12,6 +12,7 @@ import type { AcpResponse, AcpSessionUpdate, AuthenticateUpdateNotification, + AskUserQuestionRequest, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; @@ -47,6 +48,10 @@ export class AcpConnection { onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = () => {}; onEndTurn: () => void = () => {}; + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }> = () => Promise.resolve({ optionId: 'proceed_once' }); // Called after successful initialize() with the initialize result onInitialized: (init: unknown) => void = () => {}; @@ -215,6 +220,7 @@ export class AcpConnection { onPermissionRequest: this.onPermissionRequest, onAuthenticateUpdate: this.onAuthenticateUpdate, onEndTurn: this.onEndTurn, + onAskUserQuestion: this.onAskUserQuestion, }; // Handle message diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts index c2fad7701..d43cc8f61 100644 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -18,6 +18,7 @@ import type { AcpSessionUpdate, AcpPermissionRequest, AuthenticateUpdateNotification, + Question, } from '../types/acpTypes.js'; import { CLIENT_METHODS } from '../constants/acpSchema.js'; import type { @@ -220,27 +221,71 @@ export class AcpMessageHandler { callbacks: AcpConnectionCallbacks, ): Promise<{ outcome: { outcome: string; optionId: string }; + answers?: Record; }> { try { - const response = await callbacks.onPermissionRequest(params); - const optionId = response?.optionId; - console.log('[ACP] Permission request:', optionId); - // Handle cancel, deny, or allow - let outcome: string; - if (optionId && (optionId.includes('reject') || optionId === 'cancel')) { - outcome = 'cancelled'; - } else { - outcome = 'selected'; - } - console.log('[ACP] Permission outcome:', outcome); + // Check if this is an ask_user_question request + const isInteract = + params.toolCall?.toolCallId?.includes('ask_user_question'); - return { - outcome: { - outcome, - // optionId: optionId === 'cancel' ? 'cancel' : optionId, - optionId, - }, - }; + if (isInteract) { + // Handle ask_user_question separately + const questions: Question[] = + params.toolCall?.rawInput?.questions || []; + const metadata = params.toolCall?.rawInput?.metadata; + + const response = await callbacks.onAskUserQuestion({ + sessionId: params.sessionId, + questions, + metadata, + }); + + const optionId = response?.optionId; + const answers = response?.answers; + console.log('[ACP] AskUserQuestion response:', optionId); + + let outcome: string; + if ( + optionId && + (optionId.includes('reject') || optionId === 'cancel') + ) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] AskUserQuestion outcome:', outcome); + + return { + outcome: { + outcome, + optionId, + }, + answers, + }; + } else { + // Handle regular permission request + const response = await callbacks.onPermissionRequest(params); + const optionId = response?.optionId; + console.log('[ACP] Permission request:', optionId); + // Handle cancel, deny, or allow + let outcome: string; + if ( + optionId && + (optionId.includes('reject') || optionId === 'cancel') + ) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] Permission outcome:', outcome); + + return { + outcome: { + outcome, + optionId, + }, + }; + } } catch (_error) { return { outcome: { diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 0944ee5b7..adcff709f 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -10,6 +10,7 @@ import type { AuthenticateUpdateNotification, ModelInfo, AvailableCommand, + AskUserQuestionRequest, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; @@ -145,6 +146,16 @@ export class QwenAgentManager { return { optionId: 'allow_once' }; }; + this.connection.onAskUserQuestion = async ( + data: AskUserQuestionRequest, + ) => { + if (this.callbacks.onAskUserQuestion) { + const result = await this.callbacks.onAskUserQuestion(data); + return result; + } + return { optionId: 'cancel' }; + }; + this.connection.onEndTurn = (reason?: string) => { try { if (this.callbacks.onEndTurn) { @@ -1313,6 +1324,20 @@ export class QwenAgentManager { this.sessionUpdateHandler.updateCallbacks(this.callbacks); } + /** + * Register ask user question callback + * + * @param callback - Ask user question callback function + */ + onAskUserQuestion( + callback: ( + request: AskUserQuestionRequest, + ) => Promise<{ optionId: string; answers?: Record }>, + ): void { + this.callbacks.onAskUserQuestion = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + /** * Register end-of-turn callback * diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 14304a386..298174cee 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -257,6 +257,10 @@ export interface AcpPermissionRequest { rawInput?: { command?: string; description?: string; + questions?: Question[]; + metadata?: { + source?: string; + }; [key: string]: unknown; }; title?: string; @@ -264,6 +268,38 @@ export interface AcpPermissionRequest { }; } +// Ask User Question types +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionRequest { + sessionId: string; + questions: Question[]; + metadata?: { + source?: string; + }; +} + +// Ask User Question update (sent by agent when asking questions) +export interface AskUserQuestionUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'ask_user_question'; + questions: Question[]; + metadata?: { + source?: string; + }; + }; +} + export type AcpMessage = | AcpRequest | AcpNotification diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index b92cb35e5..866abcfad 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -7,6 +7,7 @@ import type { AcpPermissionRequest, ModelInfo, AvailableCommand, + AskUserQuestionRequest, } from './acpTypes.js'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; @@ -52,6 +53,9 @@ export interface QwenAgentCallbacks { onToolCall?: (update: ToolCallUpdateData) => void; onPlan?: (entries: PlanEntry[]) => void; onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onAskUserQuestion?: ( + request: AskUserQuestionRequest, + ) => Promise<{ optionId: string; answers?: Record }>; onEndTurn?: (reason?: string) => void; onModeInfo?: (info: { currentModeId?: ApprovalModeValue; diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts index 7ada3aedf..8021a4e29 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -9,6 +9,7 @@ import type { AcpSessionUpdate, AcpPermissionRequest, AuthenticateUpdateNotification, + AskUserQuestionRequest, } from './acpTypes.js'; export interface PendingRequest { @@ -25,6 +26,10 @@ export interface AcpConnectionCallbacks { }>; onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void; onEndTurn: (reason?: string) => void; + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }>; } export interface AcpConnectionState { diff --git a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts index f17f68170..76025b6b1 100644 --- a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts +++ b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts @@ -12,3 +12,14 @@ export interface PermissionResponseMessage { type: string; data: PermissionResponsePayload; } + +export interface AskUserQuestionResponsePayload { + optionId?: string; + answers: Record; + cancelled?: boolean; +} + +export interface AskUserQuestionResponseMessage { + type: string; + data: AskUserQuestionResponsePayload; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8d2c0bfed..a8b74b329 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -44,11 +44,16 @@ import { InputForm } from './components/layout/InputForm.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '../types/acpTypes.js'; +import type { + ModelInfo, + AvailableCommand, + Question, +} from '../types/acpTypes.js'; import { DEFAULT_TOKEN_LIMIT, tokenLimit, } from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; +import { AskUserQuestionDialog } from '@qwen-code/webui'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -70,6 +75,13 @@ export const App: React.FC = () => { options: PermissionOption[]; toolCall: PermissionToolCall; } | null>(null); + const [askUserQuestionRequest, setAskUserQuestionRequest] = useState<{ + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + } | null>(null); const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading @@ -331,6 +343,7 @@ export const App: React.FC = () => { clearToolCalls, setPlanEntries, handlePermissionRequest: setPermissionRequest, + handleAskUserQuestion: setAskUserQuestionRequest, inputFieldRef, setInputText, setEditMode, @@ -481,6 +494,31 @@ export const App: React.FC = () => { [vscode], ); + // Handle ask user question response + const handleAskUserQuestionResponse = useCallback( + (answers: Record) => { + // Forward answers to extension as ACP permission response + vscode.postMessage({ + type: 'askUserQuestionResponse', + data: { answers }, + }); + + setAskUserQuestionRequest(null); + }, + [vscode], + ); + + // Handle ask user question cancel + const handleAskUserQuestionCancel = useCallback(() => { + // Forward cancel to extension as ACP permission response with cancel option + vscode.postMessage({ + type: 'askUserQuestionResponse', + data: { answers: {}, cancelled: true }, + }); + + setAskUserQuestionRequest(null); + }, [vscode]); + // Handle completion selection const handleCompletionSelect = useCallback( (item: CompletionItem) => { @@ -1012,6 +1050,14 @@ export const App: React.FC = () => { onClose={() => setPermissionRequest(null)} /> )} + + {isAuthenticated && askUserQuestionRequest && ( + + )} ); }; diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts index 30b9abe56..b89c8fd86 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -6,7 +6,10 @@ import type { QwenAgentManager } from '../services/qwenAgentManager.js'; import type { ConversationStore } from '../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../types/webviewMessageTypes.js'; import { MessageRouter } from './handlers/MessageRouter.js'; /** @@ -61,6 +64,15 @@ export class MessageHandler { this.router.setPermissionHandler(handler); } + /** + * Set ask user question handler + */ + setAskUserQuestionHandler( + handler: (message: AskUserQuestionResponseMessage) => void, + ): void { + this.router.setAskUserQuestionHandler(handler); + } + /** * Set login handler */ diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index a202fffd9..166d2f8de 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -7,9 +7,15 @@ import * as vscode from 'vscode'; import { QwenAgentManager } from '../services/qwenAgentManager.js'; import { ConversationStore } from '../services/conversationStore.js'; -import type { AcpPermissionRequest } from '../types/acpTypes.js'; -import type { ModelInfo } from '../types/acpTypes.js'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; +import type { + ModelInfo, + AcpPermissionRequest, + AskUserQuestionRequest, +} from '../types/acpTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../types/webviewMessageTypes.js'; import { PanelManager } from '../webview/PanelManager.js'; import { MessageHandler } from '../webview/MessageHandler.js'; import { WebViewContent } from '../webview/WebViewContent.js'; @@ -29,6 +35,11 @@ export class WebViewProvider { // a diff, auto-allow read/execute, or auto-reject on cancel). private pendingPermissionRequest: AcpPermissionRequest | null = null; private pendingPermissionResolve: ((optionId: string) => void) | null = null; + // Track a pending ask user question request and its resolver + private pendingAskUserQuestionRequest: AskUserQuestionRequest | null = null; + private pendingAskUserQuestionResolve: + | ((result: { optionId: string; answers?: Record }) => void) + | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; private authState: boolean | null = null; @@ -407,6 +418,60 @@ export class WebViewProvider { }); }, ); + + this.agentManager.onAskUserQuestion( + async (request: AskUserQuestionRequest) => { + // Send ask user question request to WebView + this.sendMessageToWebView({ + type: 'askUserQuestion', + data: request, + }); + + // Wait for user response + return new Promise<{ + optionId: string; + answers?: Record; + }>((resolve) => { + // Cache the pending request and its resolver + this.pendingAskUserQuestionRequest = request; + this.pendingAskUserQuestionResolve = (result) => { + try { + resolve(result); + } finally { + // Always clear pending state + this.pendingAskUserQuestionRequest = null; + this.pendingAskUserQuestionResolve = null; + // Instruct the webview UI to close the dialog + this.sendMessageToWebView({ + type: 'askUserQuestionResolved', + data: { optionId: result.optionId }, + }); + } + }; + const handler = (message: AskUserQuestionResponseMessage) => { + if (message.type !== 'askUserQuestionResponse') { + return; + } + + const { optionId, answers, cancelled } = message.data; + + // Resolve with the result + if (cancelled) { + this.pendingAskUserQuestionResolve?.({ + optionId: 'cancel', + }); + } else { + this.pendingAskUserQuestionResolve?.({ + optionId: optionId || 'proceed_once', + answers, + }); + } + }; + // Store handler in message handler + this.messageHandler.setAskUserQuestionHandler(handler); + }); + }, + ); } async show(): Promise { @@ -1150,6 +1215,25 @@ export class WebViewProvider { } return; } + // Handle ask user question response + if (message.type === 'askUserQuestionResponse') { + const askUserQuestionMsg = message as AskUserQuestionResponseMessage; + const answers = askUserQuestionMsg.data.answers || {}; + const cancelled = askUserQuestionMsg.data.cancelled || false; + + // Resolve the pending ask user question promise + if (cancelled) { + this.pendingAskUserQuestionResolve?.({ + optionId: 'cancel', + }); + } else { + this.pendingAskUserQuestionResolve?.({ + optionId: 'proceed_once', + answers, + }); + } + return; + } await this.messageHandler.route(message); }, null, diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index de23fb1e5..9cb401b43 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -7,7 +7,10 @@ import type { IMessageHandler } from './BaseMessageHandler.js'; import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../../types/webviewMessageTypes.js'; import { SessionMessageHandler } from './SessionMessageHandler.js'; import { FileMessageHandler } from './FileMessageHandler.js'; import { EditorMessageHandler } from './EditorMessageHandler.js'; @@ -25,6 +28,9 @@ export class MessageRouter { private permissionHandler: | ((message: PermissionResponseMessage) => void) | null = null; + private askUserQuestionHandler: + | ((message: AskUserQuestionResponseMessage) => void) + | null = null; constructor( agentManager: QwenAgentManager, @@ -86,6 +92,14 @@ export class MessageRouter { return; } + // Handle ask user question response specially + if (message.type === 'askUserQuestionResponse') { + if (this.askUserQuestionHandler) { + this.askUserQuestionHandler(message as AskUserQuestionResponseMessage); + } + return; + } + // Find appropriate handler const handler = this.handlers.find((h) => h.canHandle(message.type)); @@ -135,6 +149,15 @@ export class MessageRouter { this.permissionHandler = handler; } + /** + * Set ask user question handler + */ + setAskUserQuestionHandler( + handler: (message: AskUserQuestionResponseMessage) => void, + ): void { + this.askUserQuestionHandler = handler; + } + /** * Set login handler */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 30a1166b0..aa19c6553 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -14,7 +14,11 @@ import type { } from '../../types/chatTypes.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '../../types/acpTypes.js'; +import type { + ModelInfo, + AvailableCommand, + Question, +} from '../../types/acpTypes.js'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -114,6 +118,17 @@ interface UseWebViewMessagesProps { } | null, ) => void; + // Ask User Question + handleAskUserQuestion: ( + request: { + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + } | null, + ) => void; + // Input inputFieldRef: React.RefObject; setInputText: (text: string) => void; @@ -143,6 +158,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, inputFieldRef, setInputText, setEditMode, @@ -167,6 +183,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, setIsAuthenticated, setUsageStats, setModelInfo, @@ -216,6 +233,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, setIsAuthenticated, setUsageStats, setModelInfo, @@ -629,6 +647,19 @@ export const useWebViewMessages = ({ break; } + case 'askUserQuestion': { + // Handle ask user question request from extension + const questionsData = message.data as { + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + }; + handlers.handleAskUserQuestion(questionsData); + break; + } + case 'plan': if (message.data.entries && Array.isArray(message.data.entries)) { const entries = message.data.entries as PlanEntry[]; diff --git a/packages/webui/src/components/messages/AskUserQuestionDialog.tsx b/packages/webui/src/components/messages/AskUserQuestionDialog.tsx new file mode 100644 index 000000000..36da95a3c --- /dev/null +++ b/packages/webui/src/components/messages/AskUserQuestionDialog.tsx @@ -0,0 +1,524 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AskUserQuestionDialog component for displaying questions to the user + * and collecting their responses in the WebView + */ + +import type { FC } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionDialogProps { + questions: Question[]; + onSubmit: (answers: Record) => void; + onCancel: () => void; +} + +interface AnswerState { + selectedOption?: string; + customInput?: string; + multiSelectedOptions?: string[]; + customInputChecked?: boolean; +} + +export const AskUserQuestionDialog: FC = ({ + questions, + onSubmit, + onCancel, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [showCustomInput, setShowCustomInput] = useState(false); + const containerRef = useRef(null); + const customInputRef = useRef(null); + + const hasMultipleQuestions = questions.length > 1; + const totalTabs = hasMultipleQuestions + ? questions.length + 1 + : questions.length; + const isSubmitTab = + hasMultipleQuestions && currentQuestionIndex === totalTabs - 1; + + const currentQuestion = isSubmitTab ? null : questions[currentQuestionIndex]; + const isMultiSelect = currentQuestion?.multiSelect ?? false; + + // Get current answer state + const currentAnswer = answers[currentQuestionIndex] || {}; + + // Get answer for a specific question + const getAnswerForQuestion = useCallback( + (idx: number): string | undefined => { + const q = questions[idx]; + const answerState = answers[idx]; + if (!answerState) { + return undefined; + } + + if (q?.multiSelect) { + const selections = [...(answerState.multiSelectedOptions || [])]; + const customValue = (answerState.customInput || '').trim(); + if (answerState.customInputChecked && customValue) { + selections.push(customValue); + } + return selections.length > 0 ? selections.join(', ') : undefined; + } + + // Check if custom input was used (value doesn't match any option) + if (answerState.customInput && answerState.customInput.trim()) { + const matchesOption = q?.options.some( + (opt) => opt.label === answerState.customInput?.trim(), + ); + if (!matchesOption) { + return answerState.customInput.trim(); + } + } + + return answerState.selectedOption; + }, + [questions, answers], + ); + + // Handle submitting all answers + const handleSubmit = useCallback(() => { + const answersRecord: Record = {}; + questions.forEach((_, idx) => { + const answer = getAnswerForQuestion(idx); + if (answer !== undefined) { + answersRecord[idx] = answer; + } + }); + onSubmit(answersRecord); + }, [questions, onSubmit, getAnswerForQuestion]); + + // Handle confirming multi-select for current question + const handleMultiSelectConfirm = useCallback(() => { + if (!currentQuestion) { + return; + } + + const answerState = answers[currentQuestionIndex] || {}; + const selections = [...(answerState.multiSelectedOptions || [])]; + const customValue = (answerState.customInput || '').trim(); + if (answerState.customInputChecked && customValue) { + selections.push(customValue); + } + if (selections.length === 0) { + return; + } + + const value = selections.join(', '); + + const updatedAnswers = { + ...answers, + [currentQuestionIndex]: { + ...answerState, + selectedOption: value, + }, + }; + setAnswers(updatedAnswers); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: value }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + }, [ + currentQuestion, + answers, + currentQuestionIndex, + hasMultipleQuestions, + totalTabs, + onSubmit, + ]); + + // Handle option selection + const handleOptionSelect = useCallback( + (optionIndex: number) => { + if (!currentQuestion) { + return; + } + + if (isMultiSelect) { + const answerState = answers[currentQuestionIndex] || {}; + const current = answerState.multiSelectedOptions || []; + const option = currentQuestion.options[optionIndex]; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + multiSelectedOptions: updated, + }, + }); + } else { + const option = currentQuestion.options[optionIndex]; + const answerState = answers[currentQuestionIndex] || {}; + const updated = { + ...answerState, + selectedOption: option.label, + }; + setAnswers({ ...answers, [currentQuestionIndex]: updated }); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: option.label }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + } + }, + [ + currentQuestion, + isMultiSelect, + answers, + currentQuestionIndex, + hasMultipleQuestions, + totalTabs, + onSubmit, + ], + ); + + // Handle custom input change + const handleCustomInputChange = (value: string) => { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInput: value, + customInputChecked: isMultiSelect && value.trim().length > 0, + }, + }); + }; + + // Handle custom input submit + const handleCustomInputSubmit = () => { + const value = currentAnswer.customInput?.trim() || ''; + if (!value) { + return; + } + + if (isMultiSelect) { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInputChecked: !answerState.customInputChecked, + }, + }); + } else { + const answerState = answers[currentQuestionIndex] || {}; + const updated = { + ...answerState, + selectedOption: value, + }; + setAnswers({ ...answers, [currentQuestionIndex]: updated }); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: value }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + } + }; + + // Escape to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onCancel]); + + // Focus custom input when shown + useEffect(() => { + if (showCustomInput && customInputRef.current) { + customInputRef.current.focus(); + } + }, [showCustomInput]); + + // Reset custom input visibility when switching tabs + useEffect(() => { + setShowCustomInput(false); + }, [currentQuestionIndex]); + + // Shared tab bar renderer + const renderTabs = () => ( +
+ {questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + const isActive = idx === currentQuestionIndex; + return ( + + ); + })} + +
+ ); + + // Container style + const containerStyle = { + backgroundColor: 'var(--app-input-secondary-background)', + borderColor: 'var(--app-input-border)', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + }; + + // Render submit tab + if (isSubmitTab) { + return ( +
+ {renderTabs()} + + {/* Show selected answers */} +
+
+ Your answers: +
+ {questions.map((q, idx) => { + const answer = getAnswerForQuestion(idx); + return ( +
+ {q.header}:{' '} + {answer ? ( + + {answer} + + ) : ( + (not answered) + )} +
+ ); + })} +
+ + {/* Submit/Cancel buttons */} +
+ + +
+
+ ); + } + + // Render question tab + return ( +
+ {/* Tabs for multiple questions */} + {hasMultipleQuestions && renderTabs()} + + {/* Question */} +
+ {!hasMultipleQuestions && ( +
+ + {currentQuestion!.header} + +
+ )} +
+ {currentQuestion!.question} +
+
+ + {/* Options */} +
+ {currentQuestion!.options.map((opt, index) => { + const isSelected = + !isMultiSelect && currentAnswer.selectedOption === opt.label; + const isMultiChecked = + isMultiSelect && + currentAnswer.multiSelectedOptions?.includes(opt.label); + + return ( +
+ + {opt.description && ( +
+ {opt.description} +
+ )} +
+ ); + })} + + {/* Custom input ("Other") */} +
+ {showCustomInput ? ( +
+ {isMultiSelect && ( + { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInputChecked: !answerState.customInputChecked, + }, + }); + }} + > + {currentAnswer.customInputChecked ? '☑' : '☐'} + + )} + handleCustomInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCustomInputSubmit(); + } + }} + placeholder="Type your answer..." + /> +
+ ) : ( + + )} +
+
+ + {/* Action buttons */} +
+ {isMultiSelect && ( + + )} + +
+
+ ); +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 330c0cb6d..39e0a8cbf 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -86,6 +86,12 @@ export type { CollapsibleFileContentProps, ContentSegment, } from './components/messages/CollapsibleFileContent'; +export { AskUserQuestionDialog } from './components/messages/AskUserQuestionDialog'; +export type { + AskUserQuestionDialogProps, + Question, + QuestionOption, +} from './components/messages/AskUserQuestionDialog'; // ChatViewer - standalone chat display component export {