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