1mod model_picker;
4mod suggestions;
5#[cfg(test)]
6mod tests;
7
8use crate::attachment::{AttachmentEvent, AttachmentStrip};
9use crate::context_meter::format_tokens;
10use crate::control::composed_button;
11use crate::cues::{self, Cue};
12use crate::stream::ProgressState;
13use crate::surface::{eyebrow, meta};
14use crate::theme::SemanticStyledExt as _;
15use gpui::{
16 AnyElement, App, AppContext as _, Bounds, Div, ElementId, Entity, EventEmitter, FocusHandle,
17 Focusable, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels,
18 Render, Role, SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription,
19 Window, deferred, div, prelude::FluentBuilder as _,
20};
21use gpui_base::StyledExt as _;
22use gpui_base::{Align, Button, POPUP_PRIORITY, Positioner};
23use gpui_component::{
24 ActiveTheme as _, ElementExt as _, Icon, IconName, Sizable as _, h_flex,
25 input::{
26 Enter, Escape, InputEvent, MoveDown, MoveEnd, MoveHome, MoveUp, Textarea, TextareaState,
27 },
28 scroll::ScrollableElement as _,
29 v_flex,
30};
31use std::collections::HashSet;
32
33use model_picker::{prompt_model_control, retain_active_model};
34use suggestions::{PromptToken, SuggestionKey, build_submission};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PromptModel {
43 id: SharedString,
44 label: SharedString,
45 provider: Option<SharedString>,
46 description: Option<SharedString>,
47 context_window: Option<u64>,
48 disabled: bool,
49}
50
51impl PromptModel {
52 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
54 Self {
55 id: id.into(),
56 label: label.into(),
57 provider: None,
58 description: None,
59 context_window: None,
60 disabled: false,
61 }
62 }
63
64 pub fn provider(mut self, provider: impl Into<SharedString>) -> Self {
66 self.provider = Some(provider.into());
67 self
68 }
69
70 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
72 self.description = Some(description.into());
73 self
74 }
75
76 pub fn context_window(mut self, tokens: u64) -> Self {
78 self.context_window = Some(tokens);
79 self
80 }
81
82 pub fn disabled(mut self, disabled: bool) -> Self {
84 self.disabled = disabled;
85 self
86 }
87
88 pub fn provider_name(&self) -> Option<&SharedString> {
90 self.provider.as_ref()
91 }
92
93 pub fn description_text(&self) -> Option<&SharedString> {
95 self.description.as_ref()
96 }
97
98 pub fn context_window_tokens(&self) -> Option<u64> {
100 self.context_window
101 }
102
103 pub fn id(&self) -> &SharedString {
105 &self.id
106 }
107
108 pub fn label(&self) -> &SharedString {
110 &self.label
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum PromptActions {
122 #[default]
124 Split,
125 Leading,
127 Trailing,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum PromptSubmit {
134 #[default]
136 Label,
137 Glyph,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct PromptMention {
145 id: SharedString,
146 label: SharedString,
147}
148
149impl PromptMention {
150 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
152 Self {
153 id: id.into(),
154 label: label.into(),
155 }
156 }
157
158 pub fn id(&self) -> &SharedString {
160 &self.id
161 }
162
163 pub fn label(&self) -> &SharedString {
165 &self.label
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct PromptCommand {
172 id: SharedString,
173 label: SharedString,
174 description: Option<SharedString>,
175}
176
177impl PromptCommand {
178 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
180 Self {
181 id: id.into(),
182 label: label.into(),
183 description: None,
184 }
185 }
186
187 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
189 self.description = Some(description.into());
190 self
191 }
192
193 pub fn id(&self) -> &SharedString {
195 &self.id
196 }
197
198 pub fn label(&self) -> &SharedString {
200 &self.label
201 }
202}
203
204pub use crate::attachment::Attachment as PromptAttachment;
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct PromptSubmission {
211 text: SharedString,
212 model_id: Option<SharedString>,
213 attachment_ids: Vec<SharedString>,
214}
215
216impl PromptSubmission {
217 pub fn text(&self) -> &SharedString {
219 &self.text
220 }
221
222 pub fn model_id(&self) -> Option<&SharedString> {
224 self.model_id.as_ref()
225 }
226
227 pub fn attachment_ids(&self) -> &[SharedString] {
229 &self.attachment_ids
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum PromptBarEvent {
236 DraftChanged {
238 id: SharedString,
240 draft: SharedString,
242 },
243 Submit {
245 id: SharedString,
247 submission: PromptSubmission,
249 },
250 CancelRequested {
252 id: SharedString,
254 },
255 ModelChanged {
257 id: SharedString,
259 model_id: SharedString,
261 },
262 MentionSelected {
264 id: SharedString,
266 mention_id: SharedString,
268 },
269 CommandSelected {
271 id: SharedString,
273 command_id: SharedString,
275 },
276 AttachRequested {
278 id: SharedString,
280 },
281 AttachmentRemoved {
283 id: SharedString,
285 attachment_id: SharedString,
287 },
288 EnhanceRequested {
290 id: SharedString,
292 draft: SharedString,
294 },
295}
296
297fn prompt_frame(id: &SharedString) -> Stateful<Div> {
298 v_flex()
299 .id(id.clone())
300 .role(Role::Group)
301 .aria_label("Prompt composer")
302 .w_full()
303 .min_w_0()
304}
305
306fn prompt_listbox(id: ElementId, label: &'static str) -> Stateful<Div> {
307 v_flex().id(id).role(Role::ListBox).aria_label(label)
308}
309
310fn prompt_status(id: ElementId, label: SharedString) -> Stateful<Div> {
311 div().id(id).role(Role::Status).aria_label(label)
312}
313
314fn prompt_control(
315 id: impl Into<ElementId>,
316 label: impl Into<SharedString>,
317 cx: &mut App,
318) -> Button {
319 prompt_control_with_tone(id, label, false, cx)
320}
321
322fn prompt_control_with_tone(
323 id: impl Into<ElementId>,
324 label: impl Into<SharedString>,
325 primary: bool,
326 cx: &mut App,
327) -> Button {
328 let label = label.into();
329 prompt_control_shell(id, label.clone(), primary, cx).child(div().child(label))
330}
331
332fn prompt_control_shell(
335 id: impl Into<ElementId>,
336 label: SharedString,
337 primary: bool,
338 cx: &mut App,
339) -> Button {
340 let tokens = cx.theme().semantic_tokens();
341 let (background, foreground, border, hover, active) = if primary {
342 (
343 cx.theme().button_primary,
344 cx.theme().button_primary_foreground,
345 cx.theme().primary,
346 cx.theme().button_primary_hover,
347 cx.theme().button_primary_active,
348 )
349 } else {
350 (
351 cx.theme().transparent,
352 cx.theme().foreground,
353 cx.theme().transparent,
354 cx.theme().button_hover,
355 cx.theme().button_active,
356 )
357 };
358 composed_button(id, label.clone())
359 .flex()
360 .items_center()
361 .justify_center()
362 .h(crate::sizing::control_md(cx))
363 .px(tokens.spacing.sm)
364 .border_1()
365 .border_color(border)
366 .rounded(tokens.radius.sm)
367 .bg(background)
368 .text_token(tokens.typography.sm)
369 .text_color(foreground)
370 .hover(|style| style.bg(hover))
371 .active(|style| style.bg(active))
372 .focus_visible(|style| style.border_color(cx.theme().ring))
373 .styles(|styles| {
374 styles.disabled(|style| {
375 style
376 .bg(cx.theme().muted)
377 .text_color(cx.theme().muted_foreground)
378 })
379 })
380}
381
382pub struct PromptBar {
402 style: gpui::StyleRefinement,
409 id: SharedString,
410 editor: Entity<TextareaState>,
411 models: Vec<PromptModel>,
412 selected_model: Option<SharedString>,
413 mentions: Vec<PromptMention>,
414 commands: Vec<PromptCommand>,
415 attachments: Vec<PromptAttachment>,
416 progress: ProgressState,
417 last_draft: String,
418 last_cursor: usize,
419 token: Option<PromptToken>,
420 filtered: Vec<SuggestionKey>,
421 active_suggestion: Option<SuggestionKey>,
422 model_menu_open: bool,
423 active_model: Option<SharedString>,
424 model_trigger_bounds: Bounds<Pixels>,
425 model_trigger_rem_size: Pixels,
426 actions: PromptActions,
427 submit: PromptSubmit,
428 _subscriptions: Vec<Subscription>,
429}
430
431impl PromptBar {
432 pub fn new(
434 id: impl Into<SharedString>,
435 window: &mut Window,
436 cx: &mut gpui::Context<Self>,
437 ) -> Self {
438 let editor = cx.new(|cx| {
439 TextareaState::new(window, cx)
440 .auto_grow(1, 5)
441 .submit_on_enter(true)
442 .placeholder("Message the agent; use @ mentions or / commands")
443 });
444 let subscription = cx.subscribe_in(
445 &editor,
446 window,
447 |this, editor, event: &InputEvent, window, cx| {
448 this.on_input_event(editor, event, window, cx);
449 },
450 );
451 let observation = cx.observe(&editor, |this, editor, cx| {
452 let cursor = editor.read(cx).cursor();
453 if this.last_cursor != cursor {
454 this.last_cursor = cursor;
455 this.refresh_suggestions(cx);
456 }
457 });
458 Self {
459 style: gpui::StyleRefinement::default(),
460 id: id.into(),
461 editor,
462 models: Vec::new(),
463 selected_model: None,
464 mentions: Vec::new(),
465 commands: Vec::new(),
466 attachments: Vec::new(),
467 progress: ProgressState::Pending,
468 last_draft: String::new(),
469 last_cursor: 0,
470 token: None,
471 filtered: Vec::new(),
472 active_suggestion: None,
473 model_menu_open: false,
474 active_model: None,
475 model_trigger_bounds: Bounds::default(),
476 model_trigger_rem_size: window.rem_size(),
477 actions: PromptActions::default(),
478 submit: PromptSubmit::default(),
479 _subscriptions: vec![subscription, observation],
480 }
481 }
482
483 pub fn set_models(
487 &mut self,
488 models: impl IntoIterator<Item = PromptModel>,
489 cx: &mut gpui::Context<Self>,
490 ) {
491 let models: Vec<_> = models.into_iter().collect();
492 if !stable_ids_are_unique(models.iter().map(PromptModel::id)) {
493 return;
494 }
495 if self.models == models {
496 return;
497 }
498 self.models = models;
499 if self.models.is_empty() {
500 self.model_menu_open = false;
501 }
502 if self.selected_model.as_ref().is_none_or(|selected| {
503 !self
504 .models
505 .iter()
506 .any(|model| &model.id == selected && !model.disabled)
507 }) {
508 self.selected_model = self
509 .models
510 .iter()
511 .find(|model| !model.disabled)
512 .map(|model| model.id.clone());
513 }
514 self.active_model = retain_active_model(
515 self.active_model.take(),
516 self.selected_model.as_ref(),
517 &self.models,
518 );
519 cx.notify();
520 }
521
522 pub fn set_selected_model(
524 &mut self,
525 model_id: impl Into<SharedString>,
526 cx: &mut gpui::Context<Self>,
527 ) {
528 let model_id = model_id.into();
529 if self.selected_model.as_ref() == Some(&model_id)
530 || !self
531 .models
532 .iter()
533 .any(|model| model.id == model_id && !model.disabled)
534 {
535 return;
536 }
537 self.active_model = Some(model_id.clone());
538 self.selected_model = Some(model_id);
539 cx.notify();
540 }
541
542 pub fn set_mentions(
546 &mut self,
547 mentions: impl IntoIterator<Item = PromptMention>,
548 cx: &mut gpui::Context<Self>,
549 ) {
550 let mentions: Vec<_> = mentions.into_iter().collect();
551 if !stable_ids_are_unique(mentions.iter().map(PromptMention::id)) {
552 return;
553 }
554 if self.mentions != mentions {
555 self.mentions = mentions;
556 if !self.refresh_suggestions(cx) {
557 cx.notify();
558 }
559 }
560 }
561
562 pub fn set_commands(
566 &mut self,
567 commands: impl IntoIterator<Item = PromptCommand>,
568 cx: &mut gpui::Context<Self>,
569 ) {
570 let commands: Vec<_> = commands.into_iter().collect();
571 if !stable_ids_are_unique(commands.iter().map(PromptCommand::id)) {
572 return;
573 }
574 if self.commands != commands {
575 self.commands = commands;
576 if !self.refresh_suggestions(cx) {
577 cx.notify();
578 }
579 }
580 }
581
582 pub fn set_attachments(
586 &mut self,
587 attachments: impl IntoIterator<Item = PromptAttachment>,
588 cx: &mut gpui::Context<Self>,
589 ) {
590 let attachments: Vec<_> = attachments.into_iter().collect();
591 if !stable_ids_are_unique(attachments.iter().map(PromptAttachment::id)) {
592 return;
593 }
594 if self.attachments != attachments {
595 self.attachments = attachments;
596 cx.notify();
597 }
598 }
599
600 pub fn set_actions(&mut self, actions: PromptActions, cx: &mut gpui::Context<Self>) {
605 if self.actions != actions {
606 self.actions = actions;
607 cx.notify();
608 }
609 }
610
611 pub fn set_submit(&mut self, submit: PromptSubmit, cx: &mut gpui::Context<Self>) {
613 if self.submit != submit {
614 self.submit = submit;
615 cx.notify();
616 }
617 }
618
619 pub fn actions(&self) -> PromptActions {
621 self.actions
622 }
623
624 pub fn submit_appearance(&self) -> PromptSubmit {
626 self.submit
627 }
628
629 pub fn set_progress(&mut self, progress: ProgressState, cx: &mut gpui::Context<Self>) {
631 if self.progress != progress {
632 self.progress = progress;
633 cx.notify();
634 }
635 }
636
637 pub fn set_draft(
640 &mut self,
641 draft: impl Into<SharedString>,
642 window: &mut Window,
643 cx: &mut gpui::Context<Self>,
644 ) {
645 let draft = draft.into().to_string();
646 if self.last_draft == draft {
647 return;
648 }
649 self.last_draft = draft.clone();
650 let cursor = draft.len();
651 self.last_cursor = cursor;
652 self.editor.update(cx, |editor, cx| {
653 editor.set_value(draft, window, cx);
654 editor.set_selected_range(cursor..cursor, cx);
655 });
656 if !self.refresh_suggestions(cx) {
657 cx.notify();
658 }
659 }
660
661 pub fn draft(&self, cx: &App) -> SharedString {
663 self.editor.read(cx).value().to_string().into()
664 }
665
666 pub fn focus(&self, window: &mut Window, cx: &mut App) {
668 self.focus_handle(cx).focus(window, cx);
669 }
670
671 fn move_active_suggestion(&mut self, direction: isize, cx: &mut gpui::Context<Self>) {
672 if self.filtered.is_empty() {
673 return;
674 }
675 let current = self
676 .active_suggestion
677 .as_ref()
678 .and_then(|active| {
679 self.filtered
680 .iter()
681 .position(|candidate| candidate == active)
682 })
683 .unwrap_or(0);
684 let next = if direction < 0 {
685 current.checked_sub(1).unwrap_or(self.filtered.len() - 1)
686 } else {
687 (current + 1) % self.filtered.len()
688 };
689 self.active_suggestion = self.filtered.get(next).cloned();
690 cx.notify();
691 }
692
693 fn capture_suggestion_action(
694 &mut self,
695 direction: Option<isize>,
696 cx: &mut gpui::Context<Self>,
697 ) {
698 if self.filtered.is_empty() {
699 return;
700 }
701 match direction {
702 Some(direction) => self.move_active_suggestion(direction, cx),
703 None => {
704 self.token = None;
705 self.filtered.clear();
706 self.active_suggestion = None;
707 cx.notify();
708 }
709 }
710 cx.stop_propagation();
711 }
712
713 fn capture_move_up(&mut self, _: &MoveUp, _: &mut Window, cx: &mut gpui::Context<Self>) {
714 if self.model_menu_open {
715 self.move_active_model(-1, cx);
716 cx.stop_propagation();
717 } else {
718 self.capture_suggestion_action(Some(-1), cx);
719 }
720 }
721
722 fn capture_move_down(&mut self, _: &MoveDown, _: &mut Window, cx: &mut gpui::Context<Self>) {
723 if self.model_menu_open {
724 self.move_active_model(1, cx);
725 cx.stop_propagation();
726 } else {
727 self.capture_suggestion_action(Some(1), cx);
728 }
729 }
730
731 fn capture_move_home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut gpui::Context<Self>) {
732 if self.model_menu_open {
733 self.move_active_model_to_edge(false, cx);
734 cx.stop_propagation();
735 }
736 }
737
738 fn capture_move_end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut gpui::Context<Self>) {
739 if self.model_menu_open {
740 self.move_active_model_to_edge(true, cx);
741 cx.stop_propagation();
742 }
743 }
744
745 fn capture_escape(&mut self, _: &Escape, window: &mut Window, cx: &mut gpui::Context<Self>) {
746 let handled = if self.model_menu_open {
747 self.model_menu_open = false;
748 true
749 } else if self.token.is_some() {
750 self.token = None;
751 self.filtered.clear();
752 self.active_suggestion = None;
753 true
754 } else {
755 false
756 };
757 if handled {
758 self.focus(window, cx);
759 cx.notify();
760 cx.stop_propagation();
761 }
762 }
763
764 fn capture_enter(&mut self, action: &Enter, window: &mut Window, cx: &mut gpui::Context<Self>) {
765 if action.shift {
766 return;
767 }
768 if self.model_menu_open {
769 self.confirm_active_model(window, cx);
770 } else if self.token.is_some() && self.active_suggestion.is_some() {
771 self.insert_active_suggestion(window, cx);
772 } else {
773 self.submit(window, cx);
774 }
775 cx.stop_propagation();
776 }
777
778 fn insert_active_suggestion(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) {
779 let (Some(token), Some(active)) = (self.token.clone(), self.active_suggestion.clone())
780 else {
781 return;
782 };
783 let separator = {
784 let draft = self.editor.read(cx).value();
785 if draft
786 .get(token.range.end..)
787 .and_then(|suffix| suffix.chars().next())
788 .is_none_or(|character| !character.is_whitespace())
789 {
790 " "
791 } else {
792 ""
793 }
794 };
795 let (replacement, event) = match active {
796 SuggestionKey::Mention(id) => {
797 let Some(mention) = self.mentions.iter().find(|mention| mention.id == id) else {
798 return;
799 };
800 (
801 format!("@{}{separator}", mention.label),
802 PromptBarEvent::MentionSelected {
803 id: self.id.clone(),
804 mention_id: mention.id.clone(),
805 },
806 )
807 }
808 SuggestionKey::Command(id) => {
809 let Some(command) = self.commands.iter().find(|command| command.id == id) else {
810 return;
811 };
812 (
813 format!("/{}{separator}", command.label),
814 PromptBarEvent::CommandSelected {
815 id: self.id.clone(),
816 command_id: command.id.clone(),
817 },
818 )
819 }
820 };
821 self.editor.update(cx, |editor, cx| {
822 editor.set_selected_range(token.range, cx);
823 editor.replace(&replacement, window, cx);
824 });
825 self.token = None;
826 self.filtered.clear();
827 self.active_suggestion = None;
828 cx.emit(event);
829 cx.notify();
830 }
831
832 fn submit(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) {
833 if self.progress == ProgressState::Running {
834 return;
835 }
836 let draft = self.editor.read(cx).value().to_string();
837 let Some(submission) =
838 build_submission(&draft, self.selected_model.clone(), &self.attachments)
839 else {
840 return;
841 };
842 cx.emit(PromptBarEvent::Submit {
843 id: self.id.clone(),
844 submission,
845 });
846 cues::emit(cx, Cue::Submitted);
847 self.last_draft.clear();
848 self.editor.update(cx, |editor, cx| {
849 editor.set_value("", window, cx);
850 });
851 self.token = None;
852 self.filtered.clear();
853 self.active_suggestion = None;
854 cx.notify();
855 }
856}
857
858impl EventEmitter<PromptBarEvent> for PromptBar {}
859
860impl Focusable for PromptBar {
861 fn focus_handle(&self, cx: &App) -> FocusHandle {
862 self.editor.focus_handle(cx)
863 }
864}
865
866impl gpui::Styled for PromptBar {
867 fn style(&mut self) -> &mut gpui::StyleRefinement {
868 &mut self.style
869 }
870}
871
872impl Render for PromptBar {
873 fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
874 let tokens = cx.theme().semantic_tokens();
875 let root_id = self.id.clone();
876 let suggestions = self
877 .filtered
878 .iter()
879 .filter_map(|key| {
880 let label = self.suggestion_label(key)?;
881 let key = key.clone();
882 let selected = self.active_suggestion.as_ref() == Some(&key);
883 Some(
884 prompt_control(
885 (
886 gpui::ElementId::from(root_id.clone()),
887 format!("suggestion-{key:?}"),
888 ),
889 label.clone(),
890 cx,
891 )
892 .role(Role::ListBoxOption)
893 .selected(selected)
894 .aria_selected(selected)
895 .w_full()
896 .when(selected, |button| button.bg(cx.theme().accent))
897 .on_click(cx.listener(move |this, _, window, cx| {
898 this.active_suggestion = Some(key.clone());
899 this.insert_active_suggestion(window, cx);
900 })),
901 )
902 })
903 .collect::<Vec<_>>();
904 let attachments = self.attachments.clone();
905 let selected_model_label: SharedString = self
906 .selected_model
907 .as_ref()
908 .and_then(|selected| self.models.iter().find(|model| &model.id == selected))
909 .map(|model| format!("Model: {}", model.label).into())
910 .unwrap_or_else(|| "No models available".into());
911 let draft = self.editor.read(cx).value().to_string();
912 let running = self.progress == ProgressState::Running;
913 let submit_ack = crate::motion::acknowledged_state(
916 ElementId::from((ElementId::from(self.id.clone()), "submit-swap")),
917 running as u64,
918 window,
919 cx,
920 );
921 let progress_text = match &self.progress {
922 ProgressState::Pending => None,
923 ProgressState::Running => Some(SharedString::from("Running; cancel is available")),
924 ProgressState::Complete => Some(SharedString::from("Ready for another prompt")),
925 ProgressState::Failed(reason) => Some(format!("Failed: {reason}").into()),
926 };
927
928 prompt_frame(&self.id)
929 .gap(tokens.spacing.sm)
930 .p(tokens.spacing.md)
931 .border_1()
932 .border_color(cx.theme().border)
933 .rounded(tokens.radius.lg)
934 .bg(tokens.colors.surface)
935 .capture_action(cx.listener(Self::capture_enter))
936 .capture_action(cx.listener(Self::capture_move_up))
937 .capture_action(cx.listener(Self::capture_move_down))
938 .capture_action(cx.listener(Self::capture_move_home))
939 .capture_action(cx.listener(Self::capture_move_end))
940 .capture_action(cx.listener(Self::capture_escape))
941 .when(!attachments.is_empty(), |this| {
942 this.child(
943 AttachmentStrip::new((gpui::ElementId::from(root_id.clone()), "attachments"))
944 .label("Prompt attachments")
945 .items(attachments)
946 .removable(true)
947 .compact(true)
948 .on_event(cx.listener(|this, event: &AttachmentEvent, _, cx| {
949 if let AttachmentEvent::Removed { id } = event {
950 cx.emit(PromptBarEvent::AttachmentRemoved {
951 id: this.id.clone(),
952 attachment_id: id.clone(),
953 });
954 }
955 })),
956 )
957 })
958 .child(
959 div()
960 .debug_selector(|| "prompt-bar-editor".to_owned())
961 .w_full()
962 .child(Textarea::new(&self.editor)),
963 )
964 .when(!suggestions.is_empty(), |this| {
965 this.child(
966 crate::popup::popover_surface(
967 prompt_listbox(
968 (gpui::ElementId::from(root_id.clone()), "suggestions").into(),
969 "Prompt suggestions",
970 ),
971 cx,
972 )
973 .debug_selector(|| "prompt-bar-suggestions".to_owned())
974 .p(tokens.spacing.xs)
979 .max_h(tokens.spacing.xxl + tokens.spacing.xxl + tokens.spacing.xxl)
980 .overflow_y_scrollbar()
981 .children(suggestions),
982 )
983 })
984 .when_some(progress_text, |this, progress_text| {
985 this.child(
986 prompt_status(
987 (gpui::ElementId::from(root_id.clone()), "progress").into(),
988 progress_text.clone(),
989 )
990 .text_token(tokens.typography.xs)
991 .text_color(cx.theme().muted_foreground)
992 .child(progress_text),
993 )
994 })
995 .child(
996 h_flex()
997 .w_full()
998 .min_w_0()
999 .items_center()
1000 .flex_wrap()
1001 .gap(tokens.spacing.xs)
1002 .map(|row| match self.actions {
1006 PromptActions::Split => row.justify_between(),
1007 PromptActions::Leading => row.justify_start(),
1008 PromptActions::Trailing => row.justify_end(),
1009 })
1010 .child(
1011 h_flex()
1012 .flex_wrap()
1013 .items_center()
1014 .gap(tokens.spacing.xs)
1015 .child(if self.models.is_empty() {
1016 prompt_status(
1017 (gpui::ElementId::from(root_id.clone()), "model-empty").into(),
1018 selected_model_label.clone(),
1019 )
1020 .debug_selector(|| "prompt-bar-model-empty".to_owned())
1021 .text_token(tokens.typography.sm)
1022 .text_color(cx.theme().muted_foreground)
1023 .child(selected_model_label)
1024 .into_any_element()
1025 } else {
1026 let prompt = cx.entity();
1027 div()
1028 .on_prepaint(move |bounds, window, cx| {
1029 let rem_size = window.rem_size();
1030 let changed = prompt.update(cx, |prompt, _| {
1031 let changed = prompt.model_trigger_bounds != bounds
1032 || prompt.model_trigger_rem_size != rem_size;
1033 prompt.model_trigger_bounds = bounds;
1034 prompt.model_trigger_rem_size = rem_size;
1035 changed
1036 });
1037 if changed {
1038 window.request_animation_frame();
1039 }
1040 })
1041 .child(
1042 prompt_model_control(
1043 (gpui::ElementId::from(root_id.clone()), "model"),
1044 selected_model_label,
1045 self.model_menu_open,
1046 cx,
1047 )
1048 .debug_selector(|| "prompt-bar-model-trigger".to_owned())
1049 .border_color(cx.theme().border)
1050 .when(self.model_menu_open, |button| {
1051 button.bg(cx.theme().accent)
1052 })
1053 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1054 cx.stop_propagation();
1055 })
1056 .on_click(
1057 cx.listener(|this, _, window, cx| {
1058 this.toggle_model_menu(window, cx);
1059 }),
1060 ),
1061 )
1062 .into_any_element()
1063 })
1064 .child(
1065 prompt_control(
1066 (gpui::ElementId::from(root_id.clone()), "attach"),
1067 "Attach",
1068 cx,
1069 )
1070 .debug_selector(|| "prompt-bar-attach-control".to_owned())
1071 .on_click(cx.listener(
1072 |this, _, _, cx| {
1073 cx.emit(PromptBarEvent::AttachRequested {
1074 id: this.id.clone(),
1075 });
1076 },
1077 )),
1078 )
1079 .child(
1080 prompt_control(
1081 (gpui::ElementId::from(root_id.clone()), "enhance"),
1082 "Enhance",
1083 cx,
1084 )
1085 .debug_selector(|| "prompt-bar-enhance-control".to_owned())
1086 .disabled(draft.trim().is_empty() || running)
1087 .on_click(cx.listener(
1088 |this, _, _, cx| {
1089 let draft = this.editor.read(cx).value().to_string();
1090 cx.emit(PromptBarEvent::EnhanceRequested {
1091 id: this.id.clone(),
1092 draft: draft.into(),
1093 });
1094 },
1095 )),
1096 ),
1097 )
1098 .child(
1099 prompt_control_shell(
1100 (gpui::ElementId::from(root_id.clone()), "submit"),
1101 if running {
1102 "Cancel".into()
1103 } else {
1104 "Send".into()
1105 },
1106 true,
1107 cx,
1108 )
1109 .map(|button| match self.submit {
1110 PromptSubmit::Glyph => button
1114 .w(crate::sizing::control_lg(cx))
1115 .px(gpui::Pixels::ZERO)
1116 .justify_center()
1117 .rounded(cx.theme().radius_full())
1118 .child(
1119 div().opacity(submit_ack).child(
1120 Icon::new(if running {
1121 IconName::Close
1122 } else {
1123 IconName::ArrowUp
1124 })
1125 .small(),
1126 ),
1127 ),
1128 PromptSubmit::Label => button.child(
1132 v_flex()
1133 .relative()
1134 .items_center()
1135 .children(["Send", "Cancel"].map(|ghost| {
1136 div()
1137 .h(gpui::rems(0.))
1138 .overflow_hidden()
1139 .opacity(0.)
1140 .child(ghost)
1141 }))
1142 .child(div().opacity(submit_ack).child(if running {
1143 "Cancel"
1144 } else {
1145 "Send"
1146 })),
1147 ),
1148 })
1149 .when(running, |button| {
1150 button.debug_selector(|| "prompt-bar-cancel-control".to_owned())
1151 })
1152 .when(!running, |button| {
1153 button.debug_selector(|| "prompt-bar-send-control".to_owned())
1154 })
1155 .disabled(!running && draft.trim().is_empty())
1156 .on_click(cx.listener(
1157 move |this, _, window, cx| {
1158 if running {
1159 cx.emit(PromptBarEvent::CancelRequested {
1160 id: this.id.clone(),
1161 });
1162 cues::emit(cx, Cue::Cancelled);
1163 } else {
1164 this.submit(window, cx);
1165 }
1166 },
1167 )),
1168 ),
1169 )
1170 .when(self.model_menu_open, |this| {
1171 this.child(self.render_model_picker(&root_id, window, cx))
1172 })
1173 .refine_style(&self.style)
1174 }
1175}
1176
1177fn stable_ids_are_unique<'a>(mut ids: impl Iterator<Item = &'a SharedString>) -> bool {
1184 let mut seen = HashSet::new();
1185 ids.all(|id| seen.insert(id))
1186}