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