1use 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#[derive(Clone, Debug, PartialEq, Eq)]
21pub enum ComposerEvent {
22 Submit(String),
24 Stop,
26}
27
28pub 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 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 pub fn send_icon(mut self, icon: impl Into<ChatIcon>) -> Self {
77 self.send_icon = icon.into();
78 self
79 }
80
81 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 pub fn textarea(&self) -> &Entity<TextareaState> {
89 &self.textarea
90 }
91
92 pub fn value(&self, cx: &App) -> SharedString {
94 self.textarea.read(cx).value()
95 }
96
97 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 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 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 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 pub fn is_streaming(&self) -> bool {
133 self.streaming
134 }
135
136 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 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 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 pub fn request_stop(&mut self, cx: &mut Context<Self>) {
168 if self.streaming {
169 cx.emit(ComposerEvent::Stop);
170 }
171 }
172
173 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}