terminal_tool.rs

  1use agent_client_protocol as acp;
  2use agent_settings::AgentSettings;
  3use anyhow::Result;
  4use futures::FutureExt as _;
  5use gpui::{App, Entity, SharedString, Task};
  6use project::Project;
  7use schemars::JsonSchema;
  8use serde::{Deserialize, Serialize};
  9use settings::Settings;
 10use std::{
 11    path::{Path, PathBuf},
 12    rc::Rc,
 13    sync::Arc,
 14    time::Duration,
 15};
 16use util::markdown::MarkdownInlineCode;
 17
 18use crate::{
 19    AgentTool, ThreadEnvironment, ToolCallEventStream, ToolPermissionDecision,
 20    decide_permission_from_settings,
 21};
 22
 23const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
 24
 25/// Executes a shell one-liner and returns the combined output.
 26///
 27/// 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.
 28///
 29/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
 30///
 31/// 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.
 32///
 33/// 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.
 34///
 35/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs.
 36///
 37/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
 38///
 39/// The terminal emulator is an interactive pty, so commands may block waiting for user input.
 40/// Some commands can be configured not to do this, such as `git --no-pager diff` and similar.
 41#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 42pub struct TerminalToolInput {
 43    /// The one-liner command to execute.
 44    pub command: String,
 45    /// Working directory for the command. This must be one of the root directories of the project.
 46    pub cd: String,
 47    /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed.
 48    pub timeout_ms: Option<u64>,
 49}
 50
 51pub struct TerminalTool {
 52    project: Entity<Project>,
 53    environment: Rc<dyn ThreadEnvironment>,
 54}
 55
 56impl TerminalTool {
 57    pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
 58        Self {
 59            project,
 60            environment,
 61        }
 62    }
 63}
 64
 65impl AgentTool for TerminalTool {
 66    type Input = TerminalToolInput;
 67    type Output = String;
 68
 69    fn name() -> &'static str {
 70        "terminal"
 71    }
 72
 73    fn kind() -> acp::ToolKind {
 74        acp::ToolKind::Execute
 75    }
 76
 77    fn initial_title(
 78        &self,
 79        input: Result<Self::Input, serde_json::Value>,
 80        _cx: &mut App,
 81    ) -> SharedString {
 82        if let Ok(input) = input {
 83            let mut lines = input.command.lines();
 84            let first_line = lines.next().unwrap_or_default();
 85            let remaining_line_count = lines.count();
 86            match remaining_line_count {
 87                0 => MarkdownInlineCode(first_line).to_string().into(),
 88                1 => MarkdownInlineCode(&format!(
 89                    "{} - {} more line",
 90                    first_line, remaining_line_count
 91                ))
 92                .to_string()
 93                .into(),
 94                n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
 95                    .to_string()
 96                    .into(),
 97            }
 98        } else {
 99            "".into()
100        }
101    }
102
103    fn run(
104        self: Arc<Self>,
105        input: Self::Input,
106        event_stream: ToolCallEventStream,
107        cx: &mut App,
108    ) -> Task<Result<Self::Output>> {
109        let working_dir = match working_dir(&input, &self.project, cx) {
110            Ok(dir) => dir,
111            Err(err) => return Task::ready(Err(err)),
112        };
113
114        let settings = AgentSettings::get_global(cx);
115        let decision = decide_permission_from_settings("terminal", &input.command, settings);
116
117        let authorize = match decision {
118            ToolPermissionDecision::Allow => None,
119            ToolPermissionDecision::Deny(reason) => {
120                return Task::ready(Err(anyhow::anyhow!("{}", reason)));
121            }
122            ToolPermissionDecision::Confirm => {
123                // Use authorize_required since permission rules already determined confirmation is needed
124                Some(event_stream.authorize_required(self.initial_title(Ok(input.clone()), cx), cx))
125            }
126        };
127        cx.spawn(async move |cx| {
128            if let Some(authorize) = authorize {
129                authorize.await?;
130            }
131
132            let terminal = self
133                .environment
134                .create_terminal(
135                    input.command.clone(),
136                    working_dir,
137                    Some(COMMAND_OUTPUT_LIMIT),
138                    cx,
139                )
140                .await?;
141
142            let terminal_id = terminal.id(cx)?;
143            event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
144                acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)),
145            ]));
146
147            let timeout = input.timeout_ms.map(Duration::from_millis);
148
149            let mut timed_out = false;
150            let mut user_stopped_via_signal = false;
151            let wait_for_exit = terminal.wait_for_exit(cx)?;
152
153            match timeout {
154                Some(timeout) => {
155                    let timeout_task = cx.background_executor().timer(timeout);
156
157                    futures::select! {
158                        _ = wait_for_exit.clone().fuse() => {},
159                        _ = timeout_task.fuse() => {
160                            timed_out = true;
161                            terminal.kill(cx)?;
162                            wait_for_exit.await;
163                        }
164                        _ = event_stream.cancelled_by_user().fuse() => {
165                            user_stopped_via_signal = true;
166                            terminal.kill(cx)?;
167                            wait_for_exit.await;
168                        }
169                    }
170                }
171                None => {
172                    futures::select! {
173                        _ = wait_for_exit.clone().fuse() => {},
174                        _ = event_stream.cancelled_by_user().fuse() => {
175                            user_stopped_via_signal = true;
176                            terminal.kill(cx)?;
177                            wait_for_exit.await;
178                        }
179                    }
180                }
181            };
182
183            // Check if user stopped - we check both:
184            // 1. The cancellation signal from RunningTurn::cancel (e.g. user pressed main Stop button)
185            // 2. The terminal's user_stopped flag (e.g. user clicked Stop on the terminal card)
186            // Note: user_stopped_via_signal is already set above if we detected cancellation in the select!
187            // but we also check was_cancelled_by_user() for cases where cancellation happened after wait_for_exit completed
188            let user_stopped_via_signal =
189                user_stopped_via_signal || event_stream.was_cancelled_by_user();
190            let user_stopped_via_terminal = terminal.was_stopped_by_user(cx).unwrap_or(false);
191            let user_stopped = user_stopped_via_signal || user_stopped_via_terminal;
192
193            let output = terminal.current_output(cx)?;
194
195            Ok(process_content(
196                output,
197                &input.command,
198                timed_out,
199                user_stopped,
200            ))
201        })
202    }
203}
204
205fn process_content(
206    output: acp::TerminalOutputResponse,
207    command: &str,
208    timed_out: bool,
209    user_stopped: bool,
210) -> String {
211    let content = output.output.trim();
212    let is_empty = content.is_empty();
213
214    let content = format!("```\n{content}\n```");
215    let content = if output.truncated {
216        format!(
217            "Command output too long. The first {} bytes:\n\n{content}",
218            content.len(),
219        )
220    } else {
221        content
222    };
223
224    let content = if user_stopped {
225        if is_empty {
226            "The user stopped this command. No output was captured before stopping.\n\n\
227            Since the user intentionally interrupted this command, ask them what they would like to do next \
228            rather than automatically retrying or assuming something went wrong.".to_string()
229        } else {
230            format!(
231                "The user stopped this command. Output captured before stopping:\n\n{}\n\n\
232                Since the user intentionally interrupted this command, ask them what they would like to do next \
233                rather than automatically retrying or assuming something went wrong.",
234                content
235            )
236        }
237    } else if timed_out {
238        if is_empty {
239            format!("Command \"{command}\" timed out. No output was captured.")
240        } else {
241            format!(
242                "Command \"{command}\" timed out. Output captured before timeout:\n\n{}",
243                content
244            )
245        }
246    } else {
247        let exit_code = output.exit_status.as_ref().and_then(|s| s.exit_code);
248        match exit_code {
249            Some(0) => {
250                if is_empty {
251                    "Command executed successfully.".to_string()
252                } else {
253                    content
254                }
255            }
256            Some(exit_code) => {
257                if is_empty {
258                    format!("Command \"{command}\" failed with exit code {}.", exit_code)
259                } else {
260                    format!(
261                        "Command \"{command}\" failed with exit code {}.\n\n{content}",
262                        exit_code
263                    )
264                }
265            }
266            None => {
267                if is_empty {
268                    "Command terminated unexpectedly. No output was captured.".to_string()
269                } else {
270                    format!(
271                        "Command terminated unexpectedly. Output captured:\n\n{}",
272                        content
273                    )
274                }
275            }
276        }
277    };
278    content
279}
280
281fn working_dir(
282    input: &TerminalToolInput,
283    project: &Entity<Project>,
284    cx: &mut App,
285) -> Result<Option<PathBuf>> {
286    let project = project.read(cx);
287    let cd = &input.cd;
288
289    if cd == "." || cd.is_empty() {
290        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
291        let mut worktrees = project.worktrees(cx);
292
293        match worktrees.next() {
294            Some(worktree) => {
295                anyhow::ensure!(
296                    worktrees.next().is_none(),
297                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
298                );
299                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
300            }
301            None => Ok(None),
302        }
303    } else {
304        let input_path = Path::new(cd);
305
306        if input_path.is_absolute() {
307            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
308            if project
309                .worktrees(cx)
310                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
311            {
312                return Ok(Some(input_path.into()));
313            }
314        } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
315            return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
316        }
317
318        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_process_content_user_stopped() {
328        let output = acp::TerminalOutputResponse::new("partial output".to_string(), false);
329
330        let result = process_content(output, "cargo build", false, true);
331
332        assert!(
333            result.contains("user stopped"),
334            "Expected 'user stopped' message, got: {}",
335            result
336        );
337        assert!(
338            result.contains("partial output"),
339            "Expected output to be included, got: {}",
340            result
341        );
342        assert!(
343            result.contains("ask them what they would like to do"),
344            "Should instruct agent to ask user, got: {}",
345            result
346        );
347    }
348
349    #[test]
350    fn test_process_content_user_stopped_empty_output() {
351        let output = acp::TerminalOutputResponse::new("".to_string(), false);
352
353        let result = process_content(output, "cargo build", false, true);
354
355        assert!(
356            result.contains("user stopped"),
357            "Expected 'user stopped' message, got: {}",
358            result
359        );
360        assert!(
361            result.contains("No output was captured"),
362            "Expected 'No output was captured', got: {}",
363            result
364        );
365    }
366
367    #[test]
368    fn test_process_content_timed_out() {
369        let output = acp::TerminalOutputResponse::new("build output here".to_string(), false);
370
371        let result = process_content(output, "cargo build", true, false);
372
373        assert!(
374            result.contains("timed out"),
375            "Expected 'timed out' message for timeout, got: {}",
376            result
377        );
378        assert!(
379            result.contains("build output here"),
380            "Expected output to be included, got: {}",
381            result
382        );
383    }
384
385    #[test]
386    fn test_process_content_timed_out_with_empty_output() {
387        let output = acp::TerminalOutputResponse::new("".to_string(), false);
388
389        let result = process_content(output, "sleep 1000", true, false);
390
391        assert!(
392            result.contains("timed out"),
393            "Expected 'timed out' for timeout, got: {}",
394            result
395        );
396        assert!(
397            result.contains("No output was captured"),
398            "Expected 'No output was captured' for empty output, got: {}",
399            result
400        );
401    }
402
403    #[test]
404    fn test_process_content_with_success() {
405        let output = acp::TerminalOutputResponse::new("success output".to_string(), false)
406            .exit_status(acp::TerminalExitStatus::new().exit_code(0));
407
408        let result = process_content(output, "echo hello", false, false);
409
410        assert!(
411            result.contains("success output"),
412            "Expected output to be included, got: {}",
413            result
414        );
415        assert!(
416            !result.contains("failed"),
417            "Success should not say 'failed', got: {}",
418            result
419        );
420    }
421
422    #[test]
423    fn test_process_content_with_success_empty_output() {
424        let output = acp::TerminalOutputResponse::new("".to_string(), false)
425            .exit_status(acp::TerminalExitStatus::new().exit_code(0));
426
427        let result = process_content(output, "true", false, false);
428
429        assert!(
430            result.contains("executed successfully"),
431            "Expected success message for empty output, got: {}",
432            result
433        );
434    }
435
436    #[test]
437    fn test_process_content_with_error_exit() {
438        let output = acp::TerminalOutputResponse::new("error output".to_string(), false)
439            .exit_status(acp::TerminalExitStatus::new().exit_code(1));
440
441        let result = process_content(output, "false", false, false);
442
443        assert!(
444            result.contains("failed with exit code 1"),
445            "Expected failure message, got: {}",
446            result
447        );
448        assert!(
449            result.contains("error output"),
450            "Expected output to be included, got: {}",
451            result
452        );
453    }
454
455    #[test]
456    fn test_process_content_with_error_exit_empty_output() {
457        let output = acp::TerminalOutputResponse::new("".to_string(), false)
458            .exit_status(acp::TerminalExitStatus::new().exit_code(1));
459
460        let result = process_content(output, "false", false, false);
461
462        assert!(
463            result.contains("failed with exit code 1"),
464            "Expected failure message, got: {}",
465            result
466        );
467    }
468
469    #[test]
470    fn test_process_content_unexpected_termination() {
471        let output = acp::TerminalOutputResponse::new("some output".to_string(), false);
472
473        let result = process_content(output, "some_command", false, false);
474
475        assert!(
476            result.contains("terminated unexpectedly"),
477            "Expected 'terminated unexpectedly' message, got: {}",
478            result
479        );
480        assert!(
481            result.contains("some output"),
482            "Expected output to be included, got: {}",
483            result
484        );
485    }
486
487    #[test]
488    fn test_process_content_unexpected_termination_empty_output() {
489        let output = acp::TerminalOutputResponse::new("".to_string(), false);
490
491        let result = process_content(output, "some_command", false, false);
492
493        assert!(
494            result.contains("terminated unexpectedly"),
495            "Expected 'terminated unexpectedly' message, got: {}",
496            result
497        );
498        assert!(
499            result.contains("No output was captured"),
500            "Expected 'No output was captured' for empty output, got: {}",
501            result
502        );
503    }
504}