Introduce `/search` command to assistant (#12372)

Antonio Scandurra created

This pull request introduces semantic search to the assistant using a
slash command:


https://github.com/zed-industries/zed/assets/482957/62f39eae-d7d5-46bf-a356-dd081ff88312

Moreover, this also adds a status to pending slash commands, so that we
can show when a query is running or whether it failed:

<img width="1588" alt="image"
src="https://github.com/zed-industries/zed/assets/482957/e8d85960-6275-4552-a068-85efb74cfde1">

I think this could be better design-wise, but seems like a pretty good
start.

Release Notes:

- N/A

Change summary

Cargo.lock                                                    |   1 
crates/assistant/Cargo.toml                                   |   1 
crates/assistant/src/assistant.rs                             |  17 
crates/assistant/src/assistant_panel.rs                       | 243 ++--
crates/assistant/src/slash_command.rs                         |   1 
crates/assistant/src/slash_command/active_command.rs          |  25 
crates/assistant/src/slash_command/file_command.rs            |  35 
crates/assistant/src/slash_command/project_command.rs         |  24 
crates/assistant/src/slash_command/prompt_command.rs          |  23 
crates/assistant/src/slash_command/search_command.rs          | 164 +++
crates/assistant_slash_command/src/assistant_slash_command.rs |  11 
crates/extension/src/extension_slash_command.rs               |  18 
crates/language/src/language.rs                               |   2 
crates/semantic_index/src/embedding/cloud.rs                  |   4 
crates/semantic_index/src/semantic_index.rs                   |  86 +
crates/zed/src/zed.rs                                         |   1 
16 files changed, 469 insertions(+), 187 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -368,6 +368,7 @@ dependencies = [
  "rope",
  "schemars",
  "search",
+ "semantic_index",
  "serde",
  "serde_json",
  "settings",

crates/assistant/Cargo.toml 🔗

@@ -41,6 +41,7 @@ regex.workspace = true
 rope.workspace = true
 schemars.workspace = true
 search.workspace = true
+semantic_index.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true

crates/assistant/src/assistant.rs 🔗

@@ -16,12 +16,14 @@ use command_palette_hooks::CommandPaletteFilter;
 pub(crate) use completion_provider::*;
 use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
 pub(crate) use saved_conversation::*;
+use semantic_index::{CloudEmbeddingProvider, SemanticIndex};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use std::{
     fmt::{self, Display},
     sync::Arc,
 };
+use util::paths::EMBEDDINGS_DIR;
 
 actions!(
     assistant,
@@ -232,6 +234,21 @@ impl Assistant {
 pub fn init(client: Arc<Client>, cx: &mut AppContext) {
     cx.set_global(Assistant::default());
     AssistantSettings::register(cx);
+
+    cx.spawn(|mut cx| {
+        let client = client.clone();
+        async move {
+            let embedding_provider = CloudEmbeddingProvider::new(client.clone());
+            let semantic_index = SemanticIndex::new(
+                EMBEDDINGS_DIR.join("semantic-index-db.0.mdb"),
+                Arc::new(embedding_provider),
+                &mut cx,
+            )
+            .await?;
+            cx.update(|cx| cx.set_global(semantic_index))
+        }
+    })
+    .detach();
     completion_provider::init(client, cx);
     assistant_slash_command::init(cx);
     assistant_panel::init(cx);

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,4 +1,5 @@
 use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager};
+use crate::slash_command::search_command;
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel},
     codegen::{self, Codegen, CodegenKind},
@@ -13,9 +14,10 @@ use crate::{
     SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation,
 };
 use anyhow::{anyhow, Result};
-use assistant_slash_command::{RenderFoldPlaceholder, SlashCommandOutput};
+use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection};
 use client::telemetry::Telemetry;
-use collections::{hash_map, HashMap, HashSet, VecDeque};
+use collections::{hash_map, BTreeSet, HashMap, HashSet, VecDeque};
+use editor::actions::UnfoldAt;
 use editor::{
     actions::{FoldAt, MoveDown, MoveUp},
     display_map::{
@@ -28,7 +30,8 @@ use editor::{
 use editor::{display_map::FlapId, FoldPlaceholder};
 use file_icons::FileIcons;
 use fs::Fs;
-use futures::StreamExt;
+use futures::future::Shared;
+use futures::{FutureExt, StreamExt};
 use gpui::{
     canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
     AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty,
@@ -38,11 +41,11 @@ use gpui::{
     UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace,
     WindowContext,
 };
+use language::LspAdapterDelegate;
 use language::{
-    language_settings::SoftWrap, AutoindentMode, Buffer, LanguageRegistry, OffsetRangeExt as _,
-    Point, ToOffset as _,
+    language_settings::SoftWrap, AnchorRangeExt, 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};
@@ -208,6 +211,7 @@ impl AssistantPanel {
                     );
                     slash_command_registry.register_command(active_command::ActiveSlashCommand);
                     slash_command_registry.register_command(project_command::ProjectSlashCommand);
+                    slash_command_registry.register_command(search_command::SearchSlashCommand);
 
                     Self {
                         workspace: workspace_handle,
@@ -1456,8 +1460,7 @@ enum ConversationEvent {
         updated: Vec<PendingSlashCommand>,
     },
     SlashCommandFinished {
-        output_range: Range<language::Anchor>,
-        render_placeholder: RenderFoldPlaceholder,
+        sections: Vec<SlashCommandOutputSection<language::Anchor>>,
     },
 }
 
@@ -1467,21 +1470,6 @@ 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>,
@@ -1501,8 +1489,6 @@ pub struct Conversation {
     pending_edit_suggestion_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>,
@@ -1541,8 +1527,6 @@ impl Conversation {
             token_count: None,
             pending_token_count: Task::ready(None),
             pending_edit_suggestion_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(())),
@@ -1653,8 +1637,6 @@ impl Conversation {
                 token_count: None,
                 pending_edit_suggestion_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(())),
@@ -1786,6 +1768,7 @@ impl Conversation {
                                 argument: argument.map(ToString::to_string),
                                 tooltip_text: command.tooltip_text().into(),
                                 source_range,
+                                status: PendingSlashCommandStatus::Idle,
                             };
                             updated.push(pending_command.clone());
                             new_commands.push(pending_command);
@@ -1867,10 +1850,10 @@ impl Conversation {
     }
 
     fn pending_command_for_position(
-        &self,
+        &mut self,
         position: language::Anchor,
-        cx: &AppContext,
-    ) -> Option<&PendingSlashCommand> {
+        cx: &mut ModelContext<Self>,
+    ) -> Option<&mut PendingSlashCommand> {
         let buffer = self.buffer.read(cx);
         let ix = self
             .pending_slash_commands
@@ -1884,54 +1867,72 @@ impl Conversation {
                 }
             })
             .ok()?;
-        self.pending_slash_commands.get(ix)
+        self.pending_slash_commands.get_mut(ix)
     }
 
     fn insert_command_output(
         &mut self,
-        invocation_id: SlashCommandInvocationId,
         command_range: Range<language::Anchor>,
         output: Task<Result<SlashCommandOutput>>,
         cx: &mut ModelContext<Self>,
     ) {
+        self.reparse_slash_commands(cx);
+
         let insert_output_task = cx.spawn(|this, mut cx| {
+            let command_range = command_range.clone();
             async move {
-                let output = output.await?;
-
-                let mut text = output.text;
-                LineEnding::normalize(&mut text);
-                if !text.ends_with('\n') {
-                    text.push('\n');
-                }
+                let output = output.await;
+                this.update(&mut cx, |this, cx| match output {
+                    Ok(output) => {
+                        let sections = 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 + output.text.len();
+                            buffer.edit([(start..old_end, output.text)], None, cx);
+                            if buffer.chars_at(new_end).next() != Some('\n') {
+                                buffer.edit([(new_end..new_end, "\n")], None, cx);
+                            }
 
-                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);
+                            let mut sections = output
+                                .sections
+                                .into_iter()
+                                .map(|section| SlashCommandOutputSection {
+                                    range: buffer.anchor_after(start + section.range.start)
+                                        ..buffer.anchor_before(start + section.range.end),
+                                    render_placeholder: section.render_placeholder,
+                                })
+                                .collect::<Vec<_>>();
+                            sections.sort_by(|a, b| a.range.cmp(&b.range, buffer));
+                            sections
+                        });
+                        cx.emit(ConversationEvent::SlashCommandFinished { sections });
+                    }
+                    Err(error) => {
+                        if let Some(pending_command) =
+                            this.pending_command_for_position(command_range.start, cx)
+                        {
+                            pending_command.status =
+                                PendingSlashCommandStatus::Error(error.to_string());
+                            cx.emit(ConversationEvent::PendingSlashCommandsUpdated {
+                                removed: vec![pending_command.source_range.clone()],
+                                updated: vec![pending_command.clone()],
+                            });
                         }
-                        buffer.anchor_after(start)..buffer.anchor_before(new_end)
-                    });
-                    cx.emit(ConversationEvent::SlashCommandFinished {
-                        output_range,
-                        render_placeholder: output.render_placeholder,
-                    });
-                })?;
-
-                anyhow::Ok(())
+                    }
+                })
+                .ok();
             }
-            .log_err()
         });
 
