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