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