1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ToolApproval {
45 #[default]
47 NotRequired,
48 Requested,
50 Approved,
52 Rejected,
54}
55
56#[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 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 pub fn summary(mut self, summary: impl Into<SharedString>) -> Self {
93 self.summary = Some(summary.into());
94 self
95 }
96
97 pub fn input(mut self, input: impl Into<SharedString>) -> Self {
99 self.input = Some(input.into());
100 self
101 }
102
103 pub fn input_language(mut self, language: impl Into<SharedString>) -> Self {
105 self.input_language = language.into();
106 self
107 }
108
109 pub fn output(mut self, output: impl Into<SharedString>) -> Self {
111 self.output = Some(output.into());
112 self
113 }
114
115 pub fn elapsed(mut self, elapsed: Duration) -> Self {
117 self.elapsed = Some(elapsed);
118 self
119 }
120
121 pub fn approval(mut self, approval: ToolApproval) -> Self {
123 self.approval = approval;
124 self
125 }
126
127 pub fn icon(mut self, icon: impl IconNamed) -> Self {
129 self.icon = Some(icon.path());
130 self
131 }
132
133 pub fn id(&self) -> &SharedString {
135 &self.id
136 }
137
138 pub fn name(&self) -> &SharedString {
140 &self.name
141 }
142
143 pub fn summary_text(&self) -> Option<&SharedString> {
145 self.summary.as_ref()
146 }
147
148 pub fn approval_state(&self) -> ToolApproval {
150 self.approval
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum ToolCallEvent {
157 Toggled {
159 id: SharedString,
161 open: bool,
163 },
164 Approved {
166 id: SharedString,
168 },
169 Rejected {
171 id: SharedString,
173 },
174}
175
176#[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 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 pub fn open(mut self, open: bool) -> Self {
220 self.open = Some(open);
221 self
222 }
223
224 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 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 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 let showing = open;
287 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 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 .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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
592pub enum ToolGroupEvent {
593 Toggled {
595 id: SharedString,
597 open: bool,
599 },
600}
601
602#[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 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 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
648 self.title = Some(title.into());
649 self
650 }
651
652 pub fn count(mut self, count: usize) -> Self {
654 self.count = count;
655 self
656 }
657
658 pub fn active(mut self, active: bool) -> Self {
661 self.active = active;
662 self
663 }
664
665 pub fn open(mut self, open: bool) -> Self {
667 self.open = Some(open);
668 self
669 }
670
671 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 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 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 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 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 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}