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, 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}