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