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