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