agent2: Refine terminal tool call display (#35984)
Release Notes: - N/A
This commit is contained in:
parent
54d4665100
commit
094e878ccf
2 changed files with 309 additions and 40 deletions
|
@ -29,8 +29,14 @@ impl Terminal {
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
command: cx
|
command: cx.new(|cx| {
|
||||||
.new(|cx| Markdown::new(command.into(), Some(language_registry.clone()), None, cx)),
|
Markdown::new(
|
||||||
|
format!("```\n{}\n```", command).into(),
|
||||||
|
Some(language_registry.clone()),
|
||||||
|
None,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}),
|
||||||
working_dir,
|
working_dir,
|
||||||
terminal,
|
terminal,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
|
|
|
@ -38,7 +38,7 @@ use theme::ThemeSettings;
|
||||||
use ui::{
|
use ui::{
|
||||||
Disclosure, Divider, DividerColor, KeyBinding, Scrollbar, ScrollbarState, Tooltip, prelude::*,
|
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 workspace::{CollaboratorId, Workspace};
|
||||||
use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
|
use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
|
||||||
|
|
||||||
|
@ -75,6 +75,7 @@ pub struct AcpThreadView {
|
||||||
edits_expanded: bool,
|
edits_expanded: bool,
|
||||||
plan_expanded: bool,
|
plan_expanded: bool,
|
||||||
editor_expanded: bool,
|
editor_expanded: bool,
|
||||||
|
terminal_expanded: bool,
|
||||||
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
|
message_history: Rc<RefCell<MessageHistory<Vec<acp::ContentBlock>>>>,
|
||||||
_cancel_task: Option<Task<()>>,
|
_cancel_task: Option<Task<()>>,
|
||||||
_subscriptions: [Subscription; 1],
|
_subscriptions: [Subscription; 1],
|
||||||
|
@ -200,6 +201,7 @@ impl AcpThreadView {
|
||||||
edits_expanded: false,
|
edits_expanded: false,
|
||||||
plan_expanded: false,
|
plan_expanded: false,
|
||||||
editor_expanded: false,
|
editor_expanded: false,
|
||||||
|
terminal_expanded: true,
|
||||||
message_history,
|
message_history,
|
||||||
_subscriptions: [subscription],
|
_subscriptions: [subscription],
|
||||||
_cancel_task: None,
|
_cancel_task: None,
|
||||||
|
@ -768,7 +770,7 @@ impl AcpThreadView {
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
view.set_embedded_mode(None, cx);
|
view.set_embedded_mode(Some(1000), cx);
|
||||||
view
|
view
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -914,17 +916,26 @@ impl AcpThreadView {
|
||||||
.child(message_body)
|
.child(message_body)
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
AgentThreadEntry::ToolCall(tool_call) => div()
|
AgentThreadEntry::ToolCall(tool_call) => {
|
||||||
.w_full()
|
let has_terminals = tool_call.terminals().next().is_some();
|
||||||
.py_1p5()
|
|
||||||
.px_5()
|
div().w_full().py_1p5().px_5().map(|this| {
|
||||||
.child(self.render_tool_call(index, tool_call, window, cx))
|
if has_terminals {
|
||||||
.into_any(),
|
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 {
|
let Some(thread) = self.thread() else {
|
||||||
return primary;
|
return primary;
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
|
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
|
||||||
if index == total_entries - 1 && !is_generating {
|
if index == total_entries - 1 && !is_generating {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
@ -1173,8 +1184,7 @@ impl AcpThreadView {
|
||||||
|| has_nonempty_diff
|
|| has_nonempty_diff
|
||||||
|| self.expanded_tool_calls.contains(&tool_call.id);
|
|| self.expanded_tool_calls.contains(&tool_call.id);
|
||||||
|
|
||||||
let gradient_color = cx.theme().colors().panel_background;
|
let gradient_overlay = |color: Hsla| {
|
||||||
let gradient_overlay = {
|
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.top_0()
|
.top_0()
|
||||||
|
@ -1183,8 +1193,8 @@ impl AcpThreadView {
|
||||||
.h_full()
|
.h_full()
|
||||||
.bg(linear_gradient(
|
.bg(linear_gradient(
|
||||||
90.,
|
90.,
|
||||||
linear_color_stop(gradient_color, 1.),
|
linear_color_stop(color, 1.),
|
||||||
linear_color_stop(gradient_color.opacity(0.2), 0.),
|
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({
|
.on_click(cx.listener({
|
||||||
let id = tool_call.id.clone();
|
let id = tool_call.id.clone();
|
||||||
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
|
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
|
||||||
|
@ -1321,11 +1341,9 @@ impl AcpThreadView {
|
||||||
.children(tool_call.content.iter().map(|content| {
|
.children(tool_call.content.iter().map(|content| {
|
||||||
div()
|
div()
|
||||||
.py_1p5()
|
.py_1p5()
|
||||||
.child(
|
.child(self.render_tool_call_content(
|
||||||
self.render_tool_call_content(
|
content, tool_call, window, cx,
|
||||||
content, window, cx,
|
))
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}))
|
}))
|
||||||
.child(self.render_permission_buttons(
|
.child(self.render_permission_buttons(
|
||||||
|
@ -1339,11 +1357,9 @@ impl AcpThreadView {
|
||||||
this.children(tool_call.content.iter().map(|content| {
|
this.children(tool_call.content.iter().map(|content| {
|
||||||
div()
|
div()
|
||||||
.py_1p5()
|
.py_1p5()
|
||||||
.child(
|
.child(self.render_tool_call_content(
|
||||||
self.render_tool_call_content(
|
content, tool_call, window, cx,
|
||||||
content, window, cx,
|
))
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -1360,6 +1376,7 @@ impl AcpThreadView {
|
||||||
fn render_tool_call_content(
|
fn render_tool_call_content(
|
||||||
&self,
|
&self,
|
||||||
content: &ToolCallContent,
|
content: &ToolCallContent,
|
||||||
|
tool_call: &ToolCall,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
@ -1380,7 +1397,9 @@ impl AcpThreadView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()),
|
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>,
|
cx: &Context<Self>,
|
||||||
) -> Div {
|
) -> Div {
|
||||||
h_flex()
|
h_flex()
|
||||||
.p_1p5()
|
.py_1()
|
||||||
|
.pl_2()
|
||||||
|
.pr_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.justify_end()
|
.justify_between()
|
||||||
|
.flex_wrap()
|
||||||
.when(!empty_content, |this| {
|
.when(!empty_content, |this| {
|
||||||
this.border_t_1()
|
this.border_t_1()
|
||||||
.border_color(self.tool_card_border_color(cx))
|
.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());
|
let option_id = SharedString::from(option.id.0.clone());
|
||||||
Button::new((option_id, entry_ix), option.name.clone())
|
Button::new((option_id, entry_ix), option.name.clone())
|
||||||
.map(|this| match option.kind {
|
.map(|this| match option.kind {
|
||||||
|
@ -1433,7 +1460,7 @@ impl AcpThreadView {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
|
fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
|
||||||
|
@ -1449,18 +1476,242 @@ impl AcpThreadView {
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_terminal(&self, terminal: &Entity<acp_thread::Terminal>) -> AnyElement {
|
fn render_terminal_tool_call(
|
||||||
v_flex()
|
&self,
|
||||||
.h_72()
|
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(
|
.child(
|
||||||
if let Some(terminal_view) = self.terminal_views.get(&terminal.entity_id()) {
|
div()
|
||||||
// TODO: terminal has all the state we need to reproduce
|
.id(("command-target-path", terminal.entity_id()))
|
||||||
// what we had in the terminal card.
|
.w_full()
|
||||||
terminal_view.clone().into_any_element()
|
.max_w_full()
|
||||||
} else {
|
.overflow_x_scroll()
|
||||||
Empty.into_any()
|
.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()
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use agent_client_protocol::SessionId;
|
use agent_client_protocol::SessionId;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue