find_path_tool.rs

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