Files
Ely-GPUI-Components/src/chat/media.rs
T
ZacharyZhang-NY c0afce2507 T23a: AI chat, messages
- messages: yours in a quiet bubble, the assistant's as prose beside its mark,
  system notes, errors; avatars, headers with model and time, footers
- MessageList over gpui's bottom-aligned list: drawn near the view, held at the
  newest, counting arrivals while away; a way back down; day separators
- streaming text and markdown reveal toward the text as it grows, with a caret;
  a blinking cursor before the first words
- CodeBlock with language, copy, run, apply, download and folding;
  MarkdownRenderer::code lets messages draw fences with it
- pictures, grids, files, voice notes, video over host frames, link previews
- quotes, actions, an editor that takes focus, versions, regenerate, stop and
  continue
- errors with retry, a rate limit counting down, feedback, thinking blocks
- gallery: the AI Chat page with scripted states
2026-09-26 05:35:09 -04:00

426 lines
14 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ObjectFit,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
div, prelude::*,
};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::source,
forms::{Pick, Run},
primitives::{Icon, IconName, Image},
theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize},
typography::{Ellipsis, format::file_size},
};
/// A picture's box: its own shape, no wider than `widest`.
pub(crate) fn fitted(width: f32, height: f32, widest: f32) -> (f32, f32) {
assert!(
width > 0.0 && height > 0.0,
"a picture of {width}x{height} has no shape"
);
let shown = width.min(widest);
(shown, shown * height / width)
}
/// A picture in a message, in its own shape up to the column's width, a caption under it; a press asks the owner to open it large.
#[derive(IntoElement)]
pub struct ImageMessage {
id: ElementId,
source: SharedString,
size: (f32, f32),
caption: Option<SharedString>,
on_open: Option<Run>,
}
impl ImageMessage {
/// `source` is a file or a web address; `width` and `height` its pixels.
pub fn new(
id: impl Into<ElementId>,
source: impl Into<SharedString>,
width: f32,
height: f32,
) -> Self {
Self {
id: id.into(),
source: source.into(),
size: (width, height),
caption: None,
on_open: None,
}
}
pub fn caption(mut self, caption: impl Into<SharedString>) -> Self {
self.caption = Some(caption.into());
self
}
pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageMessage {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let widest = f32::from(theme.prose_width().to_pixels(window.rem_size()));
let (width, height) = fitted(self.size.0, self.size.1, widest);
let open = self.on_open;
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id(self.id.clone())
.relative()
.w(Pixels::from(width))
.h(Pixels::from(height))
.when_some(open, |picture, open| {
picture
.cursor_pointer()
.on_click(move |_, window, cx| open(window, cx))
})
.child(
Image::new((self.id.clone(), "picture"), source(&self.source))
.fit(ObjectFit::Contain)
.size_full(),
),
)
.children(self.caption.map(|caption| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(caption)
}))
}
}
/// Pictures sent together as a grid: one fills it, two share a row, three set one large beside two, four make a square; past four, the last tile counts the rest. A press asks to open one.
#[derive(IntoElement)]
pub struct ImageGrid {
id: ElementId,
sources: Vec<SharedString>,
on_open: Option<Pick>,
}
impl ImageGrid {
pub fn new(
id: impl Into<ElementId>,
sources: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let sources: Vec<SharedString> = sources.into_iter().map(Into::into).collect();
assert!(!sources.is_empty(), "a grid holds a picture");
Self {
id: id.into(),
sources,
on_open: None,
}
}
/// Gets the index of the picture pressed.
pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageGrid {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let side = theme.prose_width();
let rest = self.sources.len().saturating_sub(4);
let tile = |ix: usize| -> AnyElement {
let open = self.on_open.clone();
div()
.id((self.id.clone(), format!("tile-{ix}")))
.relative()
.flex_1()
.min_w_0()
.h_full()
.bg(colors.sunken)
.when_some(open, |tile, open| {
tile.cursor_pointer()
.on_click(move |_, window, cx| open(ix, window, cx))
})
.child(
Image::new(
(self.id.clone(), format!("picture-{ix}")),
source(&self.sources[ix]),
)
.fit(ObjectFit::Cover)
.size_full(),
)
.when(ix == 3 && rest > 0, |tile| {
tile.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.bg(colors.media_backdrop.opacity(0.55))
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.on_media)
.child(format!("+{rest}")),
)
})
.into_any_element()
};
let row = || div().flex().flex_1().min_h_0().gap_0p5();
let grid = div().w(side).h(side).flex().flex_col().gap_0p5();
match self.sources.len() {
1 => grid.child(row().child(tile(0))),
2 => grid
.h(side * 0.5)
.child(row().child(tile(0)).child(tile(1))),
3 => grid.child(
div().flex().flex_1().gap_0p5().child(tile(0)).child(
div()
.flex_1()
.flex()
.flex_col()
.gap_0p5()
.child(row().child(tile(1)))
.child(row().child(tile(2))),
),
),
_ => grid
.child(row().child(tile(0)).child(tile(1)))
.child(row().child(tile(2)).child(tile(3))),
}
}
}
/// The icon a file's name suggests.
pub(crate) fn file_icon(name: &str) -> IconName {
let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase());
match extension.as_deref() {
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "heic") => IconName::Image,
Some("pdf" | "md" | "txt" | "doc" | "docx" | "rtf" | "pages") => IconName::FileText,
_ => IconName::File,
}
}
/// A file in a message: its kind's icon, its name and size, and a way to download it.
#[derive(IntoElement)]
pub struct FileMessage {
id: ElementId,
name: SharedString,
bytes: u64,
on_download: Option<Run>,
}
impl FileMessage {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, bytes: u64) -> Self {
Self {
id: id.into(),
name: name.into(),
bytes,
on_download: None,
}
}
pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_download = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FileMessage {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.w(theme.prose_width())
.flex()
.items_center()
.gap_3()
.p_2()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.flex_none()
.size(theme.avatar_size(AvatarSize::Md))
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Sm))
.bg(colors.hover)
.child(
Icon::new(file_icon(&self.name))
.size(IconSize::Md)
.color(colors.fg_muted),
),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child(Ellipsis::new(self.name.clone())),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(file_size(self.bytes, false)),
),
)
.children(self.on_download.map(|download| {
IconButton::new((self.id.clone(), "download"), IconName::Download)
.variant(ButtonVariant::Ghost)
.tooltip("Download")
.on_click(move |_, window, cx| download(window, cx))
}))
}
}
/// A link unfolded: the site, the page's title and a line about it, and its picture; a press opens it.
#[derive(IntoElement)]
pub struct LinkPreviewCard {
id: ElementId,
site: SharedString,
title: SharedString,
description: Option<SharedString>,
picture: Option<SharedString>,
on_open: Option<Run>,
}
impl LinkPreviewCard {
pub fn new(
id: impl Into<ElementId>,
site: impl Into<SharedString>,
title: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
site: site.into(),
title: title.into(),
description: None,
picture: None,
on_open: None,
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
self
}
/// A square picture, a file or a web address.
pub fn picture(mut self, picture: impl Into<SharedString>) -> Self {
self.picture = Some(picture.into());
self
}
pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for LinkPreviewCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let open = self.on_open;
div()
.id(self.id.clone())
.w(theme.container_width(ContainerSize::Sm))
.flex()
.gap_3()
.p_3()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.when_some(open, |card, open| {
card.cursor_pointer()
.hover(|card| card.bg(colors.hover))
.on_click(move |_, window, cx| open(window, cx))
})
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.flex()
.items_center()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(
Icon::new(IconName::Globe)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(self.site),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child(self.title),
)
.children(self.description.map(|text| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(text)
})),
)
.children(self.picture.map(|picture| {
div()
.flex_none()
.size(theme.avatar_size(AvatarSize::Xl))
.rounded(theme.radius(Radius::Md))
.bg(colors.sunken)
.child(
Image::new((self.id.clone(), "picture"), source(&picture))
.fit(ObjectFit::Cover)
.size_full(),
)
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_picture_keeps_its_shape_within_the_column() {
assert_eq!(fitted(800.0, 600.0, 320.0), (320.0, 240.0));
assert_eq!(
fitted(200.0, 100.0, 320.0),
(200.0, 100.0),
"small ones stay"
);
}
#[test]
fn a_file_name_picks_its_icon() {
assert_eq!(file_icon("lift.PNG"), IconName::Image);
assert_eq!(file_icon("notes.md"), IconName::FileText);
assert_eq!(file_icon("archive"), IconName::File);
}
}