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