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:
1 parent
03f6ceb583
commit
c0afce2507
21 files changed
+3376
-34
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
¬e,
|
||||
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))),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user