diff --git a/apps/web-roo-code/src/app/pricing/page.tsx b/apps/web-roo-code/src/app/pricing/page.tsx
index a46b5c67cc5..6851b47b6a1 100644
--- a/apps/web-roo-code/src/app/pricing/page.tsx
+++ b/apps/web-roo-code/src/app/pricing/page.tsx
@@ -291,11 +291,7 @@ export default function PricingPage() {
To pay for Cloud Agents running time (${PRICE_CREDITS}/hour)
To pay for AI model inference costs (
-
+
varies by model
)
diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md
index fe985d7ec89..b872f1316ad 100644
--- a/locales/zh-TW/README.md
+++ b/locales/zh-TW/README.md
@@ -6,15 +6,15 @@
- 快速取得協助 → 加入 Discord • 偏好非同步溝通?→ 加入 r/RooCode
+ 快速取得協助 → 加入 Discord • 偏好非同步?→ 加入 r/RooCode
# Roo Code
-> 您的 AI 驅動開發團隊,就在您的編輯器中
+> 你的 AI 驅動開發團隊,就在你的編輯器裡
- 🌐 支援語言
+ 🌐 可用語言
- [English](../../README.md)
- [Català](../ca/README.md)
@@ -35,7 +35,7 @@
- [简体中文](../zh-CN/README.md)
- [繁體中文](../zh-TW/README.md)
- ...
-
+
---
@@ -51,14 +51,14 @@
## 模式
-Roo Code 會配合您的工作方式,而非要您配合它:
+Roo Code 適應您的工作方式,而不是相反:
-- 程式碼模式:日常開發、編輯和檔案操作
+- 程式碼模式:日常編碼、編輯和檔案操作
- 架構師模式:規劃系統、規格和遷移
- 詢問模式:快速回答、解釋和文件
-- 偵錯模式:追蹤問題、新增日誌、鎖定根本原因
+- 偵錯模式:追蹤問題、新增日誌、隔離根本原因
- 自訂模式:為您的團隊或工作流程建置專門的模式
-- Roomote Control:Roomote Control 讓您能遠端控制在本機 VS Code 執行個體中運行的工作。
+- Roomote Control:Roomote Control 讓你能遠端控制在本機 VS Code 執行個體中運行的工作。
更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
@@ -82,12 +82,12 @@ Roo Code 會配合您的工作方式,而非要您配合它:
- **[YouTube 頻道](https://youtube.com/@roocodeyt?feature=shared):** 觀看教學和功能實際操作。
- **[Discord 伺服器](https://discord.gg/roocode):** 加入社群以獲得即時協助和討論。
- **[Reddit 社群](https://www.reddit.com/r/RooCode):** 分享您的經驗,看看其他人正在建立什麼。
-- **[GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues):** 回報問題並追蹤開發進度。
+- **[GitHub 問題](https://github.com/RooCodeInc/Roo-Code/issues):** 回報錯誤並追蹤開發進度。
- **[功能請求](https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop):** 有想法嗎?與開發人員分享。
---
-## 本機設定與開發
+## 本地設定與開發
1. **複製**儲存庫:
@@ -95,7 +95,7 @@ Roo Code 會配合您的工作方式,而非要您配合它:
git clone https://github.com/RooCodeInc/Roo-Code.git
```
-2. **安裝相依套件**:
+2. **安裝依賴套件**:
```sh
pnpm install
@@ -107,7 +107,7 @@ pnpm install
### 開發模式(F5)
-若要進行開發,請使用 VSCode 的內建偵錯功能:
+對於積極的開發,請使用 VSCode 的內建偵錯功能:
在 VSCode 中按 `F5`(或前往 **執行** → **開始偵錯**)。這將在執行 Roo Code 擴充功能的新 VSCode 視窗中開啟。
@@ -127,7 +127,7 @@ pnpm install:vsix [-y] [--editor=]
- 詢問要使用的編輯器命令(code/cursor/code-insiders) - 預設為“code”
- 解除安裝任何現有版本的擴充功能。
- 建置最新的 VSIX 套件。
-- 安裝新建置的 VSIX。
+- 安裝新建立的 VSIX。
- 提示您重新啟動 VS Code 以使變更生效。
選項:
@@ -144,7 +144,7 @@ pnpm install:vsix [-y] [--editor=]
pnpm vsix
```
2. 將在 `bin/` 目錄中產生一個 `.vsix` 檔案(例如 `bin/roo-cline-.vsix`)。
-3. 使用 VSCode CLI 手動安裝:
+3. 使用 VSCode CLI 手動安裝
```sh
code --install-extension bin/roo-cline-.vsix
```
@@ -163,7 +163,7 @@ pnpm install:vsix [-y] [--editor=]
## 貢獻
-我們歡迎社群貢獻!請從閱讀我們的 [CONTRIBUTING.md](CONTRIBUTING.md) 開始。
+我們歡迎社群貢獻!請閱讀我們的 [CONTRIBUTING.md](CONTRIBUTING.md) 開始。
---
@@ -173,4 +173,4 @@ pnpm install:vsix [-y] [--editor=]
---
-**享受 Roo Code!** 無論您是想嚴格控管它,還是讓它自主運作,我們都迫不及待地想看看您會打造些什麼。如果您有問題或功能想法,請造訪我們的 [Reddit 社群](https://www.reddit.com/r/RooCode/)或 [Discord](https://discord.gg/roocode)。祝您開發愉快!
+**享受 Roo Code!** 無論您是將它拴在短繩上還是讓它自主漫遊,我們迫不及待地想看看您會建構什麼。如果您有問題或功能想法,請造訪我們的 [Reddit 社群](https://www.reddit.com/r/RooCode/)或 [Discord](https://discord.gg/roocode)。祝您開發愉快!
diff --git a/packages/types/src/followup.ts b/packages/types/src/followup.ts
index 1a5424cd11e..4d7047665f2 100644
--- a/packages/types/src/followup.ts
+++ b/packages/types/src/followup.ts
@@ -8,6 +8,8 @@ import { z } from "zod"
export interface FollowUpData {
/** The question being asked by the LLM */
question?: string
+ /** Array of questions being asked by the LLM */
+ questions?: FollowUpQuestion[]
/** Array of suggested answers that the user can select */
suggest?: Array
}
@@ -22,6 +24,12 @@ export interface SuggestionItem {
mode?: string
}
+/**
+ * Type definition for a follow-up question
+ * Can be a simple string or an object with text and options
+ */
+export type FollowUpQuestion = string | { text: string; options?: string[] }
+
/**
* Zod schema for SuggestionItem
*/
@@ -30,11 +38,23 @@ export const suggestionItemSchema = z.object({
mode: z.string().optional(),
})
+/**
+ * Zod schema for FollowUpQuestion
+ */
+export const followUpQuestionSchema = z.union([
+ z.string(),
+ z.object({
+ text: z.string(),
+ options: z.array(z.string()).optional(),
+ }),
+])
+
/**
* Zod schema for FollowUpData
*/
export const followUpDataSchema = z.object({
question: z.string().optional(),
+ questions: z.array(followUpQuestionSchema).optional(),
suggest: z.array(suggestionItemSchema).optional(),
})
diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts
index 11b9fe148d1..f71b923d6a8 100644
--- a/packages/types/src/global-settings.ts
+++ b/packages/types/src/global-settings.ts
@@ -222,6 +222,12 @@ export const globalSettingsSchema = z.object({
lastTaskExportPath: z.string().optional(),
lastImageSavePath: z.string().optional(),
+ /**
+ * Whether to show multiple questions one by one or all at once.
+ * @default false (all at once)
+ */
+ showQuestionsOneByOne: z.boolean().optional(),
+
/**
* Path to worktree to auto-open after switching workspaces.
* Used by the worktree feature to open the Roo Code sidebar in a new window.
@@ -392,6 +398,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 21bc59092a1..f34102e22cf 100644
--- a/packages/types/src/vscode-extension-host.ts
+++ b/packages/types/src/vscode-extension-host.ts
@@ -331,6 +331,7 @@ export type ExtensionState = Pick<
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
+ | "showQuestionsOneByOne"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts
index 72c34f94a07..b1322c65bfb 100644
--- a/src/core/assistant-message/NativeToolCallParser.ts
+++ b/src/core/assistant-message/NativeToolCallParser.ts
@@ -475,9 +475,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,
}
}
@@ -818,9 +818,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 b0591206ade..45465962926 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 010a6240f1e..08ae0499e57 100644
--- a/src/core/tools/AskFollowupQuestionTool.ts
+++ b/src/core/tools/AskFollowupQuestionTool.ts
@@ -9,8 +9,13 @@ interface Suggestion {
mode?: string
}
+interface Question {
+ text: string
+ options?: string[]
+}
+
interface AskFollowupQuestionParams {
- question: string
+ questions: Array
follow_up: Suggestion[]
}
@@ -18,26 +23,26 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
readonly name = "ask_followup_question" as const
async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise {
- const { question, follow_up } = params
+ const { questions, follow_up } = params
const { handleError, pushToolResult } = callbacks
try {
- if (!question) {
+ 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) {
@@ -46,11 +51,17 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
}
override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise {
- const question: string | undefined = block.nativeArgs?.question ?? block.params.question
+ // Get question from params or nativeArgs
+ const questions = block.nativeArgs?.questions ?? []
+ const firstQuestion = questions[0]
+ const multiQuestionText = typeof firstQuestion === "string" ? firstQuestion : firstQuestion?.text
+ const singleQuestionText = (block.nativeArgs as any)?.question ?? block.params.question
+
+ const questionText = multiQuestionText ?? singleQuestionText
// 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)
- await task.ask("followup", question ?? "", block.partial).catch(() => {})
+ // The full JSON with suggestions will be sent when the tool call is complete
+ await task.ask("followup", questionText ?? "", block.partial).catch(() => {})
}
}
diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts
index e13f639ba00..858b03706f3 100644
--- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts
+++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts
@@ -29,7 +29,7 @@ describe("askFollowupQuestionTool", () => {
question: "What would you like to do?",
},
nativeArgs: {
- question: "What would you like to do?",
+ questions: ["What would you like to do?"],
follow_up: [{ text: "Option 1" }, { text: "Option 2" }],
},
partial: false,
@@ -56,7 +56,7 @@ describe("askFollowupQuestionTool", () => {
question: "What would you like to do?",
},
nativeArgs: {
- question: "What would you like to do?",
+ questions: ["What would you like to do?"],
follow_up: [
{ text: "Write code", mode: "code" },
{ text: "Debug issue", mode: "debug" },
@@ -88,7 +88,7 @@ describe("askFollowupQuestionTool", () => {
question: "What would you like to do?",
},
nativeArgs: {
- question: "What would you like to do?",
+ questions: ["What would you like to do?"],
follow_up: [{ text: "Regular option" }, { text: "Plan architecture", mode: "architect" }],
},
partial: false,
@@ -109,18 +109,75 @@ 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,
+ })
+
+ 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,
+ })
+
+ 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" },
+ ],
},
}
@@ -130,18 +187,20 @@ describe("askFollowupQuestionTool", () => {
pushToolResult: mockPushToolResult,
})
- // 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, {
@@ -150,7 +209,8 @@ describe("askFollowupQuestionTool", () => {
pushToolResult: mockPushToolResult,
})
- expect(mockCline.ask).toHaveBeenCalledWith("followup", "Choose wisely", true)
+ // Should show first question during streaming
+ expect(mockCline.ask).toHaveBeenCalledWith("followup", "Question 1", true)
})
})
@@ -160,34 +220,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")
@@ -199,10 +258,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 570f55c4f2f..e9c94e72bf4 100644
--- a/src/shared/tools.ts
+++ b/src/shared/tools.ts
@@ -7,6 +7,7 @@ import type {
ToolName,
BrowserActionParams,
GenerateImageParams,
+ FollowUpQuestion,
} from "@roo-code/types"
export type ToolResponse = string | Array
@@ -72,6 +73,8 @@ export const toolParamNames = [
"old_string", // search_replace and edit_file parameter
"new_string", // search_replace and edit_file parameter
"expected_replacements", // edit_file parameter for multiple occurrences
+ "expected_replacements", // edit_file parameter for multiple occurrences
+ "questions", // ask_followup_question parameter for multiple questions
"artifact_id", // read_command_output parameter
"search", // read_command_output parameter for grep-like search
"offset", // read_command_output and read_file parameter
@@ -108,7 +111,7 @@ export type NativeToolArgs = {
list_files: { path: string; recursive?: boolean }
new_task: { mode: string; message: string; todos?: string }
ask_followup_question: {
- question: string
+ questions: FollowUpQuestion[]
follow_up: Array<{ text: string; mode?: string }>
}
browser_action: BrowserActionParams
@@ -235,7 +238,7 @@ export interface AccessMcpResourceToolUse extends ToolUse<"access_mcp_resource">
export interface AskFollowupQuestionToolUse extends ToolUse<"ask_followup_question"> {
name: "ask_followup_question"
- params: Partial, "question" | "follow_up">>
+ params: Partial, "question" | "follow_up" | "questions">>
}
export interface AttemptCompletionToolUse extends ToolUse<"attempt_completion"> {
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index 74639ff5ac5..aa63f04d88f 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -39,6 +39,7 @@ import McpResourceRow from "../mcp/McpResourceRow"
import { Mention } from "./Mention"
import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
import { FollowUpSuggest } from "./FollowUpSuggest"
+import { MultiQuestionHandler } from "./MultiQuestionHandler"
import { BatchFilePermission } from "./BatchFilePermission"
import { BatchDiffApproval } from "./BatchDiffApproval"
import { ProgressIndicator } from "./ProgressIndicator"
@@ -1726,17 +1727,28 @@ export const ChatRowContent = ({
)}
-
-
+ {followUpData?.questions && followUpData.questions.length > 0 ? (
+ {
+ onSuggestionClick?.({ answer: response })
+ }}
+ />
+ ) : (
+ <>
+
+
+ >
+ )}
>
)
diff --git a/webview-ui/src/components/chat/MultiQuestionHandler.tsx b/webview-ui/src/components/chat/MultiQuestionHandler.tsx
new file mode 100644
index 00000000000..dea9da65d0c
--- /dev/null
+++ b/webview-ui/src/components/chat/MultiQuestionHandler.tsx
@@ -0,0 +1,178 @@
+import { useState, useEffect } from "react"
+import { Button, AutosizeTextarea } from "@/components/ui"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
+import { useExtensionState } from "@src/context/ExtensionStateContext"
+import { FollowUpQuestion } from "@roo-code/types"
+
+interface MultiQuestionHandlerProps {
+ questions: FollowUpQuestion[]
+ onSendResponse: (response: string) => void
+}
+
+interface QuestionItemProps {
+ question: FollowUpQuestion
+ 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) => (
+ 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}
+
+ ))}
+
+ )}
+
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 [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(() => {
+ if (showQuestionsOneByOne) {
+ setOneByOneInputValue(textAnswers[currentQuestionIndex] || "")
+ }
+ }, [currentQuestionIndex, textAnswers, showQuestionsOneByOne])
+
+ const updateTextAnswer = (index: number, value: string) => {
+ const next = [...textAnswers]
+ next[index] = value
+ setTextAnswers(next)
+ }
+
+ const handleNext = () => {
+ updateTextAnswer(currentQuestionIndex, oneByOneInputValue)
+ if (currentQuestionIndex < questions.length - 1) setCurrentQuestionIndex(currentQuestionIndex + 1)
+ }
+
+ const handlePrevious = () => {
+ updateTextAnswer(currentQuestionIndex, oneByOneInputValue)
+ 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 = () => {
+ let finalAnswers = textAnswers
+ if (showQuestionsOneByOne) {
+ finalAnswers = [...textAnswers]
+ finalAnswers[currentQuestionIndex] = oneByOneInputValue
+ }
+
+ 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)
+ }
+
+ if (showQuestionsOneByOne) {
+ return (
+
+
handleOptionClick(currentQuestionIndex, opt)}
+ />
+
+ {currentQuestionIndex > 0 && (
+
+ {t("chat:questions.previous")}
+
+ )}
+
+ {t(
+ currentQuestionIndex < questions.length - 1
+ ? "chat:questions.next"
+ : "chat:questions.finish",
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+ {questions.map((q, i) => (
+
updateTextAnswer(i, val)}
+ onOptionClick={(opt) => handleOptionClick(i, opt)}
+ />
+ ))}
+
+
+ {t("chat:questions.finish")}
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/chat/__tests__/MultiQuestionHandler.spec.tsx b/webview-ui/src/components/chat/__tests__/MultiQuestionHandler.spec.tsx
new file mode 100644
index 00000000000..d18163e139c
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/MultiQuestionHandler.spec.tsx
@@ -0,0 +1,242 @@
+import { render, screen, fireEvent } from "@/utils/test-utils"
+import { vi, describe, it, expect, beforeEach } from "vitest"
+import TranslationProvider from "@src/i18n/TranslationContext"
+import { MultiQuestionHandler } from "../MultiQuestionHandler"
+
+// Mock ExtensionStateContext
+vi.mock("@src/context/ExtensionStateContext", () => ({
+ useExtensionState: () => ({
+ language: "en",
+ showQuestionsOneByOne: true,
+ }),
+}))
+
+// Mock react-i18next
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ i18n: {
+ t: (key: string, options?: Record) => {
+ // Mock specific translations used in tests
+ 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"
+ if (key === "chat:questions.finish") return "Finish"
+ return key
+ },
+ changeLanguage: vi.fn(),
+ },
+ }),
+}))
+
+// Mock translations
+vi.mock("@src/i18n/setup", () => ({
+ default: {
+ t: (key: string, options?: Record) => {
+ // Mock specific translations used in tests
+ 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"
+ if (key === "chat:questions.finish") return "Finish"
+ return key
+ },
+ changeLanguage: vi.fn(),
+ },
+ loadTranslations: vi.fn(),
+}))
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe("MultiQuestionHandler", () => {
+ const mockOnSendResponse = vi.fn()
+
+ beforeEach(() => {
+ mockOnSendResponse.mockClear()
+ })
+
+ it("should render single question correctly", () => {
+ const questions = ["What is your name?"]
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText("Question 1 of 1")).toBeInTheDocument()
+ expect(screen.getByText("What is your name?")).toBeInTheDocument()
+ expect(screen.getByText("Finish")).toBeInTheDocument()
+ })
+
+ it("should render multiple questions with navigation", () => {
+ const questions = ["What is your name?", "What is your age?"]
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText("Question 1 of 2")).toBeInTheDocument()
+ expect(screen.getByText("What is your name?")).toBeInTheDocument()
+ expect(screen.getByText("Next")).toBeInTheDocument()
+ expect(screen.queryByText("Previous")).not.toBeInTheDocument()
+ expect(screen.queryByText("Finish")).not.toBeInTheDocument()
+ })
+
+ it("should navigate between questions without sending responses", () => {
+ const questions = ["What is your name?", "What is your age?"]
+ render(
+
+
+ ,
+ )
+
+ const textarea = screen.getByPlaceholderText("Type your answer...")
+ fireEvent.change(textarea, { target: { value: "John" } })
+
+ const nextButton = screen.getByText("Next")
+ fireEvent.click(nextButton)
+
+ expect(screen.getByText("Question 2 of 2")).toBeInTheDocument()
+ expect(screen.getByText("What is your age?")).toBeInTheDocument()
+ expect(mockOnSendResponse).not.toHaveBeenCalled()
+
+ const previousButton = screen.getByText("Previous")
+ fireEvent.click(previousButton)
+
+ expect(screen.getByText("Question 1 of 2")).toBeInTheDocument()
+ expect(screen.getByText("What is your name?")).toBeInTheDocument()
+ expect(textarea).toHaveValue("John")
+ expect(mockOnSendResponse).not.toHaveBeenCalled()
+ })
+
+ it("should only send response when Finish is clicked", () => {
+ const questions = ["What is your name?", "What is your age?"]
+ render(
+
+
+ ,
+ )
+
+ const textarea = screen.getByPlaceholderText("Type your answer...")
+ fireEvent.change(textarea, { target: { value: "John" } })
+
+ const nextButton = screen.getByText("Next")
+ fireEvent.click(nextButton)
+
+ fireEvent.change(textarea, { target: { value: "25" } })
+
+ const finishButton = screen.getByText("Finish")
+ fireEvent.click(finishButton)
+
+ expect(mockOnSendResponse).toHaveBeenCalledTimes(1)
+ expect(mockOnSendResponse).toHaveBeenCalledWith(
+ "Question: What is your name?\nAnswer: John\n\nQuestion: What is your age?\nAnswer: 25",
+ )
+ })
+
+ it("should handle skipped questions", () => {
+ const questions = ["What is your name?", "What is your age?"]
+ render(
+
+
+ ,
+ )
+
+ const nextButton = screen.getByText("Next")
+ fireEvent.click(nextButton) // Skip first question
+
+ const textarea = screen.getByPlaceholderText("Type your answer...")
+ fireEvent.change(textarea, { target: { value: "25" } })
+
+ const finishButton = screen.getByText("Finish")
+ fireEvent.click(finishButton)
+
+ expect(mockOnSendResponse).toHaveBeenCalledWith(
+ "Question: What is your name?\nAnswer: (skipped)\n\nQuestion: What is your age?\nAnswer: 25",
+ )
+ })
+
+ it("should preserve answers when navigating back and forth", () => {
+ const questions = ["Q1", "Q2", "Q3"]
+ render(
+
+
+ ,
+ )
+
+ const textarea = screen.getByPlaceholderText("Type your answer...")
+
+ // Answer Q1
+ fireEvent.change(textarea, { target: { value: "A1" } })
+ fireEvent.click(screen.getByText("Next"))
+
+ // Answer Q2
+ fireEvent.change(textarea, { target: { value: "A2" } })
+ fireEvent.click(screen.getByText("Next"))
+
+ // Go back to Q1
+ fireEvent.click(screen.getByText("Previous"))
+ fireEvent.click(screen.getByText("Previous"))
+
+ expect(textarea).toHaveValue("A1")
+
+ // Go to Q2
+ fireEvent.click(screen.getByText("Next"))
+ expect(textarea).toHaveValue("A2")
+
+ expect(mockOnSendResponse).not.toHaveBeenCalled()
+ })
+
+ it("should handle options correctly without copying to text area", () => {
+ const questions = [{ text: "Color?", options: ["Red", "Blue"] }]
+ render(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ 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")
+ })
+})
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx
index 444336db8e9..0f84ed55380 100644
--- a/webview-ui/src/components/settings/SettingsView.tsx
+++ b/webview-ui/src/components/settings/SettingsView.tsx
@@ -212,6 +212,7 @@ const SettingsView = forwardRef(({ onDone, t
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
+ showQuestionsOneByOne,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@@ -417,6 +418,7 @@ const SettingsView = forwardRef(({ onDone, t
enterBehavior: enterBehavior ?? "send",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
+ showQuestionsOneByOne: showQuestionsOneByOne ?? false,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
profileThresholds,
imageGenerationProvider,
@@ -908,6 +910,7 @@ const SettingsView = forwardRef(({ onDone, t
)}
diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx
index a3488dc59e1..3b4d526ee94 100644
--- a/webview-ui/src/components/settings/UISettings.tsx
+++ b/webview-ui/src/components/settings/UISettings.tsx
@@ -12,12 +12,14 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
interface UISettingsProps extends HTMLAttributes {
reasoningBlockCollapsed: boolean
enterBehavior: "send" | "newline"
+ showQuestionsOneByOne: boolean
setCachedStateField: SetCachedStateField
}
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 (
{t("settings:sections.ui")}
@@ -91,6 +102,24 @@ export const UISettings = ({
+
+ {/* Show Questions One By One Setting */}
+
+
+
handleShowQuestionsOneByOneChange(e.target.checked)}
+ data-testid="show-questions-one-by-one-checkbox">
+ {t("settings:ui.showQuestionsOneByOne.label")}
+
+
+ {t("settings:ui.showQuestionsOneByOne.description")}
+
+
+
diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx
index 2a21a410b38..f8f3eed5c65 100644
--- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx
+++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx
@@ -6,6 +6,7 @@ describe("UISettings", () => {
const defaultProps = {
reasoningBlockCollapsed: false,
enterBehavior: "send" as const,
+ showQuestionsOneByOne: false,
setCachedStateField: vi.fn(),
}
diff --git a/webview-ui/src/components/ui/autosize-textarea.tsx b/webview-ui/src/components/ui/autosize-textarea.tsx
index d23f7a43600..26c660f6e56 100644
--- a/webview-ui/src/components/ui/autosize-textarea.tsx
+++ b/webview-ui/src/components/ui/autosize-textarea.tsx
@@ -91,7 +91,7 @@ export const AutosizeTextarea = React.forwardRef void
includeCurrentCost?: boolean
setIncludeCurrentCost: (value: boolean) => void
+ showQuestionsOneByOne?: boolean
+ setShowQuestionsOneByOne: (value: boolean) => void
showWorktreesInHomeScreen: boolean
setShowWorktreesInHomeScreen: (value: boolean) => void
}
@@ -264,6 +266,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
openRouterImageGenerationSelectedModel: "",
includeCurrentTime: true,
includeCurrentCost: true,
+ showQuestionsOneByOne: false,
})
const [didHydrateState, setDidHydrateState] = useState(false)
@@ -287,6 +290,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 })),
@@ -332,6 +336,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)
@@ -609,6 +617,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setIncludeCurrentTime,
includeCurrentCost,
setIncludeCurrentCost,
+ showQuestionsOneByOne,
+ setShowQuestionsOneByOne,
showWorktreesInHomeScreen: state.showWorktreesInHomeScreen ?? true,
setShowWorktreesInHomeScreen: (value) =>
setState((prevState) => ({ ...prevState, showWorktreesInHomeScreen: value })),
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index 420ecadafc8..a4fa90f74ed 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -292,7 +292,13 @@
"goToSubtask": "View task"
},
"questions": {
- "hasQuestion": "Roo has a question"
+ "hasQuestion": "Roo has a question",
+ "questionNumber": "Question {{number}}",
+ "questionNumberOfTotal": "Question {{current}} of {{total}}",
+ "typeAnswer": "Type your answer here...",
+ "previous": "Previous",
+ "next": "Next",
+ "finish": "Finish"
},
"taskCompleted": "Task Completed",
"error": "Error",
diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json
index de76ba4c679..f6cf71670ff 100644
--- a/webview-ui/src/i18n/locales/en/settings.json
+++ b/webview-ui/src/i18n/locales/en/settings.json
@@ -126,6 +126,10 @@
"requireCtrlEnterToSend": {
"label": "Require {{primaryMod}}+Enter to send messages",
"description": "When enabled, you must press {{primaryMod}}+Enter to send messages instead of just Enter"
+ },
+ "showQuestionsOneByOne": {
+ "label": "Show questions one by one",
+ "description": "When enabled, questions from the model will be displayed one at a time."
}
},
"prompts": {