From 7abe7b2c33e128ee5bd7876104e4e6ad1c0c60fb Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 31 Jul 2026 17:51:05 +0800 Subject: [PATCH] feat(vscode): show all agent questions in one form with multi-select support --- .changeset/vscode-question-dialog-form.md | 5 + apps/vscode/test/question-answers.test.ts | 94 ++++++++ .../src/components/QuestionDialog.tsx | 226 ++++++++++-------- .../webview-ui/src/lib/question-answers.ts | 60 +++++ 4 files changed, 291 insertions(+), 94 deletions(-) create mode 100644 .changeset/vscode-question-dialog-form.md create mode 100644 apps/vscode/test/question-answers.test.ts create mode 100644 apps/vscode/webview-ui/src/lib/question-answers.ts diff --git a/.changeset/vscode-question-dialog-form.md b/.changeset/vscode-question-dialog-form.md new file mode 100644 index 0000000000..5c28184623 --- /dev/null +++ b/.changeset/vscode-question-dialog-form.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Show all agent questions in a single form with multi-select support, and allow skipping questions or dismissing the prompt. diff --git a/apps/vscode/test/question-answers.test.ts b/apps/vscode/test/question-answers.test.ts new file mode 100644 index 0000000000..b6d65cfc3f --- /dev/null +++ b/apps/vscode/test/question-answers.test.ts @@ -0,0 +1,94 @@ +/** + * Scenario: the QuestionDialog form state is projected into the AskUserQuestion answers contract. + * Responsibilities: verify single/multi-select resolution, custom text precedence, join format, + * and unanswered-question omission one case at a time. + * Wiring: the pure builder and real protocol types are used directly; there are no stubs. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts apps/vscode/test/question-answers.test.ts + */ + +import { describe, expect, it } from 'vitest'; + +import type { QuestionItem } from '../shared/legacy-sdk'; +import { + buildQuestionAnswers, + type QuestionFormState, +} from '../webview-ui/src/lib/question-answers'; + +function question(overrides: Partial = {}): QuestionItem { + return { + question: 'Pick a color?', + header: 'Style', + options: [ + { label: 'Red', description: '' }, + { label: 'Blue', description: '' }, + { label: 'Green', description: '' }, + ], + ...overrides, + }; +} + +function form(overrides: Partial = {}): QuestionFormState { + return { single: {}, multi: {}, custom: {}, ...overrides }; +} + +describe('buildQuestionAnswers (projects the form state into the answers contract)', () => { + it('maps a single-select choice to the option label keyed by question text', () => { + const answers = buildQuestionAnswers([question()], form({ single: { 0: 1 } })); + + expect(answers).toEqual({ 'Pick a color?': 'Blue' }); + }); + + it('lets custom text win over a single-select choice', () => { + const answers = buildQuestionAnswers( + [question()], + form({ single: { 0: 1 }, custom: { 0: ' Chartreuse ' } }), + ); + + expect(answers).toEqual({ 'Pick a color?': 'Chartreuse' }); + }); + + it("joins multi-select labels with ', ' following the TUI convention", () => { + const answers = buildQuestionAnswers( + [question({ multi_select: true })], + form({ multi: { 0: new Set([2, 0]) } }), + ); + + expect(answers).toEqual({ 'Pick a color?': 'Red, Green' }); + }); + + it('appends custom text as an extra label for multi-select questions', () => { + const answers = buildQuestionAnswers( + [question({ multi_select: true })], + form({ multi: { 0: new Set([1]) }, custom: { 0: 'Purple' } }), + ); + + expect(answers).toEqual({ 'Pick a color?': 'Blue, Purple' }); + }); + + it('omits questions without any answer', () => { + const answers = buildQuestionAnswers( + [question({ question: 'First?' }), question({ question: 'Second?' })], + form({ single: { 1: 0 } }), + ); + + expect(answers).toEqual({ 'Second?': 'Red' }); + }); + + it('returns an empty record when nothing is answered (dismiss semantics)', () => { + const answers = buildQuestionAnswers( + [question(), question({ question: 'Another?', multi_select: true })], + form({ multi: { 1: new Set() }, custom: { 0: ' ' } }), + ); + + expect(answers).toEqual({}); + }); + + it('ignores out-of-range selections and empty option lists', () => { + const answers = buildQuestionAnswers( + [question(), question({ question: 'Empty?', options: [] })], + form({ single: { 0: 7, 1: 0 } }), + ); + + expect(answers).toEqual({}); + }); +}); diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index c4919269a9..a3aed54196 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -1,124 +1,162 @@ import { useState, useEffect } from "react"; +import { IconCheck } from "@tabler/icons-react"; import { useChatStore } from "@/stores"; +import { buildQuestionAnswers, type QuestionFormState } from "@/lib/question-answers"; import { cn } from "@/lib/utils"; +const EMPTY_FORM: QuestionFormState = { single: {}, multi: {}, custom: {} }; + export function QuestionDialog() { const { pendingQuestion, respondQuestion } = useChatStore(); - const [customInput, setCustomInput] = useState(""); - const [showCustom, setShowCustom] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(1); - const [questionIndex, setQuestionIndex] = useState(0); - const [answers, setAnswers] = useState>({}); + const [form, setForm] = useState(EMPTY_FORM); + const [customOpen, setCustomOpen] = useState>({}); const questions = pendingQuestion?.questions ?? []; - const question = questions[questionIndex]; useEffect(() => { if (pendingQuestion) { - setShowCustom(false); - setCustomInput(""); - setSelectedIndex(1); - setQuestionIndex(0); - setAnswers({}); + setForm(EMPTY_FORM); + setCustomOpen({}); } }, [pendingQuestion?.id]); - if (!pendingQuestion || !question) return null; + if (!pendingQuestion || questions.length === 0) return null; - // Step through the questions one by one; submit all answers after the last. - const handleAnswer = async (answer: string) => { - const nextAnswers = { ...answers, [question.question]: answer }; - if (questionIndex + 1 < questions.length) { - setAnswers(nextAnswers); - setQuestionIndex(questionIndex + 1); - setShowCustom(false); - setCustomInput(""); - setSelectedIndex(1); - } else { - await respondQuestion(nextAnswers); - } + const answers = buildQuestionAnswers(questions, form); + const answeredCount = Object.keys(answers).length; + + const handleSubmit = async () => { + await respondQuestion(buildQuestionAnswers(questions, form)); }; - const handleSelect = async (optionLabel: string) => { - await handleAnswer(optionLabel); + const handleDismiss = async () => { + await respondQuestion({}); }; - const handleCustomSubmit = async () => { - if (!customInput.trim()) return; - await handleAnswer(customInput.trim()); + const selectSingle = (questionIdx: number, optionIdx: number) => { + setForm((f) => ({ + single: { ...f.single, [questionIdx]: optionIdx }, + multi: f.multi, + // A chosen option and custom text are mutually exclusive for single-select. + custom: { ...f.custom, [questionIdx]: "" }, + })); }; - const options = question.options || []; - const customIndex = options.length + 1; + const toggleMulti = (questionIdx: number, optionIdx: number) => { + setForm((f) => { + const next = new Set(f.multi[questionIdx] ?? []); + if (next.has(optionIdx)) next.delete(optionIdx); + else next.add(optionIdx); + return { single: f.single, multi: { ...f.multi, [questionIdx]: next }, custom: f.custom }; + }); + }; + + const setCustomText = (questionIdx: number, text: string, isMulti: boolean) => { + setForm((f) => ({ + single: isMulti ? f.single : { ...f.single, [questionIdx]: undefined }, + multi: f.multi, + custom: { ...f.custom, [questionIdx]: text }, + })); + }; return (
-
- {questions.length > 1 && ( +
+ {questions.map((question, questionIdx) => { + const options = question.options ?? []; + const isMulti = question.multi_select === true; + const customText = form.custom[questionIdx] ?? ""; + const customSelected = customText.trim().length > 0; + return ( +
0 && "border-t border-border/60")}> + {question.header &&
{question.header}
} +
{question.question}
+ {isMulti &&
Select all that apply
} +
+ {options.map((option, optionIdx) => { + const selected = isMulti ? form.multi[questionIdx]?.has(optionIdx) === true : form.single[questionIdx] === optionIdx; + return ( + + ); + })} + {customOpen[questionIdx] ? ( + setCustomText(questionIdx, e.target.value, isMulti)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) void handleSubmit(); + if (e.key === "Escape") setCustomOpen((open) => ({ ...open, [questionIdx]: false })); + }} + placeholder={isMulti ? "Add another response..." : "Enter your response..."} + className="w-full px-2 py-1 rounded-md text-xs border border-border bg-background outline-none focus:border-blue-500" + /> + ) : ( + + )} +
+
+ ); + })} +
+
+ {answeredCount < questions.length && (
- Question {questionIndex + 1} of {questions.length} + {answeredCount} of {questions.length} answered — unanswered questions will be skipped.
)} - {question.header &&
{question.header}
} -
{question.question}
-
- {options.map((option, idx) => ( - - ))} - {showCustom ? ( -
- setCustomInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void handleCustomSubmit(); - if (e.key === "Escape") setShowCustom(false); - }} - placeholder="Enter your response..." - className="flex-1 px-2 py-1 rounded-md text-xs border border-border bg-background outline-none focus:border-blue-500" - /> - -
- ) : ( - - )} +
+ +
diff --git a/apps/vscode/webview-ui/src/lib/question-answers.ts b/apps/vscode/webview-ui/src/lib/question-answers.ts new file mode 100644 index 0000000000..6acc82b44d --- /dev/null +++ b/apps/vscode/webview-ui/src/lib/question-answers.ts @@ -0,0 +1,60 @@ +import type { QuestionItem } from "shared/legacy-sdk"; + +/** + * Per-question form state collected by QuestionDialog, keyed by question index. + */ +export interface QuestionFormState { + /** Single-select questions: the chosen option index. */ + single: Record; + /** Multi-select questions: the chosen option indexes. */ + multi: Record | undefined>; + /** Per-question free-text input. */ + custom: Record; +} + +/** + * Build the answers record submitted to the agent, keyed by question text. + * + * - Single-select: a non-empty custom text wins over the chosen option. + * - Multi-select: chosen labels joined with `', '` (the TUI convention), + * with a non-empty custom text appended as an extra label. + * - Questions without any answer are omitted; an all-empty form yields `{}`, + * which the agent treats as the user dismissing the question. + */ +export function buildQuestionAnswers( + questions: QuestionItem[], + state: QuestionFormState, +): Record { + const answers: Record = {}; + + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + if (!question) continue; + const options = question.options ?? []; + const custom = state.custom[i]?.trim(); + + if (question.multi_select) { + const labels: string[] = []; + const selected = state.multi[i]; + if (selected) { + for (let j = 0; j < options.length; j++) { + const label = options[j]?.label; + if (selected.has(j) && label) labels.push(label); + } + } + if (custom) labels.push(custom); + if (labels.length > 0) answers[question.question] = labels.join(", "); + continue; + } + + if (custom) { + answers[question.question] = custom; + continue; + } + const chosen = state.single[i]; + const label = chosen === undefined ? undefined : options[chosen]?.label; + if (label) answers[question.question] = label; + } + + return answers; +}