Add slash commands for adding context into the assistant (#12102)

Max Brunsfeld , Marshall , and Antonio Scandurra created

Tasks

* [x] remove old flaps and output when editing a slash command
* [x] the completing a command name that takes args, insert a space to
prepare for typing an arg
* [x] always trigger completions when  typing in a slash command
* [x] don't show line numbers
* [x] implement `prompt` command
* [x] `current-file` command
* [x] state gets corrupted on `duplicate line up` on a slash command
* [x] exclude slash command source from completion request

Next steps:
* show output token count in flap trailer
* add `/project` command that matches project ambient context
* delete ambient context

Release Notes:

- N/A

---------

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>

Change summary

Cargo.lock                                                 |   1 
crates/assistant/Cargo.toml                                |   1 
crates/assistant/src/ambient_context/current_project.rs    |  10 
crates/assistant/src/ambient_context/recent_buffers.rs     |  10 
crates/assistant/src/assistant.rs                          |   2 
crates/assistant/src/assistant_panel.rs                    | 737 +++++++
crates/assistant/src/completion_provider.rs                |   2 
crates/assistant/src/prompt_library.rs                     |   4 
crates/assistant/src/slash_command.rs                      | 319 +++
crates/assistant/src/slash_command/current_file_command.rs | 135 +
crates/assistant/src/slash_command/file_command.rs         | 145 +
crates/assistant/src/slash_command/prompt_command.rs       |  88 
crates/collab_ui/src/chat_panel/message_editor.rs          |  11 
crates/editor/src/editor.rs                                | 151 +
crates/editor/src/element.rs                               | 102 
crates/gpui/src/window.rs                                  |   3 
crates/multi_buffer/src/multi_buffer.rs                    |  41 
crates/text/src/text.rs                                    |  25 
18 files changed, 1,641 insertions(+), 146 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -348,6 +348,7 @@ dependencies = [
  "file_icons",
  "fs",
  "futures 0.3.28",
+ "fuzzy",
  "gpui",
  "http 0.1.0",
  "indoc",

crates/assistant/Cargo.toml ๐Ÿ”—

@@ -21,6 +21,7 @@ editor.workspace = true
 file_icons.workspace = true
 fs.workspace = true
 futures.workspace = true
+fuzzy.workspace = true
 gpui.workspace = true
 http.workspace = true
 indoc.workspace = true

crates/assistant/src/ambient_context/current_project.rs ๐Ÿ”—

@@ -34,10 +34,12 @@ impl Default for CurrentProjectContext {
 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(),
-        })
+        self.enabled
+            .then(|| LanguageModelRequestMessage {
+                role: Role::System,
+                content: self.message.clone(),
+            })
+            .filter(|message| !message.content.is_empty())
     }
 
     /// Updates the [`CurrentProjectContext`] for the given [`Project`].

crates/assistant/src/ambient_context/recent_buffers.rs ๐Ÿ”—

@@ -87,10 +87,12 @@ impl RecentBuffersContext {
 
     /// 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(),
-        })
+        self.enabled
+            .then(|| LanguageModelRequestMessage {
+                role: Role::System,
+                content: self.snapshot.message.to_string(),
+            })
+            .filter(|message| !message.content.is_empty())
     }
 }
 

crates/assistant/src/assistant.rs ๐Ÿ”—

@@ -7,6 +7,7 @@ mod prompt_library;
 mod prompts;
 mod saved_conversation;
 mod search;
+mod slash_command;
 mod streaming_diff;
 
 use ambient_context::AmbientContextSnapshot;
@@ -16,6 +17,7 @@ use client::{proto, Client};
 use command_palette_hooks::CommandPaletteFilter;
 pub(crate) use completion_provider::*;
 use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
+pub(crate) use prompt_library::*;
 pub(crate) use saved_conversation::*;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};

crates/assistant/src/assistant_panel.rs ๐Ÿ”—