-        self.invocations.insert(
-            invocation_id,
-            SlashCommandInvocation {
-                _pending_output: insert_output_task,
-            },
-        );
+        if let Some(pending_command) = self.pending_command_for_position(command_range.start, cx) {
+            pending_command.status = PendingSlashCommandStatus::Running {
+                _task: insert_output_task.shared(),
+            };
+            cx.emit(ConversationEvent::PendingSlashCommandsUpdated {
+                removed: vec![pending_command.source_range.clone()],
+                updated: vec![pending_command.clone()],
+            });
+        }
     }
 
     fn remaining_tokens(&self) -> Option<isize> {
@@ -2565,10 +2566,18 @@ fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option<ParsedEditSugge
 struct PendingSlashCommand {
     name: String,
     argument: Option<String>,
+    status: PendingSlashCommandStatus,
     source_range: Range<language::Anchor>,
     tooltip_text: SharedString,
 }
 
+#[derive(Clone)]
+enum PendingSlashCommandStatus {
+    Idle,
+    Running { _task: Shared<Task<()>> },
+    Error(String),
+}
+
 struct PendingCompletion {
     id: usize,
     _task: Task<()>,
@@ -2773,19 +2782,16 @@ impl ConversationEditor {
         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)
+    ) {
+        if let Some(command) = self.slash_command_registry.command(name) {
+            if let Some(lsp_adapter_delegate) = self.lsp_adapter_delegate.clone() {
+                let argument = argument.map(ToString::to_string);
+                let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx);
+                self.conversation.update(cx, |conversation, cx| {
+                    conversation.insert_command_output(command_range, output, cx)
+                });
+            }
+        }
     }
 
     fn handle_conversation_event(
@@ -2901,6 +2907,7 @@ impl ConversationEditor {
                                     render_pending_slash_command_toggle(
                                         row,
                                         command.tooltip_text.clone(),
+                                        command.status.clone(),
                                         confirm_command.clone(),
                                     )
                                 }
@@ -2935,39 +2942,38 @@ impl ConversationEditor {
                     );
                 })
             }
