diff --git a/Cargo.lock b/Cargo.lock index 29474c8f0..f532859a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "arboard" version = "3.6.1" @@ -211,6 +217,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -259,7 +271,7 @@ dependencies = [ "rlimit", "strum", "strum_macros", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uucore", @@ -292,7 +304,7 @@ dependencies = [ "strum", "strum_macros", "terminfo", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -312,7 +324,7 @@ dependencies = [ "cached", "indenter", "peg", - "thiserror", + "thiserror 2.0.18", "tracing", "utf8-chars", ] @@ -369,7 +381,7 @@ dependencies = [ "cached_proc_macro_types", "hashbrown 0.15.5", "once_cell", - "thiserror", + "thiserror 2.0.18", "web-time", ] @@ -407,6 +419,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -495,7 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" dependencies = [ "nix 0.30.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -644,7 +662,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", ] @@ -772,6 +790,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -836,7 +865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1158,7 +1187,7 @@ dependencies = [ "markup5ever_rcdom", "once_cell", "regex", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1393,6 +1422,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.181" @@ -1421,7 +1456,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall 0.7.1", ] @@ -1549,7 +1584,7 @@ version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "909805cbad4d569e69b80e101290fe72e92b9742ba9e333b0c1e83b22fb7447b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "ctor", "futures", "napi-build", @@ -1607,15 +1642,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -1625,9 +1672,9 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -1730,7 +1777,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -1742,7 +1789,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags", + "bitflags 2.10.0", "dispatch2", "objc2", ] @@ -1753,7 +1800,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags", + "bitflags 2.10.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -1772,7 +1819,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] @@ -1793,7 +1840,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] @@ -2012,6 +2059,7 @@ dependencies = [ "os_pipe", "parking_lot", "phf 0.13.1", + "portable-pty", "rayon", "smallvec", "syntect", @@ -2020,7 +2068,7 @@ dependencies = [ "tokio-util", "unicode-segmentation", "unicode-width", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2047,13 +2095,34 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2105,7 +2174,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ - "bitflags", + "bitflags 2.10.0", "chrono", "flate2", "procfs-core", @@ -2118,7 +2187,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ - "bitflags", + "bitflags 2.10.0", "chrono", "hex", ] @@ -2232,7 +2301,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2241,7 +2310,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2312,7 +2381,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2382,6 +2451,33 @@ dependencies = [ "syn", ] +[[package]] +name = "serial2" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2531,7 +2627,7 @@ dependencies = [ "regex-syntax", "serde", "serde_derive", - "thiserror", + "thiserror 2.0.18", "walkdir", ] @@ -2582,13 +2678,33 @@ dependencies = [ "phf_codegen 0.11.3", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2813,7 +2929,7 @@ dependencies = [ "os_display", "phf 0.13.1", "procfs", - "thiserror", + "thiserror 2.0.18", "unic-langid", "uucore_procs", "wild", @@ -2950,7 +3066,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags", + "bitflags 2.10.0", "rustix", "wayland-backend", "wayland-scanner", @@ -2962,7 +3078,7 @@ version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2974,7 +3090,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3415,6 +3531,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -3441,7 +3566,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror", + "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", "wayland-client", diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 7d2ff5ca2..5473ae419 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -22,6 +22,7 @@ brush-builtins = { version = "0.1.0", path = "../brush-builtins-vendored" } parking_lot = "0.12.5" clap = { version = "4", features = ["derive"] } os_pipe = "1" +portable-pty = "0.9" sysinfo = "0.38" grep-regex = "0.1" grep-searcher = "0.1" diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 1c2f54652..d8038e1d3 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -30,6 +30,7 @@ pub mod image; pub mod keys; pub mod prof; pub mod ps; +pub mod pty; pub mod shell; pub mod system_info; pub mod task; diff --git a/crates/pi-natives/src/pty.rs b/crates/pi-natives/src/pty.rs new file mode 100644 index 000000000..5e8b2e8b5 --- /dev/null +++ b/crates/pi-natives/src/pty.rs @@ -0,0 +1,364 @@ +//! PTY-backed interactive command execution exported via N-API. +//! +//! # Overview +//! Provides a stateful PTY session that supports streaming output and stdin +//! passthrough while a command is running. + +use std::{ + collections::HashMap, + io::{Read, Write}, + str, + sync::{Arc, Mutex, mpsc}, + time::Duration, +}; + +use napi::{ + bindgen_prelude::*, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, +}; +use napi_derive::napi; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + +use crate::task; + +/// Options for running a command in a PTY session. +#[napi(object)] +pub struct PtyStartOptions<'env> { + /// Command string to execute. + pub command: String, + /// Working directory for command execution. + pub cwd: Option, + /// Environment variables for this command. + pub env: Option>, + /// Timeout in milliseconds before cancelling. + #[napi(js_name = "timeoutMs")] + pub timeout_ms: Option, + /// Abort signal for cancelling the operation. + pub signal: Option>, + /// PTY column count. + pub cols: Option, + /// PTY row count. + pub rows: Option, +} + +/// Result of a PTY command run. +#[napi(object)] +pub struct PtyRunResult { + /// Exit code when the command completes. + pub exit_code: Option, + /// Whether command was cancelled by signal/user kill. + pub cancelled: bool, + /// Whether command timed out. + pub timed_out: bool, +} + +#[derive(Clone)] +struct PtyRunConfig { + command: String, + cwd: Option, + env: Option>, + cols: u16, + rows: u16, +} + +enum ReaderEvent { + Chunk(String), + Done, +} + +enum ControlMessage { + Input(String), + Resize { cols: u16, rows: u16 }, + Kill, +} + +struct PtySessionCore { + control_tx: mpsc::Sender, +} + +/// Stateful PTY session for interactive stdin/stdout passthrough. +#[napi] +pub struct PtySession { + core: Arc>>, +} + +impl Default for PtySession { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl PtySession { + #[napi(constructor)] + pub fn new() -> Self { + Self { core: Arc::new(Mutex::new(None)) } + } + + /// Start a PTY command and stream output chunks via callback. + #[napi] + pub fn start<'env>( + &self, + env: &'env Env, + options: PtyStartOptions<'env>, + #[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option< + ThreadsafeFunction, + >, + ) -> Result> { + let run_config = PtyRunConfig { + command: options.command, + cwd: options.cwd, + env: options.env, + cols: options.cols.unwrap_or(120).clamp(20, 400), + rows: options.rows.unwrap_or(40).clamp(5, 200), + }; + let ct = task::CancelToken::new(options.timeout_ms, options.signal); + let core = Arc::clone(&self.core); + + // Register control channel synchronously so write()/kill() work immediately. + let (control_tx, control_rx) = mpsc::channel::(); + { + let mut guard = core + .lock() + .map_err(|_| Error::from_reason("PTY session lock poisoned"))?; + if guard.is_some() { + return Err(Error::from_reason("PTY session already running")); + } + *guard = Some(PtySessionCore { control_tx }); + } + task::future(env, "pty.start", async move { + let run_result = + tokio::task::spawn_blocking(move || run_pty_sync(run_config, on_chunk, control_rx, ct)) + .await; + + // Always clear core regardless of result + let mut guard = core + .lock() + .map_err(|_| Error::from_reason("PTY session lock poisoned"))?; + *guard = None; + drop(guard); + + match run_result { + Ok(inner) => inner, + Err(err) => Err(Error::from_reason(format!("PTY execution task failed: {err}"))), + } + }) + } + + /// Write raw input bytes to PTY stdin. + #[napi] + pub fn write(&self, data: String) -> Result<()> { + self.send_control(ControlMessage::Input(data)) + } + + /// Resize the active PTY. + #[napi] + pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { + self.send_control(ControlMessage::Resize { + cols: cols.clamp(20, 400), + rows: rows.clamp(5, 200), + }) + } + + /// Force-kill the active PTY command. + #[napi] + pub fn kill(&self) -> Result<()> { + self.send_control(ControlMessage::Kill) + } +} + +impl PtySession { + fn send_control(&self, message: ControlMessage) -> Result<()> { + let guard = self + .core + .lock() + .map_err(|_| Error::from_reason("PTY session lock poisoned"))?; + let core = guard + .as_ref() + .ok_or_else(|| Error::from_reason("PTY session is not running"))?; + core + .control_tx + .send(message) + .map_err(|_| Error::from_reason("PTY session is no longer available")) + } +} + +fn run_pty_sync( + config: PtyRunConfig, + on_chunk: Option>, + control_rx: mpsc::Receiver, + ct: task::CancelToken, +) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: config.rows, + cols: config.cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| Error::from_reason(format!("Failed to open PTY: {err}")))?; + + let mut cmd = CommandBuilder::new("sh"); + cmd.arg("-lc"); + cmd.arg(&config.command); + if let Some(cwd) = config.cwd.as_ref() { + cmd.cwd(cwd); + } + if let Some(env) = config.env.as_ref() { + for (key, value) in env { + cmd.env(key, value); + } + } + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|err| Error::from_reason(format!("Failed to spawn PTY command: {err}")))?; + drop(pair.slave); + + let master = pair.master; + let mut writer = master + .take_writer() + .map_err(|err| Error::from_reason(format!("Failed to create PTY writer: {err}")))?; + let mut reader = master + .try_clone_reader() + .map_err(|err| Error::from_reason(format!("Failed to create PTY reader: {err}")))?; + + let (reader_tx, reader_rx) = mpsc::channel::(); + let reader_thread = std::thread::spawn(move || { + const REPLACEMENT: &str = "\u{FFFD}"; + const BUF: usize = 4096; + let mut buf = [0u8; BUF + 4]; + let mut it = 0; + loop { + match reader.read(&mut buf[it..BUF]) { + Ok(0) => { + break; + }, + Ok(n) => { + it += n; + while it > 0 { + let pending = &buf[..it]; + match str::from_utf8(pending) { + Ok(text) => { + let _ = reader_tx.send(ReaderEvent::Chunk(text.to_string())); + it = 0; + break; + }, + Err(err) => { + let valid_up_to = err.valid_up_to(); + if valid_up_to > 0 { + // SAFETY: [..valid_up_to] is guaranteed valid UTF-8 by valid_up_to(). + let text = unsafe { str::from_utf8_unchecked(&pending[..valid_up_to]) }; + let _ = reader_tx.send(ReaderEvent::Chunk(text.to_string())); + buf.copy_within(valid_up_to..it, 0); + it -= valid_up_to; + } + match err.error_len() { + Some(invalid_len) => { + let _ = reader_tx.send(ReaderEvent::Chunk(REPLACEMENT.to_string())); + buf.copy_within(invalid_len..it, 0); + it -= invalid_len; + }, + None => { + break; + }, + } + }, + } + } + }, + Err(_) => { + break; + }, + } + } + for chunk in buf[..it].utf8_chunks() { + let valid = chunk.valid(); + if !valid.is_empty() { + let _ = reader_tx.send(ReaderEvent::Chunk(valid.to_string())); + } + if !chunk.invalid().is_empty() { + let _ = reader_tx.send(ReaderEvent::Chunk(REPLACEMENT.to_string())); + } + } + let _ = reader_tx.send(ReaderEvent::Done); + }); + + let mut timed_out = false; + let mut cancelled = false; + let mut reader_done = false; + let mut exit_code: Option = None; + + while exit_code.is_none() || !reader_done { + if let Err(err) = ct.heartbeat() { + let message = err.to_string(); + timed_out = message.contains("Timeout"); + cancelled = !timed_out; + let _ = child.kill(); + } + + loop { + match control_rx.try_recv() { + Ok(ControlMessage::Input(data)) => { + let _ = writer.write_all(data.as_bytes()); + let _ = writer.flush(); + }, + Ok(ControlMessage::Resize { cols, rows }) => { + let _ = master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }); + }, + Ok(ControlMessage::Kill) => { + cancelled = true; + let _ = child.kill(); + }, + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => break, + } + } + + loop { + match reader_rx.try_recv() { + Ok(ReaderEvent::Chunk(chunk)) => emit_chunk(&chunk, on_chunk.as_ref()), + Ok(ReaderEvent::Done) => { + reader_done = true; + break; + }, + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + reader_done = true; + break; + }, + } + } + + if exit_code.is_none() + && let Some(status) = child + .try_wait() + .map_err(|err| Error::from_reason(format!("Failed checking PTY status: {err}")))? + { + exit_code = Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX)); + } + + if exit_code.is_none() || !reader_done { + std::thread::sleep(Duration::from_millis(16)); + } + } + + if exit_code.is_none() { + let status = child + .wait() + .map_err(|err| Error::from_reason(format!("Failed waiting PTY process: {err}")))?; + exit_code = Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX)); + } + + let _ = reader_thread.join(); + + Ok(PtyRunResult { exit_code, cancelled, timed_out }) +} + +fn emit_chunk(text: &str, callback: Option<&ThreadsafeFunction>) { + if let Some(callback) = callback { + callback.call(Ok(text.to_string()), ThreadsafeFunctionCallMode::NonBlocking); + } +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f3eb45bef..764cc6d9c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,10 +1,20 @@ # Changelog ## [Unreleased] + ### Added +- Added `--no-pty` flag to disable PTY-based interactive bash execution +- Added `PI_NO_PTY` environment variable to disable PTY-based interactive bash execution +- Added `bash.virtualTerminal` setting to control PTY-backed interactive execution for bash commands +- Added interactive PTY-based bash execution with real-time terminal rendering and input forwarding - Added sourcekit-lsp language server support for Swift files +### Changed + +- Changed diff line format from space-separated (`+123 content`) to pipe-delimited (`+123|content`) for improved parsing reliability +- Changed bash tool to use interactive PTY execution by default when UI is available, falling back to standard execution when disabled + ## [11.13.1] - 2026-02-12 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 78118f974..70d0e5622 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -91,6 +91,7 @@ "@oh-my-pi/pi-tui": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", "@sinclair/typebox": "^0.34.48", + "@xterm/headless": "^6.0.0", "ajv": "^8.17.1", "chalk": "^5.6.2", "diff": "^8.0.3", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 5af9c8547..46082568d 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -32,6 +32,7 @@ export interface Args { tools?: string[]; noTools?: boolean; noLsp?: boolean; + noPty?: boolean; hooks?: string[]; extensions?: string[]; noExtensions?: boolean; @@ -109,6 +110,8 @@ export function parseArgs(args: string[], extensionFlags?: Map s.trim()); const validTools: string[] = []; @@ -219,6 +222,7 @@ export function getExtraHelpText(): string { PI_SMOL_MODEL - Override smol/fast model (see --smol) PI_SLOW_MODEL - Override slow/reasoning model (see --slow) PI_PLAN_MODEL - Override planning model (see --plan) + PI_NO_PTY - Disable PTY-based interactive bash execution For complete environment variable reference, see: ${chalk.dim("docs/environment-variables.md")} diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 17032c436..211c0c15b 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -77,6 +77,9 @@ export default class Index extends Command { "no-lsp": Flags.boolean({ description: "Disable LSP tools, formatting, and diagnostics", }), + "no-pty": Flags.boolean({ + description: "Disable PTY-based interactive bash execution", + }), tools: Flags.string({ description: "Comma-separated list of tools to enable (default: all)", }), diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 8c38d2f0a..f5b0e8b0a 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -630,6 +630,17 @@ export const SETTINGS_SCHEMA = { // ───────────────────────────────────────────────────────────────────────── // Bash interceptor settings // ───────────────────────────────────────────────────────────────────────── + "bash.virtualTerminal": { + type: "enum", + values: ["on", "off"] as const, + default: "on", + ui: { + tab: "bash", + label: "Virtual terminal", + description: "Use PTY-backed interactive execution for bash", + submenu: true, + }, + }, "bashInterceptor.enabled": { type: "boolean", default: false, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5e59f7a1c..0e59610cd 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -526,6 +526,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise 1 && lines[lines.length - 1] === "") { + lines.pop(); + } + return Math.max(1, lines.length); +} + +function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string { + const padded = String(lineNum).padStart(width, " "); + return `${prefix}${padded}|${content}`; +} + /** * Generate a unified diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). @@ -26,15 +39,7 @@ export function generateDiffString(oldContent: string, newContent: string, conte const parts = Diff.diffLines(oldContent, newContent); const output: string[] = []; - const countLines = (content: string): number => { - const lines = content.split("\n"); - if (lines.length > 1 && lines[lines.length - 1] === "") { - lines.pop(); - } - return Math.max(1, lines.length); - }; - - const maxLineNum = Math.max(countLines(oldContent), countLines(newContent)); + const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent)); const lineNumWidth = String(maxLineNum).length; let oldLineNum = 1; @@ -58,12 +63,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte // Show the change for (const line of raw) { if (part.added) { - const lineNum = String(newLineNum).padStart(lineNumWidth, " "); - output.push(`+${lineNum} ${line}`); + output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line)); newLineNum++; } else { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(`-${lineNum} ${line}`); + output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line)); oldLineNum++; } } @@ -91,21 +94,20 @@ export function generateDiffString(oldContent: string, newContent: string, conte // Add ellipsis if we skipped lines at start if (skipStart > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "...")); oldLineNum += skipStart; newLineNum += skipStart; } for (const line of linesToShow) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(` ${lineNum} ${line}`); + output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line)); oldLineNum++; newLineNum++; } // Add ellipsis if we skipped lines at end if (skipEnd > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "...")); oldLineNum += skipEnd; newLineNum += skipEnd; } @@ -150,18 +152,32 @@ export function generateUnifiedDiffString(oldContent: string, newContent: string const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines }); const output: string[] = []; let firstChangedLine: number | undefined; - + const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent)); + const lineNumWidth = String(maxLineNum).length; for (const hunk of patch.hunks) { output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + let oldLine = hunk.oldStart; let newLine = hunk.newStart; for (const line of hunk.lines) { - output.push(line); - if (firstChangedLine === undefined && (line.startsWith("+") || line.startsWith("-"))) { - firstChangedLine = newLine; + if (line.startsWith("-")) { + if (firstChangedLine === undefined) firstChangedLine = newLine; + output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1))); + oldLine++; + continue; } - if (line.startsWith("+") || line.startsWith(" ")) { + if (line.startsWith("+")) { + if (firstChangedLine === undefined) firstChangedLine = newLine; + output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1))); newLine++; + continue; } + if (line.startsWith(" ")) { + output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1))); + oldLine++; + newLine++; + continue; + } + output.push(line); } } diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts new file mode 100644 index 000000000..2d33dbfdc --- /dev/null +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -0,0 +1,339 @@ +import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; +import { type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives"; +import { + type Component, + matchesKey, + padding, + parseKey, + parseKittySequence, + truncateToWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; +import xterm from "@xterm/headless"; +import type { Theme } from "../modes/theme/theme"; +import { OutputSink, type OutputSummary } from "../session/streaming-output"; +import { getStateIcon } from "../tui"; +import { replaceTabs, wrapBrackets } from "./render-utils"; + +export interface BashInteractiveResult extends OutputSummary { + exitCode: number | undefined; + cancelled: boolean; + timedOut: boolean; +} + +function normalizeCaptureChunk(chunk: string): string { + const noAnsi = Bun.stripANSI(chunk); + const normalized = noAnsi.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n"); + return normalized.replace(/[\x00-\x08\x0B-\x1F\x7F]/gu, ""); +} + +const XtermTerminal = xterm.Terminal; + +function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string { + const kitty = parseKittySequence(data); + if (!kitty) { + return data; + } + if (kitty.eventType === 3) { + return ""; + } + const keyId = parseKey(data); + if (!keyId) { + return data; + } + const normalizedKey = keyId.toLowerCase(); + if (normalizedKey === "up") return applicationCursorKeysMode ? "\x1bOA" : "\x1b[A"; + if (normalizedKey === "down") return applicationCursorKeysMode ? "\x1bOB" : "\x1b[B"; + if (normalizedKey === "right") return applicationCursorKeysMode ? "\x1bOC" : "\x1b[C"; + if (normalizedKey === "left") return applicationCursorKeysMode ? "\x1bOD" : "\x1b[D"; + if (normalizedKey === "home") return applicationCursorKeysMode ? "\x1bOH" : "\x1b[H"; + if (normalizedKey === "end") return applicationCursorKeysMode ? "\x1bOF" : "\x1b[F"; + if (normalizedKey === "pageup") return "\x1b[5~"; + if (normalizedKey === "pagedown") return "\x1b[6~"; + if (normalizedKey === "insert") return "\x1b[2~"; + if (normalizedKey === "delete") return "\x1b[3~"; + if (normalizedKey === "shift+tab") return "\x1b[Z"; + if (normalizedKey === "enter") return "\r"; + if (normalizedKey === "tab") return "\t"; + if (normalizedKey === "space") return " "; + if (normalizedKey === "backspace") return "\x7f"; + if (normalizedKey === "escape") return "\x1b"; + const ctrlMatch = /^ctrl\+([a-z])$/u.exec(normalizedKey); + if (ctrlMatch) { + const letter = ctrlMatch[1]!; + return String.fromCharCode(letter.charCodeAt(0) - 96); + } + const altMatch = /^alt\+([a-z])$/u.exec(normalizedKey); + if (altMatch) { + return `\x1b${altMatch[1]!}`; + } + // For any other Kitty sequence with a printable codepoint, emit the character directly + if (kitty.codepoint >= 32 && kitty.codepoint < 127) { + let ch = String.fromCharCode(kitty.codepoint); + // Apply ctrl modifier if present (modifier bit 4 = ctrl) + if (kitty.modifier & 4) { + const code = kitty.codepoint; + if (code >= 97 && code <= 122) { + ch = String.fromCharCode(code - 96); + } + } + // Apply alt modifier if present (modifier bit 2 = alt) + if (kitty.modifier & 2) { + ch = `\x1b${ch}`; + } + return ch; + } + return data; +} +class BashInteractiveOverlayComponent implements Component { + #terminal: XtermTerminalType; + #state: "running" | "complete" | "timed_out" | "killed" = "running"; + #exitCode: number | undefined; + #onInput: (data: string) => void = () => {}; + #onDismiss: () => void = () => {}; + #onDispose: () => void = () => {}; + #session: PtySession | null = null; + #lastCols = 0; + #lastRows = 0; + + constructor( + private readonly command: string, + private readonly uiTheme: Theme, + private readonly getTerminalRows: () => number, + ) { + this.#terminal = new XtermTerminal({ + cols: 120, + rows: 40, + disableStdin: true, + allowProposedApi: true, + scrollback: 10_000, + }); + } + + setHandlers(onInput: (data: string) => void, onDismiss: () => void, onDispose: () => void): void { + this.#onInput = onInput; + this.#onDismiss = onDismiss; + this.#onDispose = onDispose; + } + + appendOutput(chunk: string): void { + this.#terminal.write(chunk); + } + + setSession(session: PtySession): void { + this.#session = session; + } + + setComplete(result: { exitCode: number | undefined; cancelled: boolean; timedOut: boolean }): void { + this.#exitCode = result.exitCode; + if (result.timedOut) { + this.#state = "timed_out"; + return; + } + if (result.cancelled) { + this.#state = "killed"; + return; + } + this.#state = "complete"; + } + + handleInput(data: string): void { + if (this.#state === "running" && (matchesKey(data, "escape") || matchesKey(data, "esc"))) { + this.#onDismiss(); + return; + } + if (this.#state !== "running") { + return; + } + const normalizedInput = normalizeInputForPty(data, this.#terminal.modes.applicationCursorKeysMode); + if (!normalizedInput) { + return; + } + this.#onInput(normalizedInput); + } + #stateText(): string { + if (this.#state === "running") return this.uiTheme.fg("warning", "running"); + if (this.#state === "timed_out") return this.uiTheme.fg("warning", "timed out"); + if (this.#state === "killed") return this.uiTheme.fg("warning", "killed"); + if (this.#exitCode === 0) return this.uiTheme.fg("success", "exit 0"); + if (this.#exitCode === undefined) return this.uiTheme.fg("warning", "exited"); + return this.uiTheme.fg("error", `exit ${this.#exitCode}`); + } + + #readViewport(innerWidth: number, maxContentRows: number): string[] { + this.#terminal.resize(innerWidth, maxContentRows); + const buffer = this.#terminal.buffer.active; + const viewportY = buffer.viewportY; + const visibleLines: string[] = []; + for (let i = 0; i < maxContentRows; i++) { + const line = buffer.getLine(viewportY + i)?.translateToString(true) ?? ""; + visibleLines.push(truncateToWidth(replaceTabs(line), innerWidth)); + } + return visibleLines; + } + render(width: number): string[] { + const safeWidth = Math.max(20, width); + const innerWidth = Math.max(1, safeWidth - 2); + const maxOverlayRows = Math.max(5, Math.floor(this.getTerminalRows() * 0.8)); + const chromeRows = 4; + const maxContentRows = Math.max(1, maxOverlayRows - chromeRows); + // Propagate terminal resize to PTY session + const currentCols = innerWidth; + const currentRows = maxContentRows; + if (this.#session && (currentCols !== this.#lastCols || currentRows !== this.#lastRows)) { + this.#lastCols = currentCols; + this.#lastRows = currentRows; + try { + this.#session.resize(currentCols, currentRows); + } catch { + // Session may have ended + } + } + const statusIcon = + this.#state === "running" + ? getStateIcon("running", this.uiTheme) + : this.#state === "complete" && this.#exitCode === 0 + ? getStateIcon("success", this.uiTheme) + : getStateIcon("warning", this.uiTheme); + const title = this.uiTheme.fg("accent", "InteractiveTerm"); + const statusBadge = this.uiTheme.fg("dim", wrapBrackets(this.#stateText(), this.uiTheme)); + const prefix = `${statusIcon} ${title} `; + const suffix = ` ${statusBadge}`; + const available = Math.max(1, innerWidth - visibleWidth(prefix) - visibleWidth(suffix)); + const cmd = truncateToWidth(this.uiTheme.fg("muted", replaceTabs(this.command)), available); + const header = truncateToWidth(`${prefix}${cmd}${suffix}`, innerWidth); + const footer = + this.#state === "running" + ? truncateToWidth( + `${this.uiTheme.fg("warning", "esc")} ${this.uiTheme.fg("dim", "force-kill")} ${this.uiTheme.fg("dim", "· input forwarded to PTY")}`, + innerWidth, + ) + : truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth); + const visibleLines = this.#readViewport(innerWidth, maxContentRows); + const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)]; + const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxSharp.horizontal.repeat(innerWidth)); + const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxSharp.vertical); + const boxLine = (line: string) => + `${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`; + return [ + `${this.uiTheme.fg("border", this.uiTheme.boxSharp.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.topRight)}`, + boxLine(header), + ...content.map(boxLine), + boxLine(footer), + `${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomRight)}`, + ]; + } + + invalidate(): void {} + + dispose(): void { + this.#terminal.dispose(); + this.#onDispose(); + } +} + +export async function runInteractiveBashPty( + ui: NonNullable, + options: { + command: string; + cwd: string; + timeoutMs: number; + signal?: AbortSignal; + env?: Record; + artifactPath?: string; + artifactId?: string; + }, +): Promise { + const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId }); + let pendingChunks = Promise.resolve(); + const result = await ui.custom( + (tui, uiTheme, _keybindings, done) => { + const session = new PtySession(); + const component = new BashInteractiveOverlayComponent(options.command, uiTheme, () => tui.terminal.rows); + component.setSession(session); + let finished = false; + const finalize = (run: PtyRunResult) => { + if (finished) return; + finished = true; + component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut }); + tui.requestRender(); + void (async () => { + await pendingChunks; + const summary = await sink.dump(); + done({ + exitCode: run.exitCode, + cancelled: run.cancelled, + timedOut: run.timedOut, + ...summary, + }); + })(); + }; + const cols = Math.max(20, tui.terminal.columns - 2); + const rows = Math.max(5, tui.terminal.rows - 4); + component.setHandlers( + data => { + try { + session.write(data); + } catch { + // ignore writes after command exits + } + }, + () => { + try { + session.kill(); + } catch { + // ignore + } + }, + () => { + try { + session.kill(); + } catch { + // ignore + } + }, + ); + void session + .start( + { + command: options.command, + cwd: options.cwd, + timeoutMs: options.timeoutMs, + env: { + ...options.env, + PAGER: "cat", + GIT_PAGER: "cat", + MANPAGER: "cat", + SYSTEMD_PAGER: "cat", + BAT_PAGER: "cat", + DELTA_PAGER: "cat", + AWS_PAGER: "", + HOMEBREW_PAGER: "cat", + LESS: "FRX", + }, + signal: options.signal, + cols, + rows, + }, + (err, chunk) => { + if (err || !chunk) return; + component.appendOutput(chunk); + const normalizedChunk = normalizeCaptureChunk(chunk); + pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {}); + tui.requestRender(); + }, + ) + .then(finalize) + .catch(error => { + pendingChunks = pendingChunks + .then(() => sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`)) + .catch(() => {}); + finalize({ exitCode: undefined, cancelled: false, timedOut: false }); + }); + return component; + }, + { overlay: true }, + ); + return result; +} diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 0644f58c6..767ef9afa 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -3,10 +3,10 @@ import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { isEnoent } from "@oh-my-pi/pi-utils"; +import { $env, isEnoent } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; -import { type BashExecutorOptions, executeBash } from "../exec/bash-executor"; +import { type BashResult, executeBash } from "../exec/bash-executor"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { truncateToVisualLines } from "../modes/components/visual-truncate"; import type { Theme } from "../modes/theme/theme"; @@ -14,6 +14,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" }; import { renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; import type { ToolSession } from "."; +import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive"; import { checkBashInterception } from "./bash-interceptor"; import { applyHeadTail, normalizeBashCommand } from "./bash-normalize"; import type { OutputMeta } from "./output-meta"; @@ -42,6 +43,13 @@ export interface BashToolDetails { export interface BashToolOptions {} +function normalizeResultOutput(result: BashResult | BashInteractiveResult): string { + return result.output || ""; +} + +function isInteractiveResult(result: BashResult | BashInteractiveResult): result is BashInteractiveResult { + return "timedOut" in result; +} /** * Bash tool implementation. * @@ -108,35 +116,49 @@ export class BashTool implements AgentTool { const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined; const { artifactPath, artifactId } = await allocateOutputArtifact(this.session, "bash"); - const executorOptions: BashExecutorOptions = { - cwd: commandCwd, - timeout: timeoutMs, - signal, - env: extraEnv, - artifactPath, - artifactId, - onChunk: chunk => { - tailBuffer.append(chunk); - if (onUpdate) { - onUpdate({ - content: [{ type: "text", text: tailBuffer.text() }], - details: {}, - }); - } - }, - }; - - // Handle errors - const result = await executeBash(command, executorOptions); + const usePty = + this.session.settings.get("bash.virtualTerminal") === "on" && + $env.PI_NO_PTY !== "1" && + ctx?.hasUI === true && + ctx.ui !== undefined; + const result: BashResult | BashInteractiveResult = usePty + ? await runInteractiveBashPty(ctx.ui!, { + command, + cwd: commandCwd, + timeoutMs, + signal, + env: extraEnv, + artifactPath, + artifactId, + }) + : await executeBash(command, { + cwd: commandCwd, + timeout: timeoutMs, + signal, + env: extraEnv, + artifactPath, + artifactId, + onChunk: chunk => { + tailBuffer.append(chunk); + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: tailBuffer.text() }], + details: {}, + }); + } + }, + }); if (result.cancelled) { if (signal?.aborted) { - throw new ToolAbortError(result.output || "Command aborted"); + throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted"); } - throw new ToolError(result.output || "Command aborted"); + throw new ToolError(normalizeResultOutput(result) || "Command aborted"); + } + if (isInteractiveResult(result) && result.timedOut) { + throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`); } - // Apply head/tail filtering if specified - let outputText = result.output || ""; + let outputText = normalizeResultOutput(result); const headTailResult = applyHeadTail(outputText, headLines, tailLines); if (headTailResult.applied) { outputText = headTailResult.text; @@ -144,10 +166,11 @@ export class BashTool implements AgentTool { if (!outputText) { outputText = "(no output)"; } - const details: BashToolDetails = {}; const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" }); - + if (result.exitCode === undefined) { + throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`); + } if (result.exitCode !== 0 && result.exitCode !== undefined) { throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`); } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index cce7c85f6..354900f3a 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Added `PtySession` class for PTY-backed interactive command execution with streaming output +- Added `PtyStartOptions` interface to configure pseudo-terminal sessions with command, working directory, environment variables, and terminal dimensions +- Added `PtyRunResult` interface to report command exit code, cancellation, and timeout status +- Added `write()` method to send raw input to PTY stdin +- Added `resize()` method to dynamically adjust PTY column and row dimensions +- Added `kill()` method to force-terminate active commands ## [11.3.0] - 2026-02-06 diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index bc46fe549..e796eb207 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -111,6 +111,11 @@ export { type ShellRunResult, } from "./shell"; +// ============================================================================= +// PTY execution +// ============================================================================= + +export { type PtyRunResult, PtySession, type PtyStartOptions } from "./pty"; // ============================================================================= // Process management // ============================================================================= diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 4441c4ccf..89c41515f 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -23,6 +23,7 @@ import "./html/types"; import "./image/types"; import "./keys/types"; import "./ps/types"; +import "./pty/types"; import "./shell/types"; import "./system-info/types"; import "./text/types"; @@ -170,6 +171,7 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("extractSegments"); checkFn("matchesKittySequence"); checkFn("executeShell"); + checkFn("PtySession"); checkFn("Shell"); checkFn("parseKey"); checkFn("matchesLegacySequence"); diff --git a/packages/natives/src/pty/index.ts b/packages/natives/src/pty/index.ts new file mode 100644 index 000000000..a18a67ebc --- /dev/null +++ b/packages/natives/src/pty/index.ts @@ -0,0 +1,10 @@ +/** + * PTY-backed interactive execution. + */ + +import { native } from "../native"; + +export type { PtyRunResult, PtySessionConstructor, PtyStartOptions } from "./types"; + +export const { PtySession } = native; +export type PtySession = import("./types").PtySession; diff --git a/packages/natives/src/pty/types.ts b/packages/natives/src/pty/types.ts new file mode 100644 index 000000000..cacfc5870 --- /dev/null +++ b/packages/natives/src/pty/types.ts @@ -0,0 +1,57 @@ +/** + * Types for PTY-backed interactive execution. + */ + +import type { Cancellable, TsFunc } from "../bindings"; + +/** + * Options for starting a command in a pseudo-terminal session. + */ +export interface PtyStartOptions extends Cancellable { + /** Command to execute. */ + command: string; + /** Working directory for command execution. */ + cwd?: string; + /** Environment variables for this command. */ + env?: Record; + /** PTY column count. */ + cols?: number; + /** PTY row count. */ + rows?: number; +} + +/** + * Result of a PTY command run. + */ +export interface PtyRunResult { + /** Exit code of the command, if available. */ + exitCode?: number; + /** Whether the command was cancelled by abort signal or kill request. */ + cancelled: boolean; + /** Whether the command timed out. */ + timedOut: boolean; +} + +/** Stateful PTY session instance. */ +export interface PtySession { + /** Start command execution and stream output while it runs. */ + start(options: PtyStartOptions, onChunk?: TsFunc): Promise; + /** Write raw input bytes to PTY stdin. */ + write(data: string): void; + /** Resize active PTY. */ + resize(cols: number, rows: number): void; + /** Force-kill active command. */ + kill(): void; +} + +/** Native PTY session constructor. */ +export interface PtySessionConstructor { + new (): PtySession; +} + +declare module "../bindings" { + interface NativeBindings { + /** Stateful PTY session constructor for interactive terminal passthrough. */ + PtySession: PtySessionConstructor; + } +}