AskUserQuestion is no longer disabled. Its permission request opens a form docked on the composer: one question at a time with header tabs, numbered option rows (click or 1-9), multi-select with Next/Submit, a custom answer typed in the composer, and Skip. The transcript keeps each question with its answer. Background tasks (run_in_background commands, Monitor, background subagents) are tracked from the CLI's task events and listed above the composer with their age and a stop button (stop_task). A finished task leaves a row in the transcript, and the turn the CLI starts on its own to report back is shown as a running turn. Long foreground commands get a Background button (background_tasks, Ctrl+B semantics). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 6 changed files with 866 additions and 24 deletions.
| @@ -47,12 +47,20 @@ follow the desktop. | ||
| 47 | 47 | collapsible cards with their input and output. |
| 48 | 48 | - Inline permission prompts (Allow, Deny, "Always allow" / "Accept all edits"), and Pi |
| 49 | 49 | extensions' confirm and choice dialogs as one-click questions. |
| 50 | + - Claude's questions (AskUserQuestion) open as a form on top of the composer, one question | |
| 51 | + at a time with tabs between them. Pick an option by click or with 1–9, tick several on | |
| 52 | + multi-select questions, type your own answer in the composer, or skip. What you answered | |
| 53 | + stays in the transcript. | |
| 50 | 54 | - Attachments: images are sent inline, other files by path. A ring shows how full the |
| 51 | 55 | context window is. |
| 52 | 56 | - A project panel: the machine, **Open in** your editor (Zed, VS Code, Cursor, VSCodium, |
| 53 | 57 | Sublime Text, Kate or the file manager, whichever are installed), the branch with a |
| 54 | 58 | switcher, **Commit** (asks the agent to commit, or commit and push), the size of the |
| 55 | 59 | uncommitted changes, and the thread's cost. |
| 60 | + - Background tasks: commands, monitors and subagents Claude leaves running show in a strip | |
| 61 | + above the composer with their age and a stop button. When one finishes, the transcript | |
| 62 | + says so and Claude picks the work back up and reports back on its own. A long-running | |
| 63 | + command can be sent to the background from its tool card, like Ctrl+B in Claude Code. | |
| 56 | 64 | - Interrupt with Esc or the stop button. Messages sent while the agent works are queued. |
| 57 | 65 | - Crash recovery: if a process dies, the next message restarts it and resumes the session. |
| 58 | 66 | - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's: |
| @@ -303,9 +303,6 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 303 | 303 | "stdio", |
| 304 | 304 | // Lets the user switch to "Full access" at runtime. |
| 305 | 305 | "--allow-dangerously-skip-permissions", |
| 306 | - // The GUI has no question form yet; Claude asks in plain text instead. | |
| 307 | - "--disallowedTools", | |
| 308 | - "AskUserQuestion", | |
| 309 | 306 | "--permission-mode", |
| 310 | 307 | config.permission_mode.as_cli(), |
| 311 | 308 | ]) |
| @@ -19,6 +19,8 @@ use crate::models::{Catalog, Provider}; | ||
| 19 | 19 | |
| 20 | 20 | const DEFAULT_TITLE: &str = "New thread"; |
| 21 | 21 | const STDERR_TAIL: usize = 20; |
| 22 | + /// Claude Code's tool for putting multiple-choice questions to the user. | |
| 23 | + pub const ASK_USER_QUESTION: &str = "AskUserQuestion"; | |
| 22 | 24 | /// The API's limit for one base64 image. |
| 23 | 25 | const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024; |
| 24 | 26 | |
| @@ -90,6 +92,117 @@ pub struct ToolCall { | ||
| 90 | 92 | /// Created from a permission request for a tool we never saw being called |
| 91 | 93 | /// (subagent tools), so no result will ever arrive for it. |
| 92 | 94 | pub detached: bool, |
| 95 | + /// Claude's AskUserQuestion: the form the user fills in. It's answered | |
| 96 | + /// through the tool's permission prompt. | |
| 97 | + pub ask: Option<UserQuestions>, | |
| 98 | + /// The CLI's task for a long-running command or subagent, once it has | |
| 99 | + /// one: a foreground task can be sent to the background. | |
| 100 | + pub task_id: Option<String>, | |
| 101 | + /// Set once the tool runs as a background task. | |
| 102 | + pub background: Option<TaskStatus>, | |
| 103 | + } | |
| 104 | + | |
| 105 | + /// Where a background task stands. | |
| 106 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 107 | + pub enum TaskStatus { | |
| 108 | + Running, | |
| 109 | + Completed, | |
| 110 | + Failed, | |
| 111 | + Stopped, | |
| 112 | + } | |
| 113 | + | |
| 114 | + impl TaskStatus { | |
| 115 | + fn from_cli(status: &str) -> Self { | |
| 116 | + match status { | |
| 117 | + "completed" => TaskStatus::Completed, | |
| 118 | + "failed" => TaskStatus::Failed, | |
| 119 | + "running" | "pending" => TaskStatus::Running, | |
| 120 | + _ => TaskStatus::Stopped, | |
| 121 | + } | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + /// A command, monitor or subagent Claude left running in the background. The | |
| 126 | + /// CLI wakes Claude when it finishes. | |
| 127 | + pub struct BackgroundTask { | |
| 128 | + pub task_id: String, | |
| 129 | + pub description: SharedString, | |
| 130 | + pub started: Instant, | |
| 131 | + } | |
| 132 | + | |
| 133 | + pub struct AskOption { | |
| 134 | + pub label: SharedString, | |
| 135 | + pub description: Option<SharedString>, | |
| 136 | + } | |
| 137 | + | |
| 138 | + pub struct AskQuestion { | |
| 139 | + pub question: SharedString, | |
| 140 | + /// A short chip label, e.g. "Auth method". | |
| 141 | + pub header: SharedString, | |
| 142 | + pub options: Vec<AskOption>, | |
| 143 | + pub multi_select: bool, | |
| 144 | + } | |
| 145 | + | |
| 146 | + /// The questions an AskUserQuestion call puts to the user, and their answers | |
| 147 | + /// so far. | |
| 148 | + pub struct UserQuestions { | |
| 149 | + pub questions: Vec<AskQuestion>, | |
| 150 | + /// The question on screen. | |
| 151 | + pub current: usize, | |
| 152 | + /// Options ticked on each multi-select question, not yet committed. | |
| 153 | + pub picked: Vec<Vec<usize>>, | |
| 154 | + pub answers: Vec<Option<SharedString>>, | |
| 155 | + } | |
| 156 | + | |
| 157 | + impl UserQuestions { | |
| 158 | + fn parse(input: &Value) -> Option<Self> { | |
| 159 | + let text = |value: &Value, key: &str| { | |
| 160 | + value.get(key).and_then(Value::as_str).map(|text| SharedString::from(text.to_string())) | |
| 161 | + }; | |
| 162 | + let questions: Vec<AskQuestion> = input | |
| 163 | + .get("questions")? | |
| 164 | + .as_array()? | |
| 165 | + .iter() | |
| 166 | + .filter_map(|question| { | |
| 167 | + Some(AskQuestion { | |
| 168 | + question: text(question, "question")?, | |
| 169 | + header: text(question, "header").unwrap_or_default(), | |
| 170 | + options: question | |
| 171 | + .get("options") | |
| 172 | + .and_then(Value::as_array) | |
| 173 | + .into_iter() | |
| 174 | + .flatten() | |
| 175 | + .filter_map(|option| { | |
| 176 | + Some(AskOption { | |
| 177 | + label: text(option, "label")?, | |
| 178 | + description: text(option, "description").filter(|text| !text.is_empty()), | |
| 179 | + }) | |
| 180 | + }) | |
| 181 | + .collect(), | |
| 182 | + multi_select: question.get("multiSelect").and_then(Value::as_bool).unwrap_or(false), | |
| 183 | + }) | |
| 184 | + }) | |
| 185 | + .collect(); | |
| 186 | + if questions.is_empty() { | |
| 187 | + return None; | |
| 188 | + } | |
| 189 | + let count = questions.len(); | |
| 190 | + Some(Self { questions, current: 0, picked: vec![Vec::new(); count], answers: vec![None; count] }) | |
| 191 | + } | |
| 192 | + | |
| 193 | + /// Records an answer and moves to the next unanswered question. Returns | |
| 194 | + /// true once every question has one. | |
| 195 | + fn answer(&mut self, answer: SharedString) -> bool { | |
| 196 | + self.answers[self.current] = Some(answer); | |
| 197 | + let count = self.questions.len(); | |
| 198 | + match (1..count).map(|step| (self.current + step) % count).find(|&q| self.answers[q].is_none()) { | |
| 199 | + Some(next) => { | |
| 200 | + self.current = next; | |
| 201 | + false | |
| 202 | + } | |
| 203 | + None => self.answers.iter().all(Option::is_some), | |
| 204 | + } | |
| 205 | + } | |
| 93 | 206 | } |
| 94 | 207 | |
| 95 | 208 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| @@ -119,6 +232,8 @@ pub enum ChatItem { | ||
| 119 | 232 | Tool(ToolCall), |
| 120 | 233 | Notice(NoticeKind, SharedString), |
| 121 | 234 | Question(Question), |
| 235 | + /// A background task finished; Claude picks the work back up from here. | |
| 236 | + TaskEvent { summary: SharedString, status: TaskStatus }, | |
| 122 | 237 | TurnEnd { duration_ms: u64, cost: Option<f64> }, |
| 123 | 238 | } |
| 124 | 239 | |
| @@ -181,6 +296,8 @@ enum PendingControl { | ||
| 181 | 296 | SetPermissionMode, |
| 182 | 297 | FlagSettings, |
| 183 | 298 | Interrupt, |
| 299 | + Background, | |
| 300 | + StopTask, | |
| 184 | 301 | } |
| 185 | 302 | |
| 186 | 303 | /// A finished turn, which the transcript folds into one "Worked for …" row |
| @@ -235,6 +352,10 @@ pub struct Session { | ||
| 235 | 352 | /// Tokens in the context window as of the last response. |
| 236 | 353 | pub context_tokens: Option<u64>, |
| 237 | 354 | pub context_window: Option<u64>, |
| 355 | + /// Tasks running in the background, oldest first. | |
| 356 | + pub background_tasks: Vec<BackgroundTask>, | |
| 357 | + /// When each task the CLI reported started, and the tool that started it. | |
| 358 | + task_origins: HashMap<String, (Instant, Option<String>)>, | |
| 238 | 359 | /// Finished turns the user unfolded, keyed by their `TurnEnd` index. |
| 239 | 360 | expanded_turns: HashSet<usize>, |
| 240 | 361 | /// What each finished turn changed on disk, keyed by its `TurnEnd` index. |
| @@ -293,6 +414,8 @@ impl Session { | ||
| 293 | 414 | settled: false, |
| 294 | 415 | context_tokens: None, |
| 295 | 416 | context_window: None, |
| 417 | + background_tasks: Vec::new(), | |
| 418 | + task_origins: HashMap::new(), | |
| 296 | 419 | expanded_turns: HashSet::new(), |
| 297 | 420 | turn_changes: HashMap::new(), |
| 298 | 421 | expanded_dirs: HashMap::new(), |
| @@ -505,17 +628,42 @@ impl Session { | ||
| 505 | 628 | self.title = title_from(&source).into(); |
| 506 | 629 | } |
| 507 | 630 | self.items.push(ChatItem::User { text: text.into(), attachments: names }); |
| 508 | - if !self.is_working() { | |
| 509 | - self.turn_started = Some(Instant::now()); | |
| 510 | - self.turn_cost = 0.; | |
| 511 | - let cwd = self.cwd.clone(); | |
| 512 | - let index = self.snapshot_index.clone(); | |
| 513 | - self.base_snapshot = | |
| 514 | - Some(cx.background_executor().spawn(async move { git::snapshot(&cwd, &index) })); | |
| 515 | - } | |
| 516 | - self.status = SessionStatus::Working; | |
| 631 | + self.begin_turn(cx); | |
| 517 | 632 | self.settled = false; |
| 633 | + cx.notify(); | |
| 634 | + } | |
| 635 | + | |
| 636 | + /// Marks a turn as running, unless one already is. Turns also start | |
| 637 | + /// without a message, when a background task finishes and the CLI wakes | |
| 638 | + /// Claude up. | |
| 639 | + fn begin_turn(&mut self, cx: &mut Context<Self>) { | |
| 518 | 640 | self.updated = Instant::now(); |
| 641 | + if self.is_working() { | |
| 642 | + return; | |
| 643 | + } | |
| 644 | + self.turn_started = Some(Instant::now()); | |
| 645 | + self.turn_cost = 0.; | |
| 646 | + let cwd = self.cwd.clone(); | |
| 647 | + let index = self.snapshot_index.clone(); | |
| 648 | + self.base_snapshot = Some(cx.background_executor().spawn(async move { git::snapshot(&cwd, &index) })); | |
| 649 | + self.status = SessionStatus::Working; | |
| 650 | + } | |
| 651 | + | |
| 652 | + /// Sends a running command or subagent to the background, like Ctrl+B in | |
| 653 | + /// Claude Code's terminal UI. Claude carries on and hears when it's done. | |
| 654 | + pub fn background_tool(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 655 | + let Some(ChatItem::Tool(tool)) = self.items.get(ix) else { return }; | |
| 656 | + if tool.task_id.is_none() || tool.background.is_some() || tool.state != ToolState::Running { | |
| 657 | + return; | |
| 658 | + } | |
| 659 | + let id = tool.id.clone(); | |
| 660 | + self.control(PendingControl::Background, json!({"subtype": "background_tasks", "tool_use_id": id})); | |
| 661 | + cx.notify(); | |
| 662 | + } | |
| 663 | + | |
| 664 | + /// Stops a background task. | |
| 665 | + pub fn stop_task(&mut self, task_id: &str, cx: &mut Context<Self>) { | |
| 666 | + self.control(PendingControl::StopTask, json!({"subtype": "stop_task", "task_id": task_id})); | |
| 519 | 667 | cx.notify(); |
| 520 | 668 | } |
| 521 | 669 | |
| @@ -531,6 +679,7 @@ impl Session { | ||
| 531 | 679 | self.base_snapshot = None; |
| 532 | 680 | self.interrupt_requested = false; |
| 533 | 681 | self.pending_controls.clear(); |
| 682 | + self.drop_background_tasks(); | |
| 534 | 683 | self.finish_open_tools("The thread was settled before the tool finished."); |
| 535 | 684 | if self.status != SessionStatus::Exited { |
| 536 | 685 | self.status = SessionStatus::Idle; |
| @@ -677,7 +826,8 @@ impl Session { | ||
| 677 | 826 | .rev() |
| 678 | 827 | .find(|&ix| matches!(self.items[ix], ChatItem::Assistant(_))); |
| 679 | 828 | let has_work = (first..end).any(|ix| { |
| 680 | - Some(ix) != answer && !matches!(self.items[ix], ChatItem::Notice(NoticeKind::Error, _)) | |
| 829 | + Some(ix) != answer | |
| 830 | + && !matches!(self.items[ix], ChatItem::Notice(NoticeKind::Error, _) | ChatItem::TaskEvent { .. }) | |
| 681 | 831 | }); |
| 682 | 832 | Some(FinishedTurn { first, answer, end, duration_ms, cost, has_work }) |
| 683 | 833 | } |
| @@ -739,6 +889,125 @@ impl Session { | ||
| 739 | 889 | cx.notify(); |
| 740 | 890 | } |
| 741 | 891 | |
| 892 | + /// The AskUserQuestion form waiting for answers, if any. | |
| 893 | + pub fn pending_ask(&self) -> Option<(usize, &UserQuestions)> { | |
| 894 | + self.items.iter().enumerate().find_map(|(ix, item)| match item { | |
| 895 | + ChatItem::Tool(ToolCall { ask: Some(ask), permission: Some(prompt), .. }) | |
| 896 | + if prompt.state == PermissionState::Pending => | |
| 897 | + { | |
| 898 | + Some((ix, ask)) | |
| 899 | + } | |
| 900 | + _ => None, | |
| 901 | + }) | |
| 902 | + } | |
| 903 | + | |
| 904 | + fn pending_ask_mut(&mut self, ix: usize) -> Option<&mut UserQuestions> { | |
| 905 | + match self.items.get_mut(ix) { | |
| 906 | + Some(ChatItem::Tool(ToolCall { ask: Some(ask), permission: Some(prompt), .. })) | |
| 907 | + if prompt.state == PermissionState::Pending => | |
| 908 | + { | |
| 909 | + Some(ask) | |
| 910 | + } | |
| 911 | + _ => None, | |
| 912 | + } | |
| 913 | + } | |
| 914 | + | |
| 915 | + /// Shows another of the form's questions. | |
| 916 | + pub fn ask_show(&mut self, ix: usize, question: usize, cx: &mut Context<Self>) { | |
| 917 | + if let Some(ask) = self.pending_ask_mut(ix) | |
| 918 | + && question < ask.questions.len() | |
| 919 | + { | |
| 920 | + ask.current = question; | |
| 921 | + cx.notify(); | |
| 922 | + } | |
| 923 | + } | |
| 924 | + | |
| 925 | + /// Picks an option on the current question: answers a single-select | |
| 926 | + /// question, or ticks the option on a multi-select one. | |
| 927 | + pub fn ask_pick(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) { | |
| 928 | + let Some(ask) = self.pending_ask_mut(ix) else { return }; | |
| 929 | + let question = &ask.questions[ask.current]; | |
| 930 | + let Some(label) = question.options.get(option).map(|option| option.label.clone()) else { return }; | |
| 931 | + if question.multi_select { | |
| 932 | + let picked = &mut ask.picked[ask.current]; | |
| 933 | + match picked.iter().position(|&picked| picked == option) { | |
| 934 | + Some(position) => { | |
| 935 | + picked.remove(position); | |
| 936 | + } | |
| 937 | + None => picked.push(option), | |
| 938 | + } | |
| 939 | + cx.notify(); | |
| 940 | + return; | |
| 941 | + } | |
| 942 | + if ask.answer(label) { | |
| 943 | + self.submit_ask(ix); | |
| 944 | + } | |
| 945 | + cx.notify(); | |
| 946 | + } | |
| 947 | + | |
| 948 | + /// Commits the options ticked on the current multi-select question. | |
| 949 | + pub fn ask_confirm(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 950 | + let Some(ask) = self.pending_ask_mut(ix) else { return }; | |
| 951 | + let question = &ask.questions[ask.current]; | |
| 952 | + let mut picked = ask.picked[ask.current].clone(); | |
| 953 | + picked.sort_unstable(); | |
| 954 | + let labels: Vec<&str> = | |
| 955 | + picked.iter().filter_map(|&option| question.options.get(option)).map(|option| option.label.as_ref()).collect(); | |
| 956 | + if labels.is_empty() { | |
| 957 | + return; | |
| 958 | + } | |
| 959 | + if ask.answer(labels.join(", ").into()) { | |
| 960 | + self.submit_ask(ix); | |
| 961 | + } | |
| 962 | + cx.notify(); | |
| 963 | + } | |
| 964 | + | |
| 965 | + /// Answers the current question in the user's own words. | |
| 966 | + pub fn ask_custom(&mut self, ix: usize, text: String, cx: &mut Context<Self>) { | |
| 967 | + let Some(ask) = self.pending_ask_mut(ix) else { return }; | |
| 968 | + if ask.answer(text.into()) { | |
| 969 | + self.submit_ask(ix); | |
| 970 | + } | |
| 971 | + cx.notify(); | |
| 972 | + } | |
| 973 | + | |
| 974 | + /// Dismisses the form without answering. | |
| 975 | + pub fn ask_skip(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 976 | + let Some(process) = &self.process else { return }; | |
| 977 | + let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return }; | |
| 978 | + let Some(prompt) = tool.permission.as_mut().filter(|prompt| prompt.state == PermissionState::Pending) | |
| 979 | + else { | |
| 980 | + return; | |
| 981 | + }; | |
| 982 | + process.respond_control( | |
| 983 | + &prompt.request_id, | |
| 984 | + json!({"behavior": "deny", "message": "The user dismissed the questions without answering."}), | |
| 985 | + ); | |
| 986 | + prompt.state = PermissionState::Denied; | |
| 987 | + cx.notify(); | |
| 988 | + } | |
| 989 | + | |
| 990 | + /// Sends the form's answers, keyed by question text as the CLI expects. | |
| 991 | + fn submit_ask(&mut self, ix: usize) { | |
| 992 | + let Some(process) = &self.process else { return }; | |
| 993 | + let Some(ChatItem::Tool(ToolCall { ask: Some(ask), permission: Some(prompt), .. })) = self.items.get_mut(ix) | |
| 994 | + else { | |
| 995 | + return; | |
| 996 | + }; | |
| 997 | + let answers: serde_json::Map<String, Value> = ask | |
| 998 | + .questions | |
| 999 | + .iter() | |
| 1000 | + .zip(&ask.answers) | |
| 1001 | + .filter_map(|(question, answer)| Some((question.question.to_string(), answer.as_ref()?.to_string().into()))) | |
| 1002 | + .collect(); | |
| 1003 | + let questions = prompt.input.get("questions").cloned().unwrap_or_default(); | |
| 1004 | + process.respond_control( | |
| 1005 | + &prompt.request_id, | |
| 1006 | + json!({"behavior": "allow", "updatedInput": {"questions": questions, "answers": answers}}), | |
| 1007 | + ); | |
| 1008 | + prompt.state = PermissionState::Allowed; | |
| 1009 | + } | |
| 1010 | + | |
| 742 | 1011 | /// Answers an extension's question with one of its options. |
| 743 | 1012 | pub fn answer_question(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) { |
| 744 | 1013 | let Some(process) = &self.process else { return }; |
| @@ -781,6 +1050,7 @@ impl Session { | ||
| 781 | 1050 | |
| 782 | 1051 | fn handle_exit(&mut self, code: Option<i32>) { |
| 783 | 1052 | self.process = None; |
| 1053 | + self.drop_background_tasks(); | |
| 784 | 1054 | self.thinking = false; |
| 785 | 1055 | self.streaming = None; |
| 786 | 1056 | self.status = SessionStatus::Exited; |
| @@ -844,7 +1114,7 @@ impl Session { | ||
| 844 | 1114 | let parent_tool_use_id = message.get("parent_tool_use_id").and_then(Value::as_str); |
| 845 | 1115 | |
| 846 | 1116 | match message.get("type").and_then(Value::as_str) { |
| 847 | - Some("system") => self.handle_system(&message), | |
| 1117 | + Some("system") => self.handle_system(&message, cx), | |
| 848 | 1118 | Some("stream_event") if parent_tool_use_id.is_none() => { |
| 849 | 1119 | self.handle_stream_event(&message["event"], cx) |
| 850 | 1120 | } |
| @@ -880,7 +1150,7 @@ impl Session { | ||
| 880 | 1150 | } |
| 881 | 1151 | } |
| 882 | 1152 | |
| 883 | - fn handle_system(&mut self, message: &Value) { | |
| 1153 | + fn handle_system(&mut self, message: &Value, cx: &mut Context<Self>) { | |
| 884 | 1154 | if let Some(mode) = message |
| 885 | 1155 | .get("permissionMode") |
| 886 | 1156 | .and_then(Value::as_str) |
| @@ -888,11 +1158,83 @@ impl Session { | ||
| 888 | 1158 | { |
| 889 | 1159 | self.permission_mode = mode; |
| 890 | 1160 | } |
| 891 | - match message.get("subtype").and_then(Value::as_str) { | |
| 1161 | + let text = |key: &str| message.get(key).and_then(Value::as_str); | |
| 1162 | + match text("subtype") { | |
| 892 | 1163 | Some("init") => { |
| 893 | 1164 | if let Some(model) = message.get("model").and_then(Value::as_str) { |
| 894 | 1165 | self.active_model = Some(model.to_string()); |
| 895 | 1166 | } |
| 1167 | + // Each turn opens with `init`; one we didn't start was woken | |
| 1168 | + // by a background task. | |
| 1169 | + if self.status == SessionStatus::Idle { | |
| 1170 | + self.begin_turn(cx); | |
| 1171 | + } | |
| 1172 | + } | |
| 1173 | + Some("task_started") => { | |
| 1174 | + let Some(task_id) = text("task_id") else { return }; | |
| 1175 | + let tool_use_id = text("tool_use_id").map(str::to_string); | |
| 1176 | + self.task_origins.insert(task_id.to_string(), (Instant::now(), tool_use_id.clone())); | |
| 1177 | + let backgrounded = message.get("is_backgrounded").and_then(Value::as_bool).unwrap_or(false); | |
| 1178 | + if let Some(tool) = self.find_tool_mut(tool_use_id.as_deref().unwrap_or_default()) { | |
| 1179 | + tool.task_id = Some(task_id.to_string()); | |
| 1180 | + if backgrounded { | |
| 1181 | + tool.background = Some(TaskStatus::Running); | |
| 1182 | + } | |
| 1183 | + } | |
| 1184 | + } | |
| 1185 | + Some("task_updated") => { | |
| 1186 | + let backgrounded = message.pointer("/patch/is_backgrounded").and_then(Value::as_bool) == Some(true); | |
| 1187 | + let tool_use_id = text("task_id") | |
| 1188 | + .and_then(|task_id| self.task_origins.get(task_id)) | |
| 1189 | + .and_then(|(_, tool_use_id)| tool_use_id.clone()); | |
| 1190 | + if backgrounded && let Some(tool) = self.find_tool_mut(tool_use_id.as_deref().unwrap_or_default()) { | |
| 1191 | + tool.background = Some(TaskStatus::Running); | |
| 1192 | + } | |
| 1193 | + } | |
| 1194 | + // The full list of background tasks, whenever it changes. | |
| 1195 | + Some("background_tasks_changed") => { | |
| 1196 | + let tasks = message.get("tasks").and_then(Value::as_array).cloned().unwrap_or_default(); | |
| 1197 | + self.background_tasks = tasks | |
| 1198 | + .iter() | |
| 1199 | + .filter_map(|task| { | |
| 1200 | + let task_id = task.get("task_id").and_then(Value::as_str)?.to_string(); | |
| 1201 | + let started = | |
| 1202 | + self.task_origins.get(&task_id).map_or_else(Instant::now, |(started, _)| *started); | |
| 1203 | + let description = task.get("description").and_then(Value::as_str).unwrap_or("Background task"); | |
| 1204 | + Some(BackgroundTask { | |
| 1205 | + task_id, | |
| 1206 | + description: description.to_string().into(), | |
| 1207 | + started, | |
| 1208 | + }) | |
| 1209 | + }) | |
| 1210 | + .collect(); | |
| 1211 | + } | |
| 1212 | + Some("task_notification") => { | |
| 1213 | + let status = TaskStatus::from_cli(text("status").unwrap_or_default()); | |
| 1214 | + // A monitor reports events while it keeps running. | |
| 1215 | + let finished = status != TaskStatus::Running; | |
| 1216 | + let mut backgrounded = false; | |
| 1217 | + if let Some(task_id) = text("task_id") { | |
| 1218 | + backgrounded = self.background_tasks.iter().any(|task| task.task_id == task_id); | |
| 1219 | + if finished { | |
| 1220 | + self.task_origins.remove(task_id); | |
| 1221 | + self.background_tasks.retain(|task| task.task_id != task_id); | |
| 1222 | + } | |
| 1223 | + } | |
| 1224 | + if let Some(tool) = self.find_tool_mut(text("tool_use_id").unwrap_or_default()) { | |
| 1225 | + backgrounded |= tool.background.is_some(); | |
| 1226 | + if tool.background.is_some() { | |
| 1227 | + tool.background = Some(status); | |
| 1228 | + } | |
| 1229 | + } | |
| 1230 | + // Long foreground commands report finishing too; only | |
| 1231 | + // background tasks wake Claude. | |
| 1232 | + if !backgrounded { | |
| 1233 | + return; | |
| 1234 | + } | |
| 1235 | + let summary = text("summary").unwrap_or("A background task finished.").to_string(); | |
| 1236 | + self.items.push(ChatItem::TaskEvent { summary: summary.into(), status }); | |
| 1237 | + self.updated = Instant::now(); | |
| 896 | 1238 | } |
| 897 | 1239 | Some("compact_boundary") => { |
| 898 | 1240 | self.push_notice(NoticeKind::Info, "Conversation compacted to free up context.") |
| @@ -1128,17 +1470,20 @@ impl Session { | ||
| 1128 | 1470 | state: PermissionState::Pending, |
| 1129 | 1471 | }; |
| 1130 | 1472 | self.updated = Instant::now(); |
| 1473 | + let name = request.get("tool_name").and_then(Value::as_str).unwrap_or("Tool"); | |
| 1474 | + let ask = (name == ASK_USER_QUESTION).then(|| UserQuestions::parse(&prompt.input)).flatten(); | |
| 1131 | 1475 | let tool_use_id = request.get("tool_use_id").and_then(Value::as_str).unwrap_or_default(); |
| 1132 | 1476 | if let Some(tool) = self.find_tool_mut(tool_use_id) { |
| 1133 | 1477 | tool.input = prompt.input.clone(); |
| 1134 | 1478 | tool.permission = Some(prompt); |
| 1479 | + tool.ask = ask; | |
| 1135 | 1480 | return; |
| 1136 | 1481 | } |
| 1137 | - let name = request.get("tool_name").and_then(Value::as_str).unwrap_or("Tool"); | |
| 1138 | 1482 | let ix = self.push_tool(tool_use_id, name, prompt.input.clone()); |
| 1139 | 1483 | if let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) { |
| 1140 | 1484 | tool.permission = Some(prompt); |
| 1141 | 1485 | tool.detached = true; |
| 1486 | + tool.ask = ask; | |
| 1142 | 1487 | } |
| 1143 | 1488 | } |
| 1144 | 1489 | |
| @@ -1153,6 +1498,8 @@ impl Session { | ||
| 1153 | 1498 | PendingControl::SetPermissionMode => "change permission mode", |
| 1154 | 1499 | PendingControl::FlagSettings => "change reasoning or fast mode", |
| 1155 | 1500 | PendingControl::Interrupt => "interrupt", |
| 1501 | + PendingControl::Background => "move the command to the background", | |
| 1502 | + PendingControl::StopTask => "stop the background task", | |
| 1156 | 1503 | }; |
| 1157 | 1504 | self.push_notice(NoticeKind::Error, format!("Failed to {action}: {error}")); |
| 1158 | 1505 | return; |
| @@ -1459,6 +1806,9 @@ impl Session { | ||
| 1459 | 1806 | expanded: false, |
| 1460 | 1807 | subagent_tool_count: 0, |
| 1461 | 1808 | detached: false, |
| 1809 | + ask: None, | |
| 1810 | + task_id: None, | |
| 1811 | + background: None, | |
| 1462 | 1812 | })); |
| 1463 | 1813 | self.items.len() - 1 |
| 1464 | 1814 | } |
| @@ -1508,6 +1858,19 @@ impl Session { | ||
| 1508 | 1858 | } |
| 1509 | 1859 | } |
| 1510 | 1860 | |
| 1861 | + /// Background tasks die with the agent process. | |
| 1862 | + fn drop_background_tasks(&mut self) { | |
| 1863 | + self.background_tasks.clear(); | |
| 1864 | + self.task_origins.clear(); | |
| 1865 | + for item in &mut self.items { | |
| 1866 | + if let ChatItem::Tool(tool) = item | |
| 1867 | + && tool.background == Some(TaskStatus::Running) | |
| 1868 | + { | |
| 1869 | + tool.background = Some(TaskStatus::Stopped); | |
| 1870 | + } | |
| 1871 | + } | |
| 1872 | + } | |
| 1873 | + | |
| 1511 | 1874 | fn finish_open_tools(&mut self, reason: &str) { |
| 1512 | 1875 | for item in &mut self.items { |
| 1513 | 1876 | if let ChatItem::Tool(tool) = item |
| @@ -23,7 +23,7 @@ use crate::git; | ||
| 23 | 23 | use crate::models::{Catalog, ModelEntry, Provider}; |
| 24 | 24 | use crate::session::{ |
| 25 | 25 | ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, |
| 26 | - ToolCall, ToolState, tool_field, tool_summary, | |
| 26 | + TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, | |
| 27 | 27 | }; |
| 28 | 28 | use crate::ui::changes::{self, DiffView}; |
| 29 | 29 | use crate::ui::icons::Lucide; |
| @@ -32,6 +32,8 @@ use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent}; | ||
| 32 | 32 | use crate::ui::workspace::ProjectDirectory; |
| 33 | 33 | |
| 34 | 34 | const CONTENT_WIDTH: f32 = 760.; |
| 35 | + const COMPOSER_PLACEHOLDER: &str = "Ask for changes, send follow-ups, or attach images"; | |
| 36 | + const ANSWER_PLACEHOLDER: &str = "Type your own answer, or pick an option above"; | |
| 35 | 37 | const MAX_TOOL_OUTPUT: usize = 6_000; |
| 36 | 38 | |
| 37 | 39 | /// What the conversation asks of the workspace around it. |
| @@ -61,6 +63,8 @@ pub struct ChatView { | ||
| 61 | 63 | open_picker: Option<Picker>, |
| 62 | 64 | attachments: Vec<PathBuf>, |
| 63 | 65 | diff: Option<DiffView>, |
| 66 | + /// Whether the composer is set up to answer an AskUserQuestion form. | |
| 67 | + answering: bool, | |
| 64 | 68 | _subscriptions: Vec<Subscription>, |
| 65 | 69 | _ticker: Task<()>, |
| 66 | 70 | } |
| @@ -73,14 +77,21 @@ impl ChatView { | ||
| 73 | 77 | TextareaState::new(window, cx) |
| 74 | 78 | .auto_grow(2, 12) |
| 75 | 79 | .submit_on_enter(!crate::settings::get(|settings| settings.send_with_ctrl_enter)) |
| 76 | - .placeholder("Ask for changes, send follow-ups, or attach images") | |
| 80 | + .placeholder(COMPOSER_PLACEHOLDER) | |
| 77 | 81 | }); |
| 78 | 82 | let item_count = session.read(cx).items.len(); |
| 79 | 83 | let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx)); |
| 80 | 84 | let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx)); |
| 81 | 85 | |
| 82 | 86 | let subscriptions = vec![ |
| 83 | - cx.observe(&session, |this, session, cx| { | |
| 87 | + cx.observe_in(&session, window, |this, session, window, cx| { | |
| 88 | + // While Claude waits on a question, the composer takes a custom answer. | |
| 89 | + let answering = session.read(cx).pending_ask().is_some(); | |
| 90 | + if answering != this.answering { | |
| 91 | + this.answering = answering; | |
| 92 | + let placeholder = if answering { ANSWER_PLACEHOLDER } else { COMPOSER_PLACEHOLDER }; | |
| 93 | + this.composer.update(cx, |composer, cx| composer.set_placeholder(placeholder, window, cx)); | |
| 94 | + } | |
| 84 | 95 | let count = session.read(cx).items.len(); |
| 85 | 96 | this.scroller.update(cx, |scroller, cx| { |
| 86 | 97 | let current = scroller.item_count(); |
| @@ -118,7 +129,8 @@ impl ChatView { | ||
| 118 | 129 | loop { |
| 119 | 130 | cx.background_executor().timer(Duration::from_secs(1)).await; |
| 120 | 131 | let alive = this.update(cx, |this, cx| { |
| 121 | - if this.session.read(cx).is_working() { | |
| 132 | + let session = this.session.read(cx); | |
| 133 | + if session.is_working() || !session.background_tasks.is_empty() { | |
| 122 | 134 | cx.notify(); |
| 123 | 135 | } |
| 124 | 136 | }); |
| @@ -136,6 +148,7 @@ impl ChatView { | ||
| 136 | 148 | open_picker: None, |
| 137 | 149 | attachments: Vec::new(), |
| 138 | 150 | diff: None, |
| 151 | + answering: false, | |
| 139 | 152 | _subscriptions: subscriptions, |
| 140 | 153 | _ticker: ticker, |
| 141 | 154 | } |
| @@ -170,6 +183,17 @@ impl ChatView { | ||
| 170 | 183 | |
| 171 | 184 | fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 172 | 185 | let text = self.composer.read(cx).value().trim().to_string(); |
| 186 | + // An open question takes the text as a custom answer; Enter on an | |
| 187 | + // empty composer commits the options ticked on a multi-select one. | |
| 188 | + if let Some((ix, _)) = self.session.read(cx).pending_ask() { | |
| 189 | + if text.is_empty() { | |
| 190 | + self.session.update(cx, |session, cx| session.ask_confirm(ix, cx)); | |
| 191 | + } else { | |
| 192 | + self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)); | |
| 193 | + self.session.update(cx, |session, cx| session.ask_custom(ix, text, cx)); | |
| 194 | + } | |
| 195 | + return; | |
| 196 | + } | |
| 173 | 197 | if text.is_empty() && self.attachments.is_empty() { |
| 174 | 198 | return; |
| 175 | 199 | } |
| @@ -179,6 +203,23 @@ impl ChatView { | ||
| 179 | 203 | self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx)); |
| 180 | 204 | } |
| 181 | 205 | |
| 206 | + /// 1–9 pick an option of the open question while the composer is empty. | |
| 207 | + fn pick_with_key(&mut self, event: &KeyDownEvent, cx: &mut Context<Self>) { | |
| 208 | + let keystroke = &event.keystroke; | |
| 209 | + if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() { | |
| 210 | + return; | |
| 211 | + } | |
| 212 | + let Some(digit) = keystroke.key.parse::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else { | |
| 213 | + return; | |
| 214 | + }; | |
| 215 | + let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return }; | |
| 216 | + if digit > ask.questions[ask.current].options.len() { | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + self.session.update(cx, |session, cx| session.ask_pick(ix, digit - 1, cx)); | |
| 220 | + cx.stop_propagation(); | |
| 221 | + } | |
| 222 | + | |
| 182 | 223 | fn escape(&mut self, cx: &mut Context<Self>) { |
| 183 | 224 | if self.diff.take().is_some() { |
| 184 | 225 | cx.notify(); |
| @@ -416,6 +457,78 @@ impl ChatView { | ||
| 416 | 457 | }) |
| 417 | 458 | } |
| 418 | 459 | |
| 460 | + /// What Claude left running in the background, each with its age and a | |
| 461 | + /// stop button. Claude is woken up when one finishes. | |
| 462 | + fn render_background_tasks(&self, cx: &mut Context<Self>) -> Option<AnyElement> { | |
| 463 | + let p = metal::palette(cx); | |
| 464 | + let session = self.session.read(cx); | |
| 465 | + if session.background_tasks.is_empty() { | |
| 466 | + return None; | |
| 467 | + } | |
| 468 | + let count = session.background_tasks.len(); | |
| 469 | + let rows = session.background_tasks.iter().enumerate().map(|(row, task)| { | |
| 470 | + let elapsed = task.started.elapsed().as_secs(); | |
| 471 | + let stop_session = self.session.clone(); | |
| 472 | + let task_id = task.task_id.clone(); | |
| 473 | + h_flex() | |
| 474 | + .h(px(28.)) | |
| 475 | + .gap(px(10.)) | |
| 476 | + .px(px(4.)) | |
| 477 | + .child(metal::led(("background-led", row), Led::Live, true, p)) | |
| 478 | + .child( | |
| 479 | + div() | |
| 480 | + .flex_1() | |
| 481 | + .min_w_0() | |
| 482 | + .truncate() | |
| 483 | + .text_size(px(12.5)) | |
| 484 | + .text_color(p.ink2) | |
| 485 | + .child(task.description.clone()), | |
| 486 | + ) | |
| 487 | + .child( | |
| 488 | + div() | |
| 489 | + .font_family(metal::PIXEL) | |
| 490 | + .text_size(px(14.)) | |
| 491 | + .text_color(p.ink2) | |
| 492 | + .child(format!("{:02}:{:02}", elapsed / 60, elapsed % 60)), | |
| 493 | + ) | |
| 494 | + .child( | |
| 495 | + MetalButton::new(("background-stop", row)) | |
| 496 | + .ghost() | |
| 497 | + .mini() | |
| 498 | + .icon(IconName::Square) | |
| 499 | + .tooltip("Stop this task") | |
| 500 | + .on_click(move |_, _, cx| { | |
| 501 | + stop_session.update(cx, |session, cx| session.stop_task(&task_id, cx)) | |
| 502 | + }), | |
| 503 | + ) | |
| 504 | + }); | |
| 505 | + Some( | |
| 506 | + v_flex() | |
| 507 | + .w_full() | |
| 508 | + .max_w(px(CONTENT_WIDTH)) | |
| 509 | + .px(px(10.)) | |
| 510 | + .py(px(6.)) | |
| 511 | + .rounded(px(14.)) | |
| 512 | + .region(p) | |
| 513 | + .child( | |
| 514 | + h_flex() | |
| 515 | + .h(px(18.)) | |
| 516 | + .px(px(4.)) | |
| 517 | + .gap(px(8.)) | |
| 518 | + .child(metal::engraved( | |
| 519 | + if count == 1 { "1 background task".to_string() } else { format!("{count} background tasks") }, | |
| 520 | + p, | |
| 521 | + )) | |
| 522 | + .child(div().flex_1()) | |
| 523 | + .when(!session.is_working(), |this| { | |
| 524 | + this.child(metal::engraved("Claude reports back when it finishes", p)) | |
| 525 | + }), | |
| 526 | + ) | |
| 527 | + .children(rows) | |
| 528 | + .into_any_element(), | |
| 529 | + ) | |
| 530 | + } | |
| 531 | + | |
| 419 | 532 | fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 420 | 533 | let p = metal::palette(cx); |
| 421 | 534 | h_flex() |
| @@ -544,6 +657,7 @@ impl ChatView { | ||
| 544 | 657 | let has_input = |
| 545 | 658 | !self.composer.read(cx).value().trim().is_empty() || !self.attachments.is_empty(); |
| 546 | 659 | let focused = self.composer.read(cx).focus_handle(cx).is_focused(window); |
| 660 | + let ask_panel = session.pending_ask().map(|(ix, ask)| render_ask_panel(&self.session, ix, ask, p)); | |
| 547 | 661 | |
| 548 | 662 | let send_button = if working && !has_input { |
| 549 | 663 | MetalButton::new("stop") |
| @@ -569,7 +683,9 @@ impl ChatView { | ||
| 569 | 683 | |
| 570 | 684 | // MetalUI marks focus with a green-deep ring. |
| 571 | 685 | let mut card_shadow = p.raise.clone(); |
| 572 | - if focused { | |
| 686 | + if ask_panel.is_some() { | |
| 687 | + card_shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); | |
| 688 | + } else if focused { | |
| 573 | 689 | card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.))); |
| 574 | 690 | } |
| 575 | 691 | |
| @@ -647,6 +763,7 @@ impl ChatView { | ||
| 647 | 763 | .rounded(px(24.)) |
| 648 | 764 | .bg(metal::vertical((p.s_hi, p.s))) |
| 649 | 765 | .shadow(card_shadow) |
| 766 | + .children(ask_panel) | |
| 650 | 767 | .when(!self.attachments.is_empty(), |this| this.child(self.render_attachments(cx))) |
| 651 | 768 | .child( |
| 652 | 769 | div() |
| @@ -666,6 +783,7 @@ impl ChatView { | ||
| 666 | 783 | .pb(px(if start { 0. } else { 20. })) |
| 667 | 784 | .gap(px(8.)) |
| 668 | 785 | .when(!start, |this| this.child(self.render_activity(cx))) |
| 786 | + .when(!start, |this| this.children(self.render_background_tasks(cx))) | |
| 669 | 787 | .child( |
| 670 | 788 | v_flex() |
| 671 | 789 | .w_full() |
| @@ -752,6 +870,7 @@ impl Render for ChatView { | ||
| 752 | 870 | .size_full() |
| 753 | 871 | .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx))) |
| 754 | 872 | .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx))) |
| 873 | + .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| this.pick_with_key(event, cx))) | |
| 755 | 874 | .map(|this| match &self.diff { |
| 756 | 875 | Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, close_diff, cx))), |
| 757 | 876 | None if is_empty => this.child(self.render_start(window, cx)), |
| @@ -917,6 +1036,340 @@ fn render_question(session: &Entity<Session>, ix: usize, question: &Question, p: | ||
| 917 | 1036 | .into_any_element() |
| 918 | 1037 | } |
| 919 | 1038 | |
| 1039 | + /// Claude's AskUserQuestion form, docked on top of the composer: one | |
| 1040 | + /// question at a time, its options as numbered rows. The composer below takes | |
| 1041 | + /// a custom answer. | |
| 1042 | + fn render_ask_panel(session: &Entity<Session>, ix: usize, ask: &UserQuestions, p: &Palette) -> AnyElement { | |
| 1043 | + let current = ask.current; | |
| 1044 | + let question = &ask.questions[current]; | |
| 1045 | + let picked = &ask.picked[current]; | |
| 1046 | + let answer = ask.answers[current].as_ref(); | |
| 1047 | + let count = ask.questions.len(); | |
| 1048 | + | |
| 1049 | + let tabs = h_flex().gap(px(4.)).flex_wrap().when(count > 1, |this| { | |
| 1050 | + this.children(ask.questions.iter().enumerate().map(|(q, question)| { | |
| 1051 | + let session = session.clone(); | |
| 1052 | + let selected = q == current; | |
| 1053 | + let label: SharedString = | |
| 1054 | + if question.header.is_empty() { format!("Q{}", q + 1).into() } else { question.header.clone() }; | |
| 1055 | + h_flex() | |
| 1056 | + .id(ElementId::NamedInteger("ask-tab".into(), q as u64)) | |
| 1057 | + .h(px(22.)) | |
| 1058 | + .px(px(9.)) | |
| 1059 | + .gap(px(5.)) | |
| 1060 | + .rounded_full() | |
| 1061 | + .text_size(px(12.)) | |
| 1062 | + .font_weight(FontWeight::MEDIUM) | |
| 1063 | + .map(|this| { | |
| 1064 | + if selected { | |
| 1065 | + this.bg(metal::vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink) | |
| 1066 | + } else { | |
| 1067 | + this.cursor_pointer().text_color(p.ink3).hover(|style| style.text_color(p.ink)) | |
| 1068 | + } | |
| 1069 | + }) | |
| 1070 | + .when(ask.answers[q].is_some(), |this| { | |
| 1071 | + this.child(Icon::new(IconName::Check).size(px(11.)).text_color(p.green_ink)) | |
| 1072 | + }) | |
| 1073 | + .child(label) | |
| 1074 | + .on_click(move |_, _, cx| session.update(cx, |session, cx| session.ask_show(ix, q, cx))) | |
| 1075 | + })) | |
| 1076 | + }); | |
| 1077 | + | |
| 1078 | + let skip_session = session.clone(); | |
| 1079 | + let header = h_flex() | |
| 1080 | + .gap(px(10.)) | |
| 1081 | + .child(metal::led(("ask-led", ix), Led::Waiting, true, p)) | |
| 1082 | + .child( | |
| 1083 | + metal::engraved( | |
| 1084 | + if count > 1 { format!("Question {} of {count}", current + 1) } else { "Question".to_string() }, | |
| 1085 | + p, | |
| 1086 | + ) | |
| 1087 | + .text_color(p.amber_ink), | |
| 1088 | + ) | |
| 1089 | + .child(tabs) | |
| 1090 | + .child(div().flex_1()) | |
| 1091 | + .child( | |
| 1092 | + MetalButton::new(("ask-skip", ix)) | |
| 1093 | + .ghost() | |
| 1094 | + .mini() | |
| 1095 | + .label("Skip") | |
| 1096 | + .tooltip("Dismiss without answering") | |
| 1097 | + .on_click(move |_, _, cx| skip_session.update(cx, |session, cx| session.ask_skip(ix, cx))), | |
| 1098 | + ); | |
| 1099 | + | |
| 1100 | + let options = v_flex().gap(px(4.)).children(question.options.iter().enumerate().map(|(option, choice)| { | |
| 1101 | + let session = session.clone(); | |
| 1102 | + let selected = if question.multi_select { | |
| 1103 | + picked.contains(&option) | |
| 1104 | + } else { | |
| 1105 | + answer.is_some_and(|answer| *answer == choice.label) | |
| 1106 | + }; | |
| 1107 | + let marker = div() | |
| 1108 | + .flex() | |
| 1109 | + .flex_none() | |
| 1110 | + .items_center() | |
| 1111 | + .justify_center() | |
| 1112 | + .size(px(16.)) | |
| 1113 | + .map(|this| if question.multi_select { this.rounded(px(5.)) } else { this.rounded_full() }) | |
| 1114 | + .map(|this| { | |
| 1115 | + if selected { | |
| 1116 | + this.bg(metal::vertical((p.green_ink, p.green_deep))).shadow(p.pressed_sh.clone()) | |
| 1117 | + } else { | |
| 1118 | + this.well(p) | |
| 1119 | + } | |
| 1120 | + }) | |
| 1121 | + .when(selected, |this| { | |
| 1122 | + this.child(Icon::new(IconName::Check).size(px(11.)).text_color(gpui_kit::white())) | |
| 1123 | + }); | |
| 1124 | + h_flex() | |
| 1125 | + .id(ElementId::NamedInteger(format!("ask-{ix}-option").into(), option as u64)) | |
| 1126 | + .items_start() | |
| 1127 | + .gap(px(10.)) | |
| 1128 | + .px(px(10.)) | |
| 1129 | + .py(px(8.)) | |
| 1130 | + .rounded(px(12.)) | |
| 1131 | + .cursor_pointer() | |
| 1132 | + .map(|this| { | |
| 1133 | + if selected { | |
| 1134 | + this.bg(metal::vertical(p.row_on)).shadow({ | |
| 1135 | + let mut shadow = p.raise_sm.clone(); | |
| 1136 | + shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.45)).spread_radius(px(1.))); | |
| 1137 | + shadow | |
| 1138 | + }) | |
| 1139 | + } else { | |
| 1140 | + this.hover(|style| style.bg(p.row_hover)) | |
| 1141 | + } | |
| 1142 | + }) | |
| 1143 | + .on_click(move |_, _, cx| session.update(cx, |session, cx| session.ask_pick(ix, option, cx))) | |
| 1144 | + .when(option < 9, |this| this.child(metal::keycap((option + 1).to_string(), p))) | |
| 1145 | + .child( | |
| 1146 | + v_flex() | |
| 1147 | + .flex_1() | |
| 1148 | + .min_w_0() | |
| 1149 | + .gap(px(2.)) | |
| 1150 | + .child( | |
| 1151 | + div() | |
| 1152 | + .text_size(px(13.5)) | |
| 1153 | + .font_weight(FontWeight::MEDIUM) | |
| 1154 | + .text_color(p.ink) | |
| 1155 | + .child(choice.label.clone()), | |
| 1156 | + ) | |
| 1157 | + .when_some(choice.description.clone().filter(|description| *description != choice.label), |this, description| { | |
| 1158 | + this.child(div().text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(description)) | |
| 1159 | + }), | |
| 1160 | + ) | |
| 1161 | + .child(div().pt(px(1.)).child(marker)) | |
| 1162 | + })); | |
| 1163 | + | |
| 1164 | + let hint = match (question.multi_select, answer) { | |
| 1165 | + (_, Some(answer)) if !question.options.iter().any(|option| option.label == *answer) && !question.multi_select => { | |
| 1166 | + format!("Your answer: {answer}") | |
| 1167 | + } | |
| 1168 | + (true, _) => "Pick any that apply, or type your own answer below.".to_string(), | |
| 1169 | + (false, _) => "Pick one, or type your own answer below.".to_string(), | |
| 1170 | + }; | |
| 1171 | + let last_open = ask.answers.iter().enumerate().all(|(q, answer)| q == current || answer.is_some()); | |
| 1172 | + let confirm_session = session.clone(); | |
| 1173 | + let footer = h_flex() | |
| 1174 | + .gap(px(8.)) | |
| 1175 | + .child(div().flex_1().min_w_0().text_size(px(12.)).text_color(p.ink3).child(hint)) | |
| 1176 | + .when(question.multi_select, |this| { | |
| 1177 | + this.child( | |
| 1178 | + MetalButton::new(("ask-confirm", ix)) | |
| 1179 | + .primary() | |
| 1180 | + .compact() | |
| 1181 | + .label(if last_open { "Submit" } else { "Next" }) | |
| 1182 | + .disabled(picked.is_empty()) | |
| 1183 | + .on_click(move |_, _, cx| confirm_session.update(cx, |session, cx| session.ask_confirm(ix, cx))), | |
| 1184 | + ) | |
| 1185 | + }); | |
| 1186 | + | |
| 1187 | + v_flex() | |
| 1188 | + .gap(px(10.)) | |
| 1189 | + .px(px(16.)) | |
| 1190 | + .pt(px(14.)) | |
| 1191 | + .pb(px(12.)) | |
| 1192 | + .child(header) | |
| 1193 | + .child( | |
| 1194 | + div() | |
| 1195 | + .text_size(px(15.)) | |
| 1196 | + .line_height(px(22.)) | |
| 1197 | + .font_weight(FontWeight::SEMIBOLD) | |
| 1198 | + .text_color(p.ink) | |
| 1199 | + .child(question.question.clone()), | |
| 1200 | + ) | |
| 1201 | + .child(options) | |
| 1202 | + .child(footer) | |
| 1203 | + .child(metal::rule(p).mt(px(2.))) | |
| 1204 | + .into_any_element() | |
| 1205 | + } | |
| 1206 | + | |
| 1207 | + /// What an AskUserQuestion call asked and what the user answered, as it | |
| 1208 | + /// stays in the transcript. | |
| 1209 | + fn render_ask_record(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement { | |
| 1210 | + let state = tool.permission.as_ref().map(|prompt| prompt.state); | |
| 1211 | + let pending = state == Some(PermissionState::Pending); | |
| 1212 | + let status = match state { | |
| 1213 | + Some(PermissionState::Pending) => "Waiting for your answer", | |
| 1214 | + Some(PermissionState::Denied) => "Skipped", | |
| 1215 | + Some(PermissionState::Cancelled) => "Cancelled", | |
| 1216 | + _ => "Answered", | |
| 1217 | + }; | |
| 1218 | + let mut shadow = p.raise_sm.clone(); | |
| 1219 | + if pending { | |
| 1220 | + shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.))); | |
| 1221 | + } | |
| 1222 | + v_flex() | |
| 1223 | + .w_full() | |
| 1224 | + .gap(px(10.)) | |
| 1225 | + .px(px(14.)) | |
| 1226 | + .py(px(12.)) | |
| 1227 | + .rounded(px(14.)) | |
| 1228 | + .bg(metal::vertical(p.row_on)) | |
| 1229 | + .shadow(shadow) | |
| 1230 | + .child( | |
| 1231 | + h_flex() | |
| 1232 | + .gap(px(8.)) | |
| 1233 | + .child(Icon::new(Lucide::MessageCircleQuestionMark).size(px(14.)).text_color(if pending { | |
| 1234 | + p.amber_ink | |
| 1235 | + } else { | |
| 1236 | + p.ink3 | |
| 1237 | + })) | |
| 1238 | + .child(metal::engraved(if ask.questions.len() > 1 { "Questions" } else { "Question" }, p).when( | |
| 1239 | + pending, | |
| 1240 | + |this| this.text_color(p.amber_ink), | |
| 1241 | + )) | |
| 1242 | + .child(div().flex_1()) | |
| 1243 | + .child(metal::engraved(status, p).when(state == Some(PermissionState::Allowed), |this| { | |
| 1244 | + this.text_color(p.green_ink) | |
| 1245 | + })), | |
| 1246 | + ) | |
| 1247 | + .children(ask.questions.iter().zip(&ask.answers).enumerate().map(|(q, (question, answer))| { | |
| 1248 | + // Headers sit in a fixed column so questions and answers line up. | |
| 1249 | + h_flex() | |
| 1250 | + .id(ElementId::NamedInteger(format!("ask-record-{ix}").into(), q as u64)) | |
| 1251 | + .items_start() | |
| 1252 | + .gap(px(8.)) | |
| 1253 | + .child(div().w(px(76.)).flex_none().pt(px(2.)).when(!question.header.is_empty(), |this| { | |
| 1254 | + this.child(metal::tag(question.header.clone(), p)) | |
| 1255 | + })) | |
| 1256 | + .child( | |
| 1257 | + v_flex() | |
| 1258 | + .flex_1() | |
| 1259 | + .min_w_0() | |
| 1260 | + .gap(px(4.)) | |
| 1261 | + .child( | |
| 1262 | + div() | |
| 1263 | + .text_size(px(13.5)) | |
| 1264 | + .font_weight(FontWeight::SEMIBOLD) | |
| 1265 | + .text_color(p.ink) | |
| 1266 | + .child(question.question.clone()), | |
| 1267 | + ) | |
| 1268 | + .when_some(answer.clone(), |this, answer| { | |
| 1269 | + this.child( | |
| 1270 | + h_flex() | |
| 1271 | + .items_start() | |
| 1272 | + .gap(px(6.)) | |
| 1273 | + .text_size(px(13.)) | |
| 1274 | + .text_color(p.ink2) | |
| 1275 | + .child( | |
| 1276 | + Icon::new(Lucide::CornerDownRight) | |
| 1277 | + .size(px(12.)) | |
| 1278 | + .mt(px(3.)) | |
| 1279 | + .text_color(p.green_ink), | |
| 1280 | + ) | |
| 1281 | + .child(div().flex_1().min_w_0().child(answer)), | |
| 1282 | + ) | |
| 1283 | + }), | |
| 1284 | + ) | |
| 1285 | + })) | |
| 1286 | + .into_any_element() | |
| 1287 | + } | |
| 1288 | + | |
| 1289 | + /// Where a tool's background task stands, and the buttons to move a long | |
| 1290 | + /// command to the background or stop it there. | |
| 1291 | + fn render_task_controls(session: &Entity<Session>, ix: usize, tool: &ToolCall, p: &Palette) -> Option<AnyElement> { | |
| 1292 | + let session = session.clone(); | |
| 1293 | + match (tool.background, &tool.task_id) { | |
| 1294 | + (Some(TaskStatus::Running), Some(task_id)) => { | |
| 1295 | + let task_id = task_id.clone(); | |
| 1296 | + Some( | |
| 1297 | + h_flex() | |
| 1298 | + .gap(px(6.)) | |
| 1299 | + .child(metal::tag("Background", p)) | |
| 1300 | + .child( | |
| 1301 | + MetalButton::new(("tool-stop-task", ix)) | |
| 1302 | + .ghost() | |
| 1303 | + .mini() | |
| 1304 | + .icon(IconName::Square) | |
| 1305 | + .tooltip("Stop this task") | |
| 1306 | + .on_click(move |_, _, cx| { | |
| 1307 | + cx.stop_propagation(); | |
| 1308 | + session.update(cx, |session, cx| session.stop_task(&task_id, cx)); | |
| 1309 | + }), | |
| 1310 | + ) | |
| 1311 | + .into_any_element(), | |
| 1312 | + ) | |
| 1313 | + } | |
| 1314 | + (Some(status), _) => Some( | |
| 1315 | + metal::engraved( | |
| 1316 | + match status { | |
| 1317 | + TaskStatus::Running | TaskStatus::Completed => "Ran in background", | |
| 1318 | + TaskStatus::Failed => "Failed in background", | |
| 1319 | + TaskStatus::Stopped => "Stopped", | |
| 1320 | + }, | |
| 1321 | + p, | |
| 1322 | + ) | |
| 1323 | + .into_any_element(), | |
| 1324 | + ), | |
| 1325 | + // A long foreground command can be sent to the background. | |
| 1326 | + (None, Some(_)) if tool.state == ToolState::Running => Some( | |
| 1327 | + MetalButton::new(("tool-background", ix)) | |
| 1328 | + .ghost() | |
| 1329 | + .mini() | |
| 1330 | + .label("Background") | |
| 1331 | + .tooltip("Keep it running in the background; Claude carries on and hears when it finishes") | |
| 1332 | + .on_click(move |_, _, cx| { | |
| 1333 | + cx.stop_propagation(); | |
| 1334 | + session.update(cx, |session, cx| session.background_tool(ix, cx)); | |
| 1335 | + }) | |
| 1336 | + .into_any_element(), | |
| 1337 | + ), | |
| 1338 | + _ => None, | |
| 1339 | + } | |
| 1340 | + } | |
| 1341 | + | |
| 1342 | + /// A background task finished. Claude is woken up by it, so it heads the | |
| 1343 | + /// turn that follows. | |
| 1344 | + fn render_task_event(ix: usize, summary: &SharedString, status: TaskStatus, p: &Palette) -> AnyElement { | |
| 1345 | + let (icon, color) = match status { | |
| 1346 | + TaskStatus::Completed => (IconName::CircleCheck, p.green_ink), | |
| 1347 | + TaskStatus::Running => (IconName::Info, p.blue_ink), | |
| 1348 | + TaskStatus::Failed => (IconName::CircleX, p.red), | |
| 1349 | + TaskStatus::Stopped => (IconName::Square, p.ink3), | |
| 1350 | + }; | |
| 1351 | + h_flex() | |
| 1352 | + .id(("task-event", ix)) | |
| 1353 | + .items_start() | |
| 1354 | + .gap(px(10.)) | |
| 1355 | + .px(px(12.)) | |
| 1356 | + .py(px(8.)) | |
| 1357 | + .rounded(px(12.)) | |
| 1358 | + .region(p) | |
| 1359 | + .child(Icon::new(icon).size(px(13.)).mt(px(2.)).text_color(color)) | |
| 1360 | + .child(div().flex_1().min_w_0().text_size(px(13.)).text_color(p.ink2).child(summary.clone())) | |
| 1361 | + .child(div().pt(px(2.)).child(metal::engraved( | |
| 1362 | + match status { | |
| 1363 | + TaskStatus::Stopped => "Background task stopped", | |
| 1364 | + TaskStatus::Failed => "Background task failed", | |
| 1365 | + TaskStatus::Running => "Background task event", | |
| 1366 | + TaskStatus::Completed => "Background task", | |
| 1367 | + }, | |
| 1368 | + p, | |
| 1369 | + ))) | |
| 1370 | + .into_any_element() | |
| 1371 | + } | |
| 1372 | + | |
| 920 | 1373 | /// Renders one transcript row. Runs inside the virtual list, so it only gets |
| 921 | 1374 | /// `App` and talks to the session entity directly. |
| 922 | 1375 | fn render_item( |
| @@ -947,6 +1400,7 @@ fn render_item( | ||
| 947 | 1400 | let visible = match (&turn, item) { |
| 948 | 1401 | (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(), |
| 949 | 1402 | (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true, |
| 1403 | + (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true, | |
| 950 | 1404 | (Some(turn), _) => expanded || Some(ix) == turn.answer, |
| 951 | 1405 | (None, _) => true, |
| 952 | 1406 | }; |
| @@ -996,6 +1450,7 @@ fn render_item( | ||
| 996 | 1450 | .into_any_element(), |
| 997 | 1451 | ChatItem::Tool(tool) => render_tool(session, ix, tool, &state.cwd, cx), |
| 998 | 1452 | ChatItem::Question(question) => render_question(session, ix, question, p), |
| 1453 | + ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p), | |
| 999 | 1454 | ChatItem::Notice(NoticeKind::Info, text) => h_flex() |
| 1000 | 1455 | .items_start() |
| 1001 | 1456 | .gap(px(8.)) |
| @@ -1055,8 +1510,10 @@ fn render_item( | ||
| 1055 | 1510 | .w_full() |
| 1056 | 1511 | .max_w(px(CONTENT_WIDTH)) |
| 1057 | 1512 | .gap(px(12.)) |
| 1058 | - .children(summary) | |
| 1059 | - .children(content), | |
| 1513 | + .map(|this| match item { | |
| 1514 | + ChatItem::TaskEvent { .. } => this.children(content).children(summary), | |
| 1515 | + _ => this.children(summary).children(content), | |
| 1516 | + }), | |
| 1060 | 1517 | ) |
| 1061 | 1518 | .into_any_element() |
| 1062 | 1519 | } |
| @@ -1069,6 +1526,9 @@ fn render_tool( | ||
| 1069 | 1526 | cx: &App, |
| 1070 | 1527 | ) -> AnyElement { |
| 1071 | 1528 | let p = metal::palette(cx); |
| 1529 | + if let Some(ask) = &tool.ask { | |
| 1530 | + return render_ask_record(ix, tool, ask, p); | |
| 1531 | + } | |
| 1072 | 1532 | let summary = tool_summary(&tool.name, &tool.input, cwd); |
| 1073 | 1533 | let pending = tool |
| 1074 | 1534 | .permission |
| @@ -1078,6 +1538,14 @@ fn render_tool( | ||
| 1078 | 1538 | |
| 1079 | 1539 | let status: AnyElement = match (tool.state, pending) { |
| 1080 | 1540 | (_, true) => metal::led(("tool-led", ix), Led::Waiting, true, p), |
| 1541 | + // A backgrounded tool returns at once; what counts is its task. | |
| 1542 | + _ if tool.background == Some(TaskStatus::Running) => metal::led(("tool-led", ix), Led::Live, true, p), | |
| 1543 | + _ if tool.background == Some(TaskStatus::Failed) => { | |
| 1544 | + Icon::new(IconName::CircleX).size(px(13.)).text_color(p.red).into_any_element() | |
| 1545 | + } | |
| 1546 | + _ if tool.background == Some(TaskStatus::Stopped) => { | |
| 1547 | + Icon::new(IconName::Square).size(px(11.)).text_color(p.ink3).into_any_element() | |
| 1548 | + } | |
| 1081 | 1549 | (ToolState::Running, _) => metal::led(("tool-led", ix), Led::Live, true, p), |
| 1082 | 1550 | (ToolState::Done, _) => Icon::new(IconName::Check) |
| 1083 | 1551 | .size(px(13.)) |
| @@ -1131,6 +1599,7 @@ fn render_tool( | ||
| 1131 | 1599 | .when_some(decision, |this, (label, color)| { |
| 1132 | 1600 | this.child(metal::engraved(label, p).text_color(color)) |
| 1133 | 1601 | }) |
| 1602 | + .children(render_task_controls(session, ix, tool, p)) | |
| 1134 | 1603 | .child( |
| 1135 | 1604 | Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight }) |
| 1136 | 1605 | .size(px(12.)) |
| @@ -1308,6 +1777,7 @@ fn tool_icon(name: &str) -> IconName { | ||
| 1308 | 1777 | "task" | "agent" => IconName::Bot, |
| 1309 | 1778 | "todowrite" => IconName::CircleCheck, |
| 1310 | 1779 | "exitplanmode" => IconName::Map, |
| 1780 | + "monitor" => IconName::Eye, | |
| 1311 | 1781 | _ => IconName::Settings2, |
| 1312 | 1782 | } |
| 1313 | 1783 | } |
| @@ -13,6 +13,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 13 | 13 | Brain, |
| 14 | 14 | ChevronsUpDown, |
| 15 | 15 | Code, |
| 16 | + CornerDownRight, | |
| 16 | 17 | FileCode, |
| 17 | 18 | FileDiff, |
| 18 | 19 | FolderPlus, |
| @@ -24,6 +25,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 24 | 25 | Laptop, |
| 25 | 26 | Lock, |
| 26 | 27 | LockOpen, |
| 28 | + MessageCircleQuestionMark, | |
| 27 | 29 | PanelLeftClose, |
| 28 | 30 | PanelLeftOpen, |
| 29 | 31 | PanelRightClose, |
| @@ -763,6 +763,8 @@ impl Workspace { | ||
| 763 | 763 | } else { |
| 764 | 764 | match state.status { |
| 765 | 765 | SessionStatus::Working => (Led::Live, true), |
| 766 | + // Idle, but a background task will wake Claude. | |
| 767 | + SessionStatus::Idle if !state.background_tasks.is_empty() => (Led::Live, true), | |
| 766 | 768 | SessionStatus::Idle => (Led::Live, false), |
| 767 | 769 | SessionStatus::Starting => (Led::Off, false), |
| 768 | 770 | SessionStatus::Exited => (Led::Failed, false), |