feat(pty): added PTY-backed interactive command execution with streaming output support

- Added PTY (pseudo-terminal) backed interactive command execution with streaming output support via PtySession class in pi-natives.
- Added PtyStartOptions and PtyRunResult types to configure and report PTY session execution status.
- Added write(), resize(), and kill() methods to PtySession for interactive control of running commands.
- Added interactive bash execution via PTY with real-time terminal rendering and input forwarding in bash-interactive tool.
- Added --no-pty CLI flag and PI_NO_PTY environment variable to disable PTY-based interactive bash execution.
- Added bash.virtualTerminal setting to control PTY-backed interactive execution behavior.
This commit is contained in:
can1357
2026-02-12 04:34:24 +01:00
parent cf864bbf7e
commit 2c7a32f28c
19 changed files with 1076 additions and 88 deletions
Generated
+158 -33
View File
@@ -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",
+1
View File
@@ -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"
+1
View File
@@ -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;
+364
View File
@@ -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<String>,
/// Environment variables for this command.
pub env: Option<HashMap<String, String>>,
/// Timeout in milliseconds before cancelling.
#[napi(js_name = "timeoutMs")]
pub timeout_ms: Option<u32>,
/// Abort signal for cancelling the operation.
pub signal: Option<Unknown<'env>>,
/// PTY column count.
pub cols: Option<u16>,
/// PTY row count.
pub rows: Option<u16>,
}
/// Result of a PTY command run.
#[napi(object)]
pub struct PtyRunResult {
/// Exit code when the command completes.
pub exit_code: Option<i32>,
/// 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<String>,
env: Option<HashMap<String, String>>,
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<ControlMessage>,
}
/// Stateful PTY session for interactive stdin/stdout passthrough.
#[napi]
pub struct PtySession {
core: Arc<Mutex<Option<PtySessionCore>>>,
}
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<String>,
>,
) -> Result<PromiseRaw<'env, PtyRunResult>> {
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::<ControlMessage>();
{
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<ThreadsafeFunction<String>>,
control_rx: mpsc::Receiver<ControlMessage>,
ct: task::CancelToken,
) -> Result<PtyRunResult> {
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::<ReaderEvent>();
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<i32> = 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<String>>) {
if let Some(callback) = callback {
callback.call(Ok(text.to_string()), ThreadsafeFunctionCallMode::NonBlocking);
}
}
+10
View File
@@ -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
+1
View File
@@ -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",
+4
View File
@@ -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<string, { type: "
result.noTools = true;
} else if (arg === "--no-lsp") {
result.noLsp = true;
} else if (arg === "--no-pty") {
result.noPty = true;
} else if (arg === "--tools" && i + 1 < args.length) {
const toolNames = args[++i].split(",").map(s => 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")}
@@ -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)",
}),
@@ -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,
+4
View File
@@ -526,6 +526,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
await Settings.init({ cwd });
debugStartup("main:Settings.init");
time("Settings.init");
if (parsedArgs.noPty) {
settings.override("bash.virtualTerminal", "off");
Bun.env.PI_NO_PTY = "1";
}
const pipedInput = await readPipedInput();
let { initialMessage, initialImages } = await prepareInitialMessage(parsedArgs, settings.get("images.autoResize"));
if (pipedInput) {
@@ -36,12 +36,16 @@ function visualizeIndent(text: string): string {
/**
* Parse diff line to extract prefix, line number, and content.
* Format: "+123 content" or "-123 content" or " 123 content" or " ..."
* Supported formats: "+123|content" (canonical) and "+123 content" (legacy).
*/
function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null {
const match = line.match(/^([+-\s])(?:(\s*\d+)\s)?(.*)$/);
if (!match) return null;
return { prefix: match[1], lineNum: match[2] ?? "", content: match[3] };
const canonical = line.match(/^([+-\s])(\s*\d+)\|(.*)$/);
if (canonical) {
return { prefix: canonical[1], lineNum: canonical[2], content: canonical[3] };
}
const legacy = line.match(/^([+-\s])(?:(\s*\d+)\s)?(.*)$/);
if (!legacy) return null;
return { prefix: legacy[1], lineNum: legacy[2] ?? "", content: legacy[3] };
}
/**
@@ -110,7 +114,7 @@ export function renderDiff(diffText: string, _options: RenderDiffOptions = {}):
if (lineNum.trim().length === 0) {
return `${prefix}${content}`;
}
return `${prefix}${lineNum} ${content}`;
return `${prefix}${lineNum}|${content}`;
};
let i = 0;
+38 -22
View File
@@ -18,6 +18,19 @@ import { EditMatchError } from "./types";
// Diff String Generation
// ═══════════════════════════════════════════════════════════════════════════
function countContentLines(content: string): number {
const lines = content.split("\n");
if (lines.length > 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);
}
}
@@ -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<AgentToolContext["ui"]>,
options: {
command: string;
cwd: string;
timeoutMs: number;
signal?: AbortSignal;
env?: Record<string, string>;
artifactPath?: string;
artifactId?: string;
},
): Promise<BashInteractiveResult> {
const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
let pendingChunks = Promise.resolve();
const result = await ui.custom<BashInteractiveResult>(
(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;
}
+51 -28
View File
@@ -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<typeof bashSchema, BashToolDetails> {
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<typeof bashSchema, BashToolDetails> {
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}`);
}
+8
View File
@@ -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
+5
View File
@@ -111,6 +111,11 @@ export {
type ShellRunResult,
} from "./shell";
// =============================================================================
// PTY execution
// =============================================================================
export { type PtyRunResult, PtySession, type PtyStartOptions } from "./pty";
// =============================================================================
// Process management
// =============================================================================
+2
View File
@@ -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");
+10
View File
@@ -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;
+57
View File
@@ -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<string, string>;
/** 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<string>): Promise<PtyRunResult>;
/** 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;
}
}