Allow defining slash commands in extensions (#12255)

Marshall Bowers created

This PR adds initial support for defining slash commands for the
Assistant from extensions.

Slash commands are defined in an extension's `extension.toml`:

```toml
[slash_commands.gleam-project]
description = "Returns information about the current Gleam project."
requires_argument = false
```

and then executed via the `run_slash_command` method on the `Extension`
trait:

```rs
impl Extension for GleamExtension {
    // ...

    fn run_slash_command(
        &self,
        command: SlashCommand,
        _argument: Option<String>,
        worktree: &zed::Worktree,
    ) -> Result<Option<String>, String> {
        match command.name.as_str() {
            "gleam-project" => Ok(Some("Yayyy".to_string())),
            command => Err(format!("unknown slash command: \"{command}\"")),
        }
    }
}
```

Release Notes:

- N/A

Change summary

Cargo.lock                                                    |  4 
crates/assistant/src/assistant.rs                             |  1 
crates/assistant/src/assistant_panel.rs                       | 61 +++
crates/assistant/src/slash_command/current_file_command.rs    |  9 
crates/assistant/src/slash_command/file_command.rs            |  8 
crates/assistant/src/slash_command/prompt_command.rs          |  8 
crates/assistant_slash_command/Cargo.toml                     |  1 
crates/assistant_slash_command/src/assistant_slash_command.rs | 13 
crates/extension/Cargo.toml                                   |  1 
crates/extension/src/extension_manifest.rs                    |  9 
crates/extension/src/extension_slash_command.rs               | 85 +++++
crates/extension/src/extension_store.rs                       | 20 +
crates/extension/src/extension_store_test.rs                  |  9 
crates/extension/src/wasm_host/wit.rs                         | 18 +
crates/extension/src/wasm_host/wit/since_v0_0_7.rs            |  3 
crates/extension_api/src/extension_api.rs                     | 21 +
crates/extension_api/wit/since_v0.0.7/extension.wit           |  4 
crates/extension_api/wit/since_v0.0.7/slash-command.wit       | 11 
crates/project/src/project.rs                                 |  8 
extensions/gleam/Cargo.toml                                   |  2 
extensions/gleam/extension.toml                               |  4 
extensions/gleam/src/gleam.rs                                 | 24 +
22 files changed, 310 insertions(+), 14 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -436,6 +436,7 @@ dependencies = [
  "derive_more",
  "futures 0.3.28",
  "gpui",
+ "language",
  "parking_lot",
 ]
 
@@ -3784,6 +3785,7 @@ name = "extension"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "assistant_slash_command",
  "async-compression",
  "async-tar",
  "async-trait",
@@ -13249,7 +13251,7 @@ dependencies = [
 name = "zed_gleam"
 version = "0.1.3"
 dependencies = [
- "zed_extension_api 0.0.6",
+ "zed_extension_api 0.0.7",
 ]
 
 [[package]]

crates/assistant/src/assistant.rs 🔗

@@ -237,6 +237,7 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
     cx.set_global(Assistant::default());
     AssistantSettings::register(cx);
     completion_provider::init(client, cx);
+    assistant_slash_command::init(cx);
     assistant_panel::init(cx);
 
     CommandPaletteFilter::update_global(cx, |filter, _cx| {

crates/assistant/src/assistant_panel.rs 🔗

@@ -43,13 +43,14 @@ use gpui::{
     UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace,
     WindowContext,
 };
+use language::LspAdapterDelegate;
 use language::{
     language_settings::SoftWrap, AutoindentMode, Buffer, BufferSnapshot, LanguageRegistry,
     OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _,
 };
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
-use project::{Project, ProjectTransaction};
+use project::{Project, ProjectLspAdapterDelegate, ProjectTransaction};
 use search::{buffer_search::DivRegistrar, BufferSearchBar};
 use settings::Settings;
 use std::{
@@ -205,8 +206,7 @@ impl AssistantPanel {
                     })
                     .detach();
 
-                    let slash_command_registry = SlashCommandRegistry::new();
-
+                    let slash_command_registry = SlashCommandRegistry::global(cx);
                     let window = cx.window_handle().downcast::<Workspace>();
 
                     slash_command_registry.register_command(file_command::FileSlashCommand::new(
@@ -1129,6 +1129,13 @@ impl AssistantPanel {
         let slash_commands = self.slash_commands.clone();
         let languages = self.languages.clone();
         let telemetry = self.telemetry.clone();
+
+        let lsp_adapter_delegate = workspace
+            .update(cx, |workspace, cx| {
+                make_lsp_adapter_delegate(workspace.project(), cx)
+            })
+            .log_err();
+
         cx.spawn(|this, mut cx| async move {
             let saved_conversation = SavedConversation::load(&path, fs.as_ref()).await?;
             let model = this.update(&mut cx, |this, _| this.model.clone())?;
@@ -1139,6 +1146,7 @@ impl AssistantPanel {
                 languages,
                 slash_commands,
                 Some(telemetry),
+                lsp_adapter_delegate,
                 &mut cx,
             )
             .await?;
@@ -1484,6 +1492,7 @@ pub struct Conversation {
     telemetry: Option<Arc<Telemetry>>,
     slash_command_registry: Arc<SlashCommandRegistry>,
     language_registry: Arc<LanguageRegistry>,
+    lsp_adapter_delegate: Option<Arc<dyn LspAdapterDelegate>>,
 }
 
 impl EventEmitter<ConversationEvent> for Conversation {}
@@ -1494,6 +1503,7 @@ impl Conversation {
         language_registry: Arc<LanguageRegistry>,
         slash_command_registry: Arc<SlashCommandRegistry>,
         telemetry: Option<Arc<Telemetry>>,
+        lsp_adapter_delegate: Option<Arc<dyn LspAdapterDelegate>>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let buffer = cx.new_model(|cx| {
@@ -1526,6 +1536,7 @@ impl Conversation {
             telemetry,
             slash_command_registry,
             language_registry,
+            lsp_adapter_delegate,
         };
 
         let message = MessageAnchor {
@@ -1569,6 +1580,7 @@ impl Conversation {
         }
     }
 
+    #[allow(clippy::too_many_arguments)]
     async fn deserialize(
         saved_conversation: SavedConversation,
         model: LanguageModel,
@@ -1576,6 +1588,7 @@ impl Conversation {
         language_registry: Arc<LanguageRegistry>,
         slash_command_registry: Arc<SlashCommandRegistry>,
         telemetry: Option<Arc<Telemetry>>,
+        lsp_adapter_delegate: Option<Arc<dyn LspAdapterDelegate>>,
         cx: &mut AsyncAppContext,
     ) -> Result<Model<Self>> {
         let id = match saved_conversation.id {
@@ -1635,6 +1648,7 @@ impl Conversation {
                 telemetry,
                 language_registry,
                 slash_command_registry,
+                lsp_adapter_delegate,
             };
             this.set_language(cx);
             this.reparse_edit_suggestions(cx);
@@ -1850,7 +1864,13 @@ impl Conversation {
                                 buffer.anchor_after(offset)..buffer.anchor_before(line_end_offset);
 
                             let argument = call.argument.map(|range| &line[range]);
-                            let invocation = command.run(argument, cx);
+                            let invocation = command.run(
+                                argument,
+                                this.lsp_adapter_delegate
+                                    .clone()
+                                    .expect("no LspAdapterDelegate present when invoking command"),
+                                cx,
+                            );
 
                             new_calls.push(SlashCommandCall {
                                 name,
@@ -2728,12 +2748,16 @@ impl ConversationEditor {
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let telemetry = workspace.read(cx).client().telemetry().clone();
+        let project = workspace.read(cx).project().clone();
+        let lsp_adapter_delegate = make_lsp_adapter_delegate(&project, cx);
+
         let conversation = cx.new_model(|cx| {
             Conversation::new(
                 model,
                 language_registry,
                 slash_command_registry,
                 Some(telemetry),
+                Some(lsp_adapter_delegate),
                 cx,
             )
         });
@@ -3907,6 +3931,20 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
     }
 }
 
+fn make_lsp_adapter_delegate(
+    project: &Model<Project>,
+    cx: &mut AppContext,
+) -> Arc<dyn LspAdapterDelegate> {
+    project.update(cx, |project, cx| {
+        // TODO: Find the right worktree.
+        let worktree = project
+            .worktrees()
+            .next()
+            .expect("expected at least one worktree");
+        ProjectLspAdapterDelegate::new(project, &worktree, cx)
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use std::{cell::RefCell, path::Path, rc::Rc};
@@ -3935,6 +3973,7 @@ mod tests {
                 registry,
                 Default::default(),
                 None,
+                None,
                 cx,
             )
         });
@@ -4074,6 +4113,7 @@ mod tests {
                 registry,
                 Default::default(),
                 None,
+                None,
                 cx,
             )
         });
@@ -4180,6 +4220,7 @@ mod tests {
                 registry,
                 Default::default(),
                 None,
+                None,
                 cx,
             )
         });
@@ -4292,6 +4333,15 @@ mod tests {
             prompt_library.clone(),
         ));
 
+        let lsp_adapter_delegate = project.update(cx, |project, cx| {
+            // TODO: Find the right worktree.
+            let worktree = project
+                .worktrees()
+                .next()
+                .expect("expected at least one worktree");
+            ProjectLspAdapterDelegate::new(project, &worktree, cx)
+        });
+
         let registry = Arc::new(LanguageRegistry::test(cx.executor()));
         let conversation = cx.new_model(|cx| {
             Conversation::new(
@@ -4299,6 +4349,7 @@ mod tests {
                 registry.clone(),
                 slash_command_registry,
                 None,
+                Some(lsp_adapter_delegate),
                 cx,
             )
         });
@@ -4599,6 +4650,7 @@ mod tests {
                 registry.clone(),
                 Default::default(),
                 None,
+                None,
                 cx,
             )
         });
@@ -4642,6 +4694,7 @@ mod tests {
             registry.clone(),
             Default::default(),
             None,
+            None,
             &mut cx.to_async(),
         )
         .await

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

@@ -1,3 +1,4 @@
+use std::sync::Arc;
 use std::{borrow::Cow, cell::Cell, rc::Rc};
 
 use anyhow::{anyhow, Result};
@@ -5,6 +6,7 @@ use collections::HashMap;
 use editor::Editor;
 use futures::channel::oneshot;
 use gpui::{AppContext, Entity, Subscription, Task, WindowHandle};
+use language::LspAdapterDelegate;
 use workspace::{Event as WorkspaceEvent, Workspace};
 
 use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
@@ -41,7 +43,12 @@ impl SlashCommand for CurrentFileSlashCommand {
         false
     }
 
-    fn run(&self, _argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+    fn run(
+        self: Arc<Self>,
+        _argument: Option<&str>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut AppContext,
+    ) -> SlashCommandInvocation {
         let (invalidate_tx, invalidate_rx) = oneshot::channel();
         let invalidate_tx = Rc::new(Cell::new(Some(invalidate_tx)));
         let mut subscriptions: Vec<Subscription> = Vec::new();

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

@@ -3,6 +3,7 @@ use anyhow::Result;
 use futures::channel::oneshot;
 use fuzzy::PathMatch;
 use gpui::{AppContext, Model, Task};
+use language::LspAdapterDelegate;
 use project::{PathMatchCandidateSet, Project};
 use std::{
     path::Path,
@@ -96,7 +97,12 @@ impl SlashCommand for FileSlashCommand {
         })
     }
 
-    fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+    fn run(
+        self: Arc<Self>,
+        argument: Option<&str>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut AppContext,
+    ) -> SlashCommandInvocation {
         let project = self.project.read(cx);
         let Some(argument) = argument else {
             return SlashCommandInvocation {

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

@@ -4,6 +4,7 @@ use anyhow::{anyhow, Context, Result};
 use futures::channel::oneshot;
 use fuzzy::StringMatchCandidate;
 use gpui::{AppContext, Task};
+use language::LspAdapterDelegate;
 use std::sync::{atomic::AtomicBool, Arc};
 
 pub(crate) struct PromptSlashCommand {
@@ -65,7 +66,12 @@ impl SlashCommand for PromptSlashCommand {
         })
     }
 
-    fn run(&self, title: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+    fn run(
+        self: Arc<Self>,
+        title: Option<&str>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut AppContext,
+    ) -> SlashCommandInvocation {
         let Some(title) = title else {
             return SlashCommandInvocation {
                 output: Task::ready(Err(anyhow!("missing prompt name"))),

crates/assistant_slash_command/Cargo.toml 🔗

@@ -17,4 +17,5 @@ collections.workspace = true
 derive_more.workspace = true
 futures.workspace = true
 gpui.workspace = true
+language.workspace = true
 parking_lot.workspace = true

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -6,6 +6,7 @@ use std::sync::Arc;
 use anyhow::Result;
 use futures::channel::oneshot;
 use gpui::{AppContext, Task};
+use language::LspAdapterDelegate;
 
 pub use slash_command_registry::*;
 
@@ -23,7 +24,17 @@ pub trait SlashCommand: 'static + Send + Sync {
         cx: &mut AppContext,
     ) -> Task<Result<Vec<String>>>;
     fn requires_argument(&self) -> bool;
-    fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation;
+    fn run(
+        self: Arc<Self>,
+        argument: Option<&str>,
+        // TODO: We're just using the `LspAdapterDelegate` here because that is
+        // what the extension API is already expecting.
+        //
+        // It may be that `LspAdapterDelegate` needs a more general name, or
+        // perhaps another kind of delegate is needed here.
+        delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut AppContext,
+    ) -> SlashCommandInvocation;
 }
 
 pub struct SlashCommandInvocation {

crates/extension/Cargo.toml 🔗

@@ -14,6 +14,7 @@ doctest = false
 
 [dependencies]
 anyhow.workspace = true
+assistant_slash_command.workspace = true
 async-compression.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true

crates/extension/src/extension_manifest.rs 🔗

@@ -74,6 +74,8 @@ pub struct ExtensionManifest {
     pub grammars: BTreeMap<Arc<str>, GrammarManifestEntry>,
     #[serde(default)]
     pub language_servers: BTreeMap<LanguageServerName, LanguageServerManifestEntry>,
+    #[serde(default)]
+    pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
 }
 
 #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
@@ -128,6 +130,12 @@ impl LanguageServerManifestEntry {
     }
 }
 
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct SlashCommandManifestEntry {
+    pub description: String,
+    pub requires_argument: bool,
+}
+
 impl ExtensionManifest {
     pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
         let extension_name = extension_dir
@@ -190,5 +198,6 @@ fn manifest_from_old_manifest(
             .map(|grammar_name| (grammar_name, Default::default()))
             .collect(),
         language_servers: Default::default(),
+        slash_commands: BTreeMap::default(),
     }
 }

crates/extension/src/extension_slash_command.rs 🔗

@@ -0,0 +1,85 @@
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+
+use anyhow::{anyhow, Result};
+use assistant_slash_command::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use futures::channel::oneshot;
+use futures::FutureExt;
+use gpui::{AppContext, Task};
+use language::LspAdapterDelegate;
+use wasmtime_wasi::WasiView;
+
+use crate::wasm_host::{WasmExtension, WasmHost};
+
+pub struct ExtensionSlashCommand {
+    pub(crate) extension: WasmExtension,
+    #[allow(unused)]
+    pub(crate) host: Arc<WasmHost>,
+    pub(crate) command: crate::wit::SlashCommand,
+}
+
+impl SlashCommand for ExtensionSlashCommand {
+    fn name(&self) -> String {
+        self.command.name.clone()
+    }
+
+    fn description(&self) -> String {
+        self.command.description.clone()
+    }
+
+    fn requires_argument(&self) -> bool {
+        self.command.requires_argument
+    }
+
+    fn complete_argument(
+        &self,
+        _query: String,
+        _cancel: Arc<AtomicBool>,
+        _cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Ok(Vec::new()))
+    }
+
+    fn run(
+        self: Arc<Self>,
+        argument: Option<&str>,
+        delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut AppContext,
+    ) -> SlashCommandInvocation {
+        let argument = argument.map(|arg| arg.to_string());
+
+        let output = cx.background_executor().spawn(async move {
+            let output = self
+                .extension
+                .call({
+                    let this = self.clone();
+                    move |extension, store| {
+                        async move {
+                            let resource = store.data_mut().table().push(delegate)?;
+                            let output = extension
+                                .call_run_slash_command(
+                                    store,
+                                    &this.command,
+                                    argument.as_deref(),
+                                    resource,
+                                )
+                                .await?
+                                .map_err(|e| anyhow!("{}", e))?;
+
+                            anyhow::Ok(output)
+                        }
+                        .boxed()
+                    }
+                })
+                .await?;
+
+            output.ok_or_else(|| anyhow!("no output from command: {}", self.command.name))
+        });
+
+        SlashCommandInvocation {
+            output,
+            invalidated: oneshot::channel().1,
+            cleanup: SlashCommandCleanup::default(),
+        }
+    }
+}

crates/extension/src/extension_store.rs 🔗

@@ -2,14 +2,17 @@ pub mod extension_builder;
 mod extension_lsp_adapter;
 mod extension_manifest;
 mod extension_settings;
+mod extension_slash_command;
 mod wasm_host;
 
 #[cfg(test)]
 mod extension_store_test;
 
 use crate::extension_manifest::SchemaVersion;
+use crate::extension_slash_command::ExtensionSlashCommand;
 use crate::{extension_lsp_adapter::ExtensionLspAdapter, wasm_host::wit};
 use anyhow::{anyhow, bail, Context as _, Result};
+use assistant_slash_command::SlashCommandRegistry;
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use client::{telemetry::Telemetry, Client, ExtensionMetadata, GetExtensionsResponse};
@@ -107,6 +110,7 @@ pub struct ExtensionStore {
     index_path: PathBuf,
     language_registry: Arc<LanguageRegistry>,
     theme_registry: Arc<ThemeRegistry>,
+    slash_command_registry: Arc<SlashCommandRegistry>,
     modified_extensions: HashSet<Arc<str>>,
     wasm_host: Arc<WasmHost>,
     wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
@@ -183,6 +187,7 @@ pub fn init(
             node_runtime,
             language_registry,
             theme_registry,
+            SlashCommandRegistry::global(cx),
             cx,
         )
     });
@@ -215,6 +220,7 @@ impl ExtensionStore {
         node_runtime: Arc<dyn NodeRuntime>,
         language_registry: Arc<LanguageRegistry>,
         theme_registry: Arc<ThemeRegistry>,
+        slash_command_registry: Arc<SlashCommandRegistry>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let work_dir = extensions_dir.join("work");
@@ -245,6 +251,7 @@ impl ExtensionStore {
             telemetry,
             language_registry,
             theme_registry,
+            slash_command_registry,
             reload_tx,
             tasks: Vec::new(),
         };
@@ -1169,6 +1176,19 @@ impl ExtensionStore {
                             );
                         }
                     }
+
+                    for (slash_command_name, slash_command) in &manifest.slash_commands {
+                        this.slash_command_registry
+                            .register_command(ExtensionSlashCommand {
+                                command: crate::wit::SlashCommand {
+                                    name: slash_command_name.to_string(),
+                                    description: slash_command.description.to_string(),
+                                    requires_argument: slash_command.requires_argument,
+                                },
+                                extension: wasm_extension.clone(),
+                                host: this.wasm_host.clone(),
+                            });
+                    }
                 }
                 this.wasm_extensions.extend(wasm_extensions);
                 ThemeSettings::reload_current_theme(cx)

crates/extension/src/extension_store_test.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
     ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry,
     RELOAD_DEBOUNCE_DURATION,
 };
+use assistant_slash_command::SlashCommandRegistry;
 use async_compression::futures::bufread::GzipEncoder;
 use collections::BTreeMap;
 use fs::{FakeFs, Fs, RealFs};
@@ -156,6 +157,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         .into_iter()
                         .collect(),
                         language_servers: BTreeMap::default(),
+                        slash_commands: BTreeMap::default(),
                     }),
                     dev: false,
                 },
@@ -179,6 +181,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         languages: Default::default(),
                         grammars: BTreeMap::default(),
                         language_servers: BTreeMap::default(),
+                        slash_commands: BTreeMap::default(),
                     }),
                     dev: false,
                 },
@@ -250,6 +253,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
 
     let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
     let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
+    let slash_command_registry = SlashCommandRegistry::new();
     let node_runtime = FakeNodeRuntime::new();
 
     let store = cx.new_model(|cx| {
@@ -262,6 +266,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
             node_runtime.clone(),
             language_registry.clone(),
             theme_registry.clone(),
+            slash_command_registry.clone(),
             cx,
         )
     });
@@ -333,6 +338,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 languages: Default::default(),
                 grammars: BTreeMap::default(),
                 language_servers: BTreeMap::default(),
+                slash_commands: BTreeMap::default(),
             }),
             dev: false,
         },
@@ -382,6 +388,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
             node_runtime.clone(),
             language_registry.clone(),
             theme_registry.clone(),
+            slash_command_registry,
             cx,
         )
     });
