T23c: AI chat, input

- PromptInput: attachments and context above the field, the owner's tools and
  send below; Enter sends, Shift-Enter breaks the line; / offers commands and
  @ offers context over forms::Suggestions; files dropped on it attach behind a
  drop veil
- AttachmentButton, AttachmentChip with thumbnails and uploads, ContextChips
- VoiceInputButton and VoiceWaveform with a red dot and the time
- ParameterPanel, SystemPromptEditor, TokenCounter, CostEstimator
- forms: a Newline action on Shift-Enter keeps one-line fields to one line;
  replace_trigger moves beside Suggestions; choose and dropped shared
- gallery: composer, pickers and tuning
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 06:29:20 -04:00
1 parent fce38dec00
commit d362f08d41
17 files changed
+1562 -38

No files matched your search

+406
View File
@@ -0,0 +1,406 @@
use std::{path::PathBuf, rc::Rc};
use gpui::{
AnyElement, App, ElementId, Entity, ExternalPaths, InteractiveElement, IntoElement,
ParentElement, PathPromptOptions, RenderOnce, SharedString, Styled, Window, div,
};
use smallvec::SmallVec;
use crate::{
buttons::{ButtonVariant, IconButton},
forms::{
Choice, Enter, OnValue, Pick, Run, Suggestions, TextInput, active_trigger, choose, dropped,
replace_trigger,
},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::Kbd,
};
type OnPaths = Rc<dyn Fn(Vec<PathBuf>, &mut Window, &mut App)>;
/// Opens the system's file dialog to attach files; `on_pick` gets what was chosen.
#[derive(IntoElement)]
pub struct AttachmentButton {
id: ElementId,
on_pick: OnPaths,
}
impl AttachmentButton {
pub fn new(
id: impl Into<ElementId>,
on_pick: impl Fn(Vec<PathBuf>, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
on_pick: Rc::new(on_pick),
}
}
}
impl RenderOnce for AttachmentButton {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let pick = self.on_pick;
IconButton::new(self.id, IconName::Paperclip)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Attach files")
.on_click(move |_, window, cx| {
let pick = pick.clone();
let options = PathPromptOptions {
files: true,
directories: false,
multiple: true,
prompt: Some("Attach".into()),
};
choose(
"attachment button".into(),
options,
window,
cx,
move |paths, window, cx| pick(paths, window, cx),
);
})
}
}
/// Sends a message once there is one, or stops the answer while it streams.
#[derive(IntoElement)]
pub struct SendButton {
id: ElementId,
ready: bool,
on_send: Run,
on_stop: Option<Run>,
}
impl SendButton {
pub fn new(
id: impl Into<ElementId>,
ready: bool,
on_send: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
ready,
on_send: Rc::new(on_send),
on_stop: None,
}
}
/// While an answer streams: the button stops it.
pub fn busy(mut self, on_stop: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_stop = Some(Rc::new(on_stop));
self
}
}
impl RenderOnce for SendButton {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
match self.on_stop {
Some(stop) => IconButton::new(self.id, IconName::Square)
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.tooltip("Stop")
.on_click(move |_, window, cx| {
log::info!("send button: stop");
stop(window, cx)
}),
None => {
let send = self.on_send;
IconButton::new(self.id, IconName::ArrowUp)
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.tooltip("Send")
.disabled(!self.ready)
.on_click(move |_, window, cx| {
log::info!("send button: send");
send(window, cx)
})
}
}
}
}
/// The keys a composer takes, said quietly: Enter sends, Shift-Enter breaks the line.
#[derive(IntoElement)]
pub struct InputHint;
impl RenderOnce for InputHint {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.flex_wrap()
.items_center()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(Kbd::new("enter"))
.child("to send ·")
.child(Kbd::new("shift-enter"))
.child("for a new line")
}
}
/// While files hover the composer, a veil over it that says a drop attaches them; `group` names the composer.
#[derive(IntoElement)]
pub struct DragDropOverlay {
group: SharedString,
}
impl DragDropOverlay {
pub fn new(group: impl Into<SharedString>) -> Self {
Self {
group: group.into(),
}
}
}
impl RenderOnce for DragDropOverlay {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.absolute()
.inset_0()
.invisible()
.group_drag_over::<ExternalPaths>(self.group, |style| style.visible())
.flex()
.items_center()
.justify_center()
.gap_2()
.rounded(theme.radius(Radius::Xl))
.border_1()
.border_dashed()
.border_color(colors.focus)
.bg(colors.surface.opacity(0.94))
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::Upload)
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child("Drop to attach")
}
}
/// The rows a composer offers at `/` or `@`, and what a pick does.
fn offers(
id: ElementId,
field: &Entity<TextInput>,
commands: &[Choice],
context: &[Choice],
on_command: Option<OnValue>,
on_mention: Option<OnValue>,
cx: &App,
) -> Suggestions {
let input = field.read(cx);
let (text, caret) = (input.text().to_string(), input.cursor());
let active =
active_trigger(&text, caret, &['/', '@']).filter(|(at, mark)| *mark == '@' || *at == 0);
let query = active.map_or(String::new(), |(at, mark)| {
text[at + mark.len_utf8()..caret].to_lowercase()
});
let (listed, run) = match active {
Some((_, '/')) => (commands, on_command),
Some(_) => (context, on_mention),
None => (&[][..], None),
};
let found: Vec<Choice> = listed
.iter()
.filter(|choice| choice.label.to_lowercase().contains(&query))
.cloned()
.collect();
let rows = if run.is_some() {
found.clone()
} else {
Vec::new()
};
let (state, field) = (field.clone(), field.clone());
let pick: Pick = Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
let (at, caret) = (
active.expect("rows show at a trigger").0,
field.read(cx).cursor(),
);
replace_trigger(&field, at, caret, "", cx);
log::info!("composer: picked {}", found[ix].value);
if let Some(run) = &run {
run(&found[ix].value, window, cx);
}
});
Suggestions {
id,
state,
trigger: active.map(|(at, _)| at),
rows,
pick,
}
}
/// Where a message is written: its attachments and context above, the field, and a row of the owner's tools with send. Enter sends, Shift-Enter breaks the line; `/` at the start offers commands and `@` offers context; files dropped on it attach.
#[derive(IntoElement)]
pub struct PromptInput {
id: ElementId,
field: Entity<TextInput>,
on_send: Run,
on_stop: Option<Run>,
above: SmallVec<[AnyElement; 2]>,
tools: SmallVec<[AnyElement; 4]>,
commands: (Vec<Choice>, Option<OnValue>),
context: (Vec<Choice>, Option<OnValue>),
on_drop: Option<OnPaths>,
}
impl PromptInput {
/// `field` is the owner's multi-line text; `on_send` sends it.
pub fn new(
id: impl Into<ElementId>,
field: &Entity<TextInput>,
on_send: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
field: field.clone(),
on_send: Rc::new(on_send),
on_stop: None,
above: SmallVec::new(),
tools: SmallVec::new(),
commands: (Vec::new(), None),
context: (Vec::new(), None),
on_drop: None,
}
}
/// While an answer streams: send becomes stop.
pub fn busy(mut self, on_stop: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_stop = Some(Rc::new(on_stop));
self
}
/// Shown over the field, such as attachments, context and a quote.
pub fn above(mut self, element: impl IntoElement) -> Self {
self.above.push(element.into_any_element());
self
}
/// A tool in the row under the field, such as attach or the model.
pub fn tool(mut self, element: impl IntoElement) -> Self {
self.tools.push(element.into_any_element());
self
}
/// Commands offered at `/` at the start; `on_command` gets the one picked.
pub fn commands(
mut self,
commands: impl IntoIterator<Item = Choice>,
on_command: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.commands = (commands.into_iter().collect(), Some(Rc::new(on_command)));
self
}
/// Context offered at `@`; `on_mention` gets the one picked.
pub fn context(
mut self,
context: impl IntoIterator<Item = Choice>,
on_mention: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.context = (context.into_iter().collect(), Some(Rc::new(on_mention)));
self
}
/// Takes files dropped on the composer.
pub fn on_drop(
mut self,
handler: impl Fn(Vec<PathBuf>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_drop = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PromptInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let ready = !self.field.read(cx).text().trim().is_empty();
let focused = self.field.read(cx).focus().contains_focused(window, cx);
let group = SharedString::from(format!("composer-{}", self.id));
let (send, enter) = (self.on_send.clone(), self.on_send.clone());
let suggestions = offers(
(self.id.clone(), "offers").into(),
&self.field,
&self.commands.0,
&self.context.0,
self.commands.1.clone(),
self.context.1.clone(),
cx,
);
let button = SendButton::new((self.id.clone(), "send"), ready, move |window, cx| {
send(window, cx)
});
let button = match self.on_stop.clone() {
Some(stop) => button.busy(move |window, cx| stop(window, cx)),
None => button,
};
let busy = self.on_stop.is_some();
let body = div()
.id(self.id.clone())
.group(group.clone())
.relative()
.w_full()
.flex()
.flex_col()
.gap_2()
.p_3()
.rounded(theme.radius(Radius::Xl))
.border_1()
.border_color(if focused {
colors.focus
} else {
colors.border_strong
})
.bg(colors.surface)
.children(self.above)
.child(
suggestions.wrap(
div()
.capture_action(move |_: &Enter, window, cx| {
cx.stop_propagation();
if ready && !busy {
log::info!("composer: sent with Enter");
enter(window, cx);
}
})
.text_size(theme.text_size(TextSize::Base))
.child(self.field.clone()),
window,
cx,
),
)
.child(
div()
.flex()
.items_center()
.gap_1()
.children(self.tools)
.child(div().flex_1())
.child(button),
);
match self.on_drop {
Some(take) => body
.on_drop(move |paths: &ExternalPaths, window, cx| {
match dropped(paths.paths(), true) {
Ok(paths) => {
log::info!("composer: {} files dropped", paths.len());
take(paths, window, cx)
}
Err(reason) => log::info!("composer: refused a drop: {reason}"),
}
})
.child(DragDropOverlay::new(group)),
None => body,
}
}
}