Skip to main content

gpui_ai/
chip.rs

1//! Compact chips representing tool calls and code edits.
2
3use crate::control::{PressReleaseExt as _, composed_button};
4use crate::handlers::SharedHandler;
5use crate::theme::SemanticStyledExt as _;
6use gpui::{
7    App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
8    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
9    Window, div, prelude::FluentBuilder as _,
10};
11use gpui_component::{ActiveTheme as _, Sizable as _, StyledExt as _, h_flex, spinner::Spinner};
12use std::rc::Rc;
13
14/// The lifecycle status of the tool call a [`ToolChip`] represents.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum ToolStatus {
17    /// Queued but not started.
18    #[default]
19    Pending,
20    /// Currently executing; the chip shows a spinner.
21    Running,
22    /// Completed successfully.
23    Success,
24    /// Ended with an error.
25    Failed,
26}
27
28/// A compact, pill-shaped representation of a tool call or code edit.
29///
30/// # Example
31///
32/// ```
33/// # use gpui_ai::prelude::*;
34/// ToolChip::new("edit-1", "edit main.rs")
35///     .status(ToolStatus::Running)
36///     .detail("+12 −3");
37/// ```
38#[derive(IntoElement)]
39pub struct ToolChip {
40    id: SharedString,
41    style: StyleRefinement,
42    label: SharedString,
43    detail: Option<SharedString>,
44    status: ToolStatus,
45    on_event: Option<SharedHandler<ToolChipEvent>>,
46}
47
48impl ToolChip {
49    /// Creates a chip with a unique id and a short label (for example the
50    /// tool name or the file being edited).
51    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
52        Self {
53            id: id.into(),
54            style: StyleRefinement::default(),
55            label: label.into(),
56            detail: None,
57            status: ToolStatus::default(),
58            on_event: None,
59        }
60    }
61
62    /// Sets the lifecycle status. Default is [`ToolStatus::Pending`].
63    pub fn status(mut self, status: ToolStatus) -> Self {
64        self.status = status;
65        self
66    }
67
68    /// Adds a muted trailing detail, such as a diff stat (`+12 −3`).
69    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
70        self.detail = Some(detail.into());
71        self
72    }
73
74    /// Makes the chip clickable (for example to open the tool call).
75    pub fn on_event(
76        mut self,
77        handler: impl Fn(&ToolChipEvent, &mut Window, &mut App) + 'static,
78    ) -> Self {
79        self.on_event = Some(Rc::new(handler));
80        self
81    }
82
83    fn accessibility_label(&self) -> SharedString {
84        match &self.detail {
85            Some(detail) => format!(
86                "{}, {}, {}",
87                self.label,
88                self.status.accessibility_label(),
89                detail
90            )
91            .into(),
92            None => format!("{}, {}", self.label, self.status.accessibility_label()).into(),
93        }
94    }
95}
96
97impl ToolStatus {
98    fn accessibility_label(self) -> &'static str {
99        match self {
100            Self::Pending => "pending",
101            Self::Running => "running",
102            Self::Success => "completed",
103            Self::Failed => "failed",
104        }
105    }
106}
107
108impl Styled for ToolChip {
109    fn style(&mut self) -> &mut StyleRefinement {
110        &mut self.style
111    }
112}
113
114/// The bordered pill a tool chip wears, whichever branch renders it.
115///
116/// Deliberately not [`crate::status::chip_frame`]: that vocabulary is
117/// borderless and fully round, and this one is bordered at the medium
118/// radius. Whether the two should become one is a visual decision, not a
119/// refactor — until it is made, this at least stops the pill being stated
120/// twice inside one function, where the two copies are what drift.
121fn tool_chip_frame<E: Styled>(element: E, cx: &App) -> E {
122    let tokens = cx.theme().semantic_tokens();
123    element
124        .px(tokens.spacing.sm)
125        .py(tokens.spacing.xxs)
126        .bg(cx.theme().secondary)
127        .border_1()
128        .border_color(cx.theme().border)
129        .rounded(tokens.radius.md)
130}
131
132impl RenderOnce for ToolChip {
133    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
134        let tokens = cx.theme().semantic_tokens();
135        let status_color = match self.status {
136            ToolStatus::Pending => cx.theme().muted_foreground,
137            ToolStatus::Running => cx.theme().info,
138            ToolStatus::Success => cx.theme().success,
139            ToolStatus::Failed => cx.theme().danger,
140        };
141
142        // A status change settles its indicator in rather than popping it:
143        // the acknowledgment is keyed by the status, so it plays once per
144        // transition and never on a re-render, and the status the chip
145        // mounts with is exempt — a first render is not a transition. The
146        // chip's label never changes with status, so the indicator is the
147        // whole swap.
148        let status_progress = crate::motion::acknowledged_state(
149            ElementId::from((ElementId::from(self.id.clone()), "chip-status")),
150            self.status as u64,
151            window,
152            cx,
153        );
154        let acknowledged = if self.status == ToolStatus::Running {
155            // A running chip's signal is its spinner; fading the spinner in
156            // would stack two motions on one slot.
157            1.0
158        } else {
159            status_progress
160        };
161
162        let event = ToolChipEvent::Activated {
163            id: self.id.clone(),
164        };
165        let accessibility_label = self.accessibility_label();
166        let content = h_flex()
167            .items_center()
168            .gap(tokens.spacing.xs)
169            .text_token(tokens.typography.xs)
170            .font_family(cx.theme().mono_font_family.clone())
171            .text_color(cx.theme().foreground)
172            .child(
173                // A fixed square slot: the spinner and the dot centre in the
174                // same box, so running↔settled never nudges the label.
175                div()
176                    .flex_none()
177                    .size(crate::sizing::slot_xs(cx))
178                    .flex()
179                    .items_center()
180                    .justify_center()
181                    .child(match self.status {
182                        ToolStatus::Running => Spinner::new()
183                            .xsmall()
184                            .color(status_color)
185                            .into_any_element(),
186                        _ => div()
187                            .size_1p5()
188                            .rounded(tokens.radius.full)
189                            .bg(status_color)
190                            .opacity(acknowledged)
191                            .into_any_element(),
192                    }),
193            )
194            .child(self.label)
195            .when_some(self.detail, |this, detail| {
196                this.child(div().text_color(cx.theme().muted_foreground).child(detail))
197            })
198            .refine_style(&self.style);
199
200        if let Some(handler) = self.on_event {
201            tool_chip_frame(composed_button(self.id.clone(), accessibility_label), cx)
202                .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
203                .active(|style| style.bg(cx.theme().accent))
204                .focus_visible(|style| style.border_color(cx.theme().ring))
205                .press_release(
206                    ElementId::from((ElementId::from(self.id.clone()), "press")),
207                    tokens.radius.md,
208                    window,
209                    cx,
210                )
211                .child(content)
212                .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
213                .into_any_element()
214        } else {
215            tool_chip_frame(content, cx)
216                .id(self.id)
217                .role(Role::Status)
218                .aria_label(accessibility_label)
219                .into_any_element()
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
228    use gpui_component::h_flex;
229
230    struct ChipProbe {
231        status: ToolStatus,
232    }
233
234    impl Render for ChipProbe {
235        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
236            div().w(px(320.)).h(px(80.)).child(
237                h_flex()
238                    .debug_selector(|| "chip-hug".into())
239                    .flex_none()
240                    .child(ToolChip::new("probe-chip", "read_file").status(self.status)),
241            )
242        }
243    }
244
245    #[gpui::test]
246    fn chip_width_holds_across_every_status(cx: &mut TestAppContext) {
247        cx.update(crate::init);
248        let (probe, cx) = cx.add_window_view(|_, _| ChipProbe {
249            status: ToolStatus::Pending,
250        });
251        let cx: &mut VisualTestContext = cx;
252        cx.update(|window, cx| window.draw(cx).clear(cx));
253        let width_at = |cx: &mut VisualTestContext| {
254            cx.debug_bounds("chip-hug")
255                .expect("the chip should render")
256                .size
257                .width
258        };
259        let pending = width_at(cx);
260
261        for status in [ToolStatus::Running, ToolStatus::Success, ToolStatus::Failed] {
262            probe.update(cx, |probe, cx| {
263                probe.status = status;
264                cx.notify();
265            });
266            cx.executor()
267                .advance_clock(crate::motion::MotionTokens::DEFAULT.quick() * 2);
268            cx.update(|window, cx| window.draw(cx).clear(cx));
269            assert_eq!(
270                width_at(cx),
271                pending,
272                "the indicator slot must hold one width; {status:?} moved it"
273            );
274        }
275    }
276
277    #[test]
278    fn accessibility_name_carries_status_and_detail() {
279        assert_eq!(
280            ToolChip::new("read", "read pricing.md")
281                .status(ToolStatus::Running)
282                .detail("12 files")
283                .accessibility_label(),
284            "read pricing.md, running, 12 files"
285        );
286        assert_eq!(
287            ToolChip::new("save", "save changes")
288                .status(ToolStatus::Success)
289                .accessibility_label(),
290            "save changes, completed"
291        );
292    }
293}
294/// An interaction emitted by [`ToolChip`].
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum ToolChipEvent {
297    /// The represented tool call was selected.
298    Activated {
299        /// Stable tool-call identifier.
300        id: SharedString,
301    },
302}