use agent_client_protocol::schema as acp; use anyhow::Result; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; use language::LanguageRegistry; use markdown::Markdown; use project::Project; use std::{ path::PathBuf, process::ExitStatus, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, time::Instant, }; use task::Shell; use util::get_default_system_shell_preferring_bash; pub struct Terminal { id: acp::TerminalId, command: Entity, working_dir: Option, terminal: Entity, started_at: Instant, output: Option, output_byte_limit: Option, _output_task: Shared>, /// Flag indicating whether this terminal was stopped by explicit user action /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc, } pub struct TerminalOutput { pub ended_at: Instant, pub exit_status: Option, pub content: String, pub original_content_len: usize, pub content_line_count: usize, } impl Terminal { pub fn new( id: acp::TerminalId, command_label: &str, working_dir: Option, output_byte_limit: Option, terminal: Entity, language_registry: Arc, cx: &mut Context, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); Self { id, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), Some(language_registry.clone()), None, cx, ) }), working_dir, terminal, started_at: Instant::now(), output: None, output_byte_limit, user_stopped: Arc::new(AtomicBool::new(false)), _output_task: cx .spawn(async move |this, cx| { let exit_status = command_task.await; this.update(cx, |this, cx| { let (content, original_content_len) = this.truncated_output(cx); let content_line_count = this.terminal.read(cx).total_lines(); this.output = Some(TerminalOutput { ended_at: Instant::now(), exit_status, content, original_content_len, content_line_count, }); cx.notify(); }) .ok(); let exit_status = exit_status.map(portable_pty::ExitStatus::from); acp::TerminalExitStatus::new() .exit_code(exit_status.as_ref().map(|e| e.exit_code())) .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))) }) .shared(), } } pub fn id(&self) -> &acp::TerminalId { &self.id } pub fn wait_for_exit(&self) -> Shared> { self._output_task.clone() } pub fn kill(&mut self, cx: &mut App) { self.terminal.update(cx, |terminal, _cx| { terminal.kill_active_task(); }); } /// Marks this terminal as stopped by user action and then kills it. /// This should be called when the user explicitly clicks a Stop button. pub fn stop_by_user(&mut self, cx: &mut App) { self.user_stopped.store(true, Ordering::SeqCst); self.kill(cx); } /// Returns whether this terminal was stopped by explicit user action. pub fn was_stopped_by_user(&self) -> bool { self.user_stopped.load(Ordering::SeqCst) } pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse { if let Some(output) = self.output.as_ref() { let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); acp::TerminalOutputResponse::new( output.content.clone(), output.original_content_len > output.content.len(), ) .exit_status( acp::TerminalExitStatus::new() .exit_code(exit_status.as_ref().map(|e| e.exit_code())) .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))), ) } else { let (current_content, original_len) = self.truncated_output(cx); let truncated = current_content.len() < original_len; acp::TerminalOutputResponse::new(current_content, truncated) } } fn truncated_output(&self, cx: &App) -> (String, usize) { let terminal = self.terminal.read(cx); let mut content = terminal.get_content(); let original_content_len = content.len(); if let Some(limit) = self.output_byte_limit && content.len() > limit { let mut end_ix = 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.truncate(end_ix); } (content, original_content_len) } pub fn command(&self) -> &Entity { &self.command } pub fn update_command_label(&self, label: &str, cx: &mut App) { self.command.update(cx, |command, cx| { command.replace(format!("```\n{}\n```", label), cx); }); } pub fn working_dir(&self) -> &Option { &self.working_dir } pub fn started_at(&self) -> Instant { self.started_at } pub fn output(&self) -> Option<&TerminalOutput> { self.output.as_ref() } pub fn inner(&self) -> &Entity { &self.terminal } pub fn to_markdown(&self, cx: &App) -> String { format!( "Terminal:\n```\n{}\n```\n", self.terminal.read(cx).get_content() ) } } pub async fn create_terminal_entity( command: String, args: &[String], env_vars: Vec<(String, String)>, cwd: Option, project: &Entity, cx: &mut AsyncApp, ) -> Result> { let mut env = if let Some(dir) = &cwd { project .update(cx, |project, cx| { project.environment().update(cx, |env, cx| { env.directory_environment(dir.clone().into(), cx) }) }) .await .unwrap_or_default() } else { Default::default() }; // Disable pagers so agent/terminal commands don't hang behind interactive UIs env.insert("PAGER".into(), "".into()); // Override user core.pager (e.g. delta) which Git prefers over PAGER env.insert("GIT_PAGER".into(), "cat".into()); env.extend(env_vars); // Use remote shell or default system shell, as appropriate let shell = project .update(cx, |project, cx| { project .remote_client() .and_then(|r| r.read(cx).default_system_shell()) .map(Shell::Program) }) .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash())); let is_windows = project.read_with(cx, |project, cx| project.path_style(cx).is_windows()); let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows) .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); project .update(cx, |project, cx| { project.create_terminal_task( task::SpawnInTerminal { command: Some(task_command), args: task_args, cwd, env, ..Default::default() }, cx, ) }) .await }