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