diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index df7475d19..a31d09a7d 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 a1ca78421..50edd1392 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 68012958b..b8a23c5e7 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 5cf0b4195..23c3d0eb2 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 861b6bee5..ca9f69566 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 e2efaea0f..3617046ba 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 770322ae9..1b0245596 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 d5975531a..e4018cb0e 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 b5d041c47..44adb5544 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 3b060267a..db97ff054 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 c4c7aae6d..113684bc4 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 afef76eb7..09544039f 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 46c5e2aba..a3b27bd8c 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 e2b13e4f1..7c56d8f49 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;