terminal_tool.rs

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