@@ -5,6 +5,9 @@ use crate::{
     prompt_library::{PromptLibrary, PromptManager},
     prompts::generate_content_prompt,
     search::*,
+    slash_command::{
+        SlashCommandCleanup, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry,
+    },
     ApplyEdit, Assist, CompletionProvider, CycleMessageRole, InlineAssist, InsertActivePrompt,
     LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata,
     MessageStatus, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
@@ -14,9 +17,10 @@ use anyhow::{anyhow, Result};
 use client::telemetry::Telemetry;
 use collections::{hash_map, HashMap, HashSet, VecDeque};
 use editor::{
-    actions::{MoveDown, MoveUp},
+    actions::{FoldAt, MoveDown, MoveUp},
     display_map::{
-        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
+        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, FlapId,
+        ToDisplayPoint,
     },
     scroll::{Autoscroll, AutoscrollStrategy},
     Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt,
@@ -26,16 +30,17 @@ use file_icons::FileIcons;
 use fs::Fs;
 use futures::StreamExt;
 use gpui::{
-    canvas, div, point, relative, rems, uniform_list, Action, AnyView, AppContext, AsyncAppContext,
-    AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity, 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,
+    canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
+    AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity,
+    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::{
     language_settings::SoftWrap, AutoindentMode, Buffer, BufferSnapshot, LanguageRegistry,
-    OffsetRangeExt as _, Point, ToOffset as _,
+    OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _,
 };
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
@@ -45,7 +50,7 @@ use settings::Settings;
 use std::{
     cmp::{self, Ordering},
     fmt::Write,
-    iter,
+    iter, mem,
     ops::Range,
     path::PathBuf,
     sync::Arc,
@@ -64,6 +69,7 @@ use 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(
@@ -98,6 +104,7 @@ pub struct AssistantPanel {
     focus_handle: FocusHandle,
     toolbar: View<Toolbar>,
     languages: Arc<LanguageRegistry>,
+    slash_commands: Arc<SlashCommandRegistry>,
     prompt_library: Arc<PromptLibrary>,
     fs: Arc<dyn Fs>,
     telemetry: Arc<Telemetry>,
@@ -190,6 +197,12 @@ impl AssistantPanel {
                     })
                     .detach();
 
+                    let slash_command_registry = SlashCommandRegistry::new(
+                        workspace.project().clone(),
+                        prompt_library.clone(),
+                        cx.window_handle().downcast::<Workspace>(),
+                    );
+
                     Self {
                         workspace: workspace_handle,
                         active_conversation_editor: None,
@@ -200,6 +213,7 @@ impl AssistantPanel {
                         focus_handle,
                         toolbar,
                         languages: workspace.app_state().languages.clone(),
+                        slash_commands: slash_command_registry,
                         prompt_library,
                         fs: workspace.app_state().fs.clone(),
                         telemetry: workspace.client().telemetry().clone(),
@@ -785,6 +799,7 @@ impl AssistantPanel {
             ConversationEditor::new(
                 self.model.clone(),
                 self.languages.clone(),
+                self.slash_commands.clone(),
                 self.fs.clone(),
                 workspace,
                 cx,
@@ -1083,6 +1098,7 @@ impl AssistantPanel {
 
         let fs = self.fs.clone();
         let workspace = self.workspace.clone();
+        let slash_commands = self.slash_commands.clone();
         let languages = self.languages.clone();
         let telemetry = self.telemetry.clone();
         cx.spawn(|this, mut cx| async move {
@@ -1093,6 +1109,7 @@ impl AssistantPanel {
                 model,
                 path.clone(),
                 languages,
+                slash_commands,
                 Some(telemetry),
                 &mut cx,
             )
@@ -1380,11 +1397,15 @@ impl FocusableView for AssistantPanel {
     }
 }
 
+#[derive(Clone)]
 enum ConversationEvent {
     MessagesEdited,
     SummaryChanged,
     EditSuggestionsChanged,
     StreamedCompletion,
+    SlashCommandsChanged,
+    SlashCommandOutputAdded(Range<language::Anchor>),
+    SlashCommandOutputRemoved(Range<language::Anchor>),
 }
 
 #[derive(Default)]
@@ -1398,6 +1419,7 @@ pub struct Conversation {
     buffer: Model<Buffer>,
     pub(crate) ambient_context: AmbientContext,
     edit_suggestions: Vec<EditSuggestion>,
+    slash_command_calls: Vec<SlashCommandCall>,
     message_anchors: Vec<MessageAnchor>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     next_message_id: MessageId,
@@ -1409,10 +1431,12 @@ 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>,
     _subscriptions: Vec<Subscription>,
     telemetry: Option<Arc<Telemetry>>,
+    slash_command_registry: Arc<SlashCommandRegistry>,
     language_registry: Arc<LanguageRegistry>,
 }
 
@@ -1422,6 +1446,7 @@ impl Conversation {
     fn new(
         model: LanguageModel,
         language_registry: Arc<LanguageRegistry>,
+        slash_command_registry: Arc<SlashCommandRegistry>,
         telemetry: Option<Arc<Telemetry>>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
@@ -1438,6 +1463,7 @@ impl Conversation {
             next_message_id: Default::default(),
             ambient_context: AmbientContext::default(),
             edit_suggestions: Vec::new(),
+            slash_command_calls: Vec::new(),
             summary: None,
             pending_summary: Task::ready(None),
             completion_count: Default::default(),
@@ -1445,12 +1471,14 @@ impl Conversation {
             token_count: None,
             pending_token_count: Task::ready(None),
             pending_edit_suggestion_parse: None,
+            pending_command_invocation_parse: None,
             model,
             _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
             pending_save: Task::ready(Ok(())),
             path: None,
             buffer,
             telemetry,
+            slash_command_registry,
             language_registry,
         };
 
@@ -1500,6 +1528,7 @@ impl Conversation {
         model: LanguageModel,
         path: PathBuf,
         language_registry: Arc<LanguageRegistry>,
+        slash_command_registry: Arc<SlashCommandRegistry>,
         telemetry: Option<Arc<Telemetry>>,
         cx: &mut AsyncAppContext,
     ) -> Result<Model<Self>> {
@@ -1540,6 +1569,7 @@ impl Conversation {
                 next_message_id,
                 ambient_context: AmbientContext::default(),
                 edit_suggestions: Vec::new(),
+                slash_command_calls: Vec::new(),
                 summary: Some(Summary {
                     text: saved_conversation.summary,
                     done: true,
@@ -1549,6 +1579,7 @@ 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),
                 model,
                 _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
@@ -1557,6 +1588,7 @@ impl Conversation {
                 buffer,
                 telemetry,
                 language_registry,
+                slash_command_registry,
             };
             this.set_language(cx);
             this.reparse_edit_suggestions(cx);
@@ -1640,6 +1672,7 @@ impl Conversation {
         if *event == language::Event::Edited {
             self.count_remaining_tokens(cx);
             self.reparse_edit_suggestions(cx);
+            self.reparse_slash_command_calls(cx);
             cx.emit(ConversationEvent::MessagesEdited);
         }
     }
@@ -1725,6 +1758,220 @@ 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) = this.slash_command_registry.command(name) {
+                            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, 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);
+                }
+
+                this.slash_command_calls = new_calls;
+            })
+            .ok();
+        }));
+    }
+
+    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;
+        }
+
+        if old_call.argument.as_deref() != new_call.argument.clone().map(|range| &new_text[range]) {
+            return false;
+        }
+
+        if old_call.should_rerun {
+            return false;
+        }
+
+        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;
+                }
+            }
+        }
+        true
+    }
+
+    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,
+            ))
+        }
+    }
+
     fn remaining_tokens(&self) -> Option<isize> {
         Some(self.model.max_token_count() as isize - self.token_count? as isize)
     }
@@ -2183,6 +2430,17 @@ 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() {
@@ -2202,6 +2460,16 @@ impl Conversation {
                 let message_end = message_end
                     .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,
@@ -2209,6 +2477,7 @@ impl Conversation {
                     anchor: message_anchor.start,
                     role: metadata.role,
                     status: metadata.status.clone(),
+                    slash_command_ranges,
                     ambient_context: metadata.ambient_context.clone(),
                 });
             }
@@ -2367,6 +2636,16 @@ fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option<ParsedEditSugge
     }
 }
 
+struct SlashCommandCall {
+    source_range: Range<language::Anchor>,
+    output_range: Option<Range<language::Anchor>>,
+    name: String,
+    argument: Option<String>,
+    should_rerun: bool,
+    _invalidate: Task<()>,
+    _command_cleanup: SlashCommandCleanup,
+}
+
 struct PendingCompletion {
     id: usize,
     _task: Task<()>,
@@ -2387,6 +2666,7 @@ struct ConversationEditor {
     fs: Arc<dyn Fs>,
     workspace: WeakView<Workspace>,
     editor: View<Editor>,
+    flap_ids: HashMap<Range<language::Anchor>, FlapId>,
     blocks: HashSet<BlockId>,
     scroll_position: Option<ScrollPosition>,
     _subscriptions: Vec<Subscription>,
@@ -2396,13 +2676,21 @@ impl ConversationEditor {
     fn new(
         model: LanguageModel,
         language_registry: Arc<LanguageRegistry>,
+        slash_command_registry: Arc<SlashCommandRegistry>,
         fs: Arc<dyn Fs>,
         workspace: View<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let telemetry = workspace.read(cx).client().telemetry().clone();
-        let conversation =
-            cx.new_model(|cx| Conversation::new(model, language_registry, Some(telemetry), cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(
+                model,
+                language_registry,
+                slash_command_registry,
+                Some(telemetry),
+                cx,
+            )
+        });
         Self::for_conversation(conversation, fs, workspace, cx)
     }
 
@@ -2412,11 +2700,17 @@ impl ConversationEditor {
         workspace: View<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
+        let command_registry = conversation.read(cx).slash_command_registry.clone();
+        let completion_provider = SlashCommandCompletionProvider::new(command_registry);
+
         let editor = cx.new_view(|cx| {
             let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx);
             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
-            editor.set_show_gutter(false, cx);
+            editor.set_show_line_numbers(false, cx);
+            editor.set_show_git_diff_gutter(false, cx);
+            editor.set_show_code_actions(false, cx);
             editor.set_show_wrap_guides(false, cx);
+            editor.set_completion_provider(Box::new(completion_provider));
             editor
         });
 
@@ -2432,6 +2726,7 @@ impl ConversationEditor {
             editor,
             blocks: Default::default(),
             scroll_position: None,
+            flap_ids: Default::default(),
             fs,
             workspace: workspace.downgrade(),
             _subscriptions,
@@ -2570,6 +2865,68 @@ impl ConversationEditor {
                     }
                 });
             }
+            ConversationEvent::SlashCommandsChanged => {
+                self.editor.update(cx, |editor, cx| {
+                    let buffer = editor.buffer().read(cx).snapshot(cx);
+                    let excerpt_id = *buffer.as_singleton().unwrap().0;
+                    let conversation = self.conversation.read(cx);
+                    let colors = cx.theme().colors();
+                    let highlighted_rows = conversation
+                        .slash_command_calls
+                        .iter()
+                        .map(|call| {
+                            let start = call.source_range.start;
+                            let end = if let Some(output) = &call.output_range {
+                                output.end
+                            } else {
+                                call.source_range.end
+                            };
+                            let start = buffer.anchor_in_excerpt(excerpt_id, start).unwrap();
+                            let end = buffer.anchor_in_excerpt(excerpt_id, end).unwrap();
+                            (
+                                start..=end,
+                                Some(colors.editor_document_highlight_read_background),
+                            )
+                        })
+                        .collect::<Vec<_>>();
+
+                    editor.clear_row_highlights::<SlashCommandCall>();
+                    for (range, color) in highlighted_rows {
+                        editor.highlight_rows::<SlashCommandCall>(range, color, false, cx);
+                    }
+                });
+            }
+            ConversationEvent::SlashCommandOutputAdded(range) => {
+                self.editor.update(cx, |editor, cx| {
+                    let buffer = editor.buffer().read(cx).snapshot(cx);
+                    let excerpt_id = *buffer.as_singleton().unwrap().0;
+                    let start = buffer.anchor_in_excerpt(excerpt_id, range.start).unwrap();
+                    let end = buffer.anchor_in_excerpt(excerpt_id, range.end).unwrap();
+                    let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
+
+                    let flap_id = editor
+                        .insert_flaps(
+                            [Flap::new(
+                                start..end,
+                                render_slash_command_output_toggle,
+                                render_slash_command_output_trailer,
+                            )],
+                            cx,
+                        )
+                        .into_iter()
+                        .next()
+                        .unwrap();
+                    self.flap_ids.insert(range.clone(), flap_id);
+                    editor.fold_at(&FoldAt { buffer_row }, cx);
+                });
+            }
+            ConversationEvent::SlashCommandOutputRemoved(range) => {
+                if let Some(flap_id) = self.flap_ids.remove(range) {
+                    self.editor.update(cx, |editor, cx| {
+                        editor.remove_flaps([flap_id], cx);
+                    });
+                }
+            }
         }
     }
 
