terminal_tool.rs

  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}