find_path_tool.rs

  1use crate::{schema::json_schema_for, ui::ToolCallCardHeader};
  2use action_log::ActionLog;
  3use anyhow::{Result, anyhow};
  4use assistant_tool::{
  5    Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
  6};
  7use editor::Editor;
  8use futures::channel::oneshot::{self, Receiver};
  9use gpui::{
 10    AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window,
 11};
 12use language;
 13use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
 14use project::Project;
 15use schemars::JsonSchema;
 16use serde::{Deserialize, Serialize};
 17use std::fmt::Write;
 18use std::{cmp, path::PathBuf, sync::Arc};
 19use ui::{Disclosure, Tooltip, prelude::*};
 20use util::{ResultExt, paths::PathMatcher};
 21use workspace::Workspace;
 22
 23#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 24pub struct FindPathToolInput {
 25    /// The glob to match against every path in the project.
 26    ///
 27    /// <example>
 28    /// If the project has the following root directories:
 29    ///
 30    /// - directory1/a/something.txt
 31    /// - directory2/a/things.txt
 32    /// - directory3/a/other.txt
 33    ///
 34    /// You can get back the first two paths by providing a glob of "*thing*.txt"
 35    /// </example>
 36    pub glob: String,
 37
 38    /// Optional starting position for paginated results (0-based).
 39    /// When not provided, starts from the beginning.
 40    #[serde(default)]
 41    pub offset: usize,
 42}
 43
 44#[derive(Debug, Serialize, Deserialize)]
 45struct FindPathToolOutput {
 46    glob: String,
 47    paths: Vec<PathBuf>,
 48}
 49
 50const RESULTS_PER_PAGE: usize = 50;
 51
 52pub struct FindPathTool;
 53
 54impl Tool for FindPathTool {
 55    fn name(&self) -> String {
 56        "find_path".into()
 57    }
 58
 59    fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
 60        false
 61    }
 62
 63    fn may_perform_edits(&self) -> bool {
 64        false
 65    }
 66
 67    fn description(&self) -> String {
 68        include_str!("./find_path_tool/description.md").into()
 69    }
 70
 71    fn icon(&self) -> IconName {
 72        IconName::ToolSearch
 73    }
 74
 75    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 76        json_schema_for::<FindPathToolInput>(format)
 77    }
 78
 79    fn ui_text(&self, input: &serde_json::Value) -> String {
 80        match serde_json::from_value::<FindPathToolInput>(input.clone()) {
 81            Ok(input) => format!("Find paths matching “`{}`”", input.glob),
 82            Err(_) => "Search paths".to_string(),
 83        }
 84    }
 85
 86    fn run(
 87        self: Arc<Self>,
 88        input: serde_json::Value,
 89        _request: Arc<LanguageModelRequest>,
 90        project: Entity<Project>,
 91        _action_log: Entity<ActionLog>,
 92        _model: Arc<dyn LanguageModel>,
 93        _window: Option<AnyWindowHandle>,
 94        cx: &mut App,
 95    ) -> ToolResult {
 96        let (offset, glob) = match serde_json::from_value::<FindPathToolInput>(input) {
 97            Ok(input) => (input.offset, input.glob),
 98            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 99        };
100
101        let (sender, receiver) = oneshot::channel();
102
103        let card = cx.new(|cx| FindPathToolCard::new(glob.clone(), receiver, cx));
104
105        let search_paths_task = search_paths(&glob, project, cx);
106
107        let task = cx.background_spawn(async move {
108            let matches = search_paths_task.await?;
109            let paginated_matches: &[PathBuf] = &matches[cmp::min(offset, matches.len())
110                ..cmp::min(offset + RESULTS_PER_PAGE, matches.len())];
111
112            sender.send(paginated_matches.to_vec()).log_err();
113
114            if matches.is_empty() {
115                Ok("No matches found".to_string().into())
116            } else {
117                let mut message = format!("Found {} total matches.", matches.len());
118                if matches.len() > RESULTS_PER_PAGE {
119                    write!(
120                        &mut message,
121                        "\nShowing results {}-{} (provide 'offset' parameter for more results):",
122                        offset + 1,
123                        offset + paginated_matches.len()
124                    )
125                    .unwrap();
126                }
127
128                for mat in matches.iter().skip(offset).take(RESULTS_PER_PAGE) {
129                    write!(&mut message, "\n{}", mat.display()).unwrap();
130                }
131
132                let output = FindPathToolOutput {
133                    glob,
134                    paths: matches,
135                };
136
137                Ok(ToolResultOutput {
138                    content: ToolResultContent::Text(message),
139                    output: Some(serde_json::to_value(output)?),
140                })
141            }
142        });
143
144        ToolResult {
145            output: task,
146            card: Some(card.into()),
147        }
148    }
149
150    fn deserialize_card(
151        self: Arc<Self>,
152        output: serde_json::Value,
153        _project: Entity<Project>,
154        _window: &mut Window,
155        cx: &mut App,
156    ) -> Option<assistant_tool::AnyToolCard> {
157        let output = serde_json::from_value::<FindPathToolOutput>(output).ok()?;
158        let card = cx.new(|_| FindPathToolCard::from_output(output));
159        Some(card.into())
160    }
161}
162
163fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
164    let path_matcher = match PathMatcher::new([
165        // Sometimes models try to search for "". In this case, return all paths in the project.
166        if glob.is_empty() { "*" } else { glob },
167    ]) {
168        Ok(matcher) => matcher,
169        Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
170    };
171    let snapshots: Vec<_> = project
172        .read(cx)
173        .worktrees(cx)
174        .map(|worktree| worktree.read(cx).snapshot())
175        .collect();
176
177    cx.background_spawn(async move {
178        Ok(snapshots
179            .iter()
180            .flat_map(|snapshot| {
181                let root_name = PathBuf::from(snapshot.root_name());
182                snapshot
183                    .entries(false, 0)
184                    .map(move |entry| root_name.join(&entry.path))
185                    .filter(|path| path_matcher.is_match(&path))
186            })
187            .collect())
188    })
189}
190
191struct FindPathToolCard {
192    paths: Vec<PathBuf>,
193    expanded: bool,
194    glob: String,
195    _receiver_task: Option<Task<Result<()>>>,
196}
197
198impl FindPathToolCard {
199    fn new(glob: String, receiver: Receiver<Vec<PathBuf>>, cx: &mut Context<Self>) -> Self {
200        let _receiver_task = cx.spawn(async move |this, cx| {
201            let paths = receiver.await?;
202
203            this.update(cx, |this, _cx| {
204                this.paths = paths;
205            })
206            .log_err();
207
208            Ok(())
209        });
210
211        Self {
212            paths: Vec::new(),
213            expanded: false,
214            glob,
215            _receiver_task: Some(_receiver_task),
216        }
217    }
218
219    fn from_output(output: FindPathToolOutput) -> Self {
220        Self {
221            glob: output.glob,
222            paths: output.paths,
223            expanded: false,
224            _receiver_task: None,
225        }
226    }
227}
228
229impl ToolCard for FindPathToolCard {
230    fn render(
231        &mut self,
232        _status: &ToolUseStatus,
233        _window: &mut Window,
234        workspace: WeakEntity<Workspace>,
235        cx: &mut Context<Self>,
236    ) -> impl IntoElement {
237        let matches_label: SharedString = if self.paths.is_empty() {
238            "No matches".into()
239        } else if self.paths.len() == 1 {
240            "1 match".into()
241        } else {
242            format!("{} matches", self.paths.len()).into()
243        };
244
245        let content = if !self.paths.is_empty() && self.expanded {
246            Some(
247                v_flex()
248                    .relative()
249                    .ml_1p5()
250                    .px_1p5()
251                    .gap_0p5()
252                    .border_l_1()
253                    .border_color(cx.theme().colors().border_variant)
254                    .children(self.paths.iter().enumerate().map(|(index, path)| {
255                        let path_clone = path.clone();
256                        let workspace_clone = workspace.clone();
257                        let button_label = path.to_string_lossy().to_string();
258
259                        Button::new(("path", index), button_label)
260                            .icon(IconName::ArrowUpRight)
261                            .icon_size(IconSize::Small)
262                            .icon_position(IconPosition::End)
263                            .label_size(LabelSize::Small)
264                            .color(Color::Muted)
265                            .tooltip(Tooltip::text("Jump to File"))
266                            .on_click(move |_, window, cx| {
267                                workspace_clone
268                                    .update(cx, |workspace, cx| {
269                                        let path = PathBuf::from(&path_clone);
270                                        let Some(project_path) = workspace
271                                            .project()
272                                            .read(cx)
273                                            .find_project_path(&path, cx)
274                                        else {
275                                            return;
276                                        };
277                                        let open_task = workspace.open_path(
278                                            project_path,
279                                            None,
280                                            true,
281                                            window,
282                                            cx,
283                                        );
284                                        window
285                                            .spawn(cx, async move |cx| {
286                                                let item = open_task.await?;
287                                                if let Some(active_editor) =
288                                                    item.downcast::<Editor>()
289                                                {
290                                                    active_editor
291                                                        .update_in(cx, |editor, window, cx| {
292                                                            editor.go_to_singleton_buffer_point(
293                                                                language::Point::new(0, 0),
294                                                                window,
295                                                                cx,
296                                                            );
297                                                        })
298                                                        .log_err();
299                                                }
300                                                anyhow::Ok(())
301                                            })
302                                            .detach_and_log_err(cx);
303                                    })
304                                    .ok();
305                            })
306                    }))
307                    .into_any(),
308            )
309        } else {
310            None
311        };
312
313        v_flex()
314            .mb_2()
315            .gap_1()
316            .child(
317                ToolCallCardHeader::new(IconName::ToolSearch, matches_label)
318                    .with_code_path(&self.glob)
319                    .disclosure_slot(
320                        Disclosure::new("path-search-disclosure", self.expanded)
321                            .opened_icon(IconName::ChevronUp)
322                            .closed_icon(IconName::ChevronDown)
323                            .disabled(self.paths.is_empty())
324                            .on_click(cx.listener(move |this, _, _, _cx| {
325                                this.expanded = !this.expanded;
326                            })),
327                    ),
328            )
329            .children(content)
330    }
331}
332
333impl Component for FindPathTool {
334    fn scope() -> ComponentScope {
335        ComponentScope::Agent
336    }
337
338    fn sort_name() -> &'static str {
339        "FindPathTool"
340    }
341
342    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
343        let successful_card = cx.new(|_| FindPathToolCard {
344            paths: vec![
345                PathBuf::from("src/main.rs"),
346                PathBuf::from("src/lib.rs"),
347                PathBuf::from("tests/test.rs"),
348            ],
349            expanded: true,
350            glob: "*.rs".to_string(),
351            _receiver_task: None,
352        });
353
354        let empty_card = cx.new(|_| FindPathToolCard {
355            paths: Vec::new(),
356            expanded: false,
357            glob: "*.nonexistent".to_string(),
358            _receiver_task: None,
359        });
360
361        Some(
362            v_flex()
363                .gap_6()
364                .children(vec![example_group(vec![
365                    single_example(
366                        "With Paths",
367                        div()
368                            .size_full()
369                            .child(successful_card.update(cx, |tool, cx| {
370                                tool.render(
371                                    &ToolUseStatus::Finished("".into()),
372                                    window,
373                                    WeakEntity::new_invalid(),
374                                    cx,
375                                )
376                                .into_any_element()
377                            }))
378                            .into_any_element(),
379                    ),
380                    single_example(
381                        "No Paths",
382                        div()
383                            .size_full()
384                            .child(empty_card.update(cx, |tool, cx| {
385                                tool.render(
386                                    &ToolUseStatus::Finished("".into()),
387                                    window,
388                                    WeakEntity::new_invalid(),
389                                    cx,
390                                )
391                                .into_any_element()
392                            }))
393                            .into_any_element(),
394                    ),
395                ])])
396                .into_any_element(),
397        )
398    }
399}
400
401#[cfg(test)]
402mod test {
403    use super::*;
404    use gpui::TestAppContext;
405    use project::{FakeFs, Project};
406    use settings::SettingsStore;
407    use util::path;
408
409    #[gpui::test]
410    async fn test_find_path_tool(cx: &mut TestAppContext) {
411        init_test(cx);
412
413        let fs = FakeFs::new(cx.executor());
414        fs.insert_tree(
415            "/root",
416            serde_json::json!({
417                "apple": {
418                    "banana": {
419                        "carrot": "1",
420                    },
421                    "bandana": {
422                        "carbonara": "2",
423                    },
424                    "endive": "3"
425                }
426            }),
427        )
428        .await;
429        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
430
431        let matches = cx
432            .update(|cx| search_paths("root/**/car*", project.clone(), cx))
433            .await
434            .unwrap();
435        assert_eq!(
436            matches,
437            &[
438                PathBuf::from(path!("root/apple/banana/carrot")),
439                PathBuf::from(path!("root/apple/bandana/carbonara"))
440            ]
441        );
442
443        let matches = cx
444            .update(|cx| search_paths("**/car*", project.clone(), cx))
445            .await
446            .unwrap();
447        assert_eq!(
448            matches,
449            &[
450                PathBuf::from(path!("root/apple/banana/carrot")),
451                PathBuf::from(path!("root/apple/bandana/carbonara"))
452            ]
453        );
454    }
455
456    fn init_test(cx: &mut TestAppContext) {
457        cx.update(|cx| {
458            let settings_store = SettingsStore::test(cx);
459            cx.set_global(settings_store);
460            language::init(cx);
461            Project::init_settings(cx);
462        });
463    }
464}