terminal_tool.rs

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