terminal_tool.rs

  1use crate::schema::json_schema_for;
  2use anyhow::{Context as _, Result, anyhow, bail};
  3use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
  4use futures::{FutureExt as _, future::Shared};
  5use gpui::{
  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 input_path = Path::new(&input.cd);
129        let working_dir = match working_dir(&input, &project, input_path, cx) {
130            Ok(dir) => dir,
131            Err(err) => return Task::ready(Err(err)).into(),
132        };
133        let program = self.determine_shell.clone();
134        let command = if cfg!(windows) {
135            format!("$null | & {{{}}}", input.command.replace("\"", "'"))
136        } else {
137            format!("({}) </dev/null", input.command)
138        };
139        let args = vec!["-c".into(), command];
140        let cwd = working_dir.clone();
141        let env = match &working_dir {
142            Some(dir) => project.update(cx, |project, cx| {
143                project.directory_environment(dir.as_path().into(), cx)
144            }),
145            None => Task::ready(None).shared(),
146        };
147
148        let env = cx.spawn(async move |_| {
149            let mut env = env.await.unwrap_or_default();
150            if cfg!(unix) {
151                env.insert("PAGER".into(), "cat".into());
152            }
153            env
154        });
155
156        let Some(window) = window else {
157            // Headless setup, a test or eval. Our terminal subsystem requires a workspace,
158            // so bypass it and provide a convincing imitation using a pty.
159            let task = cx.background_spawn(async move {
160                let env = env.await;
161                let pty_system = native_pty_system();
162                let program = program.await;
163                let mut cmd = CommandBuilder::new(program);
164                cmd.args(args);
165                for (k, v) in env {
166                    cmd.env(k, v);
167                }
168                if let Some(cwd) = cwd {
169                    cmd.cwd(cwd);
170                }
171                let pair = pty_system.openpty(PtySize {
172                    rows: 24,
173                    cols: 80,
174                    ..Default::default()
175                })?;
176                let mut child = pair.slave.spawn_command(cmd)?;
177                let mut reader = pair.master.try_clone_reader()?;
178                drop(pair);
179                let mut content = Vec::new();
180                reader.read_to_end(&mut content)?;
181                let mut content = String::from_utf8(content)?;
182                // Massage the pty output a bit to try to match what the terminal codepath gives us
183                LineEnding::normalize(&mut content);
184                content = content
185                    .chars()
186                    .filter(|c| c.is_ascii_whitespace() || !c.is_ascii_control())
187                    .collect();
188                let content = content.trim_start().trim_start_matches("^D");
189                let exit_status = child.wait()?;
190                let (processed_content, _) =
191                    process_content(content, &input.command, Some(exit_status));
192                Ok(processed_content.into())
193            });
194            return ToolResult {
195                output: task,
196                card: None,
197            };
198        };
199
200        let terminal = cx.spawn({
201            let project = project.downgrade();
202            async move |cx| {
203                let program = program.await;
204                let env = env.await;
205                let terminal = project
206                    .update(cx, |project, cx| {
207                        project.create_terminal(
208                            TerminalKind::Task(task::SpawnInTerminal {
209                                command: program,
210                                args,
211                                cwd,
212                                env,
213                                ..Default::default()
214                            }),
215                            window,
216                            cx,
217                        )
218                    })?
219                    .await;
220                terminal
221            }
222        });
223
224        let command_markdown = cx.new(|cx| {
225            Markdown::new(
226                format!("```bash\n{}\n```", input.command).into(),
227                None,
228                None,
229                cx,
230            )
231        });
232
233        let card = cx.new(|cx| {
234            TerminalToolCard::new(
235                command_markdown.clone(),
236                working_dir.clone(),
237                cx.entity_id(),
238            )
239        });
240
241        let output = cx.spawn({
242            let card = card.clone();
243            async move |cx| {
244                let terminal = terminal.await?;
245                let workspace = window
246                    .downcast::<Workspace>()
247                    .and_then(|handle| handle.entity(cx).ok())
248                    .context("no workspace entity in root of window")?;
249
250                let terminal_view = window.update(cx, |_, window, cx| {
251                    cx.new(|cx| {
252                        TerminalView::new(
253                            terminal.clone(),
254                            workspace.downgrade(),
255                            None,
256                            project.downgrade(),
257                            true,
258                            window,
259                            cx,
260                        )
261                    })
262                })?;
263
264                let _ = card.update(cx, |card, _| {
265                    card.terminal = Some(terminal_view.clone());
266                    card.start_instant = Instant::now();
267                });
268
269                let exit_status = terminal
270                    .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
271                    .await;
272                let (content, content_line_count) = terminal.update(cx, |terminal, _| {
273                    (terminal.get_content(), terminal.total_lines())
274                })?;
275
276                let previous_len = content.len();
277                let (processed_content, finished_with_empty_output) = process_content(
278                    &content,
279                    &input.command,
280                    exit_status.map(portable_pty::ExitStatus::from),
281                );
282
283                let _ = card.update(cx, |card, _| {
284                    card.command_finished = true;
285                    card.exit_status = exit_status;
286                    card.was_content_truncated = processed_content.len() < previous_len;
287                    card.original_content_len = previous_len;
288                    card.content_line_count = content_line_count;
289                    card.finished_with_empty_output = finished_with_empty_output;
290                    card.elapsed_time = Some(card.start_instant.elapsed());
291                });
292
293                Ok(processed_content.into())
294            }
295        });
296
297        ToolResult {
298            output,
299            card: Some(card.into()),
300        }
301    }
302}
303
304fn process_content(
305    content: &str,
306    command: &str,
307    exit_status: Option<portable_pty::ExitStatus>,
308) -> (String, bool) {
309    let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
310
311    let content = if should_truncate {
312        let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
313        while !content.is_char_boundary(end_ix) {
314            end_ix -= 1;
315        }
316        // Don't truncate mid-line, clear the remainder of the last line
317        end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
318        &content[..end_ix]
319    } else {
320        content
321    };
322    let is_empty = content.trim().is_empty();
323
324    let content = format!(
325        "```\n{}{}```",
326        content,
327        if content.ends_with('\n') { "" } else { "\n" }
328    );
329
330    let content = if should_truncate {
331        format!(
332            "Command output too long. The first {} bytes:\n\n{}",
333            content.len(),
334            content,
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    input_path: &Path,
375    cx: &mut App,
376) -> Result<Option<PathBuf>> {
377    let project = project.read(cx);
378
379    if input.cd == "." {
380        // Accept "." 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_some() {
386                    bail!(
387                        "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
388                    );
389                }
390                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
391            }
392            None => Ok(None),
393        }
394    } else if input_path.is_absolute() {
395        // Absolute paths are allowed, but only if they're in one of the project's worktrees.
396        if !project
397            .worktrees(cx)
398            .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
399        {
400            bail!("The absolute path must be within one of the project's worktrees");
401        }
402
403        Ok(Some(input_path.into()))
404    } else {
405        let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
406            bail!("`cd` directory {:?} not found in the project", input.cd);
407        };
408
409        Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
410    }
411}
412
413struct TerminalToolCard {
414    input_command: Entity<Markdown>,
415    working_dir: Option<PathBuf>,
416    entity_id: EntityId,
417    exit_status: Option<ExitStatus>,
418    terminal: Option<Entity<TerminalView>>,
419    command_finished: bool,
420    was_content_truncated: bool,
421    finished_with_empty_output: bool,
422    content_line_count: usize,
423    original_content_len: usize,
424    preview_expanded: bool,
425    start_instant: Instant,
426    elapsed_time: Option<Duration>,
427}
428
429impl TerminalToolCard {
430    pub fn new(
431        input_command: Entity<Markdown>,
432        working_dir: Option<PathBuf>,
433        entity_id: EntityId,
434    ) -> Self {
435        Self {
436            input_command,
437            working_dir,
438            entity_id,
439            exit_status: None,
440            terminal: None,
441            command_finished: false,
442            was_content_truncated: false,
443            finished_with_empty_output: false,
444            original_content_len: 0,
445            content_line_count: 0,
446            preview_expanded: true,
447            start_instant: Instant::now(),
448            elapsed_time: None,
449        }
450    }
451}
452
453impl ToolCard for TerminalToolCard {
454    fn render(
455        &mut self,
456        status: &ToolUseStatus,
457        window: &mut Window,
458        _workspace: WeakEntity<Workspace>,
459        cx: &mut Context<Self>,
460    ) -> impl IntoElement {
461        let Some(terminal) = self.terminal.as_ref() else {
462            return Empty.into_any();
463        };
464
465        let tool_failed = matches!(status, ToolUseStatus::Error(_));
466
467        let command_failed =
468            self.command_finished && self.exit_status.is_none_or(|code| !code.success());
469
470        if (tool_failed || command_failed) && self.elapsed_time.is_none() {
471            self.elapsed_time = Some(self.start_instant.elapsed());
472        }
473        let time_elapsed = self
474            .elapsed_time
475            .unwrap_or_else(|| self.start_instant.elapsed());
476        let should_hide_terminal = tool_failed || self.finished_with_empty_output;
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(!should_hide_terminal, |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(self.preview_expanded && !should_hide_terminal, |this| {
622                this.child(
623                    div()
624                        .pt_2()
625                        .min_h_72()
626                        .border_t_1()
627                        .border_color(border_color)
628                        .bg(cx.theme().colors().editor_background)
629                        .rounded_b_md()
630                        .text_ui_sm(cx)
631                        .child(terminal.clone()),
632                )
633            })
634            .into_any()
635    }
636}
637
638fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
639    let theme_settings = ThemeSettings::get_global(cx);
640    let buffer_font_size = TextSize::Default.rems(cx);
641    let mut text_style = window.text_style();
642
643    text_style.refine(&TextStyleRefinement {
644        font_family: Some(theme_settings.buffer_font.family.clone()),
645        font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
646        font_features: Some(theme_settings.buffer_font.features.clone()),
647        font_size: Some(buffer_font_size.into()),
648        color: Some(cx.theme().colors().text),
649        ..Default::default()
650    });
651
652    MarkdownStyle {
653        base_text_style: text_style.clone(),
654        selection_background_color: cx.theme().players().local().selection,
655        ..Default::default()
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use editor::EditorSettings;
662    use fs::RealFs;
663    use gpui::{BackgroundExecutor, TestAppContext};
664    use language_model::fake_provider::FakeLanguageModel;
665    use pretty_assertions::assert_eq;
666    use serde_json::json;
667    use settings::{Settings, SettingsStore};
668    use terminal::terminal_settings::TerminalSettings;
669    use theme::ThemeSettings;
670    use util::{ResultExt as _, test::TempTree};
671
672    use super::*;
673
674    fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
675        zlog::init();
676        zlog::init_output_stdout();
677
678        executor.allow_parking();
679        cx.update(|cx| {
680            let settings_store = SettingsStore::test(cx);
681            cx.set_global(settings_store);
682            language::init(cx);
683            Project::init_settings(cx);
684            workspace::init_settings(cx);
685            ThemeSettings::register(cx);
686            TerminalSettings::register(cx);
687            EditorSettings::register(cx);
688        });
689    }
690
691    #[gpui::test]
692    async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
693        if cfg!(windows) {
694            return;
695        }
696
697        init_test(&executor, cx);
698
699        let fs = Arc::new(RealFs::new(None, executor));
700        let tree = TempTree::new(json!({
701            "project": {},
702        }));
703        let project: Entity<Project> =
704            Project::test(fs, [tree.path().join("project").as_path()], cx).await;
705        let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
706        let model = Arc::new(FakeLanguageModel::default());
707
708        let input = TerminalToolInput {
709            command: "cat".to_owned(),
710            cd: tree
711                .path()
712                .join("project")
713                .as_path()
714                .to_string_lossy()
715                .to_string(),
716        };
717        let result = cx.update(|cx| {
718            TerminalTool::run(
719                Arc::new(TerminalTool::new(cx)),
720                serde_json::to_value(input).unwrap(),
721                Arc::default(),
722                project.clone(),
723                action_log.clone(),
724                model,
725                None,
726                cx,
727            )
728        });
729
730        let output = result.output.await.log_err().map(|output| output.content);
731        assert_eq!(output, Some("Command executed successfully.".into()));
732    }
733
734    #[gpui::test]
735    async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
736        if cfg!(windows) {
737            return;
738        }
739
740        init_test(&executor, cx);
741
742        let fs = Arc::new(RealFs::new(None, executor));
743        let tree = TempTree::new(json!({
744            "project": {},
745            "other-project": {},
746        }));
747        let project: Entity<Project> =
748            Project::test(fs, [tree.path().join("project").as_path()], cx).await;
749        let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
750        let model = Arc::new(FakeLanguageModel::default());
751
752        let check = |input, expected, cx: &mut App| {
753            let headless_result = TerminalTool::run(
754                Arc::new(TerminalTool::new(cx)),
755                serde_json::to_value(input).unwrap(),
756                Arc::default(),
757                project.clone(),
758                action_log.clone(),
759                model.clone(),
760                None,
761                cx,
762            );
763            cx.spawn(async move |_| {
764                let output = headless_result
765                    .output
766                    .await
767                    .log_err()
768                    .map(|output| output.content);
769                assert_eq!(output, expected);
770            })
771        };
772
773        cx.update(|cx| {
774            check(
775                TerminalToolInput {
776                    command: "pwd".into(),
777                    cd: "project".into(),
778                },
779                Some(format!(
780                    "```\n{}\n```",
781                    tree.path().join("project").display()
782                )),
783                cx,
784            )
785        })
786        .await;
787
788        cx.update(|cx| {
789            check(
790                TerminalToolInput {
791                    command: "pwd".into(),
792                    cd: ".".into(),
793                },
794                Some(format!(
795                    "```\n{}\n```",
796                    tree.path().join("project").display()
797                )),
798                cx,
799            )
800        })
801        .await;
802
803        // Absolute path above the worktree root
804        cx.update(|cx| {
805            check(
806                TerminalToolInput {
807                    command: "pwd".into(),
808                    cd: tree.path().to_string_lossy().into(),
809                },
810                None,
811                cx,
812            )
813        })
814        .await;
815
816        project
817            .update(cx, |project, cx| {
818                project.create_worktree(tree.path().join("other-project"), true, cx)
819            })
820            .await
821            .unwrap();
822
823        cx.update(|cx| {
824            check(
825                TerminalToolInput {
826                    command: "pwd".into(),
827                    cd: "other-project".into(),
828                },
829                Some(format!(
830                    "```\n{}\n```",
831                    tree.path().join("other-project").display()
832                )),
833                cx,
834            )
835        })
836        .await;
837
838        cx.update(|cx| {
839            check(
840                TerminalToolInput {
841                    command: "pwd".into(),
842                    cd: ".".into(),
843                },
844                None,
845                cx,
846            )
847        })
848        .await;
849    }
850}