diff --git a/Cargo.lock b/Cargo.lock
index 634bacd0f3..f0d21381fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -210,6 +210,7 @@ dependencies = [
"language_models",
"log",
"lsp",
+ "open",
"paths",
"portable-pty",
"pretty_assertions",
@@ -223,6 +224,7 @@ dependencies = [
"settings",
"smol",
"task",
+ "tempfile",
"terminal",
"theme",
"ui",
diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml
index 65452f60fc..a288ff30b2 100644
--- a/crates/agent2/Cargo.toml
+++ b/crates/agent2/Cargo.toml
@@ -32,6 +32,7 @@ language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
+open.workspace = true
paths.workspace = true
portable-pty.workspace = true
project.workspace = true
@@ -67,6 +68,7 @@ pretty_assertions.workspace = true
project = { workspace = true, "features" = ["test-support"] }
reqwest_client.workspace = true
settings = { workspace = true, "features" = ["test-support"] }
+tempfile.workspace = true
terminal = { workspace = true, "features" = ["test-support"] }
theme = { workspace = true, "features" = ["test-support"] }
worktree = { workspace = true, "features" = ["test-support"] }
diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs
index edb79003b4..398ea6ad50 100644
--- a/crates/agent2/src/agent.rs
+++ b/crates/agent2/src/agent.rs
@@ -1,6 +1,7 @@
use crate::{AgentResponseEvent, Thread, templates::Templates};
use crate::{
- EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
+ CopyPathTool, CreateDirectoryTool, EditFileTool, FindPathTool, ListDirectoryTool, MovePathTool,
+ OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
};
use acp_thread::ModelSelector;
use agent_client_protocol as acp;
@@ -416,6 +417,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
let thread = cx.new(|cx| {
let mut thread = Thread::new(project.clone(), agent.project_context.clone(), action_log.clone(), agent.templates.clone(), default_model);
+ thread.add_tool(CreateDirectoryTool::new(project.clone()));
+ thread.add_tool(CopyPathTool::new(project.clone()));
+ thread.add_tool(MovePathTool::new(project.clone()));
+ thread.add_tool(ListDirectoryTool::new(project.clone()));
+ thread.add_tool(OpenTool::new(project.clone()));
thread.add_tool(ThinkingTool);
thread.add_tool(FindPathTool::new(project.clone()));
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
diff --git a/crates/agent2/src/tools.rs b/crates/agent2/src/tools.rs
index df4a7a9580..5c3920fcbb 100644
--- a/crates/agent2/src/tools.rs
+++ b/crates/agent2/src/tools.rs
@@ -1,11 +1,23 @@
+mod copy_path_tool;
+mod create_directory_tool;
+mod delete_path_tool;
mod edit_file_tool;
mod find_path_tool;
+mod list_directory_tool;
+mod move_path_tool;
+mod open_tool;
mod read_file_tool;
mod terminal_tool;
mod thinking_tool;
+pub use copy_path_tool::*;
+pub use create_directory_tool::*;
+pub use delete_path_tool::*;
pub use edit_file_tool::*;
pub use find_path_tool::*;
+pub use list_directory_tool::*;
+pub use move_path_tool::*;
+pub use open_tool::*;
pub use read_file_tool::*;
pub use terminal_tool::*;
pub use thinking_tool::*;
diff --git a/crates/agent2/src/tools/copy_path_tool.rs b/crates/agent2/src/tools/copy_path_tool.rs
new file mode 100644
index 0000000000..f973b86990
--- /dev/null
+++ b/crates/agent2/src/tools/copy_path_tool.rs
@@ -0,0 +1,118 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use util::markdown::MarkdownInlineCode;
+
+/// Copies a file or directory in the project, and returns confirmation that the
+/// copy succeeded.
+///
+/// Directory contents will be copied recursively (like `cp -r`).
+///
+/// This tool should be used when it's desirable to create a copy of a file or
+/// directory without modifying the original. It's much more efficient than
+/// doing this by separately reading and then writing the file or directory's
+/// contents, so this tool should be preferred over that approach whenever
+/// copying is the goal.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct CopyPathToolInput {
+ /// The source path of the file or directory to copy.
+ /// If a directory is specified, its contents will be copied recursively (like `cp -r`).
+ ///
+ ///
+ /// If the project has the following files:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can copy the first file by providing a source_path of "directory1/a/something.txt"
+ ///
+ pub source_path: String,
+
+ /// The destination path where the file or directory should be copied to.
+ ///
+ ///
+ /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt",
+ /// provide a destination_path of "directory2/b/copy.txt"
+ ///
+ pub destination_path: String,
+}
+
+pub struct CopyPathTool {
+ project: Entity,
+}
+
+impl CopyPathTool {
+ pub fn new(project: Entity) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for CopyPathTool {
+ type Input = CopyPathToolInput;
+ type Output = String;
+
+ fn name(&self) -> SharedString {
+ "copy_path".into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Move
+ }
+
+ fn initial_title(&self, input: Result) -> ui::SharedString {
+ if let Ok(input) = input {
+ let src = MarkdownInlineCode(&input.source_path);
+ let dest = MarkdownInlineCode(&input.destination_path);
+ format!("Copy {src} to {dest}").into()
+ } else {
+ "Copy path".into()
+ }
+ }
+
+ fn run(
+ self: Arc,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task> {
+ let copy_task = self.project.update(cx, |project, cx| {
+ match project
+ .find_project_path(&input.source_path, cx)
+ .and_then(|project_path| project.entry_for_path(&project_path, cx))
+ {
+ Some(entity) => match project.find_project_path(&input.destination_path, cx) {
+ Some(project_path) => {
+ project.copy_entry(entity.id, None, project_path.path, cx)
+ }
+ None => Task::ready(Err(anyhow!(
+ "Destination path {} was outside the project.",
+ input.destination_path
+ ))),
+ },
+ None => Task::ready(Err(anyhow!(
+ "Source path {} was not found in the project.",
+ input.source_path
+ ))),
+ }
+ });
+
+ cx.background_spawn(async move {
+ let _ = copy_task.await.with_context(|| {
+ format!(
+ "Copying {} to {}",
+ input.source_path, input.destination_path
+ )
+ })?;
+ Ok(format!(
+ "Copied {} to {}",
+ input.source_path, input.destination_path
+ ))
+ })
+ }
+}
diff --git a/crates/agent2/src/tools/create_directory_tool.rs b/crates/agent2/src/tools/create_directory_tool.rs
new file mode 100644
index 0000000000..c173c5ae67
--- /dev/null
+++ b/crates/agent2/src/tools/create_directory_tool.rs
@@ -0,0 +1,89 @@
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use gpui::{App, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use util::markdown::MarkdownInlineCode;
+
+use crate::{AgentTool, ToolCallEventStream};
+
+/// Creates a new directory at the specified path within the project. Returns
+/// confirmation that the directory was created.
+///
+/// This tool creates a directory and all necessary parent directories (similar
+/// to `mkdir -p`). It should be used whenever you need to create new
+/// directories within the project.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct CreateDirectoryToolInput {
+ /// The path of the new directory.
+ ///
+ ///
+ /// If the project has the following structure:
+ ///
+ /// - directory1/
+ /// - directory2/
+ ///
+ /// You can create a new directory by providing a path of "directory1/new_directory"
+ ///
+ pub path: String,
+}
+
+pub struct CreateDirectoryTool {
+ project: Entity,
+}
+
+impl CreateDirectoryTool {
+ pub fn new(project: Entity) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for CreateDirectoryTool {
+ type Input = CreateDirectoryToolInput;
+ type Output = String;
+
+ fn name(&self) -> SharedString {
+ "create_directory".into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Read
+ }
+
+ fn initial_title(&self, input: Result) -> SharedString {
+ if let Ok(input) = input {
+ format!("Create directory {}", MarkdownInlineCode(&input.path)).into()
+ } else {
+ "Create directory".into()
+ }
+ }
+
+ fn run(
+ self: Arc,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task> {
+ let project_path = match self.project.read(cx).find_project_path(&input.path, cx) {
+ Some(project_path) => project_path,
+ None => {
+ return Task::ready(Err(anyhow!("Path to create was outside the project")));
+ }
+ };
+ let destination_path: Arc = input.path.as_str().into();
+
+ let create_entry = self.project.update(cx, |project, cx| {
+ project.create_entry(project_path.clone(), true, cx)
+ });
+
+ cx.spawn(async move |_cx| {
+ create_entry
+ .await
+ .with_context(|| format!("Creating directory {destination_path}"))?;
+
+ Ok(format!("Created directory {destination_path}"))
+ })
+ }
+}
diff --git a/crates/agent2/src/tools/delete_path_tool.rs b/crates/agent2/src/tools/delete_path_tool.rs
new file mode 100644
index 0000000000..e013b3a3e7
--- /dev/null
+++ b/crates/agent2/src/tools/delete_path_tool.rs
@@ -0,0 +1,137 @@
+use crate::{AgentTool, ToolCallEventStream};
+use action_log::ActionLog;
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use futures::{SinkExt, StreamExt, channel::mpsc};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::{Project, ProjectPath};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+
+/// Deletes the file or directory (and the directory's contents, recursively) at
+/// the specified path in the project, and returns confirmation of the deletion.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct DeletePathToolInput {
+ /// The path of the file or directory to delete.
+ ///
+ ///
+ /// If the project has the following files:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can delete the first file by providing a path of "directory1/a/something.txt"
+ ///
+ pub path: String,
+}
+
+pub struct DeletePathTool {
+ project: Entity,
+ action_log: Entity,
+}
+
+impl DeletePathTool {
+ pub fn new(project: Entity, action_log: Entity) -> Self {
+ Self {
+ project,
+ action_log,
+ }
+ }
+}
+
+impl AgentTool for DeletePathTool {
+ type Input = DeletePathToolInput;
+ type Output = String;
+
+ fn name(&self) -> SharedString {
+ "delete_path".into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Delete
+ }
+
+ fn initial_title(&self, input: Result) -> SharedString {
+ if let Ok(input) = input {
+ format!("Delete “`{}`”", input.path).into()
+ } else {
+ "Delete path".into()
+ }
+ }
+
+ fn run(
+ self: Arc,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task> {
+ let path = input.path;
+ let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else {
+ return Task::ready(Err(anyhow!(
+ "Couldn't delete {path} because that path isn't in this project."
+ )));
+ };
+
+ let Some(worktree) = self
+ .project
+ .read(cx)
+ .worktree_for_id(project_path.worktree_id, cx)
+ else {
+ return Task::ready(Err(anyhow!(
+ "Couldn't delete {path} because that path isn't in this project."
+ )));
+ };
+
+ let worktree_snapshot = worktree.read(cx).snapshot();
+ let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
+ cx.background_spawn({
+ let project_path = project_path.clone();
+ async move {
+ for entry in
+ worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
+ {
+ if !entry.path.starts_with(&project_path.path) {
+ break;
+ }
+ paths_tx
+ .send(ProjectPath {
+ worktree_id: project_path.worktree_id,
+ path: entry.path.clone(),
+ })
+ .await?;
+ }
+ anyhow::Ok(())
+ }
+ })
+ .detach();
+
+ let project = self.project.clone();
+ let action_log = self.action_log.clone();
+ cx.spawn(async move |cx| {
+ while let Some(path) = paths_rx.next().await {
+ if let Ok(buffer) = project
+ .update(cx, |project, cx| project.open_buffer(path, cx))?
+ .await
+ {
+ action_log.update(cx, |action_log, cx| {
+ action_log.will_delete_buffer(buffer.clone(), cx)
+ })?;
+ }
+ }
+
+ let deletion_task = project
+ .update(cx, |project, cx| {
+ project.delete_file(project_path, false, cx)
+ })?
+ .with_context(|| {
+ format!("Couldn't delete {path} because that path isn't in this project.")
+ })?;
+ deletion_task
+ .await
+ .with_context(|| format!("Deleting {path}"))?;
+ Ok(format!("Deleted {path}"))
+ })
+ }
+}
diff --git a/crates/agent2/src/tools/list_directory_tool.rs b/crates/agent2/src/tools/list_directory_tool.rs
new file mode 100644
index 0000000000..61f21d8f95
--- /dev/null
+++ b/crates/agent2/src/tools/list_directory_tool.rs
@@ -0,0 +1,664 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Result, anyhow};
+use gpui::{App, Entity, SharedString, Task};
+use project::{Project, WorktreeSettings};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::fmt::Write;
+use std::{path::Path, sync::Arc};
+use util::markdown::MarkdownInlineCode;
+
+/// Lists files and directories in a given path. Prefer the `grep` or
+/// `find_path` tools when searching the codebase.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct ListDirectoryToolInput {
+ /// The fully-qualified path of the directory to list in the project.
+ ///
+ /// This path should never be absolute, and the first component
+ /// of the path should always be a root directory in a project.
+ ///
+ ///
+ /// If the project has the following root directories:
+ ///
+ /// - directory1
+ /// - directory2
+ ///
+ /// You can list the contents of `directory1` by using the path `directory1`.
+ ///
+ ///
+ ///
+ /// If the project has the following root directories:
+ ///
+ /// - foo
+ /// - bar
+ ///
+ /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
+ ///
+ pub path: String,
+}
+
+pub struct ListDirectoryTool {
+ project: Entity,
+}
+
+impl ListDirectoryTool {
+ pub fn new(project: Entity) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for ListDirectoryTool {
+ type Input = ListDirectoryToolInput;
+ type Output = String;
+
+ fn name(&self) -> SharedString {
+ "list_directory".into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Read
+ }
+
+ fn initial_title(&self, input: Result) -> SharedString {
+ if let Ok(input) = input {
+ let path = MarkdownInlineCode(&input.path);
+ format!("List the {path} directory's contents").into()
+ } else {
+ "List directory".into()
+ }
+ }
+
+ fn run(
+ self: Arc,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task> {
+ // Sometimes models will return these even though we tell it to give a path and not a glob.
+ // When this happens, just list the root worktree directories.
+ if matches!(input.path.as_str(), "." | "" | "./" | "*") {
+ let output = self
+ .project
+ .read(cx)
+ .worktrees(cx)
+ .filter_map(|worktree| {
+ worktree.read(cx).root_entry().and_then(|entry| {
+ if entry.is_dir() {
+ entry.path.to_str()
+ } else {
+ None
+ }
+ })
+ })
+ .collect::>()
+ .join("\n");
+
+ return Task::ready(Ok(output));
+ }
+
+ let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
+ return Task::ready(Err(anyhow!("Path {} not found in project", input.path)));
+ };
+ let Some(worktree) = self
+ .project
+ .read(cx)
+ .worktree_for_id(project_path.worktree_id, cx)
+ else {
+ return Task::ready(Err(anyhow!("Worktree not found")));
+ };
+
+ // Check if the directory whose contents we're listing is itself excluded or private
+ let global_settings = WorktreeSettings::get_global(cx);
+ if global_settings.is_path_excluded(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
+ &input.path
+ )));
+ }
+
+ if global_settings.is_path_private(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's global `private_files` setting: {}",
+ &input.path
+ )));
+ }
+
+ let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+ if worktree_settings.is_path_excluded(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
+ &input.path
+ )));
+ }
+
+ if worktree_settings.is_path_private(&project_path.path) {
+ return Task::ready(Err(anyhow!(
+ "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
+ &input.path
+ )));
+ }
+
+ let worktree_snapshot = worktree.read(cx).snapshot();
+ let worktree_root_name = worktree.read(cx).root_name().to_string();
+
+ let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
+ return Task::ready(Err(anyhow!("Path not found: {}", input.path)));
+ };
+
+ if !entry.is_dir() {
+ return Task::ready(Err(anyhow!("{} is not a directory.", input.path)));
+ }
+ let worktree_snapshot = worktree.read(cx).snapshot();
+
+ let mut folders = Vec::new();
+ let mut files = Vec::new();
+
+ for entry in worktree_snapshot.child_entries(&project_path.path) {
+ // Skip private and excluded files and directories
+ if global_settings.is_path_private(&entry.path)
+ || global_settings.is_path_excluded(&entry.path)
+ {
+ continue;
+ }
+
+ if self
+ .project
+ .read(cx)
+ .find_project_path(&entry.path, cx)
+ .map(|project_path| {
+ let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+
+ worktree_settings.is_path_excluded(&project_path.path)
+ || worktree_settings.is_path_private(&project_path.path)
+ })
+ .unwrap_or(false)
+ {
+ continue;
+ }
+
+ let full_path = Path::new(&worktree_root_name)
+ .join(&entry.path)
+ .display()
+ .to_string();
+ if entry.is_dir() {
+ folders.push(full_path);
+ } else {
+ files.push(full_path);
+ }
+ }
+
+ let mut output = String::new();
+
+ if !folders.is_empty() {
+ writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
+ }
+
+ if !files.is_empty() {
+ writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
+ }
+
+ if output.is_empty() {
+ writeln!(output, "{} is empty.", input.path).unwrap();
+ }
+
+ Task::ready(Ok(output))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::{TestAppContext, UpdateGlobal};
+ use indoc::indoc;
+ use project::{FakeFs, Project, WorktreeSettings};
+ use serde_json::json;
+ use settings::SettingsStore;
+ use util::path;
+
+ fn platform_paths(path_str: &str) -> String {
+ if cfg!(target_os = "windows") {
+ path_str.replace("/", "\\")
+ } else {
+ path_str.to_string()
+ }
+ }
+
+ 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);
+ });
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "src": {
+ "main.rs": "fn main() {}",
+ "lib.rs": "pub fn hello() {}",
+ "models": {
+ "user.rs": "struct User {}",
+ "post.rs": "struct Post {}"
+ },
+ "utils": {
+ "helper.rs": "pub fn help() {}"
+ }
+ },
+ "tests": {
+ "integration_test.rs": "#[test] fn test() {}"
+ },
+ "README.md": "# Project",
+ "Cargo.toml": "[package]"
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Test listing root directory
+ let input = ListDirectoryToolInput {
+ path: "project".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert_eq!(
+ output,
+ platform_paths(indoc! {"
+ # Folders:
+ project/src
+ project/tests
+
+ # Files:
+ project/Cargo.toml
+ project/README.md
+ "})
+ );
+
+ // Test listing src directory
+ let input = ListDirectoryToolInput {
+ path: "project/src".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert_eq!(
+ output,
+ platform_paths(indoc! {"
+ # Folders:
+ project/src/models
+ project/src/utils
+
+ # Files:
+ project/src/lib.rs
+ project/src/main.rs
+ "})
+ );
+
+ // Test listing directory with only files
+ let input = ListDirectoryToolInput {
+ path: "project/tests".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(!output.contains("# Folders:"));
+ assert!(output.contains("# Files:"));
+ assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "empty_dir": {}
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ let input = ListDirectoryToolInput {
+ path: "project/empty_dir".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert_eq!(output, "project/empty_dir is empty.\n");
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "file.txt": "content"
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Test non-existent path
+ let input = ListDirectoryToolInput {
+ path: "project/nonexistent".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(output.unwrap_err().to_string().contains("Path not found"));
+
+ // Test trying to list a file instead of directory
+ let input = ListDirectoryToolInput {
+ path: "project/file.txt".into(),
+ };
+ let output = cx
+ .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(
+ output
+ .unwrap_err()
+ .to_string()
+ .contains("is not a directory")
+ );
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_security(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "normal_dir": {
+ "file1.txt": "content",
+ "file2.txt": "content"
+ },
+ ".mysecrets": "SECRET_KEY=abc123",
+ ".secretdir": {
+ "config": "special configuration",
+ "secret.txt": "secret content"
+ },
+ ".mymetadata": "custom metadata",
+ "visible_dir": {
+ "normal.txt": "normal content",
+ "special.privatekey": "private key content",
+ "data.mysensitive": "sensitive data",
+ ".hidden_subdir": {
+ "hidden_file.txt": "hidden content"
+ }
+ }
+ }),
+ )
+ .await;
+
+ // Configure settings explicitly
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::(cx, |settings| {
+ settings.file_scan_exclusions = Some(vec![
+ "**/.secretdir".to_string(),
+ "**/.mymetadata".to_string(),
+ "**/.hidden_subdir".to_string(),
+ ]);
+ settings.private_files = Some(vec![
+ "**/.mysecrets".to_string(),
+ "**/*.privatekey".to_string(),
+ "**/*.mysensitive".to_string(),
+ ]);
+ });
+ });
+ });
+
+ let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Listing root directory should exclude private and excluded files
+ let input = ListDirectoryToolInput {
+ path: "project".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+
+ // Should include normal directories
+ assert!(output.contains("normal_dir"), "Should list normal_dir");
+ assert!(output.contains("visible_dir"), "Should list visible_dir");
+
+ // Should NOT include excluded or private files
+ assert!(
+ !output.contains(".secretdir"),
+ "Should not list .secretdir (file_scan_exclusions)"
+ );
+ assert!(
+ !output.contains(".mymetadata"),
+ "Should not list .mymetadata (file_scan_exclusions)"
+ );
+ assert!(
+ !output.contains(".mysecrets"),
+ "Should not list .mysecrets (private_files)"
+ );
+
+ // Trying to list an excluded directory should fail
+ let input = ListDirectoryToolInput {
+ path: "project/.secretdir".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(
+ output
+ .unwrap_err()
+ .to_string()
+ .contains("file_scan_exclusions"),
+ "Error should mention file_scan_exclusions"
+ );
+
+ // Listing a directory should exclude private files within it
+ let input = ListDirectoryToolInput {
+ path: "project/visible_dir".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+
+ // Should include normal files
+ assert!(output.contains("normal.txt"), "Should list normal.txt");
+
+ // Should NOT include private files
+ assert!(
+ !output.contains("privatekey"),
+ "Should not list .privatekey files (private_files)"
+ );
+ assert!(
+ !output.contains("mysensitive"),
+ "Should not list .mysensitive files (private_files)"
+ );
+
+ // Should NOT include subdirectories that match exclusions
+ assert!(
+ !output.contains(".hidden_subdir"),
+ "Should not list .hidden_subdir (file_scan_exclusions)"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+
+ // Create first worktree with its own private files
+ fs.insert_tree(
+ path!("/worktree1"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/fixture.*"],
+ "private_files": ["**/secret.rs", "**/config.toml"]
+ }"#
+ },
+ "src": {
+ "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
+ "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
+ "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
+ },
+ "tests": {
+ "test.rs": "mod tests { fn test_it() {} }",
+ "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
+ }
+ }),
+ )
+ .await;
+
+ // Create second worktree with different private files
+ fs.insert_tree(
+ path!("/worktree2"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{
+ "file_scan_exclusions": ["**/internal.*"],
+ "private_files": ["**/private.js", "**/data.json"]
+ }"#
+ },
+ "lib": {
+ "public.js": "export function greet() { return 'Hello from worktree2'; }",
+ "private.js": "const SECRET_TOKEN = \"private_token_2\";",
+ "data.json": "{\"api_key\": \"json_secret_key\"}"
+ },
+ "docs": {
+ "README.md": "# Public Documentation",
+ "internal.md": "# Internal Secrets and Configuration"
+ }
+ }),
+ )
+ .await;
+
+ // Set global settings
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings::(cx, |settings| {
+ settings.file_scan_exclusions =
+ Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+ settings.private_files = Some(vec!["**/.env".to_string()]);
+ });
+ });
+ });
+
+ let project = Project::test(
+ fs.clone(),
+ [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+ cx,
+ )
+ .await;
+
+ // Wait for worktrees to be fully scanned
+ cx.executor().run_until_parked();
+
+ let tool = Arc::new(ListDirectoryTool::new(project));
+
+ // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree1/src".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("main.rs"), "Should list main.rs");
+ assert!(
+ !output.contains("secret.rs"),
+ "Should not list secret.rs (local private_files)"
+ );
+ assert!(
+ !output.contains("config.toml"),
+ "Should not list config.toml (local private_files)"
+ );
+
+ // Test listing worktree1/tests - should exclude fixture.sql based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree1/tests".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("test.rs"), "Should list test.rs");
+ assert!(
+ !output.contains("fixture.sql"),
+ "Should not list fixture.sql (local file_scan_exclusions)"
+ );
+
+ // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree2/lib".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("public.js"), "Should list public.js");
+ assert!(
+ !output.contains("private.js"),
+ "Should not list private.js (local private_files)"
+ );
+ assert!(
+ !output.contains("data.json"),
+ "Should not list data.json (local private_files)"
+ );
+
+ // Test listing worktree2/docs - should exclude internal.md based on local settings
+ let input = ListDirectoryToolInput {
+ path: "worktree2/docs".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await
+ .unwrap();
+ assert!(output.contains("README.md"), "Should list README.md");
+ assert!(
+ !output.contains("internal.md"),
+ "Should not list internal.md (local file_scan_exclusions)"
+ );
+
+ // Test trying to list an excluded directory directly
+ let input = ListDirectoryToolInput {
+ path: "worktree1/src/secret.rs".into(),
+ };
+ let output = cx
+ .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
+ .await;
+ assert!(
+ output
+ .unwrap_err()
+ .to_string()
+ .contains("Cannot list directory"),
+ );
+ }
+}
diff --git a/crates/agent2/src/tools/move_path_tool.rs b/crates/agent2/src/tools/move_path_tool.rs
new file mode 100644
index 0000000000..f8d5d0d176
--- /dev/null
+++ b/crates/agent2/src/tools/move_path_tool.rs
@@ -0,0 +1,123 @@
+use crate::{AgentTool, ToolCallEventStream};
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result, anyhow};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{path::Path, sync::Arc};
+use util::markdown::MarkdownInlineCode;
+
+/// Moves or rename a file or directory in the project, and returns confirmation
+/// that the move succeeded.
+///
+/// If the source and destination directories are the same, but the filename is
+/// different, this performs a rename. Otherwise, it performs a move.
+///
+/// This tool should be used when it's desirable to move or rename a file or
+/// directory without changing its contents at all.
+#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+pub struct MovePathToolInput {
+ /// The source path of the file or directory to move/rename.
+ ///
+ ///
+ /// If the project has the following files:
+ ///
+ /// - directory1/a/something.txt
+ /// - directory2/a/things.txt
+ /// - directory3/a/other.txt
+ ///
+ /// You can move the first file by providing a source_path of "directory1/a/something.txt"
+ ///
+ pub source_path: String,
+
+ /// The destination path where the file or directory should be moved/renamed to.
+ /// If the paths are the same except for the filename, then this will be a rename.
+ ///
+ ///
+ /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt",
+ /// provide a destination_path of "directory2/b/renamed.txt"
+ ///
+ pub destination_path: String,
+}
+
+pub struct MovePathTool {
+ project: Entity,
+}
+
+impl MovePathTool {
+ pub fn new(project: Entity) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for MovePathTool {
+ type Input = MovePathToolInput;
+ type Output = String;
+
+ fn name(&self) -> SharedString {
+ "move_path".into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Move
+ }
+
+ fn initial_title(&self, input: Result) -> SharedString {
+ if let Ok(input) = input {
+ let src = MarkdownInlineCode(&input.source_path);
+ let dest = MarkdownInlineCode(&input.destination_path);
+ let src_path = Path::new(&input.source_path);
+ let dest_path = Path::new(&input.destination_path);
+
+ match dest_path
+ .file_name()
+ .and_then(|os_str| os_str.to_os_string().into_string().ok())
+ {
+ Some(filename) if src_path.parent() == dest_path.parent() => {
+ let filename = MarkdownInlineCode(&filename);
+ format!("Rename {src} to {filename}").into()
+ }
+ _ => format!("Move {src} to {dest}").into(),
+ }
+ } else {
+ "Move path".into()
+ }
+ }
+
+ fn run(
+ self: Arc,
+ input: Self::Input,
+ _event_stream: ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task> {
+ let rename_task = self.project.update(cx, |project, cx| {
+ match project
+ .find_project_path(&input.source_path, cx)
+ .and_then(|project_path| project.entry_for_path(&project_path, cx))
+ {
+ Some(entity) => match project.find_project_path(&input.destination_path, cx) {
+ Some(project_path) => project.rename_entry(entity.id, project_path.path, cx),
+ None => Task::ready(Err(anyhow!(
+ "Destination path {} was outside the project.",
+ input.destination_path
+ ))),
+ },
+ None => Task::ready(Err(anyhow!(
+ "Source path {} was not found in the project.",
+ input.source_path
+ ))),
+ }
+ });
+
+ cx.background_spawn(async move {
+ let _ = rename_task.await.with_context(|| {
+ format!("Moving {} to {}", input.source_path, input.destination_path)
+ })?;
+ Ok(format!(
+ "Moved {} to {}",
+ input.source_path, input.destination_path
+ ))
+ })
+ }
+}
diff --git a/crates/agent2/src/tools/open_tool.rs b/crates/agent2/src/tools/open_tool.rs
new file mode 100644
index 0000000000..0860b62a51
--- /dev/null
+++ b/crates/agent2/src/tools/open_tool.rs
@@ -0,0 +1,170 @@
+use crate::AgentTool;
+use agent_client_protocol::ToolKind;
+use anyhow::{Context as _, Result};
+use gpui::{App, AppContext, Entity, SharedString, Task};
+use project::Project;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::{path::PathBuf, sync::Arc};
+use util::markdown::MarkdownEscaped;
+
+/// This tool opens a file or URL with the default application associated with
+/// it on the user's operating system:
+///
+/// - On macOS, it's equivalent to the `open` command
+/// - On Windows, it's equivalent to `start`
+/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate
+///
+/// For example, it can open a web browser with a URL, open a PDF file with the
+/// default PDF viewer, etc.
+///
+/// You MUST ONLY use this tool when the user has explicitly requested opening
+/// something. You MUST NEVER assume that the user would like for you to use
+/// this tool.
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct OpenToolInput {
+ /// The path or URL to open with the default application.
+ path_or_url: String,
+}
+
+pub struct OpenTool {
+ project: Entity,
+}
+
+impl OpenTool {
+ pub fn new(project: Entity) -> Self {
+ Self { project }
+ }
+}
+
+impl AgentTool for OpenTool {
+ type Input = OpenToolInput;
+ type Output = String;
+
+ fn name(&self) -> SharedString {
+ "open".into()
+ }
+
+ fn kind(&self) -> ToolKind {
+ ToolKind::Execute
+ }
+
+ fn initial_title(&self, input: Result) -> SharedString {
+ if let Ok(input) = input {
+ format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
+ } else {
+ "Open file or URL".into()
+ }
+ }
+
+ fn run(
+ self: Arc,
+ input: Self::Input,
+ event_stream: crate::ToolCallEventStream,
+ cx: &mut App,
+ ) -> Task> {
+ // If path_or_url turns out to be a path in the project, make it absolute.
+ let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
+ let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())).to_string());
+ cx.background_spawn(async move {
+ authorize.await?;
+
+ match abs_path {
+ Some(path) => open::that(path),
+ None => open::that(&input.path_or_url),
+ }
+ .context("Failed to open URL or file path")?;
+
+ Ok(format!("Successfully opened {}", input.path_or_url))
+ })
+ }
+}
+
+fn to_absolute_path(
+ potential_path: &str,
+ project: Entity,
+ cx: &mut App,
+) -> Option {
+ let project = project.read(cx);
+ project
+ .find_project_path(PathBuf::from(potential_path), cx)
+ .and_then(|project_path| project.absolute_path(&project_path, cx))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::TestAppContext;
+ use project::{FakeFs, Project};
+ use settings::SettingsStore;
+ use std::path::Path;
+ use tempfile::TempDir;
+
+ #[gpui::test]
+ async fn test_to_absolute_path(cx: &mut TestAppContext) {
+ init_test(cx);
+ let temp_dir = TempDir::new().expect("Failed to create temp directory");
+ let temp_path = temp_dir.path().to_string_lossy().to_string();
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ &temp_path,
+ serde_json::json!({
+ "src": {
+ "main.rs": "fn main() {}",
+ "lib.rs": "pub fn lib_fn() {}"
+ },
+ "docs": {
+ "readme.md": "# Project Documentation"
+ }
+ }),
+ )
+ .await;
+
+ // Use the temp_path as the root directory, not just its filename
+ let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
+
+ // Test cases where the function should return Some
+ cx.update(|cx| {
+ // Project-relative paths should return Some
+ // Create paths using the last segment of the temp path to simulate a project-relative path
+ let root_dir_name = Path::new(&temp_path)
+ .file_name()
+ .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
+ .to_string_lossy();
+
+ assert!(
+ to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
+ .is_some(),
+ "Failed to resolve main.rs path"
+ );
+
+ assert!(
+ to_absolute_path(
+ &format!("{root_dir_name}/docs/readme.md",),
+ project.clone(),
+ cx,
+ )
+ .is_some(),
+ "Failed to resolve readme.md path"
+ );
+
+ // External URL should return None
+ let result = to_absolute_path("https://example.com", project.clone(), cx);
+ assert_eq!(result, None, "External URLs should return None");
+
+ // Path outside project
+ let result = to_absolute_path("../invalid/path", project.clone(), cx);
+ assert_eq!(result, None, "Paths outside the project should return None");
+ });
+ }
+
+ 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);
+ });
+ }
+}