message_editor.rs

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