1use gpui::prelude::*;
22use gpui::{div, px, App, ClickEvent, ElementId, IntoElement, SharedString, Window};
23
24use crate::devtools::Probed;
25use crate::feedback::{Loader, LoaderVariant};
26use crate::icon::{Icon, IconName};
27use crate::input::ClickHandler;
28use crate::style::MONO_FAMILY;
29use crate::theme::{theme, Color, ColorName, Size};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum AIToolStatus {
34 #[default]
36 Pending,
37 Running,
39 Ok,
41 Error,
43}
44
45impl AIToolStatus {
46 fn label(self) -> &'static str {
47 match self {
48 AIToolStatus::Pending => "queued",
49 AIToolStatus::Running => "running",
50 AIToolStatus::Ok => "done",
51 AIToolStatus::Error => "failed",
52 }
53 }
54}
55
56#[derive(IntoElement)]
58pub struct AIToolCall {
59 id: ElementId,
60 name: SharedString,
61 status: AIToolStatus,
62 arguments: Option<SharedString>,
63 result: Option<SharedString>,
64 meta: Option<SharedString>,
66 open: bool,
67 expandable: Option<bool>,
71 size: Size,
72 on_toggle: Option<ClickHandler>,
73}
74
75impl AIToolCall {
76 pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
77 AIToolCall {
78 id: id.into(),
79 name: name.into(),
80 status: AIToolStatus::default(),
81 arguments: None,
82 result: None,
83 meta: None,
84 open: false,
85 expandable: None,
86 size: Size::Sm,
87 on_toggle: None,
88 }
89 }
90
91 pub fn status(mut self, status: AIToolStatus) -> Self {
92 self.status = status;
93 self
94 }
95
96 pub fn arguments(mut self, arguments: impl Into<SharedString>) -> Self {
99 self.arguments = Some(arguments.into());
100 self
101 }
102
103 pub fn result(mut self, result: impl Into<SharedString>) -> Self {
105 self.result = Some(result.into());
106 self
107 }
108
109 pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
110 self.meta = Some(meta.into());
111 self
112 }
113
114 pub fn open(mut self, open: bool) -> Self {
115 self.open = open;
116 self
117 }
118
119 pub fn expandable(mut self, expandable: bool) -> Self {
122 self.expandable = Some(expandable);
123 self
124 }
125
126 pub fn size(mut self, size: Size) -> Self {
127 self.size = size;
128 self
129 }
130
131 pub fn on_toggle(
132 mut self,
133 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
134 ) -> Self {
135 self.on_toggle = Some(Box::new(handler));
136 self
137 }
138}
139
140impl RenderOnce for AIToolCall {
141 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
142 let t = theme(cx);
143 let dimmed = t.dimmed().hsla();
144 let text_color = t.text().hsla();
145 let border = t.border().hsla();
146 let surface = t.surface_hover().alpha(0.6);
147 let radius = t.radius(Size::Sm);
148 let font_xs = t.font_size(Size::Xs);
149 let mono_font = t.font_size(self.size) * 0.92;
150
151 let accent: Color = match self.status {
152 AIToolStatus::Pending => t.dimmed(),
153 AIToolStatus::Running => t.info(),
154 AIToolStatus::Ok => t.success(),
155 AIToolStatus::Error => t.danger(),
156 };
157 let accent_hsla = accent.hsla();
158
159 let badge = match self.status {
162 AIToolStatus::Running => Loader::new()
163 .variant(LoaderVariant::Dots)
164 .size(Size::Xs)
165 .color(ColorName::Blue)
166 .into_any_element(),
167 AIToolStatus::Ok => Icon::new(IconName::Check)
168 .size(Size::Xs)
169 .color(ColorName::Green)
170 .into_any_element(),
171 AIToolStatus::Error => Icon::new(IconName::CircleX)
172 .size(Size::Xs)
173 .color(ColorName::Red)
174 .into_any_element(),
175 AIToolStatus::Pending => Icon::new(IconName::Clock)
176 .size(Size::Xs)
177 .color(ColorName::Gray)
178 .into_any_element(),
179 };
180
181 let expandable = self
182 .expandable
183 .unwrap_or(self.arguments.is_some() || self.result.is_some());
184 let has_toggle = self.on_toggle.is_some() && expandable;
185
186 let header = div()
187 .id(self.id)
188 .flex()
189 .items_center()
190 .gap(px(8.0))
191 .w_full()
192 .text_size(px(font_xs))
193 .when(has_toggle, |header| header.cursor_pointer())
194 .child(badge)
195 .child(
196 div()
197 .flex()
198 .items_center()
199 .gap(px(6.0))
200 .flex_1()
201 .min_w(px(0.0))
202 .text_color(text_color)
203 .child(
204 Icon::new(IconName::Wrench)
205 .size(Size::Xs)
206 .color(ColorName::Gray),
207 )
208 .child(self.name.clone()),
209 )
210 .when_some(self.meta.clone(), |header, meta| {
211 header.child(div().text_color(dimmed).child(meta))
212 })
213 .child(
214 div()
215 .text_color(accent_hsla)
216 .child(SharedString::new_static(self.status.label())),
217 )
218 .when(expandable, |header| {
219 header.child(
220 Icon::new(if self.open {
221 IconName::ChevronDown
222 } else {
223 IconName::ChevronRight
224 })
225 .size(Size::Xs)
226 .color(ColorName::Gray),
227 )
228 })
229 .when_some(self.on_toggle, |header, handler| {
230 header.on_click(move |event, window, cx| handler(event, window, cx))
231 });
232
233 let block = |title: &'static str, body: SharedString, color| {
234 div()
235 .flex()
236 .flex_col()
237 .gap(px(2.0))
238 .child(
239 div()
240 .text_size(px(font_xs))
241 .text_color(dimmed)
242 .child(SharedString::new_static(title)),
243 )
244 .child(
245 div()
246 .w_full()
247 .px(px(8.0))
248 .py(px(6.0))
249 .rounded(px(radius))
250 .bg(surface)
251 .font_family(MONO_FAMILY)
252 .text_size(px(mono_font))
253 .text_color(color)
254 .child(body),
255 )
256 };
257
258 div()
259 .flex()
260 .flex_col()
261 .gap(px(6.0))
262 .w_full()
263 .px(px(10.0))
264 .py(px(8.0))
265 .rounded(px(radius))
266 .border_1()
267 .border_color(if self.status == AIToolStatus::Error {
268 accent_hsla
269 } else {
270 border
271 })
272 .child(header)
273 .when(self.open, |card| {
274 card
275 .when_some(self.arguments, |card, arguments| {
276 card.child(block("Arguments", arguments, text_color))
277 })
278 .when_some(self.result, |card, result| {
279 card.child(block(
280 if self.status == AIToolStatus::Error {
281 "Error"
282 } else {
283 "Result"
284 },
285 result,
286 if self.status == AIToolStatus::Error {
287 accent_hsla
288 } else {
289 text_color
290 },
291 ))
292 })
293 })
294 .probe("AIToolCall")
295 }
296}