terminal_tool.rs

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