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, terminals::TerminalKind};
 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
217                project
218                    .update(cx, |project, cx| {
219                        project.create_terminal(
220                            TerminalKind::Task(task::SpawnInTerminal {
221                                command: Some(program),
222                                args,
223                                cwd,
224                                env,
225                                ..Default::default()
226                            }),
227                            cx,
228                        )
229                    })?
230                    .await
231            }
232        });
233
234        let command_markdown = cx.new(|cx| {
235            Markdown::new(
236                format!("```bash\n{}\n```", input.command).into(),
237                None,
238                None,
239                cx,
240            )
241        });
242
243        let card = cx.new(|cx| {
244            TerminalToolCard::new(
245                command_markdown.clone(),
246                working_dir.clone(),
247                cx.entity_id(),
248                cx,
249            )
250        });
251
252        let output = cx.spawn({
253            let card = card.clone();
254            async move |cx| {
255                let terminal = terminal.await?;
256                let workspace = window
257                    .downcast::<Workspace>()
258                    .and_then(|handle| handle.entity(cx).ok())
259                    .context("no workspace entity in root of window")?;
260
261                let terminal_view = window.update(cx, |_, window, cx| {
262                    cx.new(|cx| {
263                        let mut view = TerminalView::new(
264                            terminal.clone(),
265                            workspace.downgrade(),
266                            None,
267                            project.downgrade(),
268                            window,
269                            cx,
270                        );
271                        view.set_embedded_mode(None, cx);
272                        view
273                    })
274                })?;
275
276                card.update(cx, |card, _| {
277                    card.terminal = Some(terminal_view.clone());
278                    card.start_instant = Instant::now();
279                })
280                .log_err();
281
282                let exit_status = terminal
283                    .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
284                    .await;
285                let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
286                    (terminal.get_content(), terminal.total_lines())
287                })?;
288
289                let previous_len = content.len();
290                let (processed_content, finished_with_empty_output) = process_content(
291                    &content,
292                    &input.command,
293                    exit_status.map(portable_pty::ExitStatus::from),
294                );
295
296                card.update(cx, |card, _| {
297                    card.command_finished = true;
298                    card.exit_status = exit_status;
299                    card.was_content_truncated = processed_content.len() < previous_len;
300                    card.original_content_len = previous_len;
301                    card.content_line_count = content_line_count;
302                    card.finished_with_empty_output = finished_with_empty_output;
303                    card.elapsed_time = Some(card.start_instant.elapsed());
304                })
305                .log_err();
306
307                Ok(processed_content.into())
308            }
309        });
310
311        ToolResult {
312            output,
313            card: Some(card.into()),
314        }
315    }
316}
317
318fn process_content(
319    content: &str,
320    command: &str,
321    exit_status: Option<portable_pty::ExitStatus>,
322) -> (String, bool) {
323    let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
324
325    let content = if should_truncate {
326        let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
327        while !content.is_char_boundary(end_ix) {
328            end_ix -= 1;
329        }
330        // Don't truncate mid-line, clear the remainder of the last line
331        end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
332        &content[..end_ix]
333    } else {
334        content
335    };
336    let content = content.trim();
337    let is_empty = content.is_empty();
338    let content = format!("```\n{content}\n```");
339    let content = if should_truncate {
340        format!(
341            "Command output too long. The first {} bytes:\n\n{content}",
342            content.len(),
343        )
344    } else {
345        content
346    };
347
348    let content = match exit_status {
349        Some(exit_status) if exit_status.success() => {
350            if is_empty {
351                "Command executed successfully.".to_string()
352            } else {
353                content
354            }
355        }
356        Some(exit_status) => {
357            if is_empty {
358                format!(
359                    "Command \"{command}\" failed with exit code {}.",
360                    exit_status.exit_code()
361                )
362            } else {
363                format!(
364                    "Command \"{command}\" failed with exit code {}.\n\n{content}",
365                    exit_status.exit_code()
366                )
367            }
368        }
369        None => {
370            format!(
371                "Command failed or was interrupted.\nPartial output captured:\n\n{}",
372                content,
373            )
374        }
375    };
376    (content, is_empty)
377}
378
379fn working_dir(
380    input: &TerminalToolInput,
381    project: &Entity<Project>,
382    cx: &mut App,
383) -> Result<Option<PathBuf>> {
384    let project = project.read(cx);
385    let cd = &input.cd;
386
387    if cd == "." || cd.is_empty() {
388        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
389        let mut worktrees = project.worktrees(cx);
390
391        match worktrees.next() {
392            Some(worktree) => {
393                anyhow::ensure!(
394                    worktrees.next().is_none(),
395                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
396                );
397                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
398            }
399            None => Ok(None),
400        }
401    } else {
402        let input_path = Path::new(cd);
403
404        if input_path.is_absolute() {
405            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
406            if project
407                .worktrees(cx)
408                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
409            {
410                return Ok(Some(input_path.into()));
411            }
412        } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
413            return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
414        }
415
416        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
417    }
418}
419
420struct TerminalToolCard {
421    input_command: Entity<Markdown>,
422    working_dir: Option<PathBuf>,
423    entity_id: EntityId,
424    exit_status: Option<ExitStatus>,
425    terminal: Option<Entity<TerminalView>>,
426    command_finished: bool,
427    was_content_truncated: bool,
428    finished_with_empty_output: bool,
429    content_line_count: usize,
430    original_content_len: usize,
431    preview_expanded: bool,
432    start_instant: Instant,
433    elapsed_time: Option<Duration>,
434}
435
436impl TerminalToolCard {
437    pub fn new(
438        input_command: Entity<Markdown>,
439        working_dir: Option<PathBuf>,
440        entity_id: EntityId,
441        cx: &mut Context<Self>,
442    ) -> Self {
443        let expand_terminal_card =
444            agent_settings::AgentSettings::get_global(cx).expand_terminal_card;
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: expand_terminal_card,
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.command_finished, |header| {
522                header.child(
523                    Icon::new(IconName::ArrowCircle)
524                        .size(IconSize::XSmall)
525                        .color(Color::Info)
526                        .with_animation(
527                            "arrow-circle",
528                            Animation::new(Duration::from_secs(2)).repeat(),
529                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
530                        ),
531                )
532            })
533            .when(tool_failed || command_failed, |header| {
534                header.child(
535                    div()
536                        .id(("terminal-tool-error-code-indicator", self.entity_id))
537                        .child(
538                            Icon::new(IconName::Close)
539                                .size(IconSize::Small)
540                                .color(Color::Error),
541                        )
542                        .when(command_failed && self.exit_status.is_some(), |this| {
543                            this.tooltip(Tooltip::text(format!(
544                                "Exited with code {}",
545                                self.exit_status
546                                    .and_then(|status| status.code())
547                                    .unwrap_or(-1),
548                            )))
549                        })
550                        .when(
551                            !command_failed && tool_failed && status.error().is_some(),
552                            |this| {
553                                this.tooltip(Tooltip::text(format!(
554                                    "Error: {}",
555                                    status.error().unwrap(),
556                                )))
557                            },
558                        ),
559                )
560            })
561            .when(self.was_content_truncated, |header| {
562                let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
563                    "Output exceeded terminal max lines and was \
564                        truncated, the model received the first 16 KB."
565                        .to_string()
566                } else {
567                    format!(
568                        "Output is {} long, to avoid unexpected token usage, \
569                            only 16 KB was sent back to the model.",
570                        format_file_size(self.original_content_len as u64, true),
571                    )
572                };
573                header.child(
574                    h_flex()
575                        .id(("terminal-tool-truncated-label", self.entity_id))
576                        .tooltip(Tooltip::text(tooltip))
577                        .gap_1()
578                        .child(
579                            Icon::new(IconName::Info)
580                                .size(IconSize::XSmall)
581                                .color(Color::Ignored),
582                        )
583                        .child(
584                            Label::new("Truncated")
585                                .color(Color::Muted)
586                                .size(LabelSize::Small),
587                        ),
588                )
589            })
590            .when(time_elapsed > Duration::from_secs(10), |header| {
591                header.child(
592                    Label::new(format!("({})", duration_alt_display(time_elapsed)))
593                        .buffer_font(cx)
594                        .color(Color::Muted)
595                        .size(LabelSize::Small),
596                )
597            })
598            .when(!self.finished_with_empty_output, |header| {
599                header.child(
600                    Disclosure::new(
601                        ("terminal-tool-disclosure", self.entity_id),
602                        self.preview_expanded,
603                    )
604                    .opened_icon(IconName::ChevronUp)
605                    .closed_icon(IconName::ChevronDown)
606                    .on_click(cx.listener(
607                        move |this, _event, _window, _cx| {
608                            this.preview_expanded = !this.preview_expanded;
609                        },
610                    )),
611                )
612            });
613
614        v_flex()
615            .mb_2()
616            .border_1()
617            .when(tool_failed || command_failed, |card| card.border_dashed())
618            .border_color(border_color)
619            .rounded_lg()
620            .overflow_hidden()
621            .child(
622                v_flex()
623                    .p_2()
624                    .gap_0p5()
625                    .bg(header_bg)
626                    .text_xs()
627                    .child(header)
628                    .child(
629                        MarkdownElement::new(
630                            self.input_command.clone(),
631                            markdown_style(window, cx),
632                        )
633                        .code_block_renderer(
634                            markdown::CodeBlockRenderer::Default {
635                                copy_button: false,
636                                copy_button_on_hover: true,
637                                border: false,
638                            },
639                        ),
640                    ),
641            )
642            .when(
643                self.preview_expanded && !self.finished_with_empty_output,
644                |this| {
645                    this.child(
646                        div()
647                            .pt_2()
648                            .border_t_1()
649                            .when(tool_failed || command_failed, |card| card.border_dashed())
650                            .border_color(border_color)
651                            .bg(cx.theme().colors().editor_background)
652                            .rounded_b_md()
653                            .text_ui_sm(cx)
654                            .child({
655                                let content_mode = terminal.read(cx).content_mode(window, cx);
656
657                                if content_mode.is_scrollable() {
658                                    div().h_72().child(terminal.clone()).into_any_element()
659                                } else {
660                                    ToolOutputPreview::new(
661                                        terminal.clone().into_any_element(),
662                                        terminal.entity_id(),
663                                    )
664                                    .with_total_lines(self.content_line_count)
665                                    .toggle_state(!content_mode.is_limited())
666                                    .on_toggle({
667                                        let terminal = terminal.clone();
668                                        move |is_expanded, _, cx| {
669                                            terminal.update(cx, |terminal, cx| {
670                                                terminal.set_embedded_mode(
671                                                    if is_expanded {
672                                                        None
673                                                    } else {
674                                                        Some(COLLAPSED_LINES)
675                                                    },
676                                                    cx,
677                                                );
678                                            });
679                                        }
680                                    })
681                                    .into_any_element()
682                                }
683                            }),
684                    )
685                },
686            )
687            .into_any()
688    }
689}
690
691fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
692    let theme_settings = ThemeSettings::get_global(cx);
693    let buffer_font_size = TextSize::Default.rems(cx);
694    let mut text_style = window.text_style();
695
696    text_style.refine(&TextStyleRefinement {
697        font_family: Some(theme_settings.buffer_font.family.clone()),
698        font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
699        font_features: Some(theme_settings.buffer_font.features.clone()),
700        font_size: Some(buffer_font_size.into()),
701        color: Some(cx.theme().colors().text),
702        ..Default::default()
703    });
704
705    MarkdownStyle {
706        base_text_style: text_style.clone(),
707        selection_background_color: cx.theme().colors().element_selection_background,
708        ..Default::default()
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use editor::EditorSettings;
715    use fs::RealFs;
716    use gpui::{BackgroundExecutor, TestAppContext};
717    use language_model::fake_provider::FakeLanguageModel;
718    use pretty_assertions::assert_eq;
719    use serde_json::json;
720    use settings::{Settings, SettingsStore};
721    use terminal::terminal_settings::TerminalSettings;
722    use theme::ThemeSettings;
723    use util::{ResultExt as _, test::TempTree};
724
725    use super::*;
726
727    fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
728        zlog::init_test();
729
730        executor.allow_parking();
731        cx.update(|cx| {
732            let settings_store = SettingsStore::test(cx);
733            cx.set_global(settings_store);
734            language::init(cx);
735            Project::init_settings(cx);
736            workspace::init_settings(cx);
737            ThemeSettings::register(cx);
738            TerminalSettings::register(cx);
739            EditorSettings::register(cx);
740        });
741    }
742
743    #[gpui::test]
744    async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
745        if cfg!(windows) {
746            return;
747        }
748
749        init_test(&executor, cx);
750
751        let fs = Arc::new(RealFs::new(None, executor));
752        let tree = TempTree::new(json!({
753            "project": {},
754        }));
755        let project: Entity<Project> =
756            Project::test(fs, [tree.path().join("project").as_path()], cx).await;
757        let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
758        let model = Arc::new(FakeLanguageModel::default());
759
760        let input = TerminalToolInput {
761            command: "cat".to_owned(),
762            cd: tree
763                .path()
764                .join("project")
765                .as_path()
766                .to_string_lossy()
767                .to_string(),
768        };
769        let result = cx.update(|cx| {
770            TerminalTool::run(
771                Arc::new(TerminalTool::new(cx)),
772                serde_json::to_value(input).unwrap(),
773                Arc::default(),
774                project.clone(),
775                action_log.clone(),
776                model,
777                None,
778                cx,
779            )
780        });
781
782        let output = result.output.await.log_err().unwrap().content;
783        assert_eq!(output.as_str().unwrap(), "Command executed successfully.");
784    }
785
786    #[gpui::test]
787    async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
788        if cfg!(windows) {
789            return;
790        }
791
792        init_test(&executor, cx);
793
794        let fs = Arc::new(RealFs::new(None, executor));
795        let tree = TempTree::new(json!({
796            "project": {},
797            "other-project": {},
798        }));
799        let project: Entity<Project> =
800            Project::test(fs, [tree.path().join("project").as_path()], cx).await;
801        let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
802        let model = Arc::new(FakeLanguageModel::default());
803
804        let check = |input, expected, cx: &mut App| {
805            let headless_result = TerminalTool::run(
806                Arc::new(TerminalTool::new(cx)),
807                serde_json::to_value(input).unwrap(),
808                Arc::default(),
809                project.clone(),
810                action_log.clone(),
811                model.clone(),
812                None,
813                cx,
814            );
815            cx.spawn(async move |_| {
816                let output = headless_result.output.await.map(|output| output.content);
817                assert_eq!(
818                    output
819                        .ok()
820                        .and_then(|content| content.as_str().map(ToString::to_string)),
821                    expected
822                );
823            })
824        };
825
826        cx.update(|cx| {
827            check(
828                TerminalToolInput {
829                    command: "pwd".into(),
830                    cd: ".".into(),
831                },
832                Some(format!(
833                    "```\n{}\n```",
834                    tree.path().join("project").display()
835                )),
836                cx,
837            )
838        })
839        .await;
840
841        cx.update(|cx| {
842            check(
843                TerminalToolInput {
844                    command: "pwd".into(),
845                    cd: "other-project".into(),
846                },
847                None, // other-project is a dir, but *not* a worktree (yet)
848                cx,
849            )
850        })
851        .await;
852
853        // Absolute path above the worktree root
854        cx.update(|cx| {
855            check(
856                TerminalToolInput {
857                    command: "pwd".into(),
858                    cd: tree.path().to_string_lossy().into(),
859                },
860                None,
861                cx,
862            )
863        })
864        .await;
865
866        project
867            .update(cx, |project, cx| {
868                project.create_worktree(tree.path().join("other-project"), true, cx)
869            })
870            .await
871            .unwrap();
872
873        cx.update(|cx| {
874            check(
875                TerminalToolInput {
876                    command: "pwd".into(),
877                    cd: "other-project".into(),
878                },
879                Some(format!(
880                    "```\n{}\n```",
881                    tree.path().join("other-project").display()
882                )),
883                cx,
884            )
885        })
886        .await;
887
888        cx.update(|cx| {
889            check(
890                TerminalToolInput {
891                    command: "pwd".into(),
892                    cd: ".".into(),
893                },
894                None,
895                cx,
896            )
897        })
898        .await;
899    }
900}