message_editor.rs

  1use std::sync::Arc;
  2
  3use collections::HashSet;
  4use editor::actions::MoveUp;
  5use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
  6use fs::Fs;
  7use git::ExpandCommitEditor;
  8use git_ui::git_panel;
  9use gpui::{
 10    point, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
 11    WeakEntity,
 12};
 13use language_model::LanguageModelRegistry;
 14use language_model_selector::ToggleModelSelector;
 15use project::Project;
 16use settings::Settings;
 17use std::time::Duration;
 18use theme::ThemeSettings;
 19use ui::{
 20    prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip,
 21};
 22use vim_mode_setting::VimModeSetting;
 23use workspace::Workspace;
 24
 25use crate::assistant_model_selector::AssistantModelSelector;
 26use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider};
 27use crate::context_store::{refresh_context_store_text, ContextStore};
 28use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 29use crate::profile_selector::ProfileSelector;
 30use crate::thread::{RequestKind, Thread};
 31use crate::thread_store::ThreadStore;
 32use crate::{
 33    Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker, ToggleProfileSelector,
 34};
 35
 36pub struct MessageEditor {
 37    thread: Entity<Thread>,
 38    editor: Entity<Editor>,
 39    #[allow(dead_code)]
 40    workspace: WeakEntity<Workspace>,
 41    project: Entity<Project>,
 42    context_store: Entity<ContextStore>,
 43    context_strip: Entity<ContextStrip>,
 44    context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 45    inline_context_picker: Entity<ContextPicker>,
 46    inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 47    model_selector: Entity<AssistantModelSelector>,
 48    profile_selector: Entity<ProfileSelector>,
 49    _subscriptions: Vec<Subscription>,
 50}
 51
 52impl MessageEditor {
 53    pub fn new(
 54        fs: Arc<dyn Fs>,
 55        workspace: WeakEntity<Workspace>,
 56        context_store: Entity<ContextStore>,
 57        thread_store: WeakEntity<ThreadStore>,
 58        thread: Entity<Thread>,
 59        window: &mut Window,
 60        cx: &mut Context<Self>,
 61    ) -> Self {
 62        let context_picker_menu_handle = PopoverMenuHandle::default();
 63        let inline_context_picker_menu_handle = PopoverMenuHandle::default();
 64        let model_selector_menu_handle = PopoverMenuHandle::default();
 65
 66        let editor = cx.new(|cx| {
 67            let mut editor = Editor::auto_height(10, window, cx);
 68            editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
 69            editor.set_show_indent_guides(false, cx);
 70            editor.set_context_menu_options(ContextMenuOptions {
 71                min_entries_visible: 12,
 72                max_entries_visible: 12,
 73                placement: Some(ContextMenuPlacement::Above),
 74            });
 75
 76            editor
 77        });
 78
 79        let editor_entity = editor.downgrade();
 80        editor.update(cx, |editor, _| {
 81            editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
 82                workspace.clone(),
 83                context_store.downgrade(),
 84                Some(thread_store.clone()),
 85                editor_entity,
 86            ))));
 87        });
 88
 89        let inline_context_picker = cx.new(|cx| {
 90            ContextPicker::new(
 91                workspace.clone(),
 92                Some(thread_store.clone()),
 93                context_store.downgrade(),
 94                ConfirmBehavior::Close,
 95                window,
 96                cx,
 97            )
 98        });
 99
