diff --git a/assets/icons/ellipsis_vertical.svg b/assets/icons/ellipsis_vertical.svg new file mode 100644 index 0000000000..077dbe8778 --- /dev/null +++ b/assets/icons/ellipsis_vertical.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/slash.svg b/assets/icons/slash.svg new file mode 100644 index 0000000000..792c405bb0 --- /dev/null +++ b/assets/icons/slash.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/slash_square.svg b/assets/icons/slash_square.svg new file mode 100644 index 0000000000..8f269ddeb5 --- /dev/null +++ b/assets/icons/slash_square.svg @@ -0,0 +1 @@ + diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 7bb2284b39..085358385f 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -9,6 +9,7 @@ mod model_selector; mod prompt_library; mod prompts; mod slash_command; +mod slash_command_picker; pub mod slash_command_settings; mod streaming_diff; mod terminal_inline_assistant; diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index fea5e10bd9..be945abb53 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -9,6 +9,7 @@ use crate::{ file_command::codeblock_fence_for_path, SlashCommandCompletionProvider, SlashCommandRegistry, }, + slash_command_picker, terminal_inline_assistant::TerminalInlineAssistant, Assist, ConfirmCommand, Context, ContextEvent, ContextId, ContextStore, CycleMessageRole, DeployHistory, DeployPromptLibrary, InlineAssist, InlineAssistId, InlineAssistant, @@ -1718,6 +1719,7 @@ pub struct ContextEditor { assistant_panel: WeakView, error_message: Option, show_accept_terms: bool, + slash_menu_handle: PopoverMenuHandle, } const DEFAULT_TAB_TITLE: &str = "New Context"; @@ -1779,6 +1781,7 @@ impl ContextEditor { assistant_panel, error_message: None, show_accept_terms: false, + slash_menu_handle: Default::default(), }; this.update_message_headers(cx); this.update_image_blocks(cx); @@ -2007,7 +2010,7 @@ impl ContextEditor { .collect() } - fn insert_command(&mut self, name: &str, cx: &mut ViewContext) { + pub fn insert_command(&mut self, name: &str, cx: &mut ViewContext) { if let Some(command) = SlashCommandRegistry::global(cx).command(name) { self.editor.update(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -3589,11 +3592,11 @@ impl ContextEditor { button.tooltip(move |_| tooltip.clone()) }) .layer(ElevationIndex::ModalSurface) + .child(Label::new(button_text)) .children( KeyBinding::for_action_in(&Assist, &focus_handle, cx) .map(|binding| binding.into_any_element()), ) - .child(Label::new(button_text)) .on_click(move |_event, cx| { focus_handle.dispatch_action(&Assist, cx); }) @@ -3623,7 +3626,13 @@ impl Render for ContextEditor { } else { None }; - + let focus_handle = self + .workspace + .update(cx, |workspace, cx| { + Some(workspace.active_item_as::(cx)?.focus_handle(cx)) + }) + .ok() + .flatten(); v_flex() .key_context("ContextEditor") .capture_action(cx.listener(ContextEditor::cancel)) @@ -3700,14 +3709,47 @@ impl Render for ContextEditor { ) }) .child( - h_flex().flex_none().relative().child( + h_flex().w_full().relative().child( h_flex() + .p_2() .w_full() - .absolute() - .right_4() - .bottom_2() - .justify_end() - .child(self.render_send_button(cx)), + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .gap_2() + .child(render_inject_context_menu(cx.view().downgrade(), cx)) + .child( + IconButton::new("quote-button", IconName::Quote) + .icon_size(IconSize::Small) + .on_click(|_, cx| { + cx.dispatch_action(QuoteSelection.boxed_clone()); + }) + .tooltip(move |cx| { + cx.new_view(|cx| { + Tooltip::new("Insert Selection") + .meta("Press to quote via keyboard") + .key_binding(focus_handle.as_ref().and_then( + |handle| { + KeyBinding::for_action_in( + &QuoteSelection, + &handle, + cx, + ) + }, + )) + }) + .into() + }), + ), + ) + .child( + h_flex() + .w_full() + .justify_end() + .child(div().child(self.render_send_button(cx))), + ), ), ) } @@ -3956,6 +3998,37 @@ pub struct ContextEditorToolbarItem { model_selector_menu_handle: PopoverMenuHandle>, } +fn active_editor_focus_handle( + workspace: &WeakView, + cx: &WindowContext<'_>, +) -> Option { + workspace.upgrade().and_then(|workspace| { + Some( + workspace + .read(cx) + .active_item_as::(cx)? + .focus_handle(cx), + ) + }) +} + +fn render_inject_context_menu( + active_context_editor: WeakView, + cx: &mut WindowContext<'_>, +) -> impl IntoElement { + let commands = SlashCommandRegistry::global(cx); + + slash_command_picker::SlashCommandSelector::new( + commands.clone(), + active_context_editor, + IconButton::new("trigger", IconName::SlashSquare) + .icon_size(IconSize::Small) + .tooltip(|cx| { + Tooltip::with_meta("Insert Context", None, "Type / to insert via keyboard", cx) + }), + ) +} + impl ContextEditorToolbarItem { pub fn new( workspace: &Workspace, @@ -3971,70 +4044,6 @@ impl ContextEditorToolbarItem { } } - fn render_inject_context_menu(&self, cx: &mut ViewContext) -> impl Element { - let commands = SlashCommandRegistry::global(cx); - let active_editor_focus_handle = self.workspace.upgrade().and_then(|workspace| { - Some( - workspace - .read(cx) - .active_item_as::(cx)? - .focus_handle(cx), - ) - }); - let active_context_editor = self.active_context_editor.clone(); - - PopoverMenu::new("inject-context-menu") - .trigger(IconButton::new("trigger", IconName::Quote).tooltip(|cx| { - Tooltip::with_meta("Insert Context", None, "Type / to insert via keyboard", cx) - })) - .menu(move |cx| { - let active_context_editor = active_context_editor.clone()?; - ContextMenu::build(cx, |mut menu, _cx| { - for command_name in commands.featured_command_names() { - if let Some(command) = commands.command(&command_name) { - let menu_text = SharedString::from(Arc::from(command.menu_text())); - menu = menu.custom_entry( - { - let command_name = command_name.clone(); - move |_cx| { - h_flex() - .gap_4() - .w_full() - .justify_between() - .child(Label::new(menu_text.clone())) - .child( - Label::new(format!("/{command_name}")) - .color(Color::Muted), - ) - .into_any() - } - }, - { - let active_context_editor = active_context_editor.clone(); - move |cx| { - active_context_editor - .update(cx, |context_editor, cx| { - context_editor.insert_command(&command_name, cx) - }) - .ok(); - } - }, - ) - } - } - - if let Some(active_editor_focus_handle) = active_editor_focus_handle.clone() { - menu = menu - .context(active_editor_focus_handle) - .action("Quote Selection", Box::new(QuoteSelection)); - } - - menu - }) - .into() - }) - } - fn render_remaining_tokens(&self, cx: &mut ViewContext) -> Option { let context = &self .active_context_editor @@ -4081,24 +4090,16 @@ impl ContextEditorToolbarItem { impl Render for ContextEditorToolbarItem { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let left_side = h_flex() + .pl_1() .gap_2() .flex_1() .min_w(rems(DEFAULT_TAB_TITLE.len() as f32)) .when(self.active_context_editor.is_some(), |left_side| { - left_side - .child( - IconButton::new("regenerate-context", IconName::ArrowCircle) - .visible_on_hover("toolbar") - .tooltip(|cx| Tooltip::text("Regenerate Summary", cx)) - .on_click(cx.listener(move |_, _, cx| { - cx.emit(ContextEditorToolbarItemEvent::RegenerateSummary) - })), - ) - .child(self.model_summary_editor.clone()) + left_side.child(self.model_summary_editor.clone()) }); let active_provider = LanguageModelRegistry::read_global(cx).active_provider(); let active_model = LanguageModelRegistry::read_global(cx).active_model(); - + let weak_self = cx.view().downgrade(); let right_side = h_flex() .gap_2() .child( @@ -4148,7 +4149,70 @@ impl Render for ContextEditorToolbarItem { .with_handle(self.model_selector_menu_handle.clone()), ) .children(self.render_remaining_tokens(cx)) - .child(self.render_inject_context_menu(cx)); + .child( + PopoverMenu::new("context-editor-popover") + .trigger( + IconButton::new("context-editor-trigger", IconName::EllipsisVertical) + .icon_size(IconSize::Small) + .tooltip(|cx| Tooltip::text("Open Context Options", cx)), + ) + .menu({ + let weak_self = weak_self.clone(); + move |cx| { + let weak_self = weak_self.clone(); + Some(ContextMenu::build(cx, move |menu, cx| { + let context = weak_self + .update(cx, |this, cx| { + active_editor_focus_handle(&this.workspace, cx) + }) + .ok() + .flatten(); + menu.when_some(context, |menu, context| menu.context(context)) + .entry("Regenerate Context Title", None, { + let weak_self = weak_self.clone(); + move |cx| { + weak_self + .update(cx, |_, cx| { + cx.emit(ContextEditorToolbarItemEvent::RegenerateSummary) + }) + .ok(); + } + }) + .custom_entry( + |_| { + h_flex() + .w_full() + .justify_between() + .gap_2() + .child(Label::new("Insert Context")) + .child(Label::new("/ command").color(Color::Muted)) + .into_any() + }, + { + let weak_self = weak_self.clone(); + move |cx| { + weak_self + .update(cx, |this, cx| { + if let Some(editor) = + &this.active_context_editor + { + editor + .update(cx, |this, cx| { + this.slash_menu_handle + .toggle(cx); + }) + .ok(); + } + }) + .ok(); + } + }, + ) + .action("Insert Selection", QuoteSelection.boxed_clone()) + })) + } + }), + ); h_flex() .size_full() diff --git a/crates/assistant/src/slash_command_picker.rs b/crates/assistant/src/slash_command_picker.rs new file mode 100644 index 0000000000..0675964279 --- /dev/null +++ b/crates/assistant/src/slash_command_picker.rs @@ -0,0 +1,201 @@ +use assistant_slash_command::SlashCommandRegistry; +use gpui::DismissEvent; +use gpui::WeakView; +use picker::PickerEditorPosition; + +use std::sync::Arc; +use ui::ListItemSpacing; + +use gpui::SharedString; +use gpui::Task; +use picker::{Picker, PickerDelegate}; +use ui::{prelude::*, ListItem, PopoverMenu, PopoverMenuHandle, PopoverTrigger}; + +use crate::assistant_panel::ContextEditor; + +#[derive(IntoElement)] +pub struct SlashCommandSelector { + handle: Option>>, + registry: Arc, + active_context_editor: WeakView, + trigger: T, + info_text: Option, +} + +#[derive(Clone)] +struct SlashCommandInfo { + name: SharedString, + description: SharedString, +} + +pub struct SlashCommandDelegate { + all_commands: Vec, + filtered_commands: Vec, + active_context_editor: WeakView, + selected_index: usize, +} + +impl SlashCommandSelector { + pub fn new( + registry: Arc, + active_context_editor: WeakView, + trigger: T, + ) -> Self { + SlashCommandSelector { + handle: None, + registry, + active_context_editor, + trigger, + info_text: None, + } + } + + pub fn with_handle(mut self, handle: PopoverMenuHandle>) -> Self { + self.handle = Some(handle); + self + } + + pub fn with_info_text(mut self, text: impl Into) -> Self { + self.info_text = Some(text.into()); + self + } +} + +impl PickerDelegate for SlashCommandDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.filtered_commands.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_index = ix.min(self.filtered_commands.len().saturating_sub(1)); + cx.notify(); + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Select a command...".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let all_commands = self.all_commands.clone(); + cx.spawn(|this, mut cx| async move { + let filtered_commands = cx + .background_executor() + .spawn(async move { + if query.is_empty() { + all_commands + } else { + all_commands + .into_iter() + .filter(|model_info| { + model_info + .name + .to_lowercase() + .contains(&query.to_lowercase()) + }) + .collect() + } + }) + .await; + + this.update(&mut cx, |this, cx| { + this.delegate.filtered_commands = filtered_commands; + this.delegate.set_selected_index(0, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(command) = self.filtered_commands.get(self.selected_index) { + self.active_context_editor + .update(cx, |context_editor, cx| { + context_editor.insert_command(&command.name, cx) + }) + .ok(); + cx.emit(DismissEvent); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::End + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut ViewContext>, + ) -> Option { + let command_info = self.filtered_commands.get(ix)?; + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .child( + h_flex().w_full().min_w(px(220.)).child( + v_flex() + .child( + Label::new(format!("/{}", command_info.name)) + .size(LabelSize::Small), + ) + .child( + Label::new(command_info.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ), + ) + } +} + +impl RenderOnce for SlashCommandSelector { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let all_models = self + .registry + .featured_command_names() + .into_iter() + .filter_map(|command_name| { + let command = self.registry.command(&command_name)?; + let menu_text = SharedString::from(Arc::from(command.menu_text())); + Some(SlashCommandInfo { + name: command_name.into(), + description: menu_text, + }) + }) + .collect::>(); + + let delegate = SlashCommandDelegate { + all_commands: all_models.clone(), + active_context_editor: self.active_context_editor.clone(), + filtered_commands: all_models, + selected_index: 0, + }; + + let picker_view = cx.new_view(|cx| { + let picker = Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into())); + picker + }); + + PopoverMenu::new("model-switcher") + .menu(move |_cx| Some(picker_view.clone())) + .trigger(self.trigger) + .attach(gpui::AnchorCorner::TopLeft) + .anchor(gpui::AnchorCorner::BottomLeft) + .offset(gpui::Point { + x: px(0.0), + y: px(-16.0), + }) + } +} diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 4c10220f37..376810e417 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -51,6 +51,15 @@ pub struct Picker { is_modal: bool, } +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum PickerEditorPosition { + #[default] + /// Render the editor at the start of the picker. Usually the top + Start, + /// Render the editor at the end of the picker. Usually the bottom + End, +} + pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; @@ -103,8 +112,16 @@ pub trait PickerDelegate: Sized + 'static { None } + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::default() + } + fn render_editor(&self, editor: &View, _cx: &mut ViewContext>) -> Div { v_flex() + .when( + self.editor_position() == PickerEditorPosition::End, + |this| this.child(Divider::horizontal()), + ) .child( h_flex() .overflow_hidden() @@ -113,7 +130,10 @@ pub trait PickerDelegate: Sized + 'static { .px_3() .child(editor.clone()), ) - .child(Divider::horizontal()) + .when( + self.editor_position() == PickerEditorPosition::Start, + |this| this.child(Divider::horizontal()), + ) } fn render_match( @@ -555,6 +575,8 @@ impl ModalView for Picker {} impl Render for Picker { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let editor_position = self.delegate.editor_position(); + v_flex() .key_context("Picker") .size_full() @@ -574,9 +596,15 @@ impl Render for Picker { .on_action(cx.listener(Self::secondary_confirm)) .on_action(cx.listener(Self::confirm_completion)) .on_action(cx.listener(Self::confirm_input)) - .child(match &self.head { - Head::Editor(editor) => self.delegate.render_editor(&editor.clone(), cx), - Head::Empty(empty_head) => div().child(empty_head.clone()), + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::Start { + Some(self.delegate.render_editor(&editor.clone(), cx)) + } else { + None + } + } + Head::Empty(empty_head) => Some(div().child(empty_head.clone())), }) .when(self.delegate.match_count() > 0, |el| { el.child( @@ -602,5 +630,15 @@ impl Render for Picker { ) }) .children(self.delegate.render_footer(cx)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::End { + Some(self.delegate.render_editor(&editor.clone(), cx)) + } else { + None + } + } + Head::Empty(empty_head) => Some(div().child(empty_head.clone())), + }) } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index dd8612e33a..8961b15149 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -157,6 +157,7 @@ pub enum IconName { Disconnected, Download, Ellipsis, + EllipsisVertical, Envelope, Escape, ExclamationTriangle, @@ -233,6 +234,8 @@ pub enum IconName { Server, Settings, Shift, + Slash, + SlashSquare, Sliders, SlidersAlt, Snip, @@ -320,6 +323,7 @@ impl IconName { IconName::Disconnected => "icons/disconnected.svg", IconName::Download => "icons/download.svg", IconName::Ellipsis => "icons/ellipsis.svg", + IconName::EllipsisVertical => "icons/ellipsis_vertical.svg", IconName::Envelope => "icons/feedback.svg", IconName::Escape => "icons/escape.svg", IconName::ExclamationTriangle => "icons/warning.svg", @@ -396,6 +400,8 @@ impl IconName { IconName::Server => "icons/server.svg", IconName::Settings => "icons/file_icons/settings.svg", IconName::Shift => "icons/shift.svg", + IconName::Slash => "icons/slash.svg", + IconName::SlashSquare => "icons/slash_square.svg", IconName::Sliders => "icons/sliders.svg", IconName::SlidersAlt => "icons/sliders-alt.svg", IconName::Snip => "icons/snip.svg",