Adjust design of the slash command picker (#19973)

Danilo Leal created

This PR removes the quote selection icon button from the footer and adds
it in the picker, and adds an icon field to each command entry. Final
result looks like:


https://github.com/user-attachments/assets/d177f1c1-b6f6-4652-9434-f6291b279e34

Release Notes:

- N/A

Change summary

assets/icons/wand.svg                                         |   1 
crates/assistant/src/assistant_panel.rs                       |  39 
crates/assistant/src/slash_command/auto_command.rs            |   6 
crates/assistant/src/slash_command/delta_command.rs           |   5 
crates/assistant/src/slash_command/diagnostics_command.rs     |   4 
crates/assistant/src/slash_command/file_command.rs            |   6 
crates/assistant/src/slash_command/project_command.rs         |   7 
crates/assistant/src/slash_command/prompt_command.rs          |   4 
crates/assistant/src/slash_command/search_command.rs          |   4 
crates/assistant/src/slash_command/symbols_command.rs         |   4 
crates/assistant/src/slash_command/tab_command.rs             |   6 
crates/assistant/src/slash_command/terminal_command.rs        |   4 
crates/assistant/src/slash_command_picker.rs                  | 201 +++-
crates/assistant_slash_command/src/assistant_slash_command.rs |   3 
crates/ui/src/components/icon.rs                              |   1 
15 files changed, 185 insertions(+), 110 deletions(-)

Detailed changes

assets/icons/wand.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wand"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8 19 13"/><path d="M15 9h.01"/><path d="M17.8 6.2 19 5"/><path d="m3 21 9-9"/><path d="M12.2 6.2 11 5"/></svg>

crates/assistant/src/assistant_panel.rs 🔗

@@ -73,12 +73,11 @@ use std::{
 };
 use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
 use text::SelectionGoal;
-use ui::TintColor;
 use ui::{
     prelude::*,
     utils::{format_distance_from_now, DateTimeType},
     Avatar, ButtonLike, ContextMenu, Disclosure, ElevationIndex, KeyBinding, ListItem,
-    ListItemSpacing, PopoverMenu, PopoverMenuHandle, Tooltip,
+    ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip,
 };
 use util::{maybe, ResultExt};
 use workspace::{
@@ -4006,13 +4005,7 @@ impl Render for ContextEditor {
         } else {
             None
         };
-        let focus_handle = self
-            .workspace
-            .update(cx, |workspace, cx| {
-                Some(workspace.active_item_as::<Editor>(cx)?.focus_handle(cx))
-            })
-            .ok()
-            .flatten();
+
         v_flex()
             .key_context("ContextEditor")
             .capture_action(cx.listener(ContextEditor::cancel))
@@ -4060,28 +4053,7 @@ impl Render for ContextEditor {
                         .child(
                             h_flex()
                                 .gap_1()
-                                .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").key_binding(
-                                                    focus_handle.as_ref().and_then(|handle| {
-                                                        KeyBinding::for_action_in(
-                                                            &QuoteSelection,
-                                                            &handle,
-                                                            cx,
-                                                        )
-                                                    }),
-                                                )
-                                            })
-                                            .into()
-                                        }),
-                                ),
+                                .child(render_inject_context_menu(cx.view().downgrade(), cx)),
                         )
                         .child(
                             h_flex()
@@ -4376,6 +4348,7 @@ fn render_inject_context_menu(
         Button::new("trigger", "Add Context")
             .icon(IconName::Plus)
             .icon_size(IconSize::Small)
+            .icon_color(Color::Muted)
             .icon_position(IconPosition::Start)
             .tooltip(|cx| Tooltip::text("Type / to insert via keyboard", cx)),
     )
@@ -4550,7 +4523,7 @@ impl Render for ContextEditorToolbarItem {
                                                 .w_full()
                                                 .justify_between()
                                                 .gap_2()
-                                                .child(Label::new("Insert Context"))
+                                                .child(Label::new("Add Context"))
                                                 .child(Label::new("/ command").color(Color::Muted))
                                                 .into_any()
                                         },
@@ -4574,7 +4547,7 @@ impl Render for ContextEditorToolbarItem {
                                             }
                                         },
                                     )
