Skip to main content

ragrig_gpui/
composer.rs

1//! The chat composer: an auto-growing input with send/stop controls.
2
3use gpui_kit::{
4    App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
5    IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window,
6    assets::IconName,
7    component::{
8        ActiveTheme as _, Disableable as _, Icon, Sizable as _,
9        button::{Button, ButtonVariants as _},
10        h_flex,
11        input::{InputEvent, Textarea, TextareaState},
12        v_flex,
13    },
14    div,
15};
16
17use crate::icon::ChatIcon;
18
19/// What the host should do after the composer was used.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum ComposerEvent {
22    /// The user submitted the text; it has been trimmed.
23    Submit(String),
24    /// The user asked to stop the current generation.
25    Stop,
26}
27
28/// A reusable chat input.
29///
30/// The composer owns its [`TextareaState`] and emits [`ComposerEvent`] instead
31/// of calling the host directly. A host creates one entity, subscribes to it,
32/// and calls [`set_streaming`](Self::set_streaming) when the worker starts and
33/// stops.
34pub struct Composer {
35    textarea: Entity<TextareaState>,
36    streaming: bool,
37    disabled: bool,
38    pending_clear: bool,
39    hint: Option<SharedString>,
40    send_icon: ChatIcon,
41    _input: Subscription,
42}
43
44impl Composer {
45    /// Create a composer with a one-to-six-line auto-growing input.
46    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
47        let textarea = cx.new(|cx| {
48            TextareaState::new(window, cx)
49                .auto_grow(1, 6)
50                .placeholder("Ask your library…")
51        });
52
53        let input = cx.subscribe(&textarea, |this, _, event: &InputEvent, cx| match event {
54            InputEvent::PressEnter {
55                secondary: true, ..
56            } => this.submit(cx),
57            InputEvent::Change | InputEvent::Focus | InputEvent::Blur => cx.notify(),
58            InputEvent::PressEnter { .. } => {}
59        });
60
61        Self {
62            textarea,
63            streaming: false,
64            disabled: false,
65            pending_clear: false,
66            hint: None,
67            send_icon: ChatIcon::Named(IconName::Send),
68            _input: input,
69        }
70    }
71
72    /// Replace the send button's mark.
73    ///
74    /// A raster [`ChatIcon`] has no monochrome form, so the send button falls
75    /// back to the catalog `Send` icon.
76    pub fn send_icon(mut self, icon: impl Into<ChatIcon>) -> Self {
77        self.send_icon = icon.into();
78        self
79    }
80
81    /// Replace the send button's mark and redraw.
82    pub fn set_send_icon(&mut self, icon: impl Into<ChatIcon>, cx: &mut Context<Self>) {
83        self.send_icon = icon.into();
84        cx.notify();
85    }
86
87    /// The underlying textarea, for hosts that need selection or IME access.
88    pub fn textarea(&self) -> &Entity<TextareaState> {
89        &self.textarea
90    }
91
92    /// Current text, including untrimmed whitespace.
93    pub fn value(&self, cx: &App) -> SharedString {
94        self.textarea.read(cx).value()
95    }
96
97    /// Replace the draft and move the caret to the end.
98    pub fn set_draft(
99        &mut self,
100        text: impl Into<SharedString>,
101        window: &mut Window,
102        cx: &mut Context<Self>,
103    ) {
104        let text = text.into();
105        self.textarea
106            .update(cx, |state, cx| state.set_value(text, window, cx));
107        self.textarea
108            .update(cx, |state, cx| state.focus(window, cx));
109    }
110
111    /// Clear the draft.
112    pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
113        self.textarea
114            .update(cx, |state, cx| state.clean(window, cx));
115    }
116
117    /// Focus the input.
118    pub fn focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
119        self.textarea
120            .update(cx, |state, cx| state.focus(window, cx));
121    }
122
123    /// Switch between send and stop.
124    pub fn set_streaming(&mut self, streaming: bool, cx: &mut Context<Self>) {
125        if self.streaming != streaming {
126            self.streaming = streaming;
127            cx.notify();
128        }
129    }
130
131    /// Returns whether the composer currently offers Stop.
132    pub fn is_streaming(&self) -> bool {
133        self.streaming
134    }
135
136    /// Disable submission and editing.
137    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
138        if self.disabled != disabled {
139            self.disabled = disabled;
140            cx.notify();
141        }
142    }
143
144    /// Override the line under the input.
145    pub fn set_hint(&mut self, hint: impl Into<SharedString>, cx: &mut Context<Self>) {
146        self.hint = Some(hint.into());
147        cx.notify();
148    }
149
150    /// Emit [`ComposerEvent::Submit`] for non-empty text.
151    ///
152    /// Called by Ctrl/⌘+Enter and by the Send button.
153    pub fn submit(&mut self, cx: &mut Context<Self>) {
154        if self.streaming || self.disabled {
155            return;
156        }
157        let text = self.textarea.read(cx).value().trim().to_owned();
158        if text.is_empty() {
159            return;
160        }
161        self.pending_clear = true;
162        cx.emit(ComposerEvent::Submit(text));
163        cx.notify();
164    }
165
166    /// Emit [`ComposerEvent::Stop`] while a generation is running.
167    pub fn request_stop(&mut self, cx: &mut Context<Self>) {
168        if self.streaming {
169            cx.emit(ComposerEvent::Stop);
170        }
171    }
172
173    /// Submit the current draft, or emit [`ComposerEvent::Stop`] while streaming.
174    pub fn submit_or_stop(&mut self, cx: &mut Context<Self>) {
175        if self.streaming {
176            self.request_stop(cx);
177        } else {
178            self.submit(cx);
179        }
180    }
181}
182
183impl Render for Composer {
184    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
185        if std::mem::take(&mut self.pending_clear) {
186            self.textarea
187                .update(cx, |state, cx| state.clean(window, cx));
188            self.textarea
189                .update(cx, |state, cx| state.focus(window, cx));
190        }
191
192        let empty = self.textarea.read(cx).value().trim().is_empty();
193        let entity_id = cx.entity_id().as_u64();
194        let action_id = ElementId::NamedInteger("composer-action".into(), entity_id);
195
196        let action = if self.streaming {
197            Button::new(action_id)
198                .icon(IconName::Square)
199                .label("Stop")
200                .small()
201                .on_click(cx.listener(|this, _, _, cx| this.submit_or_stop(cx)))
202        } else {
203            Button::new(action_id)
204                .icon(
205                    self.send_icon
206                        .as_icon()
207                        .unwrap_or_else(|| Icon::new(IconName::Send)),
208                )
209                .label("Send")
210                .primary()
211                .small()
212                .disabled(empty || self.disabled)
213                .on_click(cx.listener(|this, _, _, cx| this.submit_or_stop(cx)))
214        };
215
216        let hint = self.hint.clone().unwrap_or_else(|| submit_hint().into());
217
218        v_flex()
219            .p_3()
220            .gap_2()
221            .border_t_1()
222            .border_color(cx.theme().border)
223            .child(Textarea::new(&self.textarea).bordered(true))
224            .child(
225                h_flex()
226                    .justify_between()
227                    .gap_2()
228                    .child(
229                        div()
230                            .text_sm()
231                            .text_color(cx.theme().muted_foreground)
232                            .child(hint),
233                    )
234                    .child(action),
235            )
236    }
237}
238
239impl EventEmitter<ComposerEvent> for Composer {}
240
241impl Focusable for Composer {
242    fn focus_handle(&self, cx: &App) -> FocusHandle {
243        self.textarea.read(cx).focus_handle(cx)
244    }
245}
246
247fn submit_hint() -> &'static str {
248    if cfg!(target_os = "macos") {
249        "⌘+Enter to send · Enter for a new line"
250    } else {
251        "Ctrl+Enter to send · Enter for a new line"
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::{cell::RefCell, rc::Rc};
258
259    use gpui_kit::{Render, TestAppContext, VisualTestContext};
260
261    use super::*;
262
263    struct Host {
264        composer: Entity<Composer>,
265        events: Rc<RefCell<Vec<ComposerEvent>>>,
266    }
267
268    impl Host {
269        fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
270            let composer = cx.new(|cx| Composer::new(window, cx));
271            let events = Rc::new(RefCell::new(Vec::new()));
272            let sink = events.clone();
273            cx.subscribe(&composer, move |_, _, event: &ComposerEvent, _| {
274                sink.borrow_mut().push(event.clone());
275            })
276            .detach();
277            Self { composer, events }
278        }
279    }
280
281    impl Render for Host {
282        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
283            self.composer.clone()
284        }
285    }
286
287    fn mount(cx: &mut TestAppContext) -> (Entity<Host>, &mut VisualTestContext) {
288        cx.update(crate::init);
289        cx.add_window_view(Host::new)
290    }
291
292    fn focus(host: &Entity<Host>, cx: &mut VisualTestContext) {
293        cx.update(|window, cx| {
294            host.update(cx, |host, cx| {
295                host.composer
296                    .update(cx, |composer, cx| composer.focus(window, cx));
297            });
298        });
299    }
300
301    #[gpui_kit::test]
302    fn secondary_enter_emits_submit(cx: &mut TestAppContext) {
303        let (host, cx) = mount(cx);
304        focus(&host, cx);
305        cx.simulate_input("Summarise the corpus");
306
307        let shortcut = if cfg!(target_os = "macos") {
308            "cmd-enter"
309        } else {
310            "ctrl-enter"
311        };
312        cx.simulate_keystrokes(shortcut);
313
314        cx.update(|_, cx| {
315            assert_eq!(
316                host.read(cx).events.borrow().as_slice(),
317                &[ComposerEvent::Submit("Summarise the corpus".to_owned())]
318            );
319        });
320    }
321
322    #[gpui_kit::test]
323    fn enter_inserts_a_newline_without_submitting(cx: &mut TestAppContext) {
324        let (host, cx) = mount(cx);
325        focus(&host, cx);
326        cx.simulate_input("hello");
327        cx.simulate_keystrokes("enter");
328
329        cx.update(|_, cx| {
330            assert_eq!(host.read(cx).composer.read(cx).value(cx), "hello\n");
331            assert!(host.read(cx).events.borrow().is_empty());
332        });
333    }
334
335    #[gpui_kit::test]
336    fn a_streaming_composer_emits_stop(cx: &mut TestAppContext) {
337        let (host, cx) = mount(cx);
338        cx.update(|_, cx| {
339            host.update(cx, |host, cx| {
340                host.composer.update(cx, |composer, cx| {
341                    composer.set_streaming(true, cx);
342                    composer.submit_or_stop(cx);
343                });
344            });
345        });
346
347        cx.update(|_, cx| {
348            assert_eq!(
349                host.read(cx).events.borrow().as_slice(),
350                &[ComposerEvent::Stop]
351            );
352        });
353    }
354}