Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vscode-question-dialog-form.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions apps/vscode/test/question-answers.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): QuestionItem {
return {
question: 'Pick a color?',
header: 'Style',
options: [
{ label: 'Red', description: '' },
{ label: 'Blue', description: '' },
{ label: 'Green', description: '' },
],
...overrides,
};
}

function form(overrides: Partial<QuestionFormState> = {}): 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({});
});
});
226 changes: 132 additions & 94 deletions apps/vscode/webview-ui/src/components/QuestionDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>({});
const [form, setForm] = useState<QuestionFormState>(EMPTY_FORM);
const [customOpen, setCustomOpen] = useState<Record<number, boolean>>({});

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 (
<div className={cn("mb-0.5 border border-blue-200 dark:border-blue-800 rounded-lg overflow-hidden bg-background flex flex-col shrink")}>
<div className="p-2 space-y-2">
{questions.length > 1 && (
<div className="overflow-y-auto max-h-80">
{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 (
<div key={questionIdx} className={cn("p-2 space-y-1.5", questionIdx > 0 && "border-t border-border/60")}>
{question.header && <div className="text-[10px] text-muted-foreground uppercase tracking-wide">{question.header}</div>}
<div className="text-xs font-semibold text-foreground">{question.question}</div>
{isMulti && <div className="text-[10px] text-muted-foreground">Select all that apply</div>}
<div className="space-y-1.5">
{options.map((option, optionIdx) => {
const selected = isMulti ? form.multi[questionIdx]?.has(optionIdx) === true : form.single[questionIdx] === optionIdx;
return (
<button
key={optionIdx}
onClick={() => (isMulti ? toggleMulti(questionIdx, optionIdx) : selectSingle(questionIdx, optionIdx))}
className={cn(
"w-full text-left px-2 py-1 rounded-md text-xs transition-colors",
"border cursor-pointer flex items-start",
selected
? isMulti
? "border-blue-500 bg-blue-500/10"
: "bg-blue-500 text-white border-blue-500"
: "border-border bg-background hover:bg-muted/50",
)}
>
{isMulti ? (
<span
className={cn(
"mr-2 mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-[3px] border",
selected ? "bg-blue-500 border-blue-500 text-white" : "border-border",
)}
>
{selected && <IconCheck className="size-3" />}
</span>
) : (
<span className={cn("mr-2", selected ? "text-blue-200" : "text-muted-foreground")}>{optionIdx + 1}</span>
)}
<span>
<span className="font-medium">{option.label}</span>
{option.description && (
<span className={cn("ml-2", selected && !isMulti ? "text-blue-200" : "text-muted-foreground")}>- {option.description}</span>
)}
</span>
</button>
);
})}
{customOpen[questionIdx] ? (
<input
autoFocus
value={customText}
onChange={(e) => setCustomText(questionIdx, e.target.value, isMulti)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) void handleSubmit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid submitting all questions from a custom field

When a request contains multiple questions and the user types a custom response for an earlier question, pressing Enter in this input immediately calls handleSubmit(), which sends the current partial answers and skips any remaining unanswered questions. In the new all-at-once form, Enter should only commit/close the current custom field or be ignored so users can finish the rest of the form before resolving the agent question.

Useful? React with 👍 / 👎.

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"
/>
) : (
<button
onClick={() => setCustomOpen((open) => ({ ...open, [questionIdx]: true }))}
className={cn(
"w-full text-left px-2 py-1 rounded-md text-xs transition-colors",
"border cursor-pointer",
customSelected
? isMulti
? "border-blue-500 bg-blue-500/10"
: "bg-blue-500 text-white border-blue-500"
: "border-border bg-background hover:bg-muted/50",
)}
>
<span className={cn("mr-2", customSelected && !isMulti ? "text-blue-200" : "text-muted-foreground")}>{options.length + 1}</span>
<span className="font-medium">{customSelected ? customText : "Custom response..."}</span>
</button>
)}
</div>
</div>
);
})}
</div>
<div className="shrink-0 border-t border-border/60 p-2 space-y-1.5">
{answeredCount < questions.length && (
<div className="text-[10px] text-muted-foreground">
Question {questionIndex + 1} of {questions.length}
{answeredCount} of {questions.length} answered — unanswered questions will be skipped.
</div>
)}
{question.header && <div className="text-[10px] text-muted-foreground uppercase tracking-wide">{question.header}</div>}
<div className="text-xs font-semibold text-foreground">{question.question}</div>
<div className="space-y-1.5">
{options.map((option, idx) => (
<button
key={idx}
onClick={() => {
void handleSelect(option.label);
}}
onMouseEnter={() => setSelectedIndex(idx + 1)}
className={cn(
"w-full text-left px-2 py-1 rounded-md text-xs transition-colors",
"border border-border cursor-pointer",
selectedIndex === idx + 1 ? "bg-blue-500 text-white border-blue-500" : "bg-background hover:bg-muted/50",
)}
>
<span className={cn("mr-2", selectedIndex === idx + 1 ? "text-blue-200" : "text-muted-foreground")}>{idx + 1}</span>
<span className="font-medium">{option.label}</span>
{option.description && (
<span className={cn("ml-2", selectedIndex === idx + 1 ? "text-blue-200" : "text-muted-foreground")}>- {option.description}</span>
)}
</button>
))}
{showCustom ? (
<div className="flex gap-1.5">
<input
autoFocus
value={customInput}
onChange={(e) => 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"
/>
<button
onClick={() => {
void handleCustomSubmit();
}}
disabled={!customInput.trim()}
className="px-2 py-1 rounded-md text-xs bg-blue-500 text-white disabled:opacity-50 cursor-pointer"
>
Send
</button>
</div>
) : (
<button
onClick={() => setShowCustom(true)}
onMouseEnter={() => setSelectedIndex(customIndex)}
className={cn(
"w-full text-left px-2 py-1 rounded-md text-xs transition-colors",
"border border-border cursor-pointer",
selectedIndex === customIndex ? "bg-blue-500 text-white border-blue-500" : "bg-background hover:bg-muted/50",
)}
>
<span className={cn("mr-2", selectedIndex === customIndex ? "text-blue-200" : "text-muted-foreground")}>{customIndex}</span>
<span className="font-medium">Custom response...</span>
</button>
)}
<div className="flex gap-1.5">
<button onClick={() => void handleSubmit()} className="px-2 py-1 rounded-md text-xs bg-blue-500 text-white cursor-pointer">
Submit
</button>
<button onClick={() => void handleDismiss()} className="px-2 py-1 rounded-md text-xs text-muted-foreground hover:bg-muted/50 cursor-pointer">
Dismiss
</button>
</div>
</div>
</div>
Expand Down
Loading
Loading