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