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