feat: implement multi-question support with options and UI enhancements

This commit is contained in:
ScDor 2026-01-31 13:40:25 +02:00
parent 08aa544b6a
commit 5521e0df82
15 changed files with 420 additions and 114 deletions

View file

@ -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<typeof globalSettingsSchema>
@ -364,6 +370,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
mode: "code", // "architect",
customModes: [],
showQuestionsOneByOne: false,
}
export const EVALS_TIMEOUT = 5 * 60 * 1_000

View file

@ -260,6 +260,7 @@ export type ExtensionState = Pick<
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
| "showQuestionsOneByOne"
| "maxGitStatusFiles"
| "requestDelaySeconds"
> & {

View file

@ -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<TName>
}

View file

@ -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,
},
},

View file

@ -10,9 +10,13 @@ interface Suggestion {
mode?: string
}
interface Question {
text: string
options?: string[]
}
interface AskFollowupQuestionParams {
question: string
questions?: string[]
questions: Array<string | Question>
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<string | Question> = []
if (questions_xml) {
try {
// Handle both simple <question> tags and more complex <question> 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<void> {
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(`<answer>\n${text}\n</answer>`, 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<void> {
// 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(() => {})
}
}

View file

@ -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" },
],
})
}

View file

@ -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<string | { text: string; options?: string[] }>
follow_up: Array<{ text: string; mode?: string }>
}
browser_action: BrowserActionParams

View file

