1use crate::{
2 schema::json_schema_for,
3 ui::{COLLAPSED_LINES, ToolOutputPreview},
4};
5use anyhow::{Context as _, Result, anyhow};
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 fn name(&self) -> String {
76 Self::NAME.to_string()
77 }
78
79 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
80 true
81 }
82
83 fn description(&self) -> String {
84 include_str!("./terminal_tool/description.md").to_string()
85 }
86
87 fn icon(&self) -> IconName {
88 IconName::Terminal
89 }
90
91 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
92 json_schema_for::<TerminalToolInput>(format)
93 }
94
95 fn ui_text(&self, input: &serde_json::Value) -> String {
96 match serde_json::from_value::<TerminalToolInput>(input.clone()) {
97 Ok(input) => {
98 let mut lines = input.command.lines();
99 let first_line = lines.next().unwrap_or_default();
100 let remaining_line_count = lines.count();
101 match remaining_line_count {
102 0 => MarkdownInlineCode(&first_line).to_string(),
103 1 => MarkdownInlineCode(&format!(
104 "{} - {} more line",
105 first_line, remaining_line_count
106 ))
107 .to_string(),
108 n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
109 .to_string(),
110 }
111 }
112 Err(_) => "Run terminal command".to_string(),
113 }
114 }
115
116 fn run(
117 self: Arc<Self>,
118 input: serde_json::Value,
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 input: TerminalToolInput = match serde_json::from_value(input) {
127 Ok(input) => input,
128 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
129 };
130
131 let working_dir = match working_dir(&input, &project, cx) {
132 Ok(dir) => dir,
133 Err(err) => return Task::ready(Err(err)).into(),
134 };
135 let program = self.determine_shell.clone();
136 let command = if cfg!(windows) {
137 format!("$null | & {{{}}}", input.command.replace("\"", "'"))
138 } else if let Some(cwd) = working_dir
139 .as_ref()
140 .and_then(|cwd| cwd.as_os_str().to_str())
141 {
142 // Make sure once we're *inside* the shell, we cd into `cwd`
143 format!("(cd {cwd}; {}) </dev/null", input.command)
144 } else {
145 format!("({}) </dev/null", input.command)
146 };
147 let args = vec!["-c".into(), command];
148
149 let cwd = working_dir.clone();
150 let env = match &working_dir {
151 Some(dir) => project.update(cx, |project, cx| {
152 project.directory_environment(dir.as_path().into(), cx)
153 }),
154 None => Task::ready(None).shared(),
155 };
156
157 let env = cx.spawn(async move |_| {
158 let mut env = env.await.unwrap_or_default();
159 if cfg!(unix) {
160 env.insert("PAGER".into(), "cat".into());
161 }
162 env
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 program = program.await;
172 let mut cmd = CommandBuilder::new(program);
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 program = program.await;
212 let env = env.await;
213 let terminal = project
214 .update(cx, |project, cx| {
215 project.create_terminal(
216 TerminalKind::Task(task::SpawnInTerminal {
217 command: program,
218 args,
219 cwd,
220 env,
221 ..Default::default()
222 }),
223 window,
224 cx,
225 )
226 })?
227 .await;
228 terminal
229 }
230 });
231
232 let command_markdown = cx.new(|cx| {
233 Markdown::new(
234 format!("```bash\n{}\n```", input.command).into(),
235 None,
236 None,
237 cx,
238 )
239 });
240
241 let card = cx.new(|cx| {
242 TerminalToolCard::new(
243 command_markdown.clone(),
244 working_dir.clone(),
245 cx.entity_id(),
246 )
247 });
248
249 let output = cx.spawn({
250 let card = card.clone();
251 async move |cx| {
252 let terminal = terminal.await?;
253 let workspace = window
254 .downcast::<Workspace>()
255 .and_then(|handle| handle.entity(cx).ok())
256 .context("no workspace entity in root of window")?;
257
258 let terminal_view = window.update(cx, |_, window, cx| {
259 cx.new(|cx| {
260 let mut view = TerminalView::new(
261 terminal.clone(),
262 workspace.downgrade(),
263 None,
264 project.downgrade(),
265 window,
266 cx,
267 );
268 view.set_embedded_mode(None, cx);
269 view
270 })
271 })?;
272
273 card.update(cx, |card, _| {
274 card.terminal = Some(terminal_view.clone());
275 card.start_instant = Instant::now();
276 })
277 .log_err();
278
279 let exit_status = terminal
280 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
281 .await;
282 let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
283 (terminal.get_content(), terminal.total_lines())
284 })?;
285
286 let previous_len = content.len();
287 let (processed_content, finished_with_empty_output) = process_content(
288 &content,
289 &input.command,
290 exit_status.map(portable_pty::ExitStatus::from),
291 );
292
293 card.update(cx, |card, _| {
294 card.command_finished = true;
295 card.exit_status = exit_status;
296 card.was_content_truncated = processed_content.len() < previous_len;
297 card.original_content_len = previous_len;
298 card.content_line_count = content_line_count;
299 card.finished_with_empty_output = finished_with_empty_output;
300 card.elapsed_time = Some(card.start_instant.elapsed());
301 })
302 .log_err();
303
304 Ok(processed_content.into())
305 }
306 });
307
308 ToolResult {
309 output,
310 card: Some(card.into()),
311 }
312 }
313}
314
315fn process_content(
316 content: &str,
317 command: &str,
318 exit_status: Option<portable_pty::ExitStatus>,
319) -> (String, bool) {
320 let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
321
322 let content = if should_truncate {
323 let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
324 while !content.is_char_boundary(end_ix) {
325 end_ix -= 1;
326 }
327 // Don't truncate mid-line, clear the remainder of the last line
328 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
329 &content[..end_ix]
330 } else {
331 content
332 };
333 let content = content.trim();
334 let is_empty = content.is_empty();
335 let content = format!("```\n{content}\n```");
336 let content = if should_truncate {
337 format!(
338 "Command output too long. The first {} bytes:\n\n{content}",
339 content.len(),
340 )
341 } else {
342 content
343 };
344
345 let content = match exit_status {
346 Some(exit_status) if exit_status.success() => {
347 if is_empty {
348 "Command executed successfully.".to_string()
349 } else {
350 content.to_string()
351 }
352 }
353 Some(exit_status) => {
354 if is_empty {
355 format!(
356 "Command \"{command}\" failed with exit code {}.",
357 exit_status.exit_code()
358 )
359 } else {
360 format!(
361 "Command \"{command}\" failed with exit code {}.\n\n{content}",
362 exit_status.exit_code()
363 )
364 }
365 }
366 None => {
367 format!(
368 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
369 content,
370 )
371 }
372 };
373 (content, is_empty)
374}
375
376fn working_dir(
377 input: &TerminalToolInput,
378 project: &Entity<Project>,
379 cx: &mut App,
380) -> Result<Option<PathBuf>> {
381 let project = project.read(cx);
382 let cd = &input.cd;
383
384 if cd == "." || cd == "" {
385 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
386 let mut worktrees = project.worktrees(cx);
387
388 match worktrees.next() {
389 Some(worktree) => {
390 anyhow::ensure!(
391 worktrees.next().is_none(),
392 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
393 );
394 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
395 }
396 None => Ok(None),
397 }
398 } else {
399 let input_path = Path::new(cd);
400
401 if input_path.is_absolute() {
402 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
403 if project
404 .worktrees(cx)
405 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
406 {
407 return Ok(Some(input_path.into()));
408 }
409 } else {
410 if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
411 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
412 }
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 ) -> Self {
441 Self {
442 input_command,
443 working_dir,
444 entity_id,
445 exit_status: None,
446 terminal: None,
447 command_finished: false,
448 was_content_truncated: false,
449 finished_with_empty_output: false,
450 original_content_len: 0,
451 content_line_count: 0,
452 preview_expanded: true,
453 start_instant: Instant::now(),
454 elapsed_time: None,
455 }
456 }
457}
458
459impl ToolCard for TerminalToolCard {
460 fn render(
461 &mut self,
462 status: &ToolUseStatus,
463 window: &mut Window,
464 _workspace: WeakEntity<Workspace>,
465 cx: &mut Context<Self>,
466 ) -> impl IntoElement {
467 let Some(terminal) = self.terminal.as_ref() else {
468 return Empty.into_any();
469 };
470
471 let tool_failed = matches!(status, ToolUseStatus::Error(_));
472
473 let command_failed =
474 self.command_finished && self.exit_status.is_none_or(|code| !code.success());
475
476 if (tool_failed || command_failed) && self.elapsed_time.is_none() {
477 self.elapsed_time = Some(self.start_instant.elapsed());
478 }
479 let time_elapsed = self
480 .elapsed_time
481 .unwrap_or_else(|| self.start_instant.elapsed());
482
483 let header_bg = cx
484 .theme()
485 .colors()
486 .element_background
487 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
488
489 let border_color = cx.theme().colors().border.opacity(0.6);
490
491 let path = self
492 .working_dir
493 .as_ref()
494 .cloned()
495 .or_else(|| env::current_dir().ok())
496 .map(|path| format!("{}", path.display()))
497 .unwrap_or_else(|| "current directory".to_string());
498
499 let header = h_flex()
500 .flex_none()
501 .gap_1()
502 .justify_between()
503 .rounded_t_md()
504 .child(
505 div()
506 .id(("command-target-path", self.entity_id))
507 .w_full()
508 .max_w_full()
509 .overflow_x_scroll()
510 .child(
511 Label::new(path)
512 .buffer_font(cx)
513 .size(LabelSize::XSmall)
514 .color(Color::Muted),
515 ),
516 )
517 .when(self.was_content_truncated, |header| {
518 let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
519 "Output exceeded terminal max lines and was \
520 truncated, the model received the first 16 KB."
521 .to_string()
522 } else {
523 format!(
524 "Output is {} long, to avoid unexpected token usage, \
525 only 16 KB was sent back to the model.",
526 format_file_size(self.original_content_len as u64, true),
527 )
528 };
529 header.child(
530 h_flex()
531 .id(("terminal-tool-truncated-label", self.entity_id))
532 .tooltip(Tooltip::text(tooltip))
533 .gap_1()
534 .child(
535 Icon::new(IconName::Info)
536 .size(IconSize::XSmall)
537 .color(Color::Ignored),
538 )
539 .child(
540 Label::new("Truncated")
541 .color(Color::Muted)
542 .size(LabelSize::Small),
543 ),
544 )
545 })
546 .when(time_elapsed > Duration::from_secs(10), |header| {
547 header.child(
548 Label::new(format!("({})", duration_alt_display(time_elapsed)))
549 .buffer_font(cx)
550 .color(Color::Muted)
551 .size(LabelSize::Small),
552 )
553 })
554 .when(tool_failed || command_failed, |header| {
555 header.child(
556 div()
557 .id(("terminal-tool-error-code-indicator", self.entity_id))
558 .child(
559 Icon::new(IconName::Close)
560 .size(IconSize::Small)
561 .color(Color::Error),
562 )
563 .when(command_failed && self.exit_status.is_some(), |this| {
564 this.tooltip(Tooltip::text(format!(
565 "Exited with code {}",
566 self.exit_status
567 .and_then(|status| status.code())
568 .unwrap_or(-1),
569 )))
570 })
571 .when(
572 !command_failed && tool_failed && status.error().is_some(),
573 |this| {
574 this.tooltip(Tooltip::text(format!(
575 "Error: {}",
576 status.error().unwrap(),
577 )))
578 },
579 ),
580 )
581 })
582 .when(!self.finished_with_empty_output, |header| {
583 header.child(
584 Disclosure::new(
585 ("terminal-tool-disclosure", self.entity_id),
586 self.preview_expanded,
587 )
588 .opened_icon(IconName::ChevronUp)
589 .closed_icon(IconName::ChevronDown)
590 .on_click(cx.listener(
591 move |this, _event, _window, _cx| {
592 this.preview_expanded = !this.preview_expanded;
593 },
594 )),
595 )
596 });
597
598 v_flex()
599 .mb_2()
600 .border_1()
601 .when(tool_failed || command_failed, |card| card.border_dashed())
602 .border_color(border_color)
603 .rounded_lg()
604 .overflow_hidden()
605 .child(
606 v_flex()
607 .p_2()
608 .gap_0p5()
609 .bg(header_bg)
610 .text_xs()
611 .child(header)
612 .child(
613 MarkdownElement::new(
614 self.input_command.clone(),
615 markdown_style(window, cx),
616 )
617 .code_block_renderer(
618 markdown::CodeBlockRenderer::Default {
619 copy_button: false,
620 copy_button_on_hover: true,
621 border: false,
622 },
623 ),
624 ),
625 )
626 .when(
627 self.preview_expanded && !self.finished_with_empty_output,
628 |this| {
629 this.child(
630 div()
631 .pt_2()
632 .border_t_1()
633 .border_color(border_color)
634 .bg(cx.theme().colors().editor_background)
635 .rounded_b_md()
636 .text_ui_sm(cx)
637 .child(
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(!terminal.read(cx).is_content_limited(window))
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 ),
660 )
661 },
662 )
663 .into_any()
664 }
665}
666
667fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
668 let theme_settings = ThemeSettings::get_global(cx);
669 let buffer_font_size = TextSize::Default.rems(cx);
670 let mut text_style = window.text_style();
671
672 text_style.refine(&TextStyleRefinement {
673 font_family: Some(theme_settings.buffer_font.family.clone()),
674 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
675 font_features: Some(theme_settings.buffer_font.features.clone()),
676 font_size: Some(buffer_font_size.into()),
677 color: Some(cx.theme().colors().text),
678 ..Default::default()
679 });
680
681 MarkdownStyle {
682 base_text_style: text_style.clone(),
683 selection_background_color: cx.theme().players().local().selection,
684 ..Default::default()
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use editor::EditorSettings;
691 use fs::RealFs;
692 use gpui::{BackgroundExecutor, TestAppContext};
693 use language_model::fake_provider::FakeLanguageModel;
694 use pretty_assertions::assert_eq;
695 use serde_json::json;
696 use settings::{Settings, SettingsStore};
697 use terminal::terminal_settings::TerminalSettings;
698 use theme::ThemeSettings;
699 use util::{ResultExt as _, test::TempTree};
700
701 use super::*;
702
703 fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
704 zlog::init_test();
705
706 executor.allow_parking();
707 cx.update(|cx| {
708 let settings_store = SettingsStore::test(cx);
709 cx.set_global(settings_store);
710 language::init(cx);
711 Project::init_settings(cx);
712 workspace::init_settings(cx);
713 ThemeSettings::register(cx);
714 TerminalSettings::register(cx);
715 EditorSettings::register(cx);
716 });
717 }
718
719 #[gpui::test]
720 async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
721 if cfg!(windows) {
722 return;
723 }
724
725 init_test(&executor, cx);
726
727 let fs = Arc::new(RealFs::new(None, executor));
728 let tree = TempTree::new(json!({
729 "project": {},
730 }));
731 let project: Entity<Project> =
732 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
733 let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
734 let model = Arc::new(FakeLanguageModel::default());
735
736 let input = TerminalToolInput {
737 command: "cat".to_owned(),
738 cd: tree
739 .path()
740 .join("project")
741 .as_path()
742 .to_string_lossy()
743 .to_string(),
744 };
745 let result = cx.update(|cx| {
746 TerminalTool::run(
747 Arc::new(TerminalTool::new(cx)),
748 serde_json::to_value(input).unwrap(),
749 Arc::default(),
750 project.clone(),
751 action_log.clone(),
752 model,
753 None,
754 cx,
755 )
756 });
757
758 let output = result.output.await.log_err().unwrap().content;
759 assert_eq!(output.as_str().unwrap(), "Command executed successfully.");
760 }
761
762 #[gpui::test]
763 async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
764 if cfg!(windows) {
765 return;
766 }
767
768 init_test(&executor, cx);
769
770 let fs = Arc::new(RealFs::new(None, executor));
771 let tree = TempTree::new(json!({
772 "project": {},
773 "other-project": {},
774 }));
775 let project: Entity<Project> =
776 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
777 let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
778 let model = Arc::new(FakeLanguageModel::default());
779
780 let check = |input, expected, cx: &mut App| {
781 let headless_result = TerminalTool::run(
782 Arc::new(TerminalTool::new(cx)),
783 serde_json::to_value(input).unwrap(),
784 Arc::default(),
785 project.clone(),
786 action_log.clone(),
787 model.clone(),
788 None,
789 cx,
790 );
791 cx.spawn(async move |_| {
792 let output = headless_result.output.await.map(|output| output.content);
793 assert_eq!(
794 output
795 .ok()
796 .and_then(|content| content.as_str().map(ToString::to_string)),
797 expected
798 );
799 })
800 };
801
802 cx.update(|cx| {
803 check(
804 TerminalToolInput {
805 command: "pwd".into(),
806 cd: ".".into(),
807 },
808 Some(format!(
809 "```\n{}\n```",
810 tree.path().join("project").display()
811 )),
812 cx,
813 )
814 })
815 .await;
816
817 cx.update(|cx| {
818 check(
819 TerminalToolInput {
820 command: "pwd".into(),
821 cd: "other-project".into(),
822 },
823 None, // other-project is a dir, but *not* a worktree (yet)
824 cx,
825 )
826 })
827 .await;
828
829 // Absolute path above the worktree root
830 cx.update(|cx| {
831 check(
832 TerminalToolInput {
833 command: "pwd".into(),
834 cd: tree.path().to_string_lossy().into(),
835 },
836 None,
837 cx,
838 )
839 })
840 .await;
841
842 project
843 .update(cx, |project, cx| {
844 project.create_worktree(tree.path().join("other-project"), true, cx)
845 })
846 .await
847 .unwrap();
848
849 cx.update(|cx| {
850 check(
851 TerminalToolInput {
852 command: "pwd".into(),
853 cd: "other-project".into(),
854 },
855 Some(format!(
856 "```\n{}\n```",
857 tree.path().join("other-project").display()
858 )),
859 cx,
860 )
861 })
862 .await;
863
864 cx.update(|cx| {
865 check(
866 TerminalToolInput {
867 command: "pwd".into(),
868 cd: ".".into(),
869 },
870 None,
871 cx,
872 )
873 })
874 .await;
875 }
876}