use std::time::Duration; use gpui::{AnyElement, ClickEvent, CursorStyle, Window}; use ui::{CommonAnimationExt, Disclosure, Divider, DividerColor, Tooltip, prelude::*}; use util::time::duration_alt_display; const ELAPSED_DISPLAY_THRESHOLD: Duration = Duration::from_secs(10); type ClickHandler = Box; pub struct TerminalSandboxWarning { pub title: SharedString, pub detail: SharedString, pub docs_url: SharedString, } #[derive(IntoElement, RegisterComponent)] pub struct TerminalToolHeader { id: SharedString, hover_group: SharedString, working_dir: SharedString, is_expanded: bool, elapsed: Option, running: bool, truncated_tooltip: Option, failed: bool, exit_code: Option, sandbox_warning: Option, on_toggle_expand: Option, on_stop: Option, command_slot: Option, } impl TerminalToolHeader { pub fn new( id: impl Into, hover_group: impl Into, working_dir: impl Into, is_expanded: bool, ) -> Self { Self { id: id.into(), hover_group: hover_group.into(), working_dir: working_dir.into(), is_expanded, elapsed: None, running: false, truncated_tooltip: None, failed: false, exit_code: None, sandbox_warning: None, on_toggle_expand: None, on_stop: None, command_slot: None, } } pub fn elapsed(mut self, elapsed: Duration) -> Self { self.elapsed = Some(elapsed); self } pub fn running(mut self, running: bool) -> Self { self.running = running; self } pub fn truncated(mut self, tooltip: impl Into) -> Self { self.truncated_tooltip = Some(tooltip.into()); self } pub fn failed(mut self, exit_code: Option) -> Self { self.failed = true; self.exit_code = exit_code; self } pub fn sandbox_warning(mut self, warning: TerminalSandboxWarning) -> Self { self.sandbox_warning = Some(warning); self } pub fn on_toggle_expand( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_toggle_expand = Some(Box::new(handler)); self } pub fn on_stop( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_stop = Some(Box::new(handler)); self } pub fn command_slot(mut self, element: impl IntoElement) -> Self { self.command_slot = Some(element.into_any_element()); self } } impl RenderOnce for TerminalToolHeader { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let show_elapsed = self .elapsed .is_some_and(|elapsed| elapsed > ELAPSED_DISPLAY_THRESHOLD); let Self { id, hover_group, working_dir, is_expanded, elapsed, running, truncated_tooltip, failed, exit_code, sandbox_warning, on_toggle_expand, on_stop, command_slot, } = self; let child_id = |name: &str| format!("terminal-tool-{name}-{id}"); let header_bg = cx .theme() .colors() .element_background .blend(cx.theme().colors().editor_foreground.opacity(0.025)); let header_row = h_flex() .id(child_id("header")) .pt_1() .pl_1p5() .pr_1() .flex_none() .gap_1() .justify_between() .rounded_t_md() .child( div().w_full().min_w_0().overflow_hidden().child( Label::new(working_dir) .buffer_font(cx) .size(LabelSize::XSmall) .color(Color::Muted) .truncate_start(), ), ) .child( Disclosure::new(child_id("disclosure"), is_expanded) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&hover_group) .when_some(on_toggle_expand, |this, handler| this.on_click(handler)), ) .when(show_elapsed, |header| { let elapsed = elapsed.unwrap_or_default(); header.child( Label::new(format!("({})", duration_alt_display(elapsed))) .buffer_font(cx) .color(Color::Muted) .size(LabelSize::XSmall) .mr_0p5() .when(truncated_tooltip.is_some(), |s| s.mr_0()), ) }) .when(running, |header| { header .gap_1p5() .child( Icon::new(IconName::LoadCircle) .size(IconSize::Small) .color(Color::Muted) .with_rotate_animation(2), ) .child(Divider::vertical().color(DividerColor::Border).ml_1()) .child( IconButton::new(child_id("stop"), IconName::Stop) .icon_size(IconSize::Small) .icon_color(Color::Error) .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.", cx, ) }) .when_some(on_stop, |this, handler| this.on_click(handler)), ) }) .when_some(truncated_tooltip, |header, tooltip| { header.child( IconButton::new(child_id("truncated"), IconName::Info) .cursor_style(CursorStyle::Arrow) .style(ButtonStyle::Transparent) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text(tooltip)), ) }) .when(failed, |header| { header.child( IconButton::new(child_id("failed"), IconName::Close) .cursor_style(CursorStyle::Arrow) .style(ButtonStyle::Transparent) .icon_size(IconSize::Small) .icon_color(Color::Error) .when_some(exit_code, |this, code| { this.tooltip(Tooltip::text(format!("Exited with code {code}"))) }), ) }) .when_some(sandbox_warning, |header, warning| { let TerminalSandboxWarning { title, detail, docs_url, } = warning; header.child( IconButton::new(child_id("sandbox-not-applied"), IconName::LockOff) .icon_size(IconSize::Small) .tooltip(move |_window, cx| { Tooltip::with_meta( title.clone(), None, format!("{detail} Click to learn more about sandboxing."), cx, ) }) .on_click(move |_, _, cx| cx.open_url(&docs_url)), ) }); v_flex() .group(hover_group) .text_xs() .bg(header_bg) .child(header_row) .children(command_slot) } } impl Component for TerminalToolHeader { fn scope() -> ComponentScope { ComponentScope::Agent } fn name() -> &'static str { "Terminal Tool Header" } fn description() -> &'static str { "The top of a terminal tool call card in the agent panel." } fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { let working_dir = "/Users/you/projects/zed"; let card = |_id: &'static str, header: TerminalToolHeader| { v_flex() .w_full() .border_1() .border_color(cx.theme().colors().border.opacity(0.6)) .rounded_md() .overflow_hidden() .child( header.command_slot( div().px_1p5().pb_1().child( Label::new("cargo build --release") .buffer_font(cx) .size(LabelSize::XSmall), ), ), ) .into_any_element() }; let sandbox_warning = || TerminalSandboxWarning { title: "Ran without sandbox".into(), detail: "Unsandboxed execution is allowed for the rest of this thread.".into(), docs_url: "https://zed.dev/docs/ai/sandboxing".into(), }; v_flex() .gap_4() .child(example_group(vec![ single_example( "Running", card( "running", TerminalToolHeader::new( "running", "preview-terminal-header-group-running", working_dir, false, ) .running(true), ), ), single_example( "Finished (long-running)", card( "elapsed", TerminalToolHeader::new( "elapsed", "preview-terminal-header-group-elapsed", working_dir, false, ) .elapsed(Duration::from_secs(83)), ), ), single_example( "Truncated output", card( "truncated", TerminalToolHeader::new( "truncated", "preview-terminal-header-group-truncated", working_dir, true, ) .truncated( "Output is 2.5 MB long, and to avoid unexpected token \ usage, only 16 KB was sent back to the agent.", ), ), ), single_example( "Failed with exit code", card( "failed", TerminalToolHeader::new( "failed", "preview-terminal-header-group-failed", working_dir, false, ) .failed(Some(101)), ), ), single_example( "Ran without sandbox", card( "sandbox", TerminalToolHeader::new( "sandbox", "preview-terminal-header-group-sandbox", working_dir, false, ) .sandbox_warning(sandbox_warning()), ), ), single_example( "Long path (truncated from the start)", div() .w_80() .child(card( "long-path", TerminalToolHeader::new( "long-path", "preview-terminal-header-group-long-path", "/Users/you/Documents/GitHub/worktrees/some-monorepo/working-tree-three/packages/deeply/nested/service/backend/src", false, ), )) .into_any_element(), ), single_example( "Everything at once", card( "kitchen-sink", TerminalToolHeader::new( "kitchen-sink", "preview-terminal-header-group-kitchen-sink", working_dir, true, ) .elapsed(Duration::from_secs(3671)) .truncated("Output was truncated") .failed(Some(1)) .sandbox_warning(sandbox_warning()), ), ), ])) .into_any_element() } }