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 context = crate::ToolPermissionContext {
70 tool_name: "open".to_string(),
71 input_value: input.path_or_url.clone(),
72 };
73 let authorize =
74 event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx);
75 cx.background_spawn(async move {
76 futures::select! {
77 result = authorize.fuse() => result?,
78 _ = event_stream.cancelled_by_user().fuse() => {
79 anyhow::bail!("Open cancelled by user");
80 }
81 }
82
83 match abs_path {
84 Some(path) => open::that(path),
85 None => open::that(&input.path_or_url),
86 }
87 .context("Failed to open URL or file path")?;
88
89 Ok(format!("Successfully opened {}", input.path_or_url))
90 })
91 }
92}
93
94fn to_absolute_path(
95 potential_path: &str,
96 project: Entity<Project>,
97 cx: &mut App,
98) -> Option<PathBuf> {
99 let project = project.read(cx);
100 project
101 .find_project_path(PathBuf::from(potential_path), cx)
102 .and_then(|project_path| project.absolute_path(&project_path, cx))
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use gpui::TestAppContext;
109 use project::{FakeFs, Project};
110 use settings::SettingsStore;
111 use std::path::Path;
112 use tempfile::TempDir;
113
114 #[gpui::test]
115 async fn test_to_absolute_path(cx: &mut TestAppContext) {
116 init_test(cx);
117 let temp_dir = TempDir::new().expect("Failed to create temp directory");
118 let temp_path = temp_dir.path().to_string_lossy().into_owned();
119
120 let fs = FakeFs::new(cx.executor());
121 fs.insert_tree(
122 &temp_path,
123 serde_json::json!({
124 "src": {
125 "main.rs": "fn main() {}",
126 "lib.rs": "pub fn lib_fn() {}"
127 },
128 "docs": {
129 "readme.md": "# Project Documentation"
130 }
131 }),
132 )
133 .await;
134
135 // Use the temp_path as the root directory, not just its filename
136 let project = Project::test(fs.clone(), [temp_dir.path()], cx).await;
137
138 // Test cases where the function should return Some
139 cx.update(|cx| {
140 // Project-relative paths should return Some
141 // Create paths using the last segment of the temp path to simulate a project-relative path
142 let root_dir_name = Path::new(&temp_path)
143 .file_name()
144 .unwrap_or_else(|| std::ffi::OsStr::new("temp"))
145 .to_string_lossy();
146
147 assert!(
148 to_absolute_path(&format!("{root_dir_name}/src/main.rs"), project.clone(), cx)
149 .is_some(),
150 "Failed to resolve main.rs path"
151 );
152
153 assert!(
154 to_absolute_path(
155 &format!("{root_dir_name}/docs/readme.md",),
156 project.clone(),
157 cx,
158 )
159 .is_some(),
160 "Failed to resolve readme.md path"
161 );
162
163 // External URL should return None
164 let result = to_absolute_path("https://example.com", project.clone(), cx);
165 assert_eq!(result, None, "External URLs should return None");
166
167 // Path outside project
168 let result = to_absolute_path("../invalid/path", project.clone(), cx);
169 assert_eq!(result, None, "Paths outside the project should return None");
170 });
171 }
172
173 fn init_test(cx: &mut TestAppContext) {
174 cx.update(|cx| {
175 let settings_store = SettingsStore::test(cx);
176 cx.set_global(settings_store);
177 });
178 }
179}