use agent_client_protocol 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<Markdown>, working_dir: Option<PathBuf>, terminal: Entity<terminal::Terminal>, started_at: Instant, output: Option<TerminalOutput>, output_byte_limit: Option<usize>, _output_task: Shared<Task<acp::TerminalExitStatus>>, /// 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<AtomicBool>,} pub struct TerminalOutput { pub ended_at: Instant, pub exit_status: Option<ExitStatus>, 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<PathBuf>, output_byte_limit: Option<usize>, terminal: Entity<terminal::Terminal>, language_registry: Arc<LanguageRegistry>, cx: &mut Context<Self>, ) -> 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<Task<acp::TerminalExitStatus>> { 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<Markdown> { &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<PathBuf> { &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<terminal::Terminal> { &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<PathBuf>, project: &Entity<Project>, cx: &mut AsyncApp,) -> Result<Entity<terminal::Terminal>> { 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}
use agent_client_protocol 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<Markdown>,
working_dir: Option<PathBuf>,
terminal: Entity<terminal::Terminal>,
started_at: Instant,
output: Option<TerminalOutput>,
output_byte_limit: Option<usize>,
_output_task: Shared<Task<acp::TerminalExitStatus>>,
/// 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<AtomicBool>,
}
pub struct TerminalOutput {
pub ended_at: Instant,
pub exit_status: Option<ExitStatus>,
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<PathBuf>,
output_byte_limit: Option<usize>,
terminal: Entity<terminal::Terminal>,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> 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<Task<acp::TerminalExitStatus>> {
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<Markdown> {
&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<PathBuf> {
&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<terminal::Terminal> {
&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<PathBuf>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<Entity<terminal::Terminal>> {
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
}