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