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(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
64 if let Ok(input) = input {
65 let mut lines = input.command.lines();
66 let first_line = lines.next().unwrap_or_default();
67 let remaining_line_count = lines.count();
68 match remaining_line_count {
69 0 => MarkdownInlineCode(first_line).to_string().into(),
70 1 => MarkdownInlineCode(&format!(
71 "{} - {} more line",
72 first_line, remaining_line_count
73 ))
74 .to_string()
75 .into(),
76 n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
77 .to_string()
78 .into(),
79 }
80 } else {
81 "Run terminal command".into()
82 }
83 }
84
85 fn run(
86 self: Arc<Self>,
87 input: Self::Input,
88 event_stream: ToolCallEventStream,
89 cx: &mut App,
90 ) -> Task<Result<Self::Output>> {
91 let working_dir = match working_dir(&input, &self.project, cx) {
92 Ok(dir) => dir,
93 Err(err) => return Task::ready(Err(err)),
94 };
95
96 let authorize = event_stream.authorize(self.initial_title(Ok(input.clone())), cx);
97 cx.spawn(async move |cx| {
98 authorize.await?;
99
100 let terminal = self
101 .environment
102 .create_terminal(
103 input.command.clone(),
104 working_dir,
105 Some(COMMAND_OUTPUT_LIMIT),
106 cx,
107 )
108 .await?;
109
110 let terminal_id = terminal.id(cx)?;
111 event_stream.update_fields(acp::ToolCallUpdateFields {
112 content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]),
113 ..Default::default()
114 });
115
116 let exit_status = terminal.wait_for_exit(cx)?.await;
117 let output = terminal.current_output(cx)?;
118
119 Ok(process_content(output, &input.command, exit_status))
120 })
121 }
122}
123
124fn process_content(
125 output: acp::TerminalOutputResponse,
126 command: &str,
127 exit_status: acp::TerminalExitStatus,
128) -> String {
129 let content = output.output.trim();
130 let is_empty = content.is_empty();
131
132 let content = format!("```\n{content}\n```");
133 let content = if output.truncated {
134 format!(
135 "Command output too long. The first {} bytes:\n\n{content}",
136 content.len(),
137 )
138 } else {
139 content
140 };
141
142 let content = match exit_status.exit_code {
143 Some(0) => {
144 if is_empty {
145 "Command executed successfully.".to_string()
146 } else {
147 content
148 }
149 }
150 Some(exit_code) => {
151 if is_empty {
152 format!("Command \"{command}\" failed with exit code {}.", exit_code)
153 } else {
154 format!(
155 "Command \"{command}\" failed with exit code {}.\n\n{content}",
156 exit_code
157 )
158 }
159 }
160 None => {
161 format!(
162 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
163 content,
164 )
165 }
166 };
167 content
168}
169
170fn working_dir(
171 input: &TerminalToolInput,
172 project: &Entity<Project>,
173 cx: &mut App,
174) -> Result<Option<PathBuf>> {
175 let project = project.read(cx);
176 let cd = &input.cd;
177
178 if cd == "." || cd.is_empty() {
179 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
180 let mut worktrees = project.worktrees(cx);
181
182 match worktrees.next() {
183 Some(worktree) => {
184 anyhow::ensure!(
185 worktrees.next().is_none(),
186 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
187 );
188 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
189 }
190 None => Ok(None),
191 }
192 } else {
193 let input_path = Path::new(cd);
194
195 if input_path.is_absolute() {
196 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
197 if project
198 .worktrees(cx)
199 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
200 {
201 return Ok(Some(input_path.into()));
202 }
203 } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
204 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
205 }
206
207 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
208 }
209}