100        let context_strip = cx.new(|cx| {
101            ContextStrip::new(
102                context_store.clone(),
103                workspace.clone(),
104                Some(thread_store.clone()),
105                context_picker_menu_handle.clone(),
106                SuggestContextKind::File,
107                window,
108                cx,
109            )
110        });
111
112        let subscriptions = vec![
113            cx.subscribe_in(
114                &inline_context_picker,
115                window,
116                Self::handle_inline_context_picker_event,
117            ),
118            cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
119        ];
120
121        Self {
122            editor: editor.clone(),
123            project: thread.read(cx).project().clone(),
124            thread,
125            workspace,
126            context_store,
127            context_strip,
128            context_picker_menu_handle,
129            inline_context_picker,
130            inline_context_picker_menu_handle,
131            model_selector: cx.new(|cx| {
132                AssistantModelSelector::new(
133                    fs.clone(),
134                    model_selector_menu_handle,
135                    editor.focus_handle(cx),
136                    window,
137                    cx,
138                )
139            }),
140            profile_selector: cx
141                .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
142            _subscriptions: subscriptions,
143        }
144    }
145
146    fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
147        cx.notify();
148    }
149
150    fn toggle_context_picker(
151        &mut self,
152        _: &ToggleContextPicker,
153        window: &mut Window,
154        cx: &mut Context<Self>,
155    ) {
156        self.context_picker_menu_handle.toggle(window, cx);
157    }
158    pub fn remove_all_context(
159        &mut self,
160        _: &RemoveAllContext,
161        _window: &mut Window,
162        cx: &mut Context<Self>,
163    ) {
164        self.context_store.update(cx, |store, _cx| store.clear());
165        cx.notify();
166    }
167
168    fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
169        if self.is_editor_empty(cx) {
170            return;
171        }
172
173        if self.thread.read(cx).is_generating() {
174            return;
175        }
176
177        self.send_to_model(RequestKind::Chat, window, cx);
178    }
179
180    fn is_editor_empty(&self, cx: &App) -> bool {
181        self.editor.read(cx).text(cx).is_empty()
182    }
183
184    fn is_model_selected(&self, cx: &App) -> bool {
185        LanguageModelRegistry::read_global(cx)
186            .active_model()
187            .is_some()
188    }
189
190    fn send_to_model(
191        &mut self,
192        request_kind: RequestKind,
193        window: &mut Window,
194        cx: &mut Context<Self>,
195    ) {
196        let provider = LanguageModelRegistry::read_global(cx).active_provider();
197        if provider
198            .as_ref()
199            .map_or(false, |provider| provider.must_accept_terms(cx))
200        {
201            cx.notify();
202            return;
203        }
204
205        let model_registry = LanguageModelRegistry::read_global(cx);
206        let Some(model) = model_registry.active_model() else {
207            return;
208        };
209
210        let user_message = self.editor.update(cx, |editor, cx| {
211            let text = editor.text(cx);
212            editor.clear(window, cx);
213            text
214        });
215
216        let refresh_task =
217            refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
218
219        let system_prompt_context_task = self.thread.read(cx).load_system_prompt_context(cx);
220
221        let thread = self.thread.clone();
222        let context_store = self.context_store.clone();
223        let checkpoint = self.project.read(cx).git_store().read(cx).checkpoint(cx);
224        cx.spawn(async move |_, cx| {
225            let checkpoint = checkpoint.await.ok();
226            refresh_task.await;
227            let (system_prompt_context, load_error) = system_prompt_context_task.await;
228            thread
229                .update(cx, |thread, cx| {
230                    thread.set_system_prompt_context(system_prompt_context);
231                    if let Some(load_error) = load_error {
232                        cx.emit(ThreadEvent::ShowError(load_error));
233                    }
234                })
235                .ok();
236            thread
237                .update(cx, |thread, cx| {
238                    let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
239                    thread.insert_user_message(user_message, context, checkpoint, cx);
240                    thread.send_to_model(model, request_kind, cx);
241                })
242                .ok();
243        })
244        .detach();
245    }
246
247    fn handle_inline_context_picker_event(
248        &mut self,
249        _inline_context_picker: &Entity<ContextPicker>,
250        _event: &DismissEvent,
251        window: &mut Window,
252        cx: &mut Context<Self>,
253    ) {
254        let editor_focus_handle = self.editor.focus_handle(cx);
255        window.focus(&editor_focus_handle);
256    }
257
258    fn handle_context_strip_event(
259        &mut self,
260        _context_strip: &Entity<ContextStrip>,
261        event: &ContextStripEvent,
262        window: &mut Window,
263        cx: &mut Context<Self>,
264    ) {
265        match event {
266            ContextStripEvent::PickerDismissed
267            | ContextStripEvent::BlurredEmpty
268            | ContextStripEvent::BlurredDown => {
269                let editor_focus_handle = self.editor.focus_handle(cx);
270                window.focus(&editor_focus_handle);
271            }
272            ContextStripEvent::BlurredUp => {}
273        }
274    }
275
276    fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
277        if self.context_picker_menu_handle.is_deployed()
278            || self.inline_context_picker_menu_handle.is_deployed()
279        {
280            cx.propagate();
281        } else {
282            self.context_strip.focus_handle(cx).focus(window);
283        }
284    }
285}
286
287impl Focusable for MessageEditor {
288    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
289        self.editor.focus_handle(cx)
290    }
291}
292
293impl Render for MessageEditor {
294    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
295        let font_size = TextSize::Default.rems(cx);
296        let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
297
298        let focus_handle = self.editor.focus_handle(cx);
299        let inline_context_picker = self.inline_context_picker.clone();
300
301        let empty_thread = self.thread.read(cx).is_empty();
302        let is_generating = self.thread.read(cx).is_generating();
303        let is_model_selected = self.is_model_selected(cx);
304        let is_editor_empty = self.is_editor_empty(cx);
305        let submit_label_color = if is_editor_empty {
306            Color::Muted
307        } else {
308            Color::Default
309        };
310
311        let vim_mode_enabled = VimModeSetting::get_global(cx).0;
312        let platform = PlatformStyle::platform();
313        let linux = platform == PlatformStyle::Linux;
314        let windows = platform == PlatformStyle::Windows;
315        let button_width = if linux || windows || vim_mode_enabled {
316            px(82.)
317        } else {
318            px(64.)
319        };
320
321        let project = self.thread.read(cx).project();
322        let changed_files = if let Some(repository) = project.read(cx).active_repository(cx) {
323            repository.read(cx).cached_status().count()
324        } else {
325            0
326        };
327
328        let border_color = cx.theme().colors().border;
329        let active_color = cx.theme().colors().element_selected;
330        let editor_bg_color = cx.theme().colors().editor_background;
331        let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
332
333        let edit_files_container = || {
334            h_flex()
335                .mx_2()
336                .py_1()
337                .pl_2p5()
338                .pr_1()
339                .bg(bg_edit_files_disclosure)
340                .border_1()
341                .border_color(border_color)
342                .justify_between()
343                .flex_wrap()
344        };
345
346        v_flex()
347            .size_full()
348            .when(is_generating, |parent| {
349                let focus_handle = self.editor.focus_handle(cx).clone();
350                parent.child(
351                    h_flex().py_3().w_full().justify_center().child(
352                        h_flex()
353                            .flex_none()
354                            .pl_2()
355                            .pr_1()
356                            .py_1()
357                            .bg(editor_bg_color)
358                            .border_1()
359                            .border_color(cx.theme().colors().border_variant)
360                            .rounded_lg()
361                            .shadow_md()
362                            .gap_1()
363                            .child(
364                                Icon::new(IconName::ArrowCircle)
365                                    .size(IconSize::XSmall)
366                                    .color(Color::Muted)
367                                    .with_animation(
368                                        "arrow-circle",
369                                        Animation::new(Duration::from_secs(2)).repeat(),
370                                        |icon, delta| {
371                                            icon.transform(gpui::Transformation::rotate(
372                                                gpui::percentage(delta),
373                                            ))
374                                        },
375                                    ),
376                            )
377                            .child(
378                                Label::new("Generating…")
379                                    .size(LabelSize::XSmall)
380                                    .color(Color::Muted),
381                            )
382                            .child(ui::Divider::vertical())
383                            .child(
384                                Button::new("cancel-generation", "Cancel")
385                                    .label_size(LabelSize::XSmall)
386                                    .key_binding(
387                                        KeyBinding::for_action_in(
388                                            &editor::actions::Cancel,
389                                            &focus_handle,
390                                            window,
391                                            cx,
392                                        )
393                                        .map(|kb| kb.size(rems_from_px(10.))),
394                                    )
395                                    .on_click(move |_event, window, cx| {
396                                        focus_handle.dispatch_action(
397                                            &editor::actions::Cancel,
398                                            window,
399                                            cx,
400                                        );
401                                    }),
402                            ),
403                    ),
404                )
405            })
406            .when(
407                changed_files > 0 && !is_generating && !empty_thread,
408                |parent| {
409                    parent.child(
410                        edit_files_container()
411                            .border_b_0()
412                            .rounded_t_md()
413                            .shadow(smallvec::smallvec![gpui::BoxShadow {
414                                color: gpui::black().opacity(0.15),
415                                offset: point(px(1.), px(-1.)),
416                                blur_radius: px(3.),
417                                spread_radius: px(0.),
418                            }])
419                            .child(
420                                h_flex()
421                                    .gap_2()
422                                    .child(Label::new("Edits").size(LabelSize::XSmall))
423                                    .child(div().size_1().rounded_full().bg(border_color))
424                                    .child(
425                                        Label::new(format!(
426                                            "{} {}",
427                                            changed_files,
428                                            if changed_files == 1 { "file" } else { "files" }
429                                        ))
430                                        .size(LabelSize::XSmall),
431                                    ),
432                            )
433                            .child(
434                                h_flex()
435                                    .gap_1()
436                                    .child(
437                                        Button::new("panel", "Open Git Panel")
438                                            .label_size(LabelSize::XSmall)
439                                            .key_binding({
440                                                let focus_handle = focus_handle.clone();
441                                                KeyBinding::for_action_in(
442                                                    &git_panel::ToggleFocus,
443                                                    &focus_handle,
444                                                    window,
445                                                    cx,
446                                                )
447                                                .map(|kb| kb.size(rems_from_px(10.)))
448                                            })
449                                            .on_click(|_ev, _window, cx| {
450                                                cx.defer(|cx| {
451                                                    cx.dispatch_action(&git_panel::ToggleFocus)
452                                                });
453                                            }),
454                                    )
455                                    .child(
456                                        Button::new("review", "Review Diff")
457                                            .label_size(LabelSize::XSmall)
458                                            .key_binding({
459                                                let focus_handle = focus_handle.clone();
460                                                KeyBinding::for_action_in(
461                                                    &git_ui::project_diff::Diff,
462                                                    &focus_handle,
463                                                    window,
464                                                    cx,
465                                                )
466                                                .map(|kb| kb.size(rems_from_px(10.)))
467                                            })
468                                            .on_click(|_event, _window, cx| {
469                                                cx.defer(|cx| {
470                                                    cx.dispatch_action(&git_ui::project_diff::Diff)
471                                                });
472                                            }),
473                                    )
474                                    .child(
475                                        Button::new("commit", "Commit Changes")
476                                            .label_size(LabelSize::XSmall)
477                                            .key_binding({
478                                                let focus_handle = focus_handle.clone();
479                                                KeyBinding::for_action_in(
480                                                    &ExpandCommitEditor,
481                                                    &focus_handle,
482                                                    window,
483                                                    cx,
484                                                )
485                                                .map(|kb| kb.size(rems_from_px(10.)))
486                                            })
487                                            .on_click(|_event, _window, cx| {
488                                                cx.defer(|cx| {
489                                                    cx.dispatch_action(&ExpandCommitEditor)
490                                                });
491                                            }),
492                                    ),
493                            ),
494                    )
495                },
496            )
497            .when(
498                changed_files > 0 && !is_generating && empty_thread,
499                |parent| {
500                    parent.child(
501                        edit_files_container()
502                            .mb_2()
503                            .rounded_md()
504                            .child(
505                                h_flex()
506                                    .gap_2()
507                                    .child(Label::new("Consider committing your changes before starting a fresh thread").size(LabelSize::XSmall))
508                                    .child(div().size_1().rounded_full().bg(border_color))
509                                    .child(
510                                        Label::new(format!(
511                                            "{} {}",
512                                            changed_files,
513                                            if changed_files == 1 { "file" } else { "files" }
514                                        ))
515                                        .size(LabelSize::XSmall),
516                                    ),
517                            )
518                            .child(
519                                h_flex()
520                                    .gap_1()
521                                    .child(
522                                        Button::new("review", "Review Diff")
523                                            .label_size(LabelSize::XSmall)
524                                            .key_binding({
525                                                let focus_handle = focus_handle.clone();
526                                                KeyBinding::for_action_in(
527                                                    &git_ui::project_diff::Diff,
528                                                    &focus_handle,
529                                                    window,
530                                                    cx,
531                                                )
532                                                .map(|kb| kb.size(rems_from_px(10.)))
533                                            })
534                                            .on_click(|_event, _window, cx| {
535                                                cx.defer(|cx| {
536                                                    cx.dispatch_action(&git_ui::project_diff::Diff)
537                                                });
538                                            }),
539                                    )
540                                    .child(
541                                        Button::new("commit", "Commit Changes")
542                                            .label_size(LabelSize::XSmall)
543                                            .key_binding({
544                                                let focus_handle = focus_handle.clone();
545                                                KeyBinding::for_action_in(
546                                                    &ExpandCommitEditor,
547                                                    &focus_handle,
548                                                    window,
549                                                    cx,
550                                                )
551                                                .map(|kb| kb.size(rems_from_px(10.)))
552                                            })
553                                            .on_click(|_event, _window, cx| {
554                                                cx.defer(|cx| {
555                                                    cx.dispatch_action(&ExpandCommitEditor)
556                                                });
557                                            }),
558                                    ),
559                            ),
560                    )
561                },
562            )
563            .child(
564                v_flex()
565                    .key_context("MessageEditor")
566                    .on_action(cx.listener(Self::chat))
567                    .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
568                        this.profile_selector.read(cx).menu_handle().toggle(window, cx);
569                    }))
570                    .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
571                        this.model_selector
572                            .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
573                    }))
574                    .on_action(cx.listener(Self::toggle_context_picker))
575                    .on_action(cx.listener(Self::remove_all_context))
576                    .on_action(cx.listener(Self::move_up))
577                    .on_action(cx.listener(Self::toggle_chat_mode))
578                    .gap_2()
579                    .p_2()
580                    .bg(editor_bg_color)
581                    .border_t_1()
582                    .border_color(cx.theme().colors().border)
583                    .child(h_flex().justify_between().child(self.context_strip.clone()))
584                    .child(
585                        v_flex()
586                            .gap_5()
587                            .child({
588                                let settings = ThemeSettings::get_global(cx);
589                                let text_style = TextStyle {
590                                    color: cx.theme().colors().text,
591                                    font_family: settings.ui_font.family.clone(),
592                                    font_fallbacks: settings.ui_font.fallbacks.clone(),
593                                    font_features: settings.ui_font.features.clone(),
594                                    font_size: font_size.into(),
595                                    font_weight: settings.ui_font.weight,
596                                    line_height: line_height.into(),
597                                    ..Default::default()
598                                };
599
600                                EditorElement::new(
601                                    &self.editor,
602                                    EditorStyle {
603                                        background: editor_bg_color,
604                                        local_player: cx.theme().players().local(),
605                                        text: text_style,
606                                        syntax: cx.theme().syntax().clone(),
607                                        ..Default::default()
608                                    },
609                                )
610                            })
611                            .child(
612                                PopoverMenu::new("inline-context-picker")
613                                    .menu(move |window, cx| {
614                                        inline_context_picker.update(cx, |this, cx| {
615                                            this.init(window, cx);
616                                        });
617
618                                        Some(inline_context_picker.clone())
619                                    })
620                                    .attach(gpui::Corner::TopLeft)
621                                    .anchor(gpui::Corner::BottomLeft)
622                                    .offset(gpui::Point {
623                                        x: px(0.0),
624                                        y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
625                                            - px(4.0),
626                                    })
627                                    .with_handle(self.inline_context_picker_menu_handle.clone()),
628                            )
629                            .child(
630                                h_flex()
631                                    .justify_between()
632                                    .child(h_flex().gap_2().child(self.profile_selector.clone()))
633                                    .child(
634                                        h_flex().gap_1().child(self.model_selector.clone()).child(
635                                            ButtonLike::new("submit-message")
636                                                .width(button_width.into())
637                                                .style(ButtonStyle::Filled)
638                                                .disabled(
639                                                    is_editor_empty
640                                                        || !is_model_selected
641                                                        || is_generating,
642                                                )
643                                                .child(
644                                                    h_flex()
645                                                        .w_full()
646                                                        .justify_between()
647                                                        .child(
648                                                            Label::new("Submit")
649                                                                .size(LabelSize::Small)
650                                                                .color(submit_label_color),
651                                                        )
652                                                        .children(
653                                                            KeyBinding::for_action_in(
654                                                                &Chat,
655                                                                &focus_handle,
656                                                                window,
657                                                                cx,
658                                                            )
659                                                            .map(|binding| {
660                                                                binding
661                                                                    .when(vim_mode_enabled, |kb| {
662                                                                        kb.size(rems_from_px(12.))
663                                                                    })
664                                                                    .into_any_element()
665                                                            }),
666                                                        ),
667                                                )
668                                                .on_click(move |_event, window, cx| {
669                                                    focus_handle.dispatch_action(&Chat, window, cx);
670                                                })
671                                                .when(is_editor_empty, |button| {
672                                                    button.tooltip(Tooltip::text(
673                                                        "Type a message to submit",
674                                                    ))
675                                                })
676                                                .when(is_generating, |button| {
677                                                    button.tooltip(Tooltip::text(
678                                                        "Cancel to submit a new message",
679                                                    ))
680                                                })
681                                                .when(!is_model_selected, |button| {
682                                                    button.tooltip(Tooltip::text(
683                                                        "Select a model to continue",
684                                                    ))
685                                                }),
686                                        ),
687                                    ),
688                            ),
689                    ),
690            )
691    }
692}