1use crate::schema::json_schema_for;
2use anyhow::{Context as _, Result, anyhow, bail};
3use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
4use gpui::{AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, WeakEntity, Window};
5use language::LineEnding;
6use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
7use portable_pty::{CommandBuilder, PtySize, native_pty_system};
8use project::{Project, terminals::TerminalKind};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::{
12 env,
13 path::{Path, PathBuf},
14 process::ExitStatus,
15 sync::Arc,
16 time::{Duration, Instant},
17};
18use terminal_view::TerminalView;
19use ui::{Disclosure, Tooltip, prelude::*};
20use util::{
21 get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
22 time::duration_alt_display,
23};
24use workspace::Workspace;
25
26const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
27
28#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
29pub struct TerminalToolInput {
30 /// The one-liner command to execute.
31 command: String,
32 /// Working directory for the command. This must be one of the root directories of the project.
33 cd: String,
34}
35
36pub struct TerminalTool;
37
38impl Tool for TerminalTool {
39 fn name(&self) -> String {
40 "terminal".to_string()
41 }
42
43 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
44 true
45 }
46
47 fn description(&self) -> String {
48 include_str!("./terminal_tool/description.md").to_string()
49 }
50
51 fn icon(&self) -> IconName {
52 IconName::Terminal
53 }
54
55 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
56 json_schema_for::<TerminalToolInput>(format)
57 }
58
59 fn ui_text(&self, input: &serde_json::Value) -> String {
60 match serde_json::from_value::<TerminalToolInput>(input.clone()) {
61 Ok(input) => {
62 let mut lines = input.command.lines();
63 let first_line = lines.next().unwrap_or_default();
64 let remaining_line_count = lines.count();
65 match remaining_line_count {
66 0 => MarkdownInlineCode(&first_line).to_string(),
67 1 => MarkdownInlineCode(&format!(
68 "{} - {} more line",
69 first_line, remaining_line_count
70 ))
71 .to_string(),
72 n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
73 .to_string(),
74 }
75 }
76 Err(_) => "Run terminal command".to_string(),
77 }
78 }
79
80 fn run(
81 self: Arc<Self>,
82 input: serde_json::Value,
83 _messages: &[LanguageModelRequestMessage],
84 project: Entity<Project>,
85 _action_log: Entity<ActionLog>,
86 window: Option<AnyWindowHandle>,
87 cx: &mut App,
88 ) -> ToolResult {
89 let input: TerminalToolInput = match serde_json::from_value(input) {
90 Ok(input) => input,
91 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
92 };
93
94 let input_path = Path::new(&input.cd);
95 let working_dir = match working_dir(&input, &project, input_path, cx) {
96 Ok(dir) => dir,
97 Err(err) => return Task::ready(Err(err)).into(),
98 };
99 let command = get_system_shell();
100 let args = vec!["-c".into(), input.command.clone()];
101 let cwd = working_dir.clone();
102
103 let Some(window) = window else {
104 // Headless setup, a test or eval. Our terminal subsystem requires a workspace,
105 // so bypass it and provide a convincing imitation using a pty.
106 let task = cx.background_spawn(async move {
107 let pty_system = native_pty_system();
108 let mut cmd = CommandBuilder::new(command);
109 cmd.args(args);
110 if let Some(cwd) = cwd {
111 cmd.cwd(cwd);
112 }
113 let pair = pty_system.openpty(PtySize {
114 rows: 24,
115 cols: 80,
116 ..Default::default()
117 })?;
118 let mut child = pair.slave.spawn_command(cmd)?;
119 let mut reader = pair.master.try_clone_reader()?;
120 drop(pair);
121 let mut content = Vec::new();
122 reader.read_to_end(&mut content)?;
123 let mut content = String::from_utf8(content)?;
124 // Massage the pty output a bit to try to match what the terminal codepath gives us
125 LineEnding::normalize(&mut content);
126 content = content
127 .chars()
128 .filter(|c| c.is_ascii_whitespace() || !c.is_ascii_control())
129 .collect();
130 let content = content.trim_start().trim_start_matches("^D");
131 let exit_status = child.wait()?;
132 let (processed_content, _) =
133 process_content(content, &input.command, Some(exit_status));
134 Ok(processed_content)
135 });
136 return ToolResult {
137 output: task,
138 card: None,
139 };
140 };
141
142 let terminal = project.update(cx, |project, cx| {
143 project.create_terminal(
144 TerminalKind::Task(task::SpawnInTerminal {
145 command,
146 args,
147 cwd,
148 ..Default::default()
149 }),
150 window,
151 cx,
152 )
153 });
154
155 let card = cx.new(|cx| {
156 TerminalToolCard::new(input.command.clone(), working_dir.clone(), cx.entity_id())
157 });
158
159 let output = cx.spawn({
160 let card = card.clone();
161 async move |cx| {
162 let terminal = terminal.await?;
163 let workspace = window
164 .downcast::<Workspace>()
165 .and_then(|handle| handle.entity(cx).ok())
166 .context("no workspace entity in root of window")?;
167
168 let terminal_view = window.update(cx, |_, window, cx| {
169 cx.new(|cx| {
170 TerminalView::new(
171 terminal.clone(),
172 workspace.downgrade(),
173 None,
174 project.downgrade(),
175 true,
176 window,
177 cx,
178 )
179 })
180 })?;
181
182 let _ = card.update(cx, |card, _| {
183 card.terminal = Some(terminal_view.clone());
184 card.start_instant = Instant::now();
185 });
186
187 let exit_status = terminal
188 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
189 .await;
190 let (content, content_line_count) = terminal.update(cx, |terminal, _| {
191 (terminal.get_content(), terminal.total_lines())
192 })?;
193
194 let previous_len = content.len();
195 let (processed_content, finished_with_empty_output) = process_content(
196 &content,
197 &input.command,
198 exit_status.map(portable_pty::ExitStatus::from),
199 );
200
201 let _ = card.update(cx, |card, _| {
202 card.command_finished = true;
203 card.exit_status = exit_status;
204 card.was_content_truncated = processed_content.len() < previous_len;
205 card.original_content_len = previous_len;
206 card.content_line_count = content_line_count;
207 card.finished_with_empty_output = finished_with_empty_output;
208 card.elapsed_time = Some(card.start_instant.elapsed());
209 });
210
211 Ok(processed_content)
212 }
213 });
214
215 ToolResult {
216 output,
217 card: Some(card.into()),
218 }
219 }
220}
221
222fn process_content(
223 content: &str,
224 command: &str,
225 exit_status: Option<portable_pty::ExitStatus>,
226) -> (String, bool) {
227 let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
228
229 let content = if should_truncate {
230 let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
231 while !content.is_char_boundary(end_ix) {
232 end_ix -= 1;
233 }
234 // Don't truncate mid-line, clear the remainder of the last line
235 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
236 &content[..end_ix]
237 } else {
238 content
239 };
240 let is_empty = content.trim().is_empty();
241
242 let content = format!(
243 "```\n{}{}```",
244 content,
245 if content.ends_with('\n') { "" } else { "\n" }
246 );
247
248 let content = if should_truncate {
249 format!(
250 "Command output too long. The first {} bytes:\n\n{}",
251 content.len(),
252 content,
253 )
254 } else {
255 content
256 };
257
258 let content = match exit_status {
259 Some(exit_status) if exit_status.success() => {
260 if is_empty {
261 "Command executed successfully.".to_string()
262 } else {
263 content.to_string()
264 }
265 }
266 Some(exit_status) => {
267 if is_empty {
268 format!(
269 "Command \"{command}\" failed with exit code {}.",
270 exit_status.exit_code()
271 )
272 } else {
273 format!(
274 "Command \"{command}\" failed with exit code {}.\n\n{content}",
275 exit_status.exit_code()
276 )
277 }
278 }
279 None => {
280 format!(
281 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
282 content,
283 )
284 }
285 };
286 (content, is_empty)
287}
288
289fn working_dir(
290 input: &TerminalToolInput,
291 project: &Entity<Project>,
292 input_path: &Path,
293 cx: &mut App,
294) -> Result<Option<PathBuf>> {
295 let project = project.read(cx);
296
297 if input.cd == "." {
298 // Accept "." as meaning "the one worktree" if we only have one worktree.
299 let mut worktrees = project.worktrees(cx);
300
301 match worktrees.next() {
302 Some(worktree) => {
303 if worktrees.next().is_some() {
304 bail!(
305 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
306 );
307 }
308 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
309 }
310 None => Ok(None),
311 }
312 } else if input_path.is_absolute() {
313 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
314 if !project
315 .worktrees(cx)
316 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
317 {
318 bail!("The absolute path must be within one of the project's worktrees");
319 }
320
321 Ok(Some(input_path.into()))
322 } else {
323 let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
324 bail!("`cd` directory {:?} not found in the project", input.cd);
325 };
326
327 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
328 }
329}
330
331struct TerminalToolCard {
332 input_command: String,
333 working_dir: Option<PathBuf>,
334 entity_id: EntityId,
335 exit_status: Option<ExitStatus>,
336 terminal: Option<Entity<TerminalView>>,
337 command_finished: bool,
338 was_content_truncated: bool,
339 finished_with_empty_output: bool,
340 content_line_count: usize,
341 original_content_len: usize,
342 preview_expanded: bool,
343 start_instant: Instant,
344 elapsed_time: Option<Duration>,
345}
346
347impl TerminalToolCard {
348 pub fn new(input_command: String, working_dir: Option<PathBuf>, entity_id: EntityId) -> Self {
349 Self {
350 input_command,
351 working_dir,
352 entity_id,
353 exit_status: None,
354 terminal: None,
355 command_finished: false,
356 was_content_truncated: false,
357 finished_with_empty_output: false,
358 original_content_len: 0,
359 content_line_count: 0,
360 preview_expanded: true,
361 start_instant: Instant::now(),
362 elapsed_time: None,
363 }
364 }
365}
366
367impl ToolCard for TerminalToolCard {
368 fn render(
369 &mut self,
370 status: &ToolUseStatus,
371 _window: &mut Window,
372 _workspace: WeakEntity<Workspace>,
373 cx: &mut Context<Self>,
374 ) -> impl IntoElement {
375 let Some(terminal) = self.terminal.as_ref() else {
376 return Empty.into_any();
377 };
378
379 let tool_failed = matches!(status, ToolUseStatus::Error(_));
380
381 let command_failed =
382 self.command_finished && self.exit_status.is_none_or(|code| !code.success());
383
384 if (tool_failed || command_failed) && self.elapsed_time.is_none() {
385 self.elapsed_time = Some(self.start_instant.elapsed());
386 }
387 let time_elapsed = self
388 .elapsed_time
389 .unwrap_or_else(|| self.start_instant.elapsed());
390 let should_hide_terminal = tool_failed || self.finished_with_empty_output;
391
392 let header_bg = cx
393 .theme()
394 .colors()
395 .element_background
396 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
397
398 let border_color = cx.theme().colors().border.opacity(0.6);
399
400 let path = self
401 .working_dir
402 .as_ref()
403 .cloned()
404 .or_else(|| env::current_dir().ok())
405 .map(|path| format!("{}", path.display()))
406 .unwrap_or_else(|| "current directory".to_string());
407
408 let header = h_flex()
409 .flex_none()
410 .gap_1()
411 .justify_between()
412 .rounded_t_md()
413 .child(
414 div()
415 .id(("command-target-path", self.entity_id))
416 .w_full()
417 .max_w_full()
418 .overflow_x_scroll()
419 .child(
420 Label::new(path)
421 .buffer_font(cx)
422 .size(LabelSize::XSmall)
423 .color(Color::Muted),
424 ),
425 )
426 .when(self.was_content_truncated, |header| {
427 let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
428 "Output exceeded terminal max lines and was \
429 truncated, the model received the first 16 KB."
430 .to_string()
431 } else {
432 format!(
433 "Output is {} long, to avoid unexpected token usage, \
434 only 16 KB was sent back to the model.",
435 format_file_size(self.original_content_len as u64, true),
436 )
437 };
438 header.child(
439 h_flex()
440 .id(("terminal-tool-truncated-label", self.entity_id))
441 .tooltip(Tooltip::text(tooltip))
442 .gap_1()
443 .child(
444 Icon::new(IconName::Info)
445 .size(IconSize::XSmall)
446 .color(Color::Ignored),
447 )
448 .child(
449 Label::new("Truncated")
450 .color(Color::Muted)
451 .size(LabelSize::Small),
452 ),
453 )
454 })
455 .when(time_elapsed > Duration::from_secs(10), |header| {
456 header.child(
457 Label::new(format!("({})", duration_alt_display(time_elapsed)))
458 .buffer_font(cx)
459 .color(Color::Muted)
460 .size(LabelSize::Small),
461 )
462 })
463 .when(tool_failed || command_failed, |header| {
464 header.child(
465 div()
466 .id(("terminal-tool-error-code-indicator", self.entity_id))
467 .child(
468 Icon::new(IconName::Close)
469 .size(IconSize::Small)
470 .color(Color::Error),
471 )
472 .when(command_failed && self.exit_status.is_some(), |this| {
473 this.tooltip(Tooltip::text(format!(
474 "Exited with code {}",
475 self.exit_status
476 .and_then(|status| status.code())
477 .unwrap_or(-1),
478 )))
479 })
480 .when(
481 !command_failed && tool_failed && status.error().is_some(),
482 |this| {
483 this.tooltip(Tooltip::text(format!(
484 "Error: {}",
485 status.error().unwrap(),
486 )))
487 },
488 ),
489 )
490 })
491 .when(!should_hide_terminal, |header| {
492 header.child(
493 Disclosure::new(
494 ("terminal-tool-disclosure", self.entity_id),
495 self.preview_expanded,
496 )
497 .opened_icon(IconName::ChevronUp)
498 .closed_icon(IconName::ChevronDown)
499 .on_click(cx.listener(
500 move |this, _event, _window, _cx| {
501 this.preview_expanded = !this.preview_expanded;
502 },
503 )),
504 )
505 });
506
507 v_flex()
508 .mb_2()
509 .border_1()
510 .when(tool_failed || command_failed, |card| card.border_dashed())
511 .border_color(border_color)
512 .rounded_lg()
513 .overflow_hidden()
514 .child(
515 v_flex().p_2().gap_0p5().bg(header_bg).child(header).child(
516 Label::new(self.input_command.clone())
517 .buffer_font(cx)
518 .size(LabelSize::Small),
519 ),
520 )
521 .when(self.preview_expanded && !should_hide_terminal, |this| {
522 this.child(
523 div()
524 .pt_2()
525 .min_h_72()
526 .border_t_1()
527 .border_color(border_color)
528 .bg(cx.theme().colors().editor_background)
529 .rounded_b_md()
530 .text_ui_sm(cx)
531 .child(terminal.clone()),
532 )
533 })
534 .into_any()
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use editor::EditorSettings;
541 use fs::RealFs;
542 use gpui::{BackgroundExecutor, TestAppContext};
543 use pretty_assertions::assert_eq;
544 use serde_json::json;
545 use settings::{Settings, SettingsStore};
546 use terminal::terminal_settings::TerminalSettings;
547 use theme::ThemeSettings;
548 use util::{ResultExt as _, test::TempTree};
549
550 use super::*;
551
552 #[gpui::test]
553 async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
554 if cfg!(windows) {
555 return;
556 }
557
558 zlog::init();
559 zlog::init_output_stdout();
560 executor.allow_parking();
561 cx.update(|cx| {
562 let settings_store = SettingsStore::test(cx);
563 cx.set_global(settings_store);
564 language::init(cx);
565 Project::init_settings(cx);
566 workspace::init_settings(cx);
567 ThemeSettings::register(cx);
568 TerminalSettings::register(cx);
569 EditorSettings::register(cx);
570 });
571
572 let fs = Arc::new(RealFs::new(None, executor));
573 let tree = TempTree::new(json!({
574 "project": {},
575 "other-project": {},
576 }));
577 let project: Entity<Project> =
578 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
579 let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
580
581 let check = |input, expected, cx: &mut App| {
582 let headless_result = TerminalTool::run(
583 Arc::new(TerminalTool),
584 serde_json::to_value(input).unwrap(),
585 &[],
586 project.clone(),
587 action_log.clone(),
588 None,
589 cx,
590 );
591 cx.spawn(async move |_| {
592 let output = headless_result.output.await.log_err();
593 assert_eq!(output, expected);
594 })
595 };
596
597 cx.update(|cx| {
598 check(
599 TerminalToolInput {
600 command: "pwd".into(),
601 cd: "project".into(),
602 },
603 Some(format!(
604 "```\n{}\n```",
605 tree.path().join("project").display()
606 )),
607 cx,
608 )
609 })
610 .await;
611
612 cx.update(|cx| {
613 check(
614 TerminalToolInput {
615 command: "pwd".into(),
616 cd: ".".into(),
617 },
618 Some(format!(
619 "```\n{}\n```",
620 tree.path().join("project").display()
621 )),
622 cx,
623 )
624 })
625 .await;
626
627 // Absolute path above the worktree root
628 cx.update(|cx| {
629 check(
630 TerminalToolInput {
631 command: "pwd".into(),
632 cd: tree.path().to_string_lossy().into(),
633 },
634 None,
635 cx,
636 )
637 })
638 .await;
639
640 project
641 .update(cx, |project, cx| {
642 project.create_worktree(tree.path().join("other-project"), true, cx)
643 })
644 .await
645 .unwrap();
646
647 cx.update(|cx| {
648 check(
649 TerminalToolInput {
650 command: "pwd".into(),
651 cd: "other-project".into(),
652 },
653 Some(format!(
654 "```\n{}\n```",
655 tree.path().join("other-project").display()
656 )),
657 cx,
658 )
659 })
660 .await;
661
662 cx.update(|cx| {
663 check(
664 TerminalToolInput {
665 command: "pwd".into(),
666 cd: ".".into(),
667 },
668 None,
669 cx,
670 )
671 })
672 .await;
673 }
674}