guise-ui 1.5.3

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
//! `AIMessage` — one turn in a conversation.
//!
//! A message is a role, a markdown body, and whatever the model attached to
//! it: reasoning, tool calls, sources, an error. Rather than model all of that
//! as fields, the body is what this draws and everything else arrives as
//! children — so a host that has some other thing to show under a reply
//! (a diff, an image, a rating widget) just adds it.
//!
//! The shape follows what people expect from a chat: the user's turn is a
//! contained bubble, the assistant's runs full width, and system and tool
//! turns are quiet marginalia.
//!
//! ```ignore
//! AIMessage::new(AIRole::Assistant, reply)
//!     .streaming(true)
//!     .child(AIToolCall::new("search").status(AIToolStatus::Running))
//! ```

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

use super::AIStreamingText;
use crate::devtools::Probed;
use crate::icon::{Icon, IconName};
use crate::markdown::Markdown;
use crate::theme::{theme, ColorName, Size};

/// Who produced a turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AIRole {
  /// The person typing.
  #[default]
  User,
  /// The model.
  Assistant,
  /// Instructions the conversation runs under.
  System,
  /// The result handed back from a tool.
  Tool,
}

impl AIRole {
  /// The name shown above the turn.
  pub fn label(self) -> &'static str {
    match self {
      AIRole::User => "You",
      AIRole::Assistant => "Assistant",
      AIRole::System => "System",
      AIRole::Tool => "Tool",
    }
  }

  /// The glyph in the turn's gutter.
  pub fn icon(self) -> IconName {
    match self {
      AIRole::User => IconName::User,
      AIRole::Assistant => IconName::Sparkles,
      AIRole::System => IconName::Info,
      AIRole::Tool => IconName::Wrench,
    }
  }
}

/// One turn, rendered.
#[derive(IntoElement)]
pub struct AIMessage {
  role: AIRole,
  body: SharedString,
  /// Draw the body with a trailing caret, for a reply still arriving.
  streaming: bool,
  /// Shown instead of the body when the turn failed.
  error: Option<SharedString>,
  name: Option<SharedString>,
  meta: Option<SharedString>,
  avatar: bool,
  size: Size,
  /// Attachments — tool cards, sources, actions — drawn under the body.
  children: Vec<AnyElement>,
}

impl AIMessage {
  pub fn new(role: AIRole, body: impl Into<SharedString>) -> Self {
    AIMessage {
      role,
      body: body.into(),
      streaming: false,
      error: None,
      name: None,
      meta: None,
      avatar: true,
      size: Size::Sm,
      children: Vec::new(),
    }
  }

  /// The reply is still arriving: draw a caret after the last character.
  pub fn streaming(mut self, streaming: bool) -> Self {
    self.streaming = streaming;
    self
  }

  /// Replace the body with a failure notice. Whatever text had already
  /// arrived stays above it.
  pub fn error(mut self, error: impl Into<SharedString>) -> Self {
    self.error = Some(error.into());
    self
  }

  /// Override the role's default name — a model id, a person's name.
  pub fn name(mut self, name: impl Into<SharedString>) -> Self {
    self.name = Some(name.into());
    self
  }

  /// Trailing detail on the header row: a timestamp, a token count.
  pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
    self.meta = Some(meta.into());
    self
  }

  /// Hide the gutter icon and pull the body out to the full width.
  pub fn avatar(mut self, avatar: bool) -> Self {
    self.avatar = avatar;
    self
  }

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

impl ParentElement for AIMessage {
  fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
    self.children.extend(elements);
  }
}

impl RenderOnce for AIMessage {
  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
    let t = theme(cx);
    let font = t.font_size(self.size);
    let radius = t.radius(Size::Md);
    let dimmed = t.dimmed().hsla();
    let border = t.border().hsla();
    let danger = t.danger().hsla();
    let bubble = t.surface_hover().hsla();
    let quiet = self.role == AIRole::System || self.role == AIRole::Tool;

    let header = div()
      .flex()
      .items_center()
      .gap(px(6.0))
      .text_size(px(t.font_size(Size::Xs)))
      .text_color(dimmed)
      // The role's label is already `&'static str`, so the default
      // costs nothing and the override is a refcount bump.
      .child(
        self
          .name
          .unwrap_or_else(|| SharedString::new_static(self.role.label())),
      )
      .when_some(self.meta, |row, meta| {
        row
          .child(div().child(SharedString::new_static("\u{00b7}")))
          .child(meta)
      });

    let body = div()
      .w_full()
      .when(!self.body.is_empty(), |column| {
        column.child(if self.streaming {
          AIStreamingText::new(self.body.clone())
            .size(self.size)
            .into_any_element()
        } else {
          Markdown::new(self.body.clone())
            .size(self.size)
            .into_any_element()
        })
      })
      .when_some(self.error, |column, error| {
        column.child(
          div()
            .mt(px(6.0))
            .flex()
            .items_start()
            .gap(px(6.0))
            .text_size(px(t.font_size(Size::Xs)))
            .text_color(danger)
            .child(Icon::new(IconName::TriangleAlert).size(Size::Xs))
            .child(error),
        )
      })
      .children(self.children);

    // The user's turn is a contained bubble; the assistant's is the page.
    // Anything else is marginalia, dimmed and small.
    let content = match self.role {
      AIRole::User => div()
        .max_w(px(560.0))
        .px(px(12.0))
        .py(px(8.0))
        .rounded(px(radius))
        .bg(bubble)
        .child(body),
      _ if quiet => div()
        .w_full()
        .px(px(10.0))
        .py(px(6.0))
        .rounded(px(radius))
        .border_1()
        .border_color(border)
        .text_color(dimmed)
        .child(body),
      _ => div().w_full().child(body),
    };

    let gutter = self.avatar.then(|| {
      div()
        .flex_none()
        .flex()
        .items_center()
        .justify_center()
        .w(px(24.0))
        .h(px(24.0))
        .rounded(px(12.0))
        .bg(bubble)
        .text_color(dimmed)
        .child(
          Icon::new(self.role.icon())
            .size(Size::Xs)
            .color(ColorName::Gray),
        )
    });

    div()
      .flex()
      .flex_row()
      .items_start()
      .gap(px(10.0))
      .w_full()
      .text_size(px(font))
      .when(self.role == AIRole::User, |row| row.justify_end())
      .children(gutter.filter(|_| self.role != AIRole::User))
      .child(
        div()
          .flex()
          .flex_col()
          .gap(px(2.0))
          .flex_1()
          .min_w(px(0.0))
          .when(self.role == AIRole::User, |column| column.items_end())
          .child(header)
          .child(content),
      )
      .probe("AIMessage")
  }
}