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