From d362f08d41039a60475e71fa1bba8efd1b92edf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 06:29:20 -0400 Subject: [PATCH] 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 --- TASKS.md | 2 +- examples/gallery/pages/chat/compose.rs | 321 +++++++++++++++++++ examples/gallery/pages/chat/mod.rs | 4 + src/chat/attach.rs | 202 ++++++++++++ src/chat/composer.rs | 406 +++++++++++++++++++++++++ src/chat/mod.rs | 8 + src/chat/params.rs | 313 +++++++++++++++++++ src/chat/tests/compose.rs | 118 +++++++ src/chat/tests/mod.rs | 1 + src/chat/voice.rs | 124 ++++++++ src/documents/blocks/view.rs | 3 +- src/documents/suggest.rs | 15 +- src/forms/mention.rs | 14 + src/forms/mod.rs | 4 +- src/forms/tests/mod.rs | 14 + src/forms/text/actions.rs | 9 + tasks/ch21-30.md | 42 +-- 17 files changed, 1562 insertions(+), 38 deletions(-) create mode 100644 examples/gallery/pages/chat/compose.rs create mode 100644 src/chat/attach.rs create mode 100644 src/chat/composer.rs create mode 100644 src/chat/params.rs create mode 100644 src/chat/tests/compose.rs create mode 100644 src/chat/voice.rs diff --git a/TASKS.md b/TASKS.md index 3637a39..d08b49d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/examples/gallery/pages/chat/compose.rs b/examples/gallery/pages/chat/compose.rs new file mode 100644 index 0000000..1f1600c --- /dev/null +++ b/examples/gallery/pages/chat/compose.rs @@ -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 { + 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 { + 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, files: &Entity>, 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 { + 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))), + ), + ) +} diff --git a/examples/gallery/pages/chat/mod.rs b/examples/gallery/pages/chat/mod.rs index a98c758..a39b719 100644 --- a/examples/gallery/pages/chat/mod.rs +++ b/examples/gallery/pages/chat/mod.rs @@ -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() } diff --git a/src/chat/attach.rs b/src/chat/attach.rs new file mode 100644 index 0000000..ff9fcdb --- /dev/null +++ b/src/chat/attach.rs @@ -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, + pub progress: Option, +} + +/// 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, +} + +impl AttachmentChip { + pub fn new(id: impl Into, 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, +} + +impl ContextChips { + pub fn new( + id: impl Into, + items: impl IntoIterator, 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, + } + }), + ) + } +} diff --git a/src/chat/composer.rs b/src/chat/composer.rs new file mode 100644 index 0000000..92194ab --- /dev/null +++ b/src/chat/composer.rs @@ -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, &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, + on_pick: impl Fn(Vec, &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, +} + +impl SendButton { + pub fn new( + id: impl Into, + 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) -> 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::(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, + commands: &[Choice], + context: &[Choice], + on_command: Option, + on_mention: Option, + 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 = 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, + on_send: Run, + on_stop: Option, + above: SmallVec<[AnyElement; 2]>, + tools: SmallVec<[AnyElement; 4]>, + commands: (Vec, Option), + context: (Vec, Option), + on_drop: Option, +} + +impl PromptInput { + /// `field` is the owner's multi-line text; `on_send` sends it. + pub fn new( + id: impl Into, + field: &Entity, + 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, + 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, + 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, &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, + } + } +} diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 93a2499..472e356 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -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}; diff --git a/src/chat/params.rs b/src/chat/params.rs new file mode 100644 index 0000000..ab710d4 --- /dev/null +++ b/src/chat/params.rs @@ -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, +} + +type OnParameter = Rc; + +/// 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, + on_change: OnParameter, +} + +impl ParameterPanel { + /// `on_change` gets a parameter's key and its new value. + pub fn new( + id: impl Into, + parameters: impl IntoIterator, + on_change: impl Fn(&SharedString, f64, &mut Window, &mut App) + 'static, + ) -> Self { + let parameters: Vec = parameters.into_iter().collect(); + for parameter in ¶meters { + assert!( + parameter.min < parameter.max + && parameter.step > 0.0 + && (parameter.min..=parameter.max).contains(¶meter.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, +} + +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, 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, + tokens: usize, + limit: usize, + on_reset: Option, +} + +impl SystemPromptEditor { + /// `tokens` counts the field's text, as the model's tokenizer does. + pub fn new( + id: impl Into, + field: &Entity, + 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); + } +} diff --git a/src/chat/tests/compose.rs b/src/chat/tests/compose.rs new file mode 100644 index 0000000..fd01159 --- /dev/null +++ b/src/chat/tests/compose.rs @@ -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, + busy: bool, + sent: usize, + picked: Vec, +} + +impl Render for Compose { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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, 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"); +} diff --git a/src/chat/tests/mod.rs b/src/chat/tests/mod.rs index d8c794a..0ea2afa 100644 --- a/src/chat/tests/mod.rs +++ b/src/chat/tests/mod.rs @@ -8,6 +8,7 @@ use gpui::{ }; mod cite; +mod compose; use super::{ ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock, diff --git a/src/chat/voice.rs b/src/chat/voice.rs new file mode 100644 index 0000000..3f0b75c --- /dev/null +++ b/src/chat/voice.rs @@ -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, + 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, +} + +impl VoiceWaveform { + pub fn new(id: impl Into, since: Instant, levels: impl Into>) -> Self { + let levels: Vec = 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) + })), + ) + } +} diff --git a/src/documents/blocks/view.rs b/src/documents/blocks/view.rs index b4b8cb8..5f515dc 100644 --- a/src/documents/blocks/view.rs +++ b/src/documents/blocks/view.rs @@ -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, diff --git a/src/documents/suggest.rs b/src/documents/suggest.rs index c40eb92..9cb399b 100644 --- a/src/documents/suggest.rs +++ b/src/documents/suggest.rs @@ -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, Pick) { ) } -/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step. -pub(crate) fn replace_trigger( - field: &Entity, - 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, diff --git a/src/forms/mention.rs b/src/forms/mention.rs index 72d2b78..3181e08 100644 --- a/src/forms/mention.rs +++ b/src/forms/mention.rs @@ -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, + 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, active: Option) -> Option { dismissed.filter(|at| active == Some(*at)) diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 5ba966f..9092551 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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}; diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index 2cea789..9d18aee 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -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" + ); +} diff --git a/src/forms/text/actions.rs b/src/forms/text/actions.rs index dca0b43..6133a9c 100644 --- a/src/forms/text/actions.rs +++ b/src/forms/text/actions.rs @@ -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) { + if self.rows.is_some() { + self.replace(self.selection.clone(), "\n", false, cx); + } + } + fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context) { 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)) diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 2aca864..5c2b3e1 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -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