From 1a10abc0c144fd6a968a9415e96fe9753f5aa98e Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Mon, 22 Jun 2026 20:27:49 +0800 Subject: [PATCH 1/8] feat: switch pty-process to portable-pty Co-Authored-By: Claude --- Cargo.lock | 103 +++++++++++++++++++++++++++++++++------- Cargo.toml | 2 +- src/terminal/mod.rs | 113 +++++++++++++++++++++++++++++--------------- src/terminal/pty.rs | 98 ++++++++++++++++++++++++++++++-------- 4 files changed, 240 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d8d6b2..126b506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,6 +495,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" @@ -888,6 +894,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -2052,7 +2064,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -2196,6 +2208,18 @@ 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.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -2204,7 +2228,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -2560,6 +2584,27 @@ dependencies = [ "portable-atomic", ] +[[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", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2631,16 +2676,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "pty-process" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266" -dependencies = [ - "rustix 1.1.4", - "tokio", -] - [[package]] name = "pxfm" version = "0.1.28" @@ -2669,7 +2704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -2710,7 +2745,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -3396,6 +3431,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3418,6 +3464,22 @@ dependencies = [ "digest", ] +[[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" @@ -3700,7 +3762,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -4242,7 +4304,7 @@ dependencies = [ "hanconv", "image", "log", - "pty-process", + "portable-pty", "ratatui", "reqwest", "reqwest-websocket", @@ -4923,6 +4985,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 065a90a..5119b5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ hanconv = "0.5.0" bytes = "1.11.1" uuid = { version = "1.21.0", features = ["v4"] } -pty-process = { version = "0.5.3", features = ["async"] } +portable-pty = "0.9" strip-ansi-escapes = "0.2.1" # TUI diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 7197a14..4c3472f 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,18 +1,28 @@ -use pty_process::{Command, Pty, Size}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - process::Child, -}; +use portable_pty::{Child, MasterPty, PtySize}; +use tokio::sync::{mpsc, oneshot}; pub mod pty; -pub type PtyCommand = Command; -pub type PtySize = Size; - +/// A write request handed off to the blocking writer thread: the bytes to +/// write plus a one-shot channel used to acknowledge the result. +pub(crate) type WriteMsg = (Vec, oneshot::Sender>); + +/// A terminal session backed by a cross-platform PTY. +/// +/// `portable-pty` exposes blocking `std::io` handles rather than async ones, +/// so the actual reads and writes happen on dedicated blocking threads and are +/// bridged to async via tokio channels: +/// +/// - a reader thread owns the PTY reader and pushes output chunks onto +/// `read_rx`; `read()` returns them verbatim as owned `Vec`. +/// - a writer thread owns the PTY writer and drains `write_rx`; `write_all()` +/// hands it bytes plus a one-shot ack and awaits the result. pub struct EchokitChild { uuid: uuid::Uuid, - pty: Pty, - child: Child, + master: Box, + child: Option>, + read_rx: mpsc::Receiver>, + write_tx: mpsc::Sender, } #[allow(unused)] @@ -22,8 +32,15 @@ impl EchokitChild { } pub async fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { - self.pty.write_all(buf).await?; - self.pty.flush().await + let (ack_tx, ack_rx) = oneshot::channel(); + self.write_tx + .send((buf.to_vec(), ack_tx)) + .await + .map_err(|_| std::io::Error::from(std::io::ErrorKind::BrokenPipe))?; + match ack_rx.await { + Ok(res) => res, + Err(_) => Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)), + } } pub async fn send_key_iter>(&mut self, keys: &[S]) -> std::io::Result<()> { @@ -70,69 +87,87 @@ impl EchokitChild { self.write_all(b"\r").await } - pub async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { - self.pty.read(buffer).await + /// Pull the next chunk of PTY output off the channel. + /// + /// Returns the chunk verbatim (whatever the reader thread read in one go). + /// An empty `Vec` signals EOF / channel closed (the child exited or the + /// reader thread stopped). Because chunks are returned owned and whole, + /// there is no caller buffer to underfill — no partial-read buffering. + pub async fn read(&mut self) -> std::io::Result> { + Ok(self.read_rx.recv().await.unwrap_or_default()) } pub async fn read_string(&mut self) -> std::io::Result { - let mut buffer = [0u8; 1024]; let mut string_buffer = Vec::with_capacity(512); loop { - let n = self.pty.read(&mut buffer).await?; - if n == 0 { + let chunk = self.read().await?; + if chunk.is_empty() { break; } - string_buffer.extend_from_slice(&buffer[..n]); + string_buffer.extend_from_slice(&chunk); - let s = str::from_utf8(&string_buffer); - if let Ok(s) = s { - return Ok(s.to_string()); + if str::from_utf8(&string_buffer).is_ok() { + return Ok(String::from_utf8_lossy(&string_buffer).into_owned()); } } - Ok(String::from_utf8_lossy(&string_buffer).to_string()) + Ok(String::from_utf8_lossy(&string_buffer).into_owned()) } - pub async fn wait(&mut self) -> std::io::Result { - self.child.wait().await + pub async fn wait(&mut self) -> anyhow::Result { + let mut child = self + .child + .take() + .ok_or_else(|| anyhow::anyhow!("child process handle already consumed"))?; + Ok(tokio::task::spawn_blocking(move || child.wait()).await??) } - pub async fn kill(&mut self) -> std::io::Result<()> { - self.child.kill().await + pub async fn kill(&mut self) -> anyhow::Result<()> { + let child = self + .child + .as_ref() + .ok_or_else(|| anyhow::anyhow!("child process handle already consumed"))?; + let mut killer = child.clone_killer(); + tokio::task::spawn_blocking(move || killer.kill()).await?; + Ok(()) } pub fn resize(&mut self, rows: u16, cols: u16) -> std::io::Result<()> { - self.pty - .resize(PtySize::new(rows, cols)) + self.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) .map_err(std::io::Error::other) } pub async fn read_pty_output(&mut self) -> std::io::Result { - let mut buffer = [0u8; 1024]; let mut string_buffer = Vec::with_capacity(512); - let n = self.pty.read(&mut buffer).await?; - if n == 0 { + let chunk = self.read().await?; + if chunk.is_empty() { return Ok(String::new()); } - string_buffer.extend_from_slice(&buffer[..n]); + string_buffer.extend_from_slice(&chunk); - // Drain remaining buffered data + // Drain more chunks until we have a complete UTF-8 sequence + // (a multi-byte char may be split across chunks). loop { - let s = str::from_utf8(&string_buffer); - if s.is_ok() { + if str::from_utf8(&string_buffer).is_ok() { break; } - let n = self.pty.read(&mut buffer).await?; - if n == 0 { + let chunk = self.read().await?; + if chunk.is_empty() { break; } - string_buffer.extend_from_slice(&buffer[..n]); + string_buffer.extend_from_slice(&chunk); } - Ok(String::from_utf8_lossy(&string_buffer).to_string()) + Ok(String::from_utf8_lossy(&string_buffer).into_owned()) } } diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index cb5154d..26a2e81 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -1,4 +1,8 @@ -use super::{EchokitChild, PtyCommand, PtySize}; +use std::io::{Read, Write}; + +use super::{EchokitChild, WriteMsg}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use tokio::sync::mpsc; pub async fn new_with_command>( shell: &str, @@ -6,37 +10,91 @@ pub async fn new_with_command>( env: &[(S, S)], size: (u16, u16), current_dir: Option, -) -> pty_process::Result { +) -> anyhow::Result { let (row, col) = size; - let (pty, pts) = pty_process::open()?; - pty.resize(PtySize::new(row, col))?; + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows: row, + cols: col, + pixel_width: 0, + pixel_height: 0, + })?; - let mut cmd = PtyCommand::new(shell); + let mut cmd = CommandBuilder::new(shell); + for arg in args.iter() { + cmd.arg(arg.as_ref()); + } + // Explicitly inherit vibetty's environment instead of relying on + // portable-pty's implicit snapshot taken inside CommandBuilder::new + // (get_base_env). portable-pty already captures std::env::vars_os there, + // but — like its cwd default — that's implicit behavior we'd rather not + // depend on, so we set it ourselves. + for (key, value) in std::env::vars_os() { + cmd.env(key, value); + } - cmd = cmd - .args(args.iter().map(|arg| arg.as_ref())) - .env("TERM", "xterm-256color") - .env("COLUMNS", col.to_string()) - .env("LINES", row.to_string()) - .env("FORCE_COLOR", "1") - .env("COLORTERM", "truecolor") - .env("PYTHONUNBUFFERED", "1"); + cmd.env("TERM", "xterm-256color"); + cmd.env("COLUMNS", col.to_string()); + cmd.env("LINES", row.to_string()); + cmd.env("FORCE_COLOR", "1"); + cmd.env("COLORTERM", "truecolor"); + cmd.env("PYTHONUNBUFFERED", "1"); for (key, value) in env { - cmd = cmd.env(key.as_ref(), value.as_ref()); + cmd.env(key.as_ref(), value.as_ref()); } - if let Some(current_dir) = current_dir { - cmd = cmd.current_dir(current_dir); + // portable-pty defaults the working directory to $HOME when cwd is unset + // (see CommandBuilder::as_command), whereas pty-process inherited the + // parent's cwd. Fall back to vibetty's own cwd so the spawned program + // launches in the directory the user started vibetty from. + let cwd = current_dir.or_else(|| std::env::current_dir().ok()); + if let Some(cwd) = cwd { + cmd.cwd(cwd); } - let child = cmd.spawn(pts)?; - log::debug!("Started terminal with PID {}", child.id().unwrap_or(0)); + let child = pair.slave.spawn_command(cmd)?; + log::debug!("Started terminal with PID {:?}", child.process_id()); + + // The parent no longer needs the slave handle; the child holds its own. + drop(pair.slave); + + // Reader thread: owns the blocking PTY reader, pushes output chunks onto a + // channel consumed by the async `read()` path. + let mut reader = pair.master.try_clone_reader()?; + let (read_tx, read_rx) = mpsc::channel::>(128); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + if read_tx.blocking_send(buf[..n].to_vec()).is_err() { + break; // receiver dropped -> stop pumping + } + } + Err(_) => break, + } + } + }); + + // Writer thread: drains input chunks from a channel and writes them to the + // PTY, acknowledging each request via a one-shot channel. + let mut writer = pair.master.take_writer()?; + let (write_tx, mut write_rx) = mpsc::channel::(128); + tokio::task::spawn_blocking(move || { + while let Some((buf, ack)) = write_rx.blocking_recv() { + let res = writer.write_all(&buf).and_then(|_| writer.flush()); + let _ = ack.send(res); + } + }); Ok(EchokitChild { uuid: uuid::Uuid::new_v4(), - pty, - child, + master: pair.master, + child: Some(child), + read_rx, + write_tx, }) } From 23959383d6491f7127f8ecfa3423a024cbabf6a5 Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Mon, 22 Jun 2026 23:40:56 +0800 Subject: [PATCH 2/8] debug: log pty reader/writer thread activity Co-Authored-By: Claude --- src/terminal/pty.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index 26a2e81..8591fd2 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -68,13 +68,21 @@ pub async fn new_with_command>( let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { - Ok(0) => break, // EOF + Ok(0) => { + log::debug!("[pty] reader: EOF, thread exiting"); + break; + } Ok(n) => { + log::debug!("[pty] reader: {} bytes", n); if read_tx.blocking_send(buf[..n].to_vec()).is_err() { - break; // receiver dropped -> stop pumping + log::debug!("[pty] reader: channel closed, thread exiting"); + break; } } - Err(_) => break, + Err(e) => { + log::debug!("[pty] reader: error {e}, thread exiting"); + break; + } } } }); @@ -86,8 +94,13 @@ pub async fn new_with_command>( tokio::task::spawn_blocking(move || { while let Some((buf, ack)) = write_rx.blocking_recv() { let res = writer.write_all(&buf).and_then(|_| writer.flush()); + match &res { + Ok(()) => log::debug!("[pty] writer: wrote {} bytes", buf.len()), + Err(e) => log::debug!("[pty] writer: error {e}"), + } let _ = ack.send(res); } + log::debug!("[pty] writer: channel closed, thread exiting"); }); Ok(EchokitChild { From 88c7e93b53e2e28bbfd3b95c940fd17cecd70e86 Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Tue, 23 Jun 2026 00:00:00 +0800 Subject: [PATCH 3/8] fix: switch to portable-pty-psmux fork for Windows ConPTY Upstream portable-pty 0.9.0 (latest published) has a known Windows ConPTY bug (wez/wezterm#1396): spawned process produces no output and ignores input. The fix lives in wezterm master but was never published to crates.io. portable-pty-psmux is an API-compatible fork (v0.9.5) that addresses ConPTY correctness on Windows 10/11. Co-Authored-By: Claude --- Cargo.lock | 50 ++++++++++++++++++++++---------------------------- Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 126b506..12c08ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,12 +495,6 @@ 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" @@ -896,9 +890,9 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "downcast-rs" -version = "1.2.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" [[package]] name = "dunce" @@ -1957,9 +1951,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -2210,27 +2204,27 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases 0.1.1", + "cfg_aliases", "libc", + "memoffset", ] [[package]] name = "nix" -version = "0.29.0" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -2585,19 +2579,18 @@ dependencies = [ ] [[package]] -name = "portable-pty" -version = "0.9.0" +name = "portable-pty-psmux" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +checksum = "be878e62281922d14cb797f41b539fb1eadf1cb58c5d985a04a679e230442932" dependencies = [ "anyhow", - "bitflags 1.3.2", "downcast-rs", "filedescriptor", "lazy_static", "libc", "log", - "nix 0.28.0", + "nix 0.31.3", "serial2", "shared_library", "shell-words", @@ -2704,7 +2697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -2745,7 +2738,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", "socket2", @@ -4304,7 +4297,7 @@ dependencies = [ "hanconv", "image", "log", - "portable-pty", + "portable-pty-psmux", "ratatui", "reqwest", "reqwest-websocket", @@ -4987,11 +4980,12 @@ checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" [[package]] name = "winreg" -version = "0.10.1" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ - "winapi", + "cfg-if", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5119b5a..bf47e28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ hanconv = "0.5.0" bytes = "1.11.1" uuid = { version = "1.21.0", features = ["v4"] } -portable-pty = "0.9" +portable-pty = { version = "0.9.3", package = "portable-pty-psmux" } strip-ansi-escapes = "0.2.1" # TUI From 7d60d0e7e153de2abc0fed7785d2a9f5e2574b5a Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Tue, 23 Jun 2026 00:22:56 +0800 Subject: [PATCH 4/8] fix: answer ConPTY cursor-position-report (ESC[6n) on Windows Windows ConPTY sends ESC[6n (cursor position report request) on startup and stalls until the host terminal replies ESC[row;colR. vibetty never replied, so the spawned shell (cmd/pwsh/claude) produced no output and ignored input. Unix shells don't send it, so macOS was unaffected. The reader thread now watches output for ESC[6n and replies ESC[1;1R through the writer thread. Also drops the temporary reader/writer debug logging. Co-Authored-By: Claude --- src/terminal/pty.rs | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index 8591fd2..1bd9d5a 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -2,7 +2,7 @@ use std::io::{Read, Write}; use super::{EchokitChild, WriteMsg}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; pub async fn new_with_command>( shell: &str, @@ -60,29 +60,35 @@ pub async fn new_with_command>( // The parent no longer needs the slave handle; the child holds its own. drop(pair.slave); - // Reader thread: owns the blocking PTY reader, pushes output chunks onto a - // channel consumed by the async `read()` path. let mut reader = pair.master.try_clone_reader()?; let (read_tx, read_rx) = mpsc::channel::>(128); + let (write_tx, mut write_rx) = mpsc::channel::(128); + // A second handle on the write channel so the reader thread can answer + // terminal queries (e.g. ConPTY's cursor-position report). + let query_tx = write_tx.clone(); + + // Reader thread: owns the blocking PTY reader, pushes output chunks onto a + // channel consumed by the async `read()` path. It also answers the + // cursor-position-report request (`ESC[6n`) that Windows ConPTY sends on + // startup — ConPTY stalls until the host replies, and Unix shells never + // send it, which is why this only manifested on Windows. tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { - Ok(0) => { - log::debug!("[pty] reader: EOF, thread exiting"); - break; - } + Ok(0) => break, // EOF Ok(n) => { - log::debug!("[pty] reader: {} bytes", n); - if read_tx.blocking_send(buf[..n].to_vec()).is_err() { - log::debug!("[pty] reader: channel closed, thread exiting"); - break; + let chunk = &buf[..n]; + if chunk.windows(4).any(|w| w == b"\x1b[6n") { + // Reply with cursor position row 1, col 1. + let (ack_tx, _ack_rx) = oneshot::channel(); + let _ = query_tx.blocking_send((b"\x1b[1;1R".to_vec(), ack_tx)); + } + if read_tx.blocking_send(chunk.to_vec()).is_err() { + break; // receiver dropped -> stop pumping } } - Err(e) => { - log::debug!("[pty] reader: error {e}, thread exiting"); - break; - } + Err(_) => break, } } }); @@ -90,17 +96,11 @@ pub async fn new_with_command>( // Writer thread: drains input chunks from a channel and writes them to the // PTY, acknowledging each request via a one-shot channel. let mut writer = pair.master.take_writer()?; - let (write_tx, mut write_rx) = mpsc::channel::(128); tokio::task::spawn_blocking(move || { while let Some((buf, ack)) = write_rx.blocking_recv() { let res = writer.write_all(&buf).and_then(|_| writer.flush()); - match &res { - Ok(()) => log::debug!("[pty] writer: wrote {} bytes", buf.len()), - Err(e) => log::debug!("[pty] writer: error {e}"), - } let _ = ack.send(res); } - log::debug!("[pty] writer: channel closed, thread exiting"); }); Ok(EchokitChild { From 62509dd72aaeb453d7299ab01e92b6b599e2561a Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Tue, 23 Jun 2026 00:48:53 +0800 Subject: [PATCH 5/8] perf: only scan for ESC[6n on the first PTY read ConPTY sends the cursor-position report request exactly once at startup, so checking every output chunk was wasted work. Scan only the first read, then stream subsequent output straight through. Co-Authored-By: Claude --- src/terminal/pty.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index 1bd9d5a..6153875 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -74,15 +74,21 @@ pub async fn new_with_command>( // send it, which is why this only manifested on Windows. tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8192]; + // ConPTY only sends ESC[6n once, at startup, so only scan the first + // read for it — never the steady-state output stream. + let mut first = true; loop { match reader.read(&mut buf) { Ok(0) => break, // EOF Ok(n) => { let chunk = &buf[..n]; - if chunk.windows(4).any(|w| w == b"\x1b[6n") { - // Reply with cursor position row 1, col 1. - let (ack_tx, _ack_rx) = oneshot::channel(); - let _ = query_tx.blocking_send((b"\x1b[1;1R".to_vec(), ack_tx)); + if first { + if chunk.windows(4).any(|w| w == b"\x1b[6n") { + // Reply with cursor position row 1, col 1. + let (ack_tx, _ack_rx) = oneshot::channel(); + let _ = query_tx.blocking_send((b"\x1b[1;1R".to_vec(), ack_tx)); + } + first = false; } if read_tx.blocking_send(chunk.to_vec()).is_err() { break; // receiver dropped -> stop pumping From 54cbb356a2ae753f4053af9f642922650ca6e279 Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Tue, 23 Jun 2026 01:00:53 +0800 Subject: [PATCH 6/8] bump version to 0.3.3 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12c08ed..5912288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4281,7 +4281,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vibetty" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index bf47e28..1fbfa16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vibetty" -version = "0.3.2" +version = "0.3.3" edition = "2024" From bb94d8ad5e84e1e44bc7feb37d8d04afc7fde73d Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Tue, 23 Jun 2026 01:05:11 +0800 Subject: [PATCH 7/8] ci: add Windows (x86_64-pc-windows-msvc) to release matrix Co-Authored-By: Claude --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccfe9eb..00e6094 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: - target: aarch64-apple-darwin os: macos-latest artifact: vibetty-macos-arm64 + - target: x86_64-pc-windows-msvc + os: windows-latest + artifact: vibetty-windows-x64.exe runs-on: ${{ matrix.os }} steps: From 372864adf5d9ad613e7d2290f503c751f61a562c Mon Sep 17 00:00:00 2001 From: zzz <458761603@qq.com> Date: Tue, 23 Jun 2026 01:06:46 +0800 Subject: [PATCH 8/8] ci: run build/clippy/test on Windows too Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e15ef0..b086f8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,11 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4