message_editor.rs

  1use std::sync::Arc;
  2
  3use editor::actions::MoveUp;
  4use editor::{Editor, EditorElement, EditorEvent, EditorStyle};
  5use fs::Fs;
  6use gpui::{
  7    pulsating_between, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription,
  8    TextStyle, WeakEntity,
  9};
 10use language_model::LanguageModelRegistry;
 11use rope::Point;
 12use settings::Settings;
 13use std::time::Duration;
 14use text::Bias;
 15use theme::ThemeSettings;
 16use ui::{
 17    prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Switch,
 18    TintColor, Tooltip,
 19};
 20use vim_mode_setting::VimModeSetting;
 21use workspace::Workspace;
 22
 23use crate::assistant_model_selector::AssistantModelSelector;
 24use crate::context_picker::{ConfirmBehavior, ContextPicker};
 25use crate::context_store::{refresh_context_store_text, ContextStore};
 26use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 27use crate::thread::{RequestKind, Thread};
 28use crate::thread_store::ThreadStore;
 29use crate::{Chat, ChatMode, RemoveAllContext, ToggleContextPicker};
 30
 31pub struct MessageEditor {
 32    thread: Entity<Thread>,
 33    editor: Entity<Editor>,
 34    context_store: Entity<ContextStore>,
 35    context_strip: Entity<ContextStrip>,
 36    context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 37    inline_context_picker: Entity<ContextPicker>,
 38    inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
 39    model_selector: Entity<AssistantModelSelector>,
 40    use_tools: bool,
 41    _subscriptions: Vec<Subscription>,
 42}
 43
 44impl MessageEditor {
 45    pub fn new(
 46        fs: Arc<dyn Fs>,
 47        workspace: WeakEntity<Workspace>,
 48        thread_store: WeakEntity<ThreadStore>,
 49        thread: Entity<Thread>,
 50        window: &mut Window,
 51        cx: &mut Context<Self>,
 52    ) -> Self {
 53        let context_store = cx.new(|_cx| ContextStore::new(workspace.clone()));
 54        let context_picker_menu_handle = PopoverMenuHandle::default();
 55        let inline_context_picker_menu_handle = PopoverMenuHandle::default();
 56
 57        let editor = cx.new(|cx| {
 58            let mut editor = Editor::auto_height(10, window, cx);
 59            editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx);
 60            editor.set_show_indent_guides(false, cx);
 61
 62            editor
 63        });
 64
 65        let inline_context_picker = cx.new(|cx| {
 66            ContextPicker::new(
 67                workspace.clone(),
 68                Some(thread_store.clone()),
 69                context_store.downgrade(),
 70                editor.downgrade(),
 71                ConfirmBehavior::Close,
 72                window,
 73                cx,
 74            )
 75        });
 76
 77        let context_strip = cx.new(|cx| {
 78            ContextStrip::new(
 79                context_store.clone(),
 80                workspace.clone(),
 81                editor.downgrade(),
 82                Some(thread_store.clone()),
 83                context_picker_menu_handle.clone(),
 84                SuggestContextKind::File,
 85                window,
 86                cx,
 87            )
 88        });
 89
 90        let subscriptions = vec![
 91            cx.subscribe_in(&editor, window, Self::handle_editor_event),
 92            cx.subscribe_in(
 93                &inline_context_picker,
 94                window,
 95                Self::handle_inline_context_picker_event,
 96            ),
 97            cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
 98        ];
 99
100        Self {
101            thread,
102            editor: editor.clone(),
103            context_store,
104            context_strip,
105            context_picker_menu_handle,
106            inline_context_picker,
107            inline_context_picker_menu_handle,
108            model_selector: cx
109                .new(|cx| AssistantModelSelector::new(fs, editor.focus_handle(cx), window, cx)),
110            use_tools: false,
111            _subscriptions: subscriptions,
112        }
113    }
114
115    fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
116        self.use_tools = !self.use_tools;
117        cx.notify();
118    }
119
120    fn toggle_context_picker(
121        &mut self,
122        _: &ToggleContextPicker,
123        window: &mut Window,
124        cx: &mut Context<Self>,
125    ) {
126        self.context_picker_menu_handle.toggle(window, cx);
127    }
128
129    pub fn remove_all_context(
130        &mut self,
131        _: &RemoveAllContext,
132        _window: &mut Window,
133        cx: &mut Context<Self>,
134    ) {
135        self.context_store.update(cx, |store, _cx| store.clear());
136        cx.notify();
137    }
138
139    fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context<Self>) {
140        self.send_to_model(RequestKind::Chat, window, cx);
141    }
142
143    fn is_editor_empty(&self, cx: &App) -> bool {
144        self.editor.read(cx).text(cx).is_empty()
145    }
146
147    fn is_model_selected(&self, cx: &App) -> bool {
148        LanguageModelRegistry::read_global(cx)
149            .active_model()
150            .is_some()
151    }
152
153    fn send_to_model(
154        &mut self,
155        request_kind: RequestKind,
156        window: &mut Window,
157        cx: &mut Context<Self>,
158    ) {
159        let provider = LanguageModelRegistry::read_global(cx).active_provider();
160        if provider
161            .as_ref()
162            .map_or(false, |provider| provider.must_accept_terms(cx))
163        {
164            cx.notify();
165            return;
166        }
167
168        let model_registry = LanguageModelRegistry::read_global(cx);
169        let Some(model) = model_registry.active_model() else {
170            return;
171        };
172
173        let user_message = self.editor.update(cx, |editor, cx| {
174            let text = editor.text(cx);
175            editor.clear(window, cx);
176            text
177        });
178
179        let refresh_task = refresh_context_store_text(self.context_store.clone(), cx);
180
181        let thread = self.thread.clone();
182        let context_store = self.context_store.clone();
183        let use_tools = self.use_tools;
184        cx.spawn(move |_, mut cx| async move {
185            refresh_task.await;
186            thread
187                .update(&mut cx, |thread, cx| {
188                    let context = context_store.read(cx).snapshot(cx).collect::<Vec<_>>();
189                    thread.insert_user_message(user_message, context, cx);
190                    thread.send_to_model(model, request_kind, use_tools, cx);
191                })
192                .ok();
193        })
194        .detach();
195    }
196
197    fn handle_editor_event(
198        &mut self,
199        editor: &Entity<Editor>,
200        event: &EditorEvent,
201        window: &mut Window,
202        cx: &mut Context<Self>,
203    ) {
204        match event {
205            EditorEvent::SelectionsChanged { .. } => {
206                editor.update(cx, |editor, cx| {
207                    let snapshot = editor.buffer().read(cx).snapshot(cx);
208                    let newest_cursor = editor.selections.newest::<Point>(cx).head();
209                    if newest_cursor.column > 0 {
210                        let behind_cursor = snapshot.clip_point(
211                            Point::new(newest_cursor.row, newest_cursor.column - 1),
212                            Bias::Left,
213                        );
214                        let char_behind_cursor = snapshot.chars_at(behind_cursor).next();
215                        if char_behind_cursor == Some('@') {
216                            self.inline_context_picker_menu_handle.show(window, cx);
217                        }
218                    }
219                });
220            }
221            _ => {}
222        }
223    }
224
225    fn handle_inline_context_picker_event(
226        &mut self,
227        _inline_context_picker: &Entity<ContextPicker>,
228        _event: &DismissEvent,
229        window: &mut Window,
230        cx: &mut Context<Self>,
231    ) {
232        let editor_focus_handle = self.editor.focus_handle(cx);
233        window.focus(&editor_focus_handle);
234    }
235
236    fn handle_context_strip_event(
237        &mut self,
238        _context_strip: &Entity<ContextStrip>,
239        event: &ContextStripEvent,
240        window: &mut Window,
241        cx: &mut Context<Self>,
242    ) {
243        match event {
244            ContextStripEvent::PickerDismissed
245            | ContextStripEvent::BlurredEmpty
246            | ContextStripEvent::BlurredDown => {
247                let editor_focus_handle = self.editor.focus_handle(cx);
248                window.focus(&editor_focus_handle);
249            }
250            ContextStripEvent::BlurredUp => {}
251        }
252    }
253
254    fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
255        if self.context_picker_menu_handle.is_deployed()
256            || self.inline_context_picker_menu_handle.is_deployed()
257        {
258            cx.propagate();
259        } else {
260            self.context_strip.focus_handle(cx).focus(window);
261        }
262    }
263}
264
265impl Focusable for MessageEditor {
266    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
267        self.editor.focus_handle(cx)
268    }
269}
270
271impl Render for MessageEditor {
272    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
273        let font_size = TextSize::Default.rems(cx);
274        let line_height = font_size.to_pixels(window.rem_size()) * 1.5;
275        let focus_handle = self.editor.focus_handle(cx);
276        let inline_context_picker = self.inline_context_picker.clone();
277        let bg_color = cx.theme().colors().editor_background;
278        let is_streaming_completion = self.thread.read(cx).is_streaming();
279        let is_model_selected = self.is_model_selected(cx);
280        let is_editor_empty = self.is_editor_empty(cx);
281        let submit_label_color = if is_editor_empty {
282            Color::Muted
283        } else {
284            Color::Default
285        };
286
287        let vim_mode_enabled = VimModeSetting::get_global(cx).0;
288        let platform = PlatformStyle::platform();
289        let linux = platform == PlatformStyle::Linux;
290        let windows = platform == PlatformStyle::Windows;
291        let button_width = if linux || windows || vim_mode_enabled {
292            px(92.)
293        } else {
294            px(64.)
295        };
296
297        v_flex()
298            .key_context("MessageEditor")
299            .on_action(cx.listener(Self::chat))
300            .on_action(cx.listener(|this, action, window, cx| {
301                let selector = this.model_selector.read(cx).selector.clone();
302                selector.update(cx, |this, cx| {
303                    this.toggle_model_selector(action, window, cx);
304                })
305            }))
306            .on_action(cx.listener(Self::toggle_context_picker))
307            .on_action(cx.listener(Self::remove_all_context))
308            .on_action(cx.listener(Self::move_up))
309            .on_action(cx.listener(Self::toggle_chat_mode))
310            .size_full()
311            .gap_2()
312            .p_2()
313            .bg(bg_color)
314            .child(self.context_strip.clone())
315            .child(
316                v_flex()
317                    .gap_5()
318                    .child({
319                        let settings = ThemeSettings::get_global(cx);
320                        let text_style = TextStyle {
321                            color: cx.theme().colors().text,
322                            font_family: settings.ui_font.family.clone(),
323                            font_features: settings.ui_font.features.clone(),
324                            font_size: font_size.into(),
325                            font_weight: settings.ui_font.weight,
326                            line_height: line_height.into(),
327                            ..Default::default()
328                        };
329
330                        EditorElement::new(
331                            &self.editor,
332                            EditorStyle {
333                                background: bg_color,
334                                local_player: cx.theme().players().local(),
335                                text: text_style,
336                                ..Default::default()
337                            },
338                        )
339                    })
340                    .child(
341                        PopoverMenu::new("inline-context-picker")
342                            .menu(move |window, cx| {
343                                inline_context_picker.update(cx, |this, cx| {
344                                    this.init(window, cx);
345                                });
346
347                                Some(inline_context_picker.clone())
348                            })
349                            .attach(gpui::Corner::TopLeft)
350                            .anchor(gpui::Corner::BottomLeft)
351                            .offset(gpui::Point {
352                                x: px(0.0),
353                                y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2) - px(4.0),
354                            })
355                            .with_handle(self.inline_context_picker_menu_handle.clone()),
356                    )
357                    .child(
358                        h_flex()
359                            .justify_between()
360                            .child(
361                                Switch::new("use-tools", self.use_tools.into())
362                                    .label("Tools")
363                                    .on_click(cx.listener(|this, selection, _window, _cx| {
364                                        this.use_tools = match selection {
365                                            ToggleState::Selected => true,
366                                            ToggleState::Unselected
367                                            | ToggleState::Indeterminate => false,
368                                        };
369                                    }))
370                                    .key_binding(KeyBinding::for_action_in(
371                                        &ChatMode,
372                                        &focus_handle,
373                                        window,
374                                        cx,
375                                    )),
376                            )
377                            .child(h_flex().gap_1().child(self.model_selector.clone()).child(
378                                if is_streaming_completion {
379                                    ButtonLike::new("cancel-generation")
380                                        .width(button_width.into())
381                                        .style(ButtonStyle::Tinted(TintColor::Accent))
382                                        .child(
383                                            h_flex()
384                                                .w_full()
385                                                .justify_between()
386                                                .child(
387                                                    Label::new("Cancel")
388                                                        .size(LabelSize::Small)
389                                                        .with_animation(
390                                                            "pulsating-label",
391                                                            Animation::new(Duration::from_secs(2))
392                                                                .repeat()
393                                                                .with_easing(pulsating_between(
394                                                                    0.4, 0.8,
395                                                                )),
396                                                            |label, delta| label.alpha(delta),
397                                                        ),
398                                                )
399                                                .children(
400                                                    KeyBinding::for_action_in(
401                                                        &editor::actions::Cancel,
402                                                        &focus_handle,
403                                                        window,
404                                                        cx,
405                                                    )
406                                                    .map(|binding| binding.into_any_element()),
407                                                ),
408                                        )
409                                        .on_click(move |_event, window, cx| {
410                                            focus_handle.dispatch_action(
411                                                &editor::actions::Cancel,
412                                                window,
413                                                cx,
414                                            );
415                                        })
416                                } else {
417                                    ButtonLike::new("submit-message")
418                                        .width(button_width.into())
419                                        .style(ButtonStyle::Filled)
420                                        .disabled(is_editor_empty || !is_model_selected)
421                                        .child(
422                                            h_flex()
423                                                .w_full()
424                                                .justify_between()
425                                                .child(
426                                                    Label::new("Submit")
427                                                        .size(LabelSize::Small)
428                                                        .color(submit_label_color),
429                                                )
430                                                .children(
431                                                    KeyBinding::for_action_in(
432                                                        &Chat,
433                                                        &focus_handle,
434                                                        window,
435                                                        cx,
436                                                    )
437                                                    .map(|binding| binding.into_any_element()),
438                                                ),
439                                        )
440                                        .on_click(move |_event, window, cx| {
441                                            focus_handle.dispatch_action(&Chat, window, cx);
442                                        })
443                                        .when(is_editor_empty, |button| {
444                                            button
445                                                .tooltip(Tooltip::text("Type a message to submit"))
446                                        })
447                                        .when(!is_model_selected, |button| {
448                                            button.tooltip(Tooltip::text(
449                                                "Select a model to continue",
450                                            ))
451                                        })
452                                },
453                            )),
454                    ),
455            )
456    }
457}