terminal_tool.rs

  1use agent_client_protocol as acp;
  2use anyhow::Result;
  3use gpui::{App, Entity, SharedString, Task};
  4use project::Project;
  5use schemars::JsonSchema;
  6use serde::{Deserialize, Serialize};
  7use std::{
  8    path::{Path, PathBuf},
  9    rc::Rc,
 10    sync::Arc,
 11};
 12use util::markdown::MarkdownInlineCode;
 13
 14use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
 15
 16const COMMAND_OUTPUT_LIMIT: u64 = 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    environment: Rc<dyn ThreadEnvironment>,
 40}
 41
 42impl TerminalTool {
 43    pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
 44        Self {
 45            project,
 46            environment,
 47        }
 48    }
 49}
 50
 51impl AgentTool for TerminalTool {
 52    type Input = TerminalToolInput;
 53    type Output = String;
 54
 55    fn name() -> &'static str {
 56        "terminal"
 57    }
 58
 59    fn kind() -> acp::ToolKind {
 60        acp::ToolKind::Execute
 61    }
 62
 63    fn initial_title(
 64        &self,
 65        input: Result<Self::Input, serde_json::Value>,
 66        _cx: &mut App,
 67    ) -> SharedString {
 68        if let Ok(input) = input {
 69            let mut lines = input.command.lines();
 70            let first_line = lines.next().unwrap_or_default();
 71            let remaining_line_count = lines.count();
 72            match remaining_line_count {
 73                0 => MarkdownInlineCode(first_line).to_string().into(),
 74                1 => MarkdownInlineCode(&format!(
 75                    "{} - {} more line",
 76                    first_line, remaining_line_count
 77                ))
 78                .to_string()
 79                .into(),
 80                n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
 81                    .to_string()
 82                    .into(),
 83            }
 84        } else {
 85            "Run terminal command".into()
 86        }
 87    }
 88
 89    fn run(
 90        self: Arc<Self>,
 91        input: Self::Input,
 92        event_stream: ToolCallEventStream,
 93        cx: &mut App,
 94    ) -> Task<Result<Self::Output>> {
 95        let working_dir = match working_dir(&input, &self.project, cx) {
 96            Ok(dir) => dir,
 97            Err(err) => return Task::ready(Err(err)),
 98        };
 99
100        let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx);
101        cx.spawn(async move |cx| {
102            authorize.await?;
103
104            let terminal = self
105                .environment
106                .create_terminal(
107                    input.command.clone(),
108                    working_dir,
109                    Some(COMMAND_OUTPUT_LIMIT),
110                    cx,
111                )
112                .await?;
113
114            let terminal_id = terminal.id(cx)?;
115            event_stream.update_fields(acp::ToolCallUpdateFields {
116                content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]),
117                ..Default::default()
118            });
119
120            let exit_status = terminal.wait_for_exit(cx)?.await;
121            let output = terminal.current_output(cx)?;
122
123            Ok(process_content(output, &input.command, exit_status))
124        })
125    }
126}
127
128fn process_content(
129    output: acp::TerminalOutputResponse,
130    command: &str,
131    exit_status: acp::TerminalExitStatus,
132) -> String {
133    let content = output.output.trim();
134    let is_empty = content.is_empty();
135
136    let content = format!("```\n{content}\n```");
137    let content = if output.truncated {
138        format!(
139            "Command output too long. The first {} bytes:\n\n{content}",
140            content.len(),
141        )
142    } else {
143        content
144    };
145
146    let content = match exit_status.exit_code {
147        Some(0) => {
148            if is_empty {
149                "Command executed successfully.".to_string()
150            } else {
151                content
152            }
153        }
154        Some(exit_code) => {
155            if is_empty {
156                format!("Command \"{command}\" failed with exit code {}.", exit_code)
157            } else {
158                format!(
159                    "Command \"{command}\" failed with exit code {}.\n\n{content}",
160                    exit_code
161                )
162            }
163        }
164        None => {
165            format!(
166                "Command failed or was interrupted.\nPartial output captured:\n\n{}",
167                content,
168            )
169        }
170    };
171    content
172}
173
174fn working_dir(
175    input: &TerminalToolInput,
176    project: &Entity<Project>,
177    cx: &mut App,
178) -> Result<Option<PathBuf>> {
179    let project = project.read(cx);
180    let cd = &input.cd;
181
182    if cd == "." || cd.is_empty() {
183        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
184        let mut worktrees = project.worktrees(cx);
185
186        match worktrees.next() {
187            Some(worktree) => {
188                anyhow::ensure!(
189                    worktrees.next().is_none(),
190                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
191                );
192                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
193            }
194            None => Ok(None),
195        }
196    } else {
197        let input_path = Path::new(cd);
198
199        if input_path.is_absolute() {
200            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
201            if project
202                .worktrees(cx)
203                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
204            {
205                return Ok(Some(input_path.into()));
206            }
207        } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
208            return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
209        }
210
211        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
212    }
213}