1use agent_client_protocol as acp;
2use anyhow::Result;
3use futures::{FutureExt as _, future::Shared};
4use gpui::{App, AppContext, Entity, SharedString, Task};
5use project::Project;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::{
9 path::{Path, PathBuf},
10 sync::Arc,
11};
12use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
13
14use crate::{AgentTool, ToolCallEventStream};
15
16const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
17
18/// Executes a shell one-liner and returns the combined output.
19///
20/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.
21///
22/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
23///
24/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.
25///
26/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.
27///
28/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
29#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
30pub struct TerminalToolInput {
31 /// The one-liner command to execute.
32 command: String,
33 /// Working directory for the command. This must be one of the root directories of the project.
34 cd: String,
35}
36
37pub struct TerminalTool {
38 project: Entity<Project>,
39 determine_shell: Shared<Task<String>>,
40}
41
42impl TerminalTool {
43 pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
44 let determine_shell = cx.background_spawn(async move {
45 if cfg!(windows) {
46 return get_system_shell();
47 }
48
49 if which::which("bash").is_ok() {
50 "bash".into()
51 } else {
52 get_system_shell()
53 }
54 });
55 Self {
56 project,
57 determine_shell: determine_shell.shared(),
58 }
59 }
60}
61
62impl AgentTool for TerminalTool {
63 type Input = TerminalToolInput;
64 type Output = String;
65
66 fn name() -> &'static str {
67 "terminal"
68 }
69
70 fn kind() -> acp::ToolKind {
71 acp::ToolKind::Execute
72 }
73
74 fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
75 if let Ok(input) = input {
76 let mut lines = input.command.lines();
77 let first_line = lines.next().unwrap_or_default();
78 let remaining_line_count = lines.count();
79 match remaining_line_count {
80 0 => MarkdownInlineCode(first_line).to_string().into(),
81 1 => MarkdownInlineCode(&format!(
82 "{} - {} more line",
83 first_line, remaining_line_count
84 ))
85 .to_string()
86 .into(),
87 n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
88 .to_string()
89 .into(),
90 }
91 } else {
92 "Run terminal command".into()
93 }
94 }
95
96 fn run(
97 self: Arc<Self>,
98 input: Self::Input,
99 event_stream: ToolCallEventStream,
100 cx: &mut App,
101 ) -> Task<Result<Self::Output>> {
102 let language_registry = self.project.read(cx).languages().clone();
103 let working_dir = match working_dir(&input, &self.project, cx) {
104 Ok(dir) => dir,
105 Err(err) => return Task::ready(Err(err)),
106 };
107 let program = self.determine_shell.clone();
108 let command = if cfg!(windows) {
109 format!("$null | & {{{}}}", input.command.replace("\"", "'"))
110 } else if let Some(cwd) = working_dir
111 .as_ref()
112 .and_then(|cwd| cwd.as_os_str().to_str())
113 {
114 // Make sure once we're *inside* the shell, we cd into `cwd`
115 format!("(cd {cwd}; {}) </dev/null", input.command)
116 } else {
117 format!("({}) </dev/null", input.command)
118 };
119 let args = vec!["-c".into(), command];
120
121 let env = match &working_dir {
122 Some(dir) => self.project.update(cx, |project, cx| {
123 project.directory_environment(dir.as_path().into(), cx)
124 }),
125 None => Task::ready(None).shared(),
126 };
127
128 let env = cx.spawn(async move |_| {
129 let mut env = env.await.unwrap_or_default();
130 if cfg!(unix) {
131 env.insert("PAGER".into(), "cat".into());
132 }
133 env
134 });
135
136 let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
137
138 cx.spawn({
139 async move |cx| {
140 authorize.await?;
141
142 let program = program.await;
143 let env = env.await;
144 let terminal = self
145 .project
146 .update(cx, |project, cx| {
147 project.create_terminal_task(
148 task::SpawnInTerminal {
149 command: Some(program),
150 args,
151 cwd: working_dir.clone(),
152 env,
153 ..Default::default()
154 },
155 cx,
156 )
157 })?
158 .await?;
159 let acp_terminal = cx.new(|cx| {
160 acp_thread::Terminal::new(
161 input.command.clone(),
162 working_dir.clone(),
163 terminal.clone(),
164 language_registry,
165 cx,
166 )
167 })?;
168 event_stream.update_terminal(acp_terminal.clone());
169
170 let exit_status = terminal
171 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
172 .await;
173 let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
174 (terminal.get_content(), terminal.total_lines())
175 })?;
176
177 let (processed_content, finished_with_empty_output) = process_content(
178 &content,
179 &input.command,
180 exit_status.map(portable_pty::ExitStatus::from),
181 );
182
183 acp_terminal
184 .update(cx, |terminal, cx| {
185 terminal.finish(
186 exit_status,
187 content.len(),
188 processed_content.len(),
189 content_line_count,
190 finished_with_empty_output,
191 cx,
192 );
193 })
194 .log_err();
195
196 Ok(processed_content)
197 }
198 })
199 }
200}
201
202fn process_content(
203 content: &str,
204 command: &str,
205 exit_status: Option<portable_pty::ExitStatus>,
206) -> (String, bool) {
207 let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
208
209 let content = if should_truncate {
210 let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
211 while !content.is_char_boundary(end_ix) {
212 end_ix -= 1;
213 }
214 // Don't truncate mid-line, clear the remainder of the last line
215 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
216 &content[..end_ix]
217 } else {
218 content
219 };
220 let content = content.trim();
221 let is_empty = content.is_empty();
222 let content = format!("```\n{content}\n```");
223 let content = if should_truncate {
224 format!(
225 "Command output too long. The first {} bytes:\n\n{content}",
226 content.len(),
227 )
228 } else {
229 content
230 };
231
232 let content = match exit_status {
233 Some(exit_status) if exit_status.success() => {
234 if is_empty {
235 "Command executed successfully.".to_string()
236 } else {
237 content
238 }
239 }
240 Some(exit_status) => {
241 if is_empty {
242 format!(
243 "Command \"{command}\" failed with exit code {}.",
244 exit_status.exit_code()
245 )
246 } else {
247 format!(
248 "Command \"{command}\" failed with exit code {}.\n\n{content}",
249 exit_status.exit_code()
250 )
251 }
252 }
253 None => {
254 format!(
255 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
256 content,
257 )
258 }
259 };
260 (content, is_empty)
261}
262
263fn working_dir(
264 input: &TerminalToolInput,
265 project: &Entity<Project>,
266 cx: &mut App,
267) -> Result<Option<PathBuf>> {
268 let project = project.read(cx);
269 let cd = &input.cd;
270
271 if cd == "." || cd.is_empty() {
272 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
273 let mut worktrees = project.worktrees(cx);
274
275 match worktrees.next() {
276 Some(worktree) => {
277 anyhow::ensure!(
278 worktrees.next().is_none(),
279 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
280 );
281 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
282 }
283 None => Ok(None),
284 }
285 } else {
286 let input_path = Path::new(cd);
287
288 if input_path.is_absolute() {
289 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
290 if project
291 .worktrees(cx)
292 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
293 {
294 return Ok(Some(input_path.into()));
295 }
296 } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
297 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
298 }
299
300 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use agent_settings::AgentSettings;
307 use editor::EditorSettings;
308 use fs::RealFs;
309 use gpui::{BackgroundExecutor, TestAppContext};
310 use pretty_assertions::assert_eq;
311 use serde_json::json;
312 use settings::{Settings, SettingsStore};
313 use terminal::terminal_settings::TerminalSettings;
314 use theme::ThemeSettings;
315 use util::test::TempTree;
316
317 use crate::ThreadEvent;
318
319 use super::*;
320
321 fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
322 zlog::init_test();
323
324 executor.allow_parking();
325 cx.update(|cx| {
326 let settings_store = SettingsStore::test(cx);
327 cx.set_global(settings_store);
328 language::init(cx);
329 Project::init_settings(cx);
330 ThemeSettings::register(cx);
331 TerminalSettings::register(cx);
332 EditorSettings::register(cx);
333 AgentSettings::register(cx);
334 });
335 }
336
337 #[gpui::test]
338 async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
339 if cfg!(windows) {
340 return;
341 }
342
343 init_test(&executor, cx);
344
345 let fs = Arc::new(RealFs::new(None, executor));
346 let tree = TempTree::new(json!({
347 "project": {},
348 }));
349 let project: Entity<Project> =
350 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
351
352 let input = TerminalToolInput {
353 command: "cat".to_owned(),
354 cd: tree
355 .path()
356 .join("project")
357 .as_path()
358 .to_string_lossy()
359 .to_string(),
360 };
361 let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
362 let result = cx
363 .update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
364
365 let auth = event_stream_rx.expect_authorization().await;
366 auth.response.send(auth.options[0].id.clone()).unwrap();
367 event_stream_rx.expect_terminal().await;
368 assert_eq!(result.await.unwrap(), "Command executed successfully.");
369 }
370
371 #[gpui::test]
372 async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
373 if cfg!(windows) {
374 return;
375 }
376
377 init_test(&executor, cx);
378
379 let fs = Arc::new(RealFs::new(None, executor));
380 let tree = TempTree::new(json!({
381 "project": {},
382 "other-project": {},
383 }));
384 let project: Entity<Project> =
385 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
386
387 let check = |input, expected, cx: &mut TestAppContext| {
388 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
389 let result = cx.update(|cx| {
390 Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
391 });
392 cx.run_until_parked();
393 let event = stream_rx.try_next();
394 if let Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(auth)))) = event {
395 auth.response.send(auth.options[0].id.clone()).unwrap();
396 }
397
398 cx.spawn(async move |_| {
399 let output = result.await;
400 assert_eq!(output.ok(), expected);
401 })
402 };
403
404 check(
405 TerminalToolInput {
406 command: "pwd".into(),
407 cd: ".".into(),
408 },
409 Some(format!(
410 "```\n{}\n```",
411 tree.path().join("project").display()
412 )),
413 cx,
414 )
415 .await;
416
417 check(
418 TerminalToolInput {
419 command: "pwd".into(),
420 cd: "other-project".into(),
421 },
422 None, // other-project is a dir, but *not* a worktree (yet)
423 cx,
424 )
425 .await;
426
427 // Absolute path above the worktree root
428 check(
429 TerminalToolInput {
430 command: "pwd".into(),
431 cd: tree.path().to_string_lossy().into(),
432 },
433 None,
434 cx,
435 )
436 .await;
437
438 project
439 .update(cx, |project, cx| {
440 project.create_worktree(tree.path().join("other-project"), true, cx)
441 })
442 .await
443 .unwrap();
444
445 check(
446 TerminalToolInput {
447 command: "pwd".into(),
448 cd: "other-project".into(),
449 },
450 Some(format!(
451 "```\n{}\n```",
452 tree.path().join("other-project").display()
453 )),
454 cx,
455 )
456 .await;
457
458 check(
459 TerminalToolInput {
460 command: "pwd".into(),
461 cd: ".".into(),
462 },
463 None,
464 cx,
465 )
466 .await;
467 }
468}