Extract `ExtensionSlashCommand` to `assistant_slash_command` crate (#20617)

Marshall Bowers created

This PR extracts the `ExtensionSlashCommand` implementation to the
`assistant_slash_command` crate.

The slash command related methods have been added to the `Extension`
trait. We also create separate data types for the slash command data
within the `extension` crate so that we can talk about them without
depending on the `extension_host` or `assistant_slash_command`.

Release Notes:

- N/A

Change summary

Cargo.lock                                                    |   3 
crates/assistant_slash_command/Cargo.toml                     |   3 
crates/assistant_slash_command/src/assistant_slash_command.rs |   4 
crates/assistant_slash_command/src/extension_slash_command.rs | 143 +++++
crates/extension/src/extension.rs                             |  15 
crates/extension/src/slash_command.rs                         |  43 +
crates/extension_host/src/extension_host.rs                   |  10 
crates/extension_host/src/wasm_host.rs                        |  52 +
crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs       |  40 +
crates/extensions_ui/src/extension_registration_hooks.rs      |  18 
crates/extensions_ui/src/extension_slash_command.rs           | 138 ----
crates/extensions_ui/src/extensions_ui.rs                     |   1 
12 files changed, 309 insertions(+), 161 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -459,8 +459,10 @@ name = "assistant_slash_command"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "async-trait",
  "collections",
  "derive_more",
+ "extension",
  "futures 0.3.30",
  "gpui",
  "language",
@@ -469,6 +471,7 @@ dependencies = [
  "pretty_assertions",
  "serde",
  "serde_json",
+ "ui",
  "workspace",
 ]
 

crates/assistant_slash_command/Cargo.toml 🔗

@@ -13,8 +13,10 @@ path = "src/assistant_slash_command.rs"
 
 [dependencies]
 anyhow.workspace = true
+async-trait.workspace = true
 collections.workspace = true
 derive_more.workspace = true
+extension.workspace = true
 futures.workspace = true
 gpui.workspace = true
 language.workspace = true
@@ -22,6 +24,7 @@ language_model.workspace = true
 parking_lot.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+ui.workspace = true
 workspace.workspace = true
 
 [dev-dependencies]

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -1,5 +1,8 @@
+mod extension_slash_command;
 mod slash_command_registry;
 
+pub use crate::extension_slash_command::*;
+pub use crate::slash_command_registry::*;
 use anyhow::Result;
 use futures::stream::{self, BoxStream};
 use futures::StreamExt;
@@ -7,7 +10,6 @@ use gpui::{AnyElement, AppContext, ElementId, SharedString, Task, WeakView, Wind
 use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
 pub use language_model::Role;
 use serde::{Deserialize, Serialize};
-pub use slash_command_registry::*;
 use std::{
     ops::Range,
     sync::{atomic::AtomicBool, Arc},

crates/assistant_slash_command/src/extension_slash_command.rs 🔗

@@ -0,0 +1,143 @@
+use std::path::PathBuf;
+use std::sync::{atomic::AtomicBool, Arc};
+
+use anyhow::Result;
+use async_trait::async_trait;
+use extension::{Extension, WorktreeDelegate};
+use gpui::{Task, WeakView, WindowContext};
+use language::{BufferSnapshot, LspAdapterDelegate};
+use ui::prelude::*;
+use workspace::Workspace;
+
+use crate::{
+    ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
+    SlashCommandResult,
+};
+
+/// An adapter that allows an [`LspAdapterDelegate`] to be used as a [`WorktreeDelegate`].
+struct WorktreeDelegateAdapter(Arc<dyn LspAdapterDelegate>);
+
+#[async_trait]
+impl WorktreeDelegate for WorktreeDelegateAdapter {
+    fn id(&self) -> u64 {
+        self.0.worktree_id().to_proto()
+    }
+
+    fn root_path(&self) -> String {
+        self.0.worktree_root_path().to_string_lossy().to_string()
+    }
+
+    async fn read_text_file(&self, path: PathBuf) -> Result<String> {
+        self.0.read_text_file(path).await
+    }
+
+    async fn which(&self, binary_name: String) -> Option<String> {
+        self.0
+            .which(binary_name.as_ref())
+            .await
+            .map(|path| path.to_string_lossy().to_string())
+    }
+
+    async fn shell_env(&self) -> Vec<(String, String)> {
+        self.0.shell_env().await.into_iter().collect()
+    }
+}
+
+pub struct ExtensionSlashCommand {
+    extension: Arc<dyn Extension>,
+    command: extension::SlashCommand,
+}
+
+impl ExtensionSlashCommand {
+    pub fn new(extension: Arc<dyn Extension>, command: extension::SlashCommand) -> Self {
+        Self { extension, command }
+    }
+}
+
+impl SlashCommand for ExtensionSlashCommand {
+    fn name(&self) -> String {
+        self.command.name.clone()
+    }
+
+    fn description(&self) -> String {
+        self.command.description.clone()
+    }
+
+    fn menu_text(&self) -> String {
+        self.command.tooltip_text.clone()
+    }
+
+    fn requires_argument(&self) -> bool {
+        self.command.requires_argument
+    }
+
+    fn complete_argument(
+        self: Arc<Self>,
+        arguments: &[String],
+        _cancel: Arc<AtomicBool>,
+        _workspace: Option<WeakView<Workspace>>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<Vec<ArgumentCompletion>>> {
+        let command = self.command.clone();
+        let arguments = arguments.to_owned();
+        cx.background_executor().spawn(async move {
+            let completions = self
+                .extension
+                .complete_slash_command_argument(command, arguments)
+                .await?;
+
+            anyhow::Ok(
+                completions
+                    .into_iter()
+                    .map(|completion| ArgumentCompletion {
+                        label: completion.label.into(),
+                        new_text: completion.new_text,
+                        replace_previous_arguments: false,
+                        after_completion: completion.run_command.into(),
+                    })
+                    .collect(),
+            )
+        })
+    }
+
+    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 command = self.command.clone();
+        let arguments = arguments.to_owned();
+        let output = cx.background_executor().spawn(async move {
+            let delegate =
+                delegate.map(|delegate| Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _);
+            let output = self
+                .extension
+                .run_slash_command(command, arguments, delegate)
+                .await?;
+
+            anyhow::Ok(output)
+        });
+        cx.foreground_executor().spawn(async move {
+            let output = output.await?;
+            Ok(SlashCommandOutput {
+                text: output.text,
+                sections: output
+                    .sections
+                    .into_iter()
+                    .map(|section| SlashCommandOutputSection {
+                        range: section.range,
+                        icon: IconName::Code,
+                        label: section.label.into(),
+                        metadata: None,
+                    })
+                    .collect(),
+                run_commands_in_text: false,
+            }
+            .to_event_stream())
+        })
+    }
+}

crates/extension/src/extension.rs 🔗

@@ -1,5 +1,6 @@
 pub mod extension_builder;
 mod extension_manifest;
+mod slash_command;
 
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
@@ -10,6 +11,7 @@ use gpui::Task;
 use semantic_version::SemanticVersion;
 
 pub use crate::extension_manifest::*;
+pub use crate::slash_command::*;
 
 #[async_trait]
 pub trait WorktreeDelegate: Send + Sync + 'static {
@@ -32,6 +34,19 @@ pub trait Extension: Send + Sync + 'static {
     /// Returns the path to this extension's working directory.
     fn work_dir(&self) -> Arc<Path>;
 
+    async fn complete_slash_command_argument(
+        &self,
+        command: SlashCommand,
+        arguments: Vec<String>,
+    ) -> Result<Vec<SlashCommandArgumentCompletion>>;
+
+    async fn run_slash_command(
+        &self,
+        command: SlashCommand,
+        arguments: Vec<String>,
+        resource: Option<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<SlashCommandOutput>;
+
     async fn suggest_docs_packages(&self, provider: Arc<str>) -> Result<Vec<String>>;
 
     async fn index_docs(

crates/extension/src/slash_command.rs 🔗

@@ -0,0 +1,43 @@
+use std::ops::Range;
+
+/// A slash command for use in the Assistant.
+#[derive(Debug, Clone)]
+pub struct SlashCommand {
+    /// The name of the slash command.
+    pub name: String,
+    /// The description of the slash command.
+    pub description: String,
+    /// The tooltip text to display for the run button.
+    pub tooltip_text: String,
+    /// Whether this slash command requires an argument.
+    pub requires_argument: bool,
+}
+
+/// The output of a slash command.
+#[derive(Debug, Clone)]
+pub struct SlashCommandOutput {
+    /// The text produced by the slash command.
+    pub text: String,
+    /// The list of sections to show in the slash command placeholder.
+    pub sections: Vec<SlashCommandOutputSection>,
+}
+
+/// A section in the slash command output.
+#[derive(Debug, Clone)]
+pub struct SlashCommandOutputSection {
+    /// The range this section occupies.
+    pub range: Range<usize>,
+    /// The label to display in the placeholder for this section.
+    pub label: String,
+}
+
+/// A completion for a slash command argument.
+#[derive(Debug, Clone)]
+pub struct SlashCommandArgumentCompletion {
+    /// The label to display for this completion.
+    pub label: String,
+    /// The new text that should be inserted into the command when this completion is accepted.
+    pub new_text: String,
+    /// Whether the command should be run when accepting this completion.
+    pub run_command: bool,
+}

crates/extension_host/src/extension_host.rs 🔗

@@ -132,9 +132,8 @@ pub trait ExtensionRegistrationHooks: Send + Sync + 'static {
 
     fn register_slash_command(
         &self,
-        _slash_command: wit::SlashCommand,
-        _extension: WasmExtension,
-        _host: Arc<WasmHost>,
+        _extension: Arc<dyn Extension>,
+        _command: extension::SlashCommand,
     ) {
     }
 
@@ -1250,7 +1249,8 @@ impl ExtensionStore {
 
                     for (slash_command_name, slash_command) in &manifest.slash_commands {
                         this.registration_hooks.register_slash_command(
-                            crate::wit::SlashCommand {
+                            extension.clone(),
+                            extension::SlashCommand {
                                 name: slash_command_name.to_string(),
                                 description: slash_command.description.to_string(),
                                 // We don't currently expose this as a configurable option, as it currently drives
@@ -1259,8 +1259,6 @@ impl ExtensionStore {
                                 tooltip_text: String::new(),
                                 requires_argument: slash_command.requires_argument,
                             },
-                            wasm_extension.clone(),
-                            this.wasm_host.clone(),
                         );
                     }
 

crates/extension_host/src/wasm_host.rs 🔗

@@ -3,7 +3,10 @@ pub mod wit;
 use crate::{ExtensionManifest, ExtensionRegistrationHooks};
 use anyhow::{anyhow, bail, Context as _, Result};
 use async_trait::async_trait;
-use extension::KeyValueStoreDelegate;
+use extension::{
+    KeyValueStoreDelegate, SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput,
+    WorktreeDelegate,
+};
 use fs::{normalize_path, Fs};
 use futures::future::LocalBoxFuture;
 use futures::{
@@ -29,7 +32,7 @@ use wasmtime::{
 };
 use wasmtime_wasi::{self as wasi, WasiView};
 use wit::Extension;
-pub use wit::{ExtensionProject, SlashCommand};
+pub use wit::ExtensionProject;
 
 pub struct WasmHost {
     engine: Engine,
@@ -62,6 +65,51 @@ impl extension::Extension for WasmExtension {
         self.work_dir.clone()
     }
 
+    async fn complete_slash_command_argument(
+        &self,
+        command: SlashCommand,
+        arguments: Vec<String>,
+    ) -> Result<Vec<SlashCommandArgumentCompletion>> {
+        self.call(|extension, store| {
+            async move {
+                let completions = extension
+                    .call_complete_slash_command_argument(store, &command.into(), &arguments)
+                    .await?
+                    .map_err(|err| anyhow!("{err}"))?;
+
+                Ok(completions.into_iter().map(Into::into).collect())
+            }
+            .boxed()
+        })
+        .await
+    }
+
+    async fn run_slash_command(
+        &self,
+        command: SlashCommand,
+        arguments: Vec<String>,
+        delegate: Option<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<SlashCommandOutput> {
+        self.call(|extension, store| {
+            async move {
+                let resource = if let Some(delegate) = delegate {
+                    Some(store.data_mut().table().push(delegate)?)
+                } else {
+                    None
+                };
+
+                let output = extension
+                    .call_run_slash_command(store, &command.into(), &arguments, resource)
+                    .await?
+                    .map_err(|err| anyhow!("{err}"))?;
+
+                Ok(output.into())
+            }
+            .boxed()
+        })
+        .await
+    }
+
     async fn suggest_docs_packages(&self, provider: Arc<str>) -> Result<Vec<String>> {
         self.call(|extension, store| {
             async move {

crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs 🔗

@@ -1,3 +1,4 @@
+use crate::wasm_host::wit::since_v0_2_0::slash_command::SlashCommandOutputSection;
 use crate::wasm_host::{wit::ToWasmtimeResult, WasmState};
 use ::http_client::{AsyncBody, HttpRequestExt};
 use ::settings::{Settings, WorktreeId};
@@ -54,6 +55,45 @@ pub fn linker() -> &'static Linker<WasmState> {
     LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
 }
 
+impl From<extension::SlashCommand> for SlashCommand {
+    fn from(value: extension::SlashCommand) -> Self {
+        Self {
+            name: value.name,
+            description: value.description,
+            tooltip_text: value.tooltip_text,
+            requires_argument: value.requires_argument,
+        }
+    }
+}
+
+impl From<SlashCommandOutput> for extension::SlashCommandOutput {
+    fn from(value: SlashCommandOutput) -> Self {
+        Self {
+            text: value.text,
+            sections: value.sections.into_iter().map(Into::into).collect(),
+        }
+    }
+}
+
+impl From<SlashCommandOutputSection> for extension::SlashCommandOutputSection {
+    fn from(value: SlashCommandOutputSection) -> Self {
+        Self {
+            range: value.range.start as usize..value.range.end as usize,
+            label: value.label,
+        }
+    }
+}
+
+impl From<SlashCommandArgumentCompletion> for extension::SlashCommandArgumentCompletion {
+    fn from(value: SlashCommandArgumentCompletion) -> Self {
+        Self {
+            label: value.label,
+            new_text: value.new_text,
+            run_command: value.run_command,
+        }
+    }
+}
+
 #[async_trait]
 impl HostKeyValueStore for WasmState {
     async fn insert(

crates/extensions_ui/src/extension_registration_hooks.rs 🔗

@@ -1,7 +1,7 @@
 use std::{path::PathBuf, sync::Arc};
 
 use anyhow::Result;
-use assistant_slash_command::SlashCommandRegistry;
+use assistant_slash_command::{ExtensionSlashCommand, SlashCommandRegistry};
 use context_servers::ContextServerFactoryRegistry;
 use extension::Extension;
 use extension_host::{extension_lsp_adapter::ExtensionLspAdapter, wasm_host};
@@ -14,7 +14,6 @@ use theme::{ThemeRegistry, ThemeSettings};
 use ui::SharedString;
 
 use crate::extension_context_server::ExtensionContextServer;
-use crate::extension_slash_command::ExtensionSlashCommand;
 
 pub struct ConcreteExtensionRegistrationHooks {
     slash_command_registry: Arc<SlashCommandRegistry>,
@@ -61,18 +60,11 @@ impl extension_host::ExtensionRegistrationHooks for ConcreteExtensionRegistratio
 
     fn register_slash_command(
         &self,
-        command: wasm_host::SlashCommand,
-        extension: wasm_host::WasmExtension,
-        host: Arc<wasm_host::WasmHost>,
+        extension: Arc<dyn Extension>,
+        command: extension::SlashCommand,
     ) {
-        self.slash_command_registry.register_command(
-            ExtensionSlashCommand {
-                command,
-                extension,
-                host,
-            },
-            false,
-        )
+        self.slash_command_registry
+            .register_command(ExtensionSlashCommand::new(extension, command), false)
     }
 
     fn register_context_server(

crates/extensions_ui/src/extension_slash_command.rs 🔗

@@ -1,138 +0,0 @@
-use std::sync::{atomic::AtomicBool, Arc};
-
-use anyhow::{anyhow, Result};
-use assistant_slash_command::{
-    ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
-    SlashCommandResult,
-};
-use extension_host::extension_lsp_adapter::WorktreeDelegateAdapter;
-use futures::FutureExt as _;
-use gpui::{Task, WeakView, WindowContext};
-use language::{BufferSnapshot, LspAdapterDelegate};
-use ui::prelude::*;
-use wasmtime_wasi::WasiView;
-use workspace::Workspace;
-
-use extension_host::wasm_host::{WasmExtension, WasmHost};
-
-pub struct ExtensionSlashCommand {
-    pub(crate) extension: WasmExtension,
-    #[allow(unused)]
-    pub(crate) host: Arc<WasmHost>,
-    pub(crate) command: extension_host::wasm_host::SlashCommand,
-}
-
-impl SlashCommand for ExtensionSlashCommand {
-    fn name(&self) -> String {
-        self.command.name.clone()
-    }
-
-    fn description(&self) -> String {
-        self.command.description.clone()
-    }
-
-    fn menu_text(&self) -> String {
-        self.command.tooltip_text.clone()
-    }
-
-    fn requires_argument(&self) -> bool {
-        self.command.requires_argument
-    }
-
-    fn complete_argument(
-        self: Arc<Self>,
-        arguments: &[String],
-        _cancel: Arc<AtomicBool>,
-        _workspace: Option<WeakView<Workspace>>,
-        cx: &mut WindowContext,
-    ) -> Task<Result<Vec<ArgumentCompletion>>> {
-        let arguments = arguments.to_owned();
-        cx.background_executor().spawn(async move {
-            self.extension
-                .call({
-                    let this = self.clone();
-                    move |extension, store| {
-                        async move {
-                            let completions = extension
-                                .call_complete_slash_command_argument(
-                                    store,
-                                    &this.command,
-                                    &arguments,
-                                )
-                                .await?
-                                .map_err(|e| anyhow!("{}", e))?;
-
-                            anyhow::Ok(
-                                completions
-                                    .into_iter()
-                                    .map(|completion| ArgumentCompletion {
-                                        label: completion.label.into(),
-                                        new_text: completion.new_text,
-                                        replace_previous_arguments: false,
-                                        after_completion: completion.run_command.into(),
-                                    })
-                                    .collect(),
-                            )
-                        }
-                        .boxed()
-                    }
-                })
-                .await
-        })
-    }
-
-    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 arguments = arguments.to_owned();
-        let output = cx.background_executor().spawn(async move {
-            self.extension
-                .call({
-                    let this = self.clone();
-                    move |extension, store| {
-                        async move {
-                            let resource = if let Some(delegate) = delegate {
-                                let delegate =
-                                    Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
-                                Some(store.data_mut().table().push(delegate)?)
-                            } else {
-                                None
-                            };
-                            let output = extension
-                                .call_run_slash_command(store, &this.command, &arguments, resource)
-                                .await?
-                                .map_err(|e| anyhow!("{}", e))?;
-
-                            anyhow::Ok(output)
-                        }
-                        .boxed()
-                    }
-                })
-                .await
-        });
-        cx.foreground_executor().spawn(async move {
-            let output = output.await?;
-            Ok(SlashCommandOutput {
-                text: output.text,
-                sections: output
-                    .sections
-                    .into_iter()
-                    .map(|section| SlashCommandOutputSection {
-                        range: section.range.into(),
-                        icon: IconName::Code,
-                        label: section.label.into(),
-                        metadata: None,
-                    })
-                    .collect(),
-                run_commands_in_text: false,
-            }
-            .to_event_stream())
-        })
-    }
-}

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -1,7 +1,6 @@
 mod components;
 mod extension_context_server;
 mod extension_registration_hooks;
-mod extension_slash_command;
 mod extension_suggest;
 mod extension_version_selector;