1use std::path::PathBuf;
2
3use gpui_kit::{
4 AnyElement, App, AppContext as _, Context, ElementId, Entity, EventEmitter, ExternalPaths,
5 Focusable as _, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce,
6 SharedString, StyleRefinement, Styled, Subscription, Window,
7 assets::IconName,
8 base::{
9 Disableable, StyledExt as _, TestSupportExt as _,
10 input::{InputEvent, TextareaState},
11 },
12 div,
13 prelude::FluentBuilder as _,
14 px,
15};
16
17use crate::{
18 ActiveTheme as _, Attachment, AttachmentStatus, AttachmentStrip, Button, ButtonSize,
19 EffortMenu, ModelPicker, ModelPickerEvent, ModelPickerState, PermissionEvent, PermissionMenu,
20 PermissionState, StatusSelectEvent, StatusSelectState, Textarea,
21 collapse::{self, Measured, Need},
22};
23
24const DEFAULT_MAX_LINES: usize = 10;
26
27#[derive(Clone)]
29#[non_exhaustive]
30pub enum ComposerEvent {
31 Submit {
33 text: SharedString,
35 attachments: Vec<Attachment>,
37 },
38 AttachmentsChanged,
40 PermissionChanged(SharedString),
42 PermissionLearnMore,
44 StatusChanged {
47 item: SharedString,
49 value: SharedString,
51 },
52 ModelChanged(SharedString),
54 EffortChanged(u8),
56 AddClicked,
58 MicClicked,
60 FilesDropped(Vec<PathBuf>),
62}
63
64pub struct ComposerState {
80 input: Entity<TextareaState>,
81 attachments: Vec<Attachment>,
82 permission: Entity<PermissionState>,
83 models: Entity<ModelPickerState>,
84 disabled: bool,
85 max_lines: usize,
86 clear_on_submit: bool,
87 placeholder: SharedString,
88 _subscriptions: Vec<Subscription>,
89 _status: Vec<Subscription>,
90}
91
92impl EventEmitter<ComposerEvent> for ComposerState {}
93
94impl gpui_kit::Focusable for ComposerState {
96 fn focus_handle(&self, cx: &App) -> gpui_kit::FocusHandle {
97 self.input.read(cx).focus_handle(cx)
98 }
99}
100
101impl ComposerState {
102 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
105 let placeholder = SharedString::from("Hi, what do you need today?");
106 let input = cx.new(|cx| {
107 TextareaState::new(window, cx)
108 .placeholder(placeholder.clone())
109 .auto_grow(1, DEFAULT_MAX_LINES)
110 .submit_on_enter(true)
111 });
112 let permission = cx.new(PermissionState::new);
113 let models = cx.new(|cx| ModelPickerState::new([], window, cx));
114 let mut state = Self {
115 input,
116 attachments: Vec::new(),
117 permission,
118 models,
119 disabled: false,
120 max_lines: DEFAULT_MAX_LINES,
121 clear_on_submit: true,
122 placeholder,
123 _subscriptions: Vec::new(),
124 _status: Vec::new(),
125 };
126 state.watch(window, cx);
127 state
128 }
129
130 pub fn with_models(
132 mut self,
133 models: Entity<ModelPickerState>,
134 window: &mut Window,
135 cx: &mut Context<Self>,
136 ) -> Self {
137 self.models = models;
138 self.watch(window, cx);
139 self
140 }
141
142 pub fn with_permission(
145 mut self,
146 permission: Entity<PermissionState>,
147 window: &mut Window,
148 cx: &mut Context<Self>,
149 ) -> Self {
150 self.permission = permission;
151 self.watch(window, cx);
152 self
153 }
154
155 fn watch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
159 self._subscriptions = vec![
160 cx.subscribe_in(
161 &self.input,
162 window,
163 |this, _, event: &InputEvent, window, cx| match event {
164 InputEvent::PressEnter { shift: false, .. } => {
165 this.submit(window, cx);
166 }
167 InputEvent::Change => cx.notify(),
168 _ => {}
169 },
170 ),
171 cx.subscribe(&self.permission, |_, _, event: &PermissionEvent, cx| {
172 match event {
173 PermissionEvent::Changed(id) => {
174 cx.emit(ComposerEvent::PermissionChanged(id.clone()))
175 }
176 PermissionEvent::LearnMore => cx.emit(ComposerEvent::PermissionLearnMore),
177 }
178 cx.notify();
179 }),
180 cx.subscribe(&self.models, |_, _, event: &ModelPickerEvent, cx| {
181 match event {
182 ModelPickerEvent::Selected(id) => {
183 cx.emit(ComposerEvent::ModelChanged(id.clone()))
184 }
185 ModelPickerEvent::EffortChanged(level) => {
186 cx.emit(ComposerEvent::EffortChanged(*level))
187 }
188 _ => {}
189 }
190 cx.notify();
191 }),
192 ];
193 }
194
195 pub fn watch_status(
199 &mut self,
200 item: impl Into<SharedString>,
201 state: &Entity<StatusSelectState>,
202 cx: &mut Context<Self>,
203 ) {
204 let item = item.into();
205 self._status.push(
206 cx.subscribe(state, move |_, _, event: &StatusSelectEvent, cx| {
207 let StatusSelectEvent::Changed(value) = event;
208 cx.emit(ComposerEvent::StatusChanged {
209 item: item.clone(),
210 value: value.clone(),
211 });
212 cx.notify();
213 }),
214 );
215 }
216
217 pub fn input(&self) -> &Entity<TextareaState> {
219 &self.input
220 }
221
222 pub fn permission(&self) -> &Entity<PermissionState> {
224 &self.permission
225 }
226
227 pub fn models(&self) -> &Entity<ModelPickerState> {
229 &self.models
230 }
231
232 pub fn text(&self, cx: &App) -> SharedString {
234 self.input.read(cx).value()
235 }
236
237 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
239 let text = SharedString::from(text.to_string());
240 self.input
241 .update(cx, |input, cx| input.set_value(text, window, cx));
242 cx.notify();
243 }
244
245 pub fn clear_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
247 self.set_text("", window, cx);
248 }
249
250 pub fn focus(&self, window: &mut Window, cx: &mut App) {
252 self.input.update(cx, |input, cx| input.focus(window, cx));
253 }
254
255 pub fn placeholder(&self) -> &SharedString {
257 &self.placeholder
258 }
259
260 pub fn set_placeholder(
262 &mut self,
263 placeholder: impl Into<SharedString>,
264 window: &mut Window,
265 cx: &mut Context<Self>,
266 ) {
267 let placeholder = placeholder.into();
268 if placeholder == self.placeholder {
269 return;
270 }
271 self.placeholder = placeholder.clone();
272 self.input.update(cx, |input, cx| {
273 input.set_placeholder(placeholder, window, cx)
274 });
275 }
276
277 pub fn max_lines(&self) -> usize {
279 self.max_lines
280 }
281
282 pub fn set_max_lines(&mut self, lines: usize, cx: &mut Context<Self>) {
284 let lines = lines.max(1);
285 if lines == self.max_lines {
286 return;
287 }
288 self.max_lines = lines;
289 self.input
290 .update(cx, |input, cx| input.set_auto_grow(1, lines, cx));
291 }
292
293 pub fn is_disabled(&self) -> bool {
295 self.disabled
296 }
297
298 pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
301 if disabled == self.disabled {
302 return;
303 }
304 self.disabled = disabled;
305 self.input
306 .update(cx, |input, cx| input.set_disabled(disabled, cx));
307 cx.notify();
308 }
309
310 pub fn clears_on_submit(&self) -> bool {
312 self.clear_on_submit
313 }
314
315 pub fn set_clear_on_submit(&mut self, clear: bool) {
319 self.clear_on_submit = clear;
320 }
321
322 pub fn attachments(&self) -> &[Attachment] {
324 &self.attachments
325 }
326
327 pub fn add_attachment(&mut self, attachment: Attachment, cx: &mut Context<Self>) {
330 match self
331 .attachments
332 .iter_mut()
333 .find(|existing| existing.id() == attachment.id())
334 {
335 Some(existing) => *existing = attachment,
336 None => self.attachments.push(attachment),
337 }
338 cx.emit(ComposerEvent::AttachmentsChanged);
339 cx.notify();
340 }
341
342 pub fn remove_attachment(&mut self, id: &str, cx: &mut Context<Self>) -> Option<Attachment> {
344 let ix = self.attachments.iter().position(|a| a.id() == id)?;
345 let removed = self.attachments.remove(ix);
346 cx.emit(ComposerEvent::AttachmentsChanged);
347 cx.notify();
348 Some(removed)
349 }
350
351 pub fn set_attachment_progress(
354 &mut self,
355 id: &str,
356 progress: Option<f32>,
357 cx: &mut Context<Self>,
358 ) {
359 if let Some(attachment) = self.attachments.iter_mut().find(|a| a.id() == id) {
360 attachment.set_progress(progress);
361 cx.emit(ComposerEvent::AttachmentsChanged);
362 cx.notify();
363 }
364 }
365
366 pub fn clear_attachments(&mut self, cx: &mut Context<Self>) {
368 if !self.attachments.is_empty() {
369 self.attachments.clear();
370 cx.emit(ComposerEvent::AttachmentsChanged);
371 cx.notify();
372 }
373 }
374
375 pub fn can_submit(&self, cx: &App) -> bool {
378 let busy = self
379 .attachments
380 .iter()
381 .any(|a| a.status() != AttachmentStatus::Ready);
382 !self.disabled
383 && !busy
384 && (!self.text(cx).trim().is_empty() || !self.attachments.is_empty())
385 }
386
387 pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
389 if !self.can_submit(cx) {
390 return false;
391 }
392 let text = self.text(cx);
393 let attachments = self.attachments.clone();
394 cx.emit(ComposerEvent::Submit { text, attachments });
395 if self.clear_on_submit {
396 self.set_text("", window, cx);
397 self.clear_attachments(cx);
398 }
399 true
400 }
401}
402
403enum Slot {
406 Default,
407 Hidden,
408 Custom(AnyElement),
409}
410
411impl Slot {
412 fn custom(element: impl IntoElement) -> Self {
413 Self::Custom(element.into_any_element())
414 }
415}
416
417#[derive(IntoElement)]
451#[non_exhaustive]
452pub struct Composer {
453 id: ElementId,
454 state: Entity<ComposerState>,
455 style: StyleRefinement,
456 status: Option<AnyElement>,
457 leading: Slot,
458 permission: Slot,
459 toolbar: Vec<AnyElement>,
460 models: Slot,
461 mic: Slot,
462 send: Slot,
463 show_mic: bool,
464 placeholder: Option<SharedString>,
465 max_lines: Option<usize>,
466 disabled: Option<bool>,
467}
468
469impl Composer {
470 pub fn new(id: impl Into<ElementId>, state: &Entity<ComposerState>) -> Self {
472 Self {
473 id: id.into(),
474 state: state.clone(),
475 style: StyleRefinement::default(),
476 status: None,
477 leading: Slot::Default,
478 permission: Slot::Default,
479 toolbar: Vec::new(),
480 models: Slot::Default,
481 mic: Slot::Default,
482 send: Slot::Default,
483 show_mic: false,
484 placeholder: None,
485 max_lines: None,
486 disabled: None,
487 }
488 }
489
490 pub fn status(mut self, status: impl IntoElement) -> Self {
492 self.status = Some(status.into_any_element());
493 self
494 }
495
496 pub fn no_status(mut self) -> Self {
498 self.status = None;
499 self
500 }
501
502 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
504 self.placeholder = Some(placeholder.into());
505 self
506 }
507
508 pub fn max_lines(mut self, lines: usize) -> Self {
511 self.max_lines = Some(lines);
512 self
513 }
514
515 pub fn leading(mut self, element: impl IntoElement) -> Self {
517 self.leading = Slot::custom(element);
518 self
519 }
520
521 pub fn no_leading(mut self) -> Self {
523 self.leading = Slot::Hidden;
524 self
525 }
526
527 pub fn permission_menu(mut self, element: impl IntoElement) -> Self {
529 self.permission = Slot::custom(element);
530 self
531 }
532
533 pub fn no_permission_menu(mut self) -> Self {
535 self.permission = Slot::Hidden;
536 self
537 }
538
539 pub fn toolbar(mut self, element: impl IntoElement) -> Self {
544 self.toolbar.push(element.into_any_element());
545 self
546 }
547
548 pub fn model_picker(mut self, element: impl IntoElement) -> Self {
550 self.models = Slot::custom(element);
551 self
552 }
553
554 pub fn no_model_picker(mut self) -> Self {
556 self.models = Slot::Hidden;
557 self
558 }
559
560 pub fn show_mic(mut self, show: bool) -> Self {
562 self.show_mic = show;
563 self
564 }
565
566 pub fn mic(mut self, element: impl IntoElement) -> Self {
568 self.mic = Slot::custom(element);
569 self
570 }
571
572 pub fn send_button(mut self, element: impl IntoElement) -> Self {
575 self.send = Slot::custom(element);
576 self
577 }
578}
579
580impl Disableable for Composer {
581 fn disabled(mut self, disabled: bool) -> Self {
584 self.disabled = Some(disabled);
585 self
586 }
587}
588
589impl Styled for Composer {
590 fn style(&mut self) -> &mut StyleRefinement {
591 &mut self.style
592 }
593}
594
595fn child(parent: &ElementId, name: &'static str) -> ElementId {
596 ElementId::NamedChild(parent.clone().into(), name.into())
597}
598
599impl RenderOnce for Composer {
600 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
601 let id = self.id;
602 let state = self.state;
603 state.update(cx, |state, cx| {
604 if let Some(disabled) = self.disabled {
605 state.set_disabled(disabled, cx);
606 }
607 if let Some(lines) = self.max_lines {
608 state.set_max_lines(lines, cx);
609 }
610 if let Some(placeholder) = self.placeholder.clone() {
611 state.set_placeholder(placeholder, window, cx);
612 }
613 });
614 let (input, attachments, permission, models, disabled, can_submit) = {
615 let read = state.read(cx);
616 (
617 read.input.clone(),
618 read.attachments.clone(),
619 read.permission.clone(),
620 read.models.clone(),
621 read.disabled,
622 read.can_submit(cx),
623 )
624 };
625 let focused = input.read(cx).focus_handle(cx).contains_focused(window, cx);
626 let theme = cx.theme();
627 let (fill, border, focus_border, radius, accent, gap) = (
628 theme.field,
629 theme.field_border,
630 theme.field_focus_border,
631 theme.radius_xl(),
632 theme.field_focus_border,
633 theme.base.spacing.sm,
634 );
635 let (ink, muted) = (theme.foreground(), theme.muted_foreground());
636 let disabled_opacity = theme.disabled_opacity;
637 let radius_full = theme.radius_full();
638 let has_status = self.status.is_some();
639
640 let events = |state: &Entity<ComposerState>, event: ComposerEvent| {
641 let state = state.clone();
642 move |_: &gpui_kit::ClickEvent, _: &mut Window, cx: &mut App| {
643 let event = event.clone();
644 state.update(cx, |_, cx| cx.emit(event));
645 }
646 };
647
648 let has_leading = !matches!(self.leading, Slot::Hidden);
649 let has_mic = matches!(self.mic, Slot::Default if self.show_mic)
650 || matches!(self.mic, Slot::Custom(_));
651 let has_send = !matches!(self.send, Slot::Hidden);
652 let leading = match self.leading {
653 Slot::Default => Some(
654 Button::new(child(&id, "add"))
655 .ghost()
656 .size(ButtonSize::Default)
657 .icon(IconName::Plus)
658 .text_color(ink)
659 .accessibility_label("Add")
660 .tooltip("Add")
661 .disabled(disabled)
662 .on_click(events(&state, ComposerEvent::AddClicked))
663 .into_any_element(),
664 ),
665 Slot::Hidden => None,
666 Slot::Custom(element) => Some(element),
667 };
668 let model_state = models.clone();
669 let has_default_models = matches!(self.models, Slot::Default);
670 let permission_menu = matches!(self.permission, Slot::Default)
671 .then(|| PermissionMenu::new(child(&id, "permission"), &permission));
672 let model_picker = has_default_models.then(|| {
673 ModelPicker::new(child(&id, "models"), &models).align(gpui_kit::base::Align::End)
674 });
675 let effort_menu = (has_default_models && model_state.read(cx).effort().is_some())
676 .then(|| EffortMenu::new(child(&id, "effort"), &model_state));
677 let toolbar_width = window
678 .use_keyed_state(child(&id, "toolbar-width"), cx, |_, _| Measured::default())
679 .read(cx)
680 .clone();
681 let fixed = {
682 let control = cx.theme().metrics.control_md;
683 Need {
684 natural: control,
685 min: control,
686 icon_only: None,
687 }
688 };
689 let mut needs = Vec::new();
690 let mut at = |need: Need| {
691 needs.push(need);
692 needs.len() - 1
693 };
694 if has_leading {
695 at(fixed);
696 }
697 at(Need {
698 natural: px(0.),
699 min: px(0.),
700 icon_only: None,
701 });
702 let permission_at = permission_menu
703 .as_ref()
704 .map(|menu| at(menu.need(window, cx)));
705 let model_need = model_picker.as_ref().map(|picker| picker.need(window, cx));
706 let model_at = model_need.map(&mut at);
707 let effort_at = effort_menu
708 .as_ref()
709 .and_then(|menu| menu.need(window, cx))
710 .map(&mut at);
711 if has_mic {
712 at(fixed);
713 }
714 if has_send {
715 at(fixed);
716 }
717 let order: Vec<usize> = [effort_at, permission_at, model_at]
718 .into_iter()
719 .flatten()
720 .collect();
721 let collapsed = collapse::plan(toolbar_width.width(), gap, gap, &needs, &order);
722 let is_collapsed = |index: Option<usize>| {
723 index.is_some_and(|index| collapsed.get(index).is_some_and(|fit| fit.is_icon()))
724 };
725 let permission = match self.permission {
726 Slot::Default => permission_menu.map(|menu| {
727 menu.icon_only(is_collapsed(permission_at))
728 .flex_shrink(1.)
729 .into_any_element()
730 }),
731 Slot::Hidden => None,
732 Slot::Custom(element) => Some(element),
733 };
734 let models = match self.models {
735 Slot::Default => model_picker.map(|picker| {
736 let collapsed = is_collapsed(model_at);
739 let min = model_need
740 .map(|need| match (collapsed, need.icon_only) {
741 (true, Some(icon_only)) => icon_only,
742 _ => need.min,
743 })
744 .unwrap_or_default();
745 div()
746 .flex()
747 .flex_shrink(1.)
748 .min_w(min)
749 .child(picker.icon_only(collapsed))
750 .into_any_element()
751 }),
752 Slot::Hidden => None,
753 Slot::Custom(element) => Some(element),
754 };
755 let effort = effort_menu.map(|menu| {
756 menu.icon_only(is_collapsed(effort_at))
757 .flex_shrink(1.)
758 .into_any_element()
759 });
760 let mic = match self.mic {
761 Slot::Default if self.show_mic => Some(
762 Button::new(child(&id, "mic"))
763 .outline()
764 .size(ButtonSize::Default)
765 .icon(IconName::Mic)
766 .text_color(muted)
767 .rounded(radius_full)
768 .accessibility_label("Dictate")
769 .tooltip("Dictate")
770 .disabled(disabled)
771 .on_click(events(&state, ComposerEvent::MicClicked))
772 .into_any_element(),
773 ),
774 Slot::Default | Slot::Hidden => None,
775 Slot::Custom(element) => Some(element),
776 };
777 let send = match self.send {
778 Slot::Default => Some(
779 Button::new(child(&id, "send"))
780 .primary()
781 .size(ButtonSize::Default)
782 .icon(IconName::ArrowUp)
783 .rounded(radius_full)
784 .accessibility_label("Send")
785 .tooltip("Send")
786 .disabled(!can_submit)
787 .on_click({
788 let state = state.clone();
789 move |_, window, cx| {
790 state.update(cx, |state, cx| {
791 state.submit(window, cx);
792 });
793 }
794 })
795 .into_any_element(),
796 ),
797 Slot::Hidden => None,
798 Slot::Custom(element) => Some(element),
799 };
800
801 let strip = (!attachments.is_empty()).then(|| {
802 let state = state.clone();
803 AttachmentStrip::new(child(&id, "attachments"))
804 .attachments(attachments)
805 .on_dismiss(move |attachment, _, cx| {
806 let attachment = attachment.clone();
807 state.update(cx, |state, cx| {
808 state.remove_attachment(&attachment, cx);
809 });
810 })
811 });
812
813 let drop_state = state.clone();
814 let card = div()
815 .id(child(&id, "card"))
816 .test_support()
817 .flex()
818 .flex_col()
819 .w_full()
820 .gap(gap)
821 .p_1()
822 .rounded(radius)
823 .bg(fill)
824 .border_1()
825 .border_color(if focused { focus_border } else { border })
826 .when(disabled, |this| this.opacity(disabled_opacity))
827 .when(has_status, |this| this.mt(-gpui_kit::px(1.)).relative())
828 .refine_style(&self.style)
829 .drag_over::<ExternalPaths>(move |style, _, _, _| style.border_color(accent))
830 .on_drop(move |paths: &ExternalPaths, _, cx| {
831 let paths = paths.paths().to_vec();
832 drop_state.update(cx, |_, cx| cx.emit(ComposerEvent::FilesDropped(paths)));
833 })
834 .children(strip)
835 .child(
836 Textarea::new(&input)
837 .id(child(&id, "text"))
838 .accessibility_label("Prompt")
839 .bg(gpui_kit::transparent_black())
840 .border_0()
841 .rounded_none(),
842 )
843 .child(
844 div()
845 .relative()
846 .flex()
847 .w_full()
848 .min_w_0()
849 .items_center()
850 .gap(gap)
851 .child(toolbar_width.probe())
852 .children(leading)
853 .children(permission)
854 .children(self.toolbar)
855 .child(div().flex_1())
856 .children(models)
857 .children(effort)
858 .children(mic)
859 .children(send),
860 );
861
862 div()
863 .id(id)
864 .test_support()
865 .flex()
866 .flex_col()
867 .w_full()
868 .children(self.status)
869 .child(card)
870 }
871}