agent: Improve terminal tool card design (#29712)

To-dos:

- [x] Expose the command to defend against cases where that's just super
long
- [x] Tackle the vertical scroll conflict with panel scroll
- [x] Reduce default font-size

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Agus Zubiaga <hi@aguz.me>
This commit is contained in:
Danilo Leal 2025-05-05 15:50:53 -03:00 committed by GitHub
parent e64f5ff358
commit 7dfbe0b908
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 174 additions and 145 deletions

View file

@ -708,7 +708,6 @@ impl ToolCard for EditFileToolCard {
.cursor_pointer() .cursor_pointer()
.h_5() .h_5()
.justify_center() .justify_center()
.rounded_b_md()
.border_t_1() .border_t_1()
.border_color(border_color) .border_color(border_color)
.bg(cx.theme().colors().editor_background) .bg(cx.theme().colors().editor_background)

View file

@ -1,10 +1,7 @@
use crate::schema::json_schema_for; use crate::schema::json_schema_for;
use anyhow::{Context as _, Result, anyhow, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
use gpui::{ use gpui::{AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, WeakEntity, Window};
Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
Transformation, WeakEntity, Window, percentage,
};
use language::LineEnding; use language::LineEnding;
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat}; use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use portable_pty::{CommandBuilder, PtySize, native_pty_system};
@ -19,7 +16,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use terminal_view::TerminalView; use terminal_view::TerminalView;
use ui::{Disclosure, IconName, Tooltip, prelude::*}; use ui::{Disclosure, Tooltip, prelude::*};
use util::{ use util::{
get_system_shell, markdown::MarkdownInlineCode, size::format_file_size, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
time::duration_alt_display, time::duration_alt_display,
@ -175,11 +172,13 @@ impl Tool for TerminalTool {
workspace.downgrade(), workspace.downgrade(),
None, None,
project.downgrade(), project.downgrade(),
true,
window, window,
cx, cx,
) )
}) })
})?; })?;
let _ = card.update(cx, |card, _| { let _ = card.update(cx, |card, _| {
card.terminal = Some(terminal_view.clone()); card.terminal = Some(terminal_view.clone());
card.start_instant = Instant::now(); card.start_instant = Instant::now();
@ -378,155 +377,118 @@ impl ToolCard for TerminalToolCard {
}; };
let tool_failed = matches!(status, ToolUseStatus::Error(_)); let tool_failed = matches!(status, ToolUseStatus::Error(_));
let command_failed = let command_failed =
self.command_finished && self.exit_status.is_none_or(|code| !code.success()); self.command_finished && self.exit_status.is_none_or(|code| !code.success());
if (tool_failed || command_failed) && self.elapsed_time.is_none() { if (tool_failed || command_failed) && self.elapsed_time.is_none() {
self.elapsed_time = Some(self.start_instant.elapsed()); self.elapsed_time = Some(self.start_instant.elapsed());
} }
let time_elapsed = self let time_elapsed = self
.elapsed_time .elapsed_time
.unwrap_or_else(|| self.start_instant.elapsed()); .unwrap_or_else(|| self.start_instant.elapsed());
let should_hide_terminal = let should_hide_terminal = tool_failed || self.finished_with_empty_output;
tool_failed || self.finished_with_empty_output || !self.preview_expanded;
let border_color = cx.theme().colors().border.opacity(0.6);
let header_bg = cx let header_bg = cx
.theme() .theme()
.colors() .colors()
.element_background .element_background
.blend(cx.theme().colors().editor_foreground.opacity(0.025)); .blend(cx.theme().colors().editor_foreground.opacity(0.025));
let header_label = h_flex() let border_color = cx.theme().colors().border.opacity(0.6);
.w_full()
.max_w_full() let path = self
.px_1() .working_dir
.gap_0p5() .as_ref()
.opacity(0.8) .cloned()
.child( .or_else(|| env::current_dir().ok())
h_flex() .map(|path| format!("{}", path.display()))
.child( .unwrap_or_else(|| "current directory".to_string());
Icon::new(IconName::Terminal)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(
div()
.id(("terminal-tool-header-input-command", self.entity_id))
.text_size(rems(0.8125))
.font_buffer(cx)
.child(self.input_command.clone())
.ml_1p5()
.mr_0p5()
.tooltip({
let path = self
.working_dir
.as_ref()
.cloned()
.or_else(|| env::current_dir().ok())
.map(|path| format!("\"{}\"", path.display()))
.unwrap_or_else(|| "current directory".to_string());
Tooltip::text(if self.command_finished {
format!("Ran in {path}")
} else {
format!("Running in {path}")
})
}),
),
)
.into_any_element();
let header = h_flex() let header = h_flex()
.flex_none() .flex_none()
.p_1()
.gap_1() .gap_1()
.justify_between() .justify_between()
.rounded_t_md() .rounded_t_md()
.bg(header_bg) .child(
.child(header_label) div()
.map(|header| { .id(("command-target-path", self.entity_id))
let header = header .w_full()
.when(self.was_content_truncated, |header| { .max_w_full()
let tooltip = .overflow_x_scroll()
if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { .child(
"Output exceeded terminal max lines and was \ Label::new(path)
truncated, the model received the first 16 KB." .buffer_font(cx)
.to_string() .size(LabelSize::XSmall)
} else { .color(Color::Muted),
format!( ),
"Output is {} long, to avoid unexpected token usage, \ )
only 16 KB was sent back to the model.", .when(self.was_content_truncated, |header| {
format_file_size(self.original_content_len as u64, true), let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
) "Output exceeded terminal max lines and was \
}; truncated, the model received the first 16 KB."
header.child( .to_string()
div()
.id(("terminal-tool-truncated-label", self.entity_id))
.tooltip(Tooltip::text(tooltip))
.child(
Label::new("(truncated)")
.color(Color::Disabled)
.size(LabelSize::Small),
),
)
})
.when(time_elapsed > Duration::from_secs(10), |header| {
header.child(
Label::new(format!("({})", duration_alt_display(time_elapsed)))
.buffer_font(cx)
.color(Color::Disabled)
.size(LabelSize::Small),
)
});
if tool_failed || command_failed {
header.child(
div()
.id(("terminal-tool-error-code-indicator", self.entity_id))
.child(
Icon::new(IconName::Close)
.size(IconSize::Small)
.color(Color::Error),
)
.when(command_failed && self.exit_status.is_some(), |this| {
this.tooltip(Tooltip::text(format!(
"Exited with code {}",
self.exit_status
.and_then(|status| status.code())
.unwrap_or(-1),
)))
})
.when(
!command_failed && tool_failed && status.error().is_some(),
|this| {
this.tooltip(Tooltip::text(format!(
"Error: {}",
status.error().unwrap(),
)))
},
),
)
} else if self.command_finished {
header.child(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Success),
)
} else { } else {
header.child( format!(
Icon::new(IconName::ArrowCircle) "Output is {} long, to avoid unexpected token usage, \
.size(IconSize::Small) only 16 KB was sent back to the model.",
.color(Color::Info) format_file_size(self.original_content_len as u64, true),
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
),
) )
} };
header.child(
h_flex()
.id(("terminal-tool-truncated-label", self.entity_id))
.tooltip(Tooltip::text(tooltip))
.gap_1()
.child(
Icon::new(IconName::Info)
.size(IconSize::XSmall)
.color(Color::Ignored),
)
.child(
Label::new("Truncated")
.color(Color::Muted)
.size(LabelSize::Small),
),
)
}) })
.when(!tool_failed && !self.finished_with_empty_output, |header| { .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::Small),
)
})
.when(tool_failed || command_failed, |header| {
header.child(
div()
.id(("terminal-tool-error-code-indicator", self.entity_id))
.child(
Icon::new(IconName::Close)
.size(IconSize::Small)
.color(Color::Error),
)
.when(command_failed && self.exit_status.is_some(), |this| {
this.tooltip(Tooltip::text(format!(
"Exited with code {}",
self.exit_status
.and_then(|status| status.code())
.unwrap_or(-1),
)))
})
.when(
!command_failed && tool_failed && status.error().is_some(),
|this| {
this.tooltip(Tooltip::text(format!(
"Error: {}",
status.error().unwrap(),
)))
},
),
)
})
.when(!should_hide_terminal, |header| {
header.child( header.child(
Disclosure::new( Disclosure::new(
("terminal-tool-disclosure", self.entity_id), ("terminal-tool-disclosure", self.entity_id),
@ -549,9 +511,25 @@ impl ToolCard for TerminalToolCard {
.border_color(border_color) .border_color(border_color)
.rounded_lg() .rounded_lg()
.overflow_hidden() .overflow_hidden()
.child(header) .child(
.when(!should_hide_terminal, |this| { v_flex().p_2().gap_0p5().bg(header_bg).child(header).child(
this.child(div().child(terminal.clone()).min_h(px(250.0))) Label::new(self.input_command.clone())
.buffer_font(cx)
.size(LabelSize::Small),
),
)
.when(self.preview_expanded && !should_hide_terminal, |this| {
this.child(
div()
.pt_2()
.min_h_72()
.border_t_1()
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.rounded_b_md()
.text_ui_sm(cx)
.child(terminal.clone()),
)
}) })
.into_any() .into_any()
} }

View file

@ -749,7 +749,15 @@ impl RunningState {
let terminal = terminal_task.await?; let terminal = terminal_task.await?;
let terminal_view = cx.new_window_entity(|window, cx| { let terminal_view = cx.new_window_entity(|window, cx| {
TerminalView::new(terminal.clone(), workspace, None, weak_project, window, cx) TerminalView::new(
terminal.clone(),
workspace,
None,
weak_project,
false,
window,
cx,
)
})?; })?;
running.update_in(cx, |running, window, cx| { running.update_in(cx, |running, window, cx| {

View file

@ -601,6 +601,8 @@ pub struct TerminalContent {
pub cursor_char: char, pub cursor_char: char,
pub terminal_bounds: TerminalBounds, pub terminal_bounds: TerminalBounds,
pub last_hovered_word: Option<HoveredWord>, pub last_hovered_word: Option<HoveredWord>,
pub scrolled_to_top: bool,
pub scrolled_to_bottom: bool,
} }
#[derive(Clone)] #[derive(Clone)]
@ -625,6 +627,8 @@ impl Default for TerminalContent {
cursor_char: Default::default(), cursor_char: Default::default(),
terminal_bounds: Default::default(), terminal_bounds: Default::default(),
last_hovered_word: None, last_hovered_word: None,
scrolled_to_top: false,
scrolled_to_bottom: false,
} }
} }
} }
@ -1208,6 +1212,14 @@ impl Terminal {
.push_back(InternalEvent::Scroll(AlacScroll::Bottom)); .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
} }
pub fn scrolled_to_top(&self) -> bool {
self.last_content.scrolled_to_top
}
pub fn scrolled_to_bottom(&self) -> bool {
self.last_content.scrolled_to_bottom
}
///Resize the terminal and the PTY. ///Resize the terminal and the PTY.
pub fn set_size(&mut self, new_bounds: TerminalBounds) { pub fn set_size(&mut self, new_bounds: TerminalBounds) {
if self.last_content.terminal_bounds != new_bounds { if self.last_content.terminal_bounds != new_bounds {
@ -1405,6 +1417,8 @@ impl Terminal {
cursor_char: term.grid()[content.cursor.point].c, cursor_char: term.grid()[content.cursor.point].c,
terminal_bounds: last_content.terminal_bounds, terminal_bounds: last_content.terminal_bounds,
last_hovered_word: last_content.last_hovered_word.clone(), last_hovered_word: last_content.last_hovered_word.clone(),
scrolled_to_top: content.display_offset == term.history_size(),
scrolled_to_bottom: content.display_offset == 0,
} }
} }

View file

@ -261,6 +261,7 @@ async fn deserialize_pane_group(
workspace.clone(), workspace.clone(),
Some(workspace_id), Some(workspace_id),
project.downgrade(), project.downgrade(),
false,
window, window,
cx, cx,
) )

View file

@ -1,9 +1,9 @@
use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine}; use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
use gpui::{ use gpui::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle, ElementId, Entity, FocusHandle, Focusable, Font, FontStyle, FontWeight, GlobalElementId,
Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine, LayoutId, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection,
UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px, UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px,
relative, size, relative, size,
@ -158,6 +158,7 @@ pub struct TerminalElement {
focused: bool, focused: bool,
cursor_visible: bool, cursor_visible: bool,
interactivity: Interactivity, interactivity: Interactivity,
embedded: bool,
block_below_cursor: Option<Rc<BlockProperties>>, block_below_cursor: Option<Rc<BlockProperties>>,
} }
@ -178,6 +179,7 @@ impl TerminalElement {
focused: bool, focused: bool,
cursor_visible: bool, cursor_visible: bool,
block_below_cursor: Option<Rc<BlockProperties>>, block_below_cursor: Option<Rc<BlockProperties>>,
embedded: bool,
) -> TerminalElement { ) -> TerminalElement {
TerminalElement { TerminalElement {
terminal, terminal,
@ -187,6 +189,7 @@ impl TerminalElement {
focus: focus.clone(), focus: focus.clone(),
cursor_visible, cursor_visible,
block_below_cursor, block_below_cursor,
embedded,
interactivity: Default::default(), interactivity: Default::default(),
} }
.track_focus(&focus) .track_focus(&focus)
@ -503,11 +506,15 @@ impl TerminalElement {
); );
self.interactivity.on_scroll_wheel({ self.interactivity.on_scroll_wheel({
let terminal_view = self.terminal_view.downgrade(); let terminal_view = self.terminal_view.downgrade();
move |e, _, cx| { move |e, window, cx| {
terminal_view terminal_view
.update(cx, |terminal_view, cx| { .update(cx, |terminal_view, cx| {
terminal_view.scroll_wheel(e, cx); if !terminal_view.embedded
cx.notify(); || terminal_view.focus_handle(cx).is_focused(window)
{
terminal_view.scroll_wheel(e, cx);
cx.notify();
}
}) })
.ok(); .ok();
} }
@ -580,6 +587,16 @@ impl Element for TerminalElement {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) { ) -> (LayoutId, Self::RequestLayoutState) {
if self.embedded {
let scrollable = {
let term = self.terminal.read(cx);
!term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused
};
if scrollable {
self.interactivity.occlude_mouse();
}
}
let layout_id = let layout_id =
self.interactivity self.interactivity
.request_layout(global_id, window, cx, |mut style, window, cx| { .request_layout(global_id, window, cx, |mut style, window, cx| {
@ -636,10 +653,14 @@ impl Element for TerminalElement {
let font_weight = terminal_settings.font_weight.unwrap_or_default(); let font_weight = terminal_settings.font_weight.unwrap_or_default();
let line_height = terminal_settings.line_height.value(); let line_height = terminal_settings.line_height.value();
let font_size = terminal_settings.font_size;
let font_size = let font_size = if self.embedded {
font_size.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)); window.text_style().font_size.to_pixels(window.rem_size())
} else {
terminal_settings
.font_size
.map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx))
};
let theme = cx.theme().clone(); let theme = cx.theme().clone();

View file

@ -437,6 +437,7 @@ impl TerminalPanel {
weak_workspace.clone(), weak_workspace.clone(),
database_id, database_id,
project.downgrade(), project.downgrade(),
false,
window, window,
cx, cx,
) )
@ -674,6 +675,7 @@ impl TerminalPanel {
workspace.weak_handle(), workspace.weak_handle(),
workspace.database_id(), workspace.database_id(),
workspace.project().downgrade(), workspace.project().downgrade(),
false,
window, window,
cx, cx,
) )
@ -714,6 +716,7 @@ impl TerminalPanel {
workspace.weak_handle(), workspace.weak_handle(),
workspace.database_id(), workspace.database_id(),
workspace.project().downgrade(), workspace.project().downgrade(),
false,
window, window,
cx, cx,
) )

View file

@ -111,6 +111,7 @@ pub struct TerminalView {
context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>, context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
cursor_shape: CursorShape, cursor_shape: CursorShape,
blink_state: bool, blink_state: bool,
embedded: bool,
blinking_terminal_enabled: bool, blinking_terminal_enabled: bool,
cwd_serialized: bool, cwd_serialized: bool,
blinking_paused: bool, blinking_paused: bool,
@ -162,6 +163,7 @@ impl TerminalView {
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
workspace_id: Option<WorkspaceId>, workspace_id: Option<WorkspaceId>,
project: WeakEntity<Project>, project: WeakEntity<Project>,
embedded: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@ -200,6 +202,7 @@ impl TerminalView {
blink_epoch: 0, blink_epoch: 0,
hover_target_tooltip: None, hover_target_tooltip: None,
hover_tooltip_update: Task::ready(()), hover_tooltip_update: Task::ready(()),
embedded,
workspace_id, workspace_id,
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
block_below_cursor: None, block_below_cursor: None,
@ -381,7 +384,6 @@ impl TerminalView {
return; return;
} }
} }
self.terminal.update(cx, |term, _| term.scroll_wheel(event)); self.terminal.update(cx, |term, _| term.scroll_wheel(event));
} }
@ -1377,6 +1379,7 @@ impl Render for TerminalView {
focused, focused,
self.should_show_cursor(focused, cx), self.should_show_cursor(focused, cx),
self.block_below_cursor.clone(), self.block_below_cursor.clone(),
self.embedded,
)) ))
.when_some(self.render_scrollbar(cx), |div, scrollbar| { .when_some(self.render_scrollbar(cx), |div, scrollbar| {
div.child(scrollbar) div.child(scrollbar)
@ -1502,6 +1505,7 @@ impl Item for TerminalView {
self.workspace.clone(), self.workspace.clone(),
workspace_id, workspace_id,
self.project.clone(), self.project.clone(),
false,
window, window,
cx, cx,
) )
@ -1659,6 +1663,7 @@ impl SerializableItem for TerminalView {
workspace, workspace,
Some(workspace_id), Some(workspace_id),
project.downgrade(), project.downgrade(),
false,
window, window,
cx, cx,
) )