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