1use crate::AgentTool;
2use agent_client_protocol::ToolKind;
3use anyhow::{Context as _, Result};
4use futures::FutureExt as _;
5use gpui::{App, AppContext, Entity, SharedString, Task};
6use project::Project;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::{path::PathBuf, sync::Arc};
10use util::markdown::MarkdownEscaped;
11
12/// This tool opens a file or URL with the default application associated with it on the user's operating system:
13///
14/// - On macOS, it's equivalent to the `open` command
15/// - On Windows, it's equivalent to `start`
16/// - On Linux, it uses something like `xdg-open`, `gio open`, `gnome-open`, `kde-open`, `wslview` as appropriate
17///
18/// For example, it can open a web browser with a URL, open a PDF file with the default PDF viewer, etc.
19///
20/// 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.
21#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
22pub struct OpenToolInput {
23 /// The path or URL to open with the default application.
24 path_or_url: String,
25}
26
27pub struct OpenTool {
28 project: Entity<Project>,
29}
30
31impl OpenTool {
32 pub fn new(project: Entity<Project>) -> Self {
33 Self { project }
34 }
35}
36
37impl AgentTool for OpenTool {
38 type Input = OpenToolInput;
39 type Output = String;
40
41 fn name() -> &'static str {
42 "open"
43 }
44
45 fn kind() -> ToolKind {
46 ToolKind::Execute
47 }
48
49 fn initial_title(
50 &self,
51 input: Result<Self::Input, serde_json::Value>,
52 _cx: &mut App,
53 ) -> SharedString {
54 if let Ok(input) = input {
55 format!("Open `{}`", MarkdownEscaped(&input.path_or_url)).into()
56 } else {
57 "Open file or URL".into()
58 }
59 }
60
61 fn run(
62 self: Arc<Self>,
63 input: Self::Input,
64 event_stream: crate::ToolCallEventStream,
65 cx: &mut App,
66 ) -> Task<Result<Self::Output>> {
67 // If path_or_url turns out to be a path in the project, make it absolute.
68 let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
69 let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx);
70 cx.background_spawn(async move {
71 futures::select! {
72 result = authorize.fuse() => result?,
73 _ = event_stream.cancelled_by_user().fuse() => {
74 anyhow::bail!("Open cancelled by user");
75 }
76 }
77
78 match abs_path {
79 Some(path) => open::that(path),
80 None => open::that(&input.path_or_url),
81 }
82 .context("Failed to open URL or file path")?;
83
84 Ok(format!("Successfully opened {}", input.path_or_url))
85 })
86 }
87}
88
89fn to_absolute_path(
90 potential_path: &str,
91 project: Entity<Project>,
92 cx: &mut App,
93) -> Option<PathBuf> {
94 let project = project.read(cx);
95 project
96 .find_project_path(PathBuf::from(potential_path), cx)
97 .and_then(|project_path| project.absolute_path(&project_path, cx))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use gpui::TestAppContext;
104 use project::{FakeFs, Project};
105 use settings::SettingsStore;
106 use std::path::Path;
107 use tempfile::TempDir;
108
109 #[gpui::test]
110 async fn test_to_absolute_path(cx: &mut TestAppContext) {
111 init_test(cx);
112 let temp_dir = TempDir::new().expect("Failed to create temp directory");
113 let temp_path = temp_dir.path().to_string_lossy().into_owned();
114
115 let fs = FakeFs::new(cx.executor());
116 fs.insert_tree(
117 &temp_path,
118 serde_json::json!({
119 "src": {
120 "main.rs": "fn main() {}",
121 "lib.rs": "pub fn lib_fn() {}"
122 },
123 "docs": {
124 "readme.md": "# Project Documentation"
125 }
126 }),
127 )
128 .await;
129
130 // Use the temp_path as the root directory, not just its filename
131 let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
132
133 // Test cases where the function should return Some
134 cx.update(|cx| {
135 // Project-relative paths should return Some
136 // Create paths using the last segment of the temp path to simulate a project-relative path
137 let root_dir_name = Path::new(&temp_path)
138 .file_name()
139 .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
140 .to_string_lossy();
141
142 assert!(
143 to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
144 .is_some(),
145 "Failed to resolve main.rs path"
146 );
147
148 assert!(
149 to_absolute_path(
150 &format!("{root_dir_name}/docs/readme.md",),
151 project.clone(),
152 cx,
153 )
154 .is_some(),
155 "Failed to resolve readme.md path"
156 );
157
158 // External URL should return None
159 let result = to_absolute_path("https://example.com", project.clone(), cx);
160 assert_eq!(result, None, "External URLs should return None");
161
162 // Path outside project
163 let result = to_absolute_path("../invalid/path", project.clone(), cx);
164 assert_eq!(result, None, "Paths outside the project should return None");
165 });
166 }
167
168 fn init_test(cx: &mut TestAppContext) {
169 cx.update(|cx| {
170 let settings_store = SettingsStore::test(cx);
171 cx.set_global(settings_store);
172 });
173 }
174}