terminal_tool.rs

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