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",