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}