project_index.rs

  1use anyhow::Result;
  2use assistant_tooling::LanguageModelTool;
  3use gpui::{prelude::*, AnyView, Model, Task};
  4use project::Fs;
  5use schemars::JsonSchema;
  6use semantic_index::{ProjectIndex, Status};
  7use serde::Deserialize;
  8use std::sync::Arc;
  9use ui::{
 10    div, prelude::*, CollapsibleContainer, Color, Icon, IconName, Label, SharedString,
 11    WindowContext,
 12};
 13use util::ResultExt as _;
 14
 15const DEFAULT_SEARCH_LIMIT: usize = 20;
 16
 17#[derive(Clone)]
 18pub struct CodebaseExcerpt {
 19    path: SharedString,
 20    text: SharedString,
 21    score: f32,
 22    element_id: ElementId,
 23    expanded: bool,
 24}
 25
 26// Note: Comments on a `LanguageModelTool::Input` become descriptions on the generated JSON schema as shown to the language model.
 27// Any changes or deletions to the `CodebaseQuery` comments will change model behavior.
 28
 29#[derive(Deserialize, JsonSchema)]
 30pub struct CodebaseQuery {
 31    /// Semantic search query
 32    query: String,
 33    /// Maximum number of results to return, defaults to 20
 34    limit: Option<usize>,
 35}
 36
 37pub struct ProjectIndexView {
 38    input: CodebaseQuery,
 39    output: Result<ProjectIndexOutput>,
 40}
 41
 42impl ProjectIndexView {
 43    fn toggle_expanded(&mut self, element_id: ElementId, cx: &mut ViewContext<Self>) {
 44        if let Ok(output) = &mut self.output {
 45            if let Some(excerpt) = output
 46                .excerpts
 47                .iter_mut()
 48                .find(|excerpt| excerpt.element_id == element_id)
 49            {
 50                excerpt.expanded = !excerpt.expanded;
 51                cx.notify();
 52            }
 53        }
 54    }
 55}
 56
 57impl Render for ProjectIndexView {
 58    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 59        let query = self.input.query.clone();
 60
 61        let result = &self.output;
 62
 63        let output = match result {
 64            Err(err) => {
 65                return div().child(Label::new(format!("Error: {}", err)).color(Color::Error));
 66            }
 67            Ok(output) => output,
 68        };
 69
 70        div()
 71            .v_flex()
 72            .gap_2()
 73            .child(
 74                div()
 75                    .p_2()
 76                    .rounded_md()
 77                    .bg(cx.theme().colors().editor_background)
 78                    .child(
 79                        h_flex()
 80                            .child(Label::new("Query: ").color(Color::Modified))
 81                            .child(Label::new(query).color(Color::Muted)),
 82                    ),
 83            )
 84            .children(output.excerpts.iter().map(|excerpt| {
 85                let element_id = excerpt.element_id.clone();
 86                let expanded = excerpt.expanded;
 87
 88                CollapsibleContainer::new(element_id.clone(), expanded)
 89                    .start_slot(
 90                        h_flex()
 91                            .gap_1()
 92                            .child(Icon::new(IconName::File).color(Color::Muted))
 93                            .child(Label::new(excerpt.path.clone()).color(Color::Muted)),
 94                    )
 95                    .on_click(cx.listener(move |this, _, cx| {
 96                        this.toggle_expanded(element_id.clone(), cx);
 97                    }))
 98                    .child(
 99                        div()
100                            .p_2()
101                            .rounded_md()
102                            .bg(cx.theme().colors().editor_background)
103                            .child(excerpt.text.clone()),
104                    )
105            }))
106    }
107}
108
109pub struct ProjectIndexTool {
110    project_index: Model<ProjectIndex>,
111    fs: Arc<dyn Fs>,
112}
113
114pub struct ProjectIndexOutput {
115    excerpts: Vec<CodebaseExcerpt>,
116    status: Status,
117}
118
119impl ProjectIndexTool {
120    pub fn new(project_index: Model<ProjectIndex>, fs: Arc<dyn Fs>) -> Self {
121        // Listen for project index status and update the ProjectIndexTool directly
122
123        // TODO: setup a better description based on the user's current codebase.
124        Self { project_index, fs }
125    }
126}
127
128impl LanguageModelTool for ProjectIndexTool {
129    type Input = CodebaseQuery;
130    type Output = ProjectIndexOutput;
131    type View = ProjectIndexView;
132
133    fn name(&self) -> String {
134        "query_codebase".to_string()
135    }
136
137    fn description(&self) -> String {
138        "Semantic search against the user's current codebase, returning excerpts related to the query by computing a dot product against embeddings of chunks and an embedding of the query".to_string()
139    }
140
141    fn execute(&self, query: &Self::Input, cx: &mut WindowContext) -> Task<Result<Self::Output>> {
142        let project_index = self.project_index.read(cx);
143
144        let status = project_index.status();
145        let results = project_index.search(
146            query.query.as_str(),
147            query.limit.unwrap_or(DEFAULT_SEARCH_LIMIT),
148            cx,
149        );
150
151        let fs = self.fs.clone();
152
153        cx.spawn(|cx| async move {
154            let results = results.await;
155
156            let excerpts = results.into_iter().map(|result| {
157                let abs_path = result
158                    .worktree
159                    .read_with(&cx, |worktree, _| worktree.abs_path().join(&result.path));
160                let fs = fs.clone();
161
162                async move {
163                    let path = result.path.clone();
164                    let text = fs.load(&abs_path?).await?;
165
166                    let mut start = result.range.start;
167                    let mut end = result.range.end.min(text.len());
168                    while !text.is_char_boundary(start) {
169                        start += 1;
170                    }
171                    while !text.is_char_boundary(end) {
172                        end -= 1;
173                    }
174
175                    anyhow::Ok(CodebaseExcerpt {
176                        element_id: ElementId::Name(nanoid::nanoid!().into()),
177                        expanded: false,
178                        path: path.to_string_lossy().to_string().into(),
179                        text: SharedString::from(text[start..end].to_string()),
180                        score: result.score,
181                    })
182                }
183            });
184
185            let excerpts = futures::future::join_all(excerpts)
186                .await
187                .into_iter()
188                .filter_map(|result| result.log_err())
189                .collect();
190            anyhow::Ok(ProjectIndexOutput { excerpts, status })
191        })
192    }
193
194    fn output_view(
195        _tool_call_id: String,
196        input: Self::Input,
197        output: Result<Self::Output>,
198        cx: &mut WindowContext,
199    ) -> gpui::View<Self::View> {
200        cx.new_view(|_cx| ProjectIndexView { input, output })
201    }
202
203    fn status_view(&self, cx: &mut WindowContext) -> Option<AnyView> {
204        Some(
205            cx.new_view(|cx| ProjectIndexStatusView::new(self.project_index.clone(), cx))
206                .into(),
207        )
208    }
209
210    fn format(_input: &Self::Input, output: &Result<Self::Output>) -> String {
211        match &output {
212            Ok(output) => {
213                let mut body = "Semantic search results:\n".to_string();
214
215                if output.status != Status::Idle {
216                    body.push_str("Still indexing. Results may be incomplete.\n");
217                }
218
219                if output.excerpts.is_empty() {
220                    body.push_str("No results found");
221                    return body;
222                }
223
224                for excerpt in &output.excerpts {
225                    body.push_str("Excerpt from ");
226                    body.push_str(excerpt.path.as_ref());
227                    body.push_str(", score ");
228                    body.push_str(&excerpt.score.to_string());
229                    body.push_str(":\n");
230                    body.push_str("~~~\n");
231                    body.push_str(excerpt.text.as_ref());
232                    body.push_str("~~~\n");
233                }
234                body
235            }
236            Err(err) => format!("Error: {}", err),
237        }
238    }
239}
240
241struct ProjectIndexStatusView {
242    project_index: Model<ProjectIndex>,
243}
244
245impl ProjectIndexStatusView {
246    pub fn new(project_index: Model<ProjectIndex>, cx: &mut ViewContext<Self>) -> Self {
247        cx.subscribe(&project_index, |_this, _, _status: &Status, cx| {
248            cx.notify();
249        })
250        .detach();
251        Self { project_index }
252    }
253}
254
255impl Render for ProjectIndexStatusView {
256    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
257        let status = self.project_index.read(cx).status();
258
259        h_flex().gap_2().map(|element| match status {
260            Status::Idle => element.child(Label::new("Project index ready")),
261            Status::Loading => element.child(Label::new("Project index loading...")),
262            Status::Scanning { remaining_count } => element.child(Label::new(format!(
263                "Project index scanning: {remaining_count} remaining..."
264            ))),
265        })
266    }
267}