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}