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