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