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
6 changes: 6 additions & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.6.8

### Patch Changes

- [#2466](https://github.com/MoonshotAI/kimi-code/pull/2466) [`7abe7b2`](https://github.com/MoonshotAI/kimi-code/commit/7abe7b2c33e128ee5bd7876104e4e6ad1c0c60fb) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show all agent questions in a single form with multi-select support, and allow skipping questions or dismissing the prompt.

## 0.6.7

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "moonshot-ai",
"displayName": "Kimi Code",
"description": "Official Kimi Code plugin for VS Code",
"version": "0.6.7",
"version": "0.6.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
Expand Down
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();
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