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
//! `AIComposer` — the box you type the prompt into.
//!
//! Three things make this different from a plain [`TextArea`]: Enter sends and
//! Shift+Enter breaks the line, the box grows with what's in it up to a
//! ceiling, and the send button becomes a stop button while a reply is in
//! flight — because being able to interrupt a long generation is the control
//! people reach for most.
//!
//! It emits [`AIComposerEvent`] and holds no opinion about what happens next;
//! the host sends the request.
//!
//! ```ignore
//! let composer = cx.new(|cx| AIComposer::new(cx).placeholder("Ask anything\u{2026}"));
//! cx.subscribe(&composer, |this, composer, event: &AIComposerEvent, cx| {
//!     match event {
//!         AIComposerEvent::Submit(text) => this.send(text.clone(), cx),
//!         AIComposerEvent::Stop => this.cancel(cx),
//!         AIComposerEvent::Attach => this.pick_files(cx),
//!     }
//! })
//! .detach();
//! ```

use gpui::prelude::*;
use gpui::{
  div, px, Context, Entity, EventEmitter, FocusHandle, IntoElement, SharedString, Window,
};

use crate::actionicon::ActionIcon;
use crate::devtools::Probed;
use crate::icon::IconName;
use crate::input::{TextArea, TextAreaEvent, TextAreaSubmit};
use crate::style::Variant;
use crate::theme::{theme, ColorName, Size};

/// What the composer asks the host to do.
#[derive(Debug, Clone)]
pub enum AIComposerEvent {
  /// Send this. The composer has already cleared itself.
  Submit(String),
  /// Interrupt the reply in flight.
  Stop,
  /// The attach button was pressed; the host owns the file dialog.
  Attach,
  /// The draft changed. Carries the current text, for hosts that persist it.
  Change(String),
}

/// A prompt box with a send/stop button.
pub struct AIComposer {
  input: Entity<TextArea>,
  /// A reply is streaming: send becomes stop.
  busy: bool,
  /// Show the paperclip.
  attachments: bool,
  hint: Option<SharedString>,
  size: Size,
  disabled: bool,
}

impl EventEmitter<AIComposerEvent> for AIComposer {}

impl AIComposer {
  pub fn new(cx: &mut Context<Self>) -> Self {
    let input = cx.new(|cx| {
      TextArea::new(cx)
        .rows(1)
        .max_rows(12)
        .submit_on_enter(true)
        .placeholder("Send a message\u{2026}")
    });
    // The field is a child entity, so its events have to be forwarded on
    // rather than reaching the host directly.
    cx.subscribe(&input, |this, input, _event: &TextAreaSubmit, cx| {
      let text = input.read(cx).text();
      this.submit(text, cx);
    })
    .detach();
    cx.subscribe(&input, |_this, _input, event: &TextAreaEvent, cx| {
      cx.emit(AIComposerEvent::Change(event.0.clone()));
    })
    .detach();

    AIComposer {
      input,
      busy: false,
      attachments: false,
      hint: None,
      size: Size::Sm,
      disabled: false,
    }
  }

  pub fn placeholder(self, placeholder: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
    let placeholder = placeholder.into();
    self.input.update(cx, |input, cx| {
      input.set_placeholder(placeholder, cx);
    });
    self
  }

  /// Offer an attach button, which emits [`AIComposerEvent::Attach`].
  pub fn attachments(mut self, attachments: bool) -> Self {
    self.attachments = attachments;
    self
  }

  /// A line under the box — "Shift+Enter for a new line", a model name, a
  /// disclaimer.
  pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
    self.hint = Some(hint.into());
    self
  }

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

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

  /// The inner field, for hosts that need to bind or style it.
  pub fn input(&self) -> &Entity<TextArea> {
    &self.input
  }

  pub fn focus_handle(&self, cx: &gpui::App) -> FocusHandle {
    self.input.read(cx).focus_handle()
  }

  /// The current draft.
  pub fn text(&self, cx: &gpui::App) -> String {
    self.input.read(cx).text()
  }

  /// Replace the draft — restoring a saved one, or dropping in a template.
  pub fn set_text(&mut self, text: &str, cx: &mut Context<Self>) {
    self.input.update(cx, |input, cx| input.set_text(text, cx));
    cx.notify();
  }

  /// Switch the send button to a stop button. Set it when the request goes
  /// out and clear it when the reply ends, including on error.
  pub fn set_busy(&mut self, busy: bool, cx: &mut Context<Self>) {
    self.busy = busy;
    cx.notify();
  }

  pub fn is_busy(&self) -> bool {
    self.busy
  }

  /// Send the draft and clear the box. An empty or blank draft does nothing
  /// — sending whitespace to a model is never what was meant.
  fn submit(&mut self, text: String, cx: &mut Context<Self>) {
    if self.disabled || self.busy || text.trim().is_empty() {
      return;
    }
    self.input.update(cx, |input, cx| input.set_text("", cx));
    cx.emit(AIComposerEvent::Submit(text));
    cx.notify();
  }
}

impl Render for AIComposer {
  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    let t = theme(cx);
    let dimmed = t.dimmed().hsla();
    let font_xs = t.font_size(Size::Xs);
    let empty = self.input.read(cx).is_blank();

    let action = if self.busy {
      ActionIcon::new("guise-ai-stop", IconName::Square)
        .label("Stop")
        .variant(Variant::Filled)
        .color(ColorName::Red)
        .size(self.size)
        .on_click(cx.listener(|_this, _event, _window, cx| {
          cx.emit(AIComposerEvent::Stop);
        }))
    } else {
      ActionIcon::new("guise-ai-send", IconName::ArrowUp)
        .label("Send")
        .variant(Variant::Filled)
        .size(self.size)
        // Nothing to send is a disabled button, not a silent no-op:
        // the state is visible before the click.
        .disabled(self.disabled || empty)
        .on_click(cx.listener(|this, _event, _window, cx| {
          let text = this.input.read(cx).text();
          this.submit(text, cx);
        }))
    };

    div()
      .flex()
      .flex_col()
      .gap(px(4.0))
      .w_full()
      .child(
        div()
          .flex()
          .flex_row()
          .items_end()
          .gap(px(8.0))
          .w_full()
          .child(div().flex_1().min_w(px(0.0)).child(self.input.clone()))
          .when(self.attachments, |row| {
            row.child(
              ActionIcon::new("guise-ai-attach", IconName::Paperclip)
                .label("Attach")
                .variant(Variant::Subtle)
                .color(ColorName::Gray)
                .size(self.size)
                .disabled(self.disabled)
                .on_click(cx.listener(|_this, _event, _window, cx| {
                  cx.emit(AIComposerEvent::Attach);
                })),
            )
          })
          .child(action),
      )
      .when_some(self.hint.clone(), |column, hint| {
        column.child(div().text_size(px(font_xs)).text_color(dimmed).child(hint))
      })
      .probe("AIComposer")
  }
}