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:
Generated
+158
-33
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ export {
|
||||
type ShellRunResult,
|
||||
} from "./shell";
|
||||
|
||||
// =============================================================================
|
||||
// PTY execution
|
||||
// =============================================================================
|
||||
|
||||
export { type PtyRunResult, PtySession, type PtyStartOptions } from "./pty";
|
||||
// =============================================================================
|
||||
// Process management
|
||||
// =============================================================================
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user