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 guard = cx.update(|cx| {
171 let terminal = terminal.downgrade();
172 let acp_terminal = acp_terminal.downgrade();
173 let guard = cx.new(|_| false);
174 cx.observe_release(&guard, move |cancelled, cx| {
175 if *cancelled {
176 return;
177 }
178 let Some(exit_status) = terminal
179 .update(cx, |terminal, cx| {
180 terminal.kill_active_task();
181 terminal.wait_for_completed_task(cx)
182 })
183 .ok()
184 else {
185 return;
186 };
187 cx.spawn(async move |cx| {
188 let exit_status = exit_status.await;
189 acp_terminal
190 .update(cx, |acp_terminal, cx| {
191 acp_terminal.finish(exit_status, 0, 0, 0, true, cx);
192 })
193 .ok();
194 })
195 .detach();
196 })
197 .detach();
198 guard
199 })?;
200
201 let exit_status = terminal
202 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
203 .await;
204 guard.update(cx, |cancelled, _| *cancelled = true);
205 let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
206 (terminal.get_content(), terminal.total_lines())
207 })?;
208
209 let (processed_content, finished_with_empty_output) = process_content(
210 &content,
211 &input.command,
212 exit_status.map(portable_pty::ExitStatus::from),
213 );
214
215 acp_terminal
216 .update(cx, |terminal, cx| {
217 terminal.finish(
218 exit_status,
219 content.len(),
220 processed_content.len(),
221 content_line_count,
222 finished_with_empty_output,
223 cx,
224 );
225 })
226 .log_err();
227
228 Ok(processed_content)
229 }
230 })
231 }
232}
233
234fn process_content(
235 content: &str,
236 command: &str,
237 exit_status: Option<portable_pty::ExitStatus>,
238) -> (String, bool) {
239 let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
240
241 let content = if should_truncate {
242 let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
243 while !content.is_char_boundary(end_ix) {
244 end_ix -= 1;
245 }
246 // Don't truncate mid-line, clear the remainder of the last line
247 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
248 &content[..end_ix]
249 } else {
250 content
251 };
252 let content = content.trim();
253 let is_empty = content.is_empty();
254 let content = format!("```\n{content}\n```");
255 let content = if should_truncate {
256 format!(
257 "Command output too long. The first {} bytes:\n\n{content}",
258 content.len(),
259 )
260 } else {
261 content
262 };
263
264 let content = match exit_status {
265 Some(exit_status) if exit_status.success() => {
266 if is_empty {
267 "Command executed successfully.".to_string()
268 } else {
269 content
270 }
271 }
272 Some(exit_status) => {
273 if is_empty {
274 format!(
275 "Command \"{command}\" failed with exit code {}.",
276 exit_status.exit_code()
277 )
278 } else {
279 format!(
280 "Command \"{command}\" failed with exit code {}.\n\n{content}",
281 exit_status.exit_code()
282 )
283 }
284 }
285 None => {
286 format!(
287 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
288 content,
289 )
290 }
291 };
292 (content, is_empty)
293}
294
295fn working_dir(
296 input: &TerminalToolInput,
297 project: &Entity<Project>,
298 cx: &mut App,
299) -> Result<Option<PathBuf>> {
300 let project = project.read(cx);
301 let cd = &input.cd;
302
303 if cd == "." || cd.is_empty() {
304 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
305 let mut worktrees = project.worktrees(cx);
306
307 match worktrees.next() {
308 Some(worktree) => {
309 anyhow::ensure!(
310 worktrees.next().is_none(),
311 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
312 );
313 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
314 }
315 None => Ok(None),
316 }
317 } else {
318 let input_path = Path::new(cd);
319
320 if input_path.is_absolute() {
321 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
322 if project
323 .worktrees(cx)
324 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
325 {
326 return Ok(Some(input_path.into()));
327 }
328 } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
329 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
330 }
331
332 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use agent_settings::AgentSettings;
339 use editor::EditorSettings;
340 use fs::RealFs;
341 use gpui::{BackgroundExecutor, TestAppContext};
342 use pretty_assertions::assert_eq;
343 use serde_json::json;
344 use settings::{Settings, SettingsStore};
345 use terminal::terminal_settings::TerminalSettings;
346 use theme::ThemeSettings;
347 use util::test::TempTree;
348
349 use crate::ThreadEvent;
350
351 use super::*;
352
353 fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
354 zlog::init_test();
355
356 executor.allow_parking();
357 cx.update(|cx| {
358 let settings_store = SettingsStore::test(cx);
359 cx.set_global(settings_store);
360 language::init(cx);
361 Project::init_settings(cx);
362 ThemeSettings::register(cx);
363 TerminalSettings::register(cx);
364 EditorSettings::register(cx);
365 AgentSettings::register(cx);
366 });
367 }
368
369 #[gpui::test]
370 async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
371 if cfg!(windows) {
372 return;
373 }
374
375 init_test(&executor, cx);
376
377 let fs = Arc::new(RealFs::new(None, executor));
378 let tree = TempTree::new(json!({
379 "project": {},
380 }));
381 let project: Entity<Project> =
382 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
383
384 let input = TerminalToolInput {
385 command: "cat".to_owned(),
386 cd: tree
387 .path()
388 .join("project")
389 .as_path()
390 .to_string_lossy()
391 .to_string(),
392 };
393 let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
394 let result = cx
395 .update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
396
397 let auth = event_stream_rx.expect_authorization().await;
398 auth.response.send(auth.options[0].id.clone()).unwrap();
399 event_stream_rx.expect_terminal().await;
400 assert_eq!(result.await.unwrap(), "Command executed successfully.");
401 }
402
403 #[gpui::test]
404 async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
405 if cfg!(windows) {
406 return;
407 }
408
409 init_test(&executor, cx);
410
411 let fs = Arc::new(RealFs::new(None, executor));
412 let tree = TempTree::new(json!({
413 "project": {},
414 "other-project": {},
415 }));
416 let project: Entity<Project> =
417 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
418
419 let check = |input, expected, cx: &mut TestAppContext| {
420 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
421 let result = cx.update(|cx| {
422 Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
423 });
424 cx.run_until_parked();
425 let event = stream_rx.try_next();
426 if let Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(auth)))) = event {
427 auth.response.send(auth.options[0].id.clone()).unwrap();
428 }
429
430 cx.spawn(async move |_| {
431 let output = result.await;
432 assert_eq!(output.ok(), expected);
433 })
434 };
435
436 check(
437 TerminalToolInput {
438 command: "pwd".into(),
439 cd: ".".into(),
440 },
441 Some(format!(
442 "```\n{}\n```",
443 tree.path().join("project").display()
444 )),
445 cx,
446 )
447 .await;
448
449 check(
450 TerminalToolInput {
451 command: "pwd".into(),
452 cd: "other-project".into(),
453 },
454 None, // other-project is a dir, but *not* a worktree (yet)
455 cx,
456 )
457 .await;
458
459 // Absolute path above the worktree root
460 check(
461 TerminalToolInput {
462 command: "pwd".into(),
463 cd: tree.path().to_string_lossy().into(),
464 },
465 None,
466 cx,
467 )
468 .await;
469
470 project
471 .update(cx, |project, cx| {
472 project.create_worktree(tree.path().join("other-project"), true, cx)
473 })
474 .await
475 .unwrap();
476
477 check(
478 TerminalToolInput {
479 command: "pwd".into(),
480 cd: "other-project".into(),
481 },
482 Some(format!(
483 "```\n{}\n```",
484 tree.path().join("other-project").display()
485 )),
486 cx,
487 )
488 .await;
489
490 check(
491 TerminalToolInput {
492 command: "pwd".into(),
493 cd: ".".into(),
494 },
495 None,
496 cx,
497 )
498 .await;
499 }
500}