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 shell = project
140 .update(cx, |project, cx| {
141 project
142 .remote_client()
143 .and_then(|r| r.read(cx).default_system_shell())
144 })
145 .unwrap_or_else(|| get_default_system_shell_preferring_bash());
146
147 let env = cx.spawn(async move |_| {
148 let mut env = env.await.unwrap_or_default();
149 if cfg!(unix) {
150 env.insert("PAGER".into(), "cat".into());
151 }
152 env
153 });
154
155 let build_cmd = {
156 let input_command = input.command.clone();
157 move || {
158 ShellBuilder::new(&Shell::Program(shell))
159 .redirect_stdin_to_dev_null()
160 .build(Some(input_command), &[])
161 }
162 };
163
164 let Some(window) = window else {
165 // Headless setup, a test or eval. Our terminal subsystem requires a workspace,
166 // so bypass it and provide a convincing imitation using a pty.
167 let task = cx.background_spawn(async move {
168 let env = env.await;
169 let pty_system = native_pty_system();
170 let (command, args) = build_cmd();
171 let mut cmd = CommandBuilder::new(command);
172 cmd.args(args);
173 for (k, v) in env {
174 cmd.env(k, v);
175 }
176 if let Some(cwd) = cwd {
177 cmd.cwd(cwd);
178 }
179 let pair = pty_system.openpty(PtySize {
180 rows: 24,
181 cols: 80,
182 ..Default::default()
183 })?;
184 let mut child = pair.slave.spawn_command(cmd)?;
185 let mut reader = pair.master.try_clone_reader()?;
186 drop(pair);
187 let mut content = String::new();
188 reader.read_to_string(&mut content)?;
189 // Massage the pty output a bit to try to match what the terminal codepath gives us
190 LineEnding::normalize(&mut content);
191 content = content
192 .chars()
193 .filter(|c| c.is_ascii_whitespace() || !c.is_ascii_control())
194 .collect();
195 let content = content.trim_start().trim_start_matches("^D");
196 let exit_status = child.wait()?;
197 let (processed_content, _) =
198 process_content(content, &input.command, Some(exit_status));
199 Ok(processed_content.into())
200 });
201 return ToolResult {
202 output: task,
203 card: None,
204 };
205 };
206
207 let terminal = cx.spawn({
208 let project = project.downgrade();
209 async move |cx| {
210 let (command, args) = build_cmd();
211 let env = env.await;
212 project
213 .update(cx, |project, cx| {
214 project.create_terminal_task(
215 task::SpawnInTerminal {
216 command: Some(command),
217 args,
218 cwd,
219 env,
220 ..Default::default()
221 },
222 cx,
223 )
224 })?
225 .await
226 }
227 });
228
229 let command_markdown = cx.new(|cx| {
230 Markdown::new(
231 format!("```bash\n{}\n```", input.command).into(),
232 None,
233 None,
234 cx,
235 )
236 });
237
238 let card =
239 cx.new(|cx| TerminalToolCard::new(command_markdown, working_dir, cx.entity_id(), cx));
240
241 let output = cx.spawn({
242 let card = card.clone();
243 async move |cx| {
244 let terminal = terminal.await?;
245 let workspace = window
246 .downcast::<Workspace>()
247 .and_then(|handle| handle.entity(cx).ok())
248 .context("no workspace entity in root of window")?;
249
250 let terminal_view = window.update(cx, |_, window, cx| {
251 cx.new(|cx| {
252 let mut view = TerminalView::new(
253 terminal.clone(),
254 workspace.downgrade(),
255 None,
256 project.downgrade(),
257 window,
258 cx,
259 );
260 view.set_embedded_mode(None, cx);
261 view
262 })
263 })?;
264
265 card.update(cx, |card, _| {
266 card.terminal = Some(terminal_view.clone());
267 card.start_instant = Instant::now();
268 })
269 .log_err();
270
271 let exit_status = terminal
272 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
273 .await;
274 let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
275 (terminal.get_content(), terminal.total_lines())
276 })?;
277
278 let previous_len = content.len();
279 let (processed_content, finished_with_empty_output) = process_content(
280 &content,
281 &input.command,
282 exit_status.map(portable_pty::ExitStatus::from),
283 );
284
285 card.update(cx, |card, _| {
286 card.command_finished = true;
287 card.exit_status = exit_status;
288 card.was_content_truncated = processed_content.len() < previous_len;
289 card.original_content_len = previous_len;
290 card.content_line_count = content_line_count;
291 card.finished_with_empty_output = finished_with_empty_output;
292 card.elapsed_time = Some(card.start_instant.elapsed());
293 })
294 .log_err();
295
296 Ok(processed_content.into())
297 }
298 });
299
300 ToolResult {
301 output,
302 card: Some(card.into()),
303 }
304 }
305}
306
307fn process_content(
308 content: &str,
309 command: &str,
310 exit_status: Option<portable_pty::ExitStatus>,
311) -> (String, bool) {
312 let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
313
314 let content = if should_truncate {
315 let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
316 while !content.is_char_boundary(end_ix) {
317 end_ix -= 1;
318 }
319 // Don't truncate mid-line, clear the remainder of the last line
320 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
321 &content[..end_ix]
322 } else {
323 content
324 };
325 let content = content.trim();
326 let is_empty = content.is_empty();
327 let content = format!("```\n{content}\n```");
328 let content = if should_truncate {
329 format!(
330 "Command output too long. The first {} bytes:\n\n{content}",
331 content.len(),
332 )
333 } else {
334 content
335 };
336
337 let content = match exit_status {
338 Some(exit_status) if exit_status.success() => {
339 if is_empty {
340 "Command executed successfully.".to_string()
341 } else {
342 content
343 }
344 }
345 Some(exit_status) => {
346 if is_empty {
347 format!(
348 "Command \"{command}\" failed with exit code {}.",
349 exit_status.exit_code()
350 )
351 } else {
352 format!(
353 "Command \"{command}\" failed with exit code {}.\n\n{content}",
354 exit_status.exit_code()
355 )
356 }
357 }
358 None => {
359 format!(
360 "Command failed or was interrupted.\nPartial output captured:\n\n{}",
361 content,
362 )
363 }
364 };
365 (content, is_empty)
366}
367
368fn working_dir(
369 input: &TerminalToolInput,
370 project: &Entity<Project>,
371 cx: &mut App,
372) -> Result<Option<PathBuf>> {
373 let project = project.read(cx);
374 let cd = &input.cd;
375
376 if cd == "." || cd.is_empty() {
377 // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
378 let mut worktrees = project.worktrees(cx);
379
380 match worktrees.next() {
381 Some(worktree) => {
382 anyhow::ensure!(
383 worktrees.next().is_none(),
384 "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
385 );
386 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
387 }
388 None => Ok(None),
389 }
390 } else {
391 let input_path = Path::new(cd);
392
393 if input_path.is_absolute() {
394 // Absolute paths are allowed, but only if they're in one of the project's worktrees.
395 if project
396 .worktrees(cx)
397 .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
398 {
399 return Ok(Some(input_path.into()));
400 }
401 } else if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
402 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
403 }
404
405 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
406 }
407}
408
409struct TerminalToolCard {
410 input_command: Entity<Markdown>,
411 working_dir: Option<PathBuf>,
412 entity_id: EntityId,
413 exit_status: Option<ExitStatus>,
414 terminal: Option<Entity<TerminalView>>,
415 command_finished: bool,
416 was_content_truncated: bool,
417 finished_with_empty_output: bool,
418 content_line_count: usize,
419 original_content_len: usize,
420 preview_expanded: bool,
421 start_instant: Instant,
422 elapsed_time: Option<Duration>,
423}
424
425impl TerminalToolCard {
426 pub fn new(
427 input_command: Entity<Markdown>,
428 working_dir: Option<PathBuf>,
429 entity_id: EntityId,
430 cx: &mut Context<Self>,
431 ) -> Self {
432 let expand_terminal_card =
433 agent_settings::AgentSettings::get_global(cx).expand_terminal_card;
434 Self {
435 input_command,
436 working_dir,
437 entity_id,
438 exit_status: None,
439 terminal: None,
440 command_finished: false,
441 was_content_truncated: false,
442 finished_with_empty_output: false,
443 original_content_len: 0,
444 content_line_count: 0,
445 preview_expanded: expand_terminal_card,
446 start_instant: Instant::now(),
447 elapsed_time: None,
448 }
449 }
450}
451
452impl ToolCard for TerminalToolCard {
453 fn render(
454 &mut self,
455 status: &ToolUseStatus,
456 window: &mut Window,
457 _workspace: WeakEntity<Workspace>,
458 cx: &mut Context<Self>,
459 ) -> impl IntoElement {
460 let Some(terminal) = self.terminal.as_ref() else {
461 return Empty.into_any();
462 };
463
464 let tool_failed = matches!(status, ToolUseStatus::Error(_));
465
466 let command_failed =
467 self.command_finished && self.exit_status.is_none_or(|code| !code.success());
468
469 if (tool_failed || command_failed) && self.elapsed_time.is_none() {
470 self.elapsed_time = Some(self.start_instant.elapsed());
471 }
472 let time_elapsed = self
473 .elapsed_time
474 .unwrap_or_else(|| self.start_instant.elapsed());
475
476 let header_bg = cx
477 .theme()
478 .colors()
479 .element_background
480 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
481
482 let border_color = cx.theme().colors().border.opacity(0.6);
483
484 let path = self
485 .working_dir
486 .as_ref()
487 .cloned()
488 .or_else(|| env::current_dir().ok())
489 .map(|path| path.display().to_string())
490 .unwrap_or_else(|| "current directory".to_string());
491
492 let header = h_flex()
493 .flex_none()
494 .gap_1()
495 .justify_between()
496 .rounded_t_md()
497 .child(
498 div()
499 .id(("command-target-path", self.entity_id))
500 .w_full()
501 .max_w_full()
502 .overflow_x_scroll()
503 .child(
504 Label::new(path)
505 .buffer_font(cx)
506 .size(LabelSize::XSmall)
507 .color(Color::Muted),
508 ),
509 )
510 .when(!self.command_finished, |header| {
511 header.child(
512 Icon::new(IconName::ArrowCircle)
513 .size(IconSize::XSmall)
514 .color(Color::Info)
515 .with_rotate_animation(2),
516 )
517 })
518 .when(tool_failed || command_failed, |header| {
519 header.child(
520 div()
521 .id(("terminal-tool-error-code-indicator", self.entity_id))
522 .child(
523 Icon::new(IconName::Close)
524 .size(IconSize::Small)
525 .color(Color::Error),
526 )
527 .when(command_failed && self.exit_status.is_some(), |this| {
528 this.tooltip(Tooltip::text(format!(
529 "Exited with code {}",
530 self.exit_status
531 .and_then(|status| status.code())
532 .unwrap_or(-1),
533 )))
534 })
535 .when(
536 !command_failed && tool_failed && status.error().is_some(),
537 |this| {
538 this.tooltip(Tooltip::text(format!(
539 "Error: {}",
540 status.error().unwrap(),
541 )))
542 },
543 ),
544 )
545 })
546 .when(self.was_content_truncated, |header| {
547 let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
548 "Output exceeded terminal max lines and was \
549 truncated, the model received the first 16 KB."
550 .to_string()
551 } else {
552 format!(
553 "Output is {} long, to avoid unexpected token usage, \
554 only 16 KB was sent back to the model.",
555 format_file_size(self.original_content_len as u64, true),
556 )
557 };
558 header.child(
559 h_flex()
560 .id(("terminal-tool-truncated-label", self.entity_id))
561 .tooltip(Tooltip::text(tooltip))
562 .gap_1()
563 .child(
564 Icon::new(IconName::Info)
565 .size(IconSize::XSmall)
566 .color(Color::Ignored),
567 )
568 .child(
569 Label::new("Truncated")
570 .color(Color::Muted)
571 .size(LabelSize::Small),
572 ),
573 )
574 })
575 .when(time_elapsed > Duration::from_secs(10), |header| {
576 header.child(
577 Label::new(format!("({})", duration_alt_display(time_elapsed)))
578 .buffer_font(cx)
579 .color(Color::Muted)
580 .size(LabelSize::Small),
581 )
582 })
583 .when(!self.finished_with_empty_output, |header| {
584 header.child(
585 Disclosure::new(
586 ("terminal-tool-disclosure", self.entity_id),
587 self.preview_expanded,
588 )
589 .opened_icon(IconName::ChevronUp)
590 .closed_icon(IconName::ChevronDown)
591 .on_click(cx.listener(
592 move |this, _event, _window, _cx| {
593 this.preview_expanded = !this.preview_expanded;
594 },
595 )),
596 )
597 });
598
599 v_flex()
600 .mb_2()
601 .border_1()
602 .when(tool_failed || command_failed, |card| card.border_dashed())
603 .border_color(border_color)
604 .rounded_lg()
605 .overflow_hidden()
606 .child(
607 v_flex()
608 .p_2()
609 .gap_0p5()
610 .bg(header_bg)
611 .text_xs()
612 .child(header)
613 .child(
614 MarkdownElement::new(
615 self.input_command.clone(),
616 markdown_style(window, cx),
617 )
618 .code_block_renderer(
619 markdown::CodeBlockRenderer::Default {
620 copy_button: false,
621 copy_button_on_hover: true,
622 border: false,
623 },
624 ),
625 ),
626 )
627 .when(
628 self.preview_expanded && !self.finished_with_empty_output,
629 |this| {
630 this.child(
631 div()
632 .pt_2()
633 .border_t_1()
634 .when(tool_failed || command_failed, |card| card.border_dashed())
635 .border_color(border_color)
636 .bg(cx.theme().colors().editor_background)
637 .rounded_b_md()
638 .text_ui_sm(cx)
639 .child({
640 let content_mode = terminal.read(cx).content_mode(window, cx);
641
642 if content_mode.is_scrollable() {
643 div().h_72().child(terminal.clone()).into_any_element()
644 } else {
645 ToolOutputPreview::new(
646 terminal.clone().into_any_element(),
647 terminal.entity_id(),
648 )
649 .with_total_lines(self.content_line_count)
650 .toggle_state(!content_mode.is_limited())
651 .on_toggle({
652 let terminal = terminal.clone();
653 move |is_expanded, _, cx| {
654 terminal.update(cx, |terminal, cx| {
655 terminal.set_embedded_mode(
656 if is_expanded {
657 None
658 } else {
659 Some(COLLAPSED_LINES)
660 },
661 cx,
662 );
663 });
664 }
665 })
666 .into_any_element()
667 }
668 }),
669 )
670 },
671 )
672 .into_any()
673 }
674}
675
676fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
677 let theme_settings = ThemeSettings::get_global(cx);
678 let buffer_font_size = TextSize::Default.rems(cx);
679 let mut text_style = window.text_style();
680
681 text_style.refine(&TextStyleRefinement {
682 font_family: Some(theme_settings.buffer_font.family.clone()),
683 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
684 font_features: Some(theme_settings.buffer_font.features.clone()),
685 font_size: Some(buffer_font_size.into()),
686 color: Some(cx.theme().colors().text),
687 ..Default::default()
688 });
689
690 MarkdownStyle {
691 base_text_style: text_style.clone(),
692 selection_background_color: cx.theme().colors().element_selection_background,
693 ..Default::default()
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use editor::EditorSettings;
700 use fs::RealFs;
701 use gpui::{BackgroundExecutor, TestAppContext};
702 use language_model::fake_provider::FakeLanguageModel;
703 use pretty_assertions::assert_eq;
704 use serde_json::json;
705 use settings::{Settings, SettingsStore};
706 use terminal::terminal_settings::TerminalSettings;
707 use theme::ThemeSettings;
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 ThemeSettings::register(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}