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
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}