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