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. Load more
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()))