message_editor.rs

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