Skip to main content

gpui_ai/
tool_call.rs

1//! Collapsible tool-call cards and the group that folds a burst of them.
2//!
3//! A [`ToolCall`] shows one invocation: its name, a one-line summary, the
4//! shared status vocabulary, and — when expanded — the input the agent sent,
5//! the output it received, or the failure reason. Calls that need a human
6//! decision carry Allow / Deny controls. A [`ToolGroup`] collapses several
7//! consecutive calls behind one shimmering "Running N tools…" header so a
8//! transcript stays readable while an agent works.
9//!
10//! Both are controlled: the application owns every invocation as a
11//! [`Progressive<ToolInvocation>`] and decides what each typed event means.
12
13use crate::ButtonLabelExt as _;
14use crate::control::ControlMetricsExt as _;
15use crate::control::QuietSurfaceExt as _;
16use crate::cues::{self, Cue};
17use crate::{
18    code_block::CodeBlock,
19    control::{PressReleaseExt as _, composed_button},
20    handlers::{Handler, SharedHandler},
21    motion::{Shimmer, disclosure_progress},
22    sizing::SizeTokens,
23    status::{StatusBadge, StatusTone, progress_label},
24    stream::{ProgressState, Progressive},
25    surface::{eyebrow, inset, leading_glyph_slot, meta},
26    theme::SemanticStyledExt as _,
27};
28use gpui::{
29    AnyElement, App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
30    ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
31    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
32};
33use gpui_component::{
34    ActiveTheme as _, Icon, IconName, IconNamed, Sizable as _, StyledExt as _,
35    button::{Button, ButtonVariants as _},
36    h_flex,
37    text::TextView,
38    v_flex,
39};
40use std::{rc::Rc, time::Duration};
41
42/// Whether a person must allow a tool call before it runs, and what they decided.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ToolApproval {
45    /// The call runs without a decision.
46    #[default]
47    NotRequired,
48    /// The call is paused until someone allows or denies it.
49    Requested,
50    /// Someone allowed the call.
51    Approved,
52    /// Someone denied the call.
53    Rejected,
54}
55
56/// Application-owned description of one tool invocation.
57///
58/// The lifecycle (pending, running, complete, failed) comes from the
59/// enclosing [`Progressive`]; this type carries only what the call is and
60/// what it produced.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ToolInvocation {
63    id: SharedString,
64    name: SharedString,
65    summary: Option<SharedString>,
66    input: Option<SharedString>,
67    input_language: SharedString,
68    output: Option<SharedString>,
69    elapsed: Option<Duration>,
70    approval: ToolApproval,
71    icon: Option<SharedString>,
72}
73
74impl ToolInvocation {
75    /// Creates an invocation with a stable application identifier and the
76    /// tool name shown in the header.
77    pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
78        Self {
79            id: id.into(),
80            name: name.into(),
81            summary: None,
82            input: None,
83            input_language: "json".into(),
84            output: None,
85            elapsed: None,
86            approval: ToolApproval::NotRequired,
87            icon: None,
88        }
89    }
90
91    /// Adds a one-line summary of the arguments (shown in the header).
92    pub fn summary(mut self, summary: impl Into<SharedString>) -> Self {
93        self.summary = Some(summary.into());
94        self
95    }
96
97    /// Adds the full input the agent sent, rendered as code.
98    pub fn input(mut self, input: impl Into<SharedString>) -> Self {
99        self.input = Some(input.into());
100        self
101    }
102
103    /// Sets the language used to highlight the input (default `json`).
104    pub fn input_language(mut self, language: impl Into<SharedString>) -> Self {
105        self.input_language = language.into();
106        self
107    }
108
109    /// Adds the tool's result, rendered as selectable Markdown.
110    pub fn output(mut self, output: impl Into<SharedString>) -> Self {
111        self.output = Some(output.into());
112        self
113    }
114
115    /// Adds caller-measured elapsed time; the component never owns a clock.
116    pub fn elapsed(mut self, elapsed: Duration) -> Self {
117        self.elapsed = Some(elapsed);
118        self
119    }
120
121    /// Sets the human approval state.
122    pub fn approval(mut self, approval: ToolApproval) -> Self {
123        self.approval = approval;
124        self
125    }
126
127    /// Replaces the default terminal glyph with a tool-specific icon.
128    pub fn icon(mut self, icon: impl IconNamed) -> Self {
129        self.icon = Some(icon.path());
130        self
131    }
132
133    /// Returns the stable invocation identifier.
134    pub fn id(&self) -> &SharedString {
135        &self.id
136    }
137
138    /// Returns the tool name.
139    pub fn name(&self) -> &SharedString {
140        &self.name
141    }
142
143    /// Returns the one-line summary, when present.
144    pub fn summary_text(&self) -> Option<&SharedString> {
145        self.summary.as_ref()
146    }
147
148    /// Returns the approval state.
149    pub fn approval_state(&self) -> ToolApproval {
150        self.approval
151    }
152}
153
154/// An interaction emitted by [`ToolCall`].
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum ToolCallEvent {
157    /// Requests a controlled expansion-state change.
158    Toggled {
159        /// Stable invocation identifier.
160        id: SharedString,
161        /// Proposed expansion state.
162        open: bool,
163    },
164    /// The user allowed a call that was awaiting approval.
165    Approved {
166        /// Stable invocation identifier.
167        id: SharedString,
168    },
169    /// The user denied a call that was awaiting approval.
170    Rejected {
171        /// Stable invocation identifier.
172        id: SharedString,
173    },
174}
175
176/// A collapsible card for one tool invocation.
177///
178/// # Example
179///
180/// ```
181/// # use gpui_ai::prelude::*;
182/// let call = Progressive::complete(
183///     ToolInvocation::new("read-1", "read_file")
184///         .summary("pricing.md")
185///         .input("{ \"path\": \"pricing.md\" }")
186///         .output("Read **214** lines."),
187/// );
188/// ToolCall::new(&call).on_event(|event, _, _| match event {
189///     ToolCallEvent::Approved { id } => { /* run it */ }
190///     ToolCallEvent::Rejected { id } => { /* skip it */ }
191///     ToolCallEvent::Toggled { id, open } => { /* persist `open` */ }
192/// });
193/// ```
194#[derive(IntoElement)]
195pub struct ToolCall {
196    style: StyleRefinement,
197    state: ProgressState,
198    invocation: ToolInvocation,
199    open: Option<bool>,
200    on_event: Option<SharedHandler<ToolCallEvent>>,
201}
202
203impl ToolCall {
204    /// Creates a card from a progressive invocation snapshot.
205    ///
206    /// Without an explicit [`Self::open`], the card expands when the call
207    /// failed or awaits approval and stays collapsed otherwise.
208    pub fn new(call: &Progressive<ToolInvocation>) -> Self {
209        Self {
210            style: StyleRefinement::default(),
211            state: call.state().clone(),
212            invocation: call.content().clone(),
213            open: None,
214            on_event: None,
215        }
216    }
217
218    /// Sets the expansion state explicitly, replacing the automatic policy.
219    pub fn open(mut self, open: bool) -> Self {
220        self.open = Some(open);
221        self
222    }
223
224    /// Handles typed card interactions. Without a handler the card is a
225    /// static, non-interactive summary.
226    pub fn on_event(
227        mut self,
228        handler: impl Fn(&ToolCallEvent, &mut Window, &mut App) + 'static,
229    ) -> Self {
230        self.on_event = Some(Rc::new(handler));
231        self
232    }
233
234    /// Whether the body is shown: the explicit value, or "open when it needs
235    /// attention" (failed, or awaiting approval).
236    pub fn is_open(&self) -> bool {
237        self.open.unwrap_or(
238            matches!(self.state, ProgressState::Failed(_))
239                || self.invocation.approval == ToolApproval::Requested,
240        )
241    }
242
243    fn accessibility_label(&self) -> SharedString {
244        let status = match self.invocation.approval {
245            ToolApproval::Requested => "awaiting approval",
246            ToolApproval::Rejected => "denied",
247            ToolApproval::NotRequired | ToolApproval::Approved => progress_label(&self.state),
248        };
249        match &self.invocation.summary {
250            Some(summary) => format!("{} {}, {}", self.invocation.name, summary, status).into(),
251            None => format!("{}, {}", self.invocation.name, status).into(),
252        }
253    }
254
255    fn status_glyph(&self, window: &mut Window, cx: &mut App) -> AnyElement {
256        // The terminal glyphs settle in rather than popping, once per state
257        // and never on a re-render; the state a card mounts with is exempt.
258        crate::status::progress_glyph(
259            &self.state,
260            ElementId::from((ElementId::from(self.invocation.id.clone()), "glyph")),
261            crate::sizing::slot_sm(cx),
262            window,
263            cx,
264        )
265        .into_any_element()
266    }
267}
268
269impl Styled for ToolCall {
270    fn style(&mut self) -> &mut StyleRefinement {
271        &mut self.style
272    }
273}
274
275impl RenderOnce for ToolCall {
276    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
277        let tokens = cx.theme().semantic_tokens();
278        let open = self.is_open();
279        let id = self.invocation.id.clone();
280        let root_id = ElementId::from(id.clone());
281        let disclosure = disclosure_progress((root_id.clone(), "disclosure"), open, window, cx);
282        let disclosure_opacity =
283            crate::motion::disclosure_fade((root_id.clone(), "disclosure"), open, window, cx);
284        // Opacity does not make descendants inert. Close the interaction tree
285        // immediately; the header alone retains the closing transition.
286        let showing = open;
287        // The capsule morph: a closed header-only card sits one step
288        // rounder — twice the card radius reads as a capsule on a
289        // single-line header — and relaxes to the card radius as the body
290        // opens. It rides the disclosure sample the body already animates
291        // on, so it costs no clock and snaps whenever the disclosure
292        // snaps. The header's own states are drawn inside it.
293        let card_radius = tokens.radius.lg + tokens.radius.lg * (1.0 - disclosure);
294        let accessibility_label = self.accessibility_label();
295        let interactive = self.on_event.is_some();
296        let handler = self.on_event.clone();
297        let approval = self.invocation.approval;
298        let failure = match &self.state {
299            ProgressState::Failed(reason) => Some(reason.clone()),
300            _ => None,
301        };
302        let badge = match approval {
303            ToolApproval::Requested => {
304                StatusBadge::new((root_id.clone(), "status"), "Needs approval")
305                    .tone(StatusTone::Warning)
306            }
307            ToolApproval::Rejected => {
308                StatusBadge::new((root_id.clone(), "status"), "Denied").tone(StatusTone::Neutral)
309            }
310            ToolApproval::NotRequired | ToolApproval::Approved => {
311                StatusBadge::for_progress((root_id.clone(), "status"), &self.state)
312            }
313        };
314        let tool_icon = match &self.invocation.icon {
315            Some(path) => Icon::default().path(path.clone()),
316            None => Icon::new(IconName::SquareTerminal),
317        };
318        let header = h_flex()
319            .w_full()
320            .min_w_0()
321            .items_center()
322            .gap(tokens.spacing.sm)
323            .child(
324                // A fixed square slot: spinner, check, cross, and the
325                // pending dot all centre in the same box, so a lifecycle
326                // change never nudges the name sideways.
327                div()
328                    .flex_none()
329                    .size(crate::sizing::slot_xs(cx))
330                    .flex()
331                    .items_center()
332                    .justify_center()
333                    .child(self.status_glyph(window, cx)),
334            )
335            .child(tool_icon.xsmall().text_color(cx.theme().muted_foreground))
336            .child(
337                div()
338                    .flex_none()
339                    .text_token(tokens.typography.sm)
340                    .font_weight(FontWeight::MEDIUM)
341                    .font_family(cx.theme().mono_font_family.clone())
342                    .text_color(cx.theme().foreground)
343                    .child(self.invocation.name.clone()),
344            )
345            .when_some(self.invocation.summary.clone(), |this, summary| {
346                this.child(
347                    div()
348                        .flex_1()
349                        .min_w_0()
350                        .truncate()
351                        .text_token(tokens.typography.sm)
352                        .text_color(cx.theme().muted_foreground)
353                        .child(summary),
354                )
355            })
356            .when(self.invocation.summary.is_none(), |this| {
357                this.child(div().flex_1())
358            })
359            .when_some(self.invocation.elapsed, |this, elapsed| {
360                this.child(meta(format_elapsed(elapsed), cx).flex_none())
361            })
362            .child(badge)
363            .when(interactive, |this| {
364                this.child(
365                    crate::surface::disclosure_chevron(disclosure)
366                        .text_color(cx.theme().muted_foreground),
367                )
368            });
369        let header = match handler.clone() {
370            Some(handler) => {
371                let event = ToolCallEvent::Toggled {
372                    id: id.clone(),
373                    open: !open,
374                };
375                let toggle_debug_id = id.clone();
376                composed_button((root_id.clone(), "toggle"), accessibility_label.clone())
377                    .debug_selector(move || format!("tool-call-toggle-{toggle_debug_id}"))
378                    .aria_expanded(open)
379                    .w_full()
380                    .px(tokens.spacing.md)
381                    .py(tokens.spacing.sm)
382                    .border_1()
383                    .border_color(cx.theme().transparent)
384                    // The header's states are drawn inside the card's own
385                    // corners: a closed card is header-only and takes the
386                    // whole capsule, an open one rounds its top alone. A
387                    // square fill under a rounded card was the corner the
388                    // 0.4.0 feel review saw poking out.
389                    .rounded_t(card_radius)
390                    .when(!showing, |header| header.rounded_b(card_radius))
391                    .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
392                    .active(|style| style.bg(cx.theme().accent))
393                    .focus_visible(|style| style.border_color(cx.theme().ring))
394                    .press_release(
395                        ElementId::from((root_id.clone(), "toggle")),
396                        card_radius,
397                        window,
398                        cx,
399                    )
400                    .child(header)
401                    .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
402                    .into_any_element()
403            }
404            None => header
405                .px(tokens.spacing.md)
406                .py(tokens.spacing.sm)
407                .into_any_element(),
408        };
409
410        let input = self.invocation.input.clone();
411        let input_language = self.invocation.input_language.clone();
412        let output = self.invocation.output.clone();
413        let body_debug_id = id.clone();
414        let body = v_flex()
415            .debug_selector(move || format!("tool-call-body-{body_debug_id}"))
416            .w_full()
417            .min_w_0()
418            .gap(tokens.spacing.sm)
419            .px(tokens.spacing.md)
420            .pb(tokens.spacing.md)
421            .border_t_1()
422            .border_color(crate::surface::hairline(cx))
423            .pt(tokens.spacing.sm)
424            .when(approval == ToolApproval::Requested, |this| {
425                let approve = handler.clone();
426                let reject = handler.clone();
427                let approve_id = id.clone();
428                let reject_id = id.clone();
429                this.child(
430                    h_flex()
431                        .id((root_id.clone(), "approval"))
432                        .role(Role::Group)
433                        .aria_label("Approval")
434                        .w_full()
435                        .items_center()
436                        .justify_between()
437                        .gap(tokens.spacing.sm)
438                        .child(
439                            div()
440                                .text_token(tokens.typography.sm)
441                                .text_color(cx.theme().foreground)
442                                .child("This call is waiting for your decision"),
443                        )
444                        .child(
445                            h_flex()
446                                .gap(tokens.spacing.xs)
447                                .when_some(approve, |this, handler| {
448                                    let allow_debug_id = approve_id.clone();
449                                    this.child(
450                                        div()
451                                            .debug_selector(move || {
452                                                format!("tool-call-allow-{allow_debug_id}")
453                                            })
454                                            .child(
455                                                Button::new((root_id.clone(), "allow"))
456                                                    .primary()
457                                                    .small()
458                                                    .control_metrics(cx)
459                                                    .accessibility_id(format!("{id}-allow"))
460                                                    .text_label("Allow")
461                                                    .on_click(move |_: &ClickEvent, window, cx| {
462                                                        cues::emit(
463                                                            cx,
464                                                            Cue::Decided { approved: true },
465                                                        );
466                                                        handler(
467                                                            &ToolCallEvent::Approved {
468                                                                id: approve_id.clone(),
469                                                            },
470                                                            window,
471                                                            cx,
472                                                        )
473                                                    }),
474                                            ),
475                                    )
476                                })
477                                .when_some(reject, |this, handler| {
478                                    let deny_debug_id = reject_id.clone();
479                                    this.child(
480                                        div()
481                                            .debug_selector(move || {
482                                                format!("tool-call-deny-{deny_debug_id}")
483                                            })
484                                            .child(
485                                                Button::new((root_id.clone(), "deny"))
486                                                    .outline()
487                                                    .small()
488                                                    .control_metrics(cx)
489                                                    .accessibility_id(format!("{id}-deny"))
490                                                    .text_label("Deny")
491                                                    .on_click(move |_: &ClickEvent, window, cx| {
492                                                        cues::emit(
493                                                            cx,
494                                                            Cue::Decided { approved: false },
495                                                        );
496                                                        handler(
497                                                            &ToolCallEvent::Rejected {
498                                                                id: reject_id.clone(),
499                                                            },
500                                                            window,
501                                                            cx,
502                                                        )
503                                                    }),
504                                            ),
505                                    )
506                                }),
507                        ),
508                )
509            })
510            .when_some(input, |this, input| {
511                this.child(eyebrow("Input", cx)).child(
512                    CodeBlock::new((root_id.clone(), "input"), input).language(input_language),
513                )
514            })
515            .when_some(output, |this, output| {
516                this.child(eyebrow("Output", cx)).child(
517                    inset(cx)
518                        .text_token(tokens.typography.sm)
519                        .text_color(cx.theme().foreground)
520                        .child(
521                            TextView::markdown((root_id.clone(), "output"), output)
522                                .selectable(true),
523                        ),
524                )
525            })
526            .when_some(failure.clone(), |this, reason| {
527                // The glyph rides a first-line slot: the row stays
528                // items_start, so however far the reason wraps, the
529                // triangle holds centered on the first line.
530                this.child(
531                    h_flex()
532                        .items_start()
533                        .gap(tokens.spacing.sm)
534                        .text_token(tokens.typography.sm)
535                        .text_color(cx.theme().danger)
536                        .child(
537                            leading_glyph_slot(
538                                SizeTokens::read(cx).slot_md(),
539                                Icon::new(IconName::TriangleAlert).small(),
540                            )
541                            .debug_selector(|| "tool-call-failure-glyph".into()),
542                        )
543                        .child(
544                            div()
545                                .min_w_0()
546                                .debug_selector(|| "tool-call-failure-reason".into())
547                                .child(reason),
548                        ),
549                )
550            });
551
552        let card_debug_id = id.clone();
553        v_flex()
554            .id((root_id.clone(), "card"))
555            .debug_selector(move || format!("tool-call-card-{card_debug_id}"))
556            .role(Role::Group)
557            .aria_label(accessibility_label)
558            .when_some(failure, |this, reason| this.aria_description(reason))
559            .w_full()
560            .min_w_0()
561            .bg(tokens.colors.surface)
562            .border_1()
563            .border_color(match approval {
564                ToolApproval::Requested => cx.theme().warning,
565                _ => cx.theme().border,
566            })
567            .rounded(card_radius)
568            .overflow_hidden()
569            .child(header)
570            .when(showing, |this| {
571                // Opening content grows into its own height and fades in;
572                // closing removes descendants immediately so input and
573                // semantics match aria_expanded.
574                let clip_debug_id = id.clone();
575                this.child(
576                    crate::motion::disclosure_clip(
577                        ElementId::from((root_id.clone(), "disclosure-clip")),
578                        disclosure,
579                        div().opacity(disclosure_opacity).child(body),
580                        window,
581                        cx,
582                    )
583                    .debug_selector(move || format!("tool-call-disclosure-clip-{clip_debug_id}")),
584                )
585            })
586            .refine_style(&self.style)
587    }
588}
589
590/// An interaction emitted by [`ToolGroup`].
591#[derive(Debug, Clone, PartialEq, Eq)]
592pub enum ToolGroupEvent {
593    /// Requests a controlled expansion-state change.
594    Toggled {
595        /// Stable group identifier.
596        id: SharedString,
597        /// Proposed expansion state.
598        open: bool,
599    },
600}
601
602/// Folds consecutive tool calls behind one header.
603///
604/// The header shimmers while the group is active and reads "N tool calls"
605/// once it settles. Children are any elements — typically [`ToolCall`]s.
606///
607/// # Example
608///
609/// ```no_run
610/// # use gpui_ai::prelude::*;
611/// # use gpui::ParentElement;
612/// # fn example(calls: Vec<Progressive<ToolInvocation>>) {
613/// ToolGroup::new("burst-1")
614///     .count(3)
615///     .active(true)
616///     .children(calls.iter().map(ToolCall::new));
617/// # }
618/// ```
619#[derive(IntoElement)]
620pub struct ToolGroup {
621    id: SharedString,
622    style: StyleRefinement,
623    title: Option<SharedString>,
624    count: usize,
625    active: bool,
626    open: Option<bool>,
627    children: Vec<AnyElement>,
628    on_event: Option<Handler<ToolGroupEvent>>,
629}
630
631impl ToolGroup {
632    /// Creates a group with a stable identifier.
633    pub fn new(id: impl Into<SharedString>) -> Self {
634        Self {
635            id: id.into(),
636            style: StyleRefinement::default(),
637            title: None,
638            count: 0,
639            active: false,
640            open: None,
641            children: Vec::new(),
642            on_event: None,
643        }
644    }
645
646    /// Overrides the generated "N tool calls" title.
647    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
648        self.title = Some(title.into());
649        self
650    }
651
652    /// Sets the number of calls the group represents.
653    pub fn count(mut self, count: usize) -> Self {
654        self.count = count;
655        self
656    }
657
658    /// Marks the group as still running: the title shimmers and the group
659    /// opens unless told otherwise.
660    pub fn active(mut self, active: bool) -> Self {
661        self.active = active;
662        self
663    }
664
665    /// Sets the expansion state explicitly, replacing "open while active".
666    pub fn open(mut self, open: bool) -> Self {
667        self.open = Some(open);
668        self
669    }
670
671    /// Handles typed group interactions.
672    pub fn on_event(
673        mut self,
674        handler: impl Fn(&ToolGroupEvent, &mut Window, &mut App) + 'static,
675    ) -> Self {
676        self.on_event = Some(Box::new(handler));
677        self
678    }
679
680    /// Whether the calls are shown: the explicit value, or "open while active".
681    pub fn is_open(&self) -> bool {
682        self.open.unwrap_or(self.active)
683    }
684
685    fn resolved_title(&self) -> SharedString {
686        match &self.title {
687            Some(title) => title.clone(),
688            None if self.active => format!(
689                "Running {} tool{}…",
690                self.count,
691                if self.count == 1 { "" } else { "s" }
692            )
693            .into(),
694            None => format!(
695                "{} tool call{}",
696                self.count,
697                if self.count == 1 { "" } else { "s" }
698            )
699            .into(),
700        }
701    }
702}
703
704impl ParentElement for ToolGroup {
705    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
706        self.children.extend(elements);
707    }
708}
709
710impl Styled for ToolGroup {
711    fn style(&mut self) -> &mut StyleRefinement {
712        &mut self.style
713    }
714}
715
716impl RenderOnce for ToolGroup {
717    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
718        let tokens = cx.theme().semantic_tokens();
719        let open = self.is_open();
720        let title = self.resolved_title();
721        let root_id = ElementId::from(self.id.clone());
722        let disclosure = disclosure_progress((root_id.clone(), "disclosure"), open, window, cx);
723        let disclosure_opacity =
724            crate::motion::disclosure_fade((root_id.clone(), "disclosure"), open, window, cx);
725        let showing = open;
726
727        // ParentElement accepts opaque children without domain identity.
728        // Keep their content at rest rather than assigning mutable positions
729        // to arrival clocks; the group's disclosure owns its transition.
730        let call_count = self.children.len();
731        let mut calls = Vec::with_capacity(if showing { call_count } else { 0 });
732        if showing {
733            for child in self.children {
734                calls.push(div().w_full().min_w_0().child(child));
735            }
736        }
737        let interactive = self.on_event.is_some();
738        let header = h_flex()
739            .items_center()
740            .gap(tokens.spacing.sm)
741            .text_token(tokens.typography.sm)
742            .text_color(cx.theme().muted_foreground)
743            .when(interactive, |this| {
744                this.child(crate::surface::disclosure_chevron(disclosure))
745            })
746            .child(Icon::new(IconName::SquareTerminal).xsmall())
747            .child(
748                // The shimmer speaks for work the reader cannot see. Open,
749                // the calls report their own status, so the title rests —
750                // one dominant active-work signal, never two. The title's
751                // text keeps saying "Running…" either way: semantics are
752                // carried by words, not by the highlight.
753                Shimmer::new((root_id.clone(), "title"), title.clone())
754                    .active(self.active && !open)
755                    .text_token(tokens.typography.sm),
756            )
757            .when(self.count > 0, |this| {
758                this.child(
759                    div()
760                        .px(tokens.spacing.xs)
761                        .rounded(tokens.radius.full)
762                        .bg(cx.theme().muted)
763                        .text_token(tokens.typography.xs)
764                        .font_family(cx.theme().mono_font_family.clone())
765                        .text_color(cx.theme().muted_foreground)
766                        .child(self.count.to_string()),
767                )
768            });
769        let toggle = match self.on_event {
770            Some(handler) => {
771                let event = ToolGroupEvent::Toggled {
772                    id: self.id.clone(),
773                    open: !open,
774                };
775                let group_debug_id = self.id.clone();
776                composed_button((root_id.clone(), "toggle"), title.clone())
777                    .debug_selector(move || format!("tool-group-toggle-{group_debug_id}"))
778                    .aria_expanded(open)
779                    .px(tokens.spacing.xs)
780                    .py(tokens.spacing.xxs)
781                    .quiet_press_surface(
782                        ElementId::from((root_id.clone(), "toggle")),
783                        tokens.radius.md,
784                        window,
785                        cx,
786                    )
787                    .child(header)
788                    .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
789                    .into_any_element()
790            }
791            None => header.into_any_element(),
792        };
793
794        v_flex()
795            .id(root_id.clone())
796            .role(Role::Group)
797            .aria_label(title)
798            .w_full()
799            .min_w_0()
800            .gap(tokens.spacing.xs)
801            .child(toggle)
802            .when(showing, |this| {
803                // Only expanded groups expose their child controls.
804                this.child(
805                    v_flex()
806                        .id((root_id, "calls"))
807                        .debug_selector({
808                            let calls_debug_id = self.id.clone();
809                            move || format!("tool-group-calls-{calls_debug_id}")
810                        })
811                        .role(Role::List)
812                        .aria_label("Tool calls")
813                        .w_full()
814                        .min_w_0()
815                        .gap(tokens.spacing.xs)
816                        .pl(tokens.spacing.md)
817                        .ml(tokens.spacing.xs)
818                        .border_l_1()
819                        .border_color(cx.theme().border)
820                        .opacity(disclosure_opacity)
821                        .top(tokens.spacing.xxs * (1.0 - disclosure) * crate::motion::travel(cx))
822                        .children(calls),
823                )
824            })
825            .refine_style(&self.style)
826    }
827}
828
829fn format_elapsed(elapsed: Duration) -> String {
830    let secs = elapsed.as_secs_f64();
831    if secs < 1.0 {
832        format!("{:.0}ms", secs * 1000.0)
833    } else if secs < 10.0 {
834        format!("{secs:.1}s")
835    } else if secs < 60.0 {
836        format!("{secs:.0}s")
837    } else {
838        let minutes = (secs / 60.0).floor() as u64;
839        let rest = secs as u64 % 60;
840        format!("{minutes}m {rest:02}s")
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use gpui::{Context, Entity, Render, TestAppContext, VisualTestContext, px};
848
849    struct GroupProbe {
850        open: bool,
851        count: usize,
852    }
853
854    impl Render for GroupProbe {
855        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
856            div().w(px(360.)).h(px(480.)).child(
857                ToolGroup::new("burst")
858                    .count(self.count)
859                    .open(self.open)
860                    .children((0..self.count).map(|index| {
861                        div()
862                            .h(px(20.))
863                            .child(format!("call {index}"))
864                            .into_any_element()
865                    })),
866            )
867        }
868    }
869
870    fn open_group(cx: &mut TestAppContext) -> (Entity<GroupProbe>, &mut VisualTestContext) {
871        cx.update(crate::init);
872        let (probe, cx) = cx.add_window_view(|_, _| GroupProbe {
873            open: true,
874            count: 3,
875        });
876        cx.update(|window, cx| window.draw(cx).clear(cx));
877        (probe, cx)
878    }
879
880    fn draw(cx: &mut VisualTestContext) {
881        cx.update(|window, cx| window.draw(cx).clear(cx));
882    }
883
884    /// Advances past the disclosure fade and any arrival reveals.
885    fn settle(cx: &mut VisualTestContext) {
886        cx.executor().advance_clock(Duration::from_secs(2));
887        draw(cx);
888        draw(cx);
889    }
890
891    #[gpui::test]
892    fn a_groups_first_open_and_every_reopen_join_at_rest(cx: &mut TestAppContext) {
893        let (probe, cx) = open_group(cx);
894        settle(cx);
895        crate::motion::take_reveal_frame_requests();
896        draw(cx);
897        assert_eq!(
898            crate::motion::take_reveal_frame_requests(),
899            0,
900            "the opening fade owns the first reveal; calls join at rest"
901        );
902
903        probe.update(cx, |probe, cx| {
904            probe.open = false;
905            cx.notify();
906        });
907        settle(cx);
908        probe.update(cx, |probe, cx| {
909            probe.open = true;
910            cx.notify();
911        });
912        settle(cx);
913        crate::motion::take_reveal_frame_requests();
914        draw(cx);
915        assert_eq!(
916            crate::motion::take_reveal_frame_requests(),
917            0,
918            "an acknowledged call must never cascade again on reopen"
919        );
920    }
921
922    #[gpui::test]
923    fn unkeyed_group_children_do_not_acquire_positional_arrival_motion(cx: &mut TestAppContext) {
924        let (probe, cx) = open_group(cx);
925        settle(cx);
926        crate::motion::take_reveal_frame_requests();
927
928        probe.update(cx, |probe, cx| {
929            probe.count = 4;
930            cx.notify();
931        });
932        draw(cx);
933        assert_eq!(
934            crate::motion::take_reveal_frame_requests(),
935            0,
936            "opaque children cannot acquire arrival state by mutable position"
937        );
938
939        settle(cx);
940        crate::motion::take_reveal_frame_requests();
941        probe.update(cx, |_, cx| cx.notify());
942        draw(cx);
943        assert_eq!(
944            crate::motion::take_reveal_frame_requests(),
945            0,
946            "a re-render must not replay an acknowledged arrival"
947        );
948    }
949
950    #[gpui::test]
951    fn reduced_motion_appends_at_rest(cx: &mut TestAppContext) {
952        cx.update(|cx| cx.set_reduce_motion(true));
953        let (probe, cx) = open_group(cx);
954        settle(cx);
955        crate::motion::take_reveal_frame_requests();
956
957        probe.update(cx, |probe, cx| {
958            probe.count = 4;
959            cx.notify();
960        });
961        draw(cx);
962        assert_eq!(
963            crate::motion::take_reveal_frame_requests(),
964            0,
965            "reduced motion appends without frames"
966        );
967    }
968
969    fn invocation() -> ToolInvocation {
970        ToolInvocation::new("read-1", "read_file").summary("pricing.md")
971    }
972
973    #[test]
974    fn automatic_policy_opens_only_when_attention_is_needed() {
975        assert!(!ToolCall::new(&Progressive::pending(invocation())).is_open());
976        assert!(!ToolCall::new(&Progressive::running(invocation())).is_open());
977        assert!(!ToolCall::new(&Progressive::complete(invocation())).is_open());
978        assert!(ToolCall::new(&Progressive::failed(invocation(), "timeout")).is_open());
979        assert!(
980            ToolCall::new(&Progressive::pending(
981                invocation().approval(ToolApproval::Requested)
982            ))
983            .is_open()
984        );
985        assert!(
986            !ToolCall::new(&Progressive::failed(invocation(), "timeout"))
987                .open(false)
988                .is_open()
989        );
990    }
991
992    #[test]
993    fn accessibility_label_carries_name_summary_and_decision_state() {
994        let running = ToolCall::new(&Progressive::running(invocation()));
995        assert_eq!(
996            running.accessibility_label(),
997            "read_file pricing.md, Running"
998        );
999        let awaiting = ToolCall::new(&Progressive::pending(
1000            ToolInvocation::new("send", "send_email").approval(ToolApproval::Requested),
1001        ));
1002        assert_eq!(
1003            awaiting.accessibility_label(),
1004            "send_email, awaiting approval"
1005        );
1006        let denied = ToolCall::new(&Progressive::complete(
1007            ToolInvocation::new("send", "send_email").approval(ToolApproval::Rejected),
1008        ));
1009        assert_eq!(denied.accessibility_label(), "send_email, denied");
1010    }
1011
1012    #[test]
1013    fn group_title_reflects_activity_and_count() {
1014        assert_eq!(
1015            ToolGroup::new("g").count(3).active(true).resolved_title(),
1016            "Running 3 tools…"
1017        );
1018        assert_eq!(
1019            ToolGroup::new("g").count(1).active(true).resolved_title(),
1020            "Running 1 tool…"
1021        );
1022        assert_eq!(
1023            ToolGroup::new("g").count(2).resolved_title(),
1024            "2 tool calls"
1025        );
1026        assert_eq!(
1027            ToolGroup::new("g")
1028                .count(2)
1029                .title("Research")
1030                .resolved_title(),
1031            "Research"
1032        );
1033        assert!(ToolGroup::new("g").active(true).is_open());
1034        assert!(!ToolGroup::new("g").is_open());
1035        assert!(ToolGroup::new("g").open(true).is_open());
1036    }
1037
1038    #[test]
1039    fn elapsed_formatting_covers_sub_second_to_minutes() {
1040        assert_eq!(format_elapsed(Duration::from_millis(340)), "340ms");
1041        assert_eq!(format_elapsed(Duration::from_millis(1400)), "1.4s");
1042        assert_eq!(format_elapsed(Duration::from_secs(12)), "12s");
1043        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
1044    }
1045}