@ -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<string | Question>
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 (
<div className="flex flex-col gap-3">
<div className="font-bold">{title}</div>
<div>{qText}</div>
{options && options.length > 0 && (
<div className="flex flex-wrap gap-2">
{options.map((option, idx) => (
<button
key={idx}
onClick={() => onOptionClick(option)}
className={`px-3 py-1.5 rounded text-sm transition-colors border ${
selectedOption === option
? "bg-vscode-button-background text-vscode-button-foreground border-vscode-button-background"
: "bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground border-vscode-button-secondaryHoverBackground hover:bg-vscode-button-secondaryHoverBackground"
}`}>
{option}
</button>
))}
</div>
)}
<AutosizeTextarea
value={textValue}
onChange={(e) => 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"
/>
</div>
)
}
export const MultiQuestionHandler = ({ questions, onSendResponse }: MultiQuestionHandlerProps) => {
const { t } = useAppTranslation()
const { showQuestionsOneByOne } = useExtensionState()
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
const [answers, setAnswers] = useState<string[]>(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<string[]>(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 (
<div className="flex flex-col gap-3">
<div className="font-bold">
{t("chat:questions.questionNumberOfTotal", {
current: currentQuestionIndex + 1,
total: questions.length,
})}
if (showQuestionsOneByOne) {
return (
<div className="flex flex-col gap-3">
<QuestionItem
question={questions[currentQuestionIndex]}
title={t("chat:questions.questionNumberOfTotal", {
current: currentQuestionIndex + 1,
total: questions.length,
})}
textValue={oneByOneInputValue}
selectedOption={selectedOptions[currentQuestionIndex]}
onTextChange={setOneByOneInputValue}
onOptionClick={(opt) => handleOptionClick(currentQuestionIndex, opt)}
/>
<div className="flex gap-2">
{currentQuestionIndex > 0 && (
<Button variant="secondary" onClick={handlePrevious}>
{t("chat:questions.previous")}
</Button>
)}
<Button
variant="primary"
onClick={currentQuestionIndex < questions.length - 1 ? handleNext : handleFinish}>
{t(
currentQuestionIndex < questions.length - 1
? "chat:questions.next"
: "chat:questions.finish",
)}
</Button>
</div>
</div>
<div>{questions[currentQuestionIndex]}</div>
<Textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
rows={3}
placeholder={t("chat:questions.typeAnswer")}
className="w-full"
/>
<div className="flex gap-2">
{currentQuestionIndex > 0 && (
<Button variant="secondary" onClick={handlePrevious}>
{t("chat:questions.previous")}
</Button>
)}
{currentQuestionIndex < questions.length - 1 ? (
<Button variant="primary" onClick={handleNext}>
{t("chat:questions.next")}
</Button>
) : (
<Button variant="primary" onClick={handleFinish}>
{t("chat:questions.finish")}
</Button>
)}
)
}
return (
<div className="flex flex-col gap-6">
{questions.map((q, i) => (
<QuestionItem
key={i}
question={q}
title={t("chat:questions.questionNumber", { number: i + 1 })}
textValue={textAnswers[i]}
selectedOption={selectedOptions[i]}
onTextChange={(val) => updateTextAnswer(i, val)}
onOptionClick={(opt) => handleOptionClick(i, opt)}
/>
))}
<div className="flex justify-end">
<Button variant="primary" onClick={handleFinish}>
{t("chat:questions.finish")}
</Button>
</div>
</div>
)

View file

@ -7,6 +7,7 @@ import { MultiQuestionHandler } from "../MultiQuestionHandler"
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
language: "en",
showQuestionsOneByOne: true,
}),
}))
@ -19,6 +20,9 @@ vi.mock("react-i18next", () => ({
if (key === "chat:questions.questionNumberOfTotal" && options) {
return `Question ${options.current} of ${options.total}`
}
if (key === "chat:questions.questionNumber" && options) {
return `Question ${options.number}`
}
if (key === "chat:questions.typeAnswer") return "Type your answer..."
if (key === "chat:questions.previous") return "Previous"
if (key === "chat:questions.next") return "Next"
@ -38,6 +42,9 @@ vi.mock("@src/i18n/setup", () => ({
if (key === "chat:questions.questionNumberOfTotal" && options) {
return `Question ${options.current} of ${options.total}`
}
if (key === "chat:questions.questionNumber" && options) {
return `Question ${options.number}`
}
if (key === "chat:questions.typeAnswer") return "Type your answer..."
if (key === "chat:questions.previous") return "Previous"
if (key === "chat:questions.next") return "Next"
@ -192,4 +199,44 @@ describe("MultiQuestionHandler", () => {
expect(mockOnSendResponse).not.toHaveBeenCalled()
})
it("should handle options correctly without copying to text area", () => {
const questions = [{ text: "Color?", options: ["Red", "Blue"] }]
render(
<TestWrapper>
<MultiQuestionHandler questions={questions} onSendResponse={mockOnSendResponse} />
</TestWrapper>,
)
const redButton = screen.getByText("Red")
fireEvent.click(redButton)
const textarea = screen.getByPlaceholderText("Type your answer...")
expect(textarea).toHaveValue("")
const finishButton = screen.getByText("Finish")
fireEvent.click(finishButton)
expect(mockOnSendResponse).toHaveBeenCalledWith("Question: Color?\nAnswer: Red")
})
it("should handle both option and text answer", () => {
const questions = [{ text: "Color?", options: ["Red", "Blue"] }]
render(
<TestWrapper>
<MultiQuestionHandler questions={questions} onSendResponse={mockOnSendResponse} />
</TestWrapper>,
)
const redButton = screen.getByText("Red")
fireEvent.click(redButton)
const textarea = screen.getByPlaceholderText("Type your answer...")
fireEvent.change(textarea, { target: { value: "very dark" } })
const finishButton = screen.getByText("Finish")
fireEvent.click(finishButton)
expect(mockOnSendResponse).toHaveBeenCalledWith("Question: Color?\nAnswer: Red: very dark")
})
})

View file

@ -214,6 +214,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
showQuestionsOneByOne,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -426,6 +427,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
enterBehavior: enterBehavior ?? "send",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
showQuestionsOneByOne: showQuestionsOneByOne ?? false,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
profileThresholds,
imageGenerationProvider,
@ -912,6 +914,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
enterBehavior={enterBehavior ?? "send"}
showQuestionsOneByOne={showQuestionsOneByOne ?? false}
setCachedStateField={setCachedStateField}
/>
)}

View file

@ -12,12 +12,14 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
reasoningBlockCollapsed: boolean
enterBehavior: "send" | "newline"
showQuestionsOneByOne: boolean
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}
export const UISettings = ({
reasoningBlockCollapsed,
enterBehavior,
showQuestionsOneByOne,
setCachedStateField,
...props
}: UISettingsProps) => {
@ -48,6 +50,15 @@ export const UISettings = ({
})
}
const handleShowQuestionsOneByOneChange = (value: boolean) => {
setCachedStateField("showQuestionsOneByOne", value)
// Track telemetry event
telemetryClient.capture("ui_settings_show_questions_one_by_one_changed", {
enabled: value,
})
}
return (
<div {...props}>
<SectionHeader>{t("settings:sections.ui")}</SectionHeader>
@ -91,6 +102,24 @@ export const UISettings = ({
</div>
</div>
</SearchableSetting>
{/* Show Questions One By One Setting */}
<SearchableSetting
settingId="ui-show-questions-one-by-one"
section="ui"
label={t("settings:ui.showQuestionsOneByOne.label")}>
<div className="flex flex-col gap-1">
<VSCodeCheckbox
checked={showQuestionsOneByOne}
onChange={(e: any) => handleShowQuestionsOneByOneChange(e.target.checked)}
data-testid="show-questions-one-by-one-checkbox">
<span className="font-medium">{t("settings:ui.showQuestionsOneByOne.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.showQuestionsOneByOne.description")}
</div>
</div>
</SearchableSetting>
</div>
</Section>
</div>

View file

@ -6,6 +6,7 @@ describe("UISettings", () => {
const defaultProps = {
reasoningBlockCollapsed: false,
enterBehavior: "send" as const,
showQuestionsOneByOne: false,
setCachedStateField: vi.fn(),
}

View file

@ -91,7 +91,7 @@ export const AutosizeTextarea = React.forwardRef<AutosizeTextAreaRef, AutosizeTe
value={value}
ref={textAreaRef}
className={cn(
"flex w-full rounded-xs ring-offset-background placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50 scrollbar-hide",
"flex w-full rounded ring-offset-background placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50 scrollbar-hide",
"border-[var(--vscode-input-border,var(--vscode-input-background))] focus-visible:border-vscode-focusBorder",
"bg-vscode-input-background",
"text-vscode-input-foreground",

View file

@ -167,6 +167,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setIncludeCurrentTime: (value: boolean) => void
includeCurrentCost?: boolean
setIncludeCurrentCost: (value: boolean) => void
showQuestionsOneByOne?: boolean
setShowQuestionsOneByOne: (value: boolean) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -279,6 +281,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
openRouterImageGenerationSelectedModel: "",
includeCurrentTime: true,
includeCurrentCost: true,
showQuestionsOneByOne: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
@ -301,6 +304,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [prevCloudIsAuthenticated, setPrevCloudIsAuthenticated] = useState(false)
const [includeCurrentTime, setIncludeCurrentTime] = useState(true)
const [includeCurrentCost, setIncludeCurrentCost] = useState(true)
const [showQuestionsOneByOne, setShowQuestionsOneByOne] = useState(false)
const setListApiConfigMeta = useCallback(
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
@ -346,6 +350,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
if ((newState as any).includeCurrentCost !== undefined) {
setIncludeCurrentCost((newState as any).includeCurrentCost)
}
// Update showQuestionsOneByOne if present in state message
if ((newState as any).showQuestionsOneByOne !== undefined) {
setShowQuestionsOneByOne((newState as any).showQuestionsOneByOne)
}
// Handle marketplace data if present in state message
if (newState.marketplaceItems !== undefined) {
setMarketplaceItems(newState.marketplaceItems)
@ -596,6 +604,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setIncludeCurrentTime,
includeCurrentCost,
setIncludeCurrentCost,
showQuestionsOneByOne,
setShowQuestionsOneByOne,
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -288,6 +288,7 @@
},
"questions": {
"hasQuestion": "Roo has a question",
"questionNumber": "Question {{number}}",
"questionNumberOfTotal": "Question {{current}} of {{total}}",
"typeAnswer": "Type your answer here...",
"previous": "Previous",