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