From 5521e0df823981784aa8a0efd2e709fc8d7ec0d1 Mon Sep 17 00:00:00 2001 From: ScDor <18174994+ScDor@users.noreply.github.com> Date: Sat, 31 Jan 2026 13:40:25 +0200 Subject: [PATCH] feat: implement multi-question support with options and UI enhancements --- packages/types/src/global-settings.ts | 7 + packages/types/src/vscode-extension-host.ts | 1 + .../assistant-message/NativeToolCallParser.ts | 8 +- .../native-tools/ask_followup_question.ts | 46 +++- src/core/tools/AskFollowupQuestionTool.ts | 63 ++++-- .../__tests__/askFollowupQuestionTool.spec.ts | 107 +++++++-- src/shared/tools.ts | 2 +- .../components/chat/MultiQuestionHandler.tsx | 207 +++++++++++++----- .../__tests__/MultiQuestionHandler.spec.tsx | 47 ++++ .../src/components/settings/SettingsView.tsx | 3 + .../src/components/settings/UISettings.tsx | 29 +++ .../settings/__tests__/UISettings.spec.tsx | 1 + .../src/components/ui/autosize-textarea.tsx | 2 +- .../src/context/ExtensionStateContext.tsx | 10 + webview-ui/src/i18n/locales/en/chat.json | 1 + 15 files changed, 420 insertions(+), 114 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 9a17834ced..0ec74e347a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -197,6 +197,12 @@ export const globalSettingsSchema = z.object({ hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), lastModeImportPath: z.string().optional(), + + /** + * Whether to show multiple questions one by one or all at once. + * @default false (all at once) + */ + showQuestionsOneByOne: z.boolean().optional(), }) export type GlobalSettings = z.infer @@ -364,6 +370,7 @@ export const EVALS_SETTINGS: RooCodeSettings = { mode: "code", // "architect", customModes: [], + showQuestionsOneByOne: false, } export const EVALS_TIMEOUT = 5 * 60 * 1_000 diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 86d8b2ddbb..ebddd0ef64 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -260,6 +260,7 @@ export type ExtensionState = Pick< | "enterBehavior" | "includeCurrentTime" | "includeCurrentCost" + | "showQuestionsOneByOne" | "maxGitStatusFiles" | "requestDelaySeconds" > & { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 56d71eb3dd..961b113d8d 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -394,9 +394,9 @@ export class NativeToolCallParser { break case "ask_followup_question": - if (partialArgs.question !== undefined || partialArgs.follow_up !== undefined) { + if (partialArgs.questions !== undefined || partialArgs.follow_up !== undefined) { nativeArgs = { - question: partialArgs.question, + questions: Array.isArray(partialArgs.questions) ? partialArgs.questions : undefined, follow_up: Array.isArray(partialArgs.follow_up) ? partialArgs.follow_up : undefined, } } @@ -676,9 +676,9 @@ export class NativeToolCallParser { break case "ask_followup_question": - if (args.question !== undefined && args.follow_up !== undefined) { + if (args.questions !== undefined && args.follow_up !== undefined) { nativeArgs = { - question: args.question, + questions: Array.isArray(args.questions) ? args.questions : undefined, follow_up: args.follow_up, } as NativeArgsFor } diff --git a/src/core/prompts/tools/native-tools/ask_followup_question.ts b/src/core/prompts/tools/native-tools/ask_followup_question.ts index b0591206ad..4546596292 100644 --- a/src/core/prompts/tools/native-tools/ask_followup_question.ts +++ b/src/core/prompts/tools/native-tools/ask_followup_question.ts @@ -3,16 +3,26 @@ import type OpenAI from "openai" const ASK_FOLLOWUP_QUESTION_DESCRIPTION = `Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively. Parameters: -- question: (required) A clear, specific question addressing the information needed +- questions: (required) A list of questions to ask. Each question can be a simple string or an object with "text" and "options" for multiple choice. - follow_up: (required) A list of 2-4 suggested answers. Suggestions must be complete, actionable answers without placeholders. Optionally include mode to switch modes (code/architect/etc.) Example: Asking for file path -{ "question": "What is the path to the frontend-config.json file?", "follow_up": [{ "text": "./src/frontend-config.json", "mode": null }, { "text": "./config/frontend-config.json", "mode": null }, { "text": "./frontend-config.json", "mode": null }] } +{ "questions": ["What is the path to the frontend-config.json file?"], "follow_up": [{ "text": "./src/frontend-config.json", "mode": null }, { "text": "./config/frontend-config.json", "mode": null }, { "text": "./frontend-config.json", "mode": null }] } + +Example: Asking with multiple questions and choices +{ + "questions": [ + { "text": "Which framework are you using?", "options": ["React", "Vue", "Svelte", "Other"] }, + "What is your project name?", + { "text": "Include telemetry?", "options": ["Yes", "No"] } + ], + "follow_up": [{ "text": "I've answered the questions", "mode": null }] +} Example: Asking with mode switch -{ "question": "Would you like me to implement this feature?", "follow_up": [{ "text": "Yes, implement it now", "mode": "code" }, { "text": "No, just plan it out", "mode": "architect" }] }` +{ "questions": ["Would you like me to implement this feature?"], "follow_up": [{ "text": "Yes, implement it now", "mode": "code" }, { "text": "No, just plan it out", "mode": "architect" }] }` -const QUESTION_PARAMETER_DESCRIPTION = `Clear, specific question that captures the missing information you need` +const QUESTIONS_PARAMETER_DESCRIPTION = `List of questions to ask. Each question can be a string or an object with "text" and "options" for multiple choice.` const FOLLOW_UP_PARAMETER_DESCRIPTION = `Required list of 2-4 suggested responses; each suggestion must be a complete, actionable answer and may include a mode switch` @@ -29,9 +39,29 @@ export default { parameters: { type: "object", properties: { - question: { - type: "string", - description: QUESTION_PARAMETER_DESCRIPTION, + questions: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "object", + properties: { + text: { type: "string" }, + options: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["text", "options"], + additionalProperties: false, + }, + ], + }, + description: QUESTIONS_PARAMETER_DESCRIPTION, + minItems: 1, }, follow_up: { type: "array", @@ -55,7 +85,7 @@ export default { maxItems: 4, }, }, - required: ["question", "follow_up"], + required: ["questions", "follow_up"], additionalProperties: false, }, }, diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index 69fe6c38d6..b1f00f3f9c 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -10,9 +10,13 @@ interface Suggestion { mode?: string } +interface Question { + text: string + options?: string[] +} + interface AskFollowupQuestionParams { - question: string - questions?: string[] + questions: Array follow_up: Suggestion[] } @@ -25,21 +29,33 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { const follow_up_xml = params.follow_up const suggestions: Suggestion[] = [] - const questions: string[] = [] + const questions: Array = [] if (questions_xml) { try { + // Handle both simple tags and more complex tags with options const parsedQuestions = parseXml(questions_xml, ["question"]) as { - question: string[] | string + question: any[] | any } const rawQuestions = Array.isArray(parsedQuestions?.question) ? parsedQuestions.question - : [parsedQuestions?.question].filter((q): q is string => q !== undefined) + : [parsedQuestions?.question].filter((q): q is any => q !== undefined) for (const q of rawQuestions) { if (typeof q === "string") { questions.push(q) + } else if (typeof q === "object" && q !== null) { + const text = q["#text"] || "" + const optionsStr = q["@_options"] + if (optionsStr) { + questions.push({ + text, + options: optionsStr.split(",").map((o: string) => o.trim()), + }) + } else { + questions.push(text) + } } } } catch (error) { @@ -49,8 +65,12 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { } } + // If no questions array but we have a single question, use that + if (questions.length === 0 && question) { + questions.push(question) + } + if (follow_up_xml) { - // Define the actual structure returned by the XML parser type ParsedSuggestion = string | { "#text": string; "@_mode"?: string } try { @@ -62,13 +82,10 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { ? parsedSuggest.suggest : [parsedSuggest?.suggest].filter((sug): sug is ParsedSuggestion => sug !== undefined) - // Transform parsed XML to our Suggest format for (const sug of rawSuggestions) { if (typeof sug === "string") { - // Simple string suggestion (no mode attribute) suggestions.push({ text: sug }) } else { - // XML object with text content and optional mode attribute const suggestion: Suggestion = { text: sug["#text"] } if (sug["@_mode"]) { suggestion.mode = sug["@_mode"] @@ -84,34 +101,32 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { } return { - question, questions, follow_up: suggestions, } } async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise { - const { question, questions, follow_up } = params - const { handleError, pushToolResult, toolProtocol } = callbacks + const { questions, follow_up } = params + const { handleError, pushToolResult } = callbacks try { - if (!question && (!questions || questions.length === 0)) { + if (!questions || questions.length === 0) { task.consecutiveMistakeCount++ task.recordToolError("ask_followup_question") task.didToolFailInCurrentTurn = true - pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", "question")) + pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", "questions")) return } // Transform follow_up suggestions to the format expected by task.ask - const follow_up_json = { - question, + const followup_json = { questions, suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })), } task.consecutiveMistakeCount = 0 - const { text, images } = await task.ask("followup", JSON.stringify(follow_up_json), false) + const { text, images } = await task.ask("followup", JSON.stringify(followup_json), false) await task.say("user_feedback", text ?? "", images) pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) } catch (error) { @@ -120,15 +135,15 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { } override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise { - // Get question from params (for XML protocol) or nativeArgs (for native protocol) - const question: string | undefined = block.params.question ?? block.nativeArgs?.question - // For now we don't stream multiple questions, only the main one if present - // We could improve this to stream multiple questions but it requires UI changes to handle partial arrays + // Get first question from questions array for streaming display + const questions = block.nativeArgs?.questions ?? [] + const firstQuestion = questions[0] + const questionText = typeof firstQuestion === "string" ? firstQuestion : firstQuestion?.text - // During partial streaming, only show the question to avoid displaying raw JSON - // The full JSON with suggestions will be sent when the tool call is complete (!block.partial) + // During partial streaming, only show the first question to avoid displaying raw JSON + // The full JSON with all questions and suggestions will be sent when the tool call is complete await task - .ask("followup", this.removeClosingTag("question", question, block.partial), block.partial) + .ask("followup", this.removeClosingTag("question", questionText, block.partial), block.partial) .catch(() => {}) } } diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 0c3ab5d0dc..c17489ba30 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -129,18 +129,79 @@ describe("askFollowupQuestionTool", () => { ) }) + it("should handle multiple questions in native protocol", async () => { + const block: ToolUse<"ask_followup_question"> = { + type: "tool_use", + name: "ask_followup_question", + params: {}, + nativeArgs: { + questions: ["Question A", "Question B"], + follow_up: [{ text: "Okay", mode: "code" }], + }, + partial: false, + } + + await askFollowupQuestionTool.handle(mockCline, block, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + toolProtocol: "native", + }) + + expect(mockCline.ask).toHaveBeenCalledWith( + "followup", + expect.stringContaining('"questions":["Question A","Question B"]'), + false, + ) + }) + + it("should handle multiple-choice questions in native protocol", async () => { + const block: ToolUse<"ask_followup_question"> = { + type: "tool_use", + name: "ask_followup_question", + params: {}, + nativeArgs: { + questions: [ + { text: "Framework?", options: ["React", "Vue"] }, + "Project name?", + { text: "Deploy?", options: ["Yes", "No"] }, + ], + follow_up: [{ text: "Done", mode: "code" }], + }, + partial: false, + } + + await askFollowupQuestionTool.handle(mockCline, block, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + removeClosingTag: vi.fn((tag, content) => content), + toolProtocol: "native", + }) + + expect(mockCline.ask).toHaveBeenCalledWith( + "followup", + expect.stringContaining( + '"questions":[{"text":"Framework?","options":["React","Vue"]},"Project name?",{"text":"Deploy?","options":["Yes","No"]}]', + ), + false, + ) + }) + describe("handlePartial with native protocol", () => { - it("should only send question during partial streaming to avoid raw JSON display", async () => { + it("should only send first question during partial streaming to avoid raw JSON display", async () => { const block: ToolUse<"ask_followup_question"> = { type: "tool_use", name: "ask_followup_question", - params: { - question: "What would you like to do?", - }, + params: {}, partial: true, nativeArgs: { - question: "What would you like to do?", - follow_up: [{ text: "Option 1", mode: "code" }, { text: "Option 2" }], + questions: ["What would you like to do?"], + follow_up: [ + { text: "Option 1", mode: "code" }, + { text: "Option 2", mode: "architect" }, + ], }, } @@ -152,18 +213,20 @@ describe("askFollowupQuestionTool", () => { toolProtocol: "native", }) - // During partial streaming, only the question should be sent (not JSON with suggestions) + // During partial streaming, only the first question should be sent (not JSON with suggestions) expect(mockCline.ask).toHaveBeenCalledWith("followup", "What would you like to do?", true) }) - it("should handle partial with question from params", async () => { + it("should handle partial with multiple questions", async () => { const block: ToolUse<"ask_followup_question"> = { type: "tool_use", name: "ask_followup_question", - params: { - question: "Choose wisely", - }, + params: {}, partial: true, + nativeArgs: { + questions: ["Question 1", "Question 2"], + follow_up: [], + }, } await askFollowupQuestionTool.handle(mockCline, block, { @@ -171,10 +234,11 @@ describe("askFollowupQuestionTool", () => { handleError: vi.fn(), pushToolResult: mockPushToolResult, removeClosingTag: vi.fn((tag, content) => content || ""), - toolProtocol: "xml", + toolProtocol: "native", }) - expect(mockCline.ask).toHaveBeenCalledWith("followup", "Choose wisely", true) + // Should show first question during streaming + expect(mockCline.ask).toHaveBeenCalledWith("followup", "Question 1", true) }) }) @@ -184,34 +248,33 @@ describe("askFollowupQuestionTool", () => { NativeToolCallParser.clearRawChunkState() }) - it("should build nativeArgs with question and follow_up during streaming", () => { + it("should build nativeArgs with questions and follow_up during streaming", () => { // Start a streaming tool call NativeToolCallParser.startStreamingToolCall("call_123", "ask_followup_question") // Simulate streaming JSON chunks - const chunk1 = '{"question":"What would you like?","follow_up":[{"text":"Option 1","mode":"code"}' + const chunk1 = '{"questions":["What would you like?"],"follow_up":[{"text":"Option 1","mode":"code"}' const result1 = NativeToolCallParser.processStreamingChunk("call_123", chunk1) expect(result1).not.toBeNull() expect(result1?.name).toBe("ask_followup_question") - expect(result1?.params.question).toBe("What would you like?") expect(result1?.nativeArgs).toBeDefined() // Use type assertion to access the specific fields const nativeArgs = result1?.nativeArgs as { - question: string + questions: string[] follow_up?: Array<{ text: string; mode?: string }> } - expect(nativeArgs?.question).toBe("What would you like?") + expect(nativeArgs?.questions).toEqual(["What would you like?"]) // partial-json should parse the incomplete array expect(nativeArgs?.follow_up).toBeDefined() }) - it("should finalize with complete nativeArgs", () => { + it("should finalize with complete nativeArgs including complex questions", () => { NativeToolCallParser.startStreamingToolCall("call_456", "ask_followup_question") // Add complete JSON const completeJson = - '{"question":"Choose an option","follow_up":[{"text":"Yes","mode":"code"},{"text":"No","mode":null}]}' + '{"questions":[{"text":"Framework?","options":["React","Vue"]},"Name?"],"follow_up":[{"text":"Yes","mode":"code"},{"text":"No","mode":"architect"}]}' NativeToolCallParser.processStreamingChunk("call_456", completeJson) const result = NativeToolCallParser.finalizeStreamingToolCall("call_456") @@ -223,10 +286,10 @@ describe("askFollowupQuestionTool", () => { // Type guard: regular tools have type 'tool_use', MCP tools have type 'mcp_tool_use' if (result?.type === "tool_use") { expect(result.nativeArgs).toEqual({ - question: "Choose an option", + questions: [{ text: "Framework?", options: ["React", "Vue"] }, "Name?"], follow_up: [ { text: "Yes", mode: "code" }, - { text: "No", mode: null }, + { text: "No", mode: "architect" }, ], }) } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 602ebd785e..1f51af73e2 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -98,7 +98,7 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } ask_followup_question: { - question: string + questions: Array follow_up: Array<{ text: string; mode?: string }> } browser_action: BrowserActionParams diff --git a/webview-ui/src/components/chat/MultiQuestionHandler.tsx b/webview-ui/src/components/chat/MultiQuestionHandler.tsx index 38eb4fbf70..7f1c21a945 100644 --- a/webview-ui/src/components/chat/MultiQuestionHandler.tsx +++ b/webview-ui/src/components/chat/MultiQuestionHandler.tsx @@ -1,82 +1,181 @@ -import React, { useState, useEffect } from "react" -import { Button, Textarea } from "@/components/ui" +import { useState, useEffect } from "react" +import { Button, AutosizeTextarea } from "@/components/ui" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" + +interface Question { + text: string + options?: string[] +} interface MultiQuestionHandlerProps { - questions: string[] + questions: Array onSendResponse: (response: string) => void } +interface QuestionItemProps { + question: string | Question + title: string + textValue: string + selectedOption?: string + onTextChange: (value: string) => void + onOptionClick: (option: string) => void +} + +const QuestionItem = ({ + question, + title, + textValue, + selectedOption, + onTextChange, + onOptionClick, +}: QuestionItemProps) => { + const { t } = useAppTranslation() + const qText = typeof question === "string" ? question : question.text + const options = typeof question === "string" ? undefined : question.options + + return ( +
+
{title}
+
{qText}
+ {options && options.length > 0 && ( +
+ {options.map((option, idx) => ( + + ))} +
+ )} + onTextChange(e.target.value)} + minHeight={21} + maxHeight={200} + placeholder={t("chat:questions.typeAnswer")} + className="w-full py-2 pl-3 pr-3 rounded border border-transparent" + /> +
+ ) +} + export const MultiQuestionHandler = ({ questions, onSendResponse }: MultiQuestionHandlerProps) => { const { t } = useAppTranslation() + const { showQuestionsOneByOne } = useExtensionState() const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) - const [answers, setAnswers] = useState(new Array(questions.length).fill("")) - const [inputValue, setInputValue] = useState("") + const [selectedOptions, setSelectedOptions] = useState<(string | undefined)[]>( + new Array(questions.length).fill(undefined), + ) + const [textAnswers, setTextAnswers] = useState(new Array(questions.length).fill("")) + const [oneByOneInputValue, setOneByOneInputValue] = useState("") useEffect(() => { - setInputValue(answers[currentQuestionIndex] || "") - }, [currentQuestionIndex, answers]) + if (showQuestionsOneByOne) { + setOneByOneInputValue(textAnswers[currentQuestionIndex] || "") + } + }, [currentQuestionIndex, textAnswers, showQuestionsOneByOne]) + + const updateTextAnswer = (index: number, value: string) => { + const next = [...textAnswers] + next[index] = value + setTextAnswers(next) + } const handleNext = () => { - const newAnswers = [...answers] - newAnswers[currentQuestionIndex] = inputValue - setAnswers(newAnswers) - - if (currentQuestionIndex < questions.length - 1) { - setCurrentQuestionIndex(currentQuestionIndex + 1) - } + updateTextAnswer(currentQuestionIndex, oneByOneInputValue) + if (currentQuestionIndex < questions.length - 1) setCurrentQuestionIndex(currentQuestionIndex + 1) } const handlePrevious = () => { - const newAnswers = [...answers] - newAnswers[currentQuestionIndex] = inputValue - setAnswers(newAnswers) + updateTextAnswer(currentQuestionIndex, oneByOneInputValue) + if (currentQuestionIndex > 0) setCurrentQuestionIndex(currentQuestionIndex - 1) + } - if (currentQuestionIndex > 0) { - setCurrentQuestionIndex(currentQuestionIndex - 1) - } + const handleOptionClick = (index: number, option: string) => { + setSelectedOptions((prev) => { + const next = [...prev] + next[index] = next[index] === option ? undefined : option + return next + }) } const handleFinish = () => { - const newAnswers = [...answers] - newAnswers[currentQuestionIndex] = inputValue - setAnswers(newAnswers) + let finalAnswers = textAnswers + if (showQuestionsOneByOne) { + finalAnswers = [...textAnswers] + finalAnswers[currentQuestionIndex] = oneByOneInputValue + } - const combined = questions.map((q, i) => `Question: ${q}\nAnswer: ${newAnswers[i] || "(skipped)"}`).join("\n\n") + const combined = questions + .map((q, i) => { + const qText = typeof q === "string" ? q : q.text + const text = finalAnswers[i].trim() + const option = selectedOptions[i] + const answer = option && text ? `${option}: ${text}` : option || text || "(skipped)" + return `Question: ${qText}\nAnswer: ${answer}` + }) + .join("\n\n") onSendResponse(combined) } - return ( -
-
- {t("chat:questions.questionNumberOfTotal", { - current: currentQuestionIndex + 1, - total: questions.length, - })} + if (showQuestionsOneByOne) { + return ( +
+ handleOptionClick(currentQuestionIndex, opt)} + /> +
+ {currentQuestionIndex > 0 && ( + + )} + +
-
{questions[currentQuestionIndex]}
-