assistant: Add the "create your command" item (#16467)

Piotr Osiewicz and Danilo Leal created

This PR adds an extra item to the slash command picker that links users to the doc that teaches how to create a custom one.

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>

Change summary

crates/assistant/src/slash_command_picker.rs | 172 ++++++++++++++++++---
crates/picker/src/picker.rs                  |   2 
2 files changed, 144 insertions(+), 30 deletions(-)

Detailed changes

crates/assistant/src/slash_command_picker.rs 🔗

@@ -1,9 +1,11 @@
+use std::sync::Arc;
+
 use assistant_slash_command::SlashCommandRegistry;
+use gpui::AnyElement;
 use gpui::DismissEvent;
 use gpui::WeakView;
 use picker::PickerEditorPosition;
 
-use std::sync::Arc;
 use ui::ListItemSpacing;
 
 use gpui::SharedString;
@@ -24,11 +26,31 @@ pub(super) struct SlashCommandSelector<T: PopoverTrigger> {
 struct SlashCommandInfo {
     name: SharedString,
     description: SharedString,
+    args: Option<SharedString>,
+}
+
+#[derive(Clone)]
+enum SlashCommandEntry {
+    Info(SlashCommandInfo),
+    Advert {
+        name: SharedString,
+        renderer: fn(&mut WindowContext<'_>) -> AnyElement,
+        on_confirm: fn(&mut WindowContext<'_>),
+    },
+}
+
+impl AsRef<str> for SlashCommandEntry {
+    fn as_ref(&self) -> &str {
+        match self {
+            SlashCommandEntry::Info(SlashCommandInfo { name, .. })
+            | SlashCommandEntry::Advert { name, .. } => name,
+        }
+    }
 }
 
 pub(crate) struct SlashCommandDelegate {
-    all_commands: Vec<SlashCommandInfo>,
-    filtered_commands: Vec<SlashCommandInfo>,
+    all_commands: Vec<SlashCommandEntry>,
+    filtered_commands: Vec<SlashCommandEntry>,
     active_context_editor: WeakView<ContextEditor>,
     selected_index: usize,
 }
@@ -80,7 +102,7 @@ impl PickerDelegate for SlashCommandDelegate {
                             .into_iter()
                             .filter(|model_info| {
                                 model_info
-                                    .name
+                                    .as_ref()
                                     .to_lowercase()
                                     .contains(&query.to_lowercase())
                             })
@@ -98,13 +120,42 @@ impl PickerDelegate for SlashCommandDelegate {
         })
     }
 
+    fn separators_after_indices(&self) -> Vec<usize> {
+        let mut ret = vec![];
+        let mut previous_is_advert = false;
+
+        for (index, command) in self.filtered_commands.iter().enumerate() {
+            if previous_is_advert {
+                if let SlashCommandEntry::Info(_) = command {
+                    previous_is_advert = false;
+                    debug_assert_ne!(
+                        index, 0,
+                        "index cannot be zero, as we can never have a separator at 0th position"
+                    );
+                    ret.push(index - 1);
+                }
+            } else {
+                if let SlashCommandEntry::Advert { .. } = command {
+                    previous_is_advert = true;
+                    if index != 0 {
+                        ret.push(index - 1);
+                    }
+                }
+            }
+        }
+        ret
+    }
     fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
         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();
+            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);
+            }
             cx.emit(DismissEvent);
         }
     }
@@ -119,30 +170,63 @@ impl PickerDelegate for SlashCommandDelegate {
         &self,
         ix: usize,
         selected: bool,
-        _: &mut ViewContext<Picker<Self>>,
+        cx: &mut ViewContext<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         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),
-                            )
+        match command_info {
+            SlashCommandEntry::Info(info) => Some(
+                ListItem::new(ix)
+                    .inset(true)
+                    .spacing(ListItemSpacing::Sparse)
+                    .selected(selected)
+                    .child(
+                        h_flex()
+                            .group(format!("command-entry-label-{ix}"))
+                            .w_full()
+                            .min_w(px(220.))
                             .child(
-                                Label::new(command_info.description.clone())
-                                    .size(LabelSize::Small)
-                                    .color(Color::Muted),
+                                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),
+                                                        )
+                                                        .visible_on_hover(format!(
+                                                            "command-entry-label-{ix}"
+                                                        ))
+                                                },
+                                            )),
+                                    )
+                                    .child(
+                                        Label::new(info.description.clone())
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted),
+                                    ),
                             ),
                     ),
-                ),
-        )
+            ),
+            SlashCommandEntry::Advert { renderer, .. } => Some(
+                ListItem::new(ix)
+                    .inset(true)
+                    .spacing(ListItemSpacing::Sparse)
+                    .selected(selected)
+                    .child(renderer(cx)),
+            ),
+        }
     }
 }
 
@@ -155,11 +239,41 @@ impl<T: PopoverTrigger> RenderOnce for SlashCommandSelector<T> {
             .filter_map(|command_name| {
                 let command = self.registry.command(&command_name)?;
                 let menu_text = SharedString::from(Arc::from(command.menu_text()));
-                Some(SlashCommandInfo {
+                let label = command.label(cx);
+                let args = label.filter_range.end.ne(&label.text.len()).then(|| {
+                    SharedString::from(
+                        label.text[label.filter_range.end..label.text.len()].to_owned(),
+                    )
+                });
+                Some(SlashCommandEntry::Info(SlashCommandInfo {
                     name: command_name.into(),
                     description: menu_text,
-                })
+                    args,
+                }))
             })
+            .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()
+                },
+                on_confirm: |cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
+            }])
             .collect::<Vec<_>>();
 
         let delegate = SlashCommandDelegate {

crates/picker/src/picker.rs 🔗

@@ -524,7 +524,7 @@ impl<D: PickerDelegate> Picker<D> {
                     picker
                         .border_color(cx.theme().colors().border_variant)
                         .border_b_1()
-                        .pb(px(-1.0))
+                        .py(px(-1.0))
                 },
             )
     }