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