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