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 == "" {
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 {
416 if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
417 return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
418 }
419 }
420
421 anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
422 }
423}
424
425struct TerminalToolCard {
426 input_command: Entity<Markdown>,
427 working_dir: Option<PathBuf>,
428 entity_id: EntityId,
429 exit_status: Option<ExitStatus>,
430 terminal: Option<Entity<TerminalView>>,
431 command_finished: bool,
432 was_content_truncated: bool,
433 finished_with_empty_output: bool,
434 content_line_count: usize,
435 original_content_len: usize,
436 preview_expanded: bool,
437 start_instant: Instant,
438 elapsed_time: Option<Duration>,
439}
440
441impl TerminalToolCard {
442 pub fn new(
443 input_command: Entity<Markdown>,
444 working_dir: Option<PathBuf>,
445 entity_id: EntityId,
446 cx: &mut Context<Self>,
447 ) -> Self {
448 let expand_terminal_card =
449 agent_settings::AgentSettings::get_global(cx).expand_terminal_card;
450 Self {
451 input_command,
452 working_dir,
453 entity_id,
454 exit_status: None,
455 terminal: None,
456 command_finished: false,
457 was_content_truncated: false,
458 finished_with_empty_output: false,
459 original_content_len: 0,
460 content_line_count: 0,
461 preview_expanded: expand_terminal_card,
462 start_instant: Instant::now(),
463 elapsed_time: None,
464 }
465 }
466}
467
468impl ToolCard for TerminalToolCard {
469 fn render(
470 &mut self,
471 status: &ToolUseStatus,
472 window: &mut Window,
473 _workspace: WeakEntity<Workspace>,
474 cx: &mut Context<Self>,
475 ) -> impl IntoElement {
476 let Some(terminal) = self.terminal.as_ref() else {
477 return Empty.into_any();
478 };
479
480 let tool_failed = matches!(status, ToolUseStatus::Error(_));
481
482 let command_failed =
483 self.command_finished && self.exit_status.is_none_or(|code| !code.success());
484
485 if (tool_failed || command_failed) && self.elapsed_time.is_none() {
486 self.elapsed_time = Some(self.start_instant.elapsed());
487 }
488 let time_elapsed = self
489 .elapsed_time
490 .unwrap_or_else(|| self.start_instant.elapsed());
491
492 let header_bg = cx
493 .theme()
494 .colors()
495 .element_background
496 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
497
498 let border_color = cx.theme().colors().border.opacity(0.6);
499
500 let path = self
501 .working_dir
502 .as_ref()
503 .cloned()
504 .or_else(|| env::current_dir().ok())
505 .map(|path| format!("{}", path.display()))
506 .unwrap_or_else(|| "current directory".to_string());
507
508 let header = h_flex()
509 .flex_none()
510 .gap_1()
511 .justify_between()
512 .rounded_t_md()
513 .child(
514 div()
515 .id(("command-target-path", self.entity_id))
516 .w_full()
517 .max_w_full()
518 .overflow_x_scroll()
519 .child(
520 Label::new(path)
521 .buffer_font(cx)
522 .size(LabelSize::XSmall)
523 .color(Color::Muted),
524 ),
525 )
526 .when(!self.command_finished, |header| {
527 header.child(
528 Icon::new(IconName::ArrowCircle)
529 .size(IconSize::XSmall)
530 .color(Color::Info)
531 .with_animation(
532 "arrow-circle",
533 Animation::new(Duration::from_secs(2)).repeat(),
534 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
535 ),
536 )
537 })
538 .when(tool_failed || command_failed, |header| {
539 header.child(
540 div()
541 .id(("terminal-tool-error-code-indicator", self.entity_id))
542 .child(
543 Icon::new(IconName::Close)
544 .size(IconSize::Small)
545 .color(Color::Error),
546 )
547 .when(command_failed && self.exit_status.is_some(), |this| {
548 this.tooltip(Tooltip::text(format!(
549 "Exited with code {}",
550 self.exit_status
551 .and_then(|status| status.code())
552 .unwrap_or(-1),
553 )))
554 })
555 .when(
556 !command_failed && tool_failed && status.error().is_some(),
557 |this| {
558 this.tooltip(Tooltip::text(format!(
559 "Error: {}",
560 status.error().unwrap(),
561 )))
562 },
563 ),
564 )
565 })
566 .when(self.was_content_truncated, |header| {
567 let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
568 "Output exceeded terminal max lines and was \
569 truncated, the model received the first 16 KB."
570 .to_string()
571 } else {
572 format!(
573 "Output is {} long, to avoid unexpected token usage, \
574 only 16 KB was sent back to the model.",
575 format_file_size(self.original_content_len as u64, true),
576 )
577 };
578 header.child(
579 h_flex()
580 .id(("terminal-tool-truncated-label", self.entity_id))
581 .tooltip(Tooltip::text(tooltip))
582 .gap_1()
583 .child(
584 Icon::new(IconName::Info)
585 .size(IconSize::XSmall)
586 .color(Color::Ignored),
587 )
588 .child(
589 Label::new("Truncated")
590 .color(Color::Muted)
591 .size(LabelSize::Small),
592 ),
593 )
594 })
595 .when(time_elapsed > Duration::from_secs(10), |header| {
596 header.child(
597 Label::new(format!("({})", duration_alt_display(time_elapsed)))
598 .buffer_font(cx)
599 .color(Color::Muted)
600 .size(LabelSize::Small),
601 )
602 })
603 .when(!self.finished_with_empty_output, |header| {
604 header.child(
605 Disclosure::new(
606 ("terminal-tool-disclosure", self.entity_id),
607 self.preview_expanded,
608 )
609 .opened_icon(IconName::ChevronUp)
610 .closed_icon(IconName::ChevronDown)
611 .on_click(cx.listener(
612 move |this, _event, _window, _cx| {
613 this.preview_expanded = !this.preview_expanded;
614 },
615 )),
616 )
617 });
618
619 v_flex()
620 .mb_2()
621 .border_1()
622 .when(tool_failed || command_failed, |card| card.border_dashed())
623 .border_color(border_color)
624 .rounded_lg()
625 .overflow_hidden()
626 .child(
627 v_flex()
628 .p_2()
629 .gap_0p5()
630 .bg(header_bg)
631 .text_xs()
632 .child(header)
633 .child(
634 MarkdownElement::new(
635 self.input_command.clone(),
636 markdown_style(window, cx),
637 )
638 .code_block_renderer(
639 markdown::CodeBlockRenderer::Default {
640 copy_button: false,
641 copy_button_on_hover: true,
642 border: false,
643 },
644 ),
645 ),
646 )
647 .when(
648 self.preview_expanded && !self.finished_with_empty_output,
649 |this| {
650 this.child(
651 div()
652 .pt_2()
653 .border_t_1()
654 .when(tool_failed || command_failed, |card| card.border_dashed())
655 .border_color(border_color)
656 .bg(cx.theme().colors().editor_background)
657 .rounded_b_md()
658 .text_ui_sm(cx)
659 .child({
660 let content_mode = terminal.read(cx).content_mode(window, cx);
661
662 if content_mode.is_scrollable() {
663 div().h_72().child(terminal.clone()).into_any_element()
664 } else {
665 ToolOutputPreview::new(
666 terminal.clone().into_any_element(),
667 terminal.entity_id(),
668 )
669 .with_total_lines(self.content_line_count)
670 .toggle_state(!content_mode.is_limited())
671 .on_toggle({
672 let terminal = terminal.clone();
673 move |is_expanded, _, cx| {
674 terminal.update(cx, |terminal, cx| {
675 terminal.set_embedded_mode(
676 if is_expanded {
677 None
678 } else {
679 Some(COLLAPSED_LINES)
680 },
681 cx,
682 );
683 });
684 }
685 })
686 .into_any_element()
687 }
688 }),
689 )
690 },
691 )
692 .into_any()
693 }
694}
695
696fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
697 let theme_settings = ThemeSettings::get_global(cx);
698 let buffer_font_size = TextSize::Default.rems(cx);
699 let mut text_style = window.text_style();
700
701 text_style.refine(&TextStyleRefinement {
702 font_family: Some(theme_settings.buffer_font.family.clone()),
703 font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
704 font_features: Some(theme_settings.buffer_font.features.clone()),
705 font_size: Some(buffer_font_size.into()),
706 color: Some(cx.theme().colors().text),
707 ..Default::default()
708 });
709
710 MarkdownStyle {
711 base_text_style: text_style.clone(),
712 selection_background_color: cx.theme().colors().element_selection_background,
713 ..Default::default()
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use editor::EditorSettings;
720 use fs::RealFs;
721 use gpui::{BackgroundExecutor, TestAppContext};
722 use language_model::fake_provider::FakeLanguageModel;
723 use pretty_assertions::assert_eq;
724 use serde_json::json;
725 use settings::{Settings, SettingsStore};
726 use terminal::terminal_settings::TerminalSettings;
727 use theme::ThemeSettings;
728 use util::{ResultExt as _, test::TempTree};
729
730 use super::*;
731
732 fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
733 zlog::init_test();
734
735 executor.allow_parking();
736 cx.update(|cx| {
737 let settings_store = SettingsStore::test(cx);
738 cx.set_global(settings_store);
739 language::init(cx);
740 Project::init_settings(cx);
741 workspace::init_settings(cx);
742 ThemeSettings::register(cx);
743 TerminalSettings::register(cx);
744 EditorSettings::register(cx);
745 });
746 }
747
748 #[gpui::test]
749 async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
750 if cfg!(windows) {
751 return;
752 }
753
754 init_test(&executor, cx);
755
756 let fs = Arc::new(RealFs::new(None, executor));
757 let tree = TempTree::new(json!({
758 "project": {},
759 }));
760 let project: Entity<Project> =
761 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
762 let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
763 let model = Arc::new(FakeLanguageModel::default());
764
765 let input = TerminalToolInput {
766 command: "cat".to_owned(),
767 cd: tree
768 .path()
769 .join("project")
770 .as_path()
771 .to_string_lossy()
772 .to_string(),
773 };
774 let result = cx.update(|cx| {
775 TerminalTool::run(
776 Arc::new(TerminalTool::new(cx)),
777 serde_json::to_value(input).unwrap(),
778 Arc::default(),
779 project.clone(),
780 action_log.clone(),
781 model,
782 None,
783 cx,
784 )
785 });
786
787 let output = result.output.await.log_err().unwrap().content;
788 assert_eq!(output.as_str().unwrap(), "Command executed successfully.");
789 }
790
791 #[gpui::test]
792 async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
793 if cfg!(windows) {
794 return;
795 }
796
797 init_test(&executor, cx);
798
799 let fs = Arc::new(RealFs::new(None, executor));
800 let tree = TempTree::new(json!({
801 "project": {},
802 "other-project": {},
803 }));
804 let project: Entity<Project> =
805 Project::test(fs, [tree.path().join("project").as_path()], cx).await;
806 let action_log = cx.update(|cx| cx.new(|_| ActionLog::new(project.clone())));
807 let model = Arc::new(FakeLanguageModel::default());
808
809 let check = |input, expected, cx: &mut App| {
810 let headless_result = TerminalTool::run(
811 Arc::new(TerminalTool::new(cx)),
812 serde_json::to_value(input).unwrap(),
813 Arc::default(),
814 project.clone(),
815 action_log.clone(),
816 model.clone(),
817 None,
818 cx,
819 );
820 cx.spawn(async move |_| {
821 let output = headless_result.output.await.map(|output| output.content);
822 assert_eq!(
823 output
824 .ok()
825 .and_then(|content| content.as_str().map(ToString::to_string)),
826 expected
827 );
828 })
829 };
830
831 cx.update(|cx| {
832 check(
833 TerminalToolInput {
834 command: "pwd".into(),
835 cd: ".".into(),
836 },
837 Some(format!(
838 "```\n{}\n```",
839 tree.path().join("project").display()
840 )),
841 cx,
842 )
843 })
844 .await;
845
846 cx.update(|cx| {
847 check(
848 TerminalToolInput {
849 command: "pwd".into(),
850 cd: "other-project".into(),
851 },
852 None, // other-project is a dir, but *not* a worktree (yet)
853 cx,
854 )
855 })
856 .await;
857
858 // Absolute path above the worktree root
859 cx.update(|cx| {
860 check(
861 TerminalToolInput {
862 command: "pwd".into(),
863 cd: tree.path().to_string_lossy().into(),
864 },
865 None,
866 cx,
867 )
868 })
869 .await;
870
871 project
872 .update(cx, |project, cx| {
873 project.create_worktree(tree.path().join("other-project"), true, cx)
874 })
875 .await
876 .unwrap();
877
878 cx.update(|cx| {
879 check(
880 TerminalToolInput {
881 command: "pwd".into(),
882 cd: "other-project".into(),
883 },
884 Some(format!(
885 "```\n{}\n```",
886 tree.path().join("other-project").display()
887 )),
888 cx,
889 )
890 })
891 .await;
892
893 cx.update(|cx| {
894 check(
895 TerminalToolInput {
896 command: "pwd".into(),
897 cd: ".".into(),
898 },
899 None,
900 cx,
901 )
902 })
903 .await;
904 }
905}