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