Rework context insertion UX (#12360)

Antonio Scandurra and Nathan Sobo created

- Confirming a completion now runs the command immediately
- Hitting `enter` on a line with a command now runs it
- The output of commands gets folded away and replaced with a custom
placeholder
- Eliminated ambient context

<img width="1588" alt="image"
src="https://github.com/zed-industries/zed/assets/482957/b1927a45-52d6-4634-acc9-2ee539c1d89a">

Release Notes:

- N/A

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                                                    |   3 
assets/icons/triangle_right.svg                               |   1 
assets/keymaps/default-linux.json                             |   4 
assets/keymaps/default-macos.json                             |   4 
crates/assistant/src/ambient_context.rs                       |  30 
crates/assistant/src/ambient_context/current_project.rs       | 180 -
crates/assistant/src/ambient_context/recent_buffers.rs        | 147 -
crates/assistant/src/assistant.rs                             |  10 
crates/assistant/src/assistant_panel.rs                       | 662 ++--
crates/assistant/src/omit_ranges.rs                           | 101 
crates/assistant/src/slash_command.rs                         | 139 
crates/assistant/src/slash_command/active_command.rs          | 117 
crates/assistant/src/slash_command/current_file_command.rs    | 142 -
crates/assistant/src/slash_command/file_command.rs            |  90 
crates/assistant/src/slash_command/project_command.rs         | 151 +
crates/assistant/src/slash_command/prompt_command.rs          |  64 
crates/assistant_slash_command/Cargo.toml                     |   2 
crates/assistant_slash_command/src/assistant_slash_command.rs |  43 
crates/collab_ui/src/chat_panel/message_editor.rs             |   1 
crates/editor/src/display_map/flap_map.rs                     |   8 
crates/editor/src/display_map/fold_map.rs                     |  10 
crates/editor/src/editor.rs                                   |   6 
crates/editor/src/element.rs                                  |  31 
crates/extension/Cargo.toml                                   |   2 
crates/extension/src/extension_manifest.rs                    |   1 
crates/extension/src/extension_slash_command.rs               |  39 
crates/extension/src/extension_store.rs                       |   1 
crates/extension_api/wit/since_v0.0.7/slash-command.wit       |   2 
crates/gpui/src/elements/div.rs                               |   2 
crates/project/src/project.rs                                 |  45 
crates/ui/src/components/icon.rs                              |   2 
extensions/gleam/extension.toml                               |   1 
32 files changed, 911 insertions(+), 1,130 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -434,10 +434,10 @@ dependencies = [
  "anyhow",
  "collections",
  "derive_more",
- "futures 0.3.28",
  "gpui",
  "language",
  "parking_lot",
+ "workspace",
 ]
 
 [[package]]
@@ -3823,6 +3823,7 @@ dependencies = [
  "wasmtime",
  "wasmtime-wasi",
  "wit-component",
+ "workspace",
 ]
 
 [[package]]

assets/icons/triangle_right.svg 🔗

@@ -0,0 +1 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 11L6 4L10.5 7.5L6 11Z" fill="currentColor"></path></svg>

assets/keymaps/default-linux.json 🔗

@@ -211,7 +211,9 @@
       "ctrl-s": "workspace::Save",
       "ctrl->": "assistant::QuoteSelection",
       "shift-enter": "assistant::Split",
-      "ctrl-r": "assistant::CycleMessageRole"
+      "ctrl-r": "assistant::CycleMessageRole",
+      "enter": "assistant::ConfirmCommand",
+      "alt-enter": "editor::Newline"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -227,7 +227,9 @@
       "cmd-s": "workspace::Save",
       "cmd->": "assistant::QuoteSelection",
       "shift-enter": "assistant::Split",
-      "ctrl-r": "assistant::CycleMessageRole"
+      "ctrl-r": "assistant::CycleMessageRole",
+      "enter": "assistant::ConfirmCommand",
+      "alt-enter": "editor::Newline"
     }
   },
   {

crates/assistant/src/ambient_context.rs 🔗

@@ -1,30 +0,0 @@
-mod current_project;
-mod recent_buffers;
-
-pub use current_project::*;
-pub use recent_buffers::*;
-
-#[derive(Default)]
-pub struct AmbientContext {
-    pub recent_buffers: RecentBuffersContext,
-    pub current_project: CurrentProjectContext,
-}
-
-impl AmbientContext {
-    pub fn snapshot(&self) -> AmbientContextSnapshot {
-        AmbientContextSnapshot {
-            recent_buffers: self.recent_buffers.snapshot.clone(),
-        }
-    }
-}
-
-#[derive(Clone, Default, Debug)]
-pub struct AmbientContextSnapshot {
-    pub recent_buffers: RecentBuffersSnapshot,
-}
-
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
-pub enum ContextUpdated {
-    Updating,
-    Disabled,
-}

crates/assistant/src/ambient_context/current_project.rs 🔗

@@ -1,180 +0,0 @@
-use std::fmt::Write;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-use std::time::Duration;
-
-use anyhow::{anyhow, Result};
-use fs::Fs;
-use gpui::{AsyncAppContext, ModelContext, Task, WeakModel};
-use project::{Project, ProjectPath};
-use util::ResultExt;
-
-use crate::ambient_context::ContextUpdated;
-use crate::assistant_panel::Conversation;
-use crate::{LanguageModelRequestMessage, Role};
-
-/// Ambient context about the current project.
-pub struct CurrentProjectContext {
-    pub enabled: bool,
-    pub message: String,
-    pub pending_message: Option<Task<()>>,
-}
-
-#[allow(clippy::derivable_impls)]
-impl Default for CurrentProjectContext {
-    fn default() -> Self {
-        Self {
-            enabled: false,
-            message: String::new(),
-            pending_message: None,
-        }
-    }
-}
-
-impl CurrentProjectContext {
-    /// Returns the [`CurrentProjectContext`] as a message to the language model.
-    pub fn to_message(&self) -> Option<LanguageModelRequestMessage> {
-        self.enabled
-            .then(|| LanguageModelRequestMessage {
-                role: Role::System,
-                content: self.message.clone(),
-            })
-            .filter(|message| !message.content.is_empty())
-    }
-
-    /// Updates the [`CurrentProjectContext`] for the given [`Project`].
-    pub fn update(
-        &mut self,
-        fs: Arc<dyn Fs>,
-        project: WeakModel<Project>,
-        cx: &mut ModelContext<Conversation>,
-    ) -> ContextUpdated {
-        if !self.enabled {
-            self.message.clear();
-            self.pending_message = None;
-            cx.notify();
-            return ContextUpdated::Disabled;
-        }
-
-        self.pending_message = Some(cx.spawn(|conversation, mut cx| async move {
-            const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
-            cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
-
-            let Some(path_to_cargo_toml) = Self::path_to_cargo_toml(project, &mut cx).log_err()
-            else {
-                return;
-            };
-
-            let Some(path_to_cargo_toml) = path_to_cargo_toml
-                .ok_or_else(|| anyhow!("no Cargo.toml"))
-                .log_err()
-            else {
-                return;
-            };
-
-            let message_task = cx
-                .background_executor()
-                .spawn(async move { Self::build_message(fs, &path_to_cargo_toml).await });
-
-            if let Some(message) = message_task.await.log_err() {
-                conversation
-                    .update(&mut cx, |conversation, cx| {
-                        conversation.ambient_context.current_project.message = message;
-                        conversation.count_remaining_tokens(cx);
-                        cx.notify();
-                    })
-                    .log_err();
-            }
-        }));
-
-        ContextUpdated::Updating
-    }
-
-    async fn build_message(fs: Arc<dyn Fs>, path_to_cargo_toml: &Path) -> Result<String> {
-        let buffer = fs.load(path_to_cargo_toml).await?;
-        let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?;
-
-        let mut message = String::new();
-        writeln!(message, "You are in a Rust project.")?;
-
-        if let Some(workspace) = cargo_toml.workspace {
-            writeln!(
-                message,
-                "The project is a Cargo workspace with the following members:"
-            )?;
-            for member in workspace.members {
-                writeln!(message, "- {member}")?;
-            }
-
-            if !workspace.default_members.is_empty() {
-                writeln!(message, "The default members are:")?;
-                for member in workspace.default_members {
-                    writeln!(message, "- {member}")?;
-                }
-            }
-
-            if !workspace.dependencies.is_empty() {
-                writeln!(
-                    message,
-                    "The following workspace dependencies are installed:"
-                )?;
-                for dependency in workspace.dependencies.keys() {
-                    writeln!(message, "- {dependency}")?;
-                }
-            }
-        } else if let Some(package) = cargo_toml.package {
-            writeln!(
-                message,
-                "The project name is \"{name}\".",
-                name = package.name
-            )?;
-
-            let description = package
-                .description
-                .as_ref()
-                .and_then(|description| description.get().ok().cloned());
-            if let Some(description) = description.as_ref() {
-                writeln!(message, "It describes itself as \"{description}\".")?;
-            }
-
-            if !cargo_toml.dependencies.is_empty() {
-                writeln!(message, "The following dependencies are installed:")?;
-                for dependency in cargo_toml.dependencies.keys() {
-                    writeln!(message, "- {dependency}")?;
-                }
-            }
-        }
-
-        Ok(message)
-    }
-
-    fn path_to_cargo_toml(
-        project: WeakModel<Project>,
-        cx: &mut AsyncAppContext,
-    ) -> Result<Option<PathBuf>> {
-        cx.update(|cx| {
-            let worktree = project.update(cx, |project, _cx| {
-                project
-                    .worktrees()
-                    .next()
-                    .ok_or_else(|| anyhow!("no worktree"))
-            })??;
-
-            let path_to_cargo_toml = worktree.update(cx, |worktree, _cx| {
-                let cargo_toml = worktree.entry_for_path("Cargo.toml")?;
-                Some(ProjectPath {
-                    worktree_id: worktree.id(),
-                    path: cargo_toml.path.clone(),
-                })
-            });
-            let path_to_cargo_toml = path_to_cargo_toml.and_then(|path| {
-                project
-                    .update(cx, |project, cx| project.absolute_path(&path, cx))
-                    .ok()
-                    .flatten()
-            });
-
-            Ok(path_to_cargo_toml)
-        })?
-    }
-}

crates/assistant/src/ambient_context/recent_buffers.rs 🔗

@@ -1,147 +0,0 @@
-use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role};
-use gpui::{ModelContext, Subscription, Task, WeakModel};
-use language::{Buffer, BufferSnapshot, Rope};
-use std::{fmt::Write, path::PathBuf, time::Duration};
-
-use super::ContextUpdated;
-
-pub struct RecentBuffersContext {
-    pub enabled: bool,
-    pub buffers: Vec<RecentBuffer>,
-    pub snapshot: RecentBuffersSnapshot,
-    pub pending_message: Option<Task<()>>,
-}
-
-pub struct RecentBuffer {
-    pub buffer: WeakModel<Buffer>,
-    pub _subscription: Subscription,
-}
-
-impl Default for RecentBuffersContext {
-    fn default() -> Self {
-        Self {
-            enabled: true,
-            buffers: Vec::new(),
-            snapshot: RecentBuffersSnapshot::default(),
-            pending_message: None,
-        }
-    }
-}
-
-impl RecentBuffersContext {
-    pub fn update(&mut self, cx: &mut ModelContext<Conversation>) -> ContextUpdated {
-        let source_buffers = self
-            .buffers
-            .iter()
-            .filter_map(|recent| {
-                let (full_path, snapshot) = recent
-                    .buffer
-                    .read_with(cx, |buffer, cx| {
-                        (
-                            buffer.file().map(|file| file.full_path(cx)),
-                            buffer.snapshot(),
-                        )
-                    })
-                    .ok()?;
-                Some(SourceBufferSnapshot {
-                    full_path,
-                    model: recent.buffer.clone(),
-                    snapshot,
-                })
-            })
-            .collect::<Vec<_>>();
-
-        if !self.enabled || source_buffers.is_empty() {
-            self.snapshot.message = Default::default();
-            self.snapshot.source_buffers.clear();
-            self.pending_message = None;
-            cx.notify();
-            ContextUpdated::Disabled
-        } else {
-            self.pending_message = Some(cx.spawn(|this, mut cx| async move {
-                const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
-                cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
-
-                let message = if source_buffers.is_empty() {
-                    Rope::new()
-                } else {
-                    cx.background_executor()
-                        .spawn({
-                            let source_buffers = source_buffers.clone();
-                            async move { message_for_recent_buffers(source_buffers) }
-                        })
-                        .await
-                };
-                this.update(&mut cx, |this, cx| {
-                    this.ambient_context.recent_buffers.snapshot.source_buffers = source_buffers;
-                    this.ambient_context.recent_buffers.snapshot.message = message;
-                    this.count_remaining_tokens(cx);
-                    cx.notify();
-                })
-                .ok();
-            }));
-
-            ContextUpdated::Updating
-        }
-    }
-
-    /// Returns the [`RecentBuffersContext`] as a message to the language model.
-    pub fn to_message(&self) -> Option<LanguageModelRequestMessage> {
-        self.enabled
-            .then(|| LanguageModelRequestMessage {
-                role: Role::System,
-                content: self.snapshot.message.to_string(),
-            })
-            .filter(|message| !message.content.is_empty())
-    }
-}
-
-#[derive(Clone, Default, Debug)]
-pub struct RecentBuffersSnapshot {
-    pub message: Rope,
-    pub source_buffers: Vec<SourceBufferSnapshot>,
-}
-
-#[derive(Clone)]
-pub struct SourceBufferSnapshot {
-    pub full_path: Option<PathBuf>,
-    pub model: WeakModel<Buffer>,
-    pub snapshot: BufferSnapshot,
-}
-
-impl std::fmt::Debug for SourceBufferSnapshot {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_struct("SourceBufferSnapshot")
-            .field("full_path", &self.full_path)
-            .field("model (entity id)", &self.model.entity_id())
-            .field("snapshot (text)", &self.snapshot.text())
-            .finish()
-    }
-}
-
-fn message_for_recent_buffers(buffers: Vec<SourceBufferSnapshot>) -> Rope {
-    let mut message = String::new();
-    writeln!(
-        message,
-        "The following is a list of recent buffers that the user has opened."
-    )
-    .unwrap();
-
-    for buffer in buffers {
-        if let Some(path) = buffer.full_path {
-            writeln!(message, "```{}", path.display()).unwrap();
-        } else {
-            writeln!(message, "```untitled").unwrap();
-        }
-
-        for chunk in buffer.snapshot.chunks(0..buffer.snapshot.len(), false) {
-            message.push_str(chunk.text);
-        }
-        if !message.ends_with('\n') {
-            message.push('\n');
-        }
-        message.push_str("```\n");
-    }
-
-    Rope::from(message.as_str())
-}

