Vectrum
Sign In

Add Claude's question form and background task monitoring

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>
JjohannSocommitted 1 parent 8cf2c30commit466f3c7

Showing 6 changed files with 866 additions and 24 deletions.

+8−0README.mdView File
@@ -47,12 +47,20 @@ follow the desktop.
4747 collapsible cards with their input and output.
4848 - Inline permission prompts (Allow, Deny, "Always allow" / "Accept all edits"), and Pi
4949 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.
5054 - Attachments: images are sent inline, other files by path. A ring shows how full the
5155 context window is.
5256 - A project panel: the machine, **Open in** your editor (Zed, VS Code, Cursor, VSCodium,
5357 Sublime Text, Kate or the file manager, whichever are installed), the branch with a
5458 switcher, **Commit** (asks the agent to commit, or commit and push), the size of the
5559 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.
5664 - Interrupt with Esc or the stop button. Messages sent while the agent works are queued.
5765 - Crash recovery: if a process dies, the next message restarts it and resumes the session.
5866 - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's:
+0−3src/claude.rsView File
@@ -303,9 +303,6 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
303303 "stdio",
304304 // Lets the user switch to "Full access" at runtime.
305305 "--allow-dangerously-skip-permissions",
306- // The GUI has no question form yet; Claude asks in plain text instead.
307- "--disallowedTools",
308- "AskUserQuestion",
309306 "--permission-mode",
310307 config.permission_mode.as_cli(),
311308 ])
+377−14src/session.rsView File
@@ -19,6 +19,8 @@ use crate::models::{Catalog, Provider};
1919
2020 const DEFAULT_TITLE: &str = "New thread";
2121 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";
2224 /// The API's limit for one base64 image.
2325 const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
2426
@@ -90,6 +92,117 @@ pub struct ToolCall {
9092 /// Created from a permission request for a tool we never saw being called
9193 /// (subagent tools), so no result will ever arrive for it.
9294 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+ }
93206 }
94207
95208 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -119,6 +232,8 @@ pub enum ChatItem {
119232 Tool(ToolCall),
120233 Notice(NoticeKind, SharedString),
121234 Question(Question),
235+ /// A background task finished; Claude picks the work back up from here.
236+ TaskEvent { summary: SharedString, status: TaskStatus },
122237 TurnEnd { duration_ms: u64, cost: Option<f64> },
123238 }
124239
@@ -181,6 +296,8 @@ enum PendingControl {
181296 SetPermissionMode,
182297 FlagSettings,
183298 Interrupt,
299+ Background,
300+ StopTask,
184301 }
185302
186303 /// A finished turn, which the transcript folds into one "Worked for …" row
@@ -235,6 +352,10 @@ pub struct Session {
235352 /// Tokens in the context window as of the last response.
236353 pub context_tokens: Option<u64>,
237354 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>)>,
238359 /// Finished turns the user unfolded, keyed by their `TurnEnd` index.
239360 expanded_turns: HashSet<usize>,
240361 /// What each finished turn changed on disk, keyed by its `TurnEnd` index.
@@ -293,6 +414,8 @@ impl Session {
293414 settled: false,
294415 context_tokens: None,
295416 context_window: None,
417+ background_tasks: Vec::new(),
418+ task_origins: HashMap::new(),
296419 expanded_turns: HashSet::new(),
297420 turn_changes: HashMap::new(),
298421 expanded_dirs: HashMap::new(),
@@ -505,17 +628,42 @@ impl Session {
505628 self.title = title_from(&source).into();
506629 }
507630 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);
517632 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>) {
518640 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}));
519667 cx.notify();
520668 }
521669
@@ -531,6 +679,7 @@ impl Session {
531679 self.base_snapshot = None;
532680 self.interrupt_requested = false;
533681 self.pending_controls.clear();
682+ self.drop_background_tasks();
534683 self.finish_open_tools("The thread was settled before the tool finished.");
535684 if self.status != SessionStatus::Exited {
536685 self.status = SessionStatus::Idle;
@@ -677,7 +826,8 @@ impl Session {
677826 .rev()
678827 .find(|&ix| matches!(self.items[ix], ChatItem::Assistant(_)));
679828 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 { .. })
681831 });
682832 Some(FinishedTurn { first, answer, end, duration_ms, cost, has_work })
683833 }
@@ -739,6 +889,125 @@ impl Session {
739889 cx.notify();
740890 }
741891
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+
7421011 /// Answers an extension's question with one of its options.
7431012 pub fn answer_question(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) {
7441013 let Some(process) = &self.process else { return };
@@ -781,6 +1050,7 @@ impl Session {
7811050
7821051 fn handle_exit(&mut self, code: Option<i32>) {
7831052 self.process = None;
1053+ self.drop_background_tasks();
7841054 self.thinking = false;
7851055 self.streaming = None;
7861056 self.status = SessionStatus::Exited;
@@ -844,7 +1114,7 @@ impl Session {
8441114 let parent_tool_use_id = message.get("parent_tool_use_id").and_then(Value::as_str);
8451115
8461116 match message.get("type").and_then(Value::as_str) {
847- Some("system") => self.handle_system(&message),
1117+ Some("system") => self.handle_system(&message, cx),
8481118 Some("stream_event") if parent_tool_use_id.is_none() => {
8491119 self.handle_stream_event(&message["event"], cx)
8501120 }
@@ -880,7 +1150,7 @@ impl Session {
8801150 }
8811151 }
8821152
883- fn handle_system(&mut self, message: &Value) {
1153+ fn handle_system(&mut self, message: &Value, cx: &mut Context<Self>) {
8841154 if let Some(mode) = message
8851155 .get("permissionMode")
8861156 .and_then(Value::as_str)
@@ -888,11 +1158,83 @@ impl Session {
8881158 {
8891159 self.permission_mode = mode;
8901160 }
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") {
8921163 Some("init") => {
8931164 if let Some(model) = message.get("model").and_then(Value::as_str) {
8941165 self.active_model = Some(model.to_string());
8951166 }
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();
8961238 }
8971239 Some("compact_boundary") => {
8981240 self.push_notice(NoticeKind::Info, "Conversation compacted to free up context.")
@@ -1128,17 +1470,20 @@ impl Session {
11281470 state: PermissionState::Pending,
11291471 };
11301472 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();
11311475 let tool_use_id = request.get("tool_use_id").and_then(Value::as_str).unwrap_or_default();
11321476 if let Some(tool) = self.find_tool_mut(tool_use_id) {
11331477 tool.input = prompt.input.clone();
11341478 tool.permission = Some(prompt);
1479+ tool.ask = ask;
11351480 return;
11361481 }
1137- let name = request.get("tool_name").and_then(Value::as_str).unwrap_or("Tool");
11381482 let ix = self.push_tool(tool_use_id, name, prompt.input.clone());
11391483 if let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) {
11401484 tool.permission = Some(prompt);
11411485 tool.detached = true;
1486+ tool.ask = ask;
11421487 }
11431488 }
11441489
@@ -1153,6 +1498,8 @@ impl Session {
11531498 PendingControl::SetPermissionMode => "change permission mode",
11541499 PendingControl::FlagSettings => "change reasoning or fast mode",
11551500 PendingControl::Interrupt => "interrupt",
1501+ PendingControl::Background => "move the command to the background",
1502+ PendingControl::StopTask => "stop the background task",
11561503 };
11571504 self.push_notice(NoticeKind::Error, format!("Failed to {action}: {error}"));
11581505 return;
@@ -1459,6 +1806,9 @@ impl Session {
14591806 expanded: false,
14601807 subagent_tool_count: 0,
14611808 detached: false,
1809+ ask: None,
1810+ task_id: None,
1811+ background: None,
14621812 }));
14631813 self.items.len() - 1
14641814 }
@@ -1508,6 +1858,19 @@ impl Session {
15081858 }
15091859 }
15101860
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+
15111874 fn finish_open_tools(&mut self, reason: &str) {
15121875 for item in &mut self.items {
15131876 if let ChatItem::Tool(tool) = item
+477−7src/ui/chat.rsView File
@@ -23,7 +23,7 @@ use crate::git;
2323 use crate::models::{Catalog, ModelEntry, Provider};
2424 use crate::session::{
2525 ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session,
26- ToolCall, ToolState, tool_field, tool_summary,
26+ TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary,
2727 };
2828 use crate::ui::changes::{self, DiffView};
2929 use crate::ui::icons::Lucide;
@@ -32,6 +32,8 @@ use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent};
3232 use crate::ui::workspace::ProjectDirectory;
3333
3434 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";
3537 const MAX_TOOL_OUTPUT: usize = 6_000;
3638
3739 /// What the conversation asks of the workspace around it.
@@ -61,6 +63,8 @@ pub struct ChatView {
6163 open_picker: Option<Picker>,
6264 attachments: Vec<PathBuf>,
6365 diff: Option<DiffView>,
66+ /// Whether the composer is set up to answer an AskUserQuestion form.
67+ answering: bool,
6468 _subscriptions: Vec<Subscription>,
6569 _ticker: Task<()>,
6670 }
@@ -73,14 +77,21 @@ impl ChatView {
7377 TextareaState::new(window, cx)
7478 .auto_grow(2, 12)
7579 .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)
7781 });
7882 let item_count = session.read(cx).items.len();
7983 let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx));
8084 let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx));
8185
8286 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+ }
8495 let count = session.read(cx).items.len();
8596 this.scroller.update(cx, |scroller, cx| {
8697 let current = scroller.item_count();
@@ -118,7 +129,8 @@ impl ChatView {
118129 loop {
119130 cx.background_executor().timer(Duration::from_secs(1)).await;
120131 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() {
122134 cx.notify();
123135 }
124136 });
@@ -136,6 +148,7 @@ impl ChatView {
136148 open_picker: None,
137149 attachments: Vec::new(),
138150 diff: None,
151+ answering: false,
139152 _subscriptions: subscriptions,
140153 _ticker: ticker,
141154 }
@@ -170,6 +183,17 @@ impl ChatView {
170183
171184 fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
172185 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+ }
173197 if text.is_empty() && self.attachments.is_empty() {
174198 return;
175199 }
@@ -179,6 +203,23 @@ impl ChatView {
179203 self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
180204 }
181205
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+
182223 fn escape(&mut self, cx: &mut Context<Self>) {
183224 if self.diff.take().is_some() {
184225 cx.notify();
@@ -416,6 +457,78 @@ impl ChatView {
416457 })
417458 }
418459
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+
419532 fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement {
420533 let p = metal::palette(cx);
421534 h_flex()
@@ -544,6 +657,7 @@ impl ChatView {
544657 let has_input =
545658 !self.composer.read(cx).value().trim().is_empty() || !self.attachments.is_empty();
546659 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));
547661
548662 let send_button = if working && !has_input {
549663 MetalButton::new("stop")
@@ -569,7 +683,9 @@ impl ChatView {
569683
570684 // MetalUI marks focus with a green-deep ring.
571685 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 {
573689 card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.)));
574690 }
575691
@@ -647,6 +763,7 @@ impl ChatView {
647763 .rounded(px(24.))
648764 .bg(metal::vertical((p.s_hi, p.s)))
649765 .shadow(card_shadow)
766+ .children(ask_panel)
650767 .when(!self.attachments.is_empty(), |this| this.child(self.render_attachments(cx)))
651768 .child(
652769 div()
@@ -666,6 +783,7 @@ impl ChatView {
666783 .pb(px(if start { 0. } else { 20. }))
667784 .gap(px(8.))
668785 .when(!start, |this| this.child(self.render_activity(cx)))
786+ .when(!start, |this| this.children(self.render_background_tasks(cx)))
669787 .child(
670788 v_flex()
671789 .w_full()
@@ -752,6 +870,7 @@ impl Render for ChatView {
752870 .size_full()
753871 .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx)))
754872 .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)))
755874 .map(|this| match &self.diff {
756875 Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, close_diff, cx))),
757876 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:
9171036 .into_any_element()
9181037 }
9191038
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+
9201373 /// Renders one transcript row. Runs inside the virtual list, so it only gets
9211374 /// `App` and talks to the session entity directly.
9221375 fn render_item(
@@ -947,6 +1400,7 @@ fn render_item(
9471400 let visible = match (&turn, item) {
9481401 (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
9491402 (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
1403+ (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true,
9501404 (Some(turn), _) => expanded || Some(ix) == turn.answer,
9511405 (None, _) => true,
9521406 };
@@ -996,6 +1450,7 @@ fn render_item(
9961450 .into_any_element(),
9971451 ChatItem::Tool(tool) => render_tool(session, ix, tool, &state.cwd, cx),
9981452 ChatItem::Question(question) => render_question(session, ix, question, p),
1453+ ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p),
9991454 ChatItem::Notice(NoticeKind::Info, text) => h_flex()
10001455 .items_start()
10011456 .gap(px(8.))
@@ -1055,8 +1510,10 @@ fn render_item(
10551510 .w_full()
10561511 .max_w(px(CONTENT_WIDTH))
10571512 .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+ }),
10601517 )
10611518 .into_any_element()
10621519 }
@@ -1069,6 +1526,9 @@ fn render_tool(
10691526 cx: &App,
10701527 ) -> AnyElement {
10711528 let p = metal::palette(cx);
1529+ if let Some(ask) = &tool.ask {
1530+ return render_ask_record(ix, tool, ask, p);
1531+ }
10721532 let summary = tool_summary(&tool.name, &tool.input, cwd);
10731533 let pending = tool
10741534 .permission
@@ -1078,6 +1538,14 @@ fn render_tool(
10781538
10791539 let status: AnyElement = match (tool.state, pending) {
10801540 (_, 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+ }
10811549 (ToolState::Running, _) => metal::led(("tool-led", ix), Led::Live, true, p),
10821550 (ToolState::Done, _) => Icon::new(IconName::Check)
10831551 .size(px(13.))
@@ -1131,6 +1599,7 @@ fn render_tool(
11311599 .when_some(decision, |this, (label, color)| {
11321600 this.child(metal::engraved(label, p).text_color(color))
11331601 })
1602+ .children(render_task_controls(session, ix, tool, p))
11341603 .child(
11351604 Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
11361605 .size(px(12.))
@@ -1308,6 +1777,7 @@ fn tool_icon(name: &str) -> IconName {
13081777 "task" | "agent" => IconName::Bot,
13091778 "todowrite" => IconName::CircleCheck,
13101779 "exitplanmode" => IconName::Map,
1780+ "monitor" => IconName::Eye,
13111781 _ => IconName::Settings2,
13121782 }
13131783 }
+2−0src/ui/icons.rsView File
@@ -13,6 +13,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
1313 Brain,
1414 ChevronsUpDown,
1515 Code,
16+ CornerDownRight,
1617 FileCode,
1718 FileDiff,
1819 FolderPlus,
@@ -24,6 +25,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
2425 Laptop,
2526 Lock,
2627 LockOpen,
28+ MessageCircleQuestionMark,
2729 PanelLeftClose,
2830 PanelLeftOpen,
2931 PanelRightClose,
+2−0src/ui/workspace.rsView File
@@ -763,6 +763,8 @@ impl Workspace {
763763 } else {
764764 match state.status {
765765 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),
766768 SessionStatus::Idle => (Led::Live, false),
767769 SessionStatus::Starting => (Led::Off, false),
768770 SessionStatus::Exited => (Led::Failed, false),