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