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