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