guise-ui 1.5.2

A component library for gpui, Zed's GPU-accelerated UI framework: a themed palette, sizing tokens, 130+ composable components, a reactive state layer, and an in-app Safari-style inspector.
Documentation
//! `AIToolCall` — what the model did, and whether it worked.
//!
//! A tool call is the part of a reply the user did not ask to read but needs
//! to be able to check: which tool, with what arguments, and what came back.
//! So the card shows the name and status always, and folds the arguments and
//! the result away until asked.
//!
//! Status is the load-bearing part. A call that is running, one that returned,
//! and one that failed have to be distinguishable at a glance, because a
//! stalled tool is the most common way an assistant appears broken.
//!
//! ```ignore
//! AIToolCall::new(("tool", i), "read_file")
//!     .status(AIToolStatus::Ok)
//!     .arguments(r#"{"path": "src/main.rs"}"#)
//!     .result(preview)
//!     .open(expanded)
//!     .on_toggle(cx.listener(|this, _, _, cx| this.toggle(i, cx)))
//! ```

use gpui::prelude::*;
use gpui::{div, px, App, ClickEvent, ElementId, IntoElement, SharedString, Window};

use crate::devtools::Probed;
use crate::feedback::{Loader, LoaderVariant};
use crate::icon::{Icon, IconName};
use crate::input::ClickHandler;
use crate::style::MONO_FAMILY;
use crate::theme::{theme, Color, ColorName, Size};

/// Where a tool call has got to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AIToolStatus {
  /// Requested by the model, not started.
  #[default]
  Pending,
  /// In flight.
  Running,
  /// Returned successfully.
  Ok,
  /// Returned an error, or never returned.
  Error,
}

impl AIToolStatus {
  fn label(self) -> &'static str {
    match self {
      AIToolStatus::Pending => "queued",
      AIToolStatus::Running => "running",
      AIToolStatus::Ok => "done",
      AIToolStatus::Error => "failed",
    }
  }
}

/// A collapsible record of one tool invocation.
#[derive(IntoElement)]
pub struct AIToolCall {
  id: ElementId,
  name: SharedString,
  status: AIToolStatus,
  arguments: Option<SharedString>,
  result: Option<SharedString>,
  /// Trailing detail on the header: a duration, a byte count.
  meta: Option<SharedString>,
  open: bool,
  /// Whether the card has foldable content, stated rather than inferred from
  /// `arguments`/`result` being present — a host that only supplies those
  /// while the card is open still needs the chevron to open it with.
  expandable: Option<bool>,
  size: Size,
  on_toggle: Option<ClickHandler>,
}

impl AIToolCall {
  pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
    AIToolCall {
      id: id.into(),
      name: name.into(),
      status: AIToolStatus::default(),
      arguments: None,
      result: None,
      meta: None,
      open: false,
      expandable: None,
      size: Size::Sm,
      on_toggle: None,
    }
  }

  pub fn status(mut self, status: AIToolStatus) -> Self {
    self.status = status;
    self
  }

  /// The call's input, shown verbatim. Pretty-print it before passing it in
  /// if it's JSON — this draws what it's given.
  pub fn arguments(mut self, arguments: impl Into<SharedString>) -> Self {
    self.arguments = Some(arguments.into());
    self
  }

  /// What the tool returned, or the error it raised.
  pub fn result(mut self, result: impl Into<SharedString>) -> Self {
    self.result = Some(result.into());
    self
  }

  pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
    self.meta = Some(meta.into());
    self
  }

  pub fn open(mut self, open: bool) -> Self {
    self.open = open;
    self
  }

  /// Offer the fold affordance even with no content attached yet. Defaults to
  /// whether `arguments` or `result` was given.
  pub fn expandable(mut self, expandable: bool) -> Self {
    self.expandable = Some(expandable);
    self
  }

  pub fn size(mut self, size: Size) -> Self {
    self.size = size;
    self
  }

  pub fn on_toggle(
    mut self,
    handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
  ) -> Self {
    self.on_toggle = Some(Box::new(handler));
    self
  }
}

