agent: Allow to see the whole command before running it (#38747)

Closes https://github.com/zed-industries/zed/issues/38528

In the agent panel's `thread_view.rs` file, we have a `render_tool_call`
function that controls what we show in the UI for most types of tools.
However, for some of them—for example, terminal/execute and edit
tools—we have a special rendering so we can tailor the UI for their
specific needs. But... before the specific rendering function is called,
all tools still go through the `render_tool_call`.

Problem is that, in the case of the terminal tool, you couldn't see the
full command the agent wants to run when the tool is still in its
`render_tool_call` state. That's mostly because of the treatment we give
to labels while in that state. A particularly bad scenario because
well... seeing the _full_ command _before_ you choose to accept or
reject is rather important.

This PR fixes that by essentially special-casing the terminal tool
display when in the `render_tool_call` rendering state, so to speak.
There's still a slight UI misalignment I want to fix but it shouldn't
block this fix to go out.

Here's our final result:

<img width="400" height="1172" alt="Screenshot 2025-09-23 at 6  19@2x"
src="https://github.com/user-attachments/assets/71c79e45-ab66-4102-b046-950f137fa3ea"
/>

Release Notes:

- agent: Fixed terminal command not being fully displayed while in the
"waiting for confirmation" state.
This commit is contained in:
Danilo Leal
2025-09-23 18:57:28 -03:00
committed by GitHub
parent 0a261ad8d0
commit b60f19f71e
2 changed files with 191 additions and 139 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ impl AgentTool for TerminalTool {
.into(),
}
} else {
"Run terminal command".into()
"".into()
}
}
+190 -138
View File
@@ -2079,27 +2079,6 @@ impl AcpThreadView {
let has_location = tool_call.locations.len() == 1;
let card_header_id = SharedString::from("inner-tool-call-header");
let tool_icon = if tool_call.kind == acp::ToolKind::Edit && has_location {
FileIcons::get_icon(&tool_call.locations[0].path, cx)
.map(Icon::from_path)
.unwrap_or(Icon::new(IconName::ToolPencil))
} else {
Icon::new(match tool_call.kind {
acp::ToolKind::Read => IconName::ToolSearch,
acp::ToolKind::Edit => IconName::ToolPencil,
acp::ToolKind::Delete => IconName::ToolDeleteFile,
acp::ToolKind::Move => IconName::ArrowRightLeft,
acp::ToolKind::Search => IconName::ToolSearch,
acp::ToolKind::Execute => IconName::ToolTerminal,
acp::ToolKind::Think => IconName::ToolThink,
acp::ToolKind::Fetch => IconName::ToolWeb,
acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
acp::ToolKind::Other => IconName::ToolHammer,
})
}
.size(IconSize::Small)
.color(Color::Muted);
let failed_or_canceled = match &tool_call.status {
ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
_ => false,
@@ -2109,41 +2088,16 @@ impl AcpThreadView {
tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. }
);
let is_terminal_tool = matches!(tool_call.kind, acp::ToolKind::Execute);
let is_edit =
matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some();
let use_card_layout = needs_confirmation || is_edit;
let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_overlay = {
div()
.absolute()
.top_0()
.right_0()
.w_12()
.h_full()
.map(|this| {
if use_card_layout {
this.bg(linear_gradient(
90.,
linear_color_stop(self.tool_card_header_bg(cx), 1.),
linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
))
} else {
this.bg(linear_gradient(
90.,
linear_color_stop(cx.theme().colors().panel_background, 1.),
linear_color_stop(
cx.theme().colors().panel_background.opacity(0.2),
0.,
),
))
}
})
};
let tool_output_display =
if is_open {
match &tool_call.status {
@@ -2228,104 +2182,202 @@ impl AcpThreadView {
}
})
.mr_5()
.child(
h_flex()
.group(&card_header_id)
.relative()
.w_full()
.gap_1()
.justify_between()
.when(use_card_layout, |this| {
this.p_0p5()
.rounded_t(rems_from_px(5.))
.map(|this| {
if is_terminal_tool {
this.child(
v_flex()
.p_1p5()
.gap_0p5()
.text_ui_sm(cx)
.bg(self.tool_card_header_bg(cx))
})
.child(
.child(
Label::new("Run Command")
.buffer_font(cx)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.child(
MarkdownElement::new(
tool_call.label.clone(),
terminal_command_markdown_style(window, cx),
)
.code_block_renderer(
markdown::CodeBlockRenderer::Default {
copy_button: false,
copy_button_on_hover: false,
border: false,
},
)
),
)
} else {
this.child(
h_flex()
.group(&card_header_id)
.relative()
.w_full()
.h(window.line_height() - px(2.))
.text_size(self.tool_name_font_size())
.gap_1p5()
.when(has_location || use_card_layout, |this| this.px_1())
.when(has_location, |this| {
this.cursor(CursorStyle::PointingHand)
.rounded(rems_from_px(3.)) // Concentric border radius
.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
.gap_1()
.justify_between()
.when(use_card_layout, |this| {
this.p_0p5()
.rounded_t(rems_from_px(5.))
.bg(self.tool_card_header_bg(cx))
})
.overflow_hidden()
.child(tool_icon)
.child(if has_location {
h_flex()
.id(("open-tool-call-location", entry_ix))
.w_full()
.map(|this| {
if use_card_layout {
this.text_color(cx.theme().colors().text)
} else {
this.text_color(cx.theme().colors().text_muted)
}
})
.child(self.render_markdown(
tool_call.label.clone(),
MarkdownStyle {
prevent_mouse_interaction: true,
..default_markdown_style(false, true, window, cx)
},
))
.tooltip(Tooltip::text("Jump to File"))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
}))
.into_any_element()
} else {
h_flex()
.w_full()
.child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(false, true, window, cx),
))
.into_any()
})
.when(!has_location, |this| this.child(gradient_overlay)),
)
.when(is_collapsible || failed_or_canceled, |this| {
this.child(
h_flex()
.px_1()
.gap_px()
.when(is_collapsible, |this| {
this.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.visible_on_hover(&card_header_id)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
})),
.child(self.render_tool_call_label(
entry_ix,
tool_call,
is_edit,
use_card_layout,
window,
cx,
))
.when(is_collapsible || failed_or_canceled, |this| {
this.child(
h_flex()
.px_1()
.gap_px()
.when(is_collapsible, |this| {
this.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.visible_on_hover(&card_header_id)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
})),
)
})
.when(failed_or_canceled, |this| {
this.child(
Icon::new(IconName::Close)
.color(Color::Error)
.size(IconSize::Small),
)
}),
)
})
.when(failed_or_canceled, |this| {
this.child(
Icon::new(IconName::Close)
.color(Color::Error)
.size(IconSize::Small),
)
}),
)
}),
)
}),
)
}
})
.children(tool_output_display)
}
fn render_tool_call_label(
&self,
entry_ix: usize,
tool_call: &ToolCall,
is_edit: bool,
use_card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> Div {
let has_location = tool_call.locations.len() == 1;
let tool_icon = if tool_call.kind == acp::ToolKind::Edit && has_location {
FileIcons::get_icon(&tool_call.locations[0].path, cx)
.map(Icon::from_path)
.unwrap_or(Icon::new(IconName::ToolPencil))
} else {
Icon::new(match tool_call.kind {
acp::ToolKind::Read => IconName::ToolSearch,
acp::ToolKind::Edit => IconName::ToolPencil,
acp::ToolKind::Delete => IconName::ToolDeleteFile,
acp::ToolKind::Move => IconName::ArrowRightLeft,
acp::ToolKind::Search => IconName::ToolSearch,
acp::ToolKind::Execute => IconName::ToolTerminal,
acp::ToolKind::Think => IconName::ToolThink,
acp::ToolKind::Fetch => IconName::ToolWeb,
acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
acp::ToolKind::Other => IconName::ToolHammer,
})
}
.size(IconSize::Small)
.color(Color::Muted);
let gradient_overlay = {
div()
.absolute()
.top_0()
.right_0()
.w_12()
.h_full()
.map(|this| {
if use_card_layout {
this.bg(linear_gradient(
90.,
linear_color_stop(self.tool_card_header_bg(cx), 1.),
linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
))
} else {
this.bg(linear_gradient(
90.,
linear_color_stop(cx.theme().colors().panel_background, 1.),
linear_color_stop(
cx.theme().colors().panel_background.opacity(0.2),
0.,
),
))
}
})
};
h_flex()
.relative()
.w_full()
.h(window.line_height() - px(2.))
.text_size(self.tool_name_font_size())
.gap_1p5()
.when(has_location || use_card_layout, |this| this.px_1())
.when(has_location, |this| {
this.cursor(CursorStyle::PointingHand)
.rounded(rems_from_px(3.)) // Concentric border radius
.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
})
.overflow_hidden()
.child(tool_icon)
.child(if has_location {
h_flex()
.id(("open-tool-call-location", entry_ix))
.w_full()
.map(|this| {
if use_card_layout {
this.text_color(cx.theme().colors().text)
} else {
this.text_color(cx.theme().colors().text_muted)
}
})
.child(self.render_markdown(
tool_call.label.clone(),
MarkdownStyle {
prevent_mouse_interaction: true,
..default_markdown_style(false, true, window, cx)
},
))
.tooltip(Tooltip::text("Jump to File"))
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
}))
.into_any_element()
} else {
h_flex()
.w_full()
.child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(false, true, window, cx),
))
.into_any()
})
.when(!is_edit, |this| this.child(gradient_overlay))
}
fn render_tool_call_content(
&self,
entry_ix: usize,