@@ -2732,6 +3089,7 @@ impl ConversationEditor {
 
                             h_flex()
                                 .id(("message_header", message_id.0))
+                                .pl(cx.gutter_dimensions.width)
                                 .h_11()
                                 .w_full()
                                 .relative()
@@ -3157,7 +3515,6 @@ impl Render for ConversationEditor {
             .child(
                 div()
                     .flex_grow()
-                    .pl_4()
                     .bg(cx.theme().colors().editor_background)
                     .child(self.editor.clone()),
             )
@@ -3184,14 +3541,41 @@ pub struct Message {
     anchor: language::Anchor,
     role: Role,
     status: MessageStatus,
+    slash_command_ranges: Vec<Range<usize>>,
     ambient_context: AmbientContextSnapshot,
 }
 
 impl Message {
     fn to_request_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage {
-        let content = buffer
-            .text_for_range(self.offset_range.clone())
-            .collect::<String>();
+        let mut slash_command_ranges = self.slash_command_ranges.iter().peekable();
+        let mut content = String::with_capacity(self.offset_range.len());
+        let mut offset = self.offset_range.start;
+        let mut chunks = buffer.text_for_range(self.offset_range.clone());
+        while let Some(chunk) = chunks.next() {
+            if let Some(slash_command_range) = slash_command_ranges.peek() {
+                match offset.cmp(&slash_command_range.start) {
+                    Ordering::Less => {
+                        let max_len = slash_command_range.start - offset;
+                        if chunk.len() < max_len {
+                            content.push_str(chunk);
+                            offset += chunk.len();
+                        } else {
+                            content.push_str(&chunk[..max_len]);
+                            offset += max_len;
+                            chunks.seek(slash_command_range.end);
+                            slash_command_ranges.next();
+                        }
+                    }
+                    Ordering::Equal | Ordering::Greater => {
+                        chunks.seek(slash_command_range.end);
+                        offset = slash_command_range.end;
+                        slash_command_ranges.next();
+                    }
+                }
+            } else {
+                content.push_str(chunk);
+            }
+        }
         LanguageModelRequestMessage {
             role: self.role,
             content: content.trim_end().into(),
@@ -3470,6 +3854,35 @@ struct PendingInlineAssist {
     project: WeakModel<Project>,
 }
 
+type ToggleFold = Arc<dyn Fn(bool, &mut WindowContext) + Send + Sync>;
+
+fn render_slash_command_output_toggle(
+    row: MultiBufferRow,
+    is_folded: bool,
+    fold: ToggleFold,
+    _cx: &mut WindowContext,
+) -> AnyElement {
+    IconButton::new(
+        ("slash-command-output-fold-indicator", row.0),
+        ui::IconName::ChevronDown,
+    )
+    .on_click(move |_e, cx| fold(!is_folded, cx))
+    .icon_color(ui::Color::Muted)
+    .icon_size(ui::IconSize::Small)
+    .selected(is_folded)
+    .selected_icon(ui::IconName::ChevronRight)
+    .size(ui::ButtonSize::None)
+    .into_any_element()
+}
+
+fn render_slash_command_output_trailer(
+    _row: MultiBufferRow,
+    _is_folded: bool,
+    _cx: &mut WindowContext,
+) -> AnyElement {
+    div().into_any_element()
+}
+
 fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
     ranges.sort_unstable_by(|a, b| {
         a.start
@@ -3494,14 +3907,17 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
 
 #[cfg(test)]
 mod tests {
-    use std::path::Path;
+    use std::{cell::RefCell, path::Path, rc::Rc};
 
     use super::*;
     use crate::{FakeCompletionProvider, MessageId};
+    use fs::FakeFs;
     use gpui::{AppContext, TestAppContext};
     use rope::Rope;
+    use serde_json::json;
     use settings::SettingsStore;
     use unindent::Unindent;
+    use util::test::marked_text_ranges;
 
     #[gpui::test]
     fn test_inserting_and_removing_messages(cx: &mut AppContext) {
@@ -3511,8 +3927,15 @@ mod tests {
         init(cx);
         let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
 
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(
+                LanguageModel::default(),
+                registry,
+                Default::default(),
+                None,
+                cx,
+            )
+        });
         let buffer = conversation.read(cx).buffer.clone();
 
         let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3643,8 +4066,15 @@ mod tests {
         init(cx);
         let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
 
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(
+                LanguageModel::default(),
+                registry,
+                Default::default(),
+                None,
+                cx,
+            )
+        });
         let buffer = conversation.read(cx).buffer.clone();
 
         let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3742,8 +4172,15 @@ mod tests {
         cx.set_global(settings_store);
         init(cx);
         let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(
+                LanguageModel::default(),
+                registry,
+                Default::default(),
+                None,
+                cx,
+            )
+        });
         let buffer = conversation.read(cx).buffer.clone();
 
         let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3820,6 +4257,258 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_slash_commands(cx: &mut TestAppContext) {
+        let settings_store = cx.update(SettingsStore::test);
+        cx.set_global(settings_store);
+        cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default()));
+        cx.update(Project::init_settings);
+        cx.update(init);
+        let fs = FakeFs::new(cx.background_executor.clone());
+
+        fs.insert_tree(
+            "/test",
+            json!({
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }",
+                    "main.rs": "
+                        use crate::one;
+                        fn main() { one(); }
+                    ".unindent(),
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+        let prompt_library = Arc::new(PromptLibrary::default());
+        let slash_command_registry =
+            SlashCommandRegistry::new(project.clone(), prompt_library, None);
+
+        let registry = Arc::new(LanguageRegistry::test(cx.executor()));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(
+                LanguageModel::default(),
+                registry.clone(),
+                slash_command_registry,
+                None,
+                cx,
+            )
+        });
+
+        let output_ranges = Rc::new(RefCell::new(HashSet::default()));
+        conversation.update(cx, |_, cx| {
+            cx.subscribe(&conversation, {
+                let ranges = output_ranges.clone();
+                move |_, _, event, _| match event {
+                    ConversationEvent::SlashCommandOutputAdded(range) => {
+                        ranges.borrow_mut().insert(range.clone());
+                    }
+                    ConversationEvent::SlashCommandOutputRemoved(range) => {
+                        ranges.borrow_mut().remove(range);
+                    }
+                    _ => {}
+                }
+            })
+            .detach();
+        });
+
+        let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone());
+
+        // Insert a slash command
+        buffer.update(cx, |buffer, cx| {
+            buffer.edit([(0..0, "/file src/lib.rs")], None, cx);
+        });
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            "
+            /file src/lib.rs
+            "
+            .unindent()
+            .trim_end(),
+            cx,
+        );
+
+        // The slash command runs
+        cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/lib.rsยซ
+            ```src/lib.rs
+            fn one() -> usize { 1 }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        // Edit the slash command
+        buffer.update(cx, |buffer, cx| {
+            let edit_offset = buffer.text().find("lib.rs").unwrap();
+            buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx);
+        });
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/main.rsยซ
+            ```src/lib.rs
+            fn one() -> usize { 1 }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/main.rsยซ
+            ```src/main.rs
+            use crate::one;
+            fn main() { one(); }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        // Insert newlines between the slash command and its output
+        buffer.update(cx, |buffer, cx| {
+            let edit_offset = buffer.text().find("\n```src/main.rs").unwrap();
+            buffer.edit([(edit_offset..edit_offset, "\n")], None, cx);
+        });
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/main.rsยซ
+
+            ```src/main.rs
+            use crate::one;
+            fn main() { one(); }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/main.rsยซ
+            ```src/main.rs
+            use crate::one;
+            fn main() { one(); }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        // Insert text at the beginning of the output
+        buffer.update(cx, |buffer, cx| {
+            let edit_offset = buffer.text().find("```src/main.rs").unwrap();
+            buffer.edit([(edit_offset..edit_offset, "!")], None, cx);
+        });
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/main.rsยซ
+            !```src/main.rs
+            use crate::one;
+            fn main() { one(); }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE);
+        assert_text_and_output_ranges(
+            &buffer,
+            &output_ranges.borrow(),
+            &"
+            /file src/main.rsยซ
+            ```src/main.rs
+            use crate::one;
+            fn main() { one(); }
+            ```ยป"
+                .unindent(),
+            cx,
+        );
+
+        // Slash commands are omitted from completion requests. Only their
+        // output is included.
+        let request = conversation.update(cx, |conversation, cx| {
+            conversation.to_completion_request(cx)
+        });
+        assert_eq!(
+            &request.messages[1..],
+            &[LanguageModelRequestMessage {
+                role: Role::User,
+                content: "
+                ```src/main.rs
+                use crate::one;
+                fn main() { one(); }
+                ```"
+                .unindent()
+            }]
+        );
+
+        buffer.update(cx, |buffer, cx| {
+            buffer.edit([(0..0, "hello\n")], None, cx);
+        });
+        buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                [(buffer.len()..buffer.len(), "\ngoodbye\nfarewell\n")],
+                None,
+                cx,
+            );
+        });
+        let request = conversation.update(cx, |conversation, cx| {
+            conversation.to_completion_request(cx)
+        });
+        assert_eq!(
+            &request.messages[1..],
+            &[LanguageModelRequestMessage {
+                role: Role::User,
+                content: "
+                hello
+                ```src/main.rs
+                use crate::one;
+                fn main() { one(); }
+                ```
+                goodbye
+                farewell"
+                    .unindent()
+            }]
+        );
+
+        #[track_caller]
+        fn assert_text_and_output_ranges(
+            buffer: &Model<Buffer>,
+            ranges: &HashSet<Range<language::Anchor>>,
+            expected_marked_text: &str,
+            cx: &mut TestAppContext,
+        ) {
+            let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false);
+            let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| {
+                let mut ranges = ranges
+                    .iter()
+                    .map(|range| range.to_offset(buffer))
+                    .collect::<Vec<_>>();
+                ranges.sort_by_key(|a| a.start);
+                (buffer.text(), ranges)
+            });
+
+            assert_eq!(actual_text, expected_text);
+            assert_eq!(actual_ranges, expected_ranges);
+        }
+    }
+
     #[test]
     fn test_parse_next_edit_suggestion() {
         let text = "

crates/assistant/src/completion_provider.rs ๐Ÿ”—

@@ -233,7 +233,7 @@ impl CompletionProvider {
             CompletionProvider::Anthropic(provider) => provider.count_tokens(request, cx),
             CompletionProvider::ZedDotDev(provider) => provider.count_tokens(request, cx),
             #[cfg(test)]
-            CompletionProvider::Fake(_) => unimplemented!(),
+            CompletionProvider::Fake(_) => futures::FutureExt::boxed(futures::future::ready(Ok(0))),
         }
     }
 

crates/assistant/src/prompt_library.rs ๐Ÿ”—

@@ -156,10 +156,10 @@ impl PromptLibrary {
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
 pub struct UserPrompt {
     version: String,
-    title: String,
+    pub title: String,
     author: String,
     languages: Vec<String>,
-    prompt: String,
+    pub prompt: String,
 }
 
 impl UserPrompt {

crates/assistant/src/slash_command.rs ๐Ÿ”—

@@ -0,0 +1,319 @@
+use anyhow::Result;
+use collections::HashMap;
+use editor::{CompletionProvider, Editor};
+use futures::channel::oneshot;
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{AppContext, Model, Task, ViewContext, WindowHandle};
+use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint};
+use parking_lot::{Mutex, RwLock};
+use project::Project;
+use rope::Point;
+use std::{
+    ops::Range,
+    sync::{
+        atomic::{AtomicBool, Ordering::SeqCst},
+        Arc,
+    },
+};
+use workspace::Workspace;
+
+use crate::PromptLibrary;
+
+mod current_file_command;
+mod file_command;
+mod prompt_command;
+
+pub(crate) struct SlashCommandCompletionProvider {
+    commands: Arc<SlashCommandRegistry>,
+    cancel_flag: Mutex<Arc<AtomicBool>>,
+}
+
+#[derive(Default)]
+pub(crate) struct SlashCommandRegistry {
+    commands: HashMap<String, Box<dyn SlashCommand>>,
+}
+
+pub(crate) trait SlashCommand: 'static + Send + Sync {
+    fn name(&self) -> String;
+    fn description(&self) -> String;
+    fn complete_argument(
+        &self,
+        query: String,
+        cancel: Arc<AtomicBool>,
+        cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>>;
+    fn requires_argument(&self) -> bool;
+    fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation;
+}
+
+pub(crate) struct SlashCommandInvocation {
+    pub output: Task<Result<String>>,
+    pub invalidated: oneshot::Receiver<()>,
+    pub cleanup: SlashCommandCleanup,
+}
+
+#[derive(Default)]
+pub(crate) struct SlashCommandCleanup(Option<Box<dyn FnOnce()>>);
+
+impl SlashCommandCleanup {
+    pub fn new(cleanup: impl FnOnce() + 'static) -> Self {
+        Self(Some(Box::new(cleanup)))
+    }
+}
+
+impl Drop for SlashCommandCleanup {
+    fn drop(&mut self) {
+        if let Some(cleanup) = self.0.take() {
+            cleanup();
+        }
+    }
+}
+
+pub(crate) struct SlashCommandLine {
+    /// The range within the line containing the command name.
+    pub name: Range<usize>,
+    /// The range within the line containing the command argument.
+    pub argument: Option<Range<usize>>,
+}
+
+impl SlashCommandRegistry {
+    pub fn new(
+        project: Model<Project>,
+        prompt_library: Arc<PromptLibrary>,
+        window: Option<WindowHandle<Workspace>>,
+    ) -> Arc<Self> {
+        let mut this = Self {
+            commands: HashMap::default(),
+        };
+
+        this.register_command(file_command::FileSlashCommand::new(project));
+        this.register_command(prompt_command::PromptSlashCommand::new(prompt_library));
+        if let Some(window) = window {
+            this.register_command(current_file_command::CurrentFileSlashCommand::new(window));
+        }
+
+        Arc::new(this)
+    }
+
+    fn register_command(&mut self, command: impl SlashCommand) {
+        self.commands.insert(command.name(), Box::new(command));
+    }
+
+    fn command_names(&self) -> impl Iterator<Item = &String> {
+        self.commands.keys()
+    }
+
+    pub(crate) fn command(&self, name: &str) -> Option<&dyn SlashCommand> {
+        self.commands.get(name).map(|b| &**b)
+    }
+}
+
+impl SlashCommandCompletionProvider {
+    pub fn new(commands: Arc<SlashCommandRegistry>) -> Self {
+        Self {
+            cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))),
+            commands,
+        }
+    }
+
+    fn complete_command_name(
+        &self,
+        command_name: &str,
+        range: Range<Anchor>,
+        cx: &mut AppContext,
+    ) -> Task<Result<Vec<project::Completion>>> {
+        let candidates = self
+            .commands
+            .command_names()
+            .enumerate()
+            .map(|(ix, def)| StringMatchCandidate {
+                id: ix,
+                string: def.clone(),
+                char_bag: def.as_str().into(),
+            })
+            .collect::<Vec<_>>();
+        let commands = self.commands.clone();
+        let command_name = command_name.to_string();
+        let executor = cx.background_executor().clone();
+        executor.clone().spawn(async move {
+            let matches = match_strings(
+                &candidates,
+                &command_name,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor,
+            )
+            .await;
+
+            Ok(matches
+                .into_iter()
+                .filter_map(|mat| {
+                    let command = commands.command(&mat.string)?;
+                    let mut new_text = mat.string.clone();
+                    if command.requires_argument() {
+                        new_text.push(' ');
+                    }
+
+                    Some(project::Completion {
+                        old_range: range.clone(),
+                        documentation: Some(Documentation::SingleLine(command.description())),
+                        new_text,
+                        label: CodeLabel::plain(mat.string, None),
+                        server_id: LanguageServerId(0),
+                        lsp_completion: Default::default(),
+                    })
+                })
+                .collect())
+        })
+    }
+
+    fn complete_command_argument(
+        &self,
+        command_name: &str,
+        argument: String,
+        range: Range<Anchor>,
+        cx: &mut AppContext,
+    ) -> Task<Result<Vec<project::Completion>>> {
+        let new_cancel_flag = Arc::new(AtomicBool::new(false));
+        let mut flag = self.cancel_flag.lock();
+        flag.store(true, SeqCst);
+        *flag = new_cancel_flag.clone();
+
+        if let Some(command) = self.commands.command(command_name) {
+            let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx);
+            cx.background_executor().spawn(async move {
+                Ok(completions
+                    .await?
+                    .into_iter()
+                    .map(|arg| project::Completion {
+                        old_range: range.clone(),
+                        label: CodeLabel::plain(arg.clone(), None),
+                        new_text: arg.clone(),
+                        documentation: None,
+                        server_id: LanguageServerId(0),
+                        lsp_completion: Default::default(),
+                    })
+                    .collect())
+            })
+        } else {
+            cx.background_executor()
+                .spawn(async move { Ok(Vec::new()) })
+        }
+    }
+}
+
+impl CompletionProvider for SlashCommandCompletionProvider {
+    fn completions(
+        &self,
+        buffer: &Model<Buffer>,
+        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))
+            }
+        });
+
+        task.unwrap_or_else(|| Task::ready(Ok(Vec::new())))
+    }
+
+    fn resolve_completions(
+        &self,
+        _: Model<Buffer>,
+        _: Vec<usize>,
+        _: Arc<RwLock<Box<[project::Completion]>>>,
+        _: &mut ViewContext<Editor>,
+    ) -> Task<Result<bool>> {
+        Task::ready(Ok(true))
+    }
+
+    fn apply_additional_edits_for_completion(
+        &self,
+        _: Model<Buffer>,
+        _: project::Completion,
+        _: bool,
+        _: &mut ViewContext<Editor>,
+    ) -> Task<Result<Option<language::Transaction>>> {
+        Task::ready(Ok(None))
+    }
+
+    fn is_completion_trigger(
+        &self,
+        buffer: &Model<Buffer>,
+        position: language::Anchor,
+        _text: &str,
+        _trigger_in_words: bool,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        let buffer = buffer.read(cx);
+        let position = position.to_point(buffer);
+        let line_start = Point::new(position.row, 0);
+        let mut lines = buffer.text_for_range(line_start..position).lines();
+        if let Some(line) = lines.next() {
+            SlashCommandLine::parse(line).is_some()
+        } else {
+            false
+        }
+    }
+}
+
+impl SlashCommandLine {
+    pub(crate) fn parse(line: &str) -> Option<Self> {
+        let mut call: Option<Self> = None;
+        let mut ix = 0;
+        for c in line.chars() {
+            let next_ix = ix + c.len_utf8();
+            if let Some(call) = &mut call {
+                // The command arguments start at the first non-whitespace character
+                // after the command name, and continue until the end of the line.
+                if let Some(argument) = &mut call.argument {
+                    if (*argument).is_empty() && c.is_whitespace() {
+                        argument.start = next_ix;
+                    }
+                    argument.end = next_ix;
+                }
+                // The command name ends at the first whitespace character.
+                else if !call.name.is_empty() {
+                    if c.is_whitespace() {
+                        call.argument = Some(next_ix..next_ix);
+                    } else {
+                        call.name.end = next_ix;
+                    }
+                }
+                // The command name must begin with a letter.
+                else if c.is_alphabetic() {
+                    call.name.end = next_ix;
+                } else {
+                    return None;
+                }
+            }
+            // Commands start with a slash.
+            else if c == '/' {
+                call = Some(SlashCommandLine {
+                    name: next_ix..next_ix,
+                    argument: None,
+                });
+            }
+            // The line can't contain anything before the slash except for whitespace.
+            else if !c.is_whitespace() {
+                return None;
+            }
+            ix = next_ix;
+        }
+        call
+    }
+}

crates/assistant/src/slash_command/current_file_command.rs ๐Ÿ”—

@@ -0,0 +1,135 @@
+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 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, _argument: Option<&str>, 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 ๐Ÿ”—

@@ -0,0 +1,145 @@
+use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use anyhow::Result;
+use futures::channel::oneshot;
+use fuzzy::PathMatch;
+use gpui::{AppContext, Model, Task};
+use project::{PathMatchCandidateSet, Project};
+use std::{
+    path::Path,
+    sync::{atomic::AtomicBool, Arc},
+};
+
+pub(crate) struct FileSlashCommand {
+    project: Model<Project>,
+}
+
+impl FileSlashCommand {
+    pub fn new(project: Model<Project>) -> Self {
+        Self { project }
+    }
+
+    fn search_paths(
+        &self,
+        query: String,
+        cancellation_flag: Arc<AtomicBool>,
+        cx: &mut AppContext,
+    ) -> Task<Vec<PathMatch>> {
+        let worktrees = self
+            .project
+            .read(cx)
+            .visible_worktrees(cx)
+            .collect::<Vec<_>>();
+        let include_root_name = worktrees.len() > 1;
+        let candidate_sets = worktrees
+            .into_iter()
+            .map(|worktree| {
+                let worktree = worktree.read(cx);
+                PathMatchCandidateSet {
+                    snapshot: worktree.snapshot(),
+                    include_ignored: worktree
+                        .root_entry()
+                        .map_or(false, |entry| entry.is_ignored),
+                    include_root_name,
+                    directories_only: false,
+                }
+            })
+            .collect::<Vec<_>>();
+
+        let executor = cx.background_executor().clone();
+        cx.foreground_executor().spawn(async move {
+            fuzzy::match_path_sets(
+                candidate_sets.as_slice(),
+                query.as_str(),
+                None,
+                false,
+                100,
+                &cancellation_flag,
+                executor,
+            )
+            .await
+        })
+    }
+}
+
+impl SlashCommand for FileSlashCommand {
+    fn name(&self) -> String {
+        "file".into()
+    }
+
+    fn description(&self) -> String {
+        "insert an entire file".into()
+    }
+
+    fn requires_argument(&self) -> bool {
+        true
+    }
+
+    fn complete_argument(
+        &self,
+        query: String,
+        cancellation_flag: Arc<AtomicBool>,
+        cx: &mut AppContext,
+    ) -> gpui::Task<Result<Vec<String>>> {
+        let paths = self.search_paths(query, cancellation_flag, cx);
+        cx.background_executor().spawn(async move {
+            Ok(paths
+                .await
+                .into_iter()
+                .map(|path_match| {
+                    format!(
+                        "{}{}",
+                        path_match.path_prefix,
+                        path_match.path.to_string_lossy()
+                    )
+                })
+                .collect())
+        })
+    }
+
+    fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+        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(),
+            };
+        };
+
+        let path = Path::new(argument);
+        let abs_path = project.worktrees().find_map(|worktree| {
+            let worktree = worktree.read(cx);
+            worktree.entry_for_path(path)?;
+            worktree.absolutize(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(),
+            };
+        };
+
+        let fs = project.fs().clone();
+        let argument = argument.to_string();
+        let output = 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("```");
+            output.push_str(&argument);
+            output.push('\n');
+            output.push_str(&content);
+            if !output.ends_with('\n') {
+                output.push('\n');
+            }
+            output.push_str("```");
+            Ok(output)
+        });
+        SlashCommandInvocation {
+            output,
+            invalidated: oneshot::channel().1,
+            cleanup: SlashCommandCleanup::default(),
+        }
+    }
+}