crates/assistant/src/assistant.rs 🔗

@@ -1,17 +1,15 @@
-mod ambient_context;
 pub mod assistant_panel;
 pub mod assistant_settings;
 mod codegen;
 mod completion_provider;
-mod omit_ranges;
 mod prompts;
 mod saved_conversation;
 mod search;
 mod slash_command;
 mod streaming_diff;
 
-use ambient_context::AmbientContextSnapshot;
 pub use assistant_panel::AssistantPanel;
+
 use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel};
 use client::{proto, Client};
 use command_palette_hooks::CommandPaletteFilter;
@@ -38,7 +36,8 @@ actions!(
         InsertActivePrompt,
         ToggleIncludeConversation,
         ToggleHistory,
-        ApplyEdit
+        ApplyEdit,
+        ConfirmCommand
     ]
 );
 
@@ -188,9 +187,6 @@ pub struct LanguageModelChoiceDelta {
 struct MessageMetadata {
     role: Role,
     status: MessageStatus,
-    // TODO: Delete this
-    #[serde(skip)]
-    ambient_context: AmbientContextSnapshot,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize)]

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,51 +1,48 @@
-use crate::ambient_context::{AmbientContext, ContextUpdated, RecentBuffer};
 use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager};
 use crate::{
-    ambient_context::*,
     assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel},
     codegen::{self, Codegen, CodegenKind},
-    omit_ranges::text_in_range_omitting_ranges,
     search::*,
     slash_command::{
-        current_file_command, file_command, prompt_command, SlashCommandCleanup,
+        active_command, file_command, project_command, prompt_command,
         SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry,
     },
-    ApplyEdit, Assist, CompletionProvider, CycleMessageRole, InlineAssist, LanguageModel,
-    LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus,
-    QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
-    Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation,
+    ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist,
+    LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata,
+    MessageStatus, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
+    SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation,
 };
 use anyhow::{anyhow, Result};
+use assistant_slash_command::{RenderFoldPlaceholder, SlashCommandOutput};
 use client::telemetry::Telemetry;
 use collections::{hash_map, HashMap, HashSet, VecDeque};
-use editor::FoldPlaceholder;
 use editor::{
     actions::{FoldAt, MoveDown, MoveUp},
     display_map::{
-        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, FlapId,
-        ToDisplayPoint,
+        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, ToDisplayPoint,
     },
     scroll::{Autoscroll, AutoscrollStrategy},
     Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt,
     ToOffset as _, ToPoint,
 };
+use editor::{display_map::FlapId, FoldPlaceholder};
 use file_icons::FileIcons;
 use fs::Fs;
 use futures::StreamExt;
 use gpui::{
     canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
-    AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty, Entity,
+    AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty,
     EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle,
     InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render,
     SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle,
     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 _,
+    language_settings::SoftWrap, AutoindentMode, Buffer, LanguageRegistry, OffsetRangeExt as _,
+    Point, ToOffset as _,
 };
+use language::{LineEnding, LspAdapterDelegate};
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
 use project::{Project, ProjectLspAdapterDelegate, ProjectTransaction};
@@ -54,7 +51,7 @@ use settings::Settings;
 use std::{
     cmp::{self, Ordering},
     fmt::Write,
-    iter, mem,
+    iter,
     ops::Range,
     path::PathBuf,
     sync::Arc,
@@ -71,13 +68,10 @@ use uuid::Uuid;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     searchable::Direction,
-    Event as WorkspaceEvent, Save, Toast, ToggleZoom, Toolbar, Workspace,
+    Save, Toast, ToggleZoom, Toolbar, Workspace,
 };
 use workspace::{notifications::NotificationId, NewFile};
 
