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