1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum ToolStatus {
17 #[default]
19 Pending,
20 Running,
22 Success,
24 Failed,
26}
27
28#[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 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 pub fn status(mut self, status: ToolStatus) -> Self {
64 self.status = status;
65 self
66 }
67
68 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
70 self.detail = Some(detail.into());
71 self
72 }
73
74 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
114fn 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum ToolChipEvent {
297 Activated {
299 id: SharedString,
301 },
302}