diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index b800873737..41d7fb89bb 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -29,8 +29,14 @@ impl Terminal { cx: &mut Context, ) -> 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(), diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 32f9948d97..f37deac26e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/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>>>, _cancel_task: Option>, _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| { @@ -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, ) -> 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, ) -> 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) -> AnyElement { @@ -1449,18 +1476,242 @@ impl AcpThreadView { .into_any() } - fn render_terminal(&self, terminal: &Entity) -> AnyElement { - v_flex() - .h_72() + fn render_terminal_tool_call( + &self, + terminal: &Entity, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> 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() - } else { - Empty.into_any() - }, + 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 { + "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;