message_editor.rs

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