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