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    AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement,
 12    WeakEntity, Window,
 13};
 14use language::LineEnding;
 15use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
 16use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 17use portable_pty::{CommandBuilder, PtySize, native_pty_system};
 18use project::Project;
 19use schemars::JsonSchema;
 20use serde::{Deserialize, Serialize};
 21use settings::Settings;
 22use std::{
 23    env,
 24    path::{Path, PathBuf},
 25    process::ExitStatus,
 26    sync::Arc,
 27    time::{Duration, Instant},
 28};
 29use terminal_view::TerminalView;
 30use theme::ThemeSettings;
 31use ui::{CommonAnimationExt, 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_rotate_animation(2),
526                )
527            })
528            .when(tool_failed || command_failed, |header| {
529                header.child(
530                    div()
531                        .id(("terminal-tool-error-code-indicator", self.entity_id))
532                        .child(
533                            Icon::new(IconName::Close)
534                                .size(IconSize::Small)
535                                .color(Color::Error),
536                        )
537                        .when(command_failed && self.exit_status.is_some(), |this| {
538                            this.tooltip(Tooltip::text(format!(
539                                "Exited with code {}",
540                                self.exit_status
541                                    .and_then(|status| status.code())
542                                    .unwrap_or(-1),
543                            )))
544                        })
545                        .when(
546                            !command_failed && tool_failed && status.error().is_some(),
547                            |this| {
548                                this.tooltip(Tooltip::text(format!(
549                                    "Error: {}",
550                                    status.error().unwrap(),
551                                )))
552                            },
553                        ),
554                )
555            })
556            .when(self.was_content_truncated, |header| {
557                let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
558                    "Output exceeded terminal max lines and was \
559                        truncated, the model received the first 16 KB."
560                        .to_string()
561                } else {
562                    format!(
563                        "Output is {} long, to avoid unexpected token usage, \
564                            only 16 KB was sent back to the model.",
565                        format_file_size(self.original_content_len as u64, true),
566                    )
567                };
568                header.child(
569                    h_flex()
570                        .id(("terminal-tool-truncated-label", self.entity_id))
571                        .tooltip(Tooltip::text(tooltip))
572                        .gap_1()
573                        .child(
574                            Icon::new(IconName::Info)
575                                .size(IconSize::XSmall)
576                                .color(Color::Ignored),
577                        )
578                        .child(
579                            Label::new("Truncated")
580                                .color(Color::Muted)
581                                .size(LabelSize::Small),
582                        ),
583                )
584            })
585            .when(time_elapsed > Duration::from_secs(10), |header| {
586                header.child(
587                    Label::new(format!("({})", duration_alt_display(time_elapsed)))
588                        .buffer_font(cx)
589                        .color(Color::Muted)
590                        .size(LabelSize::Small),
591                )
592            })
593            .when(!self.finished_with_empty_output, |header| {
594                header.child(
595                    Disclosure::new(
596                        ("terminal-tool-disclosure", self.entity_id),
597                        self.preview_expanded,
598                    )
599                    .opened_icon(IconName::ChevronUp)
600                    .closed_icon(IconName::ChevronDown)
601                    .on_click(cx.listener(
602                        move |this, _event, _window, _cx| {
603                            this.preview_expanded = !this.preview_expanded;
604                        },
605                    )),
606                )
607            });
608
609        v_flex()
610            .mb_2()
611            .border_1()
612            .when(tool_failed || command_failed, |card| card.border_dashed())
613            .border_color(border_color)
614            .rounded_lg()
615            .overflow_hidden()
616            .child(
617                v_flex()
618                    .p_2()
619                    .gap_0p5()
620                    .bg(header_bg)
621                    .text_xs()
622                    .child(header)
623                    .child(
624                        MarkdownElement::new(
625                            self.input_command.clone(),
626                            markdown_style(window, cx),
627                        )
628                        .code_block_renderer(
629                            markdown::CodeBlockRenderer::Default {
630                                copy_button: false,
631                                copy_button_on_hover: true,
632                                border: false,
633                            },
634                        ),
635                    ),
636            )
637            .when(
638                self.preview_expanded && !self.finished_with_empty_output,
639                |this| {
640                    this.child(
641                        div()
642                            .pt_2()
643                            .border_t_1()
644                            .when(tool_failed || command_failed, |card| card.border_dashed())
645                            .border_color(border_color)
646                            .bg(cx.theme().colors().editor_background)
647                            .rounded_b_md()
648                            .text_ui_sm(cx)
649                            .child({
650                                let content_mode = terminal.read(cx).content_mode(window, cx);
651
652                                if content_mode.is_scrollable() {
653                                    div().h_72().child(terminal.clone()).into_any_element()
654                                } else {
655                                    ToolOutputPreview::new(
656                                        terminal.clone().into_any_element(),
657                                        terminal.entity_id(),
658                                    )
659                                    .with_total_lines(self.content_line_count)
660                                    .toggle_state(!content_mode.is_limited())
661                                    .on_toggle({
662                                        let terminal = terminal.clone();
663                                        move |is_expanded, _, cx| {
664                                            terminal.update(cx, |terminal, cx| {
665                                                terminal.set_embedded_mode(
666                                                    if is_expanded {
667                                                        None
668                                                    } else {
669                                                        Some(COLLAPSED_LINES)
670                                                    },
671                                                    cx,
672                                                );
673                                            });
674                                        }
675                                    })
676                                    .into_any_element()
677                                }
678                            }),
679                    )
680                },
681            )
682            .into_any()
683    }
684}
685
686fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
687    let theme_settings = ThemeSettings::get_global(cx);
688    let buffer_font_size = TextSize::Default.rems(cx);
689    let mut text_style = window.text_style();
690
691    text_style.refine(&TextStyleRefinement {
692        font_family: Some(theme_settings.buffer_font.family.clone()),
693        font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
694        font_features: Some(theme_settings.buffer_font.features.clone()),
695        font_size: Some(buffer_font_size.into()),
696        color: Some(cx.theme().colors().text),
697        ..Default::default()
698    });
699
700    MarkdownStyle {
701        base_text_style: text_style.clone(),
702        selection_background_color: cx.theme().colors().element_selection_background,
703        ..Default::default()
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use editor::EditorSettings;
710    use fs::RealFs;
711    use gpui::{BackgroundExecutor, TestAppContext};
712    use language_model::fake_provider::FakeLanguageModel;
713    use pretty_assertions::assert_eq;
714    use serde_json::json;
715    use settings::{Settings, SettingsStore};
716    use terminal::terminal_settings::TerminalSettings;
717    use theme::ThemeSettings;
718    use util::{ResultExt as _, test::TempTree};
719
720    use super::*;
721
722    fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
723        zlog::init_test();
724
725        executor.allow_parking();
726        cx.update(|cx| {
727            let settings_store = SettingsStore::test(cx);
728            cx.set_global(settings_store);
729            language::init(cx);
730            Project::init_settings(cx);
731            workspace::init_settings(cx);
732            ThemeSettings::register(cx);
733            TerminalSettings::register(cx);
734            EditorSettings::register(cx);
735        });
736    }
737
738    #[gpui::test]
739    async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
740        if cfg!(windows) {
741            return;
742        }
743
744        init_test(&executor, cx);
745
746        let fs = Arc::new(RealFs::new(None, executor));
747        let tree = TempTree::new(json!({
748            "project": {},
749        }));
750        let project: Entity<Project> =
751            Project::test(fs, [tree.path().join("project").as_path()], cx).await;
752        let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
753        let model = Arc::new(FakeLanguageModel::default());
754
755        let input = TerminalToolInput {
756            command: "cat".to_owned(),
757            cd: tree
758                .path()
759                .join("project")
760                .as_path()
761                .to_string_lossy()
762                .to_string(),
763        };
764        let result = cx.update(|cx| {
765            TerminalTool::run(
766                Arc::new(TerminalTool::new(cx)),
767                serde_json::to_value(input).unwrap(),
768                Arc::default(),
769                project.clone(),
770                action_log.clone(),
771                model,
772                None,
773                cx,
774            )
775        });
776
777        let output = result.output.await.log_err().unwrap().content;
778        assert_eq!(output.as_str().unwrap(), "Command executed successfully.");
779    }
780
781    #[gpui::test]
782    async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
783        if cfg!(windows) {
784            return;
785        }
786
787        init_test(&executor, cx);
788
789        let fs = Arc::new(RealFs::new(None, executor));
790        let tree = TempTree::new(json!({
791            "project": {},
792            "other-project": {},
793        }));
794        let project: Entity<Project> =
795            Project::test(fs, [tree.path().join("project").as_path()], cx).await;
796        let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
797        let model = Arc::new(FakeLanguageModel::default());
798
799        let check = |input, expected, cx: &mut App| {
800            let headless_result = TerminalTool::run(
801                Arc::new(TerminalTool::new(cx)),
802                serde_json::to_value(input).unwrap(),
803                Arc::default(),
804                project.clone(),
805                action_log.clone(),
806                model.clone(),
807                None,
808                cx,
809            );
810            cx.spawn(async move |_| {
811                let output = headless_result.output.await.map(|output| output.content);
812                assert_eq!(
813                    output
814                        .ok()
815                        .and_then(|content| content.as_str().map(ToString::to_string)),
816                    expected
817                );
818            })
819        };
820
821        cx.update(|cx| {
822            check(
823                TerminalToolInput {
824                    command: "pwd".into(),
825                    cd: ".".into(),
826                },
827                Some(format!(
828                    "```\n{}\n```",
829                    tree.path().join("project").display()
830                )),
831                cx,
832            )
833        })
834        .await;
835
836        cx.update(|cx| {
837            check(
838                TerminalToolInput {
839                    command: "pwd".into(),
840                    cd: "other-project".into(),
841                },
842                None, // other-project is a dir, but *not* a worktree (yet)
843                cx,
844            )
845        })
846        .await;
847
848        // Absolute path above the worktree root
849        cx.update(|cx| {
850            check(
851                TerminalToolInput {
852                    command: "pwd".into(),
853                    cd: tree.path().to_string_lossy().into(),
854                },
855                None,
856                cx,
857            )
858        })
859        .await;
860
861        project
862            .update(cx, |project, cx| {
863                project.create_worktree(tree.path().join("other-project"), true, cx)
864            })
865            .await
866            .unwrap();
867
868        cx.update(|cx| {
869            check(
870                TerminalToolInput {
871                    command: "pwd".into(),
872                    cd: "other-project".into(),
873                },
874                Some(format!(
875                    "```\n{}\n```",
876                    tree.path().join("other-project").display()
877                )),
878                cx,
879            )
880        })
881        .await;
882
883        cx.update(|cx| {
884            check(
885                TerminalToolInput {
886                    command: "pwd".into(),
887                    cd: ".".into(),
888                },
889                None,
890                cx,
891            )
892        })
893        .await;
894    }
895}