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    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::notifications::{NotificationId, NotifyTaskExt};
 27use workspace::{Toast, Workspace};
 28
 29use crate::assistant_model_selector::AssistantModelSelector;
 30use crate::context_picker::{ConfirmBehavior, ContextPicker};
 31use crate::context_store::{refresh_context_store_text, ContextStore};
 32use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 33use crate::thread::{RequestKind, Thread};
 34use crate::thread_store::ThreadStore;
 35use crate::tool_selector::ToolSelector;
 36use crate::{Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker};
 37
 38pub struct MessageEditor {
 39    thread: Entity<Thread>,
 40    editor: Entity<Editor>,
 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
148    pub fn remove_all_context(
149        &mut self,
150        _: &RemoveAllContext,
151        _window: &mut Window,
152        cx: &mut Context<Self>,
153    ) {
154        self.context_store.update(cx, |store, _cx| store.clear());
155        cx.notify();
156    }
157
158    fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
159        if self.is_editor_empty(cx) {
160            return;
161        }
162
163        if self.thread.read(cx).is_generating() {
164            return;
165        }
166
167        self.send_to_model(RequestKind::Chat, window, cx);
168    }
169
170    fn is_editor_empty(&self, cx: &App) -> bool {
171        self.editor.read(cx).text(cx).is_empty()
172    }
173
174    fn is_model_selected(&self, cx: &App) -> bool {
175        LanguageModelRegistry::read_global(cx)
176            .active_model()
177            .is_some()
178    }
179
180    fn send_to_model(
181        &mut self,
182        request_kind: RequestKind,
183        window: &mut Window,
184        cx: &mut Context<Self>,
185    ) {
186        let provider = LanguageModelRegistry::read_global(cx).active_provider();
187        if provider
188            .as_ref()
189            .map_or(false, |provider| provider.must_accept_terms(cx))
190        {
191            cx.notify();
192            return;
193        }
194
195        let model_registry = LanguageModelRegistry::read_global(cx);
196        let Some(model) = model_registry.active_model() else {
197            return;
198        };
199
200        let user_message = self.editor.update(cx, |editor, cx| {
201            let text = editor.text(cx);
202            editor.clear(window, cx);
203            text
204        });
205
206        let refresh_task =
207            refresh_context_store_text(self.context_store.clone(), &HashSet::default(), cx);
208
209        let system_prompt_context_task = self.thread.read(cx).load_system_prompt_context(cx);
210
211        let thread = self.thread.clone();
212        let context_store = self.context_store.clone();
213        let git_store = self.project.read(cx).git_store();
214        let checkpoint = git_store.read(cx).checkpoint(cx);
215        cx.spawn(async move |_, cx| {
216            refresh_task.await;
217            let (system_prompt_context, load_error) = system_prompt_context_task.await;
218            thread
219                .update(cx, |thread, cx| {
220                    thread.set_system_prompt_context(system_prompt_context);
221                    if let Some(load_error) = load_error {
222                        cx.emit(ThreadEvent::ShowError(load_error));
223                    }
224                })
225                .ok();
226            let checkpoint = checkpoint.await.log_err();
227            thread
228                .update(cx, |thread, cx| {
229                    let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
230                    thread.insert_user_message(user_message, context, checkpoint, cx);
231                    thread.send_to_model(model, request_kind, cx);
232                })
233                .ok();
234        })
235        .detach();
236    }
237
238    fn handle_editor_event(
239        &mut self,
240        editor: &Entity<Editor>,
241        event: &EditorEvent,
242        window: &mut Window,
243        cx: &mut Context<Self>,
244    ) {
245        match event {
246            EditorEvent::SelectionsChanged { .. } => {
247                editor.update(cx, |editor, cx| {
248                    let snapshot = editor.buffer().read(cx).snapshot(cx);
249                    let newest_cursor = editor.selections.newest::<Point>(cx).head();
250                    if newest_cursor.column > 0 {
251                        let behind_cursor = snapshot.clip_point(
252                            Point::new(newest_cursor.row, newest_cursor.column - 1),
253                            Bias::Left,
254                        );
255                        let char_behind_cursor = snapshot.chars_at(behind_cursor).next();
256                        if char_behind_cursor == Some('@') {
257                            self.inline_context_picker_menu_handle.show(window, cx);
258                        }
259                    }
260                });
261            }
262            _ => {}
263        }
264    }
265
266    fn handle_inline_context_picker_event(
267        &mut self,
268        _inline_context_picker: &Entity<ContextPicker>,
269        _event: &DismissEvent,
270        window: &mut Window,
271        cx: &mut Context<Self>,
272    ) {
273        let editor_focus_handle = self.editor.focus_handle(cx);
274        window.focus(&editor_focus_handle);
275    }
276
277    fn handle_context_strip_event(
278        &mut self,
279        _context_strip: &Entity<ContextStrip>,
280        event: &ContextStripEvent,
281        window: &mut Window,
282        cx: &mut Context<Self>,
283    ) {
284        match event {
285            ContextStripEvent::PickerDismissed
286            | ContextStripEvent::BlurredEmpty
287            | ContextStripEvent::BlurredDown => {
288                let editor_focus_handle = self.editor.focus_handle(cx);
289                window.focus(&editor_focus_handle);
290            }
291            ContextStripEvent::BlurredUp => {}
292        }
293    }
294
295    fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
296        if self.context_picker_menu_handle.is_deployed()
297            || self.inline_context_picker_menu_handle.is_deployed()
298        {
299            cx.propagate();
300        } else {
301            self.context_strip.focus_handle(cx).focus(window);
302        }
303    }
304
305    fn handle_feedback_click(
306        &mut self,
307        is_positive: bool,
308        window: &mut Window,
309        cx: &mut Context<Self>,
310    ) {
311        let workspace = self.workspace.clone();
312        let report = self
313            .thread
314            .update(cx, |thread, cx| thread.report_feedback(is_positive, cx));
315
316        cx.spawn(async move |_, cx| {
317            report.await?;
318            workspace.update(cx, |workspace, cx| {
319                let message = if is_positive {
320                    "Positive feedback recorded. Thank you!"
321                } else {
322                    "Negative feedback recorded. Thank you for helping us improve!"
323                };
324
325                struct ThreadFeedback;
326                let id = NotificationId::unique::<ThreadFeedback>();
327                workspace.show_toast(Toast::new(id, message).autohide(), cx)
328            })
329        })
330        .detach_and_notify_err(window, cx);
331    }
332}
333
334impl Focusable for MessageEditor {
335    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
336        self.editor.focus_handle(cx)
337    }
338}
339
340impl Render for MessageEditor {
341    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
342        let font_size = TextSize::Default.rems(cx);
343        let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
344        let focus_handle = self.editor.focus_handle(cx);
345        let inline_context_picker = self.inline_context_picker.clone();
346        let bg_color = cx.theme().colors().editor_background;
347        let is_generating = self.thread.read(cx).is_generating();
348        let is_model_selected = self.is_model_selected(cx);
349        let is_editor_empty = self.is_editor_empty(cx);
350        let submit_label_color = if is_editor_empty {
351            Color::Muted
352        } else {
353            Color::Default
354        };
355
356        let vim_mode_enabled = VimModeSetting::get_global(cx).0;
357        let platform = PlatformStyle::platform();
358        let linux = platform == PlatformStyle::Linux;
359        let windows = platform == PlatformStyle::Windows;
360        let button_width = if linux || windows || vim_mode_enabled {
361            px(82.)
362        } else {
363            px(64.)
364        };
365
366        let project = self.thread.read(cx).project();
367        let changed_files = if let Some(repository) = project.read(cx).active_repository(cx) {
368            repository.read(cx).status().count()
369        } else {
370            0
371        };
372
373        v_flex()
374            .size_full()
375            .when(is_generating, |parent| {
376                let focus_handle = self.editor.focus_handle(cx).clone();
377                parent.child(
378                    h_flex().py_3().w_full().justify_center().child(
379                        h_flex()
380                            .flex_none()
381                            .pl_2()
382                            .pr_1()
383                            .py_1()
384                            .bg(cx.theme().colors().editor_background)
385                            .border_1()
386                            .border_color(cx.theme().colors().border_variant)
387                            .rounded_lg()
388                            .shadow_md()
389                            .gap_1()
390                            .child(
391                                Icon::new(IconName::ArrowCircle)
392                                    .size(IconSize::XSmall)
393                                    .color(Color::Muted)
394                                    .with_animation(
395                                        "arrow-circle",
396                                        Animation::new(Duration::from_secs(2)).repeat(),
397                                        |icon, delta| {
398                                            icon.transform(gpui::Transformation::rotate(
399                                                gpui::percentage(delta),
400                                            ))
401                                        },
402                                    ),
403                            )
404                            .child(
405                                Label::new("Generating…")
406                                    .size(LabelSize::XSmall)
407                                    .color(Color::Muted),
408                            )
409                            .child(ui::Divider::vertical())
410                            .child(
411                                Button::new("cancel-generation", "Cancel")
412                                    .label_size(LabelSize::XSmall)
413                                    .key_binding(
414                                        KeyBinding::for_action_in(
415                                            &editor::actions::Cancel,
416                                            &focus_handle,
417                                            window,
418                                            cx,
419                                        )
420                                        .map(|kb| kb.size(rems_from_px(10.))),
421                                    )
422                                    .on_click(move |_event, window, cx| {
423                                        focus_handle.dispatch_action(
424                                            &editor::actions::Cancel,
425                                            window,
426                                            cx,
427                                        );
428                                    }),
429                            ),
430                    ),
431                )
432            })
433            .when(changed_files > 0, |parent| {
434                parent.child(
435                    v_flex()
436                        .mx_2()
437                        .bg(cx.theme().colors().element_background)
438                        .border_1()
439                        .border_b_0()
440                        .border_color(cx.theme().colors().border)
441                        .rounded_t_md()
442                        .child(
443                            h_flex()
444                                .justify_between()
445                                .p_2()
446                                .child(
447                                    h_flex()
448                                        .gap_2()
449                                        .child(
450                                            IconButton::new(
451                                                "edits-disclosure",
452                                                IconName::GitBranchSmall,
453                                            )
454                                            .icon_size(IconSize::Small)
455                                            .on_click(
456                                                |_ev, _window, cx| {
457                                                    cx.defer(|cx| {
458                                                        cx.dispatch_action(&git_panel::ToggleFocus)
459                                                    });
460                                                },
461                                            ),
462                                        )
463                                        .child(
464                                            Label::new(format!(
465                                                "{} {} changed",
466                                                changed_files,
467                                                if changed_files == 1 { "file" } else { "files" }
468                                            ))
469                                            .size(LabelSize::XSmall)
470                                            .color(Color::Muted),
471                                        ),
472                                )
473                                .child(
474                                    h_flex()
475                                        .gap_2()
476                                        .child(
477                                            Button::new("review", "Review")
478                                                .label_size(LabelSize::XSmall)
479                                                .on_click(|_event, _window, cx| {
480                                                    cx.defer(|cx| {
481                                                        cx.dispatch_action(
482                                                            &git_ui::project_diff::Diff,
483                                                        );
484                                                    });
485                                                }),
486                                        )
487                                        .child(
488                                            Button::new("commit", "Commit")
489                                                .label_size(LabelSize::XSmall)
490                                                .on_click(|_event, _window, cx| {
491                                                    cx.defer(|cx| {
492                                                        cx.dispatch_action(&ExpandCommitEditor)
493                                                    });
494                                                }),
495                                        ),
496                                ),
497                        ),
498                )
499            })
500            .child(
501                v_flex()
502                    .key_context("MessageEditor")
503                    .on_action(cx.listener(Self::chat))
504                    .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
505                        this.model_selector
506                            .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
507                    }))
508                    .on_action(cx.listener(Self::toggle_context_picker))
509                    .on_action(cx.listener(Self::remove_all_context))
510                    .on_action(cx.listener(Self::move_up))
511                    .on_action(cx.listener(Self::toggle_chat_mode))
512                    .gap_2()
513                    .p_2()
514                    .bg(bg_color)
515                    .border_t_1()
516                    .border_color(cx.theme().colors().border)
517                    .child(
518                        h_flex()
519                            .justify_between()
520                            .child(self.context_strip.clone())
521                            .when(!self.thread.read(cx).is_empty(), |this| {
522                                this.child(
523                                    h_flex()
524                                        .gap_2()
525                                        .child(
526                                            IconButton::new(
527                                                "feedback-thumbs-up",
528                                                IconName::ThumbsUp,
529                                            )
530                                            .style(ButtonStyle::Subtle)
531                                            .icon_size(IconSize::Small)
532                                            .tooltip(Tooltip::text("Helpful"))
533                                            .on_click(
534                                                cx.listener(|this, _, window, cx| {
535                                                    this.handle_feedback_click(true, window, cx);
536                                                }),
537                                            ),
538                                        )
539                                        .child(
540                                            IconButton::new(
541                                                "feedback-thumbs-down",
542                                                IconName::ThumbsDown,
543                                            )
544                                            .style(ButtonStyle::Subtle)
545                                            .icon_size(IconSize::Small)
546                                            .tooltip(Tooltip::text("Not Helpful"))
547                                            .on_click(
548                                                cx.listener(|this, _, window, cx| {
549                                                    this.handle_feedback_click(false, window, cx);
550                                                }),
551                                            ),
552                                        ),
553                                )
554                            }),
555                    )
556                    .child(
557                        v_flex()
558                            .gap_5()
559                            .child({
560                                let settings = ThemeSettings::get_global(cx);
561                                let text_style = TextStyle {
562                                    color: cx.theme().colors().text,
563                                    font_family: settings.ui_font.family.clone(),
564                                    font_fallbacks: settings.ui_font.fallbacks.clone(),
565                                    font_features: settings.ui_font.features.clone(),
566                                    font_size: font_size.into(),
567                                    font_weight: settings.ui_font.weight,
568                                    line_height: line_height.into(),
569                                    ..Default::default()
570                                };
571
572                                EditorElement::new(
573                                    &self.editor,
574                                    EditorStyle {
575                                        background: bg_color,
576                                        local_player: cx.theme().players().local(),
577                                        text: text_style,
578                                        ..Default::default()
579                                    },
580                                )
581                            })
582                            .child(
583                                PopoverMenu::new("inline-context-picker")
584                                    .menu(move |window, cx| {
585                                        inline_context_picker.update(cx, |this, cx| {
586                                            this.init(window, cx);
587                                        });
588
589                                        Some(inline_context_picker.clone())
590                                    })
591                                    .attach(gpui::Corner::TopLeft)
592                                    .anchor(gpui::Corner::BottomLeft)
593                                    .offset(gpui::Point {
594                                        x: px(0.0),
595                                        y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
596                                            - px(4.0),
597                                    })
598                                    .with_handle(self.inline_context_picker_menu_handle.clone()),
599                            )
600                            .child(
601                                h_flex()
602                                    .justify_between()
603                                    .child(h_flex().gap_2().child(self.tool_selector.clone()))
604                                    .child(
605                                        h_flex().gap_1().child(self.model_selector.clone()).child(
606                                            ButtonLike::new("submit-message")
607                                                .width(button_width.into())
608                                                .style(ButtonStyle::Filled)
609                                                .disabled(
610                                                    is_editor_empty
611                                                        || !is_model_selected
612                                                        || is_generating,
613                                                )
614                                                .child(
615                                                    h_flex()
616                                                        .w_full()
617                                                        .justify_between()
618                                                        .child(
619                                                            Label::new("Submit")
620                                                                .size(LabelSize::Small)
621                                                                .color(submit_label_color),
622                                                        )
623                                                        .children(
624                                                            KeyBinding::for_action_in(
625                                                                &Chat,
626                                                                &focus_handle,
627                                                                window,
628                                                                cx,
629                                                            )
630                                                            .map(|binding| {
631                                                                binding
632                                                                    .when(vim_mode_enabled, |kb| {
633                                                                        kb.size(rems_from_px(12.))
634                                                                    })
635                                                                    .into_any_element()
636                                                            }),
637                                                        ),
638                                                )
639                                                .on_click(move |_event, window, cx| {
640                                                    focus_handle.dispatch_action(&Chat, window, cx);
641                                                })
642                                                .when(is_editor_empty, |button| {
643                                                    button.tooltip(Tooltip::text(
644                                                        "Type a message to submit",
645                                                    ))
646                                                })
647                                                .when(is_generating, |button| {
648                                                    button.tooltip(Tooltip::text(
649                                                        "Cancel to submit a new message",
650                                                    ))
651                                                })
652                                                .when(!is_model_selected, |button| {
653                                                    button.tooltip(Tooltip::text(
654                                                        "Select a model to continue",
655                                                    ))
656                                                }),
657                                        ),
658                                    ),
659                            ),
660                    ),
661            )
662    }
663}