terminal_tool.rs

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