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::{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}