Skip to main content

guise/ai/
toolcall.rs

1//! `AIToolCall` — what the model did, and whether it worked.
2//!
3//! A tool call is the part of a reply the user did not ask to read but needs
4//! to be able to check: which tool, with what arguments, and what came back.
5//! So the card shows the name and status always, and folds the arguments and
6//! the result away until asked.
7//!
8//! Status is the load-bearing part. A call that is running, one that returned,
9//! and one that failed have to be distinguishable at a glance, because a
10//! stalled tool is the most common way an assistant appears broken.
11//!
12//! ```ignore
13//! AIToolCall::new(("tool", i), "read_file")
14//!     .status(AIToolStatus::Ok)
15//!     .arguments(r#"{"path": "src/main.rs"}"#)
16//!     .result(preview)
17//!     .open(expanded)
18//!     .on_toggle(cx.listener(|this, _, _, cx| this.toggle(i, cx)))
19//! ```
20
21use 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/// Where a tool call has got to.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum AIToolStatus {
34    /// Requested by the model, not started.
35    #[default]
36    Pending,
37    /// In flight.
38    Running,
39    /// Returned successfully.
40    Ok,
41    /// Returned an error, or never returned.
42    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/// A collapsible record of one tool invocation.
57#[derive(IntoElement)]
58pub struct AIToolCall {
59    id: ElementId,
60    name: SharedString,
61    status: AIToolStatus,
62    arguments: Option<SharedString>,
63    result: Option<SharedString>,
64    /// Trailing detail on the header: a duration, a byte count.
65    meta: Option<SharedString>,
66    open: bool,
67    /// Whether the card has foldable content, stated rather than inferred from
68    /// `arguments`/`result` being present — a host that only supplies those
69    /// while the card is open still needs the chevron to open it with.
70    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    /// The call's input, shown verbatim. Pretty-print it before passing it in
97    /// if it's JSON — this draws what it's given.
98    pub fn arguments(mut self, arguments: impl Into<SharedString>) -> Self {
99        self.arguments = Some(arguments.into());
100        self
101    }
102
103    /// What the tool returned, or the error it raised.
104    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    /// Offer the fold affordance even with no content attached yet. Defaults to
120    /// whether `arguments` or `result` was given.
121    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        // The status glyph carries the state; the word next to it is for
160        // anyone who can't tell the colors apart.
161        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.when_some(self.arguments, |card, arguments| {
275                    card.child(block("Arguments", arguments, text_color))
276                })
277                .when_some(self.result, |card, result| {
278                    card.child(block(
279                        if self.status == AIToolStatus::Error {
280                            "Error"
281                        } else {
282                            "Result"
283                        },
284                        result,
285                        if self.status == AIToolStatus::Error {
286                            accent_hsla
287                        } else {
288                            text_color
289                        },
290                    ))
291                })
292            })
293            .probe("AIToolCall")
294    }
295}