-            ConversationEvent::SlashCommandFinished {
-                output_range,
-                render_placeholder,
-            } => {
+            ConversationEvent::SlashCommandFinished { sections } => {
                 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, output_range.start)
-                        .unwrap();
-                    let end = buffer
-                        .anchor_in_excerpt(excerpt_id, output_range.end)
-                        .unwrap();
-                    let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
-
-                    editor.insert_flaps(
-                        [Flap::new(
+                    let mut buffer_rows_to_fold = BTreeSet::new();
+                    let mut flaps = Vec::new();
+                    for section in sections {
+                        let start = buffer
+                            .anchor_in_excerpt(excerpt_id, section.range.start)
+                            .unwrap();
+                        let end = buffer
+                            .anchor_in_excerpt(excerpt_id, section.range.end)
+                            .unwrap();
+                        let buffer_row = MultiBufferRow(start.to_point(&buffer).row);
+                        buffer_rows_to_fold.insert(buffer_row);
+                        flaps.push(Flap::new(
                             start..end,
                             FoldPlaceholder {
                                 render: Arc::new({
                                     let editor = cx.view().downgrade();
-                                    let render_placeholder = render_placeholder.clone();
+                                    let render_placeholder = section.render_placeholder.clone();
                                     move |fold_id, fold_range, cx| {
                                         let editor = editor.clone();
                                         let unfold = Arc::new(move |cx: &mut WindowContext| {
                                             editor
                                                 .update(cx, |editor, cx| {
-                                                    editor.unfold_ranges(
-                                                        [fold_range.start..fold_range.end],
-                                                        true,
-                                                        false,
-                                                        cx,
+                                                    let buffer_start = fold_range.start.to_point(
+                                                        &editor.buffer().read(cx).read(cx),
                                                     );
+                                                    let buffer_row =
+                                                        MultiBufferRow(buffer_start.row);
+                                                    editor.unfold_at(&UnfoldAt { buffer_row }, cx);
                                                 })
                                                 .ok();
                                         });
@@ -2979,10 +2985,14 @@ impl ConversationEditor {
                             },
                             render_slash_command_output_toggle,
                             |_, _, _| Empty.into_any_element(),
-                        )],
-                        cx,
-                    );
-                    editor.fold_at(&FoldAt { buffer_row }, cx);
+                        ));
+                    }
+
+                    editor.insert_flaps(flaps, cx);
+
+                    for buffer_row in buffer_rows_to_fold.into_iter().rev() {
+                        editor.fold_at(&FoldAt { buffer_row }, cx);
+                    }
                 });
             }
         }
@@ -3764,19 +3774,36 @@ fn render_slash_command_output_toggle(
 fn render_pending_slash_command_toggle(
     row: MultiBufferRow,
     tooltip_text: SharedString,
+    status: PendingSlashCommandStatus,
     confirm_command: Arc<dyn Fn(&mut WindowContext)>,
 ) -> AnyElement {
-    IconButton::new(
+    let mut icon = IconButton::new(
         ("slash-command-output-fold-indicator", row.0),
         ui::IconName::TriangleRight,
     )
     .on_click(move |_e, cx| confirm_command(cx))
-    .icon_color(ui::Color::Success)
     .icon_size(ui::IconSize::Small)
-    .selected(true)
-    .size(ui::ButtonSize::None)
-    .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx))
-    .into_any_element()
+    .size(ui::ButtonSize::None);
+
+    match status {
+        PendingSlashCommandStatus::Idle => {
+            icon = icon
+                .icon_color(Color::Muted)
+                .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx));
+        }
+        PendingSlashCommandStatus::Running { .. } => {
+            icon = icon
+                .selected(true)
+                .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx));
+        }
+        PendingSlashCommandStatus::Error(error) => {
+            icon = icon
+                .icon_color(Color::Error)
+                .tooltip(move |cx| Tooltip::text(format!("error: {error}"), cx));
+        }
+    }
+
+    icon.into_any_element()
 }
 
 fn render_pending_slash_command_trailer(

crates/assistant/src/slash_command.rs 🔗

@@ -20,6 +20,7 @@ pub mod active_command;
 pub mod file_command;
 pub mod project_command;
 pub mod prompt_command;
+pub mod search_command;
 
 pub(crate) struct SlashCommandCompletionProvider {
     editor: WeakView<ConversationEditor>,

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

@@ -1,5 +1,6 @@
 use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
 use anyhow::{anyhow, Result};
+use assistant_slash_command::SlashCommandOutputSection;
 use collections::HashMap;
 use editor::Editor;
 use gpui::{AppContext, Entity, Task, WeakView};
@@ -96,16 +97,22 @@ impl SlashCommand for ActiveSlashCommand {
                     }
                 });
                 cx.foreground_executor().spawn(async move {
+                    let text = text.await;
+                    let range = 0..text.len();
                     Ok(SlashCommandOutput {
-                        text: text.await,
-                        render_placeholder: Arc::new(move |id, unfold, _| {
-                            FilePlaceholder {
-                                id,
-                                path: path.clone(),
-                                unfold,
-                            }
-                            .into_any_element()
-                        }),
+                        text,
+                        sections: vec![SlashCommandOutputSection {
+                            range,
+                            render_placeholder: Arc::new(move |id, unfold, _| {
+                                FilePlaceholder {
+                                    id,
+                                    path: path.clone(),
+                                    line_range: None,
+                                    unfold,
+                                }
+                                .into_any_element()
+                            }),
+                        }],
                     })
                 })
             } else {

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

@@ -1,10 +1,12 @@
 use super::{SlashCommand, SlashCommandOutput};
 use anyhow::Result;
+use assistant_slash_command::SlashCommandOutputSection;
 use fuzzy::PathMatch;
 use gpui::{AppContext, Model, RenderOnce, SharedString, Task, WeakView};
-use language::LspAdapterDelegate;
+use language::{LineEnding, LspAdapterDelegate};
 use project::{PathMatchCandidateSet, Project};
 use std::{
+    ops::Range,
     path::{Path, PathBuf},
     sync::{atomic::AtomicBool, Arc},
 };
@@ -128,7 +130,8 @@ impl SlashCommand for FileSlashCommand {
         let fs = project.fs().clone();
         let argument = argument.to_string();
         let text = cx.background_executor().spawn(async move {
-            let content = fs.load(&abs_path).await?;
+            let mut content = fs.load(&abs_path).await?;
+            LineEnding::normalize(&mut content);
             let mut output = String::with_capacity(argument.len() + content.len() + 9);
             output.push_str("```");
             output.push_str(&argument);
@@ -142,16 +145,21 @@ impl SlashCommand for FileSlashCommand {
         });
         cx.foreground_executor().spawn(async move {
             let text = text.await?;
+            let range = 0..text.len();
             Ok(SlashCommandOutput {
                 text,
-                render_placeholder: Arc::new(move |id, unfold, _cx| {
-                    FilePlaceholder {
-                        path: Some(path.clone()),
-                        id,
-                        unfold,
-                    }
-                    .into_any_element()
-                }),
+                sections: vec![SlashCommandOutputSection {
+                    range,
+                    render_placeholder: Arc::new(move |id, unfold, _cx| {
+                        FilePlaceholder {
+                            path: Some(path.clone()),
+                            line_range: None,
+                            id,
+                            unfold,
+                        }
+                        .into_any_element()
+                    }),
+                }],
             })
         })
     }
@@ -160,6 +168,7 @@ impl SlashCommand for FileSlashCommand {
 #[derive(IntoElement)]
 pub struct FilePlaceholder {
     pub path: Option<PathBuf>,
+    pub line_range: Option<Range<u32>>,
     pub id: ElementId,
     pub unfold: Arc<dyn Fn(&mut WindowContext)>,
 }
@@ -178,6 +187,12 @@ impl RenderOnce for FilePlaceholder {
             .layer(ElevationIndex::ElevatedSurface)
             .child(Icon::new(IconName::File))
             .child(Label::new(title))
+            .when_some(self.line_range, |button, line_range| {
+                button.child(Label::new(":")).child(Label::new(format!(
+                    "{}-{}",
+                    line_range.start, line_range.end
+                )))
+            })
             .on_click(move |_, cx| unfold(cx))
     }
 }

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

@@ -1,5 +1,6 @@
 use super::{SlashCommand, SlashCommandOutput};
 use anyhow::{anyhow, Context, Result};
+use assistant_slash_command::SlashCommandOutputSection;
 use fs::Fs;
 use gpui::{AppContext, Model, Task, WeakView};
 use language::LspAdapterDelegate;
@@ -131,18 +132,21 @@ impl SlashCommand for ProjectSlashCommand {
 
             cx.foreground_executor().spawn(async move {
                 let text = output.await?;
-
+                let range = 0..text.len();
                 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()
-                    }),
+                    sections: vec![SlashCommandOutputSection {
+                        range,
+                        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()
+                        }),
+                    }],
                 })
             })
         });

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

