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