crates/assistant/src/slash_command/prompt_command.rs ๐Ÿ”—

@@ -0,0 +1,88 @@
+use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation};
+use crate::PromptLibrary;
+use anyhow::{anyhow, Context, Result};
+use futures::channel::oneshot;
+use fuzzy::StringMatchCandidate;
+use gpui::{AppContext, Task};
+use std::sync::{atomic::AtomicBool, Arc};
+
+pub(crate) struct PromptSlashCommand {
+    library: Arc<PromptLibrary>,
+}
+
+impl PromptSlashCommand {
+    pub fn new(library: Arc<PromptLibrary>) -> Self {
+        Self { library }
+    }
+}
+
+impl SlashCommand for PromptSlashCommand {
+    fn name(&self) -> String {
+        "prompt".into()
+    }
+
+    fn description(&self) -> String {
+        "insert a prompt from the library".into()
+    }
+
+    fn requires_argument(&self) -> bool {
+        true
+    }
+
+    fn complete_argument(
+        &self,
+        query: String,
+        cancellation_flag: Arc<AtomicBool>,
+        cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        let library = self.library.clone();
+        let executor = cx.background_executor().clone();
+        cx.background_executor().spawn(async move {
+            let candidates = library
+                .prompts()
+                .into_iter()
+                .enumerate()
+                .map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.title))
+                .collect::<Vec<_>>();
+            let matches = fuzzy::match_strings(
+                &candidates,
+                &query,
+                false,
+                100,
+                &cancellation_flag,
+                executor,
+            )
+            .await;
+            Ok(matches
+                .into_iter()
+                .map(|mat| candidates[mat.candidate_id].string.clone())
+                .collect())
+        })
+    }
+
+    fn run(&self, title: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation {
+        let Some(title) = title else {
+            return SlashCommandInvocation {
+                output: Task::ready(Err(anyhow!("missing prompt name"))),
+                invalidated: oneshot::channel().1,
+                cleanup: SlashCommandCleanup::default(),
+            };
+        };
+
+        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.title == title)
+                .with_context(|| format!("no prompt found with title {:?}", title))?;
+            Ok(prompt.prompt)
+        });
+        SlashCommandInvocation {
+            output,
+            invalidated: oneshot::channel().1,
+            cleanup: SlashCommandCleanup::default(),
+        }
+    }
+}

