1use agent_client_protocol as acp;
2use anyhow::Result;
3use gpui::{App, Entity, SharedString, Task};
4use project::Project;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::{
8 path::{Path, PathBuf},
9 rc::Rc,
10 sync::Arc,
11};
12use util::markdown::MarkdownInlineCode;
13
14use crate::{AgentTool, ThreadEnvironment, ToolCallEventStream};
15
16const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024;
17
18/// Executes a shell one-liner and returns the combined output.
19///
20/// 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.
21///
22/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
23///
24/// 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.
25///
26/// 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.
27///
28/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
29#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
30pub struct TerminalToolInput {
31 /// The one-liner command to execute.
32 command: String,
33 /// Working directory for the command. This must be one of the root directories of the project.
34 cd: String,
35}
36
37pub struct TerminalTool {
38 project: Entity<Project>,
39 environment: Rc<dyn ThreadEnvironment>,
40}
41
42impl TerminalTool {
43 pub fn new(project: Entity<Project>, environment: Rc<dyn ThreadEnvironment>) -> Self {
44 Self {
45 project,
46 environment,
47 }
48 }
49}
50
51impl AgentTool for TerminalTool {
52 type Input = TerminalToolInput;
53 type Output = String;
54
55 fn name() -> &'static str {
56 "terminal"
57 }
58
59 fn kind() -> acp::ToolKind {
60 acp::ToolKind::Execute
61 }
62
63 fn initial_title(
64 &self,
65 input: Result<Self::Input, serde_json::Value>,
66 _cx: &mut App,
67 ) -> SharedString {
68 if let Ok(input) = input {
69 let mut lines = input.command.lines();
70 let first_line = lines.next().unwrap_or_default();
71 let remaining_line_count = lines.count();
72 match remaining_line_count {
73 0 => MarkdownInlineCode(first_line).to_string().into(),
74 1 => MarkdownInlineCode(&format!(
75 "{} - {} more line",
76 first_line, remaining_line_count
77 ))
78 .to_string()
79 .into(),
80 n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
81 .to_string()
82 .into(),
83 }
84 } else {
85 "".into()
86 }
87 }
88
89 fn run(
90 self: Arc<Self>,
91 input: Self::Input,
92 event_stream: ToolCallEventStream,
93 cx: &mut App,
94 ) -> Task<Result<Self::Output>> {
95 let working_dir = match working_dir(&input, &self.project, cx) {
96 Ok(dir) => dir,
97 Err(err) => return Task::ready(Err(err)),
98 };
99
100 let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx);
101 cx.spawn(async move |cx| {
102 authorize.await?;
103
104 let terminal = self
105 .environment
106 .create_terminal(
107 input.command.clone(),
108 working_dir,
109 Some(COMMAND_OUTPUT_LIMIT),
110 cx,
111 )
112 .await?;
113
114 let terminal_id = terminal.id(cx)?;
115 event_stream.update_fields(acp::ToolCallUpdateFields {
116 content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]),
117 ..Default::default()
118 });
119
120 let exit_status = terminal.wait_for_exit(cx)?.await;
121 let output = terminal.current_output(cx)?;
122
123 Ok(process_content(output, &input.command, exit_status))
124 })
125 }
126}
127
128fn process_content(
129 output: acp::TerminalOutputResponse,
130 command: &str,
131 exit_status: acp::TerminalExitStatus,
132) -> String {
133 let content = output.output.trim();
134 let is_empty = content.is_empty();
135
136 let content = format!("```\n{content}\n```");
137 let content = if output.truncated {
138 format!(
139 "Command output too long. The first {} bytes:\n\n{content}",
140 content.len(),
141 )
142 } else {
143 content
144 };
145
146 let content = match exit_status.exit_code {
147 Some(0) => {
148 if is_empty {
149 "Command executed successfully.".to_string()
150 } else {
151 content
152 }
153 }
154 Some(exit_code) => {
155 if is_empty {
156 format!("Command \"{command}\" failed with exit code {}.", exit_code)
157 } else {
158 format!(
159 "Command \"{command}\" failed with exit code {}.\n\n{content}",
160 exit_code
161 )
162 }
163 }
164 None => {
165 format!(
166 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
167 content,
168 )
169 }
170 };
171 content
172}
173
174fn working_dir(
175 input: &TerminalToolInput,
176 project: &Entity<Project>,
177 cx: &mut App,
178) -> Result<Option<PathBuf>> {
179 let project = project.read(cx);
180 let cd = &input.cd;
181
182 if cd == "." || cd.is_empty() {
183 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
184 let mut worktrees = project.worktrees(cx);
185
186 match worktrees.next() {
187 Some(worktree) => {
188 anyhow::ensure!(
189 worktrees.next().is_none(),
190 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
191 );
192 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
193 }
194 None => Ok(None),
195 }
196 } else {
197 let input_path = Path::new(cd);
198
199 if input_path.is_absolute() {
200 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
201 if project
202 .worktrees(cx)
203 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
204 {
205 return Ok(Some(input_path.into()));
206 }
207 } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
208 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
209 }
210
211 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
212 }
213}