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 @@ Join r/RooCode

    - 快速取得協助 → 加入 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) => ( + + ))} +
    + )} + 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 && ( + + )} + +
    +
    + ) + } + + return ( +
    + {questions.map((q, i) => ( + updateTextAnswer(i, val)} + onOptionClick={(opt) => handleOptionClick(i, opt)} + /> + ))} +
    + +
    +
    + ) +} 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": {