terminal_tool.rs

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