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