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 {