From 805e32c363d129f629e9d56b5af429f628cc6cdb Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:02:06 -0300 Subject: [PATCH] agent_ui: Improve loading state when there's only a terminal call (#50501) In the thread view, when the last block is an ongoing terminal tool call, we're not waiting for the LLM to generate content, but rather, we're waiting for the command to run so its output can be sent to the model. With that in mind, it doesn't make sense for the generating loading spinner to be visible. So, this PR, removes it. But I also took the opportunity to refine the terminal card header a bit, as well as make the "command running" spinner, as well as the stop button, visible for external agents, too. https://github.com/user-attachments/assets/c7e2865a-af9b-416b-a06a-908dffb916da In the video, you can see that given we only have a running-terminal tool call, there's no generation loading spinner. Release Notes: - Agent: Improved loading state for the thread view when there's only a running terminal command. --- .../src/connection_view/thread_view.rs | 196 ++++++++++-------- 1 file changed, 113 insertions(+), 83 deletions(-) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index cd8becbded396afa5c8eb4a9136db18f63e4a535..0915911104b50ffe9d464a0552d3e43b0794b34d 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -4307,6 +4307,8 @@ impl ThreadView { }) .flatten(); + let is_blocked_on_terminal_command = + !confirmation && self.is_blocked_on_terminal_command(cx); let is_waiting = confirmation || self.thread.read(cx).has_in_progress_tool_calls(); let turn_tokens_label = elapsed_label @@ -4344,6 +4346,8 @@ impl ThreadView { .color(Color::Muted), ), ) + } else if is_blocked_on_terminal_command { + this } else { this.child(SpinnerLabel::new().size(LabelSize::Small)) } @@ -4632,51 +4636,74 @@ impl ThreadView { if text.is_empty() { None } else { Some(text) } } + fn is_blocked_on_terminal_command(&self, cx: &App) -> bool { + let thread = self.thread.read(cx); + if !matches!(thread.status(), ThreadStatus::Generating) { + return false; + } + + let mut has_running_terminal_call = false; + + for entry in thread.entries().iter().rev() { + match entry { + AgentThreadEntry::UserMessage(_) => break, + AgentThreadEntry::ToolCall(tool_call) + if matches!( + tool_call.status, + ToolCallStatus::InProgress | ToolCallStatus::Pending + ) => + { + if matches!(tool_call.kind, acp::ToolKind::Execute) { + has_running_terminal_call = true; + } else { + return false; + } + } + AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + } + } + + has_running_terminal_call + } + fn render_collapsible_command( &self, + group: SharedString, is_preview: bool, command_source: &str, - tool_call_id: &acp::ToolCallId, cx: &Context, ) -> Div { - let command_group = - SharedString::from(format!("collapsible-command-group-{}", tool_call_id)); - v_flex() - .group(command_group.clone()) + .p_1p5() .bg(self.tool_card_header_bg(cx)) - .child( - v_flex() - .p_1p5() - .when(is_preview, |this| { - this.pt_1().child( - // Wrapping this label on a container with 24px height to avoid - // layout shift when it changes from being a preview label - // to the actual path where the command will run in - h_flex().h_6().child( - Label::new("Run Command") - .buffer_font(cx) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) - }) - .children(command_source.lines().map(|line| { - let text: SharedString = if line.is_empty() { - " ".into() - } else { - line.to_string().into() - }; - - Label::new(text).buffer_font(cx).size(LabelSize::Small) - })) - .child( - div().absolute().top_1().right_1().child( - CopyButton::new("copy-command", command_source.to_string()) - .tooltip_label("Copy Command") - .visible_on_hover(command_group), - ), + .when(is_preview, |this| { + this.pt_1().child( + // Wrapping this label on a container with 24px height to avoid + // layout shift when it changes from being a preview label + // to the actual path where the command will run in + h_flex().h_6().child( + Label::new("Run Command") + .buffer_font(cx) + .size(LabelSize::XSmall) + .color(Color::Muted), ), + ) + }) + .children(command_source.lines().map(|line| { + let text: SharedString = if line.is_empty() { + " ".into() + } else { + line.to_string().into() + }; + + Label::new(text).buffer_font(cx).size(LabelSize::Small) + })) + .child( + div().absolute().top_1().right_1().child( + CopyButton::new("copy-command", command_source.to_string()) + .tooltip_label("Copy Command") + .visible_on_hover(group), + ), ) } @@ -4708,7 +4735,11 @@ impl ThreadView { let needs_confirmation = confirmation_options.is_some(); let output = terminal_data.output(); - let command_finished = output.is_some(); + let command_finished = output.is_some() + && !matches!( + tool_call.status, + ToolCallStatus::InProgress | ToolCallStatus::Pending + ); let truncated_output = output.is_some_and(|output| output.original_content_len > output.content.len()); let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0); @@ -4750,14 +4781,15 @@ impl ThreadView { .unwrap_or(&command_source); let command_element = - self.render_collapsible_command(false, command_content, &tool_call.id, cx); + self.render_collapsible_command(header_group.clone(), false, command_content, cx); let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let header = h_flex() .id(header_id) - .px_1p5() .pt_1() + .pl_1p5() + .pr_1() .flex_none() .gap_1() .justify_between() @@ -4775,19 +4807,54 @@ impl ThreadView { .color(Color::Muted), ), ) + .child( + Disclosure::new( + SharedString::from(format!( + "terminal-tool-disclosure-{}", + terminal.entity_id() + )), + is_expanded, + ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&header_group) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this, _event, _window, cx| { + if is_expanded { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + cx.notify(); + } + })), + ) + .when(time_elapsed > Duration::from_secs(10), |header| { + header.child( + Label::new(format!("({})", duration_alt_display(time_elapsed))) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + }) .when(!command_finished && !needs_confirmation, |header| { header .gap_1p5() .child( - Button::new( + Icon::new(IconName::ArrowCircle) + .size(IconSize::XSmall) + .color(Color::Muted) + .with_rotate_animation(2) + ) + .child(div().h(relative(0.6)).ml_1p5().child(Divider::vertical().color(DividerColor::Border))) + .child( + IconButton::new( SharedString::from(format!("stop-terminal-{}", terminal.entity_id())), - "Stop", + IconName::Stop ) - .icon(IconName::Stop) - .icon_position(IconPosition::Start) .icon_size(IconSize::Small) .icon_color(Color::Error) - .label_size(LabelSize::Small) .tooltip(move |_window, cx| { Tooltip::with_meta( "Stop This Command", @@ -4808,13 +4875,6 @@ impl ThreadView { }) }), ) - .child(Divider::vertical()) - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_rotate_animation(2) - ) }) .when(truncated_output, |header| { let tooltip = if let Some(output) = output { @@ -4850,14 +4910,6 @@ impl ThreadView { .tooltip(Tooltip::text(tooltip)), ) }) - .when(time_elapsed > Duration::from_secs(10), |header| { - header.child( - Label::new(format!("({})", duration_alt_display(time_elapsed))) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - }) .when(tool_failed || command_failed, |header| { header.child( div() @@ -4875,29 +4927,7 @@ impl ThreadView { }), ) }) - .child( - Disclosure::new( - SharedString::from(format!( - "terminal-tool-disclosure-{}", - terminal.entity_id() - )), - is_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&header_group) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this, _event, _window, cx| { - if is_expanded { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })), - ); +; let terminal_view = self .entry_view_state @@ -5294,9 +5324,9 @@ impl ThreadView { if is_terminal_tool { let label_source = tool_call.label.read(cx).source(); this.child(self.render_collapsible_command( + card_header_id.clone(), true, label_source, - &tool_call.id, cx, )) } else {