terminal_tool.rs

  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}