terminal_tool.rs

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