-                                    .action("Insert Selection", QuoteSelection.boxed_clone())
+                                    .action("Add Selection", QuoteSelection.boxed_clone())
                             }))
                         }
                     }),

crates/assistant/src/slash_command/auto_command.rs 🔗

@@ -14,7 +14,7 @@ use language_model::{
 use semantic_index::{FileSummary, SemanticDb};
 use smol::channel;
 use std::sync::{atomic::AtomicBool, Arc};
-use ui::{BorrowAppContext, WindowContext};
+use ui::{prelude::*, BorrowAppContext, WindowContext};
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -37,6 +37,10 @@ impl SlashCommand for AutoCommand {
         "Automatically infer what context to add".into()
     }
 
+    fn icon(&self) -> IconName {
+        IconName::Wand
+    }
+
     fn menu_text(&self) -> String {
         self.description()
     }

crates/assistant/src/slash_command/delta_command.rs 🔗

@@ -10,6 +10,7 @@ use gpui::{Task, WeakView, WindowContext};
 use language::{BufferSnapshot, LspAdapterDelegate};
 use std::sync::{atomic::AtomicBool, Arc};
 use text::OffsetRangeExt;
+use ui::prelude::*;
 use workspace::Workspace;
 
 pub(crate) struct DeltaSlashCommand;
@@ -27,6 +28,10 @@ impl SlashCommand for DeltaSlashCommand {
         self.description()
     }
 
+    fn icon(&self) -> IconName {
+        IconName::Diff
+    }
+
     fn requires_argument(&self) -> bool {
         false
     }

crates/assistant/src/slash_command/file_command.rs 🔗

@@ -117,7 +117,7 @@ impl SlashCommand for FileSlashCommand {
     }
 
     fn description(&self) -> String {
-        "Insert file".into()
+        "Insert file and/or directory".into()
     }
 
     fn menu_text(&self) -> String {
@@ -128,6 +128,10 @@ impl SlashCommand for FileSlashCommand {
         true
     }
 
+    fn icon(&self) -> IconName {
+        IconName::File
+    }
+
     fn complete_argument(
         self: Arc<Self>,
         arguments: &[String],

crates/assistant/src/slash_command/project_command.rs 🔗

@@ -24,7 +24,8 @@ use std::{
     ops::DerefMut,
     sync::{atomic::AtomicBool, Arc},
 };
-use ui::{BorrowAppContext as _, IconName};
+
+use ui::prelude::*;
 use workspace::Workspace;
 
 pub struct ProjectSlashCommand {
@@ -50,6 +51,10 @@ impl SlashCommand for ProjectSlashCommand {
         "Generate a semantic search based on context".into()
     }
 
+    fn icon(&self) -> IconName {
+        IconName::Folder
+    }
+
     fn menu_text(&self) -> String {
         self.description()
     }

crates/assistant/src/slash_command/tab_command.rs 🔗

@@ -12,7 +12,7 @@ use std::{
     path::PathBuf,
     sync::{atomic::AtomicBool, Arc},
 };
-use ui::{ActiveTheme, WindowContext};
+use ui::{prelude::*, ActiveTheme, WindowContext};
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -31,6 +31,10 @@ impl SlashCommand for TabSlashCommand {
         "Insert open tabs (active tab by default)".to_owned()
     }
 
+    fn icon(&self) -> IconName {
+        IconName::FileTree
+    }
+
     fn menu_text(&self) -> String {
         self.description()
     }

crates/assistant/src/slash_command_picker.rs 🔗

@@ -1,19 +1,13 @@
 use std::sync::Arc;
 
 use assistant_slash_command::SlashCommandRegistry;
-use gpui::AnyElement;
-use gpui::DismissEvent;
-use gpui::WeakView;
-use picker::PickerEditorPosition;
 
-use ui::ListItemSpacing;
-
-use gpui::SharedString;
-use gpui::Task;
-use picker::{Picker, PickerDelegate};
-use ui::{prelude::*, ListItem, PopoverMenu, PopoverTrigger};
+use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakView};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use ui::{prelude::*, KeyBinding, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger};
 
 use crate::assistant_panel::ContextEditor;
+use crate::QuoteSelection;
 
 #[derive(IntoElement)]
 pub(super) struct SlashCommandSelector<T: PopoverTrigger> {
@@ -27,6 +21,7 @@ struct SlashCommandInfo {
     name: SharedString,
     description: SharedString,
     args: Option<SharedString>,
+    icon: IconName,
 }
 
 #[derive(Clone)]
@@ -37,6 +32,7 @@ enum SlashCommandEntry {
         renderer: fn(&mut WindowContext<'_>) -> AnyElement,
         on_confirm: fn(&mut WindowContext<'_>),
     },
+    QuoteButton,
 }
 
 impl AsRef<str> for SlashCommandEntry {
@@ -44,6 +40,7 @@ impl AsRef<str> for SlashCommandEntry {
         match self {
             SlashCommandEntry::Info(SlashCommandInfo { name, .. })
             | SlashCommandEntry::Advert { name, .. } => name,
+            SlashCommandEntry::QuoteButton => "Quote Selection",
         }
     }
 }
@@ -145,16 +142,23 @@ impl PickerDelegate for SlashCommandDelegate {
         }
         ret
     }
+
     fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
         if let Some(command) = self.filtered_commands.get(self.selected_index) {
-            if let SlashCommandEntry::Info(info) = command {
-                self.active_context_editor
-                    .update(cx, |context_editor, cx| {
-                        context_editor.insert_command(&info.name, cx)
-                    })
-                    .ok();
-            } else if let SlashCommandEntry::Advert { on_confirm, .. } = command {
-                on_confirm(cx);
+            match command {
+                SlashCommandEntry::Info(info) => {
+                    self.active_context_editor
+                        .update(cx, |context_editor, cx| {
+                            context_editor.insert_command(&info.name, cx)
+                        })
+                        .ok();
+                }
+                SlashCommandEntry::QuoteButton => {
+                    cx.dispatch_action(Box::new(QuoteSelection));
+                }
+                SlashCommandEntry::Advert { on_confirm, .. } => {
+                    on_confirm(cx);
+                }
             }
             cx.emit(DismissEvent);
         }
@@ -181,46 +185,78 @@ impl PickerDelegate for SlashCommandDelegate {
                     .spacing(ListItemSpacing::Dense)
                     .selected(selected)
                     .child(
-                        h_flex()
+                        v_flex()
                             .group(format!("command-entry-label-{ix}"))
                             .w_full()
                             .min_w(px(250.))
                             .child(
-                                v_flex()
-                                    .child(
-                                        h_flex()
-                                            .child(div().font_buffer(cx).child({
-                                                let mut label = format!("/{}", info.name);
-                                                if let Some(args) =
-                                                    info.args.as_ref().filter(|_| selected)
-                                                {
-                                                    label.push_str(&args);
-                                                }
-                                                Label::new(label).size(LabelSize::Small)
-                                            }))
-                                            .children(info.args.clone().filter(|_| !selected).map(
-                                                |args| {
-                                                    div()
-                                                        .font_buffer(cx)
-                                                        .child(
-                                                            Label::new(args)
-                                                                .size(LabelSize::Small)
-                                                                .color(Color::Muted),
-                                                        )
-                                                        .visible_on_hover(format!(
-                                                            "command-entry-label-{ix}"
-                                                        ))
-                                                },
-                                            )),
-                                    )
-                                    .child(
-                                        Label::new(info.description.clone())
-                                            .size(LabelSize::Small)
-                                            .color(Color::Muted),
-                                    ),
+                                h_flex()
+                                    .gap_1p5()
+                                    .child(Icon::new(info.icon).size(IconSize::XSmall))
+                                    .child(div().font_buffer(cx).child({
+                                        let mut label = format!("{}", info.name);
+                                        if let Some(args) = info.args.as_ref().filter(|_| selected)
+                                        {
+                                            label.push_str(&args);
+                                        }
+                                        Label::new(label).size(LabelSize::Small)
+                                    }))
+                                    .children(info.args.clone().filter(|_| !selected).map(
+                                        |args| {
+                                            div()
+                                                .font_buffer(cx)
+                                                .child(
+                                                    Label::new(args)
+                                                        .size(LabelSize::Small)
+                                                        .color(Color::Muted),
+                                                )
+                                                .visible_on_hover(format!(
+                                                    "command-entry-label-{ix}"
+                                                ))
+                                        },
+                                    )),
+                            )
+                            .child(
+                                Label::new(info.description.clone())
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
                             ),
                     ),
             ),
+            SlashCommandEntry::QuoteButton => {
+                let focus = cx.focus_handle();
+                let key_binding = KeyBinding::for_action_in(&QuoteSelection, &focus, cx);
+
+                Some(
+                    ListItem::new(ix)
+                        .inset(true)
+                        .spacing(ListItemSpacing::Dense)
+                        .selected(selected)
+                        .child(
+                            v_flex()
+                                .child(
+                                    h_flex()
+                                        .gap_1p5()
+                                        .child(Icon::new(IconName::Quote).size(IconSize::XSmall))
+                                        .child(
+                                            div().font_buffer(cx).child(
+                                                Label::new("selection").size(LabelSize::Small),
+                                            ),
+                                        ),
+                                )
+                                .child(
+                                    h_flex()
+                                        .gap_1p5()
+                                        .child(
+                                            Label::new("Insert editor selection")
+                                                .color(Color::Muted)
+                                                .size(LabelSize::Small),
+                                        )
+                                        .children(key_binding.map(|kb| kb.render(cx))),
+                                ),
+                        ),
+                )
+            }
             SlashCommandEntry::Advert { renderer, .. } => Some(
                 ListItem::new(ix)
                     .inset(true)
@@ -251,31 +287,50 @@ impl<T: PopoverTrigger> RenderOnce for SlashCommandSelector<T> {
                     name: command_name.into(),
                     description: menu_text,
                     args,
+                    icon: command.icon(),
                 }))
             })
-            .chain([SlashCommandEntry::Advert {
-                name: "create-your-command".into(),
-                renderer: |cx| {
-                    v_flex()
-                        .child(
-                            h_flex()
-                                .font_buffer(cx)
-                                .items_center()
-                                .gap_1()
-                                .child(div().font_buffer(cx).child(
-                                    Label::new("create-your-command").size(LabelSize::Small),
-                                ))
-                                .child(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall)),
-                        )
-                        .child(
-                            Label::new("Learn how to create a custom command")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        )
-                        .into_any_element()
+            .chain([
+                SlashCommandEntry::Advert {
+                    name: "create-your-command".into(),
+                    renderer: |cx| {
+                        v_flex()
+                            .w_full()
+                            .child(
+                                h_flex()
+                                    .w_full()
+                                    .font_buffer(cx)
+                                    .items_center()
+                                    .justify_between()
+                                    .child(
+                                        h_flex()
+                                            .items_center()
+                                            .gap_1p5()
+                                            .child(Icon::new(IconName::Plus).size(IconSize::XSmall))
+                                            .child(
+                                                div().font_buffer(cx).child(
+                                                    Label::new("create-your-command")
+                                                        .size(LabelSize::Small),
+                                                ),
+                                            ),
+                                    )
+                                    .child(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::XSmall)
+                                            .color(Color::Muted),
+                                    ),
+                            )
+                            .child(
+                                Label::new("Create your custom command")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .into_any_element()
+                    },
+                    on_confirm: |cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
                 },
-                on_confirm: |cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
-            }])
+                SlashCommandEntry::QuoteButton,
+            ])
             .collect::<Vec<_>>();
 
         let delegate = SlashCommandDelegate {

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -62,6 +62,9 @@ pub type SlashCommandResult = Result<BoxStream<'static, Result<SlashCommandEvent
 
 pub trait SlashCommand: 'static + Send + Sync {
     fn name(&self) -> String;
+    fn icon(&self) -> IconName {
+        IconName::Slash
+    }
     fn label(&self, _cx: &AppContext) -> CodeLabel {
         CodeLabel::plain(self.name(), None)
     }