From 2d34bc956dc433e575f5939c4ea00c2b6f551886 Mon Sep 17 00:00:00 2001 From: NealimeKenna Date: Sat, 7 Nov 2026 00:47:10 +0100 Subject: [PATCH] feat(agents): show AskUserQuestion as a question card (#1758) The native Claude Code provider turned each AskUserQuestion question into a permission request with only the option labels, shown in the permission banner. Descriptions, headers, previews and multi-select were lost, and the only way to give another answer was Skip, which tells Claude the user didn't answer. All questions now go out in one request carrying the full tool input, and the conversation shows them as a card above the composer: each option with its description, radio buttons or checkboxes for multi-select, an Other choice with a text field, the option's preview beside the options on a wide card (below them on a narrow one, sized to the tallest preview so hovering never moves the options), number keys to pick, Esc to skip, and one Submit for every answer. Ordinary permission requests keep the banner, and Skip answers as before. The answers go back in Claude Code's own format: question text to answer, multi-select picks and Other text comma-joined, with an item that contains the separator or a quote JSON-quoted so it reads back as one item. Closes #1628 Co-authored-by: NealimeKenna <22195793+NealimeKenna@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 31 ++- crates/ide-agent/src/model.rs | 26 ++ crates/ide-agent/src/native/claude/mod.rs | 56 +++-- crates/ide-agent/src/native/claude/tests.rs | 64 +++++ crates/ide-agent/src/tests.rs | 56 +++++ crates/ide-agent/src/timeline.rs | 8 +- src/bindings.ts | 19 ++ src/features/agents/Session.module.css | 150 +++++++++++ src/features/agents/SessionEditor.tsx | 260 +++++++++++++++++++- src/features/agents/sessionState.test.tsx | 102 ++++++++ src/platform/locales/text/en.json | 3 + src/platform/locales/text/zh.json | 3 + src/ui/Checkbox.module.css | 10 + src/ui/Checkbox.tsx | 33 +++ 14 files changed, 787 insertions(+), 34 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index df7475d1..a31d09a7 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -1647,17 +1647,38 @@ impl Task { .collect() }) .unwrap_or_default(); + let questions: Vec = params + .pointer("/toolCall/_meta/questions") + .and_then(|questions| serde_json::from_value(questions.clone()).ok()) + .unwrap_or_default(); + let asked: Vec = questions.iter().map(|question| question.question.clone()).collect(); let (tx, rx) = std::sync::mpsc::channel(); - let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None }); + let entry = + self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None }); self.permissions.lock().insert(id, tx); self.set_status(AgentStatus::NeedsInput, None); self.approve_pending(); let answer = rx.recv().ok().flatten(); - let name = answer - .as_ref() - .and_then(|a| options.iter().find(|o| &o.id == a)) - .map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone()); + let answers = answer + .as_deref() + .filter(|_| !asked.is_empty()) + .and_then(|reply| serde_json::from_str::>(reply).ok()); + let name = match answers { + Some(answers) => asked + .iter() + .filter_map(|question| answers.get(question)) + .map(|items| match items { + Value::Array(items) => items.iter().filter_map(Value::as_str).collect::>().join(", "), + item => item.as_str().unwrap_or_default().to_owned(), + }) + .collect::>() + .join("; "), + None => answer + .as_ref() + .and_then(|reply| options.iter().find(|option| &option.id == reply)) + .map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()), + }; { let mut s = self.state.lock(); if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) { diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index a1ca7842..50edd139 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -278,6 +278,28 @@ pub struct AgentPermissionOption { pub kind: String, } +/// A question the agent asks with `AskUserQuestion`, answered by picking options or typing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentQuestion { + #[serde(default)] + pub header: String, + pub question: String, + #[serde(default)] + pub multi_select: bool, + pub options: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentQuestionOption { + pub label: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub preview: Option, +} + /// One item of a task's conversation. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] @@ -350,6 +372,10 @@ pub enum AgentEntry { id: u32, title: String, options: Vec, + /// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object + /// of question to answer instead of an option. + #[serde(default)] + questions: Vec, /// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". chosen: Option, }, diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs index 68012958..b8a23c5e 100644 --- a/crates/ide-agent/src/native/claude/mod.rs +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -1117,38 +1117,46 @@ fn permission_result( } } -/// `AskUserQuestion`: each question as a permission request whose options are its -/// answers; `None` when one is not answered. +/// `AskUserQuestion`: one permission request carrying all its questions, answered with a +/// JSON object of question to the chosen labels and typed text; `None` when skipped. async fn ask_questions(input: &Value, call: &Value, ask: F) -> Option where F: Fn(Value, Value) -> Fut, Fut: std::future::Future>, { - let mut answers = serde_json::Map::new(); - for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() { - let question = q.get("question").and_then(Value::as_str).unwrap_or_default(); - let labels: Vec<&str> = q - .get("options") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|o| o.get("label")?.as_str()) - .collect(); - let mut options: Vec = labels - .iter() - .enumerate() - .map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" })) - .collect(); - options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" })); - let mut call = call.clone(); - call["title"] = json!(question); - let chosen = ask(call, Value::Array(options)).await?; - let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?; - answers.insert(question.to_owned(), json!(labels.get(i)?)); - } + let questions = input.get("questions")?; + let mut call = call.clone(); + call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question")); + call["_meta"] = json!({ "questions": questions }); + let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]); + let chosen = ask(call, options).await?; + let chosen: serde_json::Map = serde_json::from_str(&chosen).ok()?; + let answers = questions + .as_array()? + .iter() + .filter_map(|entry| { + let question = entry.get("question")?.as_str()?; + let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect(); + let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true); + let answer = if multi { join_answers(&items) } else { items.first()?.to_string() }; + Some((question.to_owned(), json!(answer))) + }) + .collect(); Some(Value::Object(answers)) } +/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an +/// item that holds the separator or a quote JSON-quoted so it reads back as one item. +fn join_answers(items: &[&str]) -> String { + items + .iter() + .map( + |item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() }, + ) + .collect::>() + .join(", ") +} + /// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of /// its messages, or names none (the CLI's own error before the turn began). fn belongs(turn: &Turn, result: &Value) -> bool { diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index 5cf0b419..23c3d0eb 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -1106,3 +1106,67 @@ fn tool_calls_plans_and_subagents() { assert_eq!(finished[1].update["content"][0]["text"], "It is x"); assert_eq!(finished[1].update["senderSessionId"], "ag"); } + +#[tokio::test] +async fn questions_go_out_as_one_request() { + let questions = json!([ + {"header": "Files", "question": "Where should they live?", "multiSelect": false, + "options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]}, + {"header": "Checks", "question": "Which checks?", "multiSelect": true, + "options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]}, + ]); + let input = json!({ "questions": questions }); + let asked = std::sync::Mutex::new(Vec::new()); + let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string(); + let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"}); + let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| { + asked.lock().unwrap().push((call, options)); + let reply = reply.clone(); + async move { Some(reply) } + }) + .await; + assert_eq!(result, Some(answers)); + let asked = asked.into_inner().unwrap(); + assert_eq!(asked.len(), 1); + let (call, options) = &asked[0]; + assert_eq!(call["title"], "Where should they live?"); + assert_eq!(call["_meta"]["questions"], questions); + assert_eq!(options[0]["optionId"], "reject"); +} + +#[tokio::test] +async fn answers_keep_claude_codes_format_with_other_text() { + let input = json!({"questions": [ + {"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]}, + {"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]}, + {"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]}, + ]}); + let reply = json!({ + "Which cache?": ["Memcached, for now"], + "Which checks?": ["Lint", "Tests"], + "Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"], + }) + .to_string(); + let result = ask_questions(&input, &json!({}), |_, _| { + let reply = reply.clone(); + async move { Some(reply) } + }) + .await; + assert_eq!( + result, + Some(json!({ + "Which cache?": "Memcached, for now", + "Which checks?": "Lint, Tests", + "Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"", + })) + ); +} + +#[tokio::test] +async fn skipped_questions_have_no_answers() { + let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]}); + let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await; + assert_eq!(skipped, None); + let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await; + assert_eq!(cancelled, None); +} diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 861b6bee..ca9f6956 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -156,6 +156,24 @@ impl Handler for FakeAgent { self.say(&session, "ok"); return Ok(json!({"stopReason": "end_turn"})); } + if text == "ask" { + let peer = self.latest(); + let answer: Value = tokio::runtime::Handle::current() + .block_on(peer.request( + "session/request_permission", + json!({"sessionId": session, + "toolCall": {"toolCallId": "q1", "title": "AskUserQuestion", "_meta": {"questions": [ + {"question": "Which layout?", "options": [{"label": "List"}, {"label": "Grid"}]}, + {"question": "Which checks?", "multiSelect": true, + "options": [{"label": "Disk"}, {"label": "GPU temps"}]}]}}, + "options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}), + None, + )) + .unwrap(); + let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); + self.say(&session, &format!("answered {chosen}")); + return Ok(json!({"stopReason": "end_turn"})); + } if text == "wait" { // Works until cancelled. while !self.calls.lock().iter().any(|c| c == "session/cancel") { @@ -609,6 +627,44 @@ fn prompts_become_acp_content_blocks() { assert!(clean(text(" ")).is_none()); } +#[tokio::test(flavor = "multi_thread")] +async fn answered_questions_close_the_question_with_every_picked_label() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("ask"), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let question = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string(); + mgr.respond(&task.id, question, Some(reply)).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let entries = mgr.entries(&task.id, None).unwrap(); + assert!( + entries + .iter() + .any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "List; Disk, GPU temps")), + "{entries:?}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-agent/src/timeline.rs b/crates/ide-agent/src/timeline.rs index e2efaea0..3617046b 100644 --- a/crates/ide-agent/src/timeline.rs +++ b/crates/ide-agent/src/timeline.rs @@ -434,7 +434,13 @@ mod tests { t.push(AgentEntry::Agent { text: "between".into() }); assert_eq!(t.apply(&plan("completed")).changed, vec![0]); t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"})); - t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None }); + t.push(AgentEntry::Permission { + id: 1, + title: "Run?".into(), + options: vec![], + questions: vec![], + chosen: None, + }); assert_eq!(t.end_turn(None), vec![2, 3]); let again = Timeline::from_entries(t.entries.clone()); assert_eq!(again.plan, Some(0)); diff --git a/src/bindings.ts b/src/bindings.ts index 770322ae..1b024559 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1502,6 +1502,11 @@ endedMs: number | null; cancellable: boolean } | /** A message between sessions in a subagent's conversation: its task (`incoming`) or its report. */ { type: "message"; incoming: boolean; text: string } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[]; +/** + * Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object + * of question to answer instead of an option. + */ +questions?: AgentQuestion[]; /** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */ chosen: string | null } | { type: "error"; message: string }; @@ -1598,6 +1603,20 @@ export type AgentProvider = { source: AgentSource, }; +/** A question the agent asks with `AskUserQuestion`, answered by picking options or typing. */ +export type AgentQuestion = { + header?: string, + question: string, + multiSelect?: boolean, + options: AgentQuestionOption[], +}; + +export type AgentQuestionOption = { + label: string, + description?: string, + preview?: string | null, +}; + export type AgentQueued = { id: string, prompt: AgentPrompt, diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index d5975531..e4018cb0 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -995,3 +995,153 @@ opacity: 0.4; cursor: default; } + +/* An open AskUserQuestion: every question with its options, answered at once above the composer. */ +.questionCard { + flex: none; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 50vh; + overflow-y: auto; + margin: 0 12px 8px; + padding: 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-control); + background: var(--bg-elev); + container: question-card / inline-size; +} +.question { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.questionText { + margin-bottom: 6px; + padding: 0; + color: var(--fg-strong); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.questionTag { + margin: 0 8px 0 0; + padding: 1px 6px; + border-radius: var(--radius-small); + background: var(--bg-hover); + color: var(--fg-muted); + font-size: var(--fs-small); +} +.questionLabel .questionTag { + margin: 0 0 0 8px; +} +.question .questionOption { + display: flex; + align-items: flex-start; + padding: 4px 6px; + border-radius: var(--radius-row); +} +.question .questionOption:hover { + background: var(--bg-hover); +} +.questionOption > :first-child { + margin-top: 1px; +} +.questionOption > :last-child { + flex: 1; +} +.questionChoice { + display: flex; + align-items: flex-start; + gap: 8px; +} +.questionOptionText { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} +.questionLabel { + flex: 1; + color: var(--fg); +} +.questionDescription { + color: var(--fg-muted); + font-size: var(--fs-small); + overflow-wrap: anywhere; +} +.questionKey { + flex: none; + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.questionOther { + margin: 2px 0 0 30px; +} +.questionBody { + display: flex; + flex-direction: column; + gap: 6px; +} +.questionChoices { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +@container question-card (min-width: 640px) { + .questionBodyWithPreview { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: start; + } + .questionBodyWithPreview .questionPreview { + margin: 0; + } +} +.questionPreview { + display: grid; + min-width: 0; + margin: 0 0 0 30px; +} +.questionPreviewItem { + display: flex; + flex-direction: column; + grid-area: 1 / 1; + gap: 4px; + min-width: 0; + visibility: hidden; +} +.questionPreviewActive { + visibility: visible; +} +.questionPreviewCaption { + color: var(--fg-muted); + font-size: var(--fs-small); +} +.questionPreviewBody { + margin: 0; + padding: 8px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-small); + background: var(--bg-editor); + font-family: var(--font-mono); + font-size: var(--fs-code); +} +.questionPreviewEmpty { + padding: 8px; + border: 1px dashed var(--border); + border-radius: var(--radius-small); + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.questionActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index b5d041c4..44adb554 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -13,16 +13,28 @@ import { } from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; -import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, AgentTask, AgentUsage } from "@/bindings"; +import type { + AgentChange, + AgentEntry, + AgentImage, + AgentLimit, + AgentQuestion, + AgentRetry, + AgentTask, + AgentUsage, +} from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, onIdeEvent } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; import { editorService, notifications, useEditorShown, type EditorInput } from "@/workbench/contributions"; import { Button, + Checkbox, EmptyState, Icon, IconButton, + Input, + Radio, SearchField, Spinner, T, @@ -172,7 +184,10 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { Subagents take their instructions from the agent that started them. ) : ( - + <> + + + )} {changesOpen && ( @@ -1256,7 +1271,9 @@ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEnt {shortTitle(task, entry.title)} {entry.chosen ?? Waiting for your answer} - {!entry.chosen && } + {!entry.chosen && !entry.questions?.length && ( + + )} ); @@ -1265,7 +1282,7 @@ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEnt /** The open permission request, as an editor notification banner above the conversation. */ function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) { const entry = entries?.find((e): e is PermissionEntry => e.type === "permission" && !e.chosen); - if (!entry) return null; + if (!entry || entry.questions?.length) return null; return (
@@ -1275,6 +1292,241 @@ function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEn ); } +interface QuestionAnswer { + question: AgentQuestion; + picked: string[]; + /** The typed answer; `null` while "Other" isn't chosen. */ + other: string | null; +} + +const answerItems = ({ picked, other }: QuestionAnswer) => [...picked, ...(other?.trim() ? [other.trim()] : [])]; + +const isAnswered = (answer: QuestionAnswer) => answerItems(answer).length > 0 && answer.other?.trim() !== ""; + +const RECOMMENDED_SUFFIX = " (Recommended)"; + +const isRecommended = (label: string) => label.endsWith(RECOMMENDED_SUFFIX); + +const plainLabel = (label: string) => (isRecommended(label) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label); + +/** An open `AskUserQuestion` request, as a card above the composer that answers all its questions at once. */ +function QuestionCard({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) { + const entry = entries?.find( + (candidate): candidate is PermissionEntry => + candidate.type === "permission" && !candidate.chosen && !!candidate.questions?.length, + ); + return entry?.questions ? ( + + ) : null; +} + +function QuestionForm({ + task, + entry, + questions, +}: { + task: AgentTask; + entry: PermissionEntry; + questions: AgentQuestion[]; +}) { + const [answers, setAnswers] = useState(() => + questions.map((question) => ({ question, picked: [], other: null })), + ); + const [focusedPosition, setFocusedPosition] = useState(0); + const [hovered, setHovered] = useState<{ position: number; label: string } | null>(null); + const [busy, setBusy] = useState(false); + const complete = answers.every(isAnswered); + const send = (option: string) => { + setBusy(true); + void respond(task.id, entry.id, option).finally(() => setBusy(false)); + }; + const submit = () => + send(JSON.stringify(Object.fromEntries(answers.map((answer) => [answer.question.question, answerItems(answer)])))); + const update = (previous: QuestionAnswer, next: QuestionAnswer) => + setAnswers(answers.map((answer) => (answer === previous ? next : answer))); + const choose = (answer: QuestionAnswer, label: string, checked: boolean) => + update( + answer, + answer.question.multiSelect + ? { + ...answer, + picked: checked ? [...answer.picked, label] : answer.picked.filter((picked) => picked !== label), + } + : { ...answer, picked: [label], other: null }, + ); + const chooseOther = (answer: QuestionAnswer, checked: boolean) => + update( + answer, + answer.question.multiSelect ? { ...answer, other: checked ? "" : null } : { ...answer, picked: [], other: "" }, + ); + const chooseByNumber = (number: number) => { + const answer = answers[focusedPosition]; + if (!answer) return; + const option = answer.question.options[number - 1]; + if (option) choose(answer, option.label, !answer.picked.includes(option.label)); + else if (number === answer.question.options.length + 1) chooseOther(answer, answer.other === null); + }; + return ( +
{ + event.preventDefault(); + if (complete) submit(); + }} + onKeyDown={(event) => { + const typing = event.target instanceof HTMLInputElement && event.target.type === "text"; + if (event.key === "Escape") send("reject"); + else if (!typing && !event.ctrlKey && !event.metaKey && !event.altKey && /^[1-9]$/.test(event.key)) { + event.preventDefault(); + chooseByNumber(Number(event.key)); + } + }} + > + {answers.map((answer, position) => { + const { question } = answer; + const group = `question-${entry.id}-${position}`; + const hasPreviews = question.options.some((option) => option.preview); + const targetLabel = + (hovered?.position === position ? hovered.label : undefined) ?? + answer.picked[0] ?? + (answer.other !== null ? group : undefined); + const previewed = + targetLabel === undefined + ? question.options.find((option) => option.preview) + : question.options.find((option) => option.label === targetLabel); + const choices = [ + ...question.options.map((option) => ({ + key: option.label, + checked: answer.picked.includes(option.label), + onChange: (checked: boolean) => choose(answer, option.label, checked), + label: ( + + + {plainLabel(option.label)} + {isRecommended(option.label) && ( + + Recommended + + )} + + {option.description && {option.description}} + + ), + })), + { + key: group, + checked: answer.other !== null, + onChange: (checked: boolean) => chooseOther(answer, checked), + label: ( + + Other + + ), + }, + ]; + return ( +
setFocusedPosition(position)} + > + + {question.header && {question.header}} + {question.question} + +
+
+ {choices.map((choice, index) => { + const label = ( + + {choice.label} + {index + 1} + + ); + return ( +
setHovered({ position, label: choice.key })} + onMouseLeave={() => setHovered(null)} + > + {question.multiSelect ? ( + + ) : ( + choice.onChange(true)} + /> + )} +
+ ); + })} + {answer.other !== null && ( + update(answer, { ...answer, other: event.target.value })} + onKeyDown={(event) => { + if (event.key === "Escape" && answer.other) { + event.stopPropagation(); + update(answer, { ...answer, other: "" }); + } + }} + /> + )} +
+ {hasPreviews && ( +
+ {[...question.options, null].map((option) => { + const active = option === (previewed ?? null); + return ( +
+ + {option ? plainLabel(option.label) : Other} + + {option?.preview ? ( +
{option.preview}
+ ) : ( + + No preview + + )} +
+ ); + })} +
+ )} +
+
+ ); + })} +
+ + +
+
+ ); +} + function SessionComposer({ task, entries, diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index 3b060267..db97ff05 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -138,6 +138,108 @@ describe("a pending question", () => { }); }); +describe("a pending AskUserQuestion", () => { + it("shows every question with its descriptions in a card and sends all answers at once", async () => { + backend.entries.set("A", [ + { + type: "permission", + id: 9, + title: "Which layout?", + options: [{ id: "reject", name: "Skip", kind: "reject_once" }], + questions: [ + { + header: "Layout", + question: "Which layout?", + multiSelect: false, + options: [ + { label: "Table (Recommended)", description: "One row per check", preview: null }, + { label: "List", description: "Grouped under headings", preview: null }, + ], + }, + { + header: "Checks", + question: "Which checks?", + multiSelect: true, + options: [ + { label: "Disk", description: "", preview: null }, + { label: "Updates", description: "", preview: null }, + ], + }, + ], + chosen: null, + }, + ]); + backend.answers = []; + useAgents.setState({ entries: new Map(backend.entries) }); + render(); + const card = await screen.findByRole("form", { name: "Which layout?" }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(within(card).getByText("One row per check")).toBeTruthy(); + expect(within(card).getByText("Recommended")).toBeTruthy(); + const submit = within(card).getByRole("button", { name: "Submit" }); + fireEvent.click(within(card).getByRole("radio", { name: /^List/ })); + expect(submit).toHaveProperty("disabled", true); + fireEvent.click(within(card).getByRole("checkbox", { name: /^Disk/ })); + const others = within(card).getAllByRole("checkbox", { name: /^Other/ }); + fireEvent.click(others[0]!); + expect(submit).toHaveProperty("disabled", true); + fireEvent.change(within(card).getByPlaceholderText("Type your answer"), { target: { value: "GPU temps" } }); + fireEvent.click(submit); + await waitFor(() => + expect(backend.answers).toEqual([ + ["A", 9, JSON.stringify({ "Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"] })], + ]), + ); + }); + + it("picks options with number keys, previews the picked or hovered one, and skips with Escape", async () => { + backend.entries.set("A", [ + { + type: "permission", + id: 10, + title: "Which layout?", + options: [{ id: "reject", name: "Skip", kind: "reject_once" }], + questions: [ + { + header: "", + question: "Which layout?", + multiSelect: false, + options: [ + { label: "Table", description: "", preview: "| a | b |" }, + { label: "List", description: "", preview: null }, + ], + }, + ], + chosen: null, + }, + ]); + backend.answers = []; + useAgents.setState({ entries: new Map(backend.entries) }); + render(); + const card = await screen.findByRole("form", { name: "Which layout?" }); + const table = within(card).getByRole("radio", { name: /^Table/ }); + const list = within(card).getByRole("radio", { name: /^List/ }); + fireEvent.focus(table); + fireEvent.keyDown(table, { key: "2" }); + expect(list).toHaveProperty("checked", true); + const shown = () => card.querySelector(`.${styles.questionPreviewActive}`)!; + const caption = () => shown().querySelector(`.${styles.questionPreviewCaption}`)?.textContent; + expect(card.querySelectorAll(`.${styles.questionPreviewActive}`)).toHaveLength(1); + expect(caption()).toBe("List"); + expect(within(shown()).getByText("No preview")).toBeTruthy(); + expect(within(shown()).queryByText("| a | b |")).toBeNull(); + fireEvent.mouseEnter(table.closest("label")!.parentElement!); + expect(caption()).toBe("Table"); + expect(within(shown()).getByText("| a | b |")).toBeTruthy(); + fireEvent.mouseLeave(table.closest("label")!.parentElement!); + fireEvent.keyDown(table, { key: "3" }); + expect(caption()).toBe("Other"); + expect(within(shown()).queryByText("| a | b |")).toBeNull(); + fireEvent.keyDown(table, { key: "Escape" }); + await waitFor(() => expect(backend.answers).toEqual([["A", 10, "reject"]])); + }); +}); + describe("a pending permission request in the conversation", () => { it("is answered from its line too, and one without options can be cancelled", async () => { backend.entries.set("A", [ diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index c4c7aae6..113684bc 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -51,6 +51,9 @@ "Xdebug is not loaded in PHP {version}": "Xdebug is not loaded in PHP {version}", "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.", "Open PHP Settings": "Open PHP Settings", + "Type your answer": "Type your answer", + "Recommended": "Recommended", + "No preview": "No preview", "Running in the background": "Running in the background", "Couldn't stop the background task": "Couldn't stop the background task" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index afef76eb..09544039 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6417,6 +6417,9 @@ "Xdebug is not loaded in PHP {version}": "PHP {version} 未加载 Xdebug", "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} 运行时没有 Xdebug,断点会被跳过。请为此 PHP 安装 Xdebug 3,或选择其他 PHP 解释器。", "Open PHP Settings": "打开 PHP 设置", + "Type your answer": "输入你的回答", + "Recommended": "推荐", + "No preview": "无预览", "Running in the background": "后台运行中", "Couldn't stop the background task": "无法停止后台任务" } diff --git a/src/ui/Checkbox.module.css b/src/ui/Checkbox.module.css index 46c5e2ab..a3b27bd8 100644 --- a/src/ui/Checkbox.module.css +++ b/src/ui/Checkbox.module.css @@ -57,6 +57,16 @@ .disabled .on { background: var(--border); } +.radio { + border-radius: 50%; +} +.radio.on::after { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} .native:focus-visible + .box { outline: 2px solid var(--focus-ring); outline-offset: 1px; diff --git a/src/ui/Checkbox.tsx b/src/ui/Checkbox.tsx index e2b13e4f..7c56d8f4 100644 --- a/src/ui/Checkbox.tsx +++ b/src/ui/Checkbox.tsx @@ -43,6 +43,39 @@ export function Checkbox({ checked, onChange, label, disabled, indeterminate, cl ); } +export interface RadioProps { + checked: boolean; + onChange: () => void; + /** Radios with the same name form one group, moved through with the arrow keys. */ + name: string; + label?: ReactNode; + disabled?: boolean; + className?: string; +} + +/** Radio button, drawn like the checkbox with a round box and a dot. */ +export function Radio({ checked, onChange, name, label, disabled, className }: RadioProps) { + const id = useId(); + const tx = useTx(); + return ( + + ); +} + export interface ToggleProps { checked: boolean; onChange: (checked: boolean) => void;