@@ -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;