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