Files
Ely-GPUI-Components/src/chat/actions.rs
T
ZacharyZhang-NY 4c3ed1bbb9 Port · Build: Zed's gpui at 1a28cff
gpui and gpui_platform now come from Zed's repo by git at 1a28cff,
the code gpui-pre 0.3.7 republishes, so the gallery can later build
for the browser. gpui takes stacker, as 0.2.2 always did, and
gpui_platform takes font-kit, without which macOS draws no text;
runtime_shaders moves to gpui_platform.

The API moved: focus, blur and Tab steps take cx, Corner is Anchor,
shaped lines paint with an alignment, defer_draw takes a content
mask, the inspector registers a factory, async updates return their
value, and wrapped lines are shared, not cloned. Updates through a
window context go through the window, so a closed window still ends
them as it did.

Where the new gpui behaves differently, Ely keeps its old intent.
Holds and reveals under a resting pointer survive typing through
gpui's input-modality-independent hover; previews the keyboard also
moves keep the default, and the calendar's leave ignores a key press.
Enter reaches a button inside the code editor, since gpui now arms a
key click on the press. The event logger skips a lone modifier. A
video and an image grid keep their shape in a flex column under
taffy 0.13, as a picture does.

AGENTS.md records the new stack and the rules this port changes; the
rest of the old gpui and taffy rules are re-tested in T44c.
2026-09-28 20:30:17 -04:00

376 lines
12 KiB
Rust

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()
.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), 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()
.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),
))
}
}