use std::sync::Arc; use editor::actions::MoveUp; use editor::{Editor, EditorElement, EditorEvent, EditorStyle}; use fs::Fs; use gpui::{ Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle, WeakEntity, }; use language_model::LanguageModelRegistry; use language_model_selector::ToggleModelSelector; use rope::Point; use settings::Settings; use std::time::Duration; use text::Bias; use theme::ThemeSettings; use ui::{ prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Switch, Tooltip, }; use vim_mode_setting::VimModeSetting; use workspace::Workspace; use crate::assistant_model_selector::AssistantModelSelector; use crate::context_picker::{ConfirmBehavior, ContextPicker}; use crate::context_store::{refresh_context_store_text, ContextStore}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::thread::{RequestKind, Thread}; use crate::thread_store::ThreadStore; use crate::{Chat, ChatMode, RemoveAllContext, ToggleContextPicker}; pub struct MessageEditor { thread: Entity, editor: Entity, context_store: Entity, context_strip: Entity, context_picker_menu_handle: PopoverMenuHandle, inline_context_picker: Entity, inline_context_picker_menu_handle: PopoverMenuHandle, model_selector: Entity, use_tools: bool, _subscriptions: Vec, } impl MessageEditor { pub fn new( fs: Arc, workspace: WeakEntity, thread_store: WeakEntity, thread: Entity, window: &mut Window, cx: &mut Context, ) -> Self { let context_store = cx.new(|_cx| ContextStore::new(workspace.clone())); let context_picker_menu_handle = PopoverMenuHandle::default(); let inline_context_picker_menu_handle = PopoverMenuHandle::default(); let model_selector_menu_handle = PopoverMenuHandle::default(); let editor = cx.new(|cx| { let mut editor = Editor::auto_height(10, window, cx); editor.set_placeholder_text("Ask anything, @ to mention, ↑ to select", cx); editor.set_show_indent_guides(false, cx); editor }); let inline_context_picker = cx.new(|cx| { ContextPicker::new( workspace.clone(), Some(thread_store.clone()), context_store.downgrade(), editor.downgrade(), ConfirmBehavior::Close, window, cx, ) }); let context_strip = cx.new(|cx| { ContextStrip::new( context_store.clone(), workspace.clone(), editor.downgrade(), Some(thread_store.clone()), context_picker_menu_handle.clone(), SuggestContextKind::File, window, cx, ) }); let subscriptions = vec![ cx.subscribe_in(&editor, window, Self::handle_editor_event), cx.subscribe_in( &inline_context_picker, window, Self::handle_inline_context_picker_event, ), cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event), ]; Self { thread, editor: editor.clone(), context_store, context_strip, context_picker_menu_handle, inline_context_picker, inline_context_picker_menu_handle, model_selector: cx.new(|cx| { AssistantModelSelector::new( fs, model_selector_menu_handle, editor.focus_handle(cx), window, cx, ) }), use_tools: false, _subscriptions: subscriptions, } } fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context) { self.use_tools = !self.use_tools; cx.notify(); } fn toggle_context_picker( &mut self, _: &ToggleContextPicker, window: &mut Window, cx: &mut Context, ) { self.context_picker_menu_handle.toggle(window, cx); } pub fn remove_all_context( &mut self, _: &RemoveAllContext, _window: &mut Window, cx: &mut Context, ) { self.context_store.update(cx, |store, _cx| store.clear()); cx.notify(); } fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { self.send_to_model(RequestKind::Chat, window, cx); } fn is_editor_empty(&self, cx: &App) -> bool { self.editor.read(cx).text(cx).is_empty() } fn is_model_selected(&self, cx: &App) -> bool { LanguageModelRegistry::read_global(cx) .active_model() .is_some() } fn send_to_model( &mut self, request_kind: RequestKind, window: &mut Window, cx: &mut Context, ) { let provider = LanguageModelRegistry::read_global(cx).active_provider(); if provider .as_ref() .map_or(false, |provider| provider.must_accept_terms(cx)) { cx.notify(); return; } let model_registry = LanguageModelRegistry::read_global(cx); let Some(model) = model_registry.active_model() else { return; }; let user_message = self.editor.update(cx, |editor, cx| { let text = editor.text(cx); editor.clear(window, cx); text }); let refresh_task = refresh_context_store_text(self.context_store.clone(), cx); let thread = self.thread.clone(); let context_store = self.context_store.clone(); let use_tools = self.use_tools; cx.spawn(move |_, mut cx| async move { refresh_task.await; thread .update(&mut cx, |thread, cx| { let context = context_store.read(cx).snapshot(cx).collect::>(); thread.insert_user_message(user_message, context, cx); thread.send_to_model(model, request_kind, use_tools, cx); }) .ok(); }) .detach(); } fn handle_editor_event( &mut self, editor: &Entity, event: &EditorEvent, window: &mut Window, cx: &mut Context, ) { match event { EditorEvent::SelectionsChanged { .. } => { editor.update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); let newest_cursor = editor.selections.newest::(cx).head(); if newest_cursor.column > 0 { let behind_cursor = snapshot.clip_point( Point::new(newest_cursor.row, newest_cursor.column - 1), Bias::Left, ); let char_behind_cursor = snapshot.chars_at(behind_cursor).next(); if char_behind_cursor == Some('@') { self.inline_context_picker_menu_handle.show(window, cx); } } }); } _ => {} } } fn handle_inline_context_picker_event( &mut self, _inline_context_picker: &Entity, _event: &DismissEvent, window: &mut Window, cx: &mut Context, ) { let editor_focus_handle = self.editor.focus_handle(cx); window.focus(&editor_focus_handle); } fn handle_context_strip_event( &mut self, _context_strip: &Entity, event: &ContextStripEvent, window: &mut Window, cx: &mut Context, ) { match event { ContextStripEvent::PickerDismissed | ContextStripEvent::BlurredEmpty | ContextStripEvent::BlurredDown => { let editor_focus_handle = self.editor.focus_handle(cx); window.focus(&editor_focus_handle); } ContextStripEvent::BlurredUp => {} } } fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { if self.context_picker_menu_handle.is_deployed() || self.inline_context_picker_menu_handle.is_deployed() { cx.propagate(); } else { self.context_strip.focus_handle(cx).focus(window); } } } impl Focusable for MessageEditor { fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { self.editor.focus_handle(cx) } } impl Render for MessageEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let font_size = TextSize::Default.rems(cx); let line_height = font_size.to_pixels(window.rem_size()) * 1.5; let focus_handle = self.editor.focus_handle(cx); let inline_context_picker = self.inline_context_picker.clone(); let bg_color = cx.theme().colors().editor_background; let is_streaming_completion = self.thread.read(cx).is_streaming(); let is_model_selected = self.is_model_selected(cx); let is_editor_empty = self.is_editor_empty(cx); let submit_label_color = if is_editor_empty { Color::Muted } else { Color::Default }; let vim_mode_enabled = VimModeSetting::get_global(cx).0; let platform = PlatformStyle::platform(); let linux = platform == PlatformStyle::Linux; let windows = platform == PlatformStyle::Windows; let button_width = if linux || windows || vim_mode_enabled { px(82.) } else { px(64.) }; v_flex() .size_full() .when(is_streaming_completion, |parent| { let focus_handle = self.editor.focus_handle(cx).clone(); parent.child( h_flex().py_3().w_full().justify_center().child( h_flex() .flex_none() .pl_2() .pr_1() .py_1() .bg(cx.theme().colors().editor_background) .border_1() .border_color(cx.theme().colors().border_variant) .rounded_lg() .shadow_md() .gap_1() .child( Icon::new(IconName::ArrowCircle) .size(IconSize::XSmall) .color(Color::Muted) .with_animation( "arrow-circle", Animation::new(Duration::from_secs(2)).repeat(), |icon, delta| { icon.transform(gpui::Transformation::rotate( gpui::percentage(delta), )) }, ), ) .child( Label::new("Generating…") .size(LabelSize::XSmall) .color(Color::Muted), ) .child(ui::Divider::vertical()) .child( Button::new("cancel-generation", "Cancel") .label_size(LabelSize::XSmall) .key_binding( KeyBinding::for_action_in( &editor::actions::Cancel, &focus_handle, window, cx, ) .map(|kb| kb.size(rems_from_px(10.))), ) .on_click(move |_event, window, cx| { focus_handle.dispatch_action( &editor::actions::Cancel, window, cx, ); }), ), ), ) }) .child( v_flex() .key_context("MessageEditor") .on_action(cx.listener(Self::chat)) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { this.model_selector .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); })) .on_action(cx.listener(Self::toggle_context_picker)) .on_action(cx.listener(Self::remove_all_context)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::toggle_chat_mode)) .gap_2() .p_2() .bg(bg_color) .border_t_1() .border_color(cx.theme().colors().border) .child(self.context_strip.clone()) .child( v_flex() .gap_5() .child({ let settings = ThemeSettings::get_global(cx); let text_style = TextStyle { color: cx.theme().colors().text, font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features.clone(), font_size: font_size.into(), font_weight: settings.ui_font.weight, line_height: line_height.into(), ..Default::default() }; EditorElement::new( &self.editor, EditorStyle { background: bg_color, local_player: cx.theme().players().local(), text: text_style, ..Default::default() }, ) }) .child( PopoverMenu::new("inline-context-picker") .menu(move |window, cx| { inline_context_picker.update(cx, |this, cx| { this.init(window, cx); }); Some(inline_context_picker.clone()) }) .attach(gpui::Corner::TopLeft) .anchor(gpui::Corner::BottomLeft) .offset(gpui::Point { x: px(0.0), y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2) - px(4.0), }) .with_handle(self.inline_context_picker_menu_handle.clone()), ) .child( h_flex() .justify_between() .child( Switch::new("use-tools", self.use_tools.into()) .label("Tools") .on_click(cx.listener( |this, selection, _window, _cx| { this.use_tools = match selection { ToggleState::Selected => true, ToggleState::Unselected | ToggleState::Indeterminate => false, }; }, )) .key_binding(KeyBinding::for_action_in( &ChatMode, &focus_handle, window, cx, )), ) .child( h_flex().gap_1().child(self.model_selector.clone()).child( ButtonLike::new("submit-message") .width(button_width.into()) .style(ButtonStyle::Filled) .disabled( is_editor_empty || !is_model_selected || is_streaming_completion, ) .child( h_flex() .w_full() .justify_between() .child( Label::new("Submit") .size(LabelSize::Small) .color(submit_label_color), ) .children( KeyBinding::for_action_in( &Chat, &focus_handle, window, cx, ) .map(|binding| { binding .when(vim_mode_enabled, |kb| { kb.size(rems_from_px(12.)) }) .into_any_element() }), ), ) .on_click(move |_event, window, cx| { focus_handle.dispatch_action(&Chat, window, cx); }) .when(is_editor_empty, |button| { button.tooltip(Tooltip::text( "Type a message to submit", )) }) .when(is_streaming_completion, |button| { button.tooltip(Tooltip::text( "Cancel to submit a new message", )) }) .when(!is_model_selected, |button| { button.tooltip(Tooltip::text( "Select a model to continue", )) }), ), ), ), ), ) } }