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