-const MAX_RECENT_BUFFERS: usize = 3;
-const SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(200);
-
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(
         |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
@@ -205,7 +199,6 @@ impl AssistantPanel {
                     .detach();
 
                     let slash_command_registry = SlashCommandRegistry::global(cx);
-                    let window = cx.window_handle().downcast::<Workspace>();
 
                     slash_command_registry.register_command(file_command::FileSlashCommand::new(
                         workspace.project().clone(),
@@ -213,11 +206,8 @@ impl AssistantPanel {
                     slash_command_registry.register_command(
                         prompt_command::PromptSlashCommand::new(prompt_library.clone()),
                     );
-                    if let Some(window) = window {
-                        slash_command_registry.register_command(
-                            current_file_command::CurrentFileSlashCommand::new(window),
-                        );
-                    }
+                    slash_command_registry.register_command(active_command::ActiveSlashCommand);
+                    slash_command_registry.register_command(project_command::ProjectSlashCommand);
 
                     Self {
                         workspace: workspace_handle,
@@ -1145,7 +1135,6 @@ impl AssistantPanel {
                 languages,
                 slash_commands,
                 Some(telemetry),
-                lsp_adapter_delegate,
                 &mut cx,
             )
             .await?;
@@ -1155,7 +1144,13 @@ impl AssistantPanel {
                     .upgrade()
                     .ok_or_else(|| anyhow!("workspace dropped"))?;
                 let editor = cx.new_view(|cx| {
-                    ConversationEditor::for_conversation(conversation, fs, workspace, cx)
+                    ConversationEditor::for_conversation(
+                        conversation,
+                        fs,
+                        workspace,
+                        lsp_adapter_delegate,
+                        cx,
+                    )
                 });
                 this.show_conversation(editor, cx);
                 anyhow::Ok(())
@@ -1456,9 +1451,14 @@ enum ConversationEvent {
     SummaryChanged,
     EditSuggestionsChanged,
     StreamedCompletion,
-    SlashCommandsChanged,
-    SlashCommandOutputAdded(Range<language::Anchor>),
-    SlashCommandOutputRemoved(Range<language::Anchor>),
+    PendingSlashCommandsUpdated {
+        removed: Vec<Range<language::Anchor>>,
+        updated: Vec<PendingSlashCommand>,
+    },
+    SlashCommandFinished {
+        output_range: Range<language::Anchor>,
+        render_placeholder: RenderFoldPlaceholder,
+    },
 }
 
 #[derive(Default)]
@@ -1467,12 +1467,27 @@ struct Summary {
     done: bool,
 }
 
+#[derive(Copy, Clone, Default, Eq, PartialEq, Hash)]
+pub struct SlashCommandInvocationId(usize);
+
+impl SlashCommandInvocationId {
+    fn post_inc(&mut self) -> Self {
+        let id = *self;
+        self.0 += 1;
+        id
+    }
+}
+
+struct SlashCommandInvocation {
+    _pending_output: Task<Option<()>>,
+}
+
 pub struct Conversation {
     id: Option<String>,
     buffer: Model<Buffer>,
-    pub(crate) ambient_context: AmbientContext,
     edit_suggestions: Vec<EditSuggestion>,
-    slash_command_calls: Vec<SlashCommandCall>,
+    pending_slash_commands: Vec<PendingSlashCommand>,
+    edits_since_last_slash_command_parse: language::Subscription,
     message_anchors: Vec<MessageAnchor>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     next_message_id: MessageId,
@@ -1484,14 +1499,14 @@ pub struct Conversation {
     token_count: Option<usize>,
     pending_token_count: Task<Option<()>>,
     pending_edit_suggestion_parse: Option<Task<()>>,
-    pending_command_invocation_parse: Option<Task<()>>,
     pending_save: Task<Result<()>>,
     path: Option<PathBuf>,
+    invocations: HashMap<SlashCommandInvocationId, SlashCommandInvocation>,
+    next_invocation_id: SlashCommandInvocationId,
     _subscriptions: Vec<Subscription>,
     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 {}
@@ -1502,7 +1517,6 @@ 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| {
@@ -1510,15 +1524,16 @@ impl Conversation {
             buffer.set_language_registry(language_registry.clone());
             buffer
         });
-
+        let edits_since_last_slash_command_parse =
+            buffer.update(cx, |buffer, _| buffer.subscribe());
         let mut this = Self {
             id: Some(Uuid::new_v4().to_string()),
             message_anchors: Default::default(),
             messages_metadata: Default::default(),
             next_message_id: Default::default(),
-            ambient_context: AmbientContext::default(),
             edit_suggestions: Vec::new(),
-            slash_command_calls: Vec::new(),
+            pending_slash_commands: Vec::new(),
+            edits_since_last_slash_command_parse,
             summary: None,
             pending_summary: Task::ready(None),
             completion_count: Default::default(),
@@ -1526,16 +1541,16 @@ impl Conversation {
             token_count: None,
             pending_token_count: Task::ready(None),
             pending_edit_suggestion_parse: None,
-            pending_command_invocation_parse: None,
+            next_invocation_id: SlashCommandInvocationId::default(),
+            invocations: HashMap::default(),
             model,
             _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
             pending_save: Task::ready(Ok(())),
             path: None,
             buffer,
             telemetry,
-            slash_command_registry,
             language_registry,
-            lsp_adapter_delegate,
+            slash_command_registry,
         };
 
         let message = MessageAnchor {
@@ -1548,7 +1563,6 @@ impl Conversation {
             MessageMetadata {
                 role: Role::User,
                 status: MessageStatus::Done,
-                ambient_context: AmbientContextSnapshot::default(),
             },
         );
 
@@ -1587,7 +1601,6 @@ 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 {
@@ -1620,14 +1633,16 @@ impl Conversation {
         })?;
 
         cx.new_model(move |cx| {
+            let edits_since_last_slash_command_parse =
+                buffer.update(cx, |buffer, _| buffer.subscribe());
             let mut this = Self {
                 id,
                 message_anchors,
                 messages_metadata: saved_conversation.message_metadata,
                 next_message_id,
-                ambient_context: AmbientContext::default(),
                 edit_suggestions: Vec::new(),
-                slash_command_calls: Vec::new(),
+                pending_slash_commands: Vec::new(),
+                edits_since_last_slash_command_parse,
                 summary: Some(Summary {
                     text: saved_conversation.summary,
                     done: true,
@@ -1637,8 +1652,9 @@ impl Conversation {
                 pending_completions: Default::default(),
                 token_count: None,
                 pending_edit_suggestion_parse: None,
-                pending_command_invocation_parse: None,
                 pending_token_count: Task::ready(None),
+                next_invocation_id: SlashCommandInvocationId::default(),
+                invocations: HashMap::default(),
                 model,
                 _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
                 pending_save: Task::ready(Ok(())),
@@ -1647,7 +1663,6 @@ impl Conversation {
                 telemetry,
                 language_registry,
                 slash_command_registry,
-                lsp_adapter_delegate,
             };
             this.set_language(cx);
             this.reparse_edit_suggestions(cx);
@@ -1668,60 +1683,6 @@ impl Conversation {
         .detach_and_log_err(cx);
     }
 
-    fn toggle_recent_buffers(&mut self, cx: &mut ModelContext<Self>) {
-        self.ambient_context.recent_buffers.enabled = !self.ambient_context.recent_buffers.enabled;
-        match self.ambient_context.recent_buffers.update(cx) {
-            ContextUpdated::Updating => {}
-            ContextUpdated::Disabled => {
-                self.count_remaining_tokens(cx);
-            }
-        }
-    }
-
-    fn toggle_current_project_context(
-        &mut self,
-        fs: Arc<dyn Fs>,
-        project: WeakModel<Project>,
-        cx: &mut ModelContext<Self>,
-    ) {
-        self.ambient_context.current_project.enabled =
-            !self.ambient_context.current_project.enabled;
-        match self.ambient_context.current_project.update(fs, project, cx) {
-            ContextUpdated::Updating => {}
-            ContextUpdated::Disabled => {
-                self.count_remaining_tokens(cx);
-            }
-        }
-    }
-
-    fn set_recent_buffers(
-        &mut self,
-        buffers: impl IntoIterator<Item = Model<Buffer>>,
-        cx: &mut ModelContext<Self>,
-    ) {
-        self.ambient_context.recent_buffers.buffers.clear();
-        self.ambient_context
-            .recent_buffers
-            .buffers
-            .extend(buffers.into_iter().map(|buffer| RecentBuffer {
-                buffer: buffer.downgrade(),
-                _subscription: cx.observe(&buffer, |this, _, cx| {
-                    match this.ambient_context.recent_buffers.update(cx) {
-                        ContextUpdated::Updating => {}
-                        ContextUpdated::Disabled => {
-                            this.count_remaining_tokens(cx);
-                        }
-                    }
-                }),
-            }));
-        match self.ambient_context.recent_buffers.update(cx) {
-            ContextUpdated::Updating => {}
-            ContextUpdated::Disabled => {
-                self.count_remaining_tokens(cx);
-            }
-        }
-    }
-
     fn handle_buffer_event(
         &mut self,
         _: Model<Buffer>,
@@ -1731,7 +1692,7 @@ impl Conversation {
         if *event == language::Event::Edited {
             self.count_remaining_tokens(cx);
             self.reparse_edit_suggestions(cx);
-            self.reparse_slash_command_calls(cx);
+            self.reparse_slash_commands(cx);
             cx.emit(ConversationEvent::MessagesEdited);
         }
     }
@@ -1758,6 +1719,94 @@ impl Conversation {
         });
     }
 
+    fn reparse_slash_commands(&mut self, cx: &mut ModelContext<Self>) {
+        let buffer = self.buffer.read(cx);
+        let mut row_ranges = self
+            .edits_since_last_slash_command_parse
+            .consume()
+            .into_iter()
+            .map(|edit| {
+                let start_row = buffer.offset_to_point(edit.new.start).row;
+                let end_row = buffer.offset_to_point(edit.new.end).row + 1;
+                start_row..end_row
+            })
+            .peekable();
+
+        let mut removed = Vec::new();
+        let mut updated = Vec::new();
+        while let Some(mut row_range) = row_ranges.next() {
+            while let Some(next_row_range) = row_ranges.peek() {
+                if row_range.end >= next_row_range.start {
+                    row_range.end = next_row_range.end;
+                    row_ranges.next();
+                } else {
+                    break;
+                }
+            }
+
+            let start = buffer.anchor_before(Point::new(row_range.start, 0));
+            let end = buffer.anchor_after(Point::new(
+                row_range.end - 1,
+                buffer.line_len(row_range.end - 1),
+            ));
+
+            let start_ix = match self
+                .pending_slash_commands
+                .binary_search_by(|probe| probe.source_range.start.cmp(&start, buffer))
+            {
+                Ok(ix) | Err(ix) => ix,
+            };
+            let end_ix = match self.pending_slash_commands[start_ix..]
+                .binary_search_by(|probe| probe.source_range.end.cmp(&end, buffer))
+            {
+                Ok(ix) => start_ix + ix + 1,
+                Err(ix) => start_ix + ix,
+            };
+
+            let mut new_commands = Vec::new();
+            let mut lines = buffer.text_for_range(start..end).lines();
+            let mut offset = lines.offset();
+            while let Some(line) = lines.next() {
+                if let Some(command_line) = SlashCommandLine::parse(line) {
+                    let name = &line[command_line.name.clone()];
+                    let argument = command_line.argument.as_ref().and_then(|argument| {
+                        (!argument.is_empty()).then_some(&line[argument.clone()])
+                    });
+                    if let Some(command) = self.slash_command_registry.command(name) {
+                        if !command.requires_argument() || argument.is_some() {
+                            let start_ix = offset + command_line.name.start - 1;
+                            let end_ix = offset
+                                + command_line
+                                    .argument
+                                    .map_or(command_line.name.end, |argument| argument.end);
+                            let source_range =
+                                buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix);
+                            let pending_command = PendingSlashCommand {
+                                name: name.to_string(),
+                                argument: argument.map(ToString::to_string),
+                                tooltip_text: command.tooltip_text().into(),
+                                source_range,
+                            };
+                            updated.push(pending_command.clone());
+                            new_commands.push(pending_command);
+                        }
+                    }
+                }
+
+                offset = lines.offset();
+            }
+
+            let removed_commands = self
+                .pending_slash_commands
+                .splice(start_ix..end_ix, new_commands);
+            removed.extend(removed_commands.map(|command| command.source_range));
+        }
+
+        if !updated.is_empty() || !removed.is_empty() {
+            cx.emit(ConversationEvent::PendingSlashCommandsUpdated { removed, updated });
+        }
+    }
+
     fn reparse_edit_suggestions(&mut self, cx: &mut ModelContext<Self>) {
         self.pending_edit_suggestion_parse = Some(cx.spawn(|this, mut cx| async move {
             cx.background_executor()
@@ -1817,222 +1866,72 @@ impl Conversation {
         cx.notify();
     }
 
-    fn reparse_slash_command_calls(&mut self, cx: &mut ModelContext<Self>) {
-        self.pending_command_invocation_parse = Some(cx.spawn(|this, mut cx| async move {
-            cx.background_executor().timer(SLASH_COMMAND_DEBOUNCE).await;
-
-            this.update(&mut cx, |this, cx| {
-                let buffer = this.buffer.read(cx).snapshot();
-
-                let mut changed = false;
-                let mut new_calls = Vec::new();
-                let mut old_calls = mem::take(&mut this.slash_command_calls)
-                    .into_iter()
-                    .peekable();
-                let mut lines = buffer.as_rope().chunks().lines();
-                let mut offset = 0;
-                while let Some(line) = lines.next() {
-                    let line_end_offset = offset + line.len();
-                    if let Some(call) = SlashCommandLine::parse(line) {
-                        let mut unchanged_call = None;
-                        while let Some(old_call) = old_calls.peek() {
-                            match old_call.source_range.start.to_offset(&buffer).cmp(&offset) {
-                                Ordering::Greater => break,
-                                Ordering::Equal
-                                    if this.slash_command_is_unchanged(
-                                        old_call, &call, line, &buffer,
-                                    ) =>
-                                {
-                                    unchanged_call = old_calls.next();
-                                }
-                                _ => {
-                                    changed = true;
-                                    let old_call = old_calls.next().unwrap();
-                                    this.slash_command_call_removed(old_call, cx);
-                                }
-                            }
-                        }
-
-                        let name = &line[call.name];
-                        if let Some(call) = unchanged_call {
-                            new_calls.push(call);
-                        } else if let Some((command, lsp_adapter_delegate)) = this
-                            .slash_command_registry
-                            .command(name)
-                            .zip(this.lsp_adapter_delegate.clone())
-                        {
-                            changed = true;
-                            let name = name.to_string();
-                            let source_range =
-                                buffer.anchor_after(offset)..buffer.anchor_before(line_end_offset);
-
-                            let argument = call.argument.map(|range| &line[range]);
-                            let invocation = command.run(argument, lsp_adapter_delegate, cx);
-
-                            new_calls.push(SlashCommandCall {
-                                name,
-                                argument: argument.map(|s| s.to_string()),
-                                source_range: source_range.clone(),
-                                output_range: None,
-                                should_rerun: false,
-                                _invalidate: cx.spawn(|this, mut cx| {
-                                    let source_range = source_range.clone();
-                                    let invalidated = invocation.invalidated;
-                                    async move {
-                                        if invalidated.await.is_ok() {
-                                            _ = this.update(&mut cx, |this, cx| {
-                                                let buffer = this.buffer.read(cx);
-                                                let call_ix = this
-                                                    .slash_command_calls
-                                                    .binary_search_by(|probe| {
-                                                        probe
-                                                            .source_range
-                                                            .start
-                                                            .cmp(&source_range.start, buffer)
-                                                    });
-                                                if let Ok(call_ix) = call_ix {
-                                                    this.slash_command_calls[call_ix]
-                                                        .should_rerun = true;
-                                                    this.reparse_slash_command_calls(cx);
-                                                }
-                                            });
-                                        }
-                                    }
-                                }),
-                                _command_cleanup: invocation.cleanup,
-                            });
-
-                            cx.spawn(|this, mut cx| async move {
-                                let output = invocation.output.await;
-                                this.update(&mut cx, |this, cx| {
-                                    let output_range = this.buffer.update(cx, |buffer, cx| {
-                                        let call_ix = this
-                                            .slash_command_calls
-                                            .binary_search_by(|probe| {
-                                                probe
-                                                    .source_range
-                                                    .start
-                                                    .cmp(&source_range.start, buffer)
-                                            })
-                                            .ok()?;
-
-                                        let mut output = output.log_err()?;
-                                        output.truncate(output.trim_end().len());
-
-                                        let source_end = source_range.end.to_offset(buffer);
-                                        let output_start = source_end + '\n'.len_utf8();
-                                        let output_end = output_start + output.len();
-
-                                        if buffer
-                                            .chars_at(source_end)
-                                            .next()
-                                            .map_or(false, |c| c != '\n')
-                                        {
-                                            output.push('\n');
-                                        }
-
-                                        buffer.edit(
-                                            [
-                                                (source_end..source_end, "\n".to_string()),
-                                                (source_end..source_end, output),
-                                            ],
-                                            None,
-                                            cx,
-                                        );
-
-                                        let output_start = buffer.anchor_after(output_start);
-                                        let output_end = buffer.anchor_before(output_end);
-                                        this.slash_command_calls[call_ix].output_range =
-                                            Some(output_start..output_end);
-                                        Some(source_range.end..output_end)
-                                    });
-                                    if let Some(output_range) = output_range {
-                                        cx.emit(ConversationEvent::SlashCommandOutputAdded(
-                                            output_range,
-                                        ));
-                                        cx.emit(ConversationEvent::SlashCommandsChanged);
-                                    }
-                                })
-                                .ok();
-                            })
-                            .detach();
-                        }
-                    }
-                    offset = lines.offset();
-                }
-
-                for old_call in old_calls {
-                    changed = true;
-                    this.slash_command_call_removed(old_call, cx);
-                }
-
-                if changed {
-                    cx.emit(ConversationEvent::SlashCommandsChanged);
+    fn pending_command_for_position(
+        &self,
+        position: language::Anchor,
+        cx: &AppContext,
+    ) -> Option<&PendingSlashCommand> {
+        let buffer = self.buffer.read(cx);
+        let ix = self
+            .pending_slash_commands
+            .binary_search_by(|probe| {
+                if probe.source_range.start.cmp(&position, buffer).is_gt() {
+                    Ordering::Less
+                } else if probe.source_range.end.cmp(&position, buffer).is_lt() {
+                    Ordering::Greater
+                } else {
+                    Ordering::Equal
                 }
-
-                this.slash_command_calls = new_calls;
             })
-            .ok();
-        }));
+            .ok()?;
+        self.pending_slash_commands.get(ix)
     }
 
-    fn slash_command_is_unchanged(
-        &self,
-        old_call: &SlashCommandCall,
-        new_call: &SlashCommandLine,
-        new_text: &str,
-        buffer: &BufferSnapshot,
-    ) -> bool {
-        if old_call.name != new_text[new_call.name.clone()] {
-            return false;
-        }
+    fn insert_command_output(
+        &mut self,
+        invocation_id: SlashCommandInvocationId,
+        command_range: Range<language::Anchor>,
+        output: Task<Result<SlashCommandOutput>>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let insert_output_task = cx.spawn(|this, mut cx| {
+            async move {
+                let output = output.await?;
 
-        if old_call.argument.as_deref() != new_call.argument.clone().map(|range| &new_text[range]) {
-            return false;
-        }
+                let mut text = output.text;
+                LineEnding::normalize(&mut text);
+                if !text.ends_with('\n') {
+                    text.push('\n');
+                }
 
-        if old_call.should_rerun {
-            return false;
-        }
+                this.update(&mut cx, |this, cx| {
+                    let output_range = this.buffer.update(cx, |buffer, cx| {
+                        let start = command_range.start.to_offset(buffer);
+                        let old_end = command_range.end.to_offset(buffer);
+                        let new_end = start + text.len();
+                        buffer.edit([(start..old_end, text)], None, cx);
+                        if buffer.chars_at(new_end).next() != Some('\n') {
+                            buffer.edit([(new_end..new_end, "\n")], None, cx);
+                        }
+                        buffer.anchor_after(start)..buffer.anchor_before(new_end)
+                    });
+                    cx.emit(ConversationEvent::SlashCommandFinished {
+                        output_range,
+                        render_placeholder: output.render_placeholder,
+                    });
+                })?;
 
-        if let Some(output_range) = &old_call.output_range {
-            let source_range = old_call.source_range.to_point(buffer);
-            let output_start = output_range.start.to_point(buffer);
-            if source_range.start.column != 0 {
-                return false;
-            }
-            if source_range.end.column != new_text.len() as u32 {
-                return false;
-            }
-            if output_start != Point::new(source_range.end.row + 1, 0) {
-                return false;
-            }
-            if let Some(next_char) = buffer.chars_at(output_range.end).next() {
-                if next_char != '\n' {
-                    return false;
-                }
+                anyhow::Ok(())
             }
-        }
-        true
-    }
+            .log_err()
+        });
 
-    fn slash_command_call_removed(
-        &self,
-        old_call: SlashCommandCall,
-        cx: &mut ModelContext<Conversation>,
-    ) {
-        if let Some(output_range) = old_call.output_range {
-            self.buffer.update(cx, |buffer, cx| {
-                buffer.edit(
-                    [(old_call.source_range.end..output_range.end, "")],
-                    None,
-                    cx,
-                );
-            });
-            cx.emit(ConversationEvent::SlashCommandOutputRemoved(
-                old_call.source_range.end..output_range.end,
-            ))
-        }
+        self.invocations.insert(
+            invocation_id,
+            SlashCommandInvocation {
+                _pending_output: insert_output_task,
+            },
+        );
     }
 
     fn remaining_tokens(&self) -> Option<isize> {
@@ -2207,18 +2106,11 @@ impl Conversation {
             content: include_str!("./system_prompts/edits.md").to_string(),
         };
 
-        let recent_buffers_context = self.ambient_context.recent_buffers.to_message();
-        let current_project_context = self.ambient_context.current_project.to_message();
-
-        let messages = Some(edits_system_prompt)
-            .into_iter()
-            .chain(recent_buffers_context)
-            .chain(current_project_context)
-            .chain(
-                self.messages(cx)
-                    .filter(|message| matches!(message.status, MessageStatus::Done))
-                    .map(|message| message.to_request_message(self.buffer.read(cx))),
-            );
+        let messages = Some(edits_system_prompt).into_iter().chain(
+            self.messages(cx)
+                .filter(|message| matches!(message.status, MessageStatus::Done))
+                .map(|message| message.to_request_message(self.buffer.read(cx))),
+        );
 
         LanguageModelRequest {
             model: self.model.clone(),
@@ -2277,14 +2169,8 @@ impl Conversation {
             };
             self.message_anchors
                 .insert(next_message_ix, message.clone());
-            self.messages_metadata.insert(
-                message.id,
-                MessageMetadata {
-                    role,
-                    status,
-                    ambient_context: self.ambient_context.snapshot(),
-                },
-            );
+            self.messages_metadata
+                .insert(message.id, MessageMetadata { role, status });
             cx.emit(ConversationEvent::MessagesEdited);
             Some(message)
         } else {
@@ -2342,7 +2228,6 @@ impl Conversation {
                 MessageMetadata {
                     role,
                     status: MessageStatus::Done,
-                    ambient_context: message.ambient_context.clone(),
                 },
             );
 
@@ -2387,7 +2272,6 @@ impl Conversation {
                         MessageMetadata {
                             role,
                             status: MessageStatus::Done,
-                            ambient_context: message.ambient_context,
                         },
                     );
                     (Some(selection), Some(suffix))
@@ -2493,17 +2377,6 @@ impl Conversation {
 
     fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator<Item = Message> {
         let buffer = self.buffer.read(cx);
-        let mut slash_command_calls = self
-            .slash_command_calls
-            .iter()
-            .map(|call| {
-                if let Some(output) = &call.output_range {
-                    call.source_range.start.to_offset(buffer)..output.start.to_offset(buffer)
-                } else {
-                    call.source_range.to_offset(buffer)
-                }
-            })
-            .peekable();
         let mut message_anchors = self.message_anchors.iter().enumerate().peekable();
         iter::from_fn(move || {
             if let Some((start_ix, message_anchor)) = message_anchors.next() {
@@ -2524,15 +2397,6 @@ impl Conversation {
                     .unwrap_or(language::Anchor::MAX)
                     .to_offset(buffer);
 
-                let mut slash_command_ranges = Vec::new();
-                while let Some(call_range) = slash_command_calls.peek() {
-                    if call_range.end <= message_end {
-                        slash_command_ranges.push(slash_command_calls.next().unwrap());
-                    } else {
-                        break;
-                    }
-                }
-
                 return Some(Message {
                     index_range: start_ix..end_ix,
                     offset_range: message_start..message_end,
@@ -2540,8 +2404,6 @@ impl Conversation {
                     anchor: message_anchor.start,
                     role: metadata.role,
                     status: metadata.status.clone(),
-                    slash_command_ranges,
-                    ambient_context: metadata.ambient_context.clone(),
                 });
             }
             None
@@ -2699,14 +2561,12 @@ fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option<ParsedEditSugge
     }
 }
 
-struct SlashCommandCall {
-    source_range: Range<language::Anchor>,
-    output_range: Option<Range<language::Anchor>>,
+#[derive(Clone)]
+struct PendingSlashCommand {
     name: String,
     argument: Option<String>,
-    should_rerun: bool,
-    _invalidate: Task<()>,
-    _command_cleanup: SlashCommandCleanup,
+    source_range: Range<language::Anchor>,
+    tooltip_text: SharedString,
 }
 
 struct PendingCompletion {
@@ -2724,14 +2584,16 @@ struct ScrollPosition {
     cursor: Anchor,
 }
 
-struct ConversationEditor {
+pub struct ConversationEditor {
     conversation: Model<Conversation>,
     fs: Arc<dyn Fs>,
     workspace: WeakView<Workspace>,
+    slash_command_registry: Arc<SlashCommandRegistry>,
+    lsp_adapter_delegate: Option<Arc<dyn LspAdapterDelegate>>,
     editor: View<Editor>,
-    flap_ids: HashMap<Range<language::Anchor>, FlapId>,
     blocks: HashSet<BlockId>,
     scroll_position: Option<ScrollPosition>,
+    pending_slash_command_flaps: HashMap<Range<language::Anchor>, FlapId>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -2754,21 +2616,27 @@ impl ConversationEditor {
                 language_registry,
                 slash_command_registry,
                 Some(telemetry),
-                lsp_adapter_delegate,
                 cx,
             )
         });
-        Self::for_conversation(conversation, fs, workspace, cx)
+
+        Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx)
     }
 
     fn for_conversation(
         conversation: Model<Conversation>,
         fs: Arc<dyn Fs>,
         workspace: View<Workspace>,
+        lsp_adapter_delegate: Option<Arc<dyn LspAdapterDelegate>>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let command_registry = conversation.read(cx).slash_command_registry.clone();
-        let completion_provider = SlashCommandCompletionProvider::new(command_registry);
+        let slash_command_registry = conversation.read(cx).slash_command_registry.clone();
+
+        let completion_provider = SlashCommandCompletionProvider::new(
+            cx.view().downgrade(),
+            slash_command_registry.clone(),
+            workspace.downgrade(),
+        );
 
         let editor = cx.new_view(|cx| {
             let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx);
@@ -2786,20 +2654,20 @@ impl ConversationEditor {
             cx.observe(&conversation, |_, _, cx| cx.notify()),
             cx.subscribe(&conversation, Self::handle_conversation_event),
             cx.subscribe(&editor, Self::handle_editor_event),
-            cx.subscribe(&workspace, Self::handle_workspace_event),
         ];
 
         let mut this = Self {
             conversation,
             editor,
+            slash_command_registry,
+            lsp_adapter_delegate,
             blocks: Default::default(),
             scroll_position: None,
-            flap_ids: Default::default(),
             fs,
             workspace: workspace.downgrade(),
+            pending_slash_command_flaps: HashMap::default(),
             _subscriptions,
         };
-        this.update_recent_editors(cx);
         this.update_message_headers(cx);
         this
     }
@@ -2866,12 +2734,68 @@ impl ConversationEditor {
             .collect()
     }
 
+    pub fn confirm_command(&mut self, _: &ConfirmCommand, cx: &mut ViewContext<Self>) {
+        let selections = self.editor.read(cx).selections.disjoint_anchors();
+        let mut commands_by_range = HashMap::default();
+        let workspace = self.workspace.clone();
+        self.conversation.update(cx, |conversation, cx| {
+            for selection in selections.iter() {
+                if let Some(command) =
+                    conversation.pending_command_for_position(selection.head().text_anchor, cx)
+                {
+                    commands_by_range
+                        .entry(command.source_range.clone())
+                        .or_insert_with(|| command.clone());
+                }
+            }
+        });
+
+        if commands_by_range.is_empty() {
+            cx.propagate();
+        } else {
+            for command in commands_by_range.into_values() {
+                self.run_command(
+                    command.source_range,
+                    &command.name,
+                    command.argument.as_deref(),
+                    workspace.clone(),
+                    cx,
+                );
+            }
+            cx.stop_propagation();
+        }
+    }
+
+    pub fn run_command(
+        &mut self,
+        command_range: Range<language::Anchor>,
+        name: &str,
+        argument: Option<&str>,
+        workspace: WeakView<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<SlashCommandInvocationId> {
+        let command = self.slash_command_registry.command(name)?;
+        let lsp_adapter_delegate = self.lsp_adapter_delegate.clone()?;
+        let argument = argument.map(ToString::to_string);
+        let id = self.conversation.update(cx, |conversation, _| {
+            conversation.next_invocation_id.post_inc()
+        });
+        let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx);
+        self.conversation.update(cx, |conversation, cx| {
+            conversation.insert_command_output(id, command_range, output, cx)
+        });
+
+        Some(id)
+    }
+
     fn handle_conversation_event(
         &mut self,
         _: Model<Conversation>,
         event: &ConversationEvent,
         cx: &mut ViewContext<Self>,
     ) {
+        let conversation_editor = cx.view().downgrade();
+
         match event {
             ConversationEvent::MessagesEdited => {
                 self.update_message_headers(cx);

crates/assistant/src/omit_ranges.rs 🔗

@@ -1,101 +0,0 @@
-use rope::Rope;
-use std::{cmp::Ordering, ops::Range};
-
-pub(crate) fn text_in_range_omitting_ranges(
-    rope: &Rope,
-    range: Range<usize>,
-    omit_ranges: &[Range<usize>],
-) -> String {
-    let mut content = String::with_capacity(range.len());
-    let mut omit_ranges = omit_ranges
-        .iter()
-        .skip_while(|omit_range| omit_range.end <= range.start)
-        .peekable();
-    let mut offset = range.start;
-    let mut chunks = rope.chunks_in_range(range.clone());
-    while let Some(chunk) = chunks.next() {
-        if let Some(omit_range) = omit_ranges.peek() {
-            match offset.cmp(&omit_range.start) {
-                Ordering::Less => {
-                    let max_len = omit_range.start - offset;
-                    if chunk.len() < max_len {
-                        content.push_str(chunk);
-                        offset += chunk.len();
-                    } else {
-                        content.push_str(&chunk[..max_len]);
-                        chunks.seek(omit_range.end.min(range.end));
-                        offset = omit_range.end;
-                        omit_ranges.next();
-                    }
-                }
-                Ordering::Equal | Ordering::Greater => {
-                    chunks.seek(omit_range.end.min(range.end));
-                    offset = omit_range.end;
-                    omit_ranges.next();
-                }
-            }
-        } else {
-            content.push_str(chunk);
-            offset += chunk.len();
-        }
-    }
-
-    content
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use rand::{rngs::StdRng, Rng as _};
-    use util::RandomCharIter;
-
-    #[gpui::test(iterations = 100)]
-    fn test_text_in_range_omitting_ranges(mut rng: StdRng) {
-        let text = RandomCharIter::new(&mut rng).take(1024).collect::<String>();
-        let rope = Rope::from(text.as_str());
-
-        let mut start = rng.gen_range(0..=text.len() / 2);
-        let mut end = rng.gen_range(text.len() / 2..=text.len());
-        while !text.is_char_boundary(start) {
-            start -= 1;
-        }
-        while !text.is_char_boundary(end) {
-            end += 1;
-        }
-        let range = start..end;
-
-        let mut ix = 0;
-        let mut omit_ranges = Vec::new();
-        for _ in 0..rng.gen_range(0..10) {
-            let mut start = rng.gen_range(ix..=text.len());
-            while !text.is_char_boundary(start) {
-                start += 1;
-            }
-            let mut end = rng.gen_range(start..=text.len());
-            while !text.is_char_boundary(end) {
-                end += 1;
-            }
-            omit_ranges.push(start..end);
-            ix = end;
-            if ix == text.len() {
-                break;
-            }
-        }
-
-        let mut expected_text = text[range.clone()].to_string();
-        for omit_range in omit_ranges.iter().rev() {
-            let start = omit_range
-                .start
-                .saturating_sub(range.start)
-                .min(range.len());
-            let end = omit_range.end.saturating_sub(range.start).min(range.len());
-            expected_text.replace_range(start..end, "");
-        }
-
-        assert_eq!(
-            text_in_range_omitting_ranges(&rope, range.clone(), &omit_ranges),
-            expected_text,
-            "text: {text:?}\nrange: {range:?}\nomit_ranges: {omit_ranges:?}"
-        );
-    }
-}

crates/assistant/src/slash_command.rs 🔗

@@ -1,7 +1,9 @@
+use crate::assistant_panel::ConversationEditor;
 use anyhow::Result;
+pub use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandRegistry};
 use editor::{CompletionProvider, Editor};
 use fuzzy::{match_strings, StringMatchCandidate};
-use gpui::{AppContext, Model, Task, ViewContext};
+use gpui::{Model, Task, ViewContext, WeakView, WindowContext};
 use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint};
 use parking_lot::{Mutex, RwLock};
 use rope::Point;
@@ -12,18 +14,18 @@ use std::{
         Arc,
     },
 };
+use workspace::Workspace;
 
-pub use assistant_slash_command::{
-    SlashCommand, SlashCommandCleanup, SlashCommandInvocation, SlashCommandRegistry,
-};
-
-pub mod current_file_command;
+pub mod active_command;
 pub mod file_command;
+pub mod project_command;
 pub mod prompt_command;
 
 pub(crate) struct SlashCommandCompletionProvider {
+    editor: WeakView<ConversationEditor>,
     commands: Arc<SlashCommandRegistry>,
     cancel_flag: Mutex<Arc<AtomicBool>>,
+    workspace: WeakView<Workspace>,
 }
 
 pub(crate) struct SlashCommandLine {
@@ -34,18 +36,25 @@ pub(crate) struct SlashCommandLine {
 }
 
 impl SlashCommandCompletionProvider {
-    pub fn new(commands: Arc<SlashCommandRegistry>) -> Self {
+    pub fn new(
+        editor: WeakView<ConversationEditor>,
+        commands: Arc<SlashCommandRegistry>,
+        workspace: WeakView<Workspace>,
+    ) -> Self {
         Self {
             cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))),
+            editor,
             commands,
+            workspace,
         }
     }
 
     fn complete_command_name(
         &self,
         command_name: &str,
-        range: Range<Anchor>,
-        cx: &mut AppContext,
+        command_range: Range<Anchor>,
+        name_range: Range<Anchor>,
+        cx: &mut WindowContext,
     ) -> Task<Result<Vec<project::Completion>>> {
         let candidates = self
             .commands
@@ -60,6 +69,8 @@ impl SlashCommandCompletionProvider {
             .collect::<Vec<_>>();
         let commands = self.commands.clone();
         let command_name = command_name.to_string();
+        let editor = self.editor.clone();
+        let workspace = self.workspace.clone();
         let executor = cx.background_executor().clone();
         executor.clone().spawn(async move {
             let matches = match_strings(
@@ -77,17 +88,37 @@ impl SlashCommandCompletionProvider {
                 .filter_map(|mat| {
                     let command = commands.command(&mat.string)?;
                     let mut new_text = mat.string.clone();
-                    if command.requires_argument() {
+                    let requires_argument = command.requires_argument();
+                    if requires_argument {
                         new_text.push(' ');
                     }
 
                     Some(project::Completion {
-                        old_range: range.clone(),
+                        old_range: name_range.clone(),
                         documentation: Some(Documentation::SingleLine(command.description())),
                         new_text,
-                        label: CodeLabel::plain(mat.string, None),
+                        label: CodeLabel::plain(mat.string.clone(), None),
                         server_id: LanguageServerId(0),
                         lsp_completion: Default::default(),
+                        confirm: (!requires_argument).then(|| {
+                            let command_name = mat.string.clone();
+                            let command_range = command_range.clone();
+                            let editor = editor.clone();
+                            let workspace = workspace.clone();
+                            Arc::new(move |cx: &mut WindowContext| {
+                                editor
+                                    .update(cx, |editor, cx| {
+                                        editor.run_command(
+                                            command_range.clone(),
+                                            &command_name,
+                                            None,
+                                            workspace.clone(),
+                                            cx,
+                                        );
+                                    })
+                                    .ok();
+                            }) as Arc<_>
+                        }),
                     })
                 })
                 .collect())
@@ -98,8 +129,9 @@ impl SlashCommandCompletionProvider {
         &self,
         command_name: &str,
         argument: String,
-        range: Range<Anchor>,
-        cx: &mut AppContext,
+        command_range: Range<Anchor>,
+        argument_range: Range<Anchor>,
+        cx: &mut WindowContext,
     ) -> Task<Result<Vec<project::Completion>>> {
         let new_cancel_flag = Arc::new(AtomicBool::new(false));
         let mut flag = self.cancel_flag.lock();
@@ -108,17 +140,39 @@ impl SlashCommandCompletionProvider {
 
         if let Some(command) = self.commands.command(command_name) {
             let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx);
+            let command_name: Arc<str> = command_name.into();
+            let editor = self.editor.clone();
+            let workspace = self.workspace.clone();
             cx.background_executor().spawn(async move {
                 Ok(completions
                     .await?
                     .into_iter()
                     .map(|arg| project::Completion {
-                        old_range: range.clone(),
+                        old_range: argument_range.clone(),
                         label: CodeLabel::plain(arg.clone(), None),
                         new_text: arg.clone(),
                         documentation: None,
                         server_id: LanguageServerId(0),
                         lsp_completion: Default::default(),
+                        confirm: Some(Arc::new({
+                            let command_name = command_name.clone();
+                            let command_range = command_range.clone();
+                            let editor = editor.clone();
+                            let workspace = workspace.clone();
+                            move |cx| {
+                                editor
+                                    .update(cx, |editor, cx| {
+                                        editor.run_command(
+                                            command_range.clone(),
+                                            &command_name,
+                                            Some(&arg),
+                                            workspace.clone(),
+                                            cx,
+                                        );
+                                    })
+                                    .ok();
+                            }
+                        })),
                     })
                     .collect())
             })
@@ -136,25 +190,44 @@ impl CompletionProvider for SlashCommandCompletionProvider {
         buffer_position: Anchor,
         cx: &mut ViewContext<Editor>,
     ) -> Task<Result<Vec<project::Completion>>> {
-        let task = buffer.update(cx, |buffer, cx| {
-            let position = buffer_position.to_point(buffer);
-            let line_start = Point::new(position.row, 0);
-            let mut lines = buffer.text_for_range(line_start..position).lines();
-            let line = lines.next()?;
-            let call = SlashCommandLine::parse(line)?;
-
-            let name = &line[call.name.clone()];
-            if let Some(argument) = call.argument {
-                let start = buffer.anchor_after(Point::new(position.row, argument.start as u32));
-                let argument = line[argument.clone()].to_string();
-                Some(self.complete_command_argument(name, argument, start..buffer_position, cx))
-            } else {
-                let start = buffer.anchor_after(Point::new(position.row, call.name.start as u32));
-                Some(self.complete_command_name(name, start..buffer_position, cx))
-            }
-        });
+        let Some((name, argument, command_range, argument_range)) =
+            buffer.update(cx, |buffer, _cx| {
+                let position = buffer_position.to_point(buffer);
+                let line_start = Point::new(position.row, 0);
+                let mut lines = buffer.text_for_range(line_start..position).lines();
+                let line = lines.next()?;
+                let call = SlashCommandLine::parse(line)?;
 
-        task.unwrap_or_else(|| Task::ready(Ok(Vec::new())))
+                let command_range_start = Point::new(position.row, call.name.start as u32 - 1);
+                let command_range_end = Point::new(
+                    position.row,
+                    call.argument.as_ref().map_or(call.name.end, |arg| arg.end) as u32,
+                );
+                let command_range = buffer.anchor_after(command_range_start)
+                    ..buffer.anchor_after(command_range_end);
+
+                let name = line[call.name.clone()].to_string();
+
+                Some(if let Some(argument) = call.argument {
+                    let start =
+                        buffer.anchor_after(Point::new(position.row, argument.start as u32));
+                    let argument = line[argument.clone()].to_string();
+                    (name, Some(argument), command_range, start..buffer_position)
+                } else {
+                    let start =
+                        buffer.anchor_after(Point::new(position.row, call.name.start as u32));
+                    (name, None, command_range, start..buffer_position)
+                })
+            })
+        else {
+            return Task::ready(Ok(Vec::new()));
+        };
+
+        if let Some(argument) = argument {
+            self.complete_command_argument(&name, argument, command_range, argument_range, cx)
+        } else {
+            self.complete_command_name(&name, command_range, argument_range, cx)
+        }
     }
 
     fn resolve_completions(

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

@@ -0,0 +1,117 @@
+use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
+use anyhow::{anyhow, Result};
+use collections::HashMap;
+use editor::Editor;
+use gpui::{AppContext, Entity, Task, WeakView};
+use language::LspAdapterDelegate;
+use std::{borrow::Cow, sync::Arc};
+use ui::{IntoElement, WindowContext};
+use workspace::Workspace;
+
+pub(crate) struct ActiveSlashCommand;
+
+impl SlashCommand for ActiveSlashCommand {
+    fn name(&self) -> String {
+        "active".into()
+    }
+
+    fn description(&self) -> String {
+        "insert active tab".into()
+    }
+
+    fn tooltip_text(&self) -> String {
+        "insert active tab".into()
+    }
+
+    fn complete_argument(
+        &self,
+        _query: String,
+        _cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
+        _cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Err(anyhow!("this command does not require argument")))
+    }
+
+    fn requires_argument(&self) -> bool {
+        false
+    }
+
+    fn run(
+        self: Arc<Self>,
+        _argument: Option<&str>,
+        workspace: WeakView<Workspace>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
+        let output = workspace.update(cx, |workspace, cx| {
+            let mut timestamps_by_entity_id = HashMap::default();
+            for pane in workspace.panes() {
+                let pane = pane.read(cx);
+                for entry in pane.activation_history() {
+                    timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
+                }
+            }
+
+            let mut most_recent_buffer = None;
+            for editor in workspace.items_of_type::<Editor>(cx) {
+                let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
+                    continue;
+                };
+
+                let timestamp = timestamps_by_entity_id
+                    .get(&editor.entity_id())
+                    .copied()
+                    .unwrap_or_default();
+                if most_recent_buffer
+                    .as_ref()
+                    .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp)
+                {
+                    most_recent_buffer = Some((buffer, timestamp));
+                }
+            }
+
+            if let Some((buffer, _)) = most_recent_buffer {
+                let snapshot = buffer.read(cx).snapshot();
+                let path = snapshot.resolve_file_path(cx, true);
+                let text = cx.background_executor().spawn({
+                    let path = path.clone();
+                    async move {
+                        let path = path
+                            .as_ref()
+                            .map(|path| path.to_string_lossy())
+                            .unwrap_or_else(|| Cow::Borrowed("untitled"));
+
+                        let mut output = String::with_capacity(path.len() + snapshot.len() + 9);
+                        output.push_str("```");
+                        output.push_str(&path);
+                        output.push('\n');
+                        for chunk in snapshot.as_rope().chunks() {
+                            output.push_str(chunk);
+                        }
+                        if !output.ends_with('\n') {
+                            output.push('\n');
+                        }
+                        output.push_str("```");
+                        output
+                    }
+                });
+                cx.foreground_executor().spawn(async move {
+                    Ok(SlashCommandOutput {
+                        text: text.await,
+                        render_placeholder: Arc::new(move |id, unfold, _| {
+                            FilePlaceholder {
+                                id,
+                                path: path.clone(),
+                                unfold,
+                            }
+                            .into_any_element()
+                        }),
+                    })
+                })
+            } else {
+                Task::ready(Err(anyhow!("no recent buffer found")))
+            }
+        });
+        output.unwrap_or_else(|error| Task::ready(Err(error)))
+    }
+}

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

@@ -1,142 +0,0 @@
-use std::sync::Arc;
-use std::{borrow::Cow, cell::Cell, rc::Rc};
-
-use anyhow::{anyhow, Result};
-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};
-
-pub(crate) struct CurrentFileSlashCommand {
-    workspace: WindowHandle<Workspace>,
-}
-
-impl CurrentFileSlashCommand {
-    pub fn new(workspace: WindowHandle<Workspace>) -> Self {
-        Self { workspace }
-    }
-}
-
-impl SlashCommand for CurrentFileSlashCommand {
-    fn name(&self) -> String {
-        "current_file".into()
-    }
-
-    fn description(&self) -> String {
-        "insert the current file".into()
-    }
-
-    fn complete_argument(
-        &self,
-        _query: String,
-        _cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
-        _cx: &mut AppContext,
-    ) -> Task<Result<Vec<String>>> {
-        Task::ready(Err(anyhow!("this command does not require argument")))
-    }
-
-    fn requires_argument(&self) -> bool {
-        false
-    }
-
-    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();
-        let output = self.workspace.update(cx, |workspace, cx| {
-            let mut timestamps_by_entity_id = HashMap::default();
-            for pane in workspace.panes() {
-                let pane = pane.read(cx);
-                for entry in pane.activation_history() {
-                    timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
-                }
-            }
-
-            let mut most_recent_buffer = None;
-            for editor in workspace.items_of_type::<Editor>(cx) {
-                let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
-                    continue;
-                };
-
-                let timestamp = timestamps_by_entity_id
-                    .get(&editor.entity_id())
-                    .copied()
-                    .unwrap_or_default();
-                if most_recent_buffer
-                    .as_ref()
-                    .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp)
-                {
-                    most_recent_buffer = Some((buffer, timestamp));
-                }
-            }
-
-            subscriptions.push({
-                let workspace_view = cx.view().clone();
-                let invalidate_tx = invalidate_tx.clone();
-                cx.window_context()
-                    .subscribe(&workspace_view, move |_workspace, event, _cx| match event {
-                        WorkspaceEvent::ActiveItemChanged
-                        | WorkspaceEvent::ItemAdded
-                        | WorkspaceEvent::ItemRemoved
-                        | WorkspaceEvent::PaneAdded(_)
-                        | WorkspaceEvent::PaneRemoved => {
-                            if let Some(invalidate_tx) = invalidate_tx.take() {
-                                _ = invalidate_tx.send(());
-                            }
-                        }
-                        _ => {}
-                    })
-            });
-
-            if let Some((buffer, _)) = most_recent_buffer {
-                subscriptions.push({
-                    let invalidate_tx = invalidate_tx.clone();
-                    cx.window_context().observe(&buffer, move |_buffer, _cx| {
-                        if let Some(invalidate_tx) = invalidate_tx.take() {
-                            _ = invalidate_tx.send(());
-                        }
-                    })
-                });
-
-                let snapshot = buffer.read(cx).snapshot();
-                let path = snapshot.resolve_file_path(cx, true);
-                cx.background_executor().spawn(async move {
-                    let path = path
-                        .as_ref()
-                        .map(|path| path.to_string_lossy())
-                        .unwrap_or_else(|| Cow::Borrowed("untitled"));
-
-                    let mut output = String::with_capacity(path.len() + snapshot.len() + 9);
-                    output.push_str("```");
-                    output.push_str(&path);
-                    output.push('\n');
-                    for chunk in snapshot.as_rope().chunks() {
-                        output.push_str(chunk);
-                    }
-                    if !output.ends_with('\n') {
-                        output.push('\n');
-                    }
-                    output.push_str("```");
-                    Ok(output)
-                })
-            } else {
-                Task::ready(Err(anyhow!("no recent buffer found")))
-            }
-        });
-
-        SlashCommandInvocation {
-            output: output.unwrap_or_else(|error| Task::ready(Err(error))),
-            invalidated: invalidate_rx,
-            cleanup: SlashCommandCleanup::new(move || drop(subscriptions)),
-        }
-    }
-}

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

@@ -1,14 +1,15 @@
-use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use super::{SlashCommand, SlashCommandOutput};
 use anyhow::Result;
-use futures::channel::oneshot;
 use fuzzy::PathMatch;
-use gpui::{AppContext, Model, Task};
+use gpui::{AppContext, Model, RenderOnce, SharedString, Task, WeakView};
 use language::LspAdapterDelegate;
 use project::{PathMatchCandidateSet, Project};
 use std::{
-    path::Path,
+    path::{Path, PathBuf},
     sync::{atomic::AtomicBool, Arc},
 };
+use ui::{prelude::*, ButtonLike, ElevationIndex};
+use workspace::Workspace;
 
 pub(crate) struct FileSlashCommand {
     project: Model<Project>,
@@ -30,7 +31,6 @@ impl FileSlashCommand {
             .read(cx)
             .visible_worktrees(cx)
             .collect::<Vec<_>>();
-        let include_root_name = worktrees.len() > 1;
         let candidate_sets = worktrees
             .into_iter()
             .map(|worktree| {
@@ -40,7 +40,7 @@ impl FileSlashCommand {
                     include_ignored: worktree
                         .root_entry()
                         .map_or(false, |entry| entry.is_ignored),
-                    include_root_name,
+                    include_root_name: true,
                     directories_only: false,
                 }
             })
@@ -68,7 +68,11 @@ impl SlashCommand for FileSlashCommand {
     }
 
     fn description(&self) -> String {
-        "insert an entire file".into()
+        "insert a file".into()
+    }
+
+    fn tooltip_text(&self) -> String {
+        "insert file".into()
     }
 
     fn requires_argument(&self) -> bool {
@@ -100,36 +104,30 @@ impl SlashCommand for FileSlashCommand {
     fn run(
         self: Arc<Self>,
         argument: Option<&str>,
+        _workspace: WeakView<Workspace>,
         _delegate: Arc<dyn LspAdapterDelegate>,
-        cx: &mut AppContext,
-    ) -> SlashCommandInvocation {
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
         let project = self.project.read(cx);
         let Some(argument) = argument else {
-            return SlashCommandInvocation {
-                output: Task::ready(Err(anyhow::anyhow!("missing path"))),
-                invalidated: oneshot::channel().1,
-                cleanup: SlashCommandCleanup::default(),
-            };
+            return Task::ready(Err(anyhow::anyhow!("missing path")));
         };
 
-        let path = Path::new(argument);
+        let path = PathBuf::from(argument);
         let abs_path = project.worktrees().find_map(|worktree| {
             let worktree = worktree.read(cx);
-            worktree.entry_for_path(path)?;
-            worktree.absolutize(path).ok()
+            let worktree_root_path = Path::new(worktree.root_name());
+            let relative_path = path.strip_prefix(worktree_root_path).ok()?;
+            worktree.absolutize(&relative_path).ok()
         });
 
         let Some(abs_path) = abs_path else {
-            return SlashCommandInvocation {
-                output: Task::ready(Err(anyhow::anyhow!("missing path"))),
-                invalidated: oneshot::channel().1,
-                cleanup: SlashCommandCleanup::default(),
-            };
+            return Task::ready(Err(anyhow::anyhow!("missing path")));
         };
 
         let fs = project.fs().clone();
         let argument = argument.to_string();
-        let output = cx.background_executor().spawn(async move {
+        let text = cx.background_executor().spawn(async move {
             let content = fs.load(&abs_path).await?;
             let mut output = String::with_capacity(argument.len() + content.len() + 9);
             output.push_str("```");
@@ -140,12 +138,46 @@ impl SlashCommand for FileSlashCommand {
                 output.push('\n');
             }
             output.push_str("```");
-            Ok(output)
+            anyhow::Ok(output)
         });
-        SlashCommandInvocation {
-            output,
-            invalidated: oneshot::channel().1,
-            cleanup: SlashCommandCleanup::default(),
-        }
+        cx.foreground_executor().spawn(async move {
+            let text = text.await?;
+            Ok(SlashCommandOutput {
+                text,
+                render_placeholder: Arc::new(move |id, unfold, _cx| {
+                    FilePlaceholder {
+                        path: Some(path.clone()),
+                        id,
+                        unfold,
+                    }
+                    .into_any_element()
+                }),
+            })
+        })
+    }
+}
+
+#[derive(IntoElement)]
+pub struct FilePlaceholder {
+    pub path: Option<PathBuf>,
+    pub id: ElementId,
+    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
+}
+
+impl RenderOnce for FilePlaceholder {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        let unfold = self.unfold;
+        let title = if let Some(path) = self.path.as_ref() {
+            SharedString::from(path.to_string_lossy().to_string())
+        } else {
+            SharedString::from("untitled")
+        };
+
+        ButtonLike::new(self.id)
+            .style(ButtonStyle::Filled)
+            .layer(ElevationIndex::ElevatedSurface)
+            .child(Icon::new(IconName::File))
+            .child(Label::new(title))
+            .on_click(move |_, cx| unfold(cx))
     }
 }

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

@@ -0,0 +1,151 @@
+use super::{SlashCommand, SlashCommandOutput};
+use anyhow::{anyhow, Context, Result};
+use fs::Fs;
+use gpui::{AppContext, Model, Task, WeakView};
+use language::LspAdapterDelegate;
+use project::{Project, ProjectPath};
+use std::{
+    fmt::Write,
+    path::Path,
+    sync::{atomic::AtomicBool, Arc},
+};
+use ui::{prelude::*, ButtonLike, ElevationIndex};
+use workspace::Workspace;
+
+pub(crate) struct ProjectSlashCommand;
+
+impl ProjectSlashCommand {
+    async fn build_message(fs: Arc<dyn Fs>, path_to_cargo_toml: &Path) -> Result<String> {
+        let buffer = fs.load(path_to_cargo_toml).await?;
+        let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?;
+
+        let mut message = String::new();
+        writeln!(message, "You are in a Rust project.")?;
+
+        if let Some(workspace) = cargo_toml.workspace {
+            writeln!(
+                message,
+                "The project is a Cargo workspace with the following members:"
+            )?;
+            for member in workspace.members {
+                writeln!(message, "- {member}")?;
+            }
+
+            if !workspace.default_members.is_empty() {
+                writeln!(message, "The default members are:")?;
+                for member in workspace.default_members {
+                    writeln!(message, "- {member}")?;
+                }
+            }
+
+            if !workspace.dependencies.is_empty() {
+                writeln!(
+                    message,
+                    "The following workspace dependencies are installed:"
+                )?;
+                for dependency in workspace.dependencies.keys() {
+                    writeln!(message, "- {dependency}")?;
+                }
+            }
+        } else if let Some(package) = cargo_toml.package {
+            writeln!(
+                message,
+                "The project name is \"{name}\".",
+                name = package.name
+            )?;
+
+            let description = package
+                .description
+                .as_ref()
+                .and_then(|description| description.get().ok().cloned());
+            if let Some(description) = description.as_ref() {
+                writeln!(message, "It describes itself as \"{description}\".")?;
+            }
+
+            if !cargo_toml.dependencies.is_empty() {
+                writeln!(message, "The following dependencies are installed:")?;
+                for dependency in cargo_toml.dependencies.keys() {
+                    writeln!(message, "- {dependency}")?;
+                }
+            }
+        }
+
+        Ok(message)
+    }
+
+    fn path_to_cargo_toml(project: Model<Project>, cx: &mut AppContext) -> Option<Arc<Path>> {
+        let worktree = project.read(cx).worktrees().next()?;
+        let worktree = worktree.read(cx);
+        let entry = worktree.entry_for_path("Cargo.toml")?;
+        let path = ProjectPath {
+            worktree_id: worktree.id(),
+            path: entry.path.clone(),
+        };
+        Some(Arc::from(
+            project.read(cx).absolute_path(&path, cx)?.as_path(),
+        ))
+    }
+}
+
+impl SlashCommand for ProjectSlashCommand {
+    fn name(&self) -> String {
+        "project".into()
+    }
+
+    fn description(&self) -> String {
+        "insert current project context".into()
+    }
+
+    fn tooltip_text(&self) -> String {
+        "insert current project context".into()
+    }
+
+    fn complete_argument(
+        &self,
+        _query: String,
+        _cancel: Arc<AtomicBool>,
+        _cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Err(anyhow!("this command does not require argument")))
+    }
+
+    fn requires_argument(&self) -> bool {
+        false
+    }
+
+    fn run(
+        self: Arc<Self>,
+        _argument: Option<&str>,
+        workspace: WeakView<Workspace>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
+        let output = workspace.update(cx, |workspace, cx| {
+            let project = workspace.project().clone();
+            let fs = workspace.project().read(cx).fs().clone();
+            let path = Self::path_to_cargo_toml(project, cx);
+            let output = cx.background_executor().spawn(async move {
+                let path = path.with_context(|| "Cargo.toml not found")?;
+                Self::build_message(fs, &path).await
+            });
+
+            cx.foreground_executor().spawn(async move {
+                let text = output.await?;
+
+                Ok(SlashCommandOutput {
+                    text,
+                    render_placeholder: Arc::new(move |id, unfold, _cx| {
+                        ButtonLike::new(id)
+                            .style(ButtonStyle::Filled)
+                            .layer(ElevationIndex::ElevatedSurface)
+                            .child(Icon::new(IconName::FileTree))
+                            .child(Label::new("Project"))
+                            .on_click(move |_, cx| unfold(cx))
+                            .into_any_element()
+                    }),
+                })
+            })
+        });
+        output.unwrap_or_else(|error| Task::ready(Err(error)))
+    }
+}

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

@@ -1,11 +1,12 @@
-use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use super::{SlashCommand, SlashCommandOutput};
 use crate::prompts::PromptLibrary;
 use anyhow::{anyhow, Context, Result};
-use futures::channel::oneshot;
 use fuzzy::StringMatchCandidate;
-use gpui::{AppContext, Task};
+use gpui::{AppContext, Task, WeakView};
 use language::LspAdapterDelegate;
 use std::sync::{atomic::AtomicBool, Arc};
+use ui::{prelude::*, ButtonLike, ElevationIndex};
+use workspace::Workspace;
 
 pub(crate) struct PromptSlashCommand {
     library: Arc<PromptLibrary>,
@@ -26,6 +27,10 @@ impl SlashCommand for PromptSlashCommand {
         "insert a prompt from the library".into()
     }
 
+    fn tooltip_text(&self) -> String {
+        "insert prompt".into()
+    }
+
     fn requires_argument(&self) -> bool {
         true
     }
@@ -64,32 +69,43 @@ impl SlashCommand for PromptSlashCommand {
     fn run(
         self: Arc<Self>,
         title: Option<&str>,
+        _workspace: WeakView<Workspace>,
         _delegate: Arc<dyn LspAdapterDelegate>,
-        cx: &mut AppContext,
-    ) -> SlashCommandInvocation {
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
         let Some(title) = title else {
-            return SlashCommandInvocation {
-                output: Task::ready(Err(anyhow!("missing prompt name"))),
-                invalidated: oneshot::channel().1,
-                cleanup: SlashCommandCleanup::default(),
-            };
+            return Task::ready(Err(anyhow!("missing prompt name")));
         };
 
         let library = self.library.clone();
-        let title = title.to_string();
-        let output = cx.background_executor().spawn(async move {
-            let prompt = library
-                .prompts()
-                .into_iter()
-                .find(|prompt| &prompt.1.title().to_string() == &title)
-                .with_context(|| format!("no prompt found with title {:?}", title))?
-                .1;
-            Ok(prompt.body())
+        let title = SharedString::from(title.to_string());
+        let prompt = cx.background_executor().spawn({
+            let title = title.clone();
+            async move {
+                let prompt = library
+                    .prompts()
+                    .into_iter()
+                    .map(|prompt| (prompt.1.title(), prompt))
+                    .find(|(t, _)| t == &title)
+                    .with_context(|| format!("no prompt found with title {:?}", title))?
+                    .1;
+                anyhow::Ok(prompt.1.body())
+            }
         });
-        SlashCommandInvocation {
-            output,
-            invalidated: oneshot::channel().1,
-            cleanup: SlashCommandCleanup::default(),
-        }
+        cx.foreground_executor().spawn(async move {
+            let prompt = prompt.await?;
+            Ok(SlashCommandOutput {
+                text: prompt,
+                render_placeholder: Arc::new(move |id, unfold, _cx| {
+                    ButtonLike::new(id)
+                        .style(ButtonStyle::Filled)
+                        .layer(ElevationIndex::ElevatedSurface)
+                        .child(Icon::new(IconName::Library))
+                        .child(Label::new(title.clone()))
+                        .on_click(move |_, cx| unfold(cx))
+                        .into_any_element()
+                }),
+            })
+        })
     }
 }

crates/assistant_slash_command/Cargo.toml 🔗

@@ -15,7 +15,7 @@ path = "src/assistant_slash_command.rs"
 anyhow.workspace = true
 collections.workspace = true
 derive_more.workspace = true
-futures.workspace = true
 gpui.workspace = true
 language.workspace = true
 parking_lot.workspace = true
+workspace.workspace = true

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -1,14 +1,11 @@
 mod slash_command_registry;
 
-use std::sync::atomic::AtomicBool;
-use std::sync::Arc;
-
 use anyhow::Result;
-use futures::channel::oneshot;
-use gpui::{AppContext, Task};
+use gpui::{AnyElement, AppContext, ElementId, Task, WeakView, WindowContext};
 use language::LspAdapterDelegate;
-
 pub use slash_command_registry::*;
+use std::sync::{atomic::AtomicBool, Arc};
+use workspace::Workspace;
 
 pub fn init(cx: &mut AppContext) {
     SlashCommandRegistry::default_global(cx);
@@ -17,6 +14,7 @@ pub fn init(cx: &mut AppContext) {
 pub trait SlashCommand: 'static + Send + Sync {
     fn name(&self) -> String;
     fn description(&self) -> String;
+    fn tooltip_text(&self) -> String;
     fn complete_argument(
         &self,
         query: String,
@@ -27,35 +25,24 @@ pub trait SlashCommand: 'static + Send + Sync {
     fn run(
         self: Arc<Self>,
         argument: Option<&str>,
+        workspace: WeakView<Workspace>,
         // 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;
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>>;
 }
 
-pub struct SlashCommandInvocation {
-    pub output: Task<Result<String>>,
-    pub invalidated: oneshot::Receiver<()>,
-    pub cleanup: SlashCommandCleanup,
-}
-
-#[derive(Default)]
-pub struct SlashCommandCleanup(Option<Box<dyn FnOnce()>>);
-
-impl SlashCommandCleanup {
-    pub fn new(cleanup: impl FnOnce() + 'static) -> Self {
-        Self(Some(Box::new(cleanup)))
-    }
-}
+pub type RenderFoldPlaceholder = Arc<
+    dyn Send
+        + Sync
+        + Fn(ElementId, Arc<dyn Fn(&mut WindowContext)>, &mut WindowContext) -> AnyElement,
+>;
 
-impl Drop for SlashCommandCleanup {
-    fn drop(&mut self) {
-        if let Some(cleanup) = self.0.take() {
-            cleanup();
-        }
-    }
+pub struct SlashCommandOutput {
+    pub text: String,
+    pub render_placeholder: RenderFoldPlaceholder,
 }

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -305,6 +305,7 @@ impl MessageEditor {
                     documentation: None,
                     server_id: LanguageServerId(0), // TODO: Make this optional or something?
                     lsp_completion: Default::default(), // TODO: Make this optional or something?
+                    confirm: None,
                 }
             })
             .collect()

crates/editor/src/display_map/flap_map.rs 🔗

@@ -36,7 +36,13 @@ impl FlapSnapshot {
         while let Some(item) = cursor.item() {
             match Ord::cmp(&item.flap.range.start.to_point(snapshot).row, &row.0) {
                 Ordering::Less => cursor.next(snapshot),
-                Ordering::Equal => return Some(&item.flap),
+                Ordering::Equal => {
+                    if item.flap.range.start.is_valid(snapshot) {
+                        return Some(&item.flap);
+                    } else {
+                        cursor.next(snapshot);
+                    }
+                }
                 Ordering::Greater => break,
             }
         }

crates/editor/src/display_map/fold_map.rs 🔗

@@ -20,6 +20,8 @@ pub struct FoldPlaceholder {
     pub render: Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut WindowContext) -> AnyElement>,
     /// If true, the element is constrained to the shaped width of an ellipsis.
     pub constrain_width: bool,
+    /// If true, merges the fold with an adjacent one.
+    pub merge_adjacent: bool,
 }
 
 impl FoldPlaceholder {
@@ -30,6 +32,7 @@ impl FoldPlaceholder {
         Self {
             render: Arc::new(|_id, _range, _cx| gpui::Empty.into_any_element()),
             constrain_width: true,
+            merge_adjacent: true,
         }
     }
 }
@@ -374,8 +377,11 @@ impl FoldMap {
 
                     assert!(fold_range.start.0 >= sum.input.len);
 
-                    while folds.peek().map_or(false, |(_, next_fold_range)| {
-                        next_fold_range.start <= fold_range.end
+                    while folds.peek().map_or(false, |(next_fold, next_fold_range)| {
+                        next_fold_range.start < fold_range.end
+                            || (next_fold_range.start == fold_range.end
+                                && fold.placeholder.merge_adjacent
+                                && next_fold.placeholder.merge_adjacent)
                     }) {
                         let (_, next_fold_range) = folds.next().unwrap();
                         if next_fold_range.end > fold_range.end {

crates/editor/src/editor.rs 🔗

@@ -1628,6 +1628,7 @@ impl Editor {
                     })
                     .into_any()
             }),
+            merge_adjacent: true,
         };
         let display_map = cx.new_model(|cx| {
             let file_header_size = if show_excerpt_controls { 3 } else { 2 };
@@ -3905,6 +3906,7 @@ impl Editor {
 
         let snippet;
         let text;
+
         if completion.is_snippet() {
             snippet = Some(Snippet::parse(&completion.new_text).log_err()?);
             text = snippet.as_ref().unwrap().text.clone();
@@ -3998,6 +4000,10 @@ impl Editor {
             this.refresh_inline_completion(true, cx);
         });
 
+        if let Some(confirm) = completion.confirm.as_ref() {
+            (confirm)(cx);
+        }
+
         let provider = self.completion_provider.as_ref()?;
         let apply_edits = provider.apply_additional_edits_for_completion(
             buffer_handle,

crates/editor/src/element.rs 🔗

@@ -3908,7 +3908,7 @@ enum LineFragment {
     Text(ShapedLine),
     Element {
         element: Option<AnyElement>,
-        width: Pixels,
+        size: Size<Pixels>,
         len: usize,
     },
 }
@@ -3917,9 +3917,9 @@ impl fmt::Debug for LineFragment {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         match self {
             LineFragment::Text(shaped_line) => f.debug_tuple("Text").field(shaped_line).finish(),
-            LineFragment::Element { width, len, .. } => f
+            LineFragment::Element { size, len, .. } => f
                 .debug_struct("Element")
-                .field("width", width)
+                .field("size", size)
                 .field("len", len)
                 .finish(),
         }
@@ -3999,7 +3999,7 @@ impl LineWithInvisibles {
                 len += highlighted_chunk.text.len();
                 fragments.push(LineFragment::Element {
                     element: Some(element),
-                    width: size.width,
+                    size,
                     len: highlighted_chunk.text.len(),
                 });
             } else {
@@ -4112,13 +4112,18 @@ impl LineWithInvisibles {
                 LineFragment::Text(line) => {
                     fragment_origin.x += line.width;
                 }
-                LineFragment::Element { element, width, .. } => {
+                LineFragment::Element { element, size, .. } => {
                     let mut element = element
                         .take()
                         .expect("you can't prepaint LineWithInvisibles twice");
-                    element.prepaint_at(fragment_origin, cx);
+
+                    // Center the element vertically within the line.
+                    let mut element_origin = fragment_origin;
+                    element_origin.y += (line_height - size.height) / 2.;
+                    element.prepaint_at(element_origin, cx);
                     line_elements.push(element);
-                    fragment_origin.x += *width;
+
+                    fragment_origin.x += size.width;
                 }
             }
         }
@@ -4146,8 +4151,8 @@ impl LineWithInvisibles {
                     line.paint(fragment_origin, line_height, cx).log_err();
                     fragment_origin.x += line.width;
                 }
-                LineFragment::Element { width, .. } => {
-                    fragment_origin.x += *width;
+                LineFragment::Element { size, .. } => {
+                    fragment_origin.x += size.width;
                 }
             }
         }
@@ -4225,12 +4230,12 @@ impl LineWithInvisibles {
                     fragment_start_x += shaped_line.width;
                     fragment_start_index = fragment_end_index;
                 }
-                LineFragment::Element { len, width, .. } => {
+                LineFragment::Element { len, size, .. } => {
                     let fragment_end_index = fragment_start_index + len;
                     if index < fragment_end_index {
                         return fragment_start_x;
                     }
-                    fragment_start_x += *width;
+                    fragment_start_x += size.width;
                     fragment_start_index = fragment_end_index;
                 }
             }
@@ -4255,8 +4260,8 @@ impl LineWithInvisibles {
                     fragment_start_x = fragment_end_x;
                     fragment_start_index += shaped_line.len;
                 }
-                LineFragment::Element { len, width, .. } => {
-                    let fragment_end_x = fragment_start_x + *width;
+                LineFragment::Element { len, size, .. } => {
+                    let fragment_end_x = fragment_start_x + size.width;
                     if x < fragment_end_x {
                         return Some(fragment_start_index);
                     }

crates/extension/Cargo.toml 🔗

@@ -46,6 +46,7 @@ wasmtime.workspace = true
 wasmtime-wasi.workspace = true
 wasmparser.workspace = true
 wit-component.workspace = true
+workspace.workspace = true
 task.workspace = true
 serde_json_lenient.workspace = true
 
@@ -58,3 +59,4 @@ fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/extension/src/extension_manifest.rs 🔗

@@ -133,6 +133,7 @@ impl LanguageServerManifestEntry {
 #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub struct SlashCommandManifestEntry {
     pub description: String,
+    pub tooltip_text: String,
     pub requires_argument: bool,
 }
 

crates/extension/src/extension_slash_command.rs 🔗

@@ -1,15 +1,12 @@
-use std::sync::atomic::AtomicBool;
-use std::sync::Arc;
-
+use crate::wasm_host::{WasmExtension, WasmHost};
 use anyhow::{anyhow, Result};
-use assistant_slash_command::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
-use futures::channel::oneshot;
+use assistant_slash_command::{SlashCommand, SlashCommandOutput};
 use futures::FutureExt;
-use gpui::{AppContext, Task};
+use gpui::{AppContext, IntoElement, Task, WeakView, WindowContext};
 use language::LspAdapterDelegate;
+use std::sync::{atomic::AtomicBool, Arc};
 use wasmtime_wasi::WasiView;
-
-use crate::wasm_host::{WasmExtension, WasmHost};
+use workspace::Workspace;
 
 pub struct ExtensionSlashCommand {
     pub(crate) extension: WasmExtension,
@@ -27,6 +24,10 @@ impl SlashCommand for ExtensionSlashCommand {
         self.command.description.clone()
     }
 
+    fn tooltip_text(&self) -> String {
+        self.command.tooltip_text.clone()
+    }
+
     fn requires_argument(&self) -> bool {
         self.command.requires_argument
     }
@@ -43,11 +44,11 @@ impl SlashCommand for ExtensionSlashCommand {
     fn run(
         self: Arc<Self>,
         argument: Option<&str>,
+        _workspace: WeakView<Workspace>,
         delegate: Arc<dyn LspAdapterDelegate>,
-        cx: &mut AppContext,
-    ) -> SlashCommandInvocation {
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
         let argument = argument.map(|arg| arg.to_string());
-
         let output = cx.background_executor().spawn(async move {
             let output = self
                 .extension
@@ -72,14 +73,16 @@ impl SlashCommand for ExtensionSlashCommand {
                     }
                 })
                 .await?;
-
             output.ok_or_else(|| anyhow!("no output from command: {}", self.command.name))
         });
-
-        SlashCommandInvocation {
-            output,
-            invalidated: oneshot::channel().1,
-            cleanup: SlashCommandCleanup::default(),
-        }
+        cx.foreground_executor().spawn(async move {
+            let output = output.await?;
+            Ok(SlashCommandOutput {
+                text: output,
+                render_placeholder: Arc::new(|_, _, _| {
+                    "TODO: Extension command output".into_any_element()
+                }),
+            })
+        })
     }
 }

crates/extension/src/extension_store.rs 🔗

@@ -1183,6 +1183,7 @@ impl ExtensionStore {
                                 command: crate::wit::SlashCommand {
                                     name: slash_command_name.to_string(),
                                     description: slash_command.description.to_string(),
+                                    tooltip_text: slash_command.tooltip_text.to_string(),
                                     requires_argument: slash_command.requires_argument,
                                 },
                                 extension: wasm_extension.clone(),

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

@@ -5,6 +5,8 @@ interface slash-command {
         name: string,
         /// The description of the slash command.
         description: string,
+        /// The tooltip text to display for the run button.
+        tooltip-text: string,
         /// Whether this slash command requires an argument.
         requires-argument: bool,
     }

crates/gpui/src/elements/div.rs 🔗

@@ -291,6 +291,8 @@ impl Interactivity {
                 let action = action.downcast_ref().unwrap();
                 if phase == DispatchPhase::Capture {
                     (listener)(action, cx)
+                } else {
+                    cx.propagate();
                 }
             }),
         ));

crates/project/src/project.rs 🔗

@@ -36,7 +36,7 @@ use git::{blame::Blame, repository::GitRepository};
 use globset::{Glob, GlobSet, GlobSetBuilder};
 use gpui::{
     AnyModel, AppContext, AsyncAppContext, BackgroundExecutor, BorrowAppContext, Context, Entity,
-    EventEmitter, Model, ModelContext, PromptLevel, SharedString, Task, WeakModel,
+    EventEmitter, Model, ModelContext, PromptLevel, SharedString, Task, WeakModel, WindowContext,
 };
 use itertools::Itertools;
 use language::{
@@ -407,7 +407,7 @@ pub struct InlayHint {
 }
 
 /// A completion provided by a language server
-#[derive(Clone, Debug)]
+#[derive(Clone)]
 pub struct Completion {
     /// The range of the buffer that will be replaced.
     pub old_range: Range<Anchor>,
@@ -421,6 +421,21 @@ pub struct Completion {
     pub documentation: Option<Documentation>,
     /// The raw completion provided by the language server.
     pub lsp_completion: lsp::CompletionItem,
+    /// An optional callback to invoke when this completion is confirmed.
+    pub confirm: Option<Arc<dyn Send + Sync + Fn(&mut WindowContext)>>,
+}
+
+impl std::fmt::Debug for Completion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Completion")
+            .field("old_range", &self.old_range)
+            .field("new_text", &self.new_text)
+            .field("label", &self.label)
+            .field("server_id", &self.server_id)
+            .field("documentation", &self.documentation)
+            .field("lsp_completion", &self.lsp_completion)
+            .finish()
+    }
 }
 
 /// A completion provided by a language server
@@ -2029,6 +2044,30 @@ impl Project {
         })
     }
 
+    pub fn open_buffer_for_full_path(
+        &mut self,
+        path: &Path,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Model<Buffer>>> {
+        if let Some(worktree_name) = path.components().next() {
+            let worktree = self.worktrees().find(|worktree| {
+                OsStr::new(worktree.read(cx).root_name()) == worktree_name.as_os_str()
+            });
+            if let Some(worktree) = worktree {
+                let worktree = worktree.read(cx);
+                let worktree_root_path = Path::new(worktree.root_name());
+                if let Ok(path) = path.strip_prefix(worktree_root_path) {
+                    let project_path = ProjectPath {
+                        worktree_id: worktree.id(),
+                        path: path.into(),
+                    };
+                    return self.open_buffer(project_path, cx);
+                }
+            }
+        }
+        Task::ready(Err(anyhow!("buffer not found for {:?}", path)))
+    }
+
     pub fn open_local_buffer(
         &mut self,
         abs_path: impl AsRef<Path>,
@@ -9212,6 +9251,7 @@ impl Project {
                         runs: Default::default(),
                         filter_range: Default::default(),
                     },
+                    confirm: None,
                 },
                 false,
                 cx,
@@ -10883,6 +10923,7 @@ async fn populate_labels_for_completions(
             server_id: completion.server_id,
             documentation,
             lsp_completion,
+            confirm: None,
         })
     }
 }

crates/ui/src/components/icon.rs 🔗

@@ -184,6 +184,7 @@ pub enum IconName {
     Tab,
     Terminal,
     Trash,
+    TriangleRight,
     Update,
     WholeWord,
     XCircle,
@@ -303,6 +304,7 @@ impl IconName {
             IconName::Tab => "icons/tab.svg",
             IconName::Terminal => "icons/terminal.svg",
             IconName::Trash => "icons/trash.svg",
+            IconName::TriangleRight => "icons/triangle_right.svg",
             IconName::Update => "icons/update.svg",
             IconName::WholeWord => "icons/word_search.svg",
             IconName::XCircle => "icons/error.svg",

extensions/gleam/extension.toml 🔗

@@ -17,3 +17,4 @@ commit = "8432ffe32ccd360534837256747beb5b1c82fca1"
 [slash_commands.gleam-project]
 description = "Returns information about the current Gleam project."
 requires_argument = false
+tooltip_text = "Insert Gleam project data"