1use agent_client_protocol as acp;
2use anyhow::Result;
3use futures::FutureExt as _;
4use gpui::{App, AppContext, 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 exit_status = match timeout {
128 Some(timeout) => {
129 let wait_for_exit = terminal.wait_for_exit(cx)?;
130 let timeout_task = cx.background_spawn(async move {
131 smol::Timer::after(timeout).await;
132 });
133
134 futures::select! {
135 status = wait_for_exit.clone().fuse() => status,
136 _ = timeout_task.fuse() => {
137 terminal.kill(cx)?;
138 wait_for_exit.await
139 }
140 }
141 }
142 None => terminal.wait_for_exit(cx)?.await,
143 };
144
145 let output = terminal.current_output(cx)?;
146
147 Ok(process_content(output, &input.command, exit_status))
148 })
149 }
150}
151
152fn process_content(
153 output: acp::TerminalOutputResponse,
154 command: &str,
155 exit_status: acp::TerminalExitStatus,
156) -> String {
157 let content = output.output.trim();
158 let is_empty = content.is_empty();
159
160 let content = format!("```\n{content}\n```");
161 let content = if output.truncated {
162 format!(
163 "Command output too long. The first {} bytes:\n\n{content}",
164 content.len(),
165 )
166 } else {
167 content
168 };
169
170 let content = match exit_status.exit_code {
171 Some(0) => {
172 if is_empty {
173 "Command executed successfully.".to_string()
174 } else {
175 content
176 }
177 }
178 Some(exit_code) => {
179 if is_empty {
180 format!("Command \"{command}\" failed with exit code {}.", exit_code)
181 } else {
182 format!(
183 "Command \"{command}\" failed with exit code {}.\n\n{content}",
184 exit_code
185 )
186 }
187 }
188 None => {
189 format!(
190 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
191 content,
192 )
193 }
194 };
195 content
196}
197
198fn working_dir(
199 input: &TerminalToolInput,
200 project: &Entity<Project>,
201 cx: &mut App,
202) -> Result<Option<PathBuf>> {
203 let project = project.read(cx);
204 let cd = &input.cd;
205
206 if cd == "." || cd.is_empty() {
207 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
208 let mut worktrees = project.worktrees(cx);
209
210 match worktrees.next() {
211 Some(worktree) => {
212 anyhow::ensure!(
213 worktrees.next().is_none(),
214 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
215 );
216 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
217 }
218 None => Ok(None),
219 }
220 } else {
221 let input_path = Path::new(cd);
222
223 if input_path.is_absolute() {
224 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
225 if project
226 .worktrees(cx)
227 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
228 {
229 return Ok(Some(input_path.into()));
230 }
231 } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
232 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
233 }
234
235 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
236 }
237}