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