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

+1 -1
View File
@@ -74,7 +74,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T22 Collaboration
- [x] T23a AI Chat · Messages
- [x] T23b AI Chat · Citations
- [ ] T23c AI Chat · Input
- [x] T23c AI Chat · Input
- [ ] T23d AI Chat · Conversations
- [ ] T23e AI Chat · Welcome
- [ ] T24 Agent
+321
View File
@@ -0,0 +1,321 @@
use std::time::Instant;
use ely_gpui_component::{
buttons::SegmentedControl,
chat::{
Attachment, AttachmentButton, AttachmentChip, ContextChips, CostEstimator, InputHint,
Parameter, ParameterPanel, PromptInput, SystemPromptEditor, TokenCounter, VoiceInputButton,
VoiceWaveform,
},
data_display::UsageBar,
forms::{Choice, Select, TextInput},
menus::{DropdownMenu, Menu, MenuItem},
primitives::IconName,
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, noise, section, set},
};
macro_rules! asset {
($name:literal) => {
concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/",
$name
)
};
}
fn field(key: &'static str, text: &str, window: &mut Window, cx: &mut App) -> Entity<TextInput> {
let text = text.to_string();
window.use_keyed_state(key, cx, move |window, cx| {
let mut input = TextInput::new(window, cx)
.multi_line(1, 8)
.placeholder("Ask anything, / for commands, @ for context");
input.set_text(text, cx);
input
})
}
/// A rough count for the demo; a host counts with its model's tokenizer.
fn tokens(text: &str) -> usize {
text.chars().count().div_ceil(4)
}
fn attachments() -> Vec<Attachment> {
vec![
Attachment {
key: "olive".into(),
name: "atrium-olive.jpg".into(),
bytes: 184_000,
preview: Some(asset!("atrium-olive.jpg").into()),
progress: None,
},
Attachment {
key: "notes".into(),
name: "lift-notes.pdf".into(),
bytes: 482_000,
preview: None,
progress: Some(0.6),
},
]
}
pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let draft = field(
"chat-compose",
"Compare these two lifts in dark mode",
window,
cx,
);
let files = keep("chat-attachments", attachments, window, cx);
let context = keep(
"chat-context",
|| {
vec![
(SharedString::from("Color page"), IconName::Globe),
(SharedString::from("Selection in lift.rs"), IconName::Code),
]
},
window,
cx,
);
let busy = keep("chat-busy", || false, window, cx);
let recording = keep("chat-recording", || false, window, cx);
let (now_files, now_context, now_busy, now_recording) = (
files.read(cx).clone(),
context.read(cx).clone(),
*busy.read(cx),
*recording.read(cx),
);
let (removed, dropped, picked) = (files.clone(), files.clone(), files.clone());
let chips = div()
.flex()
.flex_wrap()
.gap_2()
.children(now_files.iter().enumerate().map(|(ix, item)| {
let removed = removed.clone();
AttachmentChip::new(("chat-attachment", ix), item.clone()).on_remove(move |_, cx| {
let mut next = removed.read(cx).clone();
next.remove(ix);
set(&removed, next, cx)
})
}));
let context_chips = {
let context = context.clone();
ContextChips::new("chat-context", now_context.clone()).on_remove(move |ix, _, cx| {
let mut next = context.read(cx).clone();
next.remove(ix);
set(&context, next, cx)
})
};
let add = |paths: Vec<std::path::PathBuf>, files: &Entity<Vec<Attachment>>, cx: &mut App| {
let mut next = files.read(cx).clone();
next.extend(paths.iter().map(|path| {
let name = path
.file_name()
.expect("a chosen file has a name")
.to_string_lossy()
.to_string();
Attachment {
key: name.clone().into(),
name: name.into(),
bytes: 0,
preview: None,
progress: Some(0.0),
}
}));
set(files, next, cx)
};
let (sent, stopped, mentioned) = (busy.clone(), busy.clone(), context.clone());
let mut input = PromptInput::new("chat-composer", &draft, move |_, cx| set(&sent, true, cx))
.above(chips)
.above(context_chips)
.tool(AttachmentButton::new("chat-attach", move |paths, _, cx| {
add(paths, &picked, cx)
}))
.tool(VoiceInputButton::new(
"chat-voice",
now_recording,
move |on, _, cx| set(&recording, on, cx),
))
.commands(
[
Choice::new("summarize", "/summarize — shorten the thread"),
Choice::new("translate", "/translate — into another language"),
Choice::new("explain", "/explain — step by step"),
],
|command, _, _| log::info!("gallery: command {command}"),
)
.context(
[
Choice::new("lift.rs", "lift.rs"),
Choice::new("theme.rs", "theme.rs"),
Choice::new("notes", "lift-notes.pdf"),
],
move |item, _, cx| {
let mut next = mentioned.read(cx).clone();
next.push((item.clone(), IconName::FileText));
set(&mentioned, next, cx)
},
)
.on_drop(move |paths, _, cx| add(paths, &dropped, cx));
if now_busy {
input = input.busy(move |_, cx| set(&stopped, false, cx));
}
section(
"PromptInput / Composer / AttachmentButton / AttachmentChip / AttachmentPreview / PasteImagePreview / DragDropOverlay / ContextChips / SlashCommandMenu / ContextMentionMenu / SendButton / InputHint",
"Where a message is written: attachments and context above the field, tools below, send at the end; Enter sends and Shift-Enter breaks the line. A slash at the start offers commands, an at sign offers context, and files dropped on it attach.",
cx,
)
.child(probe(
"chat-composer",
div()
.w(px(640.))
.flex()
.flex_col()
.gap_2()
.child(input)
.child(div().flex().justify_end().child(InputHint)),
))
.child(if now_recording {
div().w(px(640.)).child(VoiceWaveform::new("chat-waveform", *keep("chat-since", Instant::now, window, cx).read(cx), levels())).into_any_element()
} else {
div().into_any_element()
})
}
fn levels() -> Vec<f32> {
let mut wave = noise(11);
(0..40)
.map(|_| (0.15 + 0.85 * wave() as f32).min(1.0))
.collect()
}
pub fn pickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let model = keep("chat-model", || SharedString::from("large"), window, cx);
let mode = keep("chat-mode-pick", || SharedString::from("chat"), window, cx);
let tools = keep("chat-tools", || (true, false, false), window, cx);
let (now_model, now_mode, (web, code, images)) = (
model.read(cx).clone(),
mode.read(cx).clone(),
*tools.read(cx),
);
let flip = |tools: &Entity<(bool, bool, bool)>, which: usize| {
let tools = tools.clone();
move |_: &mut Window, cx: &mut App| {
let (mut web, mut code, mut images) = *tools.read(cx);
match which {
0 => web = !web,
1 => code = !code,
_ => images = !images,
}
set(&tools, (web, code, images), cx)
}
};
section(
"ModelSelector / ModeSelector / ToolToggleMenu / PromptTemplateMenu",
"The model is a forms::Select with a note on each; the mode a buttons::SegmentedControl; tools and saved prompts are menus::DropdownMenu, one with checks and one that inserts a prompt.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_3()
.child(
div().w(px(220.)).child(
Select::new(
"chat-model",
[
Choice { note: Some("Best for hard problems".into()), ..Choice::new("large", "Ely Large") },
Choice { note: Some("Fast for everyday work".into()), ..Choice::new("small", "Ely Small") },
],
)
.selected(now_model)
.on_change(move |next, _, cx| set(&model, next.clone(), cx)),
),
)
.child(
SegmentedControl::new("chat-mode-pick", now_mode)
.segment("chat", "Chat", None)
.segment("research", "Research", Some(IconName::Globe))
.segment("code", "Code", Some(IconName::Code))
.on_change(move |next, _, cx| set(&mode, next.clone(), cx)),
)
.child(DropdownMenu::new(
"chat-tools",
"Tools",
Menu::new()
.item(MenuItem::check("Web search", web).on_click(flip(&tools, 0)))
.item(MenuItem::check("Run code", code).on_click(flip(&tools, 1)))
.item(MenuItem::check("Make images", images).on_click(flip(&tools, 2))),
))
.child(DropdownMenu::new(
"chat-templates",
"Prompts",
Menu::new()
.item(MenuItem::new("Review this code").on_click(|_, _| log::info!("gallery: template review")))
.item(MenuItem::new("Explain like a textbook").on_click(|_, _| log::info!("gallery: template explain"))),
)),
)
}
pub fn tuning(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let values = keep("chat-params", || (0.7, 0.95, 1024.0), window, cx);
let (temperature, top_p, longest) = *values.read(cx);
let system = window.use_keyed_state("chat-system", cx, |window, cx| {
let mut input = TextInput::new(window, cx).multi_line(3, 8);
input.set_text("You answer about color in plain words. Cite sources.", cx);
input
});
let system_tokens = tokens(system.read(cx).text());
let changed = values.clone();
section(
"TemperatureSlider / ParameterPanel / SystemPromptEditor / TokenCounter / ContextWindowMeter / CostEstimator",
"How answers are made: each knob a slider with its value; the instructions every answer follows, counted against their limit; how full the context is; and what a message may cost. The context meter is data_display::UsageBar.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.flex_col()
.gap_5()
.child(ParameterPanel::new(
"chat-parameters",
[
Parameter { key: "temperature".into(), label: "Temperature".into(), value: temperature, min: 0.0, max: 2.0, step: 0.1, hint: Some("Higher reads looser; lower, steadier.".into()) },
Parameter { key: "top_p".into(), label: "Top p".into(), value: top_p, min: 0.0, max: 1.0, step: 0.05, hint: None },
Parameter { key: "longest".into(), label: "Longest answer, in tokens".into(), value: longest, min: 256.0, max: 8192.0, step: 256.0, hint: None },
],
move |key, value, _, cx| {
let (mut t, mut p, mut l) = *changed.read(cx);
match key.as_ref() {
"temperature" => t = value,
"top_p" => p = value,
_ => l = value,
}
set(&changed, (t, p, l), cx)
},
))
.child(SystemPromptEditor::new("chat-system", &system, system_tokens, 400).on_reset(|_, _| log::info!("gallery: reset system prompt")))
.child(
UsageBar::new(200_000.0)
.part("System", 1_200.0)
.part("Messages", 38_400.0)
.part("Files", 64_000.0),
)
.child(
div()
.flex()
.justify_between()
.child(TokenCounter::new(183_600).limit(200_000))
.child(CostEstimator::new("chat-cost", 2_400, 1_024, (3.0, 15.0))),
),
)
}
+4
View File
@@ -1,5 +1,6 @@
mod blocks;
mod cite;
mod compose;
mod controls;
mod talk;
@@ -53,5 +54,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(controls::thinking(window, cx))
.child(cite::citations(window, cx))
.child(cite::search(window, cx))
.child(compose::composer(window, cx))
.child(compose::pickers(window, cx))
.child(compose::tuning(window, cx))
.into_any_element()
}
+202
View File
@@ -0,0 +1,202 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ObjectFit, ParentElement, RenderOnce, SharedString,
Styled, Window, div, relative,
};
use super::media::file_icon;
use crate::{
buttons::{ButtonVariant, IconButton},
data_display::Tag,
documents::source,
forms::{Pick, Run},
primitives::{Icon, IconName, Image},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
typography::{Ellipsis, format::file_size},
};
/// Something attached to a message: its key, name and size, a picture's preview, and how far its upload has come, 0 to 1, while it goes.
#[derive(Clone, Debug, PartialEq)]
pub struct Attachment {
pub key: SharedString,
pub name: SharedString,
pub bytes: u64,
pub preview: Option<SharedString>,
pub progress: Option<f32>,
}
/// An attachment: a picture's thumbnail or the file's icon, its name, its size or how far it has gone up, and a way to take it off.
#[derive(IntoElement)]
pub struct AttachmentChip {
id: ElementId,
attachment: Attachment,
on_remove: Option<Run>,
}
impl AttachmentChip {
pub fn new(id: impl Into<ElementId>, attachment: Attachment) -> Self {
assert!(
attachment
.progress
.is_none_or(|share| (0.0..=1.0).contains(&share)),
"an upload of {:?} lies outside 0 to 1",
attachment.progress
);
Self {
id: id.into(),
attachment,
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 AttachmentChip {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let side = theme.avatar_size(AvatarSize::Md);
let item = self.attachment;
let face = match &item.preview {
Some(preview) => div()
.size(side)
.rounded(theme.radius(Radius::Sm))
.bg(colors.sunken)
.child(
Image::new((self.id.clone(), "preview"), source(preview))
.fit(ObjectFit::Cover)
.rounded(theme.radius(Radius::Sm))
.size_full(),
),
None => div()
.size(side)
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Sm))
.bg(colors.hover)
.child(
Icon::new(file_icon(&item.name))
.size(IconSize::Md)
.color(colors.fg_muted),
),
};
let detail = match item.progress {
Some(share) => div()
.w_full()
.h(theme.progress_thickness())
.rounded_full()
.bg(colors.hover)
.child(
div()
.h_full()
.w(relative(share))
.rounded_full()
.bg(colors.accent),
)
.into_any_element(),
None => div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(file_size(item.bytes, false))
.into_any_element(),
};
div()
.min_w_0()
.max_w(theme.label_width() * 1.4)
.flex()
.items_center()
.gap_2()
.p_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(face)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child(Ellipsis::new(item.name.clone())),
)
.child(detail),
)
.children(self.on_remove.map(|remove| {
IconButton::new((self.id.clone(), "remove"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(move |_, window, cx| {
log::info!("attachment: removed");
remove(window, cx)
})
}))
}
}
/// Context given to a message, such as files, pages and a selection: each a tag with its icon, to take off.
#[derive(IntoElement)]
pub struct ContextChips {
id: ElementId,
items: Vec<(SharedString, IconName)>,
on_remove: Option<Pick>,
}
impl ContextChips {
pub fn new(
id: impl Into<ElementId>,
items: impl IntoIterator<Item = (impl Into<SharedString>, IconName)>,
) -> Self {
Self {
id: id.into(),
items: items
.into_iter()
.map(|(label, icon)| (label.into(), icon))
.collect(),
on_remove: None,
}
}
/// Gets the index of the context to take off.
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ContextChips {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
div()
.flex()
.flex_wrap()
.gap_1()
.children(
self.items
.into_iter()
.enumerate()
.map(|(ix, (label, icon))| {
let tag =
Tag::new((self.id.clone(), format!("context-{ix}")), label).icon(icon);
match self.on_remove.clone() {
Some(remove) => tag.on_remove(move |window, cx| {
log::info!("context chips: removed {ix}");
remove(ix, window, cx)
}),
None => tag,
}
}),
)
}
}
+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,
}
}
}
+8
View File
@@ -1,30 +1,38 @@
mod actions;
mod attach;
mod cite;
mod code;
mod composer;
mod list;
mod media;
mod message;
mod params;
mod play;
mod search;
mod status;
mod stream;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod voice;
pub use actions::{
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
stop_button,
};
pub use attach::{Attachment, AttachmentChip, ContextChips};
pub use cite::{CitationBadge, Source, SourceCard, SourceList};
pub use code::CodeBlock;
pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton};
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
pub use message::{
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
};
pub use params::{CostEstimator, Parameter, ParameterPanel, SystemPromptEditor, TokenCounter};
pub use play::{AudioMessage, VideoMessage};
pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard};
pub use status::{
ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator,
};
pub use stream::{StreamingCursor, StreamingMarkdown, StreamingText};
pub use voice::{VoiceInputButton, VoiceWaveform};
+313
View File
@@ -0,0 +1,313 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Input, Run, Slider, TextInput},
primitives::Tooltip,
theme::{ActiveTheme, ControlSize, TextSize},
typography::{
Ellipsis,
format::{currency, decimals, group_digits},
tabular,
},
};
/// Share of a limit where a count turns amber.
const NEAR: f64 = 0.9;
/// One tuning knob: its key and name, its value, range and step, and a line on what it does.
#[derive(Clone, Debug, PartialEq)]
pub struct Parameter {
pub key: SharedString,
pub label: SharedString,
pub value: f64,
pub min: f64,
pub max: f64,
pub step: f64,
pub hint: Option<SharedString>,
}
type OnParameter = Rc<dyn Fn(&SharedString, f64, &mut Window, &mut App)>;
/// How answers are made, knob by knob: each a slider with its value, such as temperature, top p and the longest answer.
#[derive(IntoElement)]
pub struct ParameterPanel {
id: ElementId,
parameters: Vec<Parameter>,
on_change: OnParameter,
}
impl ParameterPanel {
/// `on_change` gets a parameter's key and its new value.
pub fn new(
id: impl Into<ElementId>,
parameters: impl IntoIterator<Item = Parameter>,
on_change: impl Fn(&SharedString, f64, &mut Window, &mut App) + 'static,
) -> Self {
let parameters: Vec<Parameter> = parameters.into_iter().collect();
for parameter in &parameters {
assert!(
parameter.min < parameter.max
&& parameter.step > 0.0
&& (parameter.min..=parameter.max).contains(&parameter.value),
"parameter {} has a bad range",
parameter.key
);
}
Self {
id: id.into(),
parameters,
on_change: Rc::new(on_change),
}
}
}
impl RenderOnce for ParameterPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.w_full()
.flex()
.flex_col()
.gap_4()
.children(self.parameters.into_iter().map(|parameter| {
let change = self.on_change.clone();
let key = parameter.key.clone();
let places = decimals(parameter.step);
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.flex()
.justify_between()
.text_size(theme.text_size(TextSize::Sm))
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.font_weight(FontWeight::MEDIUM)
.child(Ellipsis::new(parameter.label)),
)
.child(
tabular(div().flex_none().text_color(colors.fg_muted))
.child(format!("{:.places$}", parameter.value)),
),
)
.child(
Slider::new((self.id.clone(), format!("slider-{key}")), parameter.value)
.range(parameter.min, parameter.max)
.step(parameter.step)
.on_change(move |value, window, cx| {
log::info!("parameters: {key} {value}");
change(&key, value, window, cx)
}),
)
.children(parameter.hint.map(|hint| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(hint)
}))
}))
}
}
/// A token count against a limit: quiet while there is room, amber near the end, red past it.
#[derive(IntoElement)]
pub struct TokenCounter {
tokens: usize,
limit: Option<usize>,
}
impl TokenCounter {
/// `tokens` as the model's own tokenizer counts them.
pub fn new(tokens: usize) -> Self {
Self {
tokens,
limit: None,
}
}
pub fn limit(mut self, limit: usize) -> Self {
assert!(limit > 0, "a limit of none takes no tokens");
self.limit = Some(limit);
self
}
}
impl RenderOnce for TokenCounter {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let count = group_digits(&self.tokens.to_string(), ',');
let (text, color) = match self.limit {
Some(limit) => {
let share = self.tokens as f64 / limit as f64;
let color = if share > 1.0 {
colors.danger
} else if share >= NEAR {
colors.warning
} else {
colors.fg_subtle
};
(
format!("{count} / {} tokens", group_digits(&limit.to_string(), ',')),
color,
)
}
None => (format!("{count} tokens"), colors.fg_subtle),
};
tabular(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(color),
)
.child(text)
}
}
/// What tokens cost in dollars, at prices per million tokens in and out.
pub(crate) fn cost(input: usize, output: usize, per_million_in: f64, per_million_out: f64) -> f64 {
(input as f64 * per_million_in + output as f64 * per_million_out) / 1_000_000.0
}
/// What a message may cost: its tokens in and out at the model's prices per million; the parts in a tooltip.
#[derive(IntoElement)]
pub struct CostEstimator {
id: ElementId,
input: usize,
output: usize,
prices: (f64, f64),
}
impl CostEstimator {
/// `prices` are dollars per million tokens, in and out.
pub fn new(id: impl Into<ElementId>, input: usize, output: usize, prices: (f64, f64)) -> Self {
assert!(prices.0 >= 0.0 && prices.1 >= 0.0, "a price below zero");
Self {
id: id.into(),
input,
output,
prices,
}
}
}
impl RenderOnce for CostEstimator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let dollars = cost(self.input, self.output, self.prices.0, self.prices.1);
let shown = if dollars > 0.0 && dollars < 0.01 {
"< $0.01".to_string()
} else {
format!("≈ {}", currency(dollars, "USD"))
};
let parts = format!(
"{} in, {} out",
group_digits(&self.input.to_string(), ','),
group_digits(&self.output.to_string(), ',')
);
tabular(
div()
.id(self.id)
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.tooltip(Tooltip::with_meta(shown.clone(), parts)),
)
.child(shown)
}
}
/// The instructions every answer follows: a titled field, its tokens against the limit, and a way back to the default.
#[derive(IntoElement)]
pub struct SystemPromptEditor {
id: ElementId,
field: Entity<TextInput>,
tokens: usize,
limit: usize,
on_reset: Option<Run>,
}
impl SystemPromptEditor {
/// `tokens` counts the field's text, as the model's tokenizer does.
pub fn new(
id: impl Into<ElementId>,
field: &Entity<TextInput>,
tokens: usize,
limit: usize,
) -> Self {
Self {
id: id.into(),
field: field.clone(),
tokens,
limit,
on_reset: None,
}
}
/// Offers a way back to the default instructions.
pub fn on_reset(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_reset = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SystemPromptEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child("System prompt"),
)
.children(self.on_reset.map(|reset| {
Button::new((self.id.clone(), "reset"), "Reset")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("system prompt: reset");
reset(window, cx)
})
})),
)
.child(Input::new(&self.field))
.child(
div()
.flex()
.justify_end()
.child(TokenCounter::new(self.tokens).limit(self.limit)),
)
}
}
#[cfg(test)]
mod tests {
use super::cost;
#[test]
fn cost_counts_tokens_at_their_prices() {
assert_eq!(cost(1_000_000, 0, 3.0, 15.0), 3.0);
assert_eq!(cost(2_000, 1_000, 3.0, 15.0), 0.021);
assert_eq!(cost(0, 0, 3.0, 15.0), 0.0);
}
}
+118
View File
@@ -0,0 +1,118 @@
use gpui::{
AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement, Render, SharedString,
Styled, TestAppContext, VisualTestContext, Window, div,
};
use super::settle;
use crate::{
chat::PromptInput,
forms::{self, Choice, TextInput},
theme::Theme,
};
/// A composer over a field, and what it asked: sends, commands and context.
struct Compose {
field: Entity<TextInput>,
busy: bool,
sent: usize,
picked: Vec<SharedString>,
}
impl Render for Compose {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (send, command, mention) = (cx.entity(), cx.entity(), cx.entity());
let input = PromptInput::new("compose", &self.field, move |_, cx| {
send.update(cx, |compose, _| compose.sent += 1)
})
.commands(
[Choice::new("summarize", "/summarize")],
move |value, _, cx| command.update(cx, |compose, _| compose.picked.push(value.clone())),
)
.context([Choice::new("lift", "lift.rs")], move |value, _, cx| {
mention.update(cx, |compose, _| compose.picked.push(value.clone()))
});
let input = if self.busy {
input.busy(|_, _| {})
} else {
input
};
div().size_full().child(input)
}
}
fn open(busy: bool, cx: &mut TestAppContext) -> (Entity<Compose>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
forms::bind_keys(cx);
});
let (compose, cx) = cx.add_window_view(move |window, cx| Compose {
field: cx.new(|cx| TextInput::new(window, cx).multi_line(1, 8)),
busy,
sent: 0,
picked: Vec::new(),
});
cx.update(|window, cx| {
window.activate_window();
let field = compose.read(cx).field.focus_handle(cx);
window.focus(&field);
});
settle(cx);
(compose, cx)
}
fn text(compose: &Entity<Compose>, cx: &mut VisualTestContext) -> String {
cx.update(|_, cx| compose.read(cx).field.read(cx).text().to_string())
}
#[gpui::test]
fn enter_sends_and_shift_enter_breaks_the_line(cx: &mut TestAppContext) {
let (compose, cx) = open(false, cx);
cx.simulate_keystrokes("enter");
assert_eq!(
compose.read_with(cx, |compose, _| compose.sent),
0,
"nothing to send"
);
cx.simulate_input("lift");
cx.simulate_keystrokes("shift-enter");
settle(cx);
assert_eq!(text(&compose, cx), "lift\n");
cx.simulate_input("tone");
settle(cx);
cx.simulate_keystrokes("enter");
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 1);
assert_eq!(
text(&compose, cx),
"lift\ntone",
"Enter sends without a new line"
);
}
#[gpui::test]
fn a_slash_picks_a_command_and_an_at_sign_picks_context(cx: &mut TestAppContext) {
let (compose, cx) = open(false, cx);
cx.simulate_input("/sum");
settle(cx);
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(text(&compose, cx), "", "the command's words go");
cx.simulate_input("see @li");
settle(cx);
cx.simulate_keystrokes("enter");
settle(cx);
assert_eq!(text(&compose, cx), "see ");
compose.read_with(cx, |compose, _| {
assert_eq!(compose.picked, ["summarize", "lift"]);
assert_eq!(compose.sent, 0, "a pick is not a send");
});
}
#[gpui::test]
fn a_busy_composer_holds_its_message(cx: &mut TestAppContext) {
let (compose, cx) = open(true, cx);
cx.simulate_input("wait");
settle(cx);
cx.simulate_keystrokes("enter");
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 0);
assert_eq!(text(&compose, cx), "wait");
}
+1
View File
@@ -8,6 +8,7 @@ use gpui::{
};
mod cite;
mod compose;
use super::{
ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock,
+124
View File
@@ -0,0 +1,124 @@
use std::{rc::Rc, time::Instant};
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
use crate::{
buttons::{ButtonVariant, IconButton},
feedback::Timer,
forms::OnFlag,
motion::Pulse,
primitives::IconName,
theme::{ActiveTheme, ControlSize, TextSize},
};
/// Starts and stops dictation: a microphone at rest, a stop square while it records.
#[derive(IntoElement)]
pub struct VoiceInputButton {
id: ElementId,
recording: bool,
on_toggle: OnFlag,
}
impl VoiceInputButton {
/// `on_toggle` gets true to start recording, false to stop.
pub fn new(
id: impl Into<ElementId>,
recording: bool,
on_toggle: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
recording,
on_toggle: Rc::new(on_toggle),
}
}
}
impl RenderOnce for VoiceInputButton {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (recording, toggle) = (self.recording, self.on_toggle);
IconButton::new(
self.id,
if recording {
IconName::Square
} else {
IconName::Mic
},
)
.variant(if recording {
ButtonVariant::Secondary
} else {
ButtonVariant::Ghost
})
.size(ControlSize::Sm)
.tooltip(if recording {
"Stop dictation"
} else {
"Dictate"
})
.on_click(move |_, window, cx| {
log::info!("voice input: recording {}", !recording);
toggle(!recording, window, cx)
})
}
}
/// Sound as it records: a red dot, how long it has run, and bars that follow the latest levels, 0 to 1, oldest first. The dot's rings rest under reduced motion.
#[derive(IntoElement)]
pub struct VoiceWaveform {
id: ElementId,
since: Instant,
levels: Vec<f32>,
}
impl VoiceWaveform {
pub fn new(id: impl Into<ElementId>, since: Instant, levels: impl Into<Vec<f32>>) -> Self {
let levels: Vec<f32> = levels.into();
assert!(
levels.iter().all(|level| (0.0..=1.0).contains(level)),
"levels lie within 0 to 1"
);
Self {
id: id.into(),
since,
levels,
}
}
}
impl RenderOnce for VoiceWaveform {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let tall = theme.control_height(ControlSize::Sm);
div()
.flex()
.items_center()
.gap_2()
.child(
Pulse::new((self.id.clone(), "dot")).child(
div()
.size(theme.status_dot())
.rounded_full()
.bg(colors.danger),
),
)
.child(Timer::new((self.id.clone(), "time"), self.since).size(TextSize::Sm))
.child(
div()
.flex_1()
.h(tall)
.flex()
.items_center()
.justify_end()
.gap_px()
.children(self.levels.into_iter().map(|level| {
div()
.w(theme.progress_thickness())
.h(tall * level.max(0.1))
.rounded_full()
.bg(colors.fg_muted)
})),
)
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ use gpui::{
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::suggest::{Offer, offers, replace_trigger},
documents::suggest::{Offer, offers},
forms::replace_trigger,
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
menus::{Menu, MenuItem, OverflowMenu},
motion::Reorder,
+1 -14
View File
@@ -4,6 +4,7 @@ use gpui::{App, ElementId, Entity, SharedString, Window};
use crate::forms::{
Choice, Pick, Suggestions, TextInput, active_trigger, emoji_found, handles_matching,
replace_trigger,
};
/// A trigger of the owner's: from the query and where it runs, the rows and what a pick does.
@@ -23,20 +24,6 @@ fn nothing() -> (Vec<Choice>, Pick) {
)
}
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
pub(crate) fn replace_trigger(
field: &Entity<TextInput>,
at: usize,
caret: usize,
text: &str,
cx: &mut App,
) {
field.update(cx, |input, cx| {
input.select(at..caret, cx);
input.insert(text, cx);
});
}
/// What `@` and `:` offer at the field's caret: people by handle, emoji by name; `extra` adds a trigger of the owner's, such as `/` in blocks.
pub(crate) fn offers(
id: ElementId,
+14
View File
@@ -32,6 +32,20 @@ pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Opt
(opens && word).then_some((ix, ch))
}
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
pub(crate) fn replace_trigger(
field: &Entity<TextInput>,
at: usize,
caret: usize,
text: &str,
cx: &mut App,
) {
field.update(cx, |input, cx| {
input.select(at..caret, cx);
input.insert(text, cx);
});
}
/// The dismissed trigger, while it is still the one being typed.
fn dismissal(dismissed: Option<usize>, active: Option<usize>) -> Option<usize> {
dismissed.filter(|at| active == Some(*at))
+3 -1
View File
@@ -63,6 +63,7 @@ pub use date::{
WeekPicker, YearPicker,
};
pub use expression::{ExpressionInput, evaluate, expression_highlights};
pub(crate) use files::dropped;
pub use files::{DropZone, FileInput};
pub(crate) use glyphs::emoji_found;
pub use glyphs::{EmojiPicker, IconPicker};
@@ -75,7 +76,7 @@ pub use knob::Knob;
pub use listbox::ListBox;
pub use masked::{COUNTRIES, MaskedInput, PhoneInput};
pub use mention::{MentionInput, mention_highlights};
pub(crate) use mention::{Suggestions, active_trigger, handles_matching};
pub(crate) use mention::{Suggestions, active_trigger, handles_matching, replace_trigger};
pub use multi::MultiSelect;
pub use number::{NumberInput, ScrubInput};
pub use options::Choice;
@@ -84,6 +85,7 @@ pub(crate) use options::{
};
pub use pairs::{FieldArray, KeyValueInput, ListInput};
pub use path::PathInput;
pub(crate) use path::choose;
pub use pattern::{RegexInput, regex_highlights};
pub use pin::PinInput;
pub use radio::{Radio, RadioGroup};
+14
View File
@@ -299,3 +299,17 @@ fn a_range_gives_a_box_per_line_it_spans(cx: &mut TestAppContext) {
"the second line starts at the edge"
);
}
#[gpui::test]
fn shift_enter_breaks_only_a_field_of_many_lines(cx: &mut TestAppContext) {
let (fields, cx) = open(1, false, cx);
focus(&fields[0], cx);
cx.simulate_input("one line");
cx.simulate_keystrokes("shift-enter");
cx.run_until_parked();
assert_eq!(
text(&fields[0], cx),
"one line",
"a single line stays single"
);
}
+9
View File
@@ -39,6 +39,7 @@ actions!(
Undo,
Redo,
Enter,
Newline,
Submit,
ShowCharacterPalette,
]
@@ -84,6 +85,7 @@ pub(crate) fn bind_keys(cx: &mut App) {
KeyBinding::new("secondary-z", Undo, Some(CONTEXT)),
KeyBinding::new("secondary-shift-z", Redo, Some(CONTEXT)),
KeyBinding::new("enter", Enter, Some(CONTEXT)),
KeyBinding::new("shift-enter", Newline, Some(CONTEXT)),
KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)),
];
if cfg!(target_os = "macos") {
@@ -290,6 +292,12 @@ impl TextInput {
}
}
fn newline(&mut self, _: &Newline, _: &mut Window, cx: &mut Context<Self>) {
if self.rows.is_some() {
self.replace(self.selection.clone(), "\n", false, cx);
}
}
fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context<Self>) {
log::info!("text input: submitted");
cx.emit(InputEvent::Submit);
@@ -380,6 +388,7 @@ impl Render for TextInput {
.on_action(cx.listener(Self::undo))
.on_action(cx.listener(Self::redo))
.on_action(cx.listener(Self::enter))
.on_action(cx.listener(Self::newline))
.on_action(cx.listener(Self::submit))
.on_action(cx.listener(Self::show_character_palette))
.on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
+21 -21
View File
@@ -121,27 +121,27 @@
## T23c AI Chat · Input
- [ ] PromptInput / Composer
- [ ] AttachmentButton
- [ ] AttachmentChip / AttachmentPreview
- [ ] PasteImagePreview
- [ ] DragDropOverlay
- [ ] VoiceInputButton
- [ ] VoiceWaveform / RecordingIndicator
- [ ] SlashCommandMenu → `documents::SlashMenu`
- [ ] PromptTemplateMenu
- [ ] ContextMentionMenu
- [ ] ContextChips
- [ ] ModelSelector
- [ ] ModeSelector
- [ ] ToolToggleMenu
- [ ] TemperatureSlider / ParameterPanel
- [ ] SystemPromptEditor
- [ ] TokenCounter
- [ ] ContextWindowMeter
- [ ] CostEstimator
- [ ] SendButton
- [ ] InputHint
- [x] PromptInput / Composer → `chat::PromptInput`
- [x] AttachmentButton
- [x] AttachmentChip / AttachmentPreview → `chat::AttachmentChip`, a picture's thumbnail or a file's icon
- [x] PasteImagePreview → a pasted picture as an `AttachmentChip`
- [x] DragDropOverlay
- [x] VoiceInputButton
- [x] VoiceWaveform / RecordingIndicator → `chat::VoiceWaveform`, a red dot and the time with the bars
- [x] SlashCommandMenu → `/` at the start of `chat::PromptInput`, over `forms::Suggestions`
- [x] PromptTemplateMenu → `menus::DropdownMenu` of saved prompts
- [x] ContextMentionMenu → `@` in `chat::PromptInput`
- [x] ContextChips
- [x] ModelSelector → `forms::Select`, a note on each model
- [x] ModeSelector → `buttons::SegmentedControl`
- [x] ToolToggleMenu → `menus::DropdownMenu` with check rows
- [x] TemperatureSlider / ParameterPanel → `chat::ParameterPanel`
- [x] SystemPromptEditor
- [x] TokenCounter
- [x] ContextWindowMeter → `data_display::UsageBar`
- [x] CostEstimator
- [x] SendButton
- [x] InputHint
## T23d AI Chat · Conversations