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 window,
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}