impl RenderOnce for AIToolCall {
  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
    let t = theme(cx);
    let dimmed = t.dimmed().hsla();
    let text_color = t.text().hsla();
    let border = t.border().hsla();
    let surface = t.surface_hover().alpha(0.6);
    let radius = t.radius(Size::Sm);
    let font_xs = t.font_size(Size::Xs);
    let mono_font = t.font_size(self.size) * 0.92;

    let accent: Color = match self.status {
      AIToolStatus::Pending => t.dimmed(),
      AIToolStatus::Running => t.info(),
      AIToolStatus::Ok => t.success(),
      AIToolStatus::Error => t.danger(),
    };
    let accent_hsla = accent.hsla();

    // The status glyph carries the state; the word next to it is for
    // anyone who can't tell the colors apart.
    let badge = match self.status {
      AIToolStatus::Running => Loader::new()
        .variant(LoaderVariant::Dots)
        .size(Size::Xs)
        .color(ColorName::Blue)
        .into_any_element(),
      AIToolStatus::Ok => Icon::new(IconName::Check)
        .size(Size::Xs)
        .color(ColorName::Green)
        .into_any_element(),
      AIToolStatus::Error => Icon::new(IconName::CircleX)
        .size(Size::Xs)
        .color(ColorName::Red)
        .into_any_element(),
      AIToolStatus::Pending => Icon::new(IconName::Clock)
        .size(Size::Xs)
        .color(ColorName::Gray)
        .into_any_element(),
    };

    let expandable = self
      .expandable
      .unwrap_or(self.arguments.is_some() || self.result.is_some());
    let has_toggle = self.on_toggle.is_some() && expandable;

    let header = div()
      .id(self.id)
      .flex()
      .items_center()
      .gap(px(8.0))
      .w_full()
      .text_size(px(font_xs))
      .when(has_toggle, |header| header.cursor_pointer())
      .child(badge)
      .child(
        div()
          .flex()
          .items_center()
          .gap(px(6.0))
          .flex_1()
          .min_w(px(0.0))
          .text_color(text_color)
          .child(
            Icon::new(IconName::Wrench)
              .size(Size::Xs)
              .color(ColorName::Gray),
          )
          .child(self.name.clone()),
      )
      .when_some(self.meta.clone(), |header, meta| {
        header.child(div().text_color(dimmed).child(meta))
      })
      .child(
        div()
          .text_color(accent_hsla)
          .child(SharedString::new_static(self.status.label())),
      )
      .when(expandable, |header| {
        header.child(
          Icon::new(if self.open {
            IconName::ChevronDown
          } else {
            IconName::ChevronRight
          })
          .size(Size::Xs)
          .color(ColorName::Gray),
        )
      })
      .when_some(self.on_toggle, |header, handler| {
        header.on_click(move |event, window, cx| handler(event, window, cx))
      });

    let block = |title: &'static str, body: SharedString, color| {
      div()
        .flex()
        .flex_col()
        .gap(px(2.0))
        .child(
          div()
            .text_size(px(font_xs))
            .text_color(dimmed)
            .child(SharedString::new_static(title)),
        )
        .child(
          div()
            .w_full()
            .px(px(8.0))
            .py(px(6.0))
            .rounded(px(radius))
            .bg(surface)
            .font_family(MONO_FAMILY)
            .text_size(px(mono_font))
            .text_color(color)
            .child(body),
        )
    };

    div()
      .flex()
      .flex_col()
      .gap(px(6.0))
      .w_full()
      .px(px(10.0))
      .py(px(8.0))
      .rounded(px(radius))
      .border_1()
      .border_color(if self.status == AIToolStatus::Error {
        accent_hsla
      } else {
        border
      })
      .child(header)
      .when(self.open, |card| {
        card
          .when_some(self.arguments, |card, arguments| {
            card.child(block("Arguments", arguments, text_color))
          })
          .when_some(self.result, |card, result| {
            card.child(block(
              if self.status == AIToolStatus::Error {
                "Error"
              } else {
                "Result"
              },
              result,
              if self.status == AIToolStatus::Error {
                accent_hsla
              } else {
                text_color
              },
            ))
          })
      })
      .probe("AIToolCall")
  }
}