terminal_tool.rs

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