@@ -460,6 +467,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
 
     let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
     let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
+    let slash_command_registry = SlashCommandRegistry::new();
     let node_runtime = FakeNodeRuntime::new();
 
     let mut status_updates = language_registry.language_server_binary_statuses();
@@ -541,6 +549,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
             node_runtime,
             language_registry.clone(),
             theme_registry.clone(),
+            slash_command_registry,
             cx,
         )
     });

crates/extension/src/wasm_host/wit.rs 🔗

@@ -19,7 +19,7 @@ use wasmtime::{
 pub use latest::CodeLabelSpanLiteral;
 pub use latest::{
     zed::extension::lsp::{Completion, CompletionKind, InsertTextFormat, Symbol, SymbolKind},
-    CodeLabel, CodeLabelSpan, Command, Range,
+    CodeLabel, CodeLabelSpan, Command, Range, SlashCommand,
 };
 pub use since_v0_0_4::LanguageServerConfig;
 
@@ -255,6 +255,22 @@ impl Extension {
             Extension::V001(_) | Extension::V004(_) => Ok(Ok(Vec::new())),
         }
     }
+
+    pub async fn call_run_slash_command(
+        &self,
+        store: &mut Store<WasmState>,
+        command: &SlashCommand,
+        argument: Option<&str>,
+        resource: Resource<Arc<dyn LspAdapterDelegate>>,
+    ) -> Result<Result<Option<String>, String>> {
+        match self {
+            Extension::V007(ext) => {
+                ext.call_run_slash_command(store, command, argument, resource)
+                    .await
+            }
+            Extension::V001(_) | Extension::V004(_) | Extension::V006(_) => Ok(Ok(None)),
+        }
+    }
 }
 
 trait ToWasmtimeResult<T> {

crates/extension_api/src/extension_api.rs 🔗

@@ -24,6 +24,7 @@ pub use wit::{
         npm_package_latest_version,
     },
     zed::extension::platform::{current_platform, Architecture, Os},
+    zed::extension::slash_command::SlashCommand,
     CodeLabel, CodeLabelSpan, CodeLabelSpanLiteral, Command, DownloadedFileType, EnvVars,
     LanguageServerInstallationStatus, Range, Worktree,
 };
