
- [x] Clean up unused and old icons - [x] Swap SVG for all in-use icons with the redesigned version - [x] Document guidelines Release Notes: - N/A
463 lines
16 KiB
Rust
463 lines
16 KiB
Rust
use crate::{schema::json_schema_for, ui::ToolCallCardHeader};
|
|
use anyhow::{Result, anyhow};
|
|
use assistant_tool::{
|
|
ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
|
|
};
|
|
use editor::Editor;
|
|
use futures::channel::oneshot::{self, Receiver};
|
|
use gpui::{
|
|
AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window,
|
|
};
|
|
use language;
|
|
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
|
|
use project::Project;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fmt::Write;
|
|
use std::{cmp, path::PathBuf, sync::Arc};
|
|
use ui::{Disclosure, Tooltip, prelude::*};
|
|
use util::{ResultExt, paths::PathMatcher};
|
|
use workspace::Workspace;
|
|
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct FindPathToolInput {
|
|
/// The glob to match against every path in the project.
|
|
///
|
|
/// <example>
|
|
/// If the project has the following root directories:
|
|
///
|
|
/// - directory1/a/something.txt
|
|
/// - directory2/a/things.txt
|
|
/// - directory3/a/other.txt
|
|
///
|
|
/// You can get back the first two paths by providing a glob of "*thing*.txt"
|
|
/// </example>
|
|
pub glob: String,
|
|
|
|
/// Optional starting position for paginated results (0-based).
|
|
/// When not provided, starts from the beginning.
|
|
#[serde(default)]
|
|
pub offset: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct FindPathToolOutput {
|
|
glob: String,
|
|
paths: Vec<PathBuf>,
|
|
}
|
|
|
|
const RESULTS_PER_PAGE: usize = 50;
|
|
|
|
pub struct FindPathTool;
|
|
|
|
impl Tool for FindPathTool {
|
|
fn name(&self) -> String {
|
|
"find_path".into()
|
|
}
|
|
|
|
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
|
|
false
|
|
}
|
|
|
|
fn may_perform_edits(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn description(&self) -> String {
|
|
include_str!("./find_path_tool/description.md").into()
|
|
}
|
|
|
|
fn icon(&self) -> IconName {
|
|
IconName::ToolSearch
|
|
}
|
|
|
|
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
|
json_schema_for::<FindPathToolInput>(format)
|
|
}
|
|
|
|
fn ui_text(&self, input: &serde_json::Value) -> String {
|
|
match serde_json::from_value::<FindPathToolInput>(input.clone()) {
|
|
Ok(input) => format!("Find paths matching “`{}`”", input.glob),
|
|
Err(_) => "Search paths".to_string(),
|
|
}
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: serde_json::Value,
|
|
_request: Arc<LanguageModelRequest>,
|
|
project: Entity<Project>,
|
|
_action_log: Entity<ActionLog>,
|
|
_model: Arc<dyn LanguageModel>,
|
|
_window: Option<AnyWindowHandle>,
|
|
cx: &mut App,
|
|
) -> ToolResult {
|
|
let (offset, glob) = match serde_json::from_value::<FindPathToolInput>(input) {
|
|
Ok(input) => (input.offset, input.glob),
|
|
Err(err) => return Task::ready(Err(anyhow!(err))).into(),
|
|
};
|
|
|
|
let (sender, receiver) = oneshot::channel();
|
|
|
|
let card = cx.new(|cx| FindPathToolCard::new(glob.clone(), receiver, cx));
|
|
|
|
let search_paths_task = search_paths(&glob, project, cx);
|
|
|
|
let task = cx.background_spawn(async move {
|
|
let matches = search_paths_task.await?;
|
|
let paginated_matches: &[PathBuf] = &matches[cmp::min(offset, matches.len())
|
|
..cmp::min(offset + RESULTS_PER_PAGE, matches.len())];
|
|
|
|
sender.send(paginated_matches.to_vec()).log_err();
|
|
|
|
if matches.is_empty() {
|
|
Ok("No matches found".to_string().into())
|
|
} else {
|
|
let mut message = format!("Found {} total matches.", matches.len());
|
|
if matches.len() > RESULTS_PER_PAGE {
|
|
write!(
|
|
&mut message,
|
|
"\nShowing results {}-{} (provide 'offset' parameter for more results):",
|
|
offset + 1,
|
|
offset + paginated_matches.len()
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
for mat in matches.iter().skip(offset).take(RESULTS_PER_PAGE) {
|
|
write!(&mut message, "\n{}", mat.display()).unwrap();
|
|
}
|
|
|
|
let output = FindPathToolOutput {
|
|
glob,
|
|
paths: matches,
|
|
};
|
|
|
|
Ok(ToolResultOutput {
|
|
content: ToolResultContent::Text(message),
|
|
output: Some(serde_json::to_value(output)?),
|
|
})
|
|
}
|
|
});
|
|
|
|
ToolResult {
|
|
output: task,
|
|
card: Some(card.into()),
|
|
}
|
|
}
|
|
|
|
fn deserialize_card(
|
|
self: Arc<Self>,
|
|
output: serde_json::Value,
|
|
_project: Entity<Project>,
|
|
_window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Option<assistant_tool::AnyToolCard> {
|
|
let output = serde_json::from_value::<FindPathToolOutput>(output).ok()?;
|
|
let card = cx.new(|_| FindPathToolCard::from_output(output));
|
|
Some(card.into())
|
|
}
|
|
}
|
|
|
|
fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
|
|
let path_matcher = match PathMatcher::new([
|
|
// Sometimes models try to search for "". In this case, return all paths in the project.
|
|
if glob.is_empty() { "*" } else { glob },
|
|
]) {
|
|
Ok(matcher) => matcher,
|
|
Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
|
|
};
|
|
let snapshots: Vec<_> = project
|
|
.read(cx)
|
|
.worktrees(cx)
|
|
.map(|worktree| worktree.read(cx).snapshot())
|
|
.collect();
|
|
|
|
cx.background_spawn(async move {
|
|
Ok(snapshots
|
|
.iter()
|
|
.flat_map(|snapshot| {
|
|
let root_name = PathBuf::from(snapshot.root_name());
|
|
snapshot
|
|
.entries(false, 0)
|
|
.map(move |entry| root_name.join(&entry.path))
|
|
.filter(|path| path_matcher.is_match(&path))
|
|
})
|
|
.collect())
|
|
})
|
|
}
|
|
|
|
struct FindPathToolCard {
|
|
paths: Vec<PathBuf>,
|
|
expanded: bool,
|
|
glob: String,
|
|
_receiver_task: Option<Task<Result<()>>>,
|
|
}
|
|
|
|
impl FindPathToolCard {
|
|
fn new(glob: String, receiver: Receiver<Vec<PathBuf>>, cx: &mut Context<Self>) -> Self {
|
|
let _receiver_task = cx.spawn(async move |this, cx| {
|
|
let paths = receiver.await?;
|
|
|
|
this.update(cx, |this, _cx| {
|
|
this.paths = paths;
|
|
})
|
|
.log_err();
|
|
|
|
Ok(())
|
|
});
|
|
|
|
Self {
|
|
paths: Vec::new(),
|
|
expanded: false,
|
|
glob,
|
|
_receiver_task: Some(_receiver_task),
|
|
}
|
|
}
|
|
|
|
fn from_output(output: FindPathToolOutput) -> Self {
|
|
Self {
|
|
glob: output.glob,
|
|
paths: output.paths,
|
|
expanded: false,
|
|
_receiver_task: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ToolCard for FindPathToolCard {
|
|
fn render(
|
|
&mut self,
|
|
_status: &ToolUseStatus,
|
|
_window: &mut Window,
|
|
workspace: WeakEntity<Workspace>,
|
|
cx: &mut Context<Self>,
|
|
) -> impl IntoElement {
|
|
let matches_label: SharedString = if self.paths.len() == 0 {
|
|
"No matches".into()
|
|
} else if self.paths.len() == 1 {
|
|
"1 match".into()
|
|
} else {
|
|
format!("{} matches", self.paths.len()).into()
|
|
};
|
|
|
|
let content = if !self.paths.is_empty() && self.expanded {
|
|
Some(
|
|
v_flex()
|
|
.relative()
|
|
.ml_1p5()
|
|
.px_1p5()
|
|
.gap_0p5()
|
|
.border_l_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.children(self.paths.iter().enumerate().map(|(index, path)| {
|
|
let path_clone = path.clone();
|
|
let workspace_clone = workspace.clone();
|
|
let button_label = path.to_string_lossy().to_string();
|
|
|
|
Button::new(("path", index), button_label)
|
|
.icon(IconName::ArrowUpRight)
|
|
.icon_size(IconSize::Small)
|
|
.icon_position(IconPosition::End)
|
|
.label_size(LabelSize::Small)
|
|
.color(Color::Muted)
|
|
.tooltip(Tooltip::text("Jump to File"))
|
|
.on_click(move |_, window, cx| {
|
|
workspace_clone
|
|
.update(cx, |workspace, cx| {
|
|
let path = PathBuf::from(&path_clone);
|
|
let Some(project_path) = workspace
|
|
.project()
|
|
.read(cx)
|
|
.find_project_path(&path, cx)
|
|
else {
|
|
return;
|
|
};
|
|
let open_task = workspace.open_path(
|
|
project_path,
|
|
None,
|
|
true,
|
|
window,
|
|
cx,
|
|
);
|
|
window
|
|
.spawn(cx, async move |cx| {
|
|
let item = open_task.await?;
|
|
if let Some(active_editor) =
|
|
item.downcast::<Editor>()
|
|
{
|
|
active_editor
|
|
.update_in(cx, |editor, window, cx| {
|
|
editor.go_to_singleton_buffer_point(
|
|
language::Point::new(0, 0),
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
.log_err();
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
})
|
|
.ok();
|
|
})
|
|
}))
|
|
.into_any(),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
v_flex()
|
|
.mb_2()
|
|
.gap_1()
|
|
.child(
|
|
ToolCallCardHeader::new(IconName::ToolSearch, matches_label)
|
|
.with_code_path(&self.glob)
|
|
.disclosure_slot(
|
|
Disclosure::new("path-search-disclosure", self.expanded)
|
|
.opened_icon(IconName::ChevronUp)
|
|
.closed_icon(IconName::ChevronDown)
|
|
.disabled(self.paths.is_empty())
|
|
.on_click(cx.listener(move |this, _, _, _cx| {
|
|
this.expanded = !this.expanded;
|
|
})),
|
|
),
|
|
)
|
|
.children(content)
|
|
}
|
|
}
|
|
|
|
impl Component for FindPathTool {
|
|
fn scope() -> ComponentScope {
|
|
ComponentScope::Agent
|
|
}
|
|
|
|
fn sort_name() -> &'static str {
|
|
"FindPathTool"
|
|
}
|
|
|
|
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
|
let successful_card = cx.new(|_| FindPathToolCard {
|
|
paths: vec![
|
|
PathBuf::from("src/main.rs"),
|
|
PathBuf::from("src/lib.rs"),
|
|
PathBuf::from("tests/test.rs"),
|
|
],
|
|
expanded: true,
|
|
glob: "*.rs".to_string(),
|
|
_receiver_task: None,
|
|
});
|
|
|
|
let empty_card = cx.new(|_| FindPathToolCard {
|
|
paths: Vec::new(),
|
|
expanded: false,
|
|
glob: "*.nonexistent".to_string(),
|
|
_receiver_task: None,
|
|
});
|
|
|
|
Some(
|
|
v_flex()
|
|
.gap_6()
|
|
.children(vec![example_group(vec![
|
|
single_example(
|
|
"With Paths",
|
|
div()
|
|
.size_full()
|
|
.child(successful_card.update(cx, |tool, cx| {
|
|
tool.render(
|
|
&ToolUseStatus::Finished("".into()),
|
|
window,
|
|
WeakEntity::new_invalid(),
|
|
cx,
|
|
)
|
|
.into_any_element()
|
|
}))
|
|
.into_any_element(),
|
|
),
|
|
single_example(
|
|
"No Paths",
|
|
div()
|
|
.size_full()
|
|
.child(empty_card.update(cx, |tool, cx| {
|
|
tool.render(
|
|
&ToolUseStatus::Finished("".into()),
|
|
window,
|
|
WeakEntity::new_invalid(),
|
|
cx,
|
|
)
|
|
.into_any_element()
|
|
}))
|
|
.into_any_element(),
|
|
),
|
|
])])
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use gpui::TestAppContext;
|
|
use project::{FakeFs, Project};
|
|
use settings::SettingsStore;
|
|
use util::path;
|
|
|
|
#[gpui::test]
|
|
async fn test_find_path_tool(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/root",
|
|
serde_json::json!({
|
|
"apple": {
|
|
"banana": {
|
|
"carrot": "1",
|
|
},
|
|
"bandana": {
|
|
"carbonara": "2",
|
|
},
|
|
"endive": "3"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
|
|
let matches = cx
|
|
.update(|cx| search_paths("root/**/car*", project.clone(), cx))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
matches,
|
|
&[
|
|
PathBuf::from("root/apple/banana/carrot"),
|
|
PathBuf::from("root/apple/bandana/carbonara")
|
|
]
|
|
);
|
|
|
|
let matches = cx
|
|
.update(|cx| search_paths("**/car*", project.clone(), cx))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
matches,
|
|
&[
|
|
PathBuf::from("root/apple/banana/carrot"),
|
|
PathBuf::from("root/apple/bandana/carbonara")
|
|
]
|
|
);
|
|
}
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
language::init(cx);
|
|
Project::init_settings(cx);
|
|
});
|
|
}
|
|
}
|