887 lines · 30.2 KB · Rust
RawDownload
use crate::{    schema::json_schema_for,    ui::{COLLAPSED_LINES, ToolOutputPreview},};use anyhow::{Context as _, Result, anyhow};use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};use futures::{FutureExt as _, future::Shared};use gpui::{    AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement,    WeakEntity, Window,};use language::LineEnding;use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};use markdown::{Markdown, MarkdownElement, MarkdownStyle};use portable_pty::{CommandBuilder, PtySize, native_pty_system};use project::{Project, terminals::TerminalKind};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use settings::Settings;use std::{    env,    path::{Path, PathBuf},    process::ExitStatus,    sync::Arc,    time::{Duration, Instant},};use terminal_view::TerminalView;use theme::ThemeSettings;use ui::{Disclosure, Tooltip, prelude::*};use util::{    ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,    time::duration_alt_display,};use workspace::Workspace; const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024; #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]pub struct TerminalToolInput {    /// The one-liner command to execute.    command: String,    /// Working directory for the command. This must be one of the root directories of the project.    cd: String,} pub struct TerminalTool {    determine_shell: Shared<Task<String>>,} impl TerminalTool {    pub const NAME: &str = "terminal";     pub(crate) fn new(cx: &mut App) -> Self {        let determine_shell = cx.background_spawn(async move {            if cfg!(windows) {                return get_system_shell();            }             if which::which("bash").is_ok() {                log::info!("agent selected bash for terminal tool");                "bash".into()            } else {                let shell = get_system_shell();                log::info!("agent selected {shell} for terminal tool");                shell            }        });        Self {            determine_shell: determine_shell.shared(),        }    }} impl Tool for TerminalTool {    fn name(&self) -> String {        Self::NAME.to_string()    }     fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {        true    }     fn may_perform_edits(&self) -> bool {        false    }     fn description(&self) -> String {        include_str!("./terminal_tool/description.md").to_string()    }     fn icon(&self) -> IconName {        IconName::Terminal    }     fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {        json_schema_for::<TerminalToolInput>(format)    }     fn ui_text(&self, input: &serde_json::Value) -> String {        match serde_json::from_value::<TerminalToolInput>(input.clone()) {            Ok(input) => {                let mut lines = input.command.lines();                let first_line = lines.next().unwrap_or_default();                let remaining_line_count = lines.count();                match remaining_line_count {                    0 => MarkdownInlineCode(&first_line).to_string(),                    1 => MarkdownInlineCode(&format!(                        "{} - {} more line",                        first_line, remaining_line_count                    ))                    .to_string(),                    n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))                        .to_string(),                }            }            Err(_) => "Run terminal command".to_string(),        }    }     fn run(        self: Arc<Self>,        input: serde_json::Value,        _request: Arc<LanguageModelRequest>,        project: Entity<Project>,        _action_log: Entity<ActionLog>,        _model: Arc<dyn LanguageModel>,        window: Option<AnyWindowHandle>,        cx: &mut App,    ) -> ToolResult {        let input: TerminalToolInput = match serde_json::from_value(input) {            Ok(input) => input,            Err(err) => return Task::ready(Err(anyhow!(err))).into(),        };         let working_dir = match working_dir(&input, &project, cx) {            Ok(dir) => dir,            Err(err) => return Task::ready(Err(err)).into(),        };        let program = self.determine_shell.clone();        let command = if cfg!(windows) {            format!("$null | & {{{}}}", input.command.replace("\"", "'"))        } else if let Some(cwd) = working_dir            .as_ref()            .and_then(|cwd| cwd.as_os_str().to_str())        {            // Make sure once we're *inside* the shell, we cd into `cwd`            format!("(cd {cwd}; {}) </dev/null", input.command)        } else {            format!("({}) </dev/null", input.command)        };        let args = vec!["-c".into(), command];         let cwd = working_dir.clone();        let env = match &working_dir {            Some(dir) => project.update(cx, |project, cx| {                project.directory_environment(dir.as_path().into(), cx)            }),            None => Task::ready(None).shared(),        };         let env = cx.spawn(async move |_| {            let mut env = env.await.unwrap_or_default();            if cfg!(unix) {                env.insert("PAGER".into(), "cat".into());            }            env        });         let Some(window) = window else {            // Headless setup, a test or eval. Our terminal subsystem requires a workspace,            // so bypass it and provide a convincing imitation using a pty.            let task = cx.background_spawn(async move {                let env = env.await;                let pty_system = native_pty_system();                let program = program.await;                let mut cmd = CommandBuilder::new(program);                cmd.args(args);                for (k, v) in env {                    cmd.env(k, v);                }                if let Some(cwd) = cwd {                    cmd.cwd(cwd);                }                let pair = pty_system.openpty(PtySize {                    rows: 24,                    cols: 80,                    ..Default::default()                })?;                let mut child = pair.slave.spawn_command(cmd)?;                let mut reader = pair.master.try_clone_reader()?;                drop(pair);                let mut content = String::new();                reader.read_to_string(&mut content)?;                // Massage the pty output a bit to try to match what the terminal codepath gives us                LineEnding::normalize(&mut content);                content = content                    .chars()                    .filter(|c| c.is_ascii_whitespace() || !c.is_ascii_control())                    .collect();                let content = content.trim_start().trim_start_matches("^D");                let exit_status = child.wait()?;                let (processed_content, _) =                    process_content(content, &input.command, Some(exit_status));                Ok(processed_content.into())            });            return ToolResult {                output: task,                card: None,            };        };         let terminal = cx.spawn({            let project = project.downgrade();            async move |cx| {                let program = program.await;                let env = env.await;                let terminal = project                    .update(cx, |project, cx| {                        project.create_terminal(                            TerminalKind::Task(task::SpawnInTerminal {                                command: program,                                args,                                cwd,                                env,                                ..Default::default()                            }),                            window,                            cx,                        )                    })?                    .await;                terminal            }        });         let command_markdown = cx.new(|cx| {            Markdown::new(                format!("```bash\n{}\n```", input.command).into(),                None,                None,                cx,            )        });         let card = cx.new(|cx| {            TerminalToolCard::new(                command_markdown.clone(),                working_dir.clone(),                cx.entity_id(),            )        });         let output = cx.spawn({            let card = card.clone();            async move |cx| {                let terminal = terminal.await?;                let workspace = window                    .downcast::<Workspace>()                    .and_then(|handle| handle.entity(cx).ok())                    .context("no workspace entity in root of window")?;                 let terminal_view = window.update(cx, |_, window, cx| {                    cx.new(|cx| {                        let mut view = TerminalView::new(                            terminal.clone(),                            workspace.downgrade(),                            None,                            project.downgrade(),                            window,                            cx,                        );                        view.set_embedded_mode(None, cx);                        view                    })                })?;                 card.update(cx, |card, _| {                    card.terminal = Some(terminal_view.clone());                    card.start_instant = Instant::now();                })                .log_err();                 let exit_status = terminal                    .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?                    .await;                let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {                    (terminal.get_content(), terminal.total_lines())                })?;                 let previous_len = content.len();                let (processed_content, finished_with_empty_output) = process_content(                    &content,                    &input.command,                    exit_status.map(portable_pty::ExitStatus::from),                );                 card.update(cx, |card, _| {                    card.command_finished = true;                    card.exit_status = exit_status;                    card.was_content_truncated = processed_content.len() < previous_len;                    card.original_content_len = previous_len;                    card.content_line_count = content_line_count;                    card.finished_with_empty_output = finished_with_empty_output;                    card.elapsed_time = Some(card.start_instant.elapsed());                })                .log_err();                 Ok(processed_content.into())            }        });         ToolResult {            output,            card: Some(card.into()),        }    }} fn process_content(    content: &str,    command: &str,    exit_status: Option<portable_pty::ExitStatus>,) -> (String, bool) {    let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;     let content = if should_truncate {        let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());        while !content.is_char_boundary(end_ix) {            end_ix -= 1;        }        // Don't truncate mid-line, clear the remainder of the last line        end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);        &content[..end_ix]    } else {        content    };    let content = content.trim();    let is_empty = content.is_empty();    let content = format!("```\n{content}\n```");    let content = if should_truncate {        format!(            "Command output too long. The first {} bytes:\n\n{content}",            content.len(),        )    } else {        content    };     let content = match exit_status {        Some(exit_status) if exit_status.success() => {            if is_empty {                "Command executed successfully.".to_string()            } else {                content.to_string()            }        }        Some(exit_status) => {            if is_empty {                format!(                    "Command \"{command}\" failed with exit code {}.",                    exit_status.exit_code()                )            } else {                format!(                    "Command \"{command}\" failed with exit code {}.\n\n{content}",                    exit_status.exit_code()                )            }        }        None => {            format!(                "Command failed or was interrupted.\nPartial output captured:\n\n{}",                content,            )        }    };    (content, is_empty)} fn working_dir(    input: &TerminalToolInput,    project: &Entity<Project>,    cx: &mut App,) -> Result<Option<PathBuf>> {    let project = project.read(cx);    let cd = &input.cd;     if cd == "." || cd == "" {        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.        let mut worktrees = project.worktrees(cx);         match worktrees.next() {            Some(worktree) => {                anyhow::ensure!(                    worktrees.next().is_none(),                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",                );                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))            }            None => Ok(None),        }    } else {        let input_path = Path::new(cd);         if input_path.is_absolute() {            // Absolute paths are allowed, but only if they're in one of the project's worktrees.            if project                .worktrees(cx)                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))            {                return Ok(Some(input_path.into()));            }        } else {            if let Some(worktree) = project.worktree_for_root_name(cd, cx) {                return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));            }        }         anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");    }} struct TerminalToolCard {    input_command: Entity<Markdown>,    working_dir: Option<PathBuf>,    entity_id: EntityId,    exit_status: Option<ExitStatus>,    terminal: Option<Entity<TerminalView>>,    command_finished: bool,    was_content_truncated: bool,    finished_with_empty_output: bool,    content_line_count: usize,    original_content_len: usize,    preview_expanded: bool,    start_instant: Instant,    elapsed_time: Option<Duration>,} impl TerminalToolCard {    pub fn new(        input_command: Entity<Markdown>,        working_dir: Option<PathBuf>,        entity_id: EntityId,    ) -> Self {        Self {            input_command,            working_dir,            entity_id,            exit_status: None,            terminal: None,            command_finished: false,            was_content_truncated: false,            finished_with_empty_output: false,            original_content_len: 0,            content_line_count: 0,            preview_expanded: true,            start_instant: Instant::now(),            elapsed_time: None,        }    }} impl ToolCard for TerminalToolCard {    fn render(        &mut self,        status: &ToolUseStatus,        window: &mut Window,        _workspace: WeakEntity<Workspace>,        cx: &mut Context<Self>,    ) -> impl IntoElement {        let Some(terminal) = self.terminal.as_ref() else {            return Empty.into_any();        };         let tool_failed = matches!(status, ToolUseStatus::Error(_));         let command_failed =            self.command_finished && self.exit_status.is_none_or(|code| !code.success());         if (tool_failed || command_failed) && self.elapsed_time.is_none() {            self.elapsed_time = Some(self.start_instant.elapsed());        }        let time_elapsed = self            .elapsed_time            .unwrap_or_else(|| self.start_instant.elapsed());         let header_bg = cx            .theme()            .colors()            .element_background            .blend(cx.theme().colors().editor_foreground.opacity(0.025));         let border_color = cx.theme().colors().border.opacity(0.6);         let path = self            .working_dir            .as_ref()            .cloned()            .or_else(|| env::current_dir().ok())            .map(|path| format!("{}", path.display()))

Lines 1–500 of 887.