@@ -104,6 +105,16 @@ pub trait Extension: Send + Sync {
     ) -> Option<CodeLabel> {
         None
     }
+
+    /// Runs the given slash command.
+    fn run_slash_command(
+        &self,
+        _command: SlashCommand,
+        _argument: Option<String>,
+        _worktree: &Worktree,
+    ) -> Result<Option<String>, String> {
+        Ok(None)
+    }
 }
 
 /// Registers the provided type as a Zed extension.
@@ -139,6 +150,8 @@ static mut EXTENSION: Option<Box<dyn Extension>> = None;
 pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes"));
 
 mod wit {
+    #![allow(clippy::too_many_arguments)]
+
     wit_bindgen::generate!({
         skip: ["init-extension"],
         path: "./wit/since_v0.0.7",
@@ -209,6 +222,14 @@ impl wit::Guest for Component {
         }
         Ok(labels)
     }
+
+    fn run_slash_command(
+        command: SlashCommand,
+        argument: Option<String>,
+        worktree: &Worktree,
+    ) -> Result<Option<String>, String> {
+        extension().run_slash_command(command, argument, worktree)
+    }
 }
 
 /// The ID of a language server.

crates/extension_api/wit/since_v0.0.7/extension.wit 🔗

@@ -6,6 +6,7 @@ world extension {
     import nodejs;
 
     use lsp.{completion, symbol};
+    use slash-command.{slash-command};
 
     /// Initializes the extension.
     export init-extension: func();
@@ -127,4 +128,7 @@ world extension {
 
     export labels-for-completions: func(language-server-id: string, completions: list<completion>) -> result<list<option<code-label>>, string>;
     export labels-for-symbols: func(language-server-id: string, symbols: list<symbol>) -> result<list<option<code-label>>, string>;
+
+    /// Runs the provided slash command.
+    export run-slash-command: func(command: slash-command, argument: option<string>, worktree: borrow<worktree>) -> result<option<string>, string>;
 }

crates/extension_api/wit/since_v0.0.7/slash-command.wit 🔗

@@ -0,0 +1,11 @@
+interface slash-command {
+    /// A slash command for use in the Assistant.
+    record slash-command {
+        /// The name of the slash command.
+        name: string,
+        /// The description of the slash command.
+        description: string,
+        /// Whether this slash command requires an argument.
+        requires-argument: bool,
+    }
+}

crates/project/src/project.rs 🔗

@@ -11175,7 +11175,7 @@ impl<P: AsRef<Path>> From<(WorktreeId, P)> for ProjectPath {
     }
 }
 
-struct ProjectLspAdapterDelegate {
+pub struct ProjectLspAdapterDelegate {
     project: WeakModel<Project>,
     worktree: worktree::Snapshot,
     fs: Arc<dyn Fs>,
@@ -11185,7 +11185,11 @@ struct ProjectLspAdapterDelegate {
 }
 
 impl ProjectLspAdapterDelegate {
-    fn new(project: &Project, worktree: &Model<Worktree>, cx: &ModelContext<Project>) -> Arc<Self> {
+    pub fn new(
+        project: &Project,
+        worktree: &Model<Worktree>,
+        cx: &ModelContext<Project>,
+    ) -> Arc<Self> {
         Arc::new(Self {
             project: cx.weak_model(),
             worktree: worktree.read(cx).snapshot(),

extensions/gleam/Cargo.toml 🔗

@@ -13,4 +13,4 @@ path = "src/gleam.rs"
 crate-type = ["cdylib"]
 
 [dependencies]
-zed_extension_api = "0.0.6"
+zed_extension_api = { path = "../../crates/extension_api" }

extensions/gleam/extension.toml 🔗

@@ -13,3 +13,7 @@ language = "Gleam"
 [grammars.gleam]
 repository = "https://github.com/gleam-lang/tree-sitter-gleam"
 commit = "8432ffe32ccd360534837256747beb5b1c82fca1"
+
+[slash_commands.gleam-project]
+description = "Returns information about the current Gleam project."
+requires_argument = false

extensions/gleam/src/gleam.rs 🔗

@@ -1,6 +1,6 @@
 use std::fs;
 use zed::lsp::CompletionKind;
-use zed::{CodeLabel, CodeLabelSpan, LanguageServerId};
+use zed::{CodeLabel, CodeLabelSpan, LanguageServerId, SlashCommand};
 use zed_extension_api::{self as zed, Result};
 
 struct GleamExtension {
@@ -142,6 +142,28 @@ impl zed::Extension for GleamExtension {
             code,
         })
     }
+
+    fn run_slash_command(
+        &self,
+        command: SlashCommand,
+        _argument: Option<String>,
+        worktree: &zed::Worktree,
+    ) -> Result<Option<String>, String> {
+        match command.name.as_str() {
+            "gleam-project" => {
+                let mut message = String::new();
+                message.push_str("You are in a Gleam project.\n");
+
+                if let Some(gleam_toml) = worktree.read_text_file("gleam.toml").ok() {
+                    message.push_str("The `gleam.toml` is as follows:\n");
+                    message.push_str(&gleam_toml);
+                }
+
+                Ok(Some(message))
+            }
+            command => Err(format!("unknown slash command: \"{command}\"")),
+        }
+    }
 }
 
 zed::register_extension!(GleamExtension);