agent2: Refine terminal tool call display (#35984)

Release Notes:

- N/A
This commit is contained in:
Danilo Leal 2025-08-11 17:50:47 -03:00 committed by GitHub
parent 54d4665100
commit 094e878ccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 309 additions and 40 deletions

View file

@ -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(),

View file

@ -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()
} 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;