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