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