thread view: Refine tool call UI (#36937)

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
This commit is contained in:
Danilo Leal 2025-08-26 12:55:40 -03:00 committed by GitHub
parent 858ab9cc23
commit 65c6c709fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 315 additions and 212 deletions

View file

@ -6,7 +6,7 @@ use agent2::HistoryStore;
use collections::HashMap;
use editor::{Editor, EditorMode, MinimapVisibility};
use gpui::{
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable,
AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, ScrollHandle,
TextStyleRefinement, WeakEntity, Window,
};
use language::language_settings::SoftWrap;
@ -154,10 +154,22 @@ impl EntryViewState {
});
}
}
AgentThreadEntry::AssistantMessage(_) => {
if index == self.entries.len() {
self.entries.push(Entry::empty())
}
AgentThreadEntry::AssistantMessage(message) => {
let entry = if let Some(Entry::AssistantMessage(entry)) =
self.entries.get_mut(index)
{
entry
} else {
self.set_entry(
index,
Entry::AssistantMessage(AssistantMessageEntry::default()),
);
let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else {
unreachable!()
};
entry
};
entry.sync(message);
}
};
}
@ -177,7 +189,7 @@ impl EntryViewState {
pub fn settings_changed(&mut self, cx: &mut App) {
for entry in self.entries.iter() {
match entry {
Entry::UserMessage { .. } => {}
Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {}
Entry::Content(response_views) => {
for view in response_views.values() {
if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
@ -208,9 +220,29 @@ pub enum ViewEvent {
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
}
#[derive(Default, Debug)]
pub struct AssistantMessageEntry {
scroll_handles_by_chunk_index: HashMap<usize, ScrollHandle>,
}
impl AssistantMessageEntry {
pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option<ScrollHandle> {
self.scroll_handles_by_chunk_index.get(&ix).cloned()
}
pub fn sync(&mut self, message: &acp_thread::AssistantMessage) {
if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() {
let ix = message.chunks.len() - 1;
let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default();
handle.scroll_to_bottom();
}
}
}
#[derive(Debug)]
pub enum Entry {
UserMessage(Entity<MessageEditor>),
AssistantMessage(AssistantMessageEntry),
Content(HashMap<EntityId, AnyEntity>),
}
@ -218,7 +250,7 @@ impl Entry {
pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
match self {
Self::UserMessage(editor) => Some(editor),
Entry::Content(_) => None,
Self::AssistantMessage(_) | Self::Content(_) => None,
}
}
@ -239,6 +271,16 @@ impl Entry {
.map(|entity| entity.downcast::<TerminalView>().unwrap())
}
pub fn scroll_handle_for_assistant_message_chunk(
&self,
chunk_ix: usize,
) -> Option<ScrollHandle> {
match self {
Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix),
Self::UserMessage(_) | Self::Content(_) => None,
}
}
fn content_map(&self) -> Option<&HashMap<EntityId, AnyEntity>> {
match self {
Self::Content(map) => Some(map),
@ -254,7 +296,7 @@ impl Entry {
pub fn has_content(&self) -> bool {
match self {
Self::Content(map) => !map.is_empty(),
Self::UserMessage(_) => false,
Self::UserMessage(_) | Self::AssistantMessage(_) => false,
}
}
}

View file

@ -20,11 +20,11 @@ use file_icons::FileIcons;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription,
Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window,
WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point,
prelude::*, pulsating_between,
CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage,
point, prelude::*, pulsating_between,
};
use language::Buffer;
@ -66,7 +66,6 @@ use crate::{
KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
};
const RESPONSE_PADDING_X: Pixels = px(19.);
pub const MIN_EDITOR_LINES: usize = 4;
pub const MAX_EDITOR_LINES: usize = 8;
@ -1334,6 +1333,10 @@ impl AcpThreadView {
window: &mut Window,
cx: &Context<Self>,
) -> AnyElement {
let is_generating = self
.thread()
.is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
let primary = match &entry {
AgentThreadEntry::UserMessage(message) => {
let Some(editor) = self
@ -1493,6 +1496,20 @@ impl AcpThreadView {
.into_any()
}
AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
let is_last = entry_ix + 1 == total_entries;
let pending_thinking_chunk_ix = if is_generating && is_last {
chunks
.iter()
.enumerate()
.next_back()
.filter(|(_, segment)| {
matches!(segment, AssistantMessageChunk::Thought { .. })
})
.map(|(index, _)| index)
} else {
None
};
let style = default_markdown_style(false, false, window, cx);
let message_body = v_flex()
.w_full()
@ -1511,6 +1528,7 @@ impl AcpThreadView {
entry_ix,
chunk_ix,
md.clone(),
Some(chunk_ix) == pending_thinking_chunk_ix,
window,
cx,
)
@ -1524,7 +1542,7 @@ impl AcpThreadView {
v_flex()
.px_5()
.py_1()
.when(entry_ix + 1 == total_entries, |this| this.pb_4())
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(message_body)
@ -1533,7 +1551,7 @@ impl AcpThreadView {
AgentThreadEntry::ToolCall(tool_call) => {
let has_terminals = tool_call.terminals().next().is_some();
div().w_full().py_1().px_5().map(|this| {
div().w_full().map(|this| {
if has_terminals {
this.children(tool_call.terminals().map(|terminal| {
self.render_terminal_tool_call(
@ -1609,64 +1627,90 @@ impl AcpThreadView {
entry_ix: usize,
chunk_ix: usize,
chunk: Entity<Markdown>,
pending: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-card-header");
let key = (entry_ix, chunk_ix);
let is_open = self.expanded_thinking_blocks.contains(&key);
let editor_bg = cx.theme().colors().editor_background;
let gradient_overlay = div()
.rounded_b_lg()
.h_full()
.absolute()
.w_full()
.bottom_0()
.left_0()
.bg(linear_gradient(
180.,
linear_color_stop(editor_bg, 1.),
linear_color_stop(editor_bg.opacity(0.2), 0.),
));
let scroll_handle = self
.entry_view_state
.read(cx)
.entry(entry_ix)
.and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
v_flex()
.rounded_md()
.border_1()
.border_color(self.tool_card_border_color(cx))
.child(
h_flex()
.id(header_id)
.group(&card_header_id)
.relative()
.w_full()
.gap_1p5()
.py_0p5()
.px_1p5()
.rounded_t_md()
.bg(self.tool_card_header_bg(cx))
.justify_between()
.border_b_1()
.border_color(self.tool_card_border_color(cx))
.child(
h_flex()
.size_4()
.justify_center()
.h(window.line_height())
.gap_1p5()
.child(
div()
.group_hover(&card_header_id, |s| s.invisible().w_0())
.child(
Icon::new(IconName::ToolThink)
.size(IconSize::Small)
.color(Color::Muted),
),
Icon::new(IconName::ToolThink)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
.absolute()
.inset_0()
.invisible()
.justify_center()
.group_hover(&card_header_id, |s| s.visible())
.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronRight)
.on_click(cx.listener({
move |this, _event, _window, cx| {
if is_open {
this.expanded_thinking_blocks.remove(&key);
} else {
this.expanded_thinking_blocks.insert(key);
}
cx.notify();
}
})),
),
div()
.text_size(self.tool_name_font_size())
.text_color(cx.theme().colors().text_muted)
.map(|this| {
if pending {
this.child("Thinking")
} else {
this.child("Thought Process")
}
}),
),
)
.child(
div()
.text_size(self.tool_name_font_size())
.text_color(cx.theme().colors().text_muted)
.child("Thinking"),
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({
move |this, _event, _window, cx| {
if is_open {
this.expanded_thinking_blocks.remove(&key);
} else {
this.expanded_thinking_blocks.insert(key);
}
cx.notify();
}
})),
)
.on_click(cx.listener({
move |this, _event, _window, cx| {
@ -1679,22 +1723,28 @@ impl AcpThreadView {
}
})),
)
.when(is_open, |this| {
this.child(
div()
.relative()
.mt_1p5()
.ml(rems(0.4))
.pl_4()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
.text_ui_sm(cx)
.child(self.render_markdown(
chunk,
default_markdown_style(false, false, window, cx),
)),
)
})
.child(
div()
.relative()
.bg(editor_bg)
.rounded_b_lg()
.child(
div()
.id(("thinking-content", chunk_ix))
.when_some(scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.p_2()
.when(!is_open, |this| this.max_h_20())
.text_ui_sm(cx)
.overflow_hidden()
.child(self.render_markdown(
chunk,
default_markdown_style(false, false, window, cx),
)),
)
.when(!is_open && pending, |this| this.child(gradient_overlay)),
)
.into_any_element()
}
@ -1705,7 +1755,6 @@ impl AcpThreadView {
window: &Window,
cx: &Context<Self>,
) -> Div {
let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-tool-call-header");
let tool_icon =
@ -1734,11 +1783,7 @@ impl AcpThreadView {
_ => false,
};
let failed_tool_call = matches!(
tool_call.status,
ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
);
let has_location = tool_call.locations.len() == 1;
let needs_confirmation = matches!(
tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. }
@ -1751,23 +1796,31 @@ impl AcpThreadView {
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
let gradient_overlay = |color: Hsla| {
let gradient_overlay = {
div()
.absolute()
.top_0()
.right_0()
.w_12()
.h_full()
.bg(linear_gradient(
90.,
linear_color_stop(color, 1.),
linear_color_stop(color.opacity(0.2), 0.),
))
};
let gradient_color = if use_card_layout {
self.tool_card_header_bg(cx)
} else {
cx.theme().colors().panel_background
.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 {
@ -1818,41 +1871,58 @@ impl AcpThreadView {
};
v_flex()
.when(use_card_layout, |this| {
this.rounded_md()
.border_1()
.border_color(self.tool_card_border_color(cx))
.bg(cx.theme().colors().editor_background)
.overflow_hidden()
.map(|this| {
if use_card_layout {
this.my_2()
.rounded_md()
.border_1()
.border_color(self.tool_card_border_color(cx))
.bg(cx.theme().colors().editor_background)
.overflow_hidden()
} else {
this.my_1()
}
})
.map(|this| {
if has_location && !use_card_layout {
this.ml_4()
} else {
this.ml_5()
}
})
.mr_5()
.child(
h_flex()
.id(header_id)
.group(&card_header_id)
.relative()
.w_full()
.max_w_full()
.gap_1()
.justify_between()
.when(use_card_layout, |this| {
this.pl_1p5()
.pr_1()
.py_0p5()
this.p_0p5()
.rounded_t_md()
.when(is_open && !failed_tool_call, |this| {
.bg(self.tool_card_header_bg(cx))
.when(is_open && !failed_or_canceled, |this| {
this.border_b_1()
.border_color(self.tool_card_border_color(cx))
})
.bg(self.tool_card_header_bg(cx))
})
.child(
h_flex()
.relative()
.w_full()
.h(window.line_height() - px(2.))
.h(window.line_height())
.text_size(self.tool_name_font_size())
.gap_0p5()
.gap_1p5()
.when(has_location || use_card_layout, |this| this.px_1())
.when(has_location, |this| {
this.cursor(CursorStyle::PointingHand)
.rounded_sm()
.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
})
.overflow_hidden()
.child(tool_icon)
.child(if tool_call.locations.len() == 1 {
.child(if has_location {
let name = tool_call.locations[0]
.path
.file_name()
@ -1863,13 +1933,6 @@ impl AcpThreadView {
h_flex()
.id(("open-tool-call-location", entry_ix))
.w_full()
.max_w_full()
.px_1p5()
.rounded_sm()
.overflow_x_scroll()
.hover(|label| {
label.bg(cx.theme().colors().element_hover.opacity(0.5))
})
.map(|this| {
if use_card_layout {
this.text_color(cx.theme().colors().text)
@ -1879,31 +1942,28 @@ impl AcpThreadView {
})
.child(name)
.tooltip(Tooltip::text("Jump to File"))
.cursor(gpui::CursorStyle::PointingHand)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
}))
.into_any_element()
} else {
h_flex()
.relative()
.w_full()
.max_w_full()
.ml_1p5()
.overflow_hidden()
.child(h_flex().pr_8().child(self.render_markdown(
.child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(false, true, window, cx),
)))
.child(gradient_overlay(gradient_color))
))
.into_any()
}),
})
.when(!has_location, |this| this.child(gradient_overlay)),
)
.child(
h_flex()
.gap_px()
.when(is_collapsible, |this| {
this.child(
.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)
@ -1920,15 +1980,16 @@ impl AcpThreadView {
}
})),
)
})
.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)
}
@ -2214,6 +2275,12 @@ impl AcpThreadView {
started_at.elapsed()
};
let header_id =
SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
let header_group = SharedString::from(format!(
"terminal-tool-header-group-{}",
terminal.entity_id()
));
let header_bg = cx
.theme()
.colors()
@ -2229,10 +2296,7 @@ impl AcpThreadView {
let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
let header = h_flex()
.id(SharedString::from(format!(
"terminal-tool-header-{}",
terminal.entity_id()
)))
.id(header_id)
.flex_none()
.gap_1()
.justify_between()
@ -2296,23 +2360,6 @@ impl AcpThreadView {
),
)
})
.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 {
@ -2365,6 +2412,7 @@ impl AcpThreadView {
)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.visible_on_hover(&header_group)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this, _event, _window, _cx| {
@ -2373,8 +2421,26 @@ impl AcpThreadView {
} else {
this.expanded_tool_calls.insert(id.clone());
}
}})),
);
}
})),
)
.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),
)))
}),
)
});
let terminal_view = self
.entry_view_state
@ -2384,7 +2450,8 @@ impl AcpThreadView {
let show_output = is_expanded && terminal_view.is_some();
v_flex()
.mb_2()
.my_2()
.mx_5()
.border_1()
.when(tool_failed || command_failed, |card| card.border_dashed())
.border_color(border_color)
@ -2392,9 +2459,10 @@ impl AcpThreadView {
.overflow_hidden()
.child(
v_flex()
.group(&header_group)
.py_1p5()
.pl_2()
.pr_1p5()
.pl_2()
.gap_0p5()
.bg(header_bg)
.text_xs()
@ -4153,13 +4221,14 @@ impl AcpThreadView {
) -> impl IntoElement {
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
if is_generating {
return h_flex().id("thread-controls-container").ml_1().child(
return h_flex().id("thread-controls-container").child(
div()
.py_2()
.px(rems_from_px(22.))
.px_5()
.child(SpinnerLabel::new().size(LabelSize::Small)),
);
}
let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
@ -4185,12 +4254,10 @@ impl AcpThreadView {
.id("thread-controls-container")
.group("thread-controls-container")
.w_full()
.mr_1()
.pt_1()
.pb_2()
.px(RESPONSE_PADDING_X)
.py_2()
.px_5()
.gap_px()
.opacity(0.4)
.opacity(0.6)
.hover(|style| style.opacity(1.))
.flex_wrap()
.justify_end();
@ -4201,56 +4268,50 @@ impl AcpThreadView {
.is_some_and(|thread| thread.read(cx).connection().telemetry().is_some())
{
let feedback = self.thread_feedback.feedback;
container = container.child(
div().visible_on_hover("thread-controls-container").child(
Label::new(
match feedback {
container = container
.child(
div().visible_on_hover("thread-controls-container").child(
Label::new(match feedback {
Some(ThreadFeedback::Positive) => "Thanks for your feedback!",
Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.",
None => "Rating the thread sends all of your current conversation to the Zed team.",
}
)
.color(Color::Muted)
.size(LabelSize::XSmall)
.truncate(),
),
).child(
h_flex()
.child(
IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(match feedback {
Some(ThreadFeedback::Positive) => Color::Accent,
_ => Color::Ignored,
})
.tooltip(Tooltip::text("Helpful Response"))
.on_click(cx.listener(move |this, _, window, cx| {
this.handle_feedback_click(
ThreadFeedback::Positive,
window,
cx,
);
})),
)
.child(
IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(match feedback {
Some(ThreadFeedback::Negative) => Color::Accent,
_ => Color::Ignored,
})
.tooltip(Tooltip::text("Not Helpful"))
.on_click(cx.listener(move |this, _, window, cx| {
this.handle_feedback_click(
ThreadFeedback::Negative,
window,
cx,
);
})),
)
)
Some(ThreadFeedback::Negative) => {
"We appreciate your feedback and will use it to improve."
}
None => {
"Rating the thread sends all of your current conversation to the Zed team."
}
})
.color(Color::Muted)
.size(LabelSize::XSmall)
.truncate(),
),
)
.child(
IconButton::new("feedback-thumbs-up", IconName::ThumbsUp)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(match feedback {
Some(ThreadFeedback::Positive) => Color::Accent,
_ => Color::Ignored,
})
.tooltip(Tooltip::text("Helpful Response"))
.on_click(cx.listener(move |this, _, window, cx| {
this.handle_feedback_click(ThreadFeedback::Positive, window, cx);
})),
)
.child(
IconButton::new("feedback-thumbs-down", IconName::ThumbsDown)
.shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(match feedback {
Some(ThreadFeedback::Negative) => Color::Accent,
_ => Color::Ignored,
})
.tooltip(Tooltip::text("Not Helpful"))
.on_click(cx.listener(move |this, _, window, cx| {
this.handle_feedback_click(ThreadFeedback::Negative, window, cx);
})),
);
}
container.child(open_as_markdown).child(scroll_to_top)

View file

@ -1323,7 +1323,7 @@ fn render_copy_code_block_button(
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::text("Copy Code"))
.tooltip(Tooltip::text("Copy"))
.on_click({
let markdown = markdown;
move |_event, _window, cx| {