1use crate::schema::json_schema_for;
2use anyhow::{Context as _, Result, anyhow};
3use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
4use gpui::{
5 Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
6 Transformation, WeakEntity, Window, percentage,
7};
8use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
9use project::{Project, terminals::TerminalKind};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::{
13 env,
14 path::{Path, PathBuf},
15 process::ExitStatus,
16 sync::Arc,
17 time::{Duration, Instant},
18};
19use terminal_view::TerminalView;
20use ui::{Disclosure, IconName, Tooltip, prelude::*};
21use util::{
22 get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
23 time::duration_alt_display,
24};
25use workspace::Workspace;
26
27const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
28
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
39impl Tool for TerminalTool {
40 fn name(&self) -> String {
41 "terminal".to_string()
42 }
43
44 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
45 true
46 }
47
48 fn description(&self) -> String {
49 include_str!("./terminal_tool/description.md").to_string()
50 }
51
52 fn icon(&self) -> IconName {
53 IconName::Terminal
54 }
55
56 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
57 json_schema_for::<TerminalToolInput>(format)
58 }
59
60 fn ui_text(&self, input: &serde_json::Value) -> String {
61 match serde_json::from_value::<TerminalToolInput>(input.clone()) {
62 Ok(input) => {
63 let mut lines = input.command.lines();
64 let first_line = lines.next().unwrap_or_default();
65 let remaining_line_count = lines.count();
66 match remaining_line_count {
67 0 => MarkdownInlineCode(&first_line).to_string(),
68 1 => MarkdownInlineCode(&format!(
69 "{} - {} more line",
70 first_line, remaining_line_count
71 ))
72 .to_string(),
73 n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
74 .to_string(),
75 }
76 }
77 Err(_) => "Run terminal command".to_string(),
78 }
79 }
80
81 fn run(
82 self: Arc<Self>,
83 input: serde_json::Value,
84 _messages: &[LanguageModelRequestMessage],
85 project: Entity<Project>,
86 _action_log: Entity<ActionLog>,
87 window: Option<AnyWindowHandle>,
88 cx: &mut App,
89 ) -> ToolResult {
90 let Some(window) = window else {
91 return Task::ready(Err(anyhow!("no window options"))).into();
92 };
93
94 let input: TerminalToolInput = match serde_json::from_value(input) {
95 Ok(input) => input,
96 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
97 };
98
99 let input_path = Path::new(&input.cd);
100 let working_dir = match working_dir(cx, &input, &project, input_path) {
101 Ok(dir) => dir,
102 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
103 };
104 let terminal = project.update(cx, |project, cx| {
105 project.create_terminal(
106 TerminalKind::Task(task::SpawnInTerminal {
107 command: get_system_shell(),
108 args: vec!["-c".into(), input.command.clone()],
109 cwd: working_dir.clone(),
110 ..Default::default()
111 }),
112 window,
113 cx,
114 )
115 });
116
117 let card = cx.new(|cx| {
118 TerminalToolCard::new(input.command.clone(), working_dir.clone(), cx.entity_id())
119 });
120
121 let output = cx.spawn({
122 let card = card.clone();
123 async move |cx| {
124 let terminal = terminal.await?;
125 let workspace = window
126 .downcast::<Workspace>()
127 .and_then(|handle| handle.entity(cx).ok())
128 .context("no workspace entity in root of window")?;
129
130 let terminal_view = window.update(cx, |_, window, cx| {
131 cx.new(|cx| {
132 TerminalView::new(
133 terminal.clone(),
134 workspace.downgrade(),
135 None,
136 project.downgrade(),
137 window,
138 cx,
139 )
140 })
141 })?;
142 let _ = card.update(cx, |card, _| {
143 card.terminal = Some(terminal_view.clone());
144 card.start_instant = Instant::now();
145 });
146
147 let exit_status = terminal
148 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
149 .await;
150 let (content, content_line_count) = terminal.update(cx, |terminal, _| {
151 (terminal.get_content(), terminal.total_lines())
152 })?;
153
154 let previous_len = content.len();
155 let (processed_content, finished_with_empty_output) =
156 process_content(content, &input.command, exit_status);
157
158 let _ = card.update(cx, |card, _| {
159 card.command_finished = true;
160 card.exit_status = exit_status;
161 card.was_content_truncated = processed_content.len() < previous_len;
162 card.original_content_len = previous_len;
163 card.content_line_count = content_line_count;
164 card.finished_with_empty_output = finished_with_empty_output;
165 card.elapsed_time = Some(card.start_instant.elapsed());
166 });
167
168 Ok(processed_content)
169 }
170 });
171
172 ToolResult {
173 output,
174 card: Some(card.into()),
175 }
176 }
177}
178
179fn process_content(
180 content: String,
181 command: &str,
182 exit_status: Option<ExitStatus>,
183) -> (String, bool) {
184 let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
185
186 let content = if should_truncate {
187 let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
188 while !content.is_char_boundary(end_ix) {
189 end_ix -= 1;
190 }
191 // Don't truncate mid-line, clear the remainder of the last line
192 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
193 &content[..end_ix]
194 } else {
195 content.as_str()
196 };
197 let is_empty = content.trim().is_empty();
198
199 let content = format!(
200 "```\n{}{}```",
201 content,
202 if content.ends_with('\n') { "" } else { "\n" }
203 );
204
205 let content = if should_truncate {
206 format!(
207 "Command output too long. The first {} bytes:\n\n{}",
208 content.len(),
209 content,
210 )
211 } else {
212 content
213 };
214
215 let content = match exit_status {
216 Some(exit_status) if exit_status.success() => {
217 if is_empty {
218 "Command executed successfully.".to_string()
219 } else {
220 content.to_string()
221 }
222 }
223 Some(exit_status) => {
224 let code = exit_status.code().unwrap_or(-1);
225 if is_empty {
226 format!("Command \"{command}\" failed with exit code {code}.")
227 } else {
228 format!("Command \"{command}\" failed with exit code {code}.\n\n{content}")
229 }
230 }
231 None => {
232 format!(
233 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
234 content,
235 )
236 }
237 };
238 (content, is_empty)
239}
240
241fn working_dir(
242 cx: &mut App,
243 input: &TerminalToolInput,
244 project: &Entity<Project>,
245 input_path: &Path,
246) -> Result<Option<PathBuf>, &'static str> {
247 let project = project.read(cx);
248
249 if input.cd == "." {
250 // Accept "." as meaning "the one worktree" if we only have one worktree.
251 let mut worktrees = project.worktrees(cx);
252
253 match worktrees.next() {
254 Some(worktree) => {
255 if worktrees.next().is_some() {
256 return Err(
257 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
258 );
259 }
260 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
261 }
262 None => Ok(None),
263 }
264 } else if input_path.is_absolute() {
265 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
266 if !project
267 .worktrees(cx)
268 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
269 {
270 return Err("The absolute path must be within one of the project's worktrees");
271 }
272
273 Ok(Some(input_path.into()))
274 } else {
275 let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
276 return Err("`cd` directory {} not found in the project");
277 };
278
279 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
280 }
281}
282
283struct TerminalToolCard {
284 input_command: String,
285 working_dir: Option<PathBuf>,
286 entity_id: EntityId,
287 exit_status: Option<ExitStatus>,
288 terminal: Option<Entity<TerminalView>>,
289 command_finished: bool,
290 was_content_truncated: bool,
291 finished_with_empty_output: bool,
292 content_line_count: usize,
293 original_content_len: usize,
294 preview_expanded: bool,
295 start_instant: Instant,
296 elapsed_time: Option<Duration>,
297}
298
299impl TerminalToolCard {
300 pub fn new(input_command: String, working_dir: Option<PathBuf>, entity_id: EntityId) -> Self {
301 Self {
302 input_command,
303 working_dir,
304 entity_id,
305 exit_status: None,
306 terminal: None,
307 command_finished: false,
308 was_content_truncated: false,
309 finished_with_empty_output: false,
310 original_content_len: 0,
311 content_line_count: 0,
312 preview_expanded: true,
313 start_instant: Instant::now(),
314 elapsed_time: None,
315 }
316 }
317}
318
319impl ToolCard for TerminalToolCard {
320 fn render(
321 &mut self,
322 status: &ToolUseStatus,
323 _window: &mut Window,
324 _workspace: WeakEntity<Workspace>,
325 cx: &mut Context<Self>,
326 ) -> impl IntoElement {
327 let Some(terminal) = self.terminal.as_ref() else {
328 return Empty.into_any();
329 };
330
331 let tool_failed = matches!(status, ToolUseStatus::Error(_));
332 let command_failed =
333 self.command_finished && self.exit_status.is_none_or(|code| !code.success());
334 if (tool_failed || command_failed) && self.elapsed_time.is_none() {
335 self.elapsed_time = Some(self.start_instant.elapsed());
336 }
337 let time_elapsed = self
338 .elapsed_time
339 .unwrap_or_else(|| self.start_instant.elapsed());
340 let should_hide_terminal =
341 tool_failed || self.finished_with_empty_output || !self.preview_expanded;
342
343 let border_color = cx.theme().colors().border.opacity(0.6);
344 let header_bg = cx
345 .theme()
346 .colors()
347 .element_background
348 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
349
350 let header_label = h_flex()
351 .w_full()
352 .max_w_full()
353 .px_1()
354 .gap_0p5()
355 .opacity(0.8)
356 .child(
357 h_flex()
358 .child(
359 Icon::new(IconName::Terminal)
360 .size(IconSize::XSmall)
361 .color(Color::Muted),
362 )
363 .child(
364 div()
365 .id(("terminal-tool-header-input-command", self.entity_id))
366 .text_size(rems(0.8125))
367 .font_buffer(cx)
368 .child(self.input_command.clone())
369 .ml_1p5()
370 .mr_0p5()
371 .tooltip({
372 let path = self
373 .working_dir
374 .as_ref()
375 .cloned()
376 .or_else(|| env::current_dir().ok())
377 .map(|path| format!("\"{}\"", path.display()))
378 .unwrap_or_else(|| "current directory".to_string());
379 Tooltip::text(if self.command_finished {
380 format!("Ran in {path}")
381 } else {
382 format!("Running in {path}")
383 })
384 }),
385 ),
386 )
387 .into_any_element();
388
389 let header = h_flex()
390 .flex_none()
391 .p_1()
392 .gap_1()
393 .justify_between()
394 .rounded_t_md()
395 .bg(header_bg)
396 .child(header_label)
397 .map(|header| {
398 let header = header
399 .when(self.was_content_truncated, |header| {
400 let tooltip =
401 if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
402 "Output exceeded terminal max lines and was \
403 truncated, the model received the first 16 KB."
404 .to_string()
405 } else {
406 format!(
407 "Output is {} long, to avoid unexpected token usage, \
408 only 16 KB was sent back to the model.",
409 format_file_size(self.original_content_len as u64, true),
410 )
411 };
412 header.child(
413 div()
414 .id(("terminal-tool-truncated-label", self.entity_id))
415 .tooltip(Tooltip::text(tooltip))
416 .child(
417 Label::new("(truncated)")
418 .color(Color::Disabled)
419 .size(LabelSize::Small),
420 ),
421 )
422 })
423 .when(time_elapsed > Duration::from_secs(10), |header| {
424 header.child(
425 Label::new(format!("({})", duration_alt_display(time_elapsed)))
426 .buffer_font(cx)
427 .color(Color::Disabled)
428 .size(LabelSize::Small),
429 )
430 });
431
432 if tool_failed || command_failed {
433 header.child(
434 div()
435 .id(("terminal-tool-error-code-indicator", self.entity_id))
436 .child(
437 Icon::new(IconName::Close)
438 .size(IconSize::Small)
439 .color(Color::Error),
440 )
441 .when(command_failed && self.exit_status.is_some(), |this| {
442 this.tooltip(Tooltip::text(format!(
443 "Exited with code {}",
444 self.exit_status
445 .and_then(|status| status.code())
446 .unwrap_or(-1),
447 )))
448 })
449 .when(
450 !command_failed && tool_failed && status.error().is_some(),
451 |this| {
452 this.tooltip(Tooltip::text(format!(
453 "Error: {}",
454 status.error().unwrap(),
455 )))
456 },
457 ),
458 )
459 } else if self.command_finished {
460 header.child(
461 Icon::new(IconName::Check)
462 .size(IconSize::Small)
463 .color(Color::Success),
464 )
465 } else {
466 header.child(
467 Icon::new(IconName::ArrowCircle)
468 .size(IconSize::Small)
469 .color(Color::Info)
470 .with_animation(
471 "arrow-circle",
472 Animation::new(Duration::from_secs(2)).repeat(),
473 |icon, delta| {
474 icon.transform(Transformation::rotate(percentage(delta)))
475 },
476 ),
477 )
478 }
479 })
480 .when(!tool_failed && !self.finished_with_empty_output, |header| {
481 header.child(
482 Disclosure::new(
483 ("terminal-tool-disclosure", self.entity_id),
484 self.preview_expanded,
485 )
486 .opened_icon(IconName::ChevronUp)
487 .closed_icon(IconName::ChevronDown)
488 .on_click(cx.listener(
489 move |this, _event, _window, _cx| {
490 this.preview_expanded = !this.preview_expanded;
491 },
492 )),
493 )
494 });
495
496 v_flex()
497 .mb_2()
498 .border_1()
499 .when(tool_failed || command_failed, |card| card.border_dashed())
500 .border_color(border_color)
501 .rounded_lg()
502 .overflow_hidden()
503 .child(header)
504 .when(!should_hide_terminal, |this| {
505 this.child(div().child(terminal.clone()).min_h(px(250.0)))
506 })
507 .into_any()
508 }
509}