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