crates/collab_ui/src/chat_panel/message_editor.rs ๐Ÿ”—

@@ -75,6 +75,17 @@ impl CompletionProvider for MessageEditorCompletionProvider {
     ) -> Task<Result<Option<language::Transaction>>> {
         Task::ready(Ok(None))
     }
+
+    fn is_completion_trigger(
+        &self,
+        _buffer: &Model<Buffer>,
+        _position: language::Anchor,
+        text: &str,
+        _trigger_in_words: bool,
+        _cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        text == "@"
+    }
 }
 
 impl MessageEditor {

crates/editor/src/editor.rs ๐Ÿ”—

@@ -449,6 +449,9 @@ pub struct Editor {
     mode: EditorMode,
     show_breadcrumbs: bool,
     show_gutter: bool,
+    show_line_numbers: Option<bool>,
+    show_git_diff_gutter: Option<bool>,
+    show_code_actions: Option<bool>,
     show_wrap_guides: Option<bool>,
     placeholder_text: Option<Arc<str>>,
     highlight_order: usize,
@@ -517,6 +520,9 @@ pub struct Editor {
 pub struct EditorSnapshot {
     pub mode: EditorMode,
     show_gutter: bool,
+    show_line_numbers: Option<bool>,
+    show_git_diff_gutter: Option<bool>,
+    show_code_actions: Option<bool>,
     render_git_blame_gutter: bool,
     pub display_snapshot: DisplaySnapshot,
     pub placeholder_text: Option<Arc<str>>,
@@ -1646,6 +1652,9 @@ impl Editor {
             mode,
             show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs,
             show_gutter: mode == EditorMode::Full,
+            show_line_numbers: None,
+            show_git_diff_gutter: None,
+            show_code_actions: None,
             show_wrap_guides: None,
             placeholder_text: None,
             highlight_order: 0,
@@ -1881,6 +1890,9 @@ impl Editor {
         EditorSnapshot {
             mode: self.mode,
             show_gutter: self.show_gutter,
+            show_line_numbers: self.show_line_numbers,
+            show_git_diff_gutter: self.show_git_diff_gutter,
+            show_code_actions: self.show_code_actions,
             render_git_blame_gutter: self.render_git_blame_gutter(cx),
             display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
             scroll_anchor: self.scroll_manager.anchor(),
@@ -1933,8 +1945,8 @@ impl Editor {
         self.custom_context_menu = Some(Box::new(f))
     }
 
-    pub fn set_completion_provider(&mut self, hub: Box<dyn CompletionProvider>) {
-        self.completion_provider = Some(hub);
+    pub fn set_completion_provider(&mut self, provider: Box<dyn CompletionProvider>) {
+        self.completion_provider = Some(provider);
     }
 
     pub fn set_inline_completion_provider<T>(
@@ -3280,22 +3292,41 @@ impl Editor {
         trigger_in_words: bool,
         cx: &mut ViewContext<Self>,
     ) {
-        if !EditorSettings::get_global(cx).show_completions_on_input {
-            return;
-        }
-
-        let selection = self.selections.newest_anchor();
-        if self
-            .buffer
-            .read(cx)
-            .is_completion_trigger(selection.head(), text, trigger_in_words, cx)
-        {
+        if self.is_completion_trigger(text, trigger_in_words, cx) {
             self.show_completions(&ShowCompletions, cx);
         } else {
             self.hide_context_menu(cx);
         }
     }
 
+    fn is_completion_trigger(
+        &self,
+        text: &str,
+        trigger_in_words: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> bool {
+        let position = self.selections.newest_anchor().head();
+        let multibuffer = self.buffer.read(cx);
+        let Some(buffer) = position
+            .buffer_id
+            .and_then(|buffer_id| multibuffer.buffer(buffer_id).clone())
+        else {
+            return false;
+        };
+
+        if let Some(completion_provider) = &self.completion_provider {
+            completion_provider.is_completion_trigger(
+                &buffer,
+                position.text_anchor,
+                text,
+                trigger_in_words,
+                cx,
+            )
+        } else {
+            false
+        }
+    }
+
     /// If any empty selections is touching the start of its innermost containing autoclose
     /// region, expand it to select the brackets.
     fn select_autoclose_pair(&mut self, cx: &mut ViewContext<Self>) {
@@ -9613,8 +9644,27 @@ impl Editor {
         cx.notify();
     }
 
-    pub fn set_show_wrap_guides(&mut self, show_gutter: bool, cx: &mut ViewContext<Self>) {
-        self.show_wrap_guides = Some(show_gutter);
+    pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut ViewContext<Self>) {
+        self.show_line_numbers = Some(show_line_numbers);
+        cx.notify();
+    }
+
+    pub fn set_show_git_diff_gutter(
+        &mut self,
+        show_git_diff_gutter: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.show_git_diff_gutter = Some(show_git_diff_gutter);
+        cx.notify();
+    }
+
+    pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut ViewContext<Self>) {
+        self.show_code_actions = Some(show_code_actions);
+        cx.notify();
+    }
+
+    pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut ViewContext<Self>) {
+        self.show_wrap_guides = Some(show_wrap_guides);
         cx.notify();
     }
 
@@ -10888,6 +10938,15 @@ pub trait CompletionProvider {
         push_to_history: bool,
         cx: &mut ViewContext<Editor>,
     ) -> Task<Result<Option<language::Transaction>>>;
+
+    fn is_completion_trigger(
+        &self,
+        buffer: &Model<Buffer>,
+        position: language::Anchor,
+        text: &str,
+        trigger_in_words: bool,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool;
 }
 
 impl CompletionProvider for Model<Project> {
@@ -10925,6 +10984,40 @@ impl CompletionProvider for Model<Project> {
             project.apply_additional_edits_for_completion(buffer, completion, push_to_history, cx)
         })
     }
+
+    fn is_completion_trigger(
+        &self,
+        buffer: &Model<Buffer>,
+        position: language::Anchor,
+        text: &str,
+        trigger_in_words: bool,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        if !EditorSettings::get_global(cx).show_completions_on_input {
+            return false;
+        }
+
+        let mut chars = text.chars();
+        let char = if let Some(char) = chars.next() {
+            char
+        } else {
+            return false;
+        };
+        if chars.next().is_some() {
+            return false;
+        }
+
+        let buffer = buffer.read(cx);
+        let scope = buffer.snapshot().language_scope_at(position);
+        if trigger_in_words && char_kind(&scope, char) == CharKind::Word {
+            return true;
+        }
+
+        buffer
+            .completion_triggers()
+            .iter()
+            .any(|string| string == text)
+    }
 }
 
 fn inlay_hint_settings(
@@ -11030,13 +11123,17 @@ impl EditorSnapshot {
         }
         let descent = cx.text_system().descent(font_id, font_size);
 
-        let show_git_gutter = matches!(
-            ProjectSettings::get_global(cx).git.git_gutter,
-            Some(GitGutterSetting::TrackedFiles)
-        );
+        let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| {
+            matches!(
+                ProjectSettings::get_global(cx).git.git_gutter,
+                Some(GitGutterSetting::TrackedFiles)
+            )
+        });
         let gutter_settings = EditorSettings::get_global(cx).gutter;
-        let gutter_lines_enabled = gutter_settings.line_numbers;
-        let line_gutter_width = if gutter_lines_enabled {
+        let show_line_numbers = self
+            .show_line_numbers
+            .unwrap_or_else(|| gutter_settings.line_numbers);
+        let line_gutter_width = if show_line_numbers {
             // Avoid flicker-like gutter resizes when the line number gains another digit and only resize the gutter on files with N*10^5 lines.
             let min_width_for_number_on_gutter = em_width * 4.0;
             max_line_number_width.max(min_width_for_number_on_gutter)
@@ -11044,26 +11141,30 @@ impl EditorSnapshot {
             0.0.into()
         };
 
+        let show_code_actions = self
+            .show_code_actions
+            .unwrap_or_else(|| gutter_settings.code_actions);
+
         let git_blame_entries_width = self
             .render_git_blame_gutter
             .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
 
         let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
-        left_padding += if gutter_settings.code_actions {
+        left_padding += if show_code_actions {
             em_width * 3.0
-        } else if show_git_gutter && gutter_lines_enabled {
+        } else if show_git_gutter && show_line_numbers {
             em_width * 2.0
-        } else if show_git_gutter || gutter_lines_enabled {
+        } else if show_git_gutter || show_line_numbers {
             em_width
         } else {
             px(0.)
         };
 
-        let right_padding = if gutter_settings.folds && gutter_lines_enabled {
+        let right_padding = if gutter_settings.folds && show_line_numbers {
             em_width * 4.0
         } else if gutter_settings.folds {
             em_width * 3.0
-        } else if gutter_lines_enabled {
+        } else if show_line_numbers {
             em_width
         } else {
             px(0.)

crates/editor/src/element.rs ๐Ÿ”—

@@ -1623,6 +1623,13 @@ impl EditorElement {
         snapshot: &EditorSnapshot,
         cx: &mut WindowContext,
     ) -> Vec<Option<ShapedLine>> {
+        let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| {
+            EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full
+        });
+        if !include_line_numbers {
+            return Vec::new();
+        }
+
         let editor = self.editor.read(cx);
         let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
             let newest = editor.selections.newest::<Point>(cx);
@@ -1638,54 +1645,47 @@ impl EditorElement {
             .head
         });
         let font_size = self.style.text.font_size.to_pixels(cx.rem_size());
-        let include_line_numbers =
-            EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full;
-        let mut shaped_line_numbers = Vec::with_capacity(rows.len());
-        let mut line_number = String::new();
+
         let is_relative = EditorSettings::get_global(cx).relative_line_numbers;
         let relative_to = if is_relative {
             Some(newest_selection_head.row())
         } else {
             None
         };
-
         let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to);
-
-        for (ix, row) in buffer_rows.into_iter().enumerate() {
-            let display_row = DisplayRow(rows.start.0 + ix as u32);
-            let color = if active_rows.contains_key(&display_row) {
-                cx.theme().colors().editor_active_line_number
-            } else {
-                cx.theme().colors().editor_line_number
-            };
-            if let Some(multibuffer_row) = row {
-                if include_line_numbers {
-                    line_number.clear();
-                    let default_number = multibuffer_row.0 + 1;
-                    let number = relative_rows
-                        .get(&DisplayRow(ix as u32 + rows.start.0))
-                        .unwrap_or(&default_number);
-                    write!(&mut line_number, "{number}").unwrap();
-                    let run = TextRun {
-                        len: line_number.len(),
-                        font: self.style.text.font(),
-                        color,
-                        background_color: None,
-                        underline: None,
-                        strikethrough: None,
-                    };
-                    let shaped_line = cx
-                        .text_system()
-                        .shape_line(line_number.clone().into(), font_size, &[run])
-                        .unwrap();
-                    shaped_line_numbers.push(Some(shaped_line));
-                }
-            } else {
-                shaped_line_numbers.push(None);
-            }
-        }
-
-        shaped_line_numbers
+        let mut line_number = String::new();
+        buffer_rows
+            .into_iter()
+            .enumerate()
+            .map(|(ix, multibuffer_row)| {
+                let multibuffer_row = multibuffer_row?;
+                let display_row = DisplayRow(rows.start.0 + ix as u32);
+                let color = if active_rows.contains_key(&display_row) {
+                    cx.theme().colors().editor_active_line_number
+                } else {
+                    cx.theme().colors().editor_line_number
+                };
+                line_number.clear();
+                let default_number = multibuffer_row.0 + 1;
+                let number = relative_rows
+                    .get(&DisplayRow(ix as u32 + rows.start.0))
+                    .unwrap_or(&default_number);
+                write!(&mut line_number, "{number}").unwrap();
+                let run = TextRun {
+                    len: line_number.len(),
+                    font: self.style.text.font(),
+                    color,
+                    background_color: None,
+                    underline: None,
+                    strikethrough: None,
+                };
+                let shaped_line = cx
+                    .text_system()
+                    .shape_line(line_number.clone().into(), font_size, &[run])
+                    .unwrap();
+                Some(shaped_line)
+            })
+            .collect()
     }
 
     fn layout_gutter_fold_toggles(
@@ -2513,10 +2513,16 @@ impl EditorElement {
             }
         }
 
-        let show_git_gutter = matches!(
-            ProjectSettings::get_global(cx).git.git_gutter,
-            Some(GitGutterSetting::TrackedFiles)
-        );
+        let show_git_gutter = layout
+            .position_map
+            .snapshot
+            .show_git_diff_gutter
+            .unwrap_or_else(|| {
+                matches!(
+                    ProjectSettings::get_global(cx).git.git_gutter,
+                    Some(GitGutterSetting::TrackedFiles)
+                )
+            });
         if show_git_gutter {
             Self::paint_diff_hunks(layout.gutter_hitbox.bounds, layout, cx)
         }
@@ -4281,7 +4287,11 @@ impl Element for EditorElement {
                                 gutter_dimensions.width - gutter_dimensions.left_padding,
                                 cx,
                             );
-                            if gutter_settings.code_actions {
+
+                            let show_code_actions = snapshot
+                                .show_code_actions
+                                .unwrap_or_else(|| gutter_settings.code_actions);
+                            if show_code_actions {
                                 let newest_selection_point =
                                     newest_selection_head.to_point(&snapshot.display_snapshot);
                                 let buffer = snapshot.buffer_snapshot.buffer_line_for_row(

crates/gpui/src/window.rs ๐Ÿ”—

@@ -4443,6 +4443,9 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
     }
 }
 
+unsafe impl<V> Send for WindowHandle<V> {}
+unsafe impl<V> Sync for WindowHandle<V> {}
+
 /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
 #[derive(Copy, Clone, PartialEq, Eq, Hash)]
 pub struct AnyWindowHandle {

crates/multi_buffer/src/multi_buffer.rs ๐Ÿ”—

@@ -110,6 +110,7 @@ impl MultiBufferRow {
     pub const MIN: Self = Self(0);
     pub const MAX: Self = Self(u32::MAX);
 }
+
 #[derive(Clone)]
 struct History {
     next_transaction_id: TransactionId,
@@ -1531,46 +1532,6 @@ impl MultiBuffer {
             .map(|state| state.buffer.clone())
     }
 
-    pub fn is_completion_trigger(
-        &self,
-        position: Anchor,
-        text: &str,
-        trigger_in_words: bool,
-        cx: &AppContext,
-    ) -> bool {
-        let mut chars = text.chars();
-        let char = if let Some(char) = chars.next() {
-            char
-        } else {
-            return false;
-        };
-        if chars.next().is_some() {
-            return false;
-        }
-
-        let snapshot = self.snapshot(cx);
-        let position = position.to_offset(&snapshot);
-        let scope = snapshot.language_scope_at(position);
-        if trigger_in_words && char_kind(&scope, char) == CharKind::Word {
-            return true;
-        }
-
-        let anchor = snapshot.anchor_before(position);
-        anchor
-            .buffer_id
-            .and_then(|buffer_id| {
-                let buffer = self.buffers.borrow().get(&buffer_id)?.buffer.clone();
-                Some(
-                    buffer
-                        .read(cx)
-                        .completion_triggers()
-                        .iter()
-                        .any(|string| string == text),
-                )
-            })
-            .unwrap_or(false)
-    }
-
     pub fn language_at<T: ToOffset>(&self, point: T, cx: &AppContext) -> Option<Arc<Language>> {
         self.point_to_buffer_offset(point, cx)
             .and_then(|(buffer, offset, _)| buffer.read(cx).language_at(offset))

crates/text/src/text.rs ๐Ÿ”—

@@ -2166,6 +2166,31 @@ impl BufferSnapshot {
         }
     }
 
+    pub fn has_edits_since_in_range(&self, since: &clock::Global, range: Range<Anchor>) -> bool {
+        if *since != self.version {
+            let start_fragment_id = self.fragment_id_for_anchor(&range.start);
+            let end_fragment_id = self.fragment_id_for_anchor(&range.end);
+            let mut cursor = self
+                .fragments
+                .filter::<_, usize>(move |summary| !since.observed_all(&summary.max_version));
+            cursor.next(&None);
+            while let Some(fragment) = cursor.item() {
+                if fragment.id > *end_fragment_id {
+                    break;
+                }
+                if fragment.id > *start_fragment_id {
+                    let was_visible = fragment.was_visible(since, &self.undo_map);
+                    let is_visible = fragment.visible;
+                    if was_visible != is_visible {
+                        return true;
+                    }
+                }
+                cursor.next(&None);
+            }
+        }
+        false
+    }
+
     pub fn has_edits_since(&self, since: &clock::Global) -> bool {
         if *since != self.version {
             let mut cursor = self