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