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
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 05:35:09 -04:00
1 parent 88a1550c14
commit 6151291443
21 files changed
+3376 -34

No files matched your search

+1
View File
@@ -114,6 +114,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them.
- Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`).
- A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`).
- A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`).
- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up.
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
- A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width.
+1 -1
View File
@@ -72,7 +72,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T21b Documents · Reading
- [x] T21c Documents · Knowledge
- [x] T22 Collaboration
- [ ] T23a AI Chat · Messages
- [x] T23a AI Chat · Messages
- [ ] T23b AI Chat · Citations
- [ ] T23c AI Chat · Input
- [ ] T23d AI Chat · Conversations
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="160" viewBox="0 0 560 160">
<rect width="560" height="160" rx="10" fill="#FBFAF7"/>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8C8A85"/>
</marker>
</defs>
<g font-family="Helvetica, Arial, sans-serif" font-size="14" text-anchor="middle">
<rect x="28" y="54" width="132" height="52" rx="8" fill="#FFFFFF" stroke="#C9C6BF"/>
<text x="94" y="85" fill="#2B2A28">Base color</text>
<rect x="214" y="54" width="132" height="52" rx="8" fill="#FFFFFF" stroke="#C9C6BF"/>
<text x="280" y="78" fill="#2B2A28">Lift</text>
<text x="280" y="96" fill="#8C8A85" font-size="12">t = 0.5 or 0.62</text>
<rect x="400" y="54" width="132" height="52" rx="8" fill="#FFFFFF" stroke="#C9C6BF"/>
<text x="466" y="85" fill="#2B2A28">Theme accent</text>
<line x1="160" y1="80" x2="210" y2="80" stroke="#8C8A85" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="346" y1="80" x2="396" y2="80" stroke="#8C8A85" stroke-width="1.5" marker-end="url(#arrow)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+141
View File
@@ -0,0 +1,141 @@
use std::time::Duration;
use ely_gpui_component::{
chat::{
AudioMessage, CodeBlock, FileMessage, ImageGrid, ImageMessage, LinkPreviewCard,
VideoMessage,
},
documents::MarkdownRenderer,
};
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
use crate::ui::{keep, noise, section, set};
macro_rules! asset {
($name:literal) => {
concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/",
$name
)
};
}
const SHORT: &str = "fn lift(c: f32, t: f32) -> f32 {\n c + t * (1.0 - c)\n}";
const LONG: &str = "/// Every accent in the library, lifted for its theme.\npub struct Accents {\n base: Hsla,\n light: f32,\n dark: f32,\n}\n\nimpl Accents {\n pub fn new(base: Hsla) -> Self {\n Self { base, light: 0.5, dark: 0.62 }\n }\n\n /// The accent as the theme shows it.\n pub fn shown(&self, dark: bool) -> Hsla {\n let t = if dark { self.dark } else { self.light };\n lift(self.base, t)\n }\n\n /// A hover, one step louder.\n pub fn hover(&self, dark: bool) -> Hsla {\n lift(self.shown(dark), 0.08)\n }\n\n /// A press, two steps louder.\n pub fn press(&self, dark: bool) -> Hsla {\n lift(self.shown(dark), 0.16)\n }\n}";
pub fn code(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
section(
"CodeBlock",
"Code in a message: its language, copy, and the host's run, apply and download. Long code folds to its first lines until opened.",
cx,
)
.child(
div()
.w(px(620.))
.flex()
.flex_col()
.gap_4()
.child(
CodeBlock::new("chat-code-short", SHORT)
.language("rust")
.on_run(|_, _| log::info!("gallery: run"))
.on_apply(|_, _| log::info!("gallery: apply"))
.on_download(|_, _| log::info!("gallery: download")),
)
.child(CodeBlock::new("chat-code-long", LONG).language("rust")),
)
}
const RICH: &str = "| Theme | Lift | Reads as |\n| --- | ---: | --- |\n| Light | 0.50 | half toward white |\n| Dark | 0.62 | a little louder |\n\n$$c' = c + t\\,(1 - c)$$";
pub fn rich(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
section(
"MathBlock / TableBlock / MermaidRenderer",
"Math is typography::Latex and tables are the markdown renderer's, in a message as in a document. A diagram arrives as the picture the host drew from its source.",
cx,
)
.child(
div()
.w(px(620.))
.flex()
.flex_col()
.gap_4()
.child(MarkdownRenderer::new("chat-rich", RICH))
.child(ImageMessage::new("chat-diagram", asset!("flow.svg"), 560.0, 160.0).caption("Drawn by the host from a diagram's source")),
)
}
pub fn media(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let mut wave = noise(7);
let peaks: Vec<f32> = (0..56)
.map(|ix| {
let swell = (ix as f32 / 56.0 * std::f32::consts::PI).sin();
(0.25 + 0.75 * swell * wave() as f32).clamp(0.08, 1.0)
})
.collect();
let playing = keep(
"chat-audio",
|| (Duration::from_secs(12), false),
window,
cx,
);
let (played, on) = *playing.read(cx);
let (toggle, seek) = (playing.clone(), playing);
section(
"ImageMessage / ImageGrid / FileMessage / AudioMessage / VideoMessage / LinkPreviewCard",
"What a message can carry: a picture in its own shape, pictures as a grid, a file, a voice note to play and scrub, a video over the host's frames, and a link unfolded.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_start()
.gap_6()
.child(
div()
.flex()
.flex_col()
.gap_4()
.child(ImageMessage::new("chat-image", asset!("atrium.jpg"), 960.0, 640.0).caption("The atrium at noon"))
.child(FileMessage::new("chat-file", "lift-notes.pdf", 482_000).on_download(|_, _| log::info!("gallery: download file")))
.child(
AudioMessage::new("chat-audio", peaks, Duration::from_secs(42))
.played(played, on)
.on_toggle(move |next, _, cx| {
let at = toggle.read(cx).0;
set(&toggle, (at, next), cx)
})
.on_seek(move |share, _, cx| {
let on = seek.read(cx).1;
set(&seek, (Duration::from_secs_f32(42.0 * share), on), cx)
}),
),
)
.child(
div()
.flex()
.flex_col()
.gap_4()
.child(ImageGrid::new(
"chat-grid",
[
asset!("dunes.jpg"),
asset!("atrium-stair.jpg"),
asset!("dunes-sun.jpg"),
asset!("atrium-olive.jpg"),
asset!("dunes-grass.jpg"),
],
))
.child(VideoMessage::new("chat-video", asset!("dunes.jpg"), 960.0, 640.0, Duration::from_secs(84)).on_toggle(|next, _, _| log::info!("gallery: video playing {next}")))
.child(
LinkPreviewCard::new("chat-link", "example.com", "Color, in light and dark")
.description("How one accent reads the same on paper and on charcoal.")
.picture(asset!("dunes-square.jpg"))
.on_open(|_, _| log::info!("gallery: open link")),
),
),
)
}
+184
View File
@@ -0,0 +1,184 @@
use std::time::{Duration, Instant};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
chat::{
BranchNavigator, ErrorMessage, FeedbackForm, MessageActions, MessageAvatar, MessageBubble,
MessageEditor, QuoteReply, RateLimitNotice, Role, ThinkingBlock, ThinkingDuration,
ThinkingIndicator, continue_button, regenerate_button, stop_button,
},
forms::TextInput,
primitives::IconName,
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, section, set},
};
const SENT: &str = "What does a lift do to a color?";
fn field(
key: &'static str,
text: &str,
hint: &str,
window: &mut Window,
cx: &mut App,
) -> Entity<TextInput> {
let (text, hint) = (text.to_string(), hint.to_string());
window.use_keyed_state(key, cx, move |window, cx| {
let mut input = TextInput::new(window, cx)
.multi_line(2, 6)
.placeholder(hint);
input.set_text(text, cx);
input
})
}
pub fn actions(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let rating = keep("chat-rating", || None::<bool>, window, cx);
let now_rating = *rating.read(cx);
let branch = keep("chat-branch", || 1_usize, window, cx);
let now_branch = *branch.read(cx);
let editing = keep("chat-editing", || false, window, cx);
let sent = keep("chat-sent", || SharedString::from(SENT), window, cx);
let now_sent = sent.read(cx).clone();
let draft = field("chat-draft", SENT, "Your message", window, cx);
let open = editing.clone();
let body = if *editing.read(cx) {
let (save, cancel, text) = (editing.clone(), editing.clone(), draft.clone());
MessageEditor::new(
"chat-editor",
&draft,
now_sent,
move |_, cx| {
let next = SharedString::from(text.read(cx).text().trim().to_string());
set(&sent, next, cx);
set(&save, false, cx)
},
move |_, cx| set(&cancel, false, cx),
)
.into_any_element()
} else {
div()
.flex()
.items_center()
.gap_3()
.child(div().child(now_sent))
.child(probe(
"chat-edit-open",
Button::new("chat-edit", "Edit")
.variant(ButtonVariant::Ghost)
.icon(IconName::Pencil)
.on_click(move |_, _, cx| set(&open, true, cx)),
))
.into_any_element()
};
section(
"QuoteReply / MessageActions / MessageEditor / BranchNavigator / RegenerateButton / StopGeneratingButton / ContinueButton",
"A quote to answer, the actions on a message, a sent message opened to change (the submit key saves, Escape cancels), the versions an answer has, and the buttons that regenerate, stop and continue.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.flex_col()
.gap_4()
.child(QuoteReply::new("chat-quote", "Assistant", "A lift blends a color toward white. Half a lift sits midway between the color and white.").on_remove(|_, _| log::info!("gallery: quote removed")))
.child(body)
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
MessageActions::new("chat-actions", SENT)
.on_edit(|_, _| log::info!("gallery: edit"))
.on_regenerate(|_, _| log::info!("gallery: regenerate"))
.rating(now_rating, move |next, _, cx| set(&rating, next, cx)),
)
.child(BranchNavigator::new("chat-branch", now_branch, 3, move |next, _, cx| set(&branch, next, cx))),
)
.child(
div()
.flex()
.gap_2()
.child(regenerate_button("chat-regenerate").on_click(|_, _, _| log::info!("gallery: regenerate")))
.child(stop_button("chat-stop").on_click(|_, _, _| log::info!("gallery: stop")))
.child(continue_button("chat-continue").on_click(|_, _, _| log::info!("gallery: continue"))),
),
)
}
pub fn status(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let until = *keep(
"chat-limit",
|| Instant::now() + Duration::from_secs(222),
window,
cx,
)
.read(cx);
let picked = keep(
"chat-reasons",
|| vec![SharedString::from("Too long")],
window,
cx,
);
let now_picked = picked.read(cx).clone();
let note = field("chat-note", "", "Anything else? (optional)", window, cx);
section(
"ErrorMessage / RetryMessage / RateLimitNotice / FeedbackForm",
"An answer that failed and a way to try again; a limit reached, counting down to when it lifts; and after a bad mark, what went wrong.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.flex_col()
.gap_4()
.child(
MessageBubble::new(Role::Error)
.avatar(MessageAvatar::new("chat-error-mark", Role::Error, "Assistant"))
.child(ErrorMessage::new("chat-error", "The answer stopped halfway").detail("The connection closed before the end. Nothing was lost.").on_retry(|_, _| log::info!("gallery: retry"))),
)
.child(RateLimitNotice::new("chat-limit", until).on_upgrade(|_, _| log::info!("gallery: raise limit")))
.child(
FeedbackForm::new(
"chat-feedback",
["Wrong", "Unclear", "Too long", "Missed the point"],
now_picked,
&note,
move |next, _, cx| set(&picked, next.to_vec(), cx),
|_, _| log::info!("gallery: feedback sent"),
)
.on_cancel(|_, _| log::info!("gallery: feedback cancelled")),
),
)
}
const REASONING: &str = "The reader asks what a lift does. Start from the definition: a blend toward white. Then give the two themes, since they lift by different amounts, and say why: a dark ground makes saturated color look louder, so it needs less of it.";
pub fn thinking(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
section(
"ThinkingBlock / ReasoningPanel / ThinkingIndicator / ThinkingDuration",
"The reasoning behind an answer, folded under how long it took; open, its steps read in a quiet column. While the assistant works, a word says what it is doing.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.flex_col()
.gap_4()
.child(probe(
"chat-thinking",
div().w(px(560.)).child(ThinkingBlock::new("chat-thought", REASONING, false, Duration::from_secs(12))),
))
.child(ThinkingBlock::new("chat-thinking-now", "Weighing how dark themes change", true, Duration::from_secs(3)))
.child(ThinkingIndicator::new("chat-searching").label("Searching the web"))
.child(ThinkingDuration::new(Duration::from_secs(75))),
)
}
+45
View File
@@ -0,0 +1,45 @@
mod blocks;
mod controls;
mod talk;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 23,
slug: "chat",
title: "AI Chat",
summary: "A conversation with an assistant: messages that stream in, code and media inside them, and what can be done with each.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::DownAt("chat-thinking", 12.0, 10.0),
Step::UpAt("chat-thinking", 12.0, 10.0),
Step::Wait(300),
Step::Shot("reasoning"),
Step::DownAt("chat-thinking", 12.0, 10.0),
Step::UpAt("chat-thinking", 12.0, 10.0),
Step::DownAt("chat-edit-open", 20.0, 12.0),
Step::UpAt("chat-edit-open", 20.0, 12.0),
Step::Wait(300),
Step::Shot("editing"),
Step::Key("escape"),
Step::Wait(200),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(talk::conversation(window, cx))
.child(talk::streaming(window, cx))
.child(blocks::code(window, cx))
.child(blocks::rich(window, cx))
.child(blocks::media(window, cx))
.child(controls::actions(window, cx))
.child(controls::status(window, cx))
.child(controls::thinking(window, cx))
.into_any_element()
}
+224
View File
@@ -0,0 +1,224 @@
use std::time::Duration;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
chat::{
ChatContainer, DateSeparator, MessageActions, MessageAvatar, MessageBubble, MessageFooter,
MessageHeader, MessageList, Role, StreamingCursor, StreamingMarkdown, stop_button,
},
primitives::IconName,
theme::{ActiveTheme, Radius},
};
use gpui::{App, ElementId, IntoElement, ParentElement, Styled, Task, Window, div, px};
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
use crate::{
probe::probe,
ui::{keep, section},
};
/// One row of the demo conversation.
#[derive(Clone)]
enum Row {
Day(Date),
Note(&'static str),
Said {
role: Role,
name: &'static str,
minutes_ago: i64,
text: &'static str,
live: bool,
},
}
const ANSWER: &str = "A **lift** blends a color toward white. Half a lift sits midway between the color and white; a full lift is white itself.\n\n- In light, accents take half a lift.\n- In dark, they take more, so they read with the same weight.";
const LIVE: &str = "The lift $t$ mixes a channel toward one:\n\n$$c' = c + t\\,(1 - c)$$\n\nIn code, for each channel:\n\n```rust\nfn lift(c: f32, t: f32) -> f32 {\n c + t * (1.0 - c)\n}\n```\n\nApplied to every";
fn rows(today: Date) -> Vec<Row> {
let yesterday = today.yesterday().expect("the day before exists");
vec![
Row::Day(yesterday),
Row::Said {
role: Role::User,
name: "Ada Park",
minutes_ago: 1500,
text: "What does a lift do to a color?",
live: false,
},
Row::Said {
role: Role::Assistant,
name: "Assistant",
minutes_ago: 1499,
text: ANSWER,
live: false,
},
Row::Day(today),
Row::Note("Grace Lin joined the conversation"),
Row::Said {
role: Role::User,
name: "Ada Park",
minutes_ago: 2,
text: "Show me the math, and the code.",
live: false,
},
Row::Said {
role: Role::Assistant,
name: "Assistant",
minutes_ago: 1,
text: LIVE,
live: true,
},
]
}
fn row(ix: usize, row: &Row, today: Date, now: Timestamp) -> gpui::AnyElement {
let key = |what: &str| (ElementId::from("chat-row"), format!("{what}-{ix}"));
let body = match row {
Row::Day(day) => return DateSeparator::new(*day, today).into_any_element(),
Row::Note(text) => MessageBubble::new(Role::System).child(*text),
Row::Said {
role,
name,
minutes_ago,
text,
live,
} => {
let header = MessageHeader::new(key("header"), *name).at(now - minutes_ago.minutes());
let header = if *role == Role::Assistant {
header.model("Ely Large")
} else {
header
};
let bubble = MessageBubble::new(*role)
.avatar(MessageAvatar::new(key("avatar"), *role, *name))
.header(header)
.child(StreamingMarkdown::new(key("text"), *text, *live));
match (role, live) {
(Role::Assistant, false) => bubble.footer(
MessageFooter::new().fact("412 tokens").fact("2.1 s").child(
MessageActions::new(key("actions"), *text)
.on_regenerate(|_, _| log::info!("gallery: regenerate"))
.rating(Some(true), |next, _, _| {
log::info!("gallery: rated {next:?}")
}),
),
),
(Role::Assistant, true) => bubble.footer(MessageFooter::new().child(
stop_button(key("stop")).on_click(|_, _, _| log::info!("gallery: stop")),
)),
_ => bubble,
}
}
};
div().px_6().py_3().child(body).into_any_element()
}
pub fn conversation(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let today = Timestamp::now().to_zoned(TimeZone::system()).date();
let now = Timestamp::now();
let all = rows(today);
let count = all.len();
let theme = cx.theme();
section(
"ChatContainer / MessageList / MessageBubble / MessageAvatar / MessageHeader / MessageFooter / DateSeparator / ScrollToBottomButton",
"A conversation held at its newest message and drawn only near the view. Yours sit on the right in a quiet bubble; the assistant's read as prose beside its mark. Scroll up and it stays put, with a way back down that counts what arrived.",
cx,
)
.child(probe(
"chat-conversation",
div()
.w(px(760.))
.h(px(600.))
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.overflow_hidden()
.child(ChatContainer::new(MessageList::new("chat-messages", count, move |ix, _, _| {
row(ix, &all[ix], today, now)
}))),
))
}
/// The demo stream: how much of it has arrived, whether more is coming, and the task that feeds it.
struct Stream {
arrived: usize,
live: bool,
_feed: Option<Task<()>>,
}
pub fn streaming(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let stream = keep(
"chat-stream",
|| Stream {
arrived: ANSWER.len(),
live: false,
_feed: None,
},
window,
cx,
);
let (arrived, live) = (stream.read(cx).arrived, stream.read(cx).live);
let text = &ANSWER[..arrived];
let theme = cx.theme();
let replay = stream.clone();
section(
"StreamingText / StreamingMarkdown / StreamingCursor",
"Text as it arrives: what came in shows at a steady pace and catches up when far behind, a caret where more will come; markdown takes shape as its lines land. The cursor alone waits for the first words.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.flex_col()
.gap_3()
.child(
div()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.min_h(px(140.))
.child(if arrived == 0 {
StreamingCursor::new("chat-cursor").into_any_element()
} else {
StreamingMarkdown::new("chat-streamed", text.to_string(), live).into_any_element()
}),
)
.child(
div().flex().child(
Button::new("chat-replay", "Replay the stream")
.variant(ButtonVariant::Secondary)
.icon(IconName::RefreshCw)
.on_click(move |_, window, cx| {
let feeder = replay.downgrade();
let feed = window.spawn(cx, async move |cx| {
cx.background_executor().timer(Duration::from_millis(600)).await;
loop {
cx.background_executor().timer(Duration::from_millis(45)).await;
let more = feeder.update(cx, |stream, cx| {
let next = (stream.arrived + 7).min(ANSWER.len());
stream.arrived = (next..=ANSWER.len())
.find(|at| ANSWER.is_char_boundary(*at))
.expect("the text ends on a boundary");
stream.live = stream.arrived < ANSWER.len();
cx.notify();
stream.live
});
if !matches!(more, Ok(true)) {
break;
}
}
});
replay.update(cx, |stream, cx| {
stream.arrived = 0;
stream.live = true;
stream._feed = Some(feed);
cx.notify();
});
}),
),
),
)
}
+2
View File
@@ -1,5 +1,6 @@
mod buttons;
mod charts;
mod chat;
mod collab;
mod data;
mod debug;
@@ -59,6 +60,7 @@ pub const ALL: &[Page] = &[
debug::PAGE,
documents::PAGE,
collab::PAGE,
chat::PAGE,
theme::PAGE,
];
+377
View File
@@ -0,0 +1,377 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, Focusable, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant, CopyButton, IconButton, ToggleButton, ToggleItem},
forms::{Input, Pick, Run, Submit, TextInput},
primitives::IconName,
theme::{ActiveTheme, ControlSize, TextSize},
typography::{Ellipsis, tabular},
};
type OnRate = Rc<dyn Fn(Option<bool>, &mut Window, &mut App)>;
/// Asks for another answer to the same message.
pub fn regenerate_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "Regenerate")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::RefreshCw)
}
/// Stops an answer while it streams.
pub fn stop_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "Stop")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.icon(IconName::CircleStop)
}
/// Picks an answer up where it was cut off.
pub fn continue_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "Continue")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.icon(IconName::StepForward)
}
/// A message quoted in a reply: a rule, who wrote it and the start of what they said; in the composer, a way to take it off.
#[derive(IntoElement)]
pub struct QuoteReply {
id: ElementId,
author: SharedString,
text: SharedString,
on_remove: Option<Run>,
}
impl QuoteReply {
pub fn new(
id: impl Into<ElementId>,
author: impl Into<SharedString>,
text: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
author: author.into(),
text: text.into(),
on_remove: None,
}
}
pub fn on_remove(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for QuoteReply {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let line = self.text.lines().next().unwrap_or_default().to_string();
div()
.w_full()
.flex()
.items_center()
.gap_2()
.pl_3()
.py_1()
.border_l_2()
.border_color(colors.border_strong)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(self.author),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(Ellipsis::new(line)),
),
)
.children(self.on_remove.map(|remove| {
IconButton::new((self.id.clone(), "remove"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove quote")
.on_click(move |_, window, cx| remove(window, cx))
}))
}
}
/// What can be done with a message, in a quiet row: copy, edit, regenerate, and a good or bad mark; each shows once its handler is set, copy always.
#[derive(IntoElement)]
pub struct MessageActions {
id: ElementId,
text: SharedString,
rating: Option<bool>,
on_edit: Option<Run>,
on_regenerate: Option<Run>,
on_rate: Option<OnRate>,
}
impl MessageActions {
/// `text` is what copy takes.
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
rating: None,
on_edit: None,
on_regenerate: None,
on_rate: None,
}
}
pub fn on_edit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_edit = Some(Rc::new(handler));
self
}
pub fn on_regenerate(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_regenerate = Some(Rc::new(handler));
self
}
/// The mark given, good or bad, and what a press asks: the other mark, or none to take it back.
pub fn rating(
mut self,
rating: Option<bool>,
on_rate: impl Fn(Option<bool>, &mut Window, &mut App) + 'static,
) -> Self {
self.rating = rating;
self.on_rate = Some(Rc::new(on_rate));
self
}
}
impl RenderOnce for MessageActions {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let button = |key: &str, icon: IconName, tip: &'static str, run: Option<Run>| {
run.map(|run| {
IconButton::new((self.id.clone(), key.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.on_click(move |_, window, cx| run(window, cx))
})
};
let mark = |good: bool| {
self.on_rate.clone().map(|rate| {
let on = self.rating == Some(good);
let (key, icon, tip) = if good {
("good", IconName::ThumbsUp, "Good answer")
} else {
("bad", IconName::ThumbsDown, "Bad answer")
};
ToggleButton::new(
(self.id.clone(), key),
ToggleItem::new(key).icon(icon).tooltip(tip),
on,
)
.size(ControlSize::Sm)
.on_toggle(move |now, window, cx| {
let next = now.then_some(good);
log::info!("message actions: rated {next:?}");
rate(next, window, cx)
})
})
};
div()
.flex()
.items_center()
.gap_0p5()
.child(CopyButton::new(
(self.id.clone(), "copy"),
self.text.clone(),
))
.children(button(
"edit",
IconName::Pencil,
"Edit",
self.on_edit.clone(),
))
.children(button(
"regenerate",
IconName::RefreshCw,
"Regenerate",
self.on_regenerate.clone(),
))
.children(mark(true))
.children(mark(false))
}
}
/// A sent message opened to change: the field, focused as it opens, then cancel and save. Escape cancels; the submit key saves once the words changed.
#[derive(IntoElement)]
pub struct MessageEditor {
id: ElementId,
field: Entity<TextInput>,
original: SharedString,
on_save: Run,
on_cancel: Run,
}
impl MessageEditor {
/// `field` holds the words being changed, `original` what was sent.
pub fn new(
id: impl Into<ElementId>,
field: &Entity<TextInput>,
original: impl Into<SharedString>,
on_save: impl Fn(&mut Window, &mut App) + 'static,
on_cancel: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
field: field.clone(),
original: original.into(),
on_save: Rc::new(on_save),
on_cancel: Rc::new(on_cancel),
}
}
}
impl RenderOnce for MessageEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let opened = window.use_keyed_state((self.id.clone(), "opened"), cx, |_, _| false);
if !*opened.read(cx) {
log::info!("message editor: takes focus");
window.focus(&self.field.focus_handle(cx));
opened.update(cx, |opened, _| *opened = true);
}
let text = self.field.read(cx).text().trim().to_string();
let changed = !text.is_empty() && text != self.original.trim();
let (save, submit, cancel, escape) = (
self.on_save.clone(),
self.on_save,
self.on_cancel.clone(),
self.on_cancel,
);
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.capture_action(move |_: &Submit, window, cx| {
cx.stop_propagation();
if changed {
log::info!("message editor: saved");
submit(window, cx);
}
})
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
log::info!("message editor: cancelled");
escape(window, cx);
}
})
.child(Input::new(&self.field))
.child(
div()
.flex()
.justify_end()
.gap_2()
.child(
Button::new((self.id.clone(), "cancel"), "Cancel")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| cancel(window, cx)),
)
.child(
Button::new((self.id.clone(), "save"), "Save")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.shortcut("secondary-enter")
.disabled(!changed)
.on_click(move |_, window, cx| {
log::info!("message editor: saved");
save(window, cx)
}),
),
)
}
}
/// Which version of a message shows, among all it has: back, where it stands, forward.
#[derive(IntoElement)]
pub struct BranchNavigator {
id: ElementId,
current: usize,
total: usize,
on_change: Pick,
}
impl BranchNavigator {
pub fn new(
id: impl Into<ElementId>,
current: usize,
total: usize,
on_change: impl Fn(usize, &mut Window, &mut App) + 'static,
) -> Self {
assert!(current < total, "version {current} of {total}");
Self {
id: id.into(),
current,
total,
on_change: Rc::new(on_change),
}
}
}
impl RenderOnce for BranchNavigator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (current, total) = (self.current, self.total);
let step = |key: &str, icon: IconName, tip: &'static str, to: Option<usize>| {
let change = self.on_change.clone();
IconButton::new((self.id.clone(), key.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.disabled(to.is_none())
.when_some(to, |button, to| {
button.on_click(move |_, window, cx| {
log::info!("branch navigator: version {to}");
change(to, window, cx)
})
})
};
div()
.flex()
.items_center()
.child(step(
"back",
IconName::ChevronLeft,
"Previous version",
current.checked_sub(1),
))
.child(
tabular(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted),
)
.child(format!("{} / {total}", current + 1)),
)
.child(step(
"forward",
IconName::ChevronRight,
"Next version",
(current + 1 < total).then_some(current + 1),
))
}
}
+192
View File
@@ -0,0 +1,192 @@
use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, HighlightStyle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*,
relative,
};
use crate::{
buttons::{Button, ButtonVariant, CopyButton, IconButton},
editor::code_colors,
forms::Run,
primitives::{Disclosure, IconName},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
/// Lines a block shows before it folds.
const FOLD_AFTER: usize = 14;
/// Where `code` folds after `lines`: the byte end of the lines kept, when there are more.
pub(crate) fn fold_at(code: &str, lines: usize) -> Option<usize> {
code.match_indices('\n')
.nth(lines.checked_sub(1)?)
.map(|(at, _)| at)
.filter(|at| code[at + 1..].contains(|c: char| !c.is_whitespace()))
}
/// Code in a message: its language, a copy button, and the owner's download, run and apply; long code folds to a few lines until opened.
#[derive(IntoElement)]
pub struct CodeBlock {
id: ElementId,
code: SharedString,
language: Option<SharedString>,
on_download: Option<Run>,
on_run: Option<Run>,
on_apply: Option<Run>,
}
impl CodeBlock {
pub fn new(id: impl Into<ElementId>, code: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
code: code.into(),
language: None,
on_download: None,
on_run: None,
on_apply: None,
}
}
pub fn language(mut self, language: impl Into<SharedString>) -> Self {
self.language = Some(language.into());
self
}
pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_download = Some(Rc::new(handler));
self
}
pub fn on_run(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_run = Some(Rc::new(handler));
self
}
/// Puts the code where it belongs, such as into the open file.
pub fn on_apply(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_apply = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CodeBlock {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false);
let opened = *open.read(cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let code = self.code.trim_end_matches('\n').to_string();
let fold = fold_at(&code, FOLD_AFTER).filter(|_| !opened);
let hidden = fold.map(|at| code[at..].lines().count() - 1);
let shown = fold.map_or(code.as_str(), |at| &code[..at]).to_string();
let styles: Vec<(Range<usize>, HighlightStyle)> = code_colors(&shown, cx);
let action = |key: &str, icon: IconName, tip: &'static str, run: Option<Run>| {
run.map(|run| {
IconButton::new((self.id.clone(), key.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.on_click(move |_, window, cx| run(window, cx))
})
};
let lines = code.lines().count();
let toggle = open.clone();
div()
.w_full()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.sunken)
.child(
div()
.flex()
.items_center()
.gap_1()
.pl_3()
.pr_1()
.py_0p5()
.border_b_1()
.border_color(colors.border)
.child(
div()
.flex_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(self.language.clone().unwrap_or_else(|| "code".into())),
)
.children(action("run", IconName::Play, "Run", self.on_run))
.children(action("apply", IconName::Check, "Apply", self.on_apply))
.children(action(
"download",
IconName::Download,
"Download",
self.on_download,
))
.child(CopyButton::new((self.id.clone(), "copy"), code.clone())),
)
.child(
div()
.id((self.id.clone(), "code"))
.overflow_x_scroll()
.px_3()
.py_2()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.line_height(relative(1.55))
.text_color(colors.syntax.variable)
.whitespace_nowrap()
.child(StyledText::new(shown).with_highlights(styles)),
)
.when(lines > FOLD_AFTER, |block| {
block.child(
div()
.flex()
.items_center()
.gap_1()
.px_1()
.py_0p5()
.border_t_1()
.border_color(colors.border)
.child(
Button::new(
(self.id.clone(), "fold"),
match hidden {
Some(hidden) => format!("Show {hidden} more lines"),
None => "Show less".to_string(),
},
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
toggle.update(cx, |open, cx| {
*open = !*open;
log::info!("code block: open {open}");
cx.notify();
})
}),
)
.child(Disclosure::new((self.id.clone(), "chevron"), opened)),
)
})
}
}
#[cfg(test)]
mod tests {
use super::fold_at;
#[test]
fn long_code_folds_after_its_lines() {
assert_eq!(fold_at("a\nb\nc", 2), Some(3));
assert_eq!(fold_at("a\nb", 2), None, "two lines fit");
assert_eq!(
fold_at("a\nb\n\n", 2),
None,
"blank lines past the fold do not count"
);
assert_eq!(fold_at("a", 0), None);
}
}
+262
View File
@@ -0,0 +1,262 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Context, ElementId, IntoElement, ListAlignment, ListOffset, ListState,
ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, div, list, prelude::*,
};
use jiff::civil::Date;
use crate::{
buttons::{Button, ButtonVariant},
forms::Run,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Elevation, TextSize},
};
type RenderItem = Rc<dyn Fn(usize, &mut Window, &mut App) -> AnyElement>;
/// A list's place: its gpui list, how many messages it knew, whether the reader scrolled away from the newest, and how many came since.
struct Feed {
list: ListState,
count: usize,
away: bool,
fresh: usize,
}
/// How many messages arrived while the reader was away, from `before` to `now`; none once they are back.
pub(crate) fn arrived(before: usize, now: usize, away: bool, fresh: usize) -> usize {
match (away, now >= before) {
(true, true) => fresh + (now - before),
_ => 0,
}
}
/// Messages down a column drawn only near the view, held at the newest: scrolled up, it stays put as messages arrive and offers a way back down with how many are new. Give each conversation its own id.
#[derive(IntoElement)]
pub struct MessageList {
id: ElementId,
count: usize,
render: RenderItem,
}
impl MessageList {
/// `render` draws message `ix` of `count`.
pub fn new(
id: impl Into<ElementId>,
count: usize,
render: impl Fn(usize, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
id: id.into(),
count,
render: Rc::new(render),
}
}
}
impl RenderOnce for MessageList {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.count;
let overdraw = cx.theme().list_overdraw().to_pixels(window.rem_size());
let feed = window.use_keyed_state(
(self.id.clone(), "feed"),
cx,
move |_, cx: &mut Context<Feed>| {
let list = ListState::new(count, ListAlignment::Bottom, overdraw);
let owner = cx.entity().downgrade();
list.set_scroll_handler(move |event, _, cx| {
let away = event.is_scrolled;
if let Err(error) = owner.update(cx, |feed, cx| {
if feed.away != away {
log::info!("message list: away {away}");
feed.away = away;
feed.fresh = 0;
cx.notify();
}
}) {
log::error!("message list: its place is gone: {error:#}");
}
});
Feed {
list,
count,
away: false,
fresh: 0,
}
},
);
if feed.read(cx).count != count {
feed.update(cx, |feed, _| {
if count >= feed.count {
feed.list.splice(feed.count..feed.count, count - feed.count);
} else {
feed.list.splice(count..feed.count, 0);
}
feed.fresh = arrived(feed.count, count, feed.away, feed.fresh);
feed.count = count;
});
}
let (state, away, fresh) = {
let feed = feed.read(cx);
(feed.list.clone(), feed.away, feed.fresh)
};
let render = self.render;
let down = feed.clone();
div()
.relative()
.size_full()
.child(list(state, move |ix, window, cx| render(ix, window, cx)).size_full())
.when(away, |view| {
view.child(
div()
.absolute()
.bottom_4()
.left_0()
.right_0()
.flex()
.justify_center()
.child(ScrollToBottomButton::new(
(self.id.clone(), "down"),
fresh,
move |_, cx| {
down.update(cx, |feed, cx| {
log::info!("message list: back to the newest");
let end = feed.list.item_count();
feed.list.scroll_to(ListOffset {
item_ix: end,
offset_in_item: Pixels::ZERO,
});
feed.away = false;
feed.fresh = 0;
cx.notify();
})
},
)),
)
})
}
}
/// A way back to the newest message, floating over the list, with how many came in while away.
#[derive(IntoElement)]
pub struct ScrollToBottomButton {
id: ElementId,
fresh: usize,
on_press: Run,
}
impl ScrollToBottomButton {
pub fn new(
id: impl Into<ElementId>,
fresh: usize,
on_press: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
fresh,
on_press: Rc::new(on_press),
}
}
}
impl RenderOnce for ScrollToBottomButton {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let label: SharedString = match self.fresh {
0 => "Latest".into(),
1 => "1 new message".into(),
n => format!("{n} new messages").into(),
};
let press = self.on_press;
div()
.rounded_full()
.shadow(cx.theme().elevation(Elevation::Floating))
.child(
Button::new(self.id, label)
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.icon(IconName::ArrowDown)
.on_click(move |_, window, cx| press(window, cx)),
)
}
}
/// A day's line between messages: Today, Yesterday, or the date.
#[derive(IntoElement)]
pub struct DateSeparator {
day: Date,
today: Date,
}
impl DateSeparator {
/// `today` names the reader's day, so tests and captures hold still.
pub fn new(day: Date, today: Date) -> Self {
Self { day, today }
}
}
/// The words for `day` seen from `today`.
pub(crate) fn day_name(day: Date, today: Date) -> String {
if day == today {
"Today".to_string()
} else if today.yesterday().is_ok_and(|yesterday| yesterday == day) {
"Yesterday".to_string()
} else if day.year() == today.year() {
day.strftime("%A, %B %-d").to_string()
} else {
day.strftime("%B %-d, %Y").to_string()
}
}
impl RenderOnce for DateSeparator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let rule = || div().flex_1().h_px().bg(colors.border);
div()
.w_full()
.flex()
.items_center()
.gap_3()
.py_2()
.child(rule())
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(day_name(self.day, self.today)),
)
.child(rule())
}
}
#[cfg(test)]
mod tests {
use jiff::civil::date;
use super::*;
#[test]
fn days_read_as_today_yesterday_or_a_date() {
let today = date(2026, 9, 26);
assert_eq!(day_name(today, today), "Today");
assert_eq!(day_name(date(2026, 9, 25), today), "Yesterday");
assert_eq!(day_name(date(2026, 9, 1), today), "Tuesday, September 1");
assert_eq!(day_name(date(2025, 12, 31), today), "December 31, 2025");
}
#[test]
fn new_messages_count_only_while_away() {
assert_eq!(arrived(3, 5, true, 0), 2);
assert_eq!(arrived(5, 6, true, 2), 3);
assert_eq!(
arrived(5, 6, false, 0),
0,
"at the newest, nothing to count"
);
assert_eq!(
arrived(6, 2, true, 3),
0,
"fewer messages start the count over"
);
}
}
+425
View File
@@ -0,0 +1,425 @@
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);
}
}
+339
View File
@@ -0,0 +1,339 @@
use std::path::Path;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*, relative,
};
use jiff::Timestamp;
use smallvec::SmallVec;
use crate::{
data_display::Avatar,
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize},
typography::RelativeTime,
};
/// Who a message is from, or what it says about the conversation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
System,
Error,
}
/// Who wrote a message at a glance: a person's picture or initials, the assistant's mark, or a warning for an error.
#[derive(IntoElement)]
pub struct MessageAvatar {
id: ElementId,
role: Role,
name: SharedString,
picture: Option<SharedString>,
}
impl MessageAvatar {
pub fn new(id: impl Into<ElementId>, role: Role, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
role,
name: name.into(),
picture: None,
}
}
/// A person's picture, a file or a web address.
pub fn picture(mut self, picture: impl Into<SharedString>) -> Self {
self.picture = Some(picture.into());
self
}
}
impl RenderOnce for MessageAvatar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
match self.role {
Role::User | Role::System => {
let avatar = Avatar::new(self.id, self.name).size(AvatarSize::Sm);
match self.picture {
Some(picture) => avatar.image(Path::new(picture.as_ref())),
None => avatar,
}
.into_any_element()
}
Role::Assistant | Role::Error => {
let (icon, tint) = if self.role == Role::Error {
(IconName::TriangleAlert, colors.danger)
} else {
(IconName::Sparkles, colors.accent)
};
div()
.flex_none()
.size(theme.avatar_size(AvatarSize::Sm))
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Md))
.bg(tint.opacity(0.12))
.child(Icon::new(icon).size(IconSize::Sm).color(tint))
.into_any_element()
}
}
}
}
/// A message's name line: who, when, and what model answered.
#[derive(IntoElement)]
pub struct MessageHeader {
id: ElementId,
name: SharedString,
at: Option<Timestamp>,
model: Option<SharedString>,
}
impl MessageHeader {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
at: None,
model: None,
}
}
pub fn at(mut self, at: Timestamp) -> Self {
self.at = Some(at);
self
}
pub fn model(mut self, model: impl Into<SharedString>) -> Self {
self.model = Some(model.into());
self
}
}
impl RenderOnce for MessageHeader {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.flex()
.items_baseline()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(self.name),
)
.children(self.model.map(|model| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(model)
}))
.children(self.at.map(|at| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(RelativeTime::new((self.id.clone(), "when"), at))
}))
}
}
/// A message's last line: quiet facts, such as tokens and time, then its actions.
#[derive(IntoElement, Default)]
pub struct MessageFooter {
facts: Vec<SharedString>,
actions: SmallVec<[AnyElement; 2]>,
}
impl MessageFooter {
pub fn new() -> Self {
Self::default()
}
pub fn fact(mut self, fact: impl Into<SharedString>) -> Self {
self.facts.push(fact.into());
self
}
}
impl ParentElement for MessageFooter {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.actions.extend(elements);
}
}
impl RenderOnce for MessageFooter {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.children(self.actions)
.when(!self.facts.is_empty(), |row| {
row.child(div().child(self.facts.join(" · ")))
})
}
}
/// One message: yours in a quiet bubble on the right, the assistant's as prose beside its mark, a system note centered and small, an error beside a warning, its body an `ErrorMessage`. A header, a footer and an avatar are optional.
#[derive(IntoElement)]
pub struct MessageBubble {
role: Role,
avatar: Option<MessageAvatar>,
header: Option<MessageHeader>,
footer: Option<MessageFooter>,
body: SmallVec<[AnyElement; 2]>,
}
impl MessageBubble {
pub fn new(role: Role) -> Self {
Self {
role,
avatar: None,
header: None,
footer: None,
body: SmallVec::new(),
}
}
pub fn avatar(mut self, avatar: MessageAvatar) -> Self {
self.avatar = Some(avatar);
self
}
pub fn header(mut self, header: MessageHeader) -> Self {
self.header = Some(header);
self
}
pub fn footer(mut self, footer: MessageFooter) -> Self {
self.footer = Some(footer);
self
}
}
impl ParentElement for MessageBubble {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for MessageBubble {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let body = div()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Base))
.line_height(relative(1.6))
.children(self.body);
match self.role {
Role::System => div()
.w_full()
.flex()
.justify_center()
.py_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(body.text_size(theme.text_size(TextSize::Xs)))
.into_any_element(),
Role::User => div()
.w_full()
.flex()
.justify_end()
.gap_3()
.child(
div()
.max_w(theme.container_width(ContainerSize::Sm))
.flex()
.flex_col()
.items_end()
.gap_1()
.children(self.header)
.child(
div()
.px_4()
.py_2p5()
.rounded(theme.radius(Radius::Xl))
.bg(colors.hover)
.text_color(colors.fg)
.child(body),
)
.children(self.footer),
)
.children(self.avatar)
.into_any_element(),
Role::Assistant | Role::Error => div()
.w_full()
.flex()
.gap_3()
.children(self.avatar)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_1()
.children(self.header)
.child(body.text_color(colors.fg))
.children(self.footer),
)
.into_any_element(),
}
}
}
/// A conversation's frame: its messages in a column sized for reading, the composer held at the bottom.
#[derive(IntoElement)]
pub struct ChatContainer {
messages: AnyElement,
composer: Option<AnyElement>,
}
impl ChatContainer {
pub fn new(messages: impl IntoElement) -> Self {
Self {
messages: messages.into_any_element(),
composer: None,
}
}
pub fn composer(mut self, composer: impl IntoElement) -> Self {
self.composer = Some(composer.into_any_element());
self
}
}
impl RenderOnce for ChatContainer {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let column = theme.container_width(ContainerSize::Md);
div()
.size_full()
.flex()
.flex_col()
.bg(theme.colors.bg)
.child(div().flex_1().min_h_0().child(self.messages))
.children(self.composer.map(|composer| {
div()
.flex_none()
.w_full()
.flex()
.justify_center()
.px_4()
.pb_4()
.child(div().w_full().max_w(column).child(composer))
}))
}
}
+26
View File
@@ -0,0 +1,26 @@
mod actions;
mod code;
mod list;
mod media;
mod message;
mod play;
mod status;
mod stream;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use actions::{
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
stop_button,
};
pub use code::CodeBlock;
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
pub use message::{
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
};
pub use play::{AudioMessage, VideoMessage};
pub use status::{
ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator,
};
pub use stream::{StreamingCursor, StreamingMarkdown, StreamingText};
+300
View File
@@ -0,0 +1,300 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
relative,
};
use super::media::fitted;
use crate::{
buttons::{ButtonVariant, IconButton},
documents::source,
forms::OnFlag,
primitives::{Icon, IconName, Image},
theme::{ActiveTheme, ContainerSize, ControlSize, IconSize, Radius, TextSize},
typography::{DurationStyle, format::duration, tabular},
};
type OnSeek = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// A clip's time as it plays: what played and the length, in clock digits.
fn clock(played: Duration, length: Duration) -> String {
format!(
"{} / {}",
duration(played.as_secs(), DurationStyle::Clock),
duration(length.as_secs(), DurationStyle::Clock)
)
}
/// A voice note or a song: play and pause, a waveform lit as far as it has played, and the time; a press on the waveform seeks there. The host plays it.
#[derive(IntoElement)]
pub struct AudioMessage {
id: ElementId,
peaks: Rc<Vec<f32>>,
length: Duration,
played: Duration,
playing: bool,
on_toggle: Option<OnFlag>,
on_seek: Option<OnSeek>,
}
impl AudioMessage {
/// `peaks` are the waveform's heights, 0 to 1, left to right.
pub fn new(id: impl Into<ElementId>, peaks: impl Into<Vec<f32>>, length: Duration) -> Self {
let peaks: Vec<f32> = peaks.into();
assert!(!peaks.is_empty(), "a waveform has peaks");
assert!(
peaks.iter().all(|peak| (0.0..=1.0).contains(peak)),
"peaks lie within 0 to 1"
);
Self {
id: id.into(),
peaks: Rc::new(peaks),
length,
played: Duration::ZERO,
playing: false,
on_toggle: None,
on_seek: None,
}
}
/// How far it has played, and whether it plays now.
pub fn played(mut self, played: Duration, playing: bool) -> Self {
self.played = played.min(self.length);
self.playing = playing;
self
}
/// Gets true to play, false to pause.
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
/// Gets the share of the clip to seek to.
pub fn on_seek(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
self.on_seek = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AudioMessage {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let share = if self.length.is_zero() {
0.0
} else {
self.played.as_secs_f32() / self.length.as_secs_f32()
};
let count = self.peaks.len();
let playing = self.playing;
let tall = theme.control_height(ControlSize::Md);
div()
.w(theme.prose_width())
.flex()
.items_center()
.gap_3()
.px_2()
.py_1p5()
.rounded_full()
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.children(self.on_toggle.map(|toggle| {
IconButton::new(
(self.id.clone(), "toggle"),
if playing {
IconName::Pause
} else {
IconName::Play
},
)
.variant(ButtonVariant::Ghost)
.tooltip(if playing { "Pause" } else { "Play" })
.on_click(move |_, window, cx| {
log::info!("audio message: playing {}", !playing);
toggle(!playing, window, cx)
})
}))
.child(
div()
.flex_1()
.h(tall)
.flex()
.items_center()
.gap_px()
.children(self.peaks.iter().enumerate().map(|(ix, peak)| {
let seek = self.on_seek.clone();
let lit = (ix as f32 + 0.5) / count as f32 <= share;
div()
.id((self.id.clone(), format!("bar-{ix}")))
.flex_1()
.h_full()
.flex()
.items_center()
.when_some(seek, |bar, seek| {
bar.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
seek((ix as f32 + 0.5) / count as f32, window, cx)
})
})
.child(
div()
.w_full()
.h(tall * peak.max(0.08))
.rounded_full()
.bg(if lit { colors.accent } else { colors.fg_subtle }),
)
})),
)
.child(
tabular(
div()
.pr_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted),
)
.child(clock(self.played, self.length)),
)
}
}
/// A video in a message: the frame the host shows, its poster or the playing frame, with play and pause over it and the time. The host decodes and plays it.
#[derive(IntoElement)]
pub struct VideoMessage {
id: ElementId,
frame: SharedString,
size: (f32, f32),
length: Duration,
played: Duration,
playing: bool,
on_toggle: Option<OnFlag>,
}
impl VideoMessage {
/// `frame` is the picture to show, a file or a web address; `width` and `height` the video's pixels.
pub fn new(
id: impl Into<ElementId>,
frame: impl Into<SharedString>,
width: f32,
height: f32,
length: Duration,
) -> Self {
Self {
id: id.into(),
frame: frame.into(),
size: (width, height),
length,
played: Duration::ZERO,
playing: false,
on_toggle: None,
}
}
pub fn played(mut self, played: Duration, playing: bool) -> Self {
self.played = played.min(self.length);
self.playing = playing;
self
}
/// Gets true to play, false to pause.
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VideoMessage {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let widest = f32::from(
theme
.container_width(ContainerSize::Sm)
.to_pixels(window.rem_size()),
);
let (width, height) = fitted(self.size.0, self.size.1, widest);
let share = if self.length.is_zero() {
0.0
} else {
self.played.as_secs_f32() / self.length.as_secs_f32()
};
let playing = self.playing;
let toggle = self.on_toggle.clone();
div()
.id(self.id.clone())
.relative()
.w(Pixels::from(width))
.h(Pixels::from(height))
.bg(colors.media_backdrop)
.when_some(toggle, |video, toggle| {
video.cursor_pointer().on_click(move |_, window, cx| {
log::info!("video message: playing {}", !playing);
toggle(!playing, window, cx)
})
})
.child(
Image::new((self.id.clone(), "frame"), source(&self.frame))
.fit(ObjectFit::Contain)
.size_full(),
)
.when(!playing, |video| {
video.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.child(
div()
.size(theme.fab_size())
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(colors.media_backdrop.opacity(0.6))
.child(
Icon::new(IconName::Play)
.size(IconSize::Lg)
.color(colors.on_media),
),
),
)
})
.child(
div()
.absolute()
.left_0()
.right_0()
.bottom_0()
.flex()
.flex_col()
.child(
div().flex().justify_end().px_2().pb_1().child(
tabular(
div()
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.bg(colors.media_backdrop.opacity(0.6))
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.on_media),
)
.child(clock(self.played, self.length)),
),
)
.child(
div()
.h(theme.progress_thickness())
.w_full()
.bg(colors.on_media.opacity(0.25))
.child(div().h_full().w(relative(share)).bg(colors.on_media)),
),
)
}
}
+420
View File
@@ -0,0 +1,420 @@
use std::{
rc::Rc,
time::{Duration, Instant},
};
use gpui::{
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
};
use super::stream::StreamingText;
use crate::{
buttons::{Button, ButtonVariant},
feedback::{Alert, Countdown},
forms::{Choice, ChoiceChips, Input, OnValues, Run, TextInput},
primitives::{Disclosure, Icon, IconName, Severity},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::ShimmerText,
};
/// An answer that failed: what went wrong in a line, a detail, and a way to try again.
#[derive(IntoElement)]
pub struct ErrorMessage {
id: ElementId,
title: SharedString,
detail: Option<SharedString>,
on_retry: Option<Run>,
}
impl ErrorMessage {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
detail: None,
on_retry: None,
}
}
pub fn detail(mut self, text: impl Into<SharedString>) -> Self {
self.detail = Some(text.into());
self
}
pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ErrorMessage {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let alert = Alert::new(self.id.clone(), Severity::Danger, self.title);
let alert = match self.detail {
Some(detail) => alert.body(detail),
None => alert,
};
match self.on_retry {
Some(retry) => alert.action(
Button::new((self.id.clone(), "retry"), "Try again")
.size(ControlSize::Sm)
.icon(IconName::RefreshCw)
.on_click(move |_, window, cx| {
log::info!("error message: retry");
retry(window, cx)
}),
),
None => alert,
}
}
}
/// Out of messages for now: when more come back, counting down, and the owner's way to raise the limit.
#[derive(IntoElement)]
pub struct RateLimitNotice {
id: ElementId,
until: Instant,
on_done: Option<Run>,
on_upgrade: Option<Run>,
}
impl RateLimitNotice {
pub fn new(id: impl Into<ElementId>, until: Instant) -> Self {
Self {
id: id.into(),
until,
on_done: None,
on_upgrade: None,
}
}
/// Runs once the wait is over.
pub fn on_done(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_done = Some(Rc::new(handler));
self
}
pub fn on_upgrade(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_upgrade = Some(Rc::new(handler));
self
}
}
impl RenderOnce for RateLimitNotice {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let countdown = Countdown::new((self.id.clone(), "left"), self.until).size(TextSize::Sm);
let countdown = match self.on_done {
Some(done) => countdown.on_done(move |window, cx| done(window, cx)),
None => countdown,
};
div()
.w_full()
.flex()
.items_center()
.gap_3()
.px_3()
.py_2()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.child(
Icon::new(IconName::Clock)
.size(IconSize::Sm)
.color(colors.warning),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_wrap()
.items_center()
.gap_1()
.child(
div()
.text_color(colors.fg)
.child("You've reached the message limit. More in"),
)
.child(countdown),
)
.children(self.on_upgrade.map(|upgrade| {
Button::new((self.id.clone(), "upgrade"), "Raise the limit")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| upgrade(window, cx))
}))
}
}
/// What went wrong with an answer, after a bad mark: reasons to pick, a note, then send; sending waits for a reason or a note.
#[derive(IntoElement)]
pub struct FeedbackForm {
id: ElementId,
reasons: Vec<SharedString>,
picked: Vec<SharedString>,
note: Entity<TextInput>,
on_pick: OnValues,
on_send: Run,
on_cancel: Option<Run>,
}
impl FeedbackForm {
/// `picked` are the reasons chosen and `on_pick` gets the next set; `note` is the owner's field; `on_send` sends both.
pub fn new(
id: impl Into<ElementId>,
reasons: impl IntoIterator<Item = impl Into<SharedString>>,
picked: impl IntoIterator<Item = impl Into<SharedString>>,
note: &Entity<TextInput>,
on_pick: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
on_send: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
reasons: reasons.into_iter().map(Into::into).collect(),
picked: picked.into_iter().map(Into::into).collect(),
note: note.clone(),
on_pick: Rc::new(on_pick),
on_send: Rc::new(on_send),
on_cancel: None,
}
}
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_cancel = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FeedbackForm {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let ready = !self.picked.is_empty() || !self.note.read(cx).text().trim().is_empty();
let (pick, send) = (self.on_pick, self.on_send);
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child("What went wrong?"),
)
.child(
ChoiceChips::new(
(self.id.clone(), "reasons"),
self.reasons
.iter()
.map(|reason| Choice::new(reason.clone(), reason.clone())),
)
.multiple()
.selected(self.picked.clone())
.on_change(move |next, window, cx| pick(next, window, cx)),
)
.child(Input::new(&self.note))
.child(
div()
.flex()
.justify_end()
.gap_2()
.children(self.on_cancel.map(|cancel| {
Button::new((self.id.clone(), "cancel"), "Cancel")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| cancel(window, cx))
}))
.child(
Button::new((self.id.clone(), "send"), "Send feedback")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.disabled(!ready)
.on_click(move |_, window, cx| {
log::info!("feedback form: sent");
send(window, cx)
}),
),
)
}
}
/// How long thinking took, in words.
pub(crate) fn thought_for(took: Duration) -> String {
match took.as_secs() {
0 => "Thought for a moment".to_string(),
1 => "Thought for 1 second".to_string(),
seconds @ 2..60 => format!("Thought for {seconds} seconds"),
seconds => format!("Thought for {}m {}s", seconds / 60, seconds % 60),
}
}
/// How long thinking took: a quiet line, such as “Thought for 12 seconds”.
#[derive(IntoElement)]
pub struct ThinkingDuration {
took: Duration,
}
impl ThinkingDuration {
pub fn new(took: Duration) -> Self {
Self { took }
}
}
impl RenderOnce for ThinkingDuration {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.text_color(cx.theme().colors.fg_muted)
.child(thought_for(self.took))
}
}
/// Work under way before an answer: a word, such as Thinking, with light sweeping across it; still under reduced motion.
#[derive(IntoElement)]
pub struct ThinkingIndicator {
id: ElementId,
label: SharedString,
}
impl ThinkingIndicator {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
label: "Thinking".into(),
}
}
/// What the work is, such as Searching the web.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
}
impl RenderOnce for ThinkingIndicator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = cx.theme().colors.clone();
div()
.flex()
.items_center()
.gap_1p5()
.child(
Icon::new(IconName::Sparkles)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.child(ShimmerText::new(self.id, self.label))
}
}
/// The reasoning behind an answer, folded under how long it took; open, its steps read in a quiet column. While thinking, it says so and the reasoning streams in.
#[derive(IntoElement)]
pub struct ThinkingBlock {
id: ElementId,
reasoning: SharedString,
thinking: bool,
took: Duration,
}
impl ThinkingBlock {
/// `took` is how long thinking ran, or has run while `thinking`.
pub fn new(
id: impl Into<ElementId>,
reasoning: impl Into<SharedString>,
thinking: bool,
took: Duration,
) -> Self {
Self {
id: id.into(),
reasoning: reasoning.into(),
thinking,
took,
}
}
}
impl RenderOnce for ThinkingBlock {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false);
let opened = *open.read(cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let header: gpui::AnyElement = if self.thinking {
ThinkingIndicator::new((self.id.clone(), "thinking")).into_any_element()
} else {
ThinkingDuration::new(self.took).into_any_element()
};
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.id((self.id.clone(), "header"))
.flex()
.items_center()
.gap_1()
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, _, cx| {
open.update(cx, |open, cx| {
*open = !*open;
log::info!("thinking block: open {open}");
cx.notify();
})
})
.child(header)
.child(
Disclosure::new((self.id.clone(), "chevron"), opened).size(IconSize::Sm),
),
)
.when(opened, |block| {
block.child(
div()
.pl_3()
.border_l_2()
.border_color(colors.border)
.text_color(colors.fg_muted)
.line_height(relative(1.6))
.child(StreamingText::new(
(self.id.clone(), "reasoning"),
self.reasoning,
self.thinking,
)),
)
})
}
}
#[cfg(test)]
mod tests {
use super::thought_for;
use std::time::Duration;
#[test]
fn thinking_time_reads_in_words() {
assert_eq!(
thought_for(Duration::from_millis(400)),
"Thought for a moment"
);
assert_eq!(thought_for(Duration::from_secs(1)), "Thought for 1 second");
assert_eq!(
thought_for(Duration::from_secs(12)),
"Thought for 12 seconds"
);
assert_eq!(thought_for(Duration::from_secs(75)), "Thought for 1m 15s");
}
}
+199
View File
@@ -0,0 +1,199 @@
use std::time::{Duration, Instant};
use gpui::{
App, ElementId, HighlightStyle, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
StyledText, Window, div,
};
use unicode_segmentation::UnicodeSegmentation;
use super::code::CodeBlock;
use crate::{
documents::MarkdownRenderer,
motion::Blink,
theme::{ActiveTheme, Radius, TextSize},
};
/// Graphemes a stream shows per second when it keeps up.
const PACE: f32 = 90.0;
/// The longest a backlog takes to show, however much arrived at once.
const CATCH_UP: Duration = Duration::from_millis(350);
/// The mark where more will come, set inline after the text.
const CARET: &str = "▍";
/// How far a reveal has come, in graphemes with the fraction carried, and when it last moved.
struct Reveal {
shown: f32,
at: Instant,
}
/// Graphemes shown after `elapsed`, from `shown` toward `total`: a steady pace, faster when far behind.
pub(crate) fn advanced(shown: f32, total: usize, elapsed: Duration) -> f32 {
let behind = total as f32 - shown;
if behind <= 0.0 {
return total as f32;
}
let rate = PACE.max(behind / CATCH_UP.as_secs_f32());
(shown + rate * elapsed.as_secs_f32()).min(total as f32)
}
/// Where the first `count` graphemes of `text` end, in bytes.
pub(crate) fn grapheme_end(text: &str, count: usize) -> usize {
text.grapheme_indices(true)
.nth(count)
.map_or(text.len(), |(at, _)| at)
}
/// How much of `text` shows now, in bytes: a live stream reveals from its first frame, a finished one shows whole. Asks for frames until caught up; under reduced motion, all at once.
fn revealed(id: &ElementId, text: &str, live: bool, window: &mut Window, cx: &mut App) -> usize {
let total = text.graphemes(true).count();
let now = cx.background_executor().now();
let state = window.use_keyed_state((id.clone(), "reveal"), cx, |_, _| Reveal {
shown: if live { 0.0 } else { total as f32 },
at: now,
});
let (shown, at) = (state.read(cx).shown.min(total as f32), state.read(cx).at);
let shown = if cx.theme().reduced_motion {
total as f32
} else {
advanced(shown, total, now.saturating_duration_since(at))
};
state.update(cx, |reveal, _| *reveal = Reveal { shown, at: now });
if (shown as usize) < total {
window.request_animation_frame();
}
grapheme_end(text, shown as usize)
}
/// Text as it arrives: what came in shows at a steady pace, catching up when far behind, with a caret while more may come.
#[derive(IntoElement)]
pub struct StreamingText {
id: ElementId,
text: SharedString,
live: bool,
}
impl StreamingText {
/// `live` while more may arrive; a finished message shows whole.
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>, live: bool) -> Self {
Self {
id: id.into(),
text: text.into(),
live,
}
}
}
impl RenderOnce for StreamingText {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let end = revealed(&self.id, &self.text, self.live, window, cx);
let caret = cx.theme().colors.fg_muted;
let shown = if self.live {
format!("{}{CARET}", &self.text[..end])
} else {
self.text[..end].to_string()
};
let styles = self
.live
.then(|| {
(
end..end + CARET.len(),
HighlightStyle {
color: Some(caret),
..HighlightStyle::default()
},
)
})
.into_iter();
StyledText::new(shown).with_highlights(styles)
}
}
/// Where more will come before any text has: a small block that blinks while it waits, still under reduced motion.
#[derive(IntoElement)]
pub struct StreamingCursor {
id: ElementId,
}
impl StreamingCursor {
pub fn new(id: impl Into<ElementId>) -> Self {
Self { id: id.into() }
}
}
impl RenderOnce for StreamingCursor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let size = theme.text_size(TextSize::Base);
Blink::new(self.id).child(
div()
.w(size * 0.5)
.h(size * 1.2)
.rounded(theme.radius(Radius::Sm))
.bg(theme.colors.fg_muted),
)
}
}
/// Markdown as it arrives, revealed like streaming text: headings, lists, tables and math take shape as their lines land, code in code blocks.
#[derive(IntoElement)]
pub struct StreamingMarkdown {
id: ElementId,
source: SharedString,
live: bool,
}
impl StreamingMarkdown {
/// `live` while more may arrive; a finished message shows whole.
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>, live: bool) -> Self {
Self {
id: id.into(),
source: source.into(),
live,
}
}
}
impl RenderOnce for StreamingMarkdown {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let end = revealed(&self.id, &self.source, self.live, window, cx);
let shown = if self.live {
format!("{}{CARET}", &self.source[..end])
} else {
self.source[..end].to_string()
};
MarkdownRenderer::new((self.id.clone(), "text"), shown).code(|id, language, code| {
let block = CodeBlock::new(id, code);
match language {
Some(language) => block.language(language),
None => block,
}
.into_any_element()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reveal_keeps_pace_and_catches_up() {
let tick = Duration::from_millis(100);
assert_eq!(
advanced(0.0, 5, tick),
5.0,
"a short line shows within a tick"
);
let long = advanced(0.0, 1000, tick);
assert!(long > PACE * 0.1, "far behind, it speeds up: {long}");
assert!(long < 1000.0, "but still reveals: {long}");
assert_eq!(advanced(12.0, 10, tick), 10.0, "shorter text clamps");
}
#[test]
fn a_count_of_graphemes_ends_on_a_boundary() {
assert_eq!(grapheme_end("héllo", 2), 3);
assert_eq!(grapheme_end("👩‍🚀 ok", 1), "👩‍🚀".len());
assert_eq!(grapheme_end("ok", 9), 2);
}
}
+164
View File
@@ -0,0 +1,164 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
AppContext as _, Context, Entity, Focusable, IntoElement, Modifiers, ParentElement, Render,
ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window,
div, point, px,
};
use super::{MessageEditor, MessageList};
use crate::{forms, forms::TextInput, theme::Theme};
type Seen = Rc<RefCell<Vec<usize>>>;
/// A conversation of tall messages, noting which ones each frame drew.
struct Talk {
count: usize,
seen: Seen,
}
impl Render for Talk {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let seen = self.seen.clone();
seen.borrow_mut().clear();
div()
.size_full()
.child(MessageList::new("talk", self.count, move |ix, _, _| {
seen.borrow_mut().push(ix);
div().h(px(80.0)).w_full().into_any_element()
}))
}
}
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn newest(seen: &Seen) -> usize {
*seen.borrow().iter().max().expect("a frame draws messages")
}
#[gpui::test]
fn the_list_follows_the_newest_until_the_reader_scrolls_away(cx: &mut TestAppContext) {
cx.update(Theme::init);
let seen = Seen::default();
let noted = seen.clone();
let (talk, cx) = cx.add_window_view(move |_, _| Talk {
count: 40,
seen: noted,
});
settle(cx);
assert_eq!(newest(&seen), 39, "it opens at the newest");
talk.update(cx, |talk, cx| {
talk.count = 42;
cx.notify();
});
settle(cx);
assert_eq!(newest(&seen), 41, "at the newest, it follows");
let middle = cx.update(|window, _| {
let size = window.viewport_size();
point(size.width / 2.0, size.height / 2.0)
});
cx.simulate_event(ScrollWheelEvent {
position: middle,
delta: ScrollDelta::Pixels(point(px(0.0), px(1200.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
settle(cx);
let away = newest(&seen);
assert!(away < 41, "scrolled up to {away}");
talk.update(cx, |talk, cx| {
talk.count = 45;
cx.notify();
});
settle(cx);
assert_eq!(newest(&seen), away, "away, it holds its place");
let button = cx.update(|window, _| {
let size = window.viewport_size();
point(size.width / 2.0, size.height - px(28.0))
});
cx.simulate_click(button, Modifiers::none());
settle(cx);
assert_eq!(newest(&seen), 44, "the button goes back to the newest");
}
/// A sent message open to change, and what the reader asked.
struct Edit {
field: Entity<TextInput>,
saved: usize,
cancelled: usize,
}
impl Render for Edit {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (save, cancel) = (cx.entity(), cx.entity());
div().size_full().child(MessageEditor::new(
"edit",
&self.field,
"Lift blends toward white.",
move |_, cx| save.update(cx, |edit, _| edit.saved += 1),
move |_, cx| cancel.update(cx, |edit, _| edit.cancelled += 1),
))
}
}
#[gpui::test]
fn an_edit_saves_only_a_change_and_escape_cancels(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
});
let (edit, cx) = cx.add_window_view(|window, cx| Edit {
field: cx.new(|cx| {
let mut input = TextInput::new(window, cx);
input.set_text("Lift blends toward white.", cx);
input
}),
saved: 0,
cancelled: 0,
});
cx.update(|window, cx| {
window.activate_window();
let field = edit.read(cx).field.focus_handle(cx);
window.focus(&field);
});
settle(cx);
cx.simulate_keystrokes("cmd-enter");
settle(cx);
assert_eq!(
edit.read_with(cx, |edit, _| edit.saved),
0,
"nothing changed"
);
cx.simulate_input(" Gently.");
settle(cx);
cx.simulate_keystrokes("cmd-enter");
settle(cx);
assert_eq!(edit.read_with(cx, |edit, _| edit.saved), 1);
cx.simulate_keystrokes("escape");
assert_eq!(edit.read_with(cx, |edit, _| edit.cancelled), 1);
}
#[gpui::test]
fn the_editor_takes_focus_as_it_opens(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
});
let (edit, cx) = cx.add_window_view(|window, cx| Edit {
field: cx.new(|cx| TextInput::new(window, cx)),
saved: 0,
cancelled: 0,
});
cx.update(|window, _| window.activate_window());
settle(cx);
cx.simulate_keystrokes("escape");
assert_eq!(
edit.read_with(cx, |edit, _| edit.cancelled),
1,
"Escape reaches the editor"
);
}
+21
View File
@@ -17,6 +17,7 @@ use crate::{
};
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
/// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host.
#[derive(IntoElement)]
@@ -26,6 +27,7 @@ pub struct MarkdownRenderer {
on_link: Option<OnLink>,
size: TextSize,
sections: Option<Entity<Sections>>,
code: Option<DrawCode>,
}
impl MarkdownRenderer {
@@ -36,6 +38,7 @@ impl MarkdownRenderer {
on_link: None,
size: TextSize::Base,
sections: None,
code: None,
}
}
@@ -51,6 +54,15 @@ impl MarkdownRenderer {
self
}
/// Draws code blocks with the owner's element, given an id, the language and the code.
pub fn code(
mut self,
draw: impl Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement + 'static,
) -> Self {
self.code = Some(Rc::new(draw));
self
}
/// Takes link presses instead of the browser.
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
self.on_link = Some(Rc::new(handler));
@@ -65,6 +77,7 @@ struct Draw<'a> {
next: std::cell::Cell<usize>,
sections: &'a Option<Entity<Sections>>,
headings: std::cell::Cell<usize>,
code: &'a Option<DrawCode>,
}
impl Draw<'_> {
@@ -192,6 +205,13 @@ impl Draw<'_> {
.children(self.nodes(inner, cx))
.into_any_element(),
Node::Code { language, text } => {
if let Some(draw) = self.code {
return draw(
self.key("code"),
language.clone().map(Into::into),
text.clone().into(),
);
}
let styles = code_colors(text, cx);
div()
.flex()
@@ -406,6 +426,7 @@ impl RenderOnce for MarkdownRenderer {
next: std::cell::Cell::new(0),
sections: &self.sections,
headings: std::cell::Cell::new(0),
code: &self.code,
};
div()
.flex()
+1
View File
@@ -2,6 +2,7 @@ mod assets;
pub mod buttons;
pub mod charts;
pub mod chat;
pub mod collab;
pub mod data_display;
pub mod debug;
+33 -33
View File
@@ -76,39 +76,39 @@
## T23a AI Chat · Messages — `src/chat`
- [ ] ChatContainer
- [ ] MessageList — virtualized, stick to bottom, new-message hint
- [ ] MessageBubble — user, assistant, system, error
- [ ] MessageAvatar
- [ ] MessageHeader
- [ ] MessageFooter
- [ ] StreamingText
- [ ] StreamingMarkdown
- [ ] StreamingCursor
- [ ] CodeBlock — language, copy, download, run, fold, apply
- [ ] MathBlock → `typography::Latex`
- [ ] TableBlock → `documents::MarkdownRenderer`
- [ ] MermaidRenderer → `documents::MermaidBlock`
- [ ] ImageMessage / ImageGrid
- [ ] FileMessage
- [ ] AudioMessage
- [ ] VideoMessage — host frames
- [ ] LinkPreviewCard
- [ ] QuoteReply
- [ ] MessageActions
- [ ] MessageEditor
- [ ] BranchNavigator
- [ ] RegenerateButton
- [ ] StopGeneratingButton
- [ ] ContinueButton
- [ ] ErrorMessage / RetryMessage
- [ ] RateLimitNotice
- [ ] FeedbackForm
- [ ] ScrollToBottomButton
- [ ] DateSeparator
- [ ] ThinkingBlock / ReasoningPanel
- [ ] ThinkingIndicator
- [ ] ThinkingDuration
- [x] ChatContainer
- [x] MessageList — virtualized, stick to bottom, new-message hint
- [x] MessageBubble — user, assistant, system, error
- [x] MessageAvatar
- [x] MessageHeader
- [x] MessageFooter
- [x] StreamingText
- [x] StreamingMarkdown
- [x] StreamingCursor
- [x] CodeBlock — language, copy, download, run, fold, apply
- [x] MathBlock → `typography::Latex`
- [x] TableBlock → `documents::MarkdownRenderer`
- [x] MermaidRenderer → the host draws the SVG; `chat::ImageMessage` shows it
- [x] ImageMessage / ImageGrid
- [x] FileMessage
- [x] AudioMessage
- [x] VideoMessage — host frames
- [x] LinkPreviewCard
- [x] QuoteReply
- [x] MessageActions
- [x] MessageEditor
- [x] BranchNavigator
- [x] RegenerateButton → `chat::regenerate_button`
- [x] StopGeneratingButton → `chat::stop_button`
- [x] ContinueButton → `chat::continue_button`
- [x] ErrorMessage / RetryMessage → `chat::ErrorMessage`, over `feedback::Alert`
- [x] RateLimitNotice
- [x] FeedbackForm
- [x] ScrollToBottomButton
- [x] DateSeparator
- [x] ThinkingBlock / ReasoningPanel → `chat::ThinkingBlock`
- [x] ThinkingIndicator
- [x] ThinkingDuration
## T23b AI Chat · Citations