@@ -1,6 +1,7 @@
 use super::{SlashCommand, SlashCommandOutput};
 use crate::prompts::PromptLibrary;
 use anyhow::{anyhow, Context, Result};
+use assistant_slash_command::SlashCommandOutputSection;
 use fuzzy::StringMatchCandidate;
 use gpui::{AppContext, Task, WeakView};
 use language::LspAdapterDelegate;
@@ -94,17 +95,21 @@ impl SlashCommand for PromptSlashCommand {
         });
         cx.foreground_executor().spawn(async move {
             let prompt = prompt.await?;
+            let range = 0..prompt.len();
             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()
-                }),
+                sections: vec![SlashCommandOutputSection {
+                    range,
+                    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/src/slash_command/search_command.rs 🔗

@@ -0,0 +1,164 @@
+use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
+use anyhow::Result;
+use assistant_slash_command::SlashCommandOutputSection;
+use gpui::{AppContext, Task, WeakView};
+use language::{LineEnding, LspAdapterDelegate};
+use semantic_index::SemanticIndex;
+use std::{
+    fmt::Write,
+    path::PathBuf,
+    sync::{atomic::AtomicBool, Arc},
+};
+use ui::{prelude::*, ButtonLike, ElevationIndex, Icon, IconName};
+use util::ResultExt;
+use workspace::Workspace;
+
+pub(crate) struct SearchSlashCommand;
+
+impl SlashCommand for SearchSlashCommand {
+    fn name(&self) -> String {
+        "search".into()
+    }
+
+    fn description(&self) -> String {
+        "semantically search files".into()
+    }
+
+    fn tooltip_text(&self) -> String {
+        "search".into()
+    }
+
+    fn requires_argument(&self) -> bool {
+        true
+    }
+
+    fn complete_argument(
+        &self,
+        _query: String,
+        _cancel: Arc<AtomicBool>,
+        _cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Ok(Vec::new()))
+    }
+
+    fn run(
+        self: Arc<Self>,
+        argument: Option<&str>,
+        workspace: WeakView<Workspace>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
+        let Some(workspace) = workspace.upgrade() else {
+            return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
+        };
+        let Some(argument) = argument else {
+            return Task::ready(Err(anyhow::anyhow!("missing search query")));
+        };
+        if argument.is_empty() {
+            return Task::ready(Err(anyhow::anyhow!("missing search query")));
+        }
+
+        let project = workspace.read(cx).project().clone();
+        let argument = argument.to_string();
+        let fs = project.read(cx).fs().clone();
+        let project_index =
+            cx.update_global(|index: &mut SemanticIndex, cx| index.project_index(project, cx));
+
+        cx.spawn(|cx| async move {
+            let results = project_index
+                .read_with(&cx, |project_index, cx| {
+                    project_index.search(argument.clone(), 5, cx)
+                })?
+                .await?;
+
+            let mut loaded_results = Vec::new();
+            for result in results {
+                let (full_path, file_content) =
+                    result.worktree.read_with(&cx, |worktree, _cx| {
+                        let entry_abs_path = worktree.abs_path().join(&result.path);
+                        let mut entry_full_path = PathBuf::from(worktree.root_name());
+                        entry_full_path.push(&result.path);
+                        let file_content = async {
+                            let entry_abs_path = entry_abs_path;
+                            fs.load(&entry_abs_path).await
+                        };
+                        (entry_full_path, file_content)
+                    })?;
+                if let Some(file_content) = file_content.await.log_err() {
+                    loaded_results.push((result, full_path, file_content));
+                }
+            }
+
+            let output = cx
+                .background_executor()
+                .spawn(async move {
+                    let mut text = format!("Search results for {argument}:\n");
+                    let mut sections = Vec::new();
+                    for (result, full_path, file_content) in loaded_results {
+                        let range_start = result.range.start.min(file_content.len());
+                        let range_end = result.range.end.min(file_content.len());
+
+                        let start_line =
+                            file_content[0..range_start].matches('\n').count() as u32 + 1;
+                        let end_line = file_content[0..range_end].matches('\n').count() as u32 + 1;
+                        let start_line_byte_offset = file_content[0..range_start]
+                            .rfind('\n')
+                            .map(|pos| pos + 1)
+                            .unwrap_or_default();
+                        let end_line_byte_offset = file_content[range_end..]
+                            .find('\n')
+                            .map(|pos| range_end + pos)
+                            .unwrap_or_else(|| file_content.len());
+
+                        let section_start_ix = text.len();
+                        writeln!(
+                            text,
+                            "```{}:{}-{}",
+                            result.path.display(),
+                            start_line,
+                            end_line,
+                        )
+                        .unwrap();
+                        let mut excerpt =
+                            file_content[start_line_byte_offset..end_line_byte_offset].to_string();
+                        LineEnding::normalize(&mut excerpt);
+                        text.push_str(&excerpt);
+                        writeln!(text, "\n```\n").unwrap();
+                        let section_end_ix = text.len() - 1;
+
+                        sections.push(SlashCommandOutputSection {
+                            range: section_start_ix..section_end_ix,
+                            render_placeholder: Arc::new(move |id, unfold, _| {
+                                FilePlaceholder {
+                                    id,
+                                    path: Some(full_path.clone()),
+                                    line_range: Some(start_line..end_line),
+                                    unfold,
+                                }
+                                .into_any_element()
+                            }),
+                        });
+                    }
+
+                    let argument = SharedString::from(argument);
+                    sections.push(SlashCommandOutputSection {
+                        range: 0..text.len(),
+                        render_placeholder: Arc::new(move |id, unfold, _cx| {
+                            ButtonLike::new(id)
+                                .style(ButtonStyle::Filled)
+                                .layer(ElevationIndex::ElevatedSurface)
+                                .child(Icon::new(IconName::MagnifyingGlass))
+                                .child(Label::new(argument.clone()))
+                                .on_click(move |_, cx| unfold(cx))
+                                .into_any_element()
+                        }),
+                    });
+
+                    SlashCommandOutput { text, sections }
+                })
+                .await;
+
+            Ok(output)
+        })
+    }
+}

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -4,7 +4,10 @@ use anyhow::Result;
 use gpui::{AnyElement, AppContext, ElementId, Task, WeakView, WindowContext};
 use language::LspAdapterDelegate;
 pub use slash_command_registry::*;
-use std::sync::{atomic::AtomicBool, Arc};
+use std::{
+    ops::Range,
+    sync::{atomic::AtomicBool, Arc},
+};
 use workspace::Workspace;
 
 pub fn init(cx: &mut AppContext) {
@@ -44,5 +47,11 @@ pub type RenderFoldPlaceholder = Arc<
 
 pub struct SlashCommandOutput {
     pub text: String,
+    pub sections: Vec<SlashCommandOutputSection<usize>>,
+}
+
+#[derive(Clone)]
+pub struct SlashCommandOutputSection<T> {
+    pub range: Range<T>,
     pub render_placeholder: RenderFoldPlaceholder,
 }

crates/extension/src/extension_slash_command.rs 🔗

@@ -1,6 +1,6 @@
 use crate::wasm_host::{WasmExtension, WasmHost};
 use anyhow::{anyhow, Result};
-use assistant_slash_command::{SlashCommand, SlashCommandOutput};
+use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
 use futures::FutureExt;
 use gpui::{AppContext, IntoElement, Task, WeakView, WindowContext};
 use language::LspAdapterDelegate;
@@ -49,7 +49,7 @@ impl SlashCommand for ExtensionSlashCommand {
         cx: &mut WindowContext,
     ) -> Task<Result<SlashCommandOutput>> {
         let argument = argument.map(|arg| arg.to_string());
-        let output = cx.background_executor().spawn(async move {
+        let text = cx.background_executor().spawn(async move {
             let output = self
                 .extension
                 .call({
@@ -76,12 +76,16 @@ impl SlashCommand for ExtensionSlashCommand {
             output.ok_or_else(|| anyhow!("no output from command: {}", self.command.name))
         });
         cx.foreground_executor().spawn(async move {
-            let output = output.await?;
+            let text = text.await?;
+            let range = 0..text.len();
             Ok(SlashCommandOutput {
-                text: output,
-                render_placeholder: Arc::new(|_, _, _| {
-                    "TODO: Extension command output".into_any_element()
-                }),
+                text,
+                sections: vec![SlashCommandOutputSection {
+                    range,
+                    render_placeholder: Arc::new(|_, _, _| {
+                        "TODO: Extension command output".into_any_element()
+                    }),
+                }],
             })
         })
     }

crates/language/src/language.rs 🔗

@@ -72,7 +72,7 @@ pub use language_registry::{
 pub use lsp::LanguageServerId;
 pub use outline::{Outline, OutlineItem};
 pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer};
-pub use text::LineEnding;
+pub use text::{AnchorRangeExt, LineEnding};
 pub use tree_sitter::{Node, Parser, Tree, TreeCursor};
 
 /// Initializes the `language` crate.

crates/semantic_index/src/embedding/cloud.rs 🔗

@@ -24,6 +24,10 @@ impl EmbeddingProvider for CloudEmbeddingProvider {
         // First, fetch any embeddings that are cached based on the requested texts' digests
         // Then compute any embeddings that are missing.
         async move {
+            if !self.client.status().borrow().is_connected() {
+                return Err(anyhow!("sign in required"));
+            }
+
             let cached_embeddings = self.client.request(proto::GetCachedEmbeddings {
                 model: self.model.clone(),
                 digests: texts

crates/semantic_index/src/semantic_index.rs 🔗

@@ -7,7 +7,7 @@ use chunking::{chunk_text, Chunk};
 use collections::{Bound, HashMap, HashSet};
 pub use embedding::*;
 use fs::Fs;
-use futures::stream::StreamExt;
+use futures::{future::Shared, stream::StreamExt, FutureExt};
 use futures_batch::ChunksTimeoutStreamExt;
 use gpui::{
     AppContext, AsyncAppContext, BorrowAppContext, Context, Entity, EntityId, EventEmitter, Global,
@@ -115,9 +115,14 @@ pub struct ProjectIndex {
     _subscription: Subscription,
 }
 
+#[derive(Clone)]
 enum WorktreeIndexHandle {
-    Loading { _task: Task<Result<()>> },
-    Loaded { index: Model<WorktreeIndex> },
+    Loading {
+        index: Shared<Task<Result<Model<WorktreeIndex>, Arc<anyhow::Error>>>>,
+    },
+    Loaded {
+        index: Model<WorktreeIndex>,
+    },
 }
 
 impl ProjectIndex {
@@ -213,26 +218,33 @@ impl ProjectIndex {
                 );
 
                 let load_worktree = cx.spawn(|this, mut cx| async move {
-                    if let Some(worktree_index) = worktree_index.await.log_err() {
-                        this.update(&mut cx, |this, _| {
-                            this.worktree_indices.insert(
-                                worktree_id,
-                                WorktreeIndexHandle::Loaded {
-                                    index: worktree_index,
-                                },
-                            );
-                        })?;
-                    } else {
-                        this.update(&mut cx, |this, _cx| {
-                            this.worktree_indices.remove(&worktree_id)
-                        })?;
-                    }
+                    let result = match worktree_index.await {
+                        Ok(worktree_index) => {
+                            this.update(&mut cx, |this, _| {
+                                this.worktree_indices.insert(
+                                    worktree_id,
+                                    WorktreeIndexHandle::Loaded {
+                                        index: worktree_index.clone(),
+                                    },
+                                );
+                            })?;
+                            Ok(worktree_index)
+                        }
+                        Err(error) => {
+                            this.update(&mut cx, |this, _cx| {
+                                this.worktree_indices.remove(&worktree_id)
+                            })?;
+                            Err(Arc::new(error))
+                        }
+                    };
+
+                    this.update(&mut cx, |this, cx| this.update_status(cx))?;
 
-                    this.update(&mut cx, |this, cx| this.update_status(cx))
+                    result
                 });
 
                 WorktreeIndexHandle::Loading {
-                    _task: load_worktree,
+                    index: load_worktree.shared(),
                 }
             });
         }
@@ -279,14 +291,22 @@ impl ProjectIndex {
         let (chunks_tx, chunks_rx) = channel::bounded(1024);
         let mut worktree_scan_tasks = Vec::new();
         for worktree_index in self.worktree_indices.values() {
-            if let WorktreeIndexHandle::Loaded { index, .. } = worktree_index {
-                let chunks_tx = chunks_tx.clone();
-                index.read_with(cx, |index, cx| {
-                    let worktree_id = index.worktree.read(cx).id();
-                    let db_connection = index.db_connection.clone();
-                    let db = index.db;
-                    worktree_scan_tasks.push(cx.background_executor().spawn({
-                        async move {
+            let worktree_index = worktree_index.clone();
+            let chunks_tx = chunks_tx.clone();
+            worktree_scan_tasks.push(cx.spawn(|cx| async move {
+                let index = match worktree_index {
+                    WorktreeIndexHandle::Loading { index } => {
+                        index.clone().await.map_err(|error| anyhow!(error))?
+                    }
+                    WorktreeIndexHandle::Loaded { index } => index.clone(),
+                };
+
+                index
+                    .read_with(&cx, |index, cx| {
+                        let worktree_id = index.worktree.read(cx).id();
+                        let db_connection = index.db_connection.clone();
+                        let db = index.db;
+                        cx.background_executor().spawn(async move {
                             let txn = db_connection
                                 .read_txn()
                                 .context("failed to create read transaction")?;
@@ -300,10 +320,10 @@ impl ProjectIndex {
                                 }
                             }
                             anyhow::Ok(())
-                        }
-                    }));
-                })
-            }
+                        })
+                    })?
+                    .await
+            }));
         }
         drop(chunks_tx);
 
@@ -357,7 +377,9 @@ impl ProjectIndex {
                 })
                 .await;
 
-            futures::future::try_join_all(worktree_scan_tasks).await?;
+            for scan_task in futures::future::join_all(worktree_scan_tasks).await {
+                scan_task.log_err();
+            }
 
             project.read_with(&cx, |project, cx| {
                 let mut search_results = Vec::with_capacity(results_by_worker.len() * limit);

crates/zed/src/zed.rs 🔗

@@ -1017,6 +1017,7 @@ mod tests {
         let workspace_1 = cx
             .read(|cx| cx.windows()[0].downcast::<Workspace>())
             .unwrap();
+        cx.run_until_parked();
         workspace_1
             .update(cx, |workspace, cx| {
                 assert_eq!(workspace.worktrees(cx).count(), 2);