agent2: Refine terminal tool call display (#35984)

Danilo Leal created

Release Notes:

- N/A

Change summary

crates/acp_thread/src/terminal.rs      |  10 
crates/agent_ui/src/acp/thread_view.rs | 337 ++++++++++++++++++++++++---
2 files changed, 308 insertions(+), 39 deletions(-)

Detailed changes

crates/acp_thread/src/terminal.rs πŸ”—

@@ -29,8 +29,14 @@ impl Terminal {
         cx: &mut Context<Self>,
     ) -> Self {
         Self {
-            command: cx
-                .new(|cx| Markdown::new(command.into(), Some(language_registry.clone()), None, cx)),
+            command: cx.new(|cx| {
+                Markdown::new(
+                    format!("```\n{}\n```", command).into(),
+                    Some(language_registry.clone()),
+                    None,
+                    cx,
+                )
+            }),
             working_dir,
             terminal,
             started_at: Instant::now(),

crates/agent_ui/src/acp/thread_view.rs πŸ”—

@@ -38,7 +38,7 @@ use theme::ThemeSettings;
 use ui::{
     Disclosure, Divider, DividerColor, KeyBinding, Scrollbar, ScrollbarState, Tooltip, prelude::*,
 };
-use util::ResultExt;
+use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, Workspace};
 use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
 
@@ -75,6 +75,7 @@ pub struct AcpThreadView {
     edits_expanded: bool,
     plan_expanded: bool,
     editor_expanded: bool,
+    terminal_expanded: bool,
     message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
     _cancel_task: Option<Task<()>>,
     _subscriptions: [Subscription; 1],
@@ -200,6 +201,7 @@ impl AcpThreadView {
             edits_expanded: false,
             plan_expanded: false,
             editor_expanded: false,
+            terminal_expanded: true,
             message_history,
             _subscriptions: [subscription],
             _cancel_task: None,
@@ -768,7 +770,7 @@ impl AcpThreadView {
                     window,
                     cx,
                 );
-                view.set_embedded_mode(None, cx);
+                view.set_embedded_mode(Some(1000), cx);
                 view
             });
 
@@ -914,17 +916,26 @@ impl AcpThreadView {
                     .child(message_body)
                     .into_any()
             }
-            AgentThreadEntry::ToolCall(tool_call) => div()
-                .w_full()
-                .py_1p5()
-                .px_5()
-                .child(self.render_tool_call(index, tool_call, window, cx))
-                .into_any(),
+            AgentThreadEntry::ToolCall(tool_call) => {
+                let has_terminals = tool_call.terminals().next().is_some();
+
+                div().w_full().py_1p5().px_5().map(|this| {
+                    if has_terminals {
+                        this.children(tool_call.terminals().map(|terminal| {
+                            self.render_terminal_tool_call(terminal, tool_call, window, cx)
+                        }))
+                    } else {
+                        this.child(self.render_tool_call(index, tool_call, window, cx))
+                    }
+                })
+            }
+            .into_any(),
         };
 
         let Some(thread) = self.thread() else {
             return primary;
         };
+
         let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
         if index == total_entries - 1 && !is_generating {
             v_flex()
@@ -1173,8 +1184,7 @@ impl AcpThreadView {
             || has_nonempty_diff
             || self.expanded_tool_calls.contains(&tool_call.id);
 
-        let gradient_color = cx.theme().colors().panel_background;
-        let gradient_overlay = {
+        let gradient_overlay = |color: Hsla| {
             div()
                 .absolute()
                 .top_0()
@@ -1183,8 +1193,8 @@ impl AcpThreadView {
                 .h_full()
                 .bg(linear_gradient(
                     90.,
-                    linear_color_stop(gradient_color, 1.),
-                    linear_color_stop(gradient_color.opacity(0.2), 0.),
+                    linear_color_stop(color, 1.),
+                    linear_color_stop(color.opacity(0.2), 0.),
                 ))
         };
 
@@ -1286,7 +1296,17 @@ impl AcpThreadView {
                                                 ),
                                             )),
                                     )
-                                    .child(gradient_overlay)
+                                    .map(|this| {
+                                        if needs_confirmation {
+                                            this.child(gradient_overlay(
+                                                self.tool_card_header_bg(cx),
+                                            ))
+                                        } else {
+                                            this.child(gradient_overlay(
+                                                cx.theme().colors().panel_background,
+                                            ))
+                                        }
+                                    })
                                     .on_click(cx.listener({
                                         let id = tool_call.id.clone();
                                         move |this: &mut Self, _, _, cx: &mut Context<Self>| {
@@ -1321,11 +1341,9 @@ impl AcpThreadView {
                                         .children(tool_call.content.iter().map(|content| {
                                             div()
                                                 .py_1p5()
-                                                .child(
-                                                    self.render_tool_call_content(
-                                                        content, window, cx,
-                                                    ),
-                                                )
+                                                .child(self.render_tool_call_content(
+                                                    content, tool_call, window, cx,
+                                                ))
                                                 .into_any_element()
                                         }))
                                         .child(self.render_permission_buttons(
@@ -1339,11 +1357,9 @@ impl AcpThreadView {
                                         this.children(tool_call.content.iter().map(|content| {
                                             div()
                                                 .py_1p5()
-                                                .child(
-                                                    self.render_tool_call_content(
-                                                        content, window, cx,
-                                                    ),
-                                                )
+                                                .child(self.render_tool_call_content(
+                                                    content, tool_call, window, cx,
+                                                ))
                                                 .into_any_element()
                                         }))
                                     }
@@ -1360,6 +1376,7 @@ impl AcpThreadView {
     fn render_tool_call_content(
         &self,
         content: &ToolCallContent,
+        tool_call: &ToolCall,
         window: &Window,
         cx: &Context<Self>,
     ) -> AnyElement {
@@ -1380,7 +1397,9 @@ impl AcpThreadView {
                 }
             }
             ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()),
-            ToolCallContent::Terminal(terminal) => self.render_terminal(terminal),
+            ToolCallContent::Terminal(terminal) => {
+                self.render_terminal_tool_call(terminal, tool_call, window, cx)
+            }
         }
     }
 
@@ -1393,14 +1412,22 @@ impl AcpThreadView {
         cx: &Context<Self>,
     ) -> Div {
         h_flex()
-            .p_1p5()
+            .py_1()
+            .pl_2()
+            .pr_1()
             .gap_1()
-            .justify_end()
+            .justify_between()
+            .flex_wrap()
             .when(!empty_content, |this| {
                 this.border_t_1()
                     .border_color(self.tool_card_border_color(cx))
             })
-            .children(options.iter().map(|option| {
+            .child(
+                div()
+                    .min_w(rems_from_px(145.))
+                    .child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
+            )
+            .child(h_flex().gap_0p5().children(options.iter().map(|option| {
                 let option_id = SharedString::from(option.id.0.clone());
                 Button::new((option_id, entry_ix), option.name.clone())
                     .map(|this| match option.kind {
@@ -1433,7 +1460,7 @@ impl AcpThreadView {
                             );
                         }
                     }))
-            }))
+            })))
     }
 
     fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
@@ -1449,18 +1476,242 @@ impl AcpThreadView {
             .into_any()
     }
 
-    fn render_terminal(&self, terminal: &Entity<acp_thread::Terminal>) -> AnyElement {
-        v_flex()
-            .h_72()
+    fn render_terminal_tool_call(
+        &self,
+        terminal: &Entity<acp_thread::Terminal>,
+        tool_call: &ToolCall,
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let terminal_data = terminal.read(cx);
+        let working_dir = terminal_data.working_dir();
+        let command = terminal_data.command();
+        let started_at = terminal_data.started_at();
+
+        let tool_failed = matches!(
+            &tool_call.status,
+            ToolCallStatus::Rejected
+                | ToolCallStatus::Canceled
+                | ToolCallStatus::Allowed {
+                    status: acp::ToolCallStatus::Failed,
+                    ..
+                }
+        );
+
+        let output = terminal_data.output();
+        let command_finished = output.is_some();
+        let truncated_output = output.is_some_and(|output| output.was_content_truncated);
+        let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
+
+        let command_failed = command_finished
+            && output.is_some_and(|o| o.exit_status.is_none_or(|status| !status.success()));
+
+        let time_elapsed = if let Some(output) = output {
+            output.ended_at.duration_since(started_at)
+        } else {
+            started_at.elapsed()
+        };
+
+        let header_bg = cx
+            .theme()
+            .colors()
+            .element_background
+            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
+        let border_color = cx.theme().colors().border.opacity(0.6);
+
+        let working_dir = working_dir
+            .as_ref()
+            .map(|path| format!("{}", path.display()))
+            .unwrap_or_else(|| "current directory".to_string());
+
+        let header = h_flex()
+            .id(SharedString::from(format!(
+                "terminal-tool-header-{}",
+                terminal.entity_id()
+            )))
+            .flex_none()
+            .gap_1()
+            .justify_between()
+            .rounded_t_md()
             .child(
-                if let Some(terminal_view) = self.terminal_views.get(&terminal.entity_id()) {
-                    // TODO: terminal has all the state we need to reproduce
-                    // what we had in the terminal card.
-                    terminal_view.clone().into_any_element()
+                div()
+                    .id(("command-target-path", terminal.entity_id()))
+                    .w_full()
+                    .max_w_full()
+                    .overflow_x_scroll()
+                    .child(
+                        Label::new(working_dir)
+                            .buffer_font(cx)
+                            .size(LabelSize::XSmall)
+                            .color(Color::Muted),
+                    ),
+            )
+            .when(!command_finished, |header| {
+                header
+                    .gap_1p5()
+                    .child(
+                        Button::new(
+                            SharedString::from(format!("stop-terminal-{}", terminal.entity_id())),
+                            "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",
+                                None,
+                                "Also possible by placing your cursor inside the terminal and using regular terminal bindings.",
+                                window,
+                                cx,
+                            )
+                        })
+                        .on_click({
+                            let terminal = terminal.clone();
+                            cx.listener(move |_this, _event, _window, cx| {
+                                let inner_terminal = terminal.read(cx).inner().clone();
+                                inner_terminal.update(cx, |inner_terminal, _cx| {
+                                    inner_terminal.kill_active_task();
+                                });
+                            })
+                        }),
+                    )
+                    .child(Divider::vertical())
+                    .child(
+                        Icon::new(IconName::ArrowCircle)
+                            .size(IconSize::XSmall)
+                            .color(Color::Info)
+                            .with_animation(
+                                "arrow-circle",
+                                Animation::new(Duration::from_secs(2)).repeat(),
+                                |icon, delta| {
+                                    icon.transform(Transformation::rotate(percentage(delta)))
+                                },
+                            ),
+                    )
+            })
+            .when(tool_failed || command_failed, |header| {
+                header.child(
+                    div()
+                        .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
+                        .child(
+                            Icon::new(IconName::Close)
+                                .size(IconSize::Small)
+                                .color(Color::Error),
+                        )
+                        .when_some(output.and_then(|o| o.exit_status), |this, status| {
+                            this.tooltip(Tooltip::text(format!(
+                                "Exited with code {}",
+                                status.code().unwrap_or(-1),
+                            )))
+                        }),
+                )
+            })
+            .when(truncated_output, |header| {
+                let tooltip = if let Some(output) = output {
+                    if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
+                        "Output exceeded terminal max lines and was \
+                            truncated, the model received the first 16 KB."
+                            .to_string()
+                    } else {
+                        format!(
+                            "Output is {} longβ€”to avoid unexpected token usage, \
+                                only 16 KB was sent back to the model.",
+                            format_file_size(output.original_content_len as u64, true),
+                        )
+                    }
                 } else {
-                    Empty.into_any()
-                },
+                    "Output was truncated".to_string()
+                };
+
+                header.child(
+                    h_flex()
+                        .id(("terminal-tool-truncated-label", terminal.entity_id()))
+                        .gap_1()
+                        .child(
+                            Icon::new(IconName::Info)
+                                .size(IconSize::XSmall)
+                                .color(Color::Ignored),
+                        )
+                        .child(
+                            Label::new("Truncated")
+                                .color(Color::Muted)
+                                .size(LabelSize::XSmall),
+                        )
+                        .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),
+                )
+            })
+            .child(
+                Disclosure::new(
+                    SharedString::from(format!(
+                        "terminal-tool-disclosure-{}",
+                        terminal.entity_id()
+                    )),
+                    self.terminal_expanded,
+                )
+                .opened_icon(IconName::ChevronUp)
+                .closed_icon(IconName::ChevronDown)
+                .on_click(cx.listener(move |this, _event, _window, _cx| {
+                    this.terminal_expanded = !this.terminal_expanded;
+                })),
+            );
+
+        let show_output =
+            self.terminal_expanded && self.terminal_views.contains_key(&terminal.entity_id());
+
+        v_flex()
+            .mb_2()
+            .border_1()
+            .when(tool_failed || command_failed, |card| card.border_dashed())
+            .border_color(border_color)
+            .rounded_lg()
+            .overflow_hidden()
+            .child(
+                v_flex()
+                    .p_2()
+                    .gap_0p5()
+                    .bg(header_bg)
+                    .text_xs()
+                    .child(header)
+                    .child(
+                        MarkdownElement::new(
+                            command.clone(),
+                            terminal_command_markdown_style(window, cx),
+                        )
+                        .code_block_renderer(
+                            markdown::CodeBlockRenderer::Default {
+                                copy_button: false,
+                                copy_button_on_hover: true,
+                                border: false,
+                            },
+                        ),
+                    ),
             )
+            .when(show_output, |this| {
+                let terminal_view = self.terminal_views.get(&terminal.entity_id()).unwrap();
+
+                this.child(
+                    div()
+                        .pt_2()
+                        .border_t_1()
+                        .when(tool_failed || command_failed, |card| card.border_dashed())
+                        .border_color(border_color)
+                        .bg(cx.theme().colors().editor_background)
+                        .rounded_b_md()
+                        .text_ui_sm(cx)
+                        .child(terminal_view.clone()),
+                )
+            })
             .into_any()
     }
 
@@ -3030,6 +3281,18 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
     }
 }
 
+fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
+    let default_md_style = default_markdown_style(true, window, cx);
+
+    MarkdownStyle {
+        base_text_style: TextStyle {
+            ..default_md_style.base_text_style
+        },
+        selection_background_color: cx.theme().colors().element_selection_background,
+        ..Default::default()
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use agent_client_protocol::SessionId;