message_editor.rs

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