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