terminal_tool.rs

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