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}