assistant: Implement `/selection` slash command (#19988)

Tristan Marechaux , Danilo Leal , and Bennet Bo Fenner created

- Closes #18868

## Summary
This PR introduces a new slash command `/selection` to enhance the
usability of the assistant's quote selection feature.

## Changes Made
1. Extracted a function from the `assistant: quote selection` action to
find the selected text and format it as an assistant section.
2. Created a new slash command `/selection` that utilizes the extracted
function to achieve the same effect as the existing `assistant: quote
selection` action.
3. Updated the documentation to include information about the new
`/selection` slash command.


Release Notes:

- Moved the text selection action to a slash command (`/selection`) in
the assistant panel

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>

Change summary

crates/assistant/src/assistant.rs                       |   4 
crates/assistant/src/assistant_panel.rs                 | 183 +++++-----
crates/assistant/src/slash_command.rs                   |   1 
crates/assistant/src/slash_command/selection_command.rs |  98 +++++
docs/src/assistant/commands.md                          |   9 
5 files changed, 206 insertions(+), 89 deletions(-)

Detailed changes

crates/assistant/src/assistant.rs 🔗

@@ -44,7 +44,8 @@ use settings::{update_settings_file, Settings, SettingsStore};
 use slash_command::{
     auto_command, cargo_workspace_command, context_server_command, default_command, delta_command,
     diagnostics_command, docs_command, fetch_command, file_command, now_command, project_command,
-    prompt_command, search_command, symbols_command, tab_command, terminal_command,
+    prompt_command, search_command, selection_command, symbols_command, tab_command,
+    terminal_command,
 };
 use std::path::PathBuf;
 use std::sync::Arc;
@@ -436,6 +437,7 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
     slash_command_registry
         .register_command(cargo_workspace_command::CargoWorkspaceSlashCommand, true);
     slash_command_registry.register_command(prompt_command::PromptSlashCommand, true);
+    slash_command_registry.register_command(selection_command::SelectionCommand, true);
     slash_command_registry.register_command(default_command::DefaultSlashCommand, false);
     slash_command_registry.register_command(terminal_command::TerminalSlashCommand, true);
     slash_command_registry.register_command(now_command::NowSlashCommand, false);

crates/assistant/src/assistant_panel.rs 🔗

@@ -3025,97 +3025,11 @@ impl ContextEditor {
         let Some(panel) = workspace.panel::<AssistantPanel>(cx) else {
             return;
         };
-        let Some(editor) = workspace
-            .active_item(cx)
-            .and_then(|item| item.act_as::<Editor>(cx))
-        else {
+
+        let Some(creases) = selections_creases(workspace, cx) else {
             return;
         };
 
-        let mut creases = vec![];
-        editor.update(cx, |editor, cx| {
-            let selections = editor.selections.all_adjusted(cx);
-            let buffer = editor.buffer().read(cx).snapshot(cx);
-            for selection in selections {
-                let range = editor::ToOffset::to_offset(&selection.start, &buffer)
-                    ..editor::ToOffset::to_offset(&selection.end, &buffer);
-                let selected_text = buffer.text_for_range(range.clone()).collect::<String>();
-                if selected_text.is_empty() {
-                    continue;
-                }
-                let start_language = buffer.language_at(range.start);
-                let end_language = buffer.language_at(range.end);
-                let language_name = if start_language == end_language {
-                    start_language.map(|language| language.code_fence_block_name())
-                } else {
-                    None
-                };
-                let language_name = language_name.as_deref().unwrap_or("");
-                let filename = buffer
-                    .file_at(selection.start)
-                    .map(|file| file.full_path(cx));
-                let text = if language_name == "markdown" {
-                    selected_text
-                        .lines()
-                        .map(|line| format!("> {}", line))
-                        .collect::<Vec<_>>()
-                        .join("\n")
-                } else {
-                    let start_symbols = buffer
-                        .symbols_containing(selection.start, None)
-                        .map(|(_, symbols)| symbols);
-                    let end_symbols = buffer
-                        .symbols_containing(selection.end, None)
-                        .map(|(_, symbols)| symbols);
-
-                    let outline_text = if let Some((start_symbols, end_symbols)) =
-                        start_symbols.zip(end_symbols)
-                    {
-                        Some(
-                            start_symbols
-                                .into_iter()
-                                .zip(end_symbols)
-                                .take_while(|(a, b)| a == b)
-                                .map(|(a, _)| a.text)
-                                .collect::<Vec<_>>()
-                                .join(" > "),
-                        )
-                    } else {
-                        None
-                    };
-
-                    let line_comment_prefix = start_language
-                        .and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
-
-                    let fence = codeblock_fence_for_path(
-                        filename.as_deref(),
-                        Some(selection.start.row..=selection.end.row),
-                    );
-
-                    if let Some((line_comment_prefix, outline_text)) =
-                        line_comment_prefix.zip(outline_text)
-                    {
-                        let breadcrumb =
-                            format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
-                        format!("{fence}{breadcrumb}{selected_text}\n```")
-                    } else {
-                        format!("{fence}{selected_text}\n```")
-                    }
-                };
-                let crease_title = if let Some(path) = filename {
-                    let start_line = selection.start.row + 1;
-                    let end_line = selection.end.row + 1;
-                    if start_line == end_line {
-                        format!("{}, Line {}", path.display(), start_line)
-                    } else {
-                        format!("{}, Lines {} to {}", path.display(), start_line, end_line)
-                    }
-                } else {
-                    "Quoted selection".to_string()
-                };
-                creases.push((text, crease_title));
-            }
-        });
         if creases.is_empty() {
             return;
         }
@@ -4006,6 +3920,99 @@ fn find_surrounding_code_block(snapshot: &BufferSnapshot, offset: usize) -> Opti
     None
 }
 
+pub fn selections_creases(
+    workspace: &mut workspace::Workspace,
+    cx: &mut ViewContext<Workspace>,
+) -> Option<Vec<(String, String)>> {
+    let editor = workspace
+        .active_item(cx)
+        .and_then(|item| item.act_as::<Editor>(cx))?;
+
+    let mut creases = vec![];
+    editor.update(cx, |editor, cx| {
+        let selections = editor.selections.all_adjusted(cx);
+        let buffer = editor.buffer().read(cx).snapshot(cx);
+        for selection in selections {
+            let range = editor::ToOffset::to_offset(&selection.start, &buffer)
+                ..editor::ToOffset::to_offset(&selection.end, &buffer);
+            let selected_text = buffer.text_for_range(range.clone()).collect::<String>();
+            if selected_text.is_empty() {
+                continue;
+            }
+            let start_language = buffer.language_at(range.start);
+            let end_language = buffer.language_at(range.end);
+            let language_name = if start_language == end_language {
+                start_language.map(|language| language.code_fence_block_name())
+            } else {
+                None
+            };
+            let language_name = language_name.as_deref().unwrap_or("");
+            let filename = buffer
+                .file_at(selection.start)
+                .map(|file| file.full_path(cx));
+            let text = if language_name == "markdown" {
+                selected_text
+                    .lines()
+                    .map(|line| format!("> {}", line))
+                    .collect::<Vec<_>>()
+                    .join("\n")
+            } else {
+                let start_symbols = buffer
+                    .symbols_containing(selection.start, None)
+                    .map(|(_, symbols)| symbols);
+                let end_symbols = buffer
+                    .symbols_containing(selection.end, None)
+                    .map(|(_, symbols)| symbols);
+
+                let outline_text =
+                    if let Some((start_symbols, end_symbols)) = start_symbols.zip(end_symbols) {
+                        Some(
+                            start_symbols
+                                .into_iter()
+                                .zip(end_symbols)
+                                .take_while(|(a, b)| a == b)
+                                .map(|(a, _)| a.text)
+                                .collect::<Vec<_>>()
+                                .join(" > "),
+                        )
+                    } else {
+                        None
+                    };
+
+                let line_comment_prefix = start_language
+                    .and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
+
+                let fence = codeblock_fence_for_path(
+                    filename.as_deref(),
+                    Some(selection.start.row..=selection.end.row),
+                );
+
+                if let Some((line_comment_prefix, outline_text)) =
+                    line_comment_prefix.zip(outline_text)
+                {
+                    let breadcrumb = format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
+                    format!("{fence}{breadcrumb}{selected_text}\n```")
+                } else {
+                    format!("{fence}{selected_text}\n```")
+                }
+            };
+            let crease_title = if let Some(path) = filename {
+                let start_line = selection.start.row + 1;
+                let end_line = selection.end.row + 1;
+                if start_line == end_line {
+                    format!("{}, Line {}", path.display(), start_line)
+                } else {
+                    format!("{}, Lines {} to {}", path.display(), start_line, end_line)
+                }
+            } else {
+                "Quoted selection".to_string()
+            };
+            creases.push((text, crease_title));
+        }
+    });
+    Some(creases)
+}
+
 fn render_fold_icon_button(
     editor: WeakView<Editor>,
     icon: IconName,

crates/assistant/src/slash_command.rs 🔗

@@ -31,6 +31,7 @@ pub mod now_command;
 pub mod project_command;
 pub mod prompt_command;
 pub mod search_command;
+pub mod selection_command;
 pub mod streaming_example_command;
 pub mod symbols_command;
 pub mod tab_command;

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

@@ -0,0 +1,98 @@
+use crate::assistant_panel::selections_creases;
+use anyhow::{anyhow, Result};
+use assistant_slash_command::{
+    ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
+    SlashCommandOutputSection, SlashCommandResult,
+};
+use futures::StreamExt;
+use gpui::{AppContext, Task, WeakView};
+use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+use ui::{IconName, SharedString, WindowContext};
+use workspace::Workspace;
+
+pub(crate) struct SelectionCommand;
+
+impl SlashCommand for SelectionCommand {
+    fn name(&self) -> String {
+        "selection".into()
+    }
+
+    fn label(&self, _cx: &AppContext) -> CodeLabel {
+        CodeLabel::plain(self.name(), None)
+    }
+
+    fn description(&self) -> String {
+        "Insert editor selection".into()
+    }
+
+    fn icon(&self) -> IconName {
+        IconName::Quote
+    }
+
+    fn menu_text(&self) -> String {
+        self.description()
+    }
+
+    fn requires_argument(&self) -> bool {
+        false
+    }
+
+    fn accepts_arguments(&self) -> bool {
+        true
+    }
+
+    fn complete_argument(
+        self: Arc<Self>,
+        _arguments: &[String],
+        _cancel: Arc<AtomicBool>,
+        _workspace: Option<WeakView<Workspace>>,
+        _cx: &mut WindowContext,
+    ) -> Task<Result<Vec<ArgumentCompletion>>> {
+        Task::ready(Err(anyhow!("this command does not require argument")))
+    }
+
+    fn run(
+        self: Arc<Self>,
+        _arguments: &[String],
+        _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
+        _context_buffer: BufferSnapshot,
+        workspace: WeakView<Workspace>,
+        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
+        cx: &mut WindowContext,
+    ) -> Task<SlashCommandResult> {
+        let mut events = vec![];
+
+        let Some(creases) = workspace
+            .update(cx, selections_creases)
+            .unwrap_or_else(|e| {
+                events.push(Err(e));
+                None
+            })
+        else {
+            return Task::ready(Err(anyhow!("no active selection")));
+        };
+
+        for (text, title) in creases {
+            events.push(Ok(SlashCommandEvent::StartSection {
+                icon: IconName::TextSnippet,
+                label: SharedString::from(title),
+                metadata: None,
+            }));
+            events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                text,
+                run_commands_in_text: false,
+            })));
+            events.push(Ok(SlashCommandEvent::EndSection { metadata: None }));
+            events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
+                text: "\n".to_string(),
+                run_commands_in_text: false,
+            })));
+        }
+
+        let result = futures::stream::iter(events).boxed();
+
+        Task::ready(Ok(result))
+    }
+}

docs/src/assistant/commands.md 🔗

@@ -13,6 +13,7 @@ Slash commands enhance the assistant's capabilities. Begin by typing a `/` at th
 - `/symbols`: Inserts the current tab's active symbols into the context
 - `/tab`: Inserts the content of the active tab or all open tabs into the context
 - `/terminal`: Inserts a select number of lines of output from the terminal
+- `/selection`: Inserts the selected text into the context
 
 ### Other Commands:
 
@@ -95,6 +96,14 @@ Usage: `/terminal [<number>]`
 
 - `<number>`: Optional parameter to specify the number of lines to insert (default is a 50).
 
+## `/selection`
+
+The `/selection` command inserts the selected text in the editor into the context. This is useful for referencing specific parts of your code.
+
+This is equivalent to the `assistant: quote selection` command ({#kb assistant::QuoteSelection}). See [Interacting with the Assistant](./assistant-panel.md#interacting-with-the-assistant)).
+
+Usage: `/selection`
+
 ## `/workflow`
 
 The `/workflow` command inserts a prompt that opts into the edit workflow. This sets up the context for the assistant to suggest edits to your code.