terminal_tool.rs

  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}