use crate::utils::executable::{shell_kind_of, ShellKind};
use anyhow::{anyhow, Context, Result};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::mpsc::{self, TryRecvError};
use std::sync::{Arc, Mutex as StdMutex};
use std::thread;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use crate::state::line_ring::LineRing;
use crate::state::live_terminal::{LiveTerminal, ScreenUpdate};
pub const DEFAULT_COLS: u16 = 200;
pub const DEFAULT_ROWS: u16 = 50;
const MAX_OUTPUT_SIZE: usize = 1_000_000;
const SCRATCH_MAX_BYTES: u64 = 50 * 1024 * 1024;
const DISCARD_READ_THROTTLE: Duration = Duration::from_millis(2);
pub const RING_BUFFER_LINES: usize = 2_000;
const MAX_PARTIAL_LINE_BYTES: usize = 64 * 1024;
const WCGW_PROMPT_PATTERN: &str = "â—‰";
const WCGW_PROMPT_END: &str = "──➤";
const PROMPT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(5);
fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, ShellKind) {
let requested = crate::config::env_text("WINX_ATTACH_TERMINAL")
.or_else(|| crate::config::env_text("WINX_USE_SCREEN"))
.unwrap_or_default();
if requested == "tmux"
|| requested == "screen"
|| crate::config::parse_bool(&requested) == Some(true)
{
let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
if requested == "tmux" && command_available("tmux") {
let mut cmd = CommandBuilder::new("tmux");
cmd.args(["new-session", "-A", "-s", &session, "bash"]);
if restricted_mode {
cmd.arg("-r");
}
return (cmd, Some(format!("tmux attach -t {session}")), ShellKind::Bash);
}
if command_available("screen") {
ensure_screenrc();
cleanup_orphaned_screens();
let mut cmd = CommandBuilder::new("screen");
cmd.args(["-q", "-S", &session, "bash"]);
if restricted_mode {
cmd.arg("-r");
}
return (cmd, Some(format!("screen -x {session}")), ShellKind::Bash);
}
}
let shell = preferred_shell(restricted_mode);
let kind = shell_kind_of(&shell);
let mut cmd = CommandBuilder::new(&shell);
match kind {
ShellKind::Bash if restricted_mode => {
cmd.arg("-r");
}
ShellKind::PowerShell => {
cmd.arg("-NoLogo");
}
ShellKind::Bash | ShellKind::Zsh => {}
}
if let Some(extra) = crate::config::env_text("WINX_SHELL_ARGS") {
for arg in extra.split_whitespace() {
cmd.arg(arg);
}
}
(cmd, None, kind)
}
pub fn configured_shell_kind(restricted_mode: bool) -> ShellKind {
shell_kind_of(&preferred_shell(restricted_mode))
}
#[cfg(windows)]
fn ensure_windows_shell_available(restricted_mode: bool) -> Result<()> {
if !restricted_mode {
if let Ok(requested) = std::env::var("WINX_SHELL") {
let path = Path::new(&requested);
if path.is_absolute() && path.is_file() {
return Ok(());
}
}
}
if crate::utils::executable::bash_executable().is_some() {
return Ok(());
}
if crate::utils::executable::powershell_executable().is_some() {
if restricted_mode {
return Err(anyhow!(
"restricted mode needs bash -r; install Git for Windows or set WINX_SHELL to an \
absolute bash.exe path (PowerShell has no equivalent restricted mode)"
));
}
return Ok(());
}
Err(anyhow!(
"no usable shell found: install Git for Windows (the System32 WSL launcher is not \
used) or PowerShell 7, or set WINX_SHELL to an absolute bash.exe or pwsh.exe path"
))
}
fn preferred_shell(restricted_mode: bool) -> String {
if !restricted_mode {
if let Ok(requested) = std::env::var("WINX_SHELL") {
if requested == "zsh" && command_available("zsh") {
return "zsh".to_string();
}
if matches!(requested.as_str(), "pwsh" | "powershell") {
if let Some(found) = crate::utils::executable::powershell_executable() {
return found.to_string_lossy().into_owned();
}
}
if Path::new(&requested).is_absolute() && Path::new(&requested).is_file() {
return requested;
}
}
}
if cfg!(windows) {
if let Some(bash) = crate::utils::executable::bash_executable() {
return bash.to_string_lossy().into_owned();
}
if !restricted_mode {
if let Some(found) = crate::utils::executable::powershell_executable() {
return found.to_string_lossy().into_owned();
}
}
}
"bash".to_string()
}
fn powershell_bootstrap(nonce: &str) -> String {
format!(
"Remove-Module PSReadLine -ErrorAction SilentlyContinue; \
if (Get-Variable PSStyle -ErrorAction SilentlyContinue) {{ $PSStyle.OutputRendering = 'PlainText' }}; \
$env:PAGER = 'cat'; $env:GIT_PAGER = 'cat'; $global:LASTEXITCODE = 0; \
function global:prompt {{ $ok = $?; $ec = $global:LASTEXITCODE; \
$code = if ($ec -is [int] -and $ec -ne 0) {{ $ec }} elseif ($ok) {{ 0 }} else {{ 1 }}; \
$global:LASTEXITCODE = 0; \
\"\u{25c9} $($ExecutionContext.SessionState.Path.CurrentLocation.ProviderPath)\u{2500}\u{2500}\u{27a4}{nonce}:$code \" }}"
)
}
fn command_available(command: &str) -> bool {
crate::utils::executable::is_available(command)
}
fn ensure_screenrc() {
let Some(home) = home::home_dir() else {
return;
};
let screenrc = home.join(".screenrc");
if screenrc.exists() {
return;
}
let _ = std::fs::write(
&screenrc,
"defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
);
}
fn cleanup_orphaned_screens() {
let Ok(output) = Command::new("screen").arg("-ls").output() else {
return;
};
let listing = String::from_utf8_lossy(&output.stdout);
for line in listing.lines() {
let Some(session) = line.split_whitespace().next() else {
continue;
};
let Some((_, name)) = session.split_once('.') else {
continue;
};
if let Some(creator_pid) = winx_creator_pid(name) {
if !process_exists(creator_pid) {
let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
}
}
}
}
fn winx_creator_pid(name: &str) -> Option<u32> {
name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
crate::os::unix::process_exists(pid)
}
#[cfg(not(unix))]
fn process_exists(pid: u32) -> bool {
crate::os::process_exists(pid)
}
pub(crate) fn native_cwd_from_prompt(cwd: &str) -> PathBuf {
if cfg!(windows) {
if let Some(native) = msys_to_native(cwd) {
return native;
}
}
PathBuf::from(cwd)
}
fn msys_to_native(cwd: &str) -> Option<PathBuf> {
let rest = cwd.strip_prefix("/cygdrive").unwrap_or(cwd);
let mut parts = rest.strip_prefix('/')?.splitn(2, '/');
let drive = parts.next()?;
if drive.len() != 1 || !drive.chars().all(|character| character.is_ascii_alphabetic()) {
return None;
}
let tail = parts.next().unwrap_or("");
let mut native = format!("{}:\\", drive.to_ascii_uppercase());
native.push_str(&tail.replace('/', "\\"));
Some(PathBuf::from(native))
}
fn timestamp_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_millis())
}
fn spawn_pty_reader(
mut reader: Box<dyn Read + Send>,
output_tx: mpsc::SyncSender<String>,
live: Arc<StdMutex<LiveTerminal>>,
discard_throttle: Arc<AtomicBool>,
) {
thread::spawn(move || {
let mut buf = [0u8; 4096];
let mut carry: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) => {
debug!("PTY reader reached EOF");
if !carry.is_empty() {
let _ = output_tx.send(String::from_utf8_lossy(&carry).into_owned());
}
break;
}
Ok(n) => {
debug!(bytes = n, "PTY reader chunk");
{
let mut emu =
live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
emu.feed(&buf[..n]);
}
carry.extend_from_slice(&buf[..n]);
let (chunk, rest) = decode_keep_incomplete(&carry);
carry = rest;
if !chunk.is_empty() && output_tx.send(chunk).is_err() {
break;
}
if discard_throttle.load(AtomicOrdering::Relaxed) {
thread::sleep(DISCARD_READ_THROTTLE);
}
}
Err(e) => {
debug!("PTY reader thread error: {}", e);
break;
}
}
}
debug!("PTY reader thread exiting");
});
}
pub struct PtyShell {
master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
writer: Box<dyn Write + Send>,
output_rx: mpsc::Receiver<String>,
size: PtySize,
pub last_command: String,
pub output_buffer: String,
pub command_running: bool,
current_cwd: PathBuf,
command_started_at: Option<Instant>,
command_generation: u64,
incarnation: u64,
pub last_exit_code: Option<i32>,
max_output_size: usize,
pub output_truncated: bool,
ring: LineRing,
pub last_returned_hash: Option<u64>,
delivered_output: String,
pending_completion_report: bool,
pub attach_hint: Option<String>,
prompt_end_marker: String,
live: Arc<StdMutex<LiveTerminal>>,
scratch_workspace_root: Option<PathBuf>,
scratch_path: Option<PathBuf>,
scratch_bytes: u64,
discard_throttle: Arc<AtomicBool>,
#[cfg(windows)]
job: Option<crate::os::windows::ProcessTreeJob>,
}
impl std::fmt::Debug for PtyShell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PtyShell")
.field("size", &format!("{}x{}", self.size.cols, self.size.rows))
.field("last_command", &self.last_command)
.field("command_running", &self.command_running)
.field("current_cwd", &self.current_cwd)
.field("output_truncated", &self.output_truncated)
.field("output_buffer_len", &self.output_buffer.len())
.field("attach_hint", &self.attach_hint)
.finish_non_exhaustive()
}
}
#[cfg(unix)]
fn killable_group(pid: u32) -> Option<i32> {
crate::os::unix::owned_process_group(pid)
}
#[cfg(unix)]
fn signal_group(pgid: i32, signal: i32) {
let _ = crate::os::unix::signal_group(pgid, signal);
}
impl Drop for PtyShell {
fn drop(&mut self) {
#[cfg(unix)]
let pgid = if self.attach_hint.is_some() {
None
} else {
self.child.process_id().and_then(killable_group)
};
#[cfg(unix)]
if let Some(pgid) = pgid {
signal_group(pgid, libc::SIGTERM);
}
#[cfg(windows)]
if let Some(job) = self.job.as_ref() {
job.terminate();
}
let _ = self.child.kill();
#[cfg(unix)]
if let Some(pgid) = pgid {
signal_group(pgid, libc::SIGKILL);
}
let _ = self.child.wait();
}
}
impl PtyShell {
pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
Self::new_with_agent_paths(initial_dir, restricted_mode, None, None)
}
pub(crate) fn new_with_agent_paths(
initial_dir: &Path,
restricted_mode: bool,
workspace_root: Option<&Path>,
temporary_artifact_dir: Option<&Path>,
) -> Result<Self> {
info!(
"Creating new PTY shell (restricted: {}) in {}",
restricted_mode,
initial_dir.display()
);
#[cfg(windows)]
ensure_windows_shell_available(restricted_mode)?;
let pty_system = native_pty_system();
let size =
PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
let (mut cmd, attach_hint, shell_kind) = attachable_command(restricted_mode);
if shell_kind.is_powershell() && restricted_mode {
return Err(anyhow!(
"restricted mode needs bash -r; PowerShell has no equivalent restricted mode"
));
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("PAGER", "cat");
cmd.env("GIT_PAGER", "cat");
cmd.env("HISTFILE", "");
cmd.env("COLUMNS", DEFAULT_COLS.to_string());
cmd.env("ROWS", DEFAULT_ROWS.to_string());
if let Some(workspace_root) = workspace_root {
cmd.env("WINX_WORKSPACE_ROOT", workspace_root);
}
if let Some(temporary_artifact_dir) = temporary_artifact_dir {
cmd.env("WINX_TEMP_DIR", temporary_artifact_dir);
}
let nonce = format!("{:016x}", rand::random::<u64>());
cmd.env(
"PROMPT_COMMAND",
format!(r#"__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec""#),
);
cmd.cwd(initial_dir);
let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
#[cfg(windows)]
let job = match crate::os::windows::ProcessTreeJob::new()
.and_then(|job| child.process_id().map_or(Ok(()), |pid| job.assign(pid)).map(|()| job))
{
Ok(job) => Some(job),
Err(error) => {
warn!(%error, "PTY child is not in a job object; background children may outlive the shell");
None
}
};
let reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
let live_reader = Arc::clone(&live);
let discard_throttle = Arc::new(AtomicBool::new(false));
let throttle_reader = Arc::clone(&discard_throttle);
spawn_pty_reader(reader, output_tx, live_reader, throttle_reader);
let mut shell = Self {
master: pair.master,
child,
writer,
output_rx,
size,
last_command: String::new(),
output_buffer: String::new(),
command_running: false,
current_cwd: initial_dir.to_path_buf(),
command_started_at: None,
command_generation: 0,
incarnation: rand::random::<u64>().max(1),
last_exit_code: None,
max_output_size: MAX_OUTPUT_SIZE,
output_truncated: false,
ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
last_returned_hash: None,
delivered_output: String::new(),
pending_completion_report: false,
attach_hint,
prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
live,
scratch_workspace_root: None,
scratch_path: None,
scratch_bytes: 0,
discard_throttle,
#[cfg(windows)]
job,
};
shell.initialize_prompt(shell_kind, &nonce)?;
debug!("PTY shell created successfully");
Ok(shell)
}
fn initialize_prompt(&mut self, shell_kind: ShellKind, nonce: &str) -> Result<()> {
let prompt_statement = match shell_kind {
ShellKind::Zsh => format!(
r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ local __winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec" }}"#
),
ShellKind::Bash => format!(
r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec"'; PS1=''"#
),
ShellKind::PowerShell => powershell_bootstrap(nonce),
};
self.write_command(&prompt_statement)?;
let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
let mut bootstrap = String::new();
let mut settled = false;
while Instant::now() < deadline {
match self.output_rx.recv_timeout(Duration::from_millis(50)) {
Ok(chunk) => {
self.answer_terminal_queries(&chunk);
bootstrap.push_str(&chunk);
if Self::check_prompt_complete(&bootstrap, &self.prompt_end_marker) {
settled = true;
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
if settled {
std::thread::sleep(Duration::from_millis(30));
} else {
warn!(
timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
"PTY shell did not print its first prompt in time; continuing anyway"
);
}
bootstrap.push_str(&self.drain_output());
debug!(
bytes = bootstrap.len(),
settled,
child_pid = ?self.child.process_id(),
"PTY prompt bootstrap drained"
);
Ok(())
}
fn answer_terminal_queries(&mut self, chunk: &str) {
const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
if !chunk.contains(CURSOR_POSITION_REQUEST) {
return;
}
debug!("PTY answered a cursor position request");
if let Err(error) = self.writer.write_all(b"\x1b[1;1R").and_then(|()| self.writer.flush()) {
debug!("PTY cursor position report failed: {error}");
}
}
fn write_command(&mut self, command: &str) -> Result<()> {
let cmd_with_newline = format!("{command}\r");
self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
self.writer.flush().context("Failed to flush PTY")?;
Ok(())
}
fn drain_output(&mut self) -> String {
let mut output = String::new();
let deadline = Instant::now() + Duration::from_millis(200);
while Instant::now() < deadline {
match self.output_rx.try_recv() {
Ok(chunk) => {
self.answer_terminal_queries(&chunk);
output.push_str(&chunk);
if output.len() > self.max_output_size {
self.output_truncated = true;
break;
}
}
Err(TryRecvError::Empty) => {
thread::sleep(Duration::from_millis(10));
}
Err(TryRecvError::Disconnected) => {
break;
}
}
}
output
}
pub fn send_command(&mut self, command: &str) -> Result<()> {
debug!(bytes = command.len(), "PTY sending command");
self.output_buffer.clear();
self.output_truncated = false;
self.reset_scratch();
self.last_command = command.to_string();
self.command_running = true;
self.mark_command_started();
self.last_exit_code = None;
self.last_returned_hash = None;
self.delivered_output.clear();
self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
self.write_command(command)?;
Ok(())
}
pub fn mark_command_started(&mut self) {
self.command_generation = self.command_generation.saturating_add(1);
self.command_started_at = Some(Instant::now());
self.pending_completion_report = true;
}
pub fn completion_report_pending(&self) -> bool {
!self.command_running && self.pending_completion_report
}
pub fn mark_completion_reported(&mut self) {
self.pending_completion_report = false;
}
pub fn command_generation(&self) -> u64 {
self.command_generation
}
pub fn incarnation(&self) -> u64 {
self.incarnation
}
pub fn command_elapsed(&self) -> Option<Duration> {
self.command_started_at.map(|started_at| started_at.elapsed())
}
pub fn current_cwd(&self) -> &Path {
&self.current_cwd
}
pub fn delivered_output(&self) -> String {
self.delivered_output.clone()
}
pub fn mark_output_delivered(&mut self, output: &str) {
self.delivered_output.clear();
self.delivered_output.push_str(output);
}
pub fn collect_scrollback(&self, lines: usize) -> String {
let rendered =
crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
match self.ring.scrollback_notice(lines) {
Some(notice) => format!("{notice}\n{rendered}"),
None => rendered,
}
}
pub fn fingerprint(text: &str) -> u64 {
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
let start = Instant::now();
let mut complete = false;
let mut no_data_count = 0;
let mut prompt_detected_at: Option<Instant> = None;
while start.elapsed() < timeout {
match self.output_rx.try_recv() {
Ok(chunk) => {
self.answer_terminal_queries(&chunk);
self.output_buffer.push_str(&chunk);
self.ring.push_chunk(&chunk);
no_data_count = 0;
if prompt_detected_at.is_none()
&& (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
|| Self::check_prompt_complete(
&self.output_buffer,
&self.prompt_end_marker,
))
{
prompt_detected_at = Some(Instant::now());
debug!("Prompt detected, draining remaining output...");
}
self.truncate_output_buffer_with_offload();
}
Err(TryRecvError::Empty) => {
thread::sleep(Duration::from_millis(10));
no_data_count += 1;
if let Some(detected_time) = prompt_detected_at {
if detected_time.elapsed() > Duration::from_millis(100) {
complete = true;
debug!("Command completed - prompt detected and drained");
break;
}
} else if no_data_count > 10
&& Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
{
prompt_detected_at = Some(Instant::now());
debug!("Prompt detected after wait, draining...");
}
}
Err(TryRecvError::Disconnected) => {
warn!("PTY reader disconnected");
complete = true;
break;
}
}
}
if complete || prompt_detected_at.is_some() {
self.command_running = false;
self.last_exit_code =
Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
self.update_current_cwd_from_prompt();
complete = true;
}
Ok((self.output_buffer.clone(), complete))
}
pub fn poll_output_nonblocking(&mut self) -> bool {
let mut prompt_seen = false;
loop {
match self.output_rx.try_recv() {
Ok(chunk) => {
self.answer_terminal_queries(&chunk);
let chunk_has_prompt =
Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
self.output_buffer.push_str(&chunk);
self.ring.push_chunk(&chunk);
if chunk_has_prompt
|| Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
{
prompt_seen = true;
}
self.truncate_output_buffer_with_offload();
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.command_running = false;
return true;
}
}
}
if prompt_seen {
self.command_running = false;
self.last_exit_code =
Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
self.update_current_cwd_from_prompt();
}
!self.command_running
}
fn update_current_cwd_from_prompt(&mut self) {
let Some(last) =
self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
else {
return;
};
let clean = crate::state::terminal::strip_ansi_codes(last);
let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
return;
};
let Some((_, cwd)) = before_marker.rsplit_once("â—‰ ") else {
return;
};
let cwd = cwd.trim();
if !cwd.is_empty() {
self.current_cwd = native_cwd_from_prompt(cwd);
}
}
pub fn output_snapshot(&self) -> String {
self.output_buffer.clone()
}
fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
Self::parse_prompt_exit_code(text, prompt_end).is_some()
}
fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
if !last.contains(WCGW_PROMPT_PATTERN) {
return None;
}
let raw_tail = last.rsplit_once(prompt_end)?.1;
let clean_tail = crate::state::terminal::strip_ansi_codes(raw_tail);
let code_text = clean_tail.strip_prefix(':')?;
let digits = code_text
.char_indices()
.take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && *c == '-'))
.count();
let (code, rest) = code_text.split_at(digits);
if !rest.chars().all(char::is_whitespace) {
return None;
}
let terminated = !rest.is_empty() || raw_tail.len() > clean_tail.len();
if !terminated {
return None;
}
code.parse::<i32>().ok()
}
pub fn set_scratch_root(&mut self, root: &Path) {
self.scratch_workspace_root = Some(root.to_path_buf());
}
pub fn scratch_path(&self) -> Option<&Path> {
self.scratch_path.as_deref()
}
pub fn reset_scratch(&mut self) {
self.scratch_path = None;
self.scratch_bytes = 0;
self.discard_throttle.store(false, AtomicOrdering::Relaxed);
}
fn offload_dropped_head(&mut self, head: &str) {
let Some(root) = self.scratch_workspace_root.clone() else {
return;
};
let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
if remaining == 0 {
self.discard_throttle.store(true, AtomicOrdering::Relaxed);
return;
}
let head = match usize::try_from(remaining) {
Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
_ => head,
};
if self.scratch_path.is_none() {
self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
}
let Some(path) = self.scratch_path.clone() else {
return;
};
match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
Ok(written) => {
self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
if self.scratch_bytes >= SCRATCH_MAX_BYTES {
self.discard_throttle.store(true, AtomicOrdering::Relaxed);
}
}
Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
}
}
fn truncate_output_buffer_with_offload(&mut self) {
if self.output_buffer.len() <= self.max_output_size {
return;
}
self.output_truncated = true;
let keep_size = self.max_output_size / 2;
let cut = crate::utils::floor_char_boundary(
&self.output_buffer,
self.output_buffer.len() - keep_size,
);
let head = self.output_buffer[..cut].to_owned();
self.offload_dropped_head(&head);
self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
}
pub fn send_interrupt(&mut self) -> Result<()> {
debug!("PTY sending Ctrl+C");
self.writer
.write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
self.writer.flush()?;
Ok(())
}
pub fn send_eof(&mut self) -> Result<()> {
debug!("PTY sending Ctrl+D");
self.writer
.write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
self.writer.flush()?;
Ok(())
}
pub fn send_suspend(&mut self) -> Result<()> {
debug!("PTY sending Ctrl+Z");
self.writer
.write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
self.writer.flush()?;
Ok(())
}
pub fn send_text(&mut self, text: &str) -> Result<()> {
debug!(bytes = text.len(), "PTY sending text");
self.send_bytes(text.as_bytes()).context("Failed to send text")?;
Ok(())
}
pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
self.writer.write_all(bytes).context("Failed to send bytes")?;
self.writer.flush()?;
Ok(())
}
pub fn send_special_key(&mut self, key: &str) -> Result<()> {
let bytes: &[u8] = match key {
"Enter" => b"\r",
"Tab" => b"\t",
"Backspace" => b"\x7F",
"Escape" => b"\x1B",
"Up" | "KeyUp" => b"\x1B[A",
"Down" | "KeyDown" => b"\x1B[B",
"Right" | "KeyRight" => b"\x1B[C",
"Left" | "KeyLeft" => b"\x1B[D",
"Home" => b"\x1B[H",
"End" => b"\x1B[F",
"PageUp" => b"\x1B[5~",
"PageDown" => b"\x1B[6~",
"Delete" => b"\x1B[3~",
"Insert" => b"\x1B[2~",
"CtrlC" | "Ctrl-C" => b"\x03",
"CtrlD" | "Ctrl-D" => b"\x04",
"CtrlZ" | "Ctrl-Z" => b"\x1A",
"CtrlL" | "Ctrl-L" => b"\x0C",
_ => return Err(anyhow!("Unknown special key: {key}")),
};
debug!("PTY sending special key: {} ({:?})", key, bytes);
self.send_bytes(bytes)?;
Ok(())
}
pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
}
pub fn live_in_alt_screen(&self) -> bool {
self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
}
pub fn live_osc_title(&self) -> String {
self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
}
pub fn live_osc_progress(&self) -> String {
self.live
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.osc_progress()
.to_string()
}
pub fn live_cursor_position(&self) -> (u16, u16) {
self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
}
pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
self.live
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.snapshot_diff(max_lines, threshold)
}
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
debug!("PTY resizing to {}x{}", cols, rows);
let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
self.master.resize(new_size).context("Failed to resize PTY")?;
self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
self.size = new_size;
Ok(())
}
pub fn get_size(&self) -> (u16, u16) {
(self.size.cols, self.size.rows)
}
pub fn process_id(&self) -> Option<u32> {
self.child.process_id()
}
pub fn is_alive(&mut self) -> bool {
self.child.try_wait().is_ok_and(|status| status.is_none())
}
}
pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
let shell = PtyShell::new(initial_dir, restricted_mode)?;
Ok(Arc::new(Mutex::new(Some(shell))))
}
fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
let mut decoded = String::new();
let mut rest = bytes;
loop {
match std::str::from_utf8(rest) {
Ok(text) => {
decoded.push_str(text);
return (decoded, Vec::new());
}
Err(error) => {
let valid = error.valid_up_to();
decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
match error.error_len() {
None => return (decoded, rest[valid..].to_vec()),
Some(bad) => {
decoded.push('\u{FFFD}');
rest = &rest[valid + bad..];
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)] use tempfile::TempDir;
#[test]
fn decode_passes_clean_utf8_through() {
let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
assert_eq!(text, "hello ✓ café");
assert!(carry.is_empty(), "expected empty carry");
}
#[test]
fn decode_carries_incomplete_trailing_char_across_reads() {
let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
assert_eq!(text1, "a");
assert_eq!(carry1, vec![0xC3]);
let mut next = carry1;
next.push(0xA9); let (text2, carry2) = decode_keep_incomplete(&next);
assert_eq!(text2, "é");
assert!(carry2.is_empty(), "expected empty carry2");
}
#[test]
fn decode_replaces_genuinely_invalid_bytes() {
let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
assert_eq!(text, "x\u{FFFD}y");
assert!(carry.is_empty(), "expected empty carry");
}
#[test]
fn decode_handles_large_invalid_burst_without_overflow() {
let big = vec![0xFFu8; 200_000];
let (text, carry) = decode_keep_incomplete(&big);
assert!(carry.is_empty(), "expected empty carry");
assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
}
#[test]
fn screen_session_name_parser_is_strict() {
assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
assert_eq!(super::winx_creator_pid("other-123-456"), None);
}
#[cfg(unix)]
#[test]
fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
assert!(super::process_exists(std::process::id()));
assert!(!super::process_exists(0));
assert!(!super::process_exists(1));
}
#[test]
fn msys_paths_convert_to_native_drives() {
use super::msys_to_native;
assert_eq!(msys_to_native("/c/Users/x/ws"), Some(PathBuf::from("C:\\Users\\x\\ws")));
assert_eq!(msys_to_native("/cygdrive/d/work"), Some(PathBuf::from("D:\\work")));
assert_eq!(msys_to_native("/c"), Some(PathBuf::from("C:\\")));
assert_eq!(msys_to_native("/home/x"), None);
assert_eq!(msys_to_native("relative"), None);
}
#[test]
fn prompt_detection_is_suffix_anchored() {
let end = "──➤deadbeefcafe0001";
assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
assert!(!PtyShell::check_prompt_complete(
"menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
end
));
assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
assert!(!PtyShell::check_prompt_complete("just some output\n", end));
assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
}
#[test]
fn powershell_bootstrap_defines_a_marker_prompt_without_psreadline() {
let bootstrap = super::powershell_bootstrap("cafe1234");
assert!(bootstrap.contains("Remove-Module PSReadLine"), "{bootstrap}");
assert!(bootstrap.contains("function global:prompt"), "{bootstrap}");
assert!(bootstrap.contains("──➤cafe1234:$code "), "{bootstrap}");
assert!(bootstrap.contains("$global:LASTEXITCODE = 0"), "{bootstrap}");
assert!(!bootstrap.contains('\n'), "bootstrap must be a single input line");
}
#[test]
fn parses_exit_code_from_prompt_marker() {
let end = "──➤cafe1234";
assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:1", end), None);
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127", end), None);
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127 ", end), Some(127));
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127\u{1b}[C", end), Some(127));
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:0\u{1b}[K", end), Some(0));
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:-1 ", end), Some(-1));
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:12a ", end), None);
assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
}
use proptest::prelude::*;
proptest! {
#[test]
fn parse_prompt_exit_code_never_panics(
text in prop::collection::vec(any::<char>(), 0..256),
nonce in "[0-9a-f]{0,16}",
) {
let s: String = text.into_iter().collect();
let end = format!("{WCGW_PROMPT_END}{nonce}");
let _ = PtyShell::parse_prompt_exit_code(&s, &end);
}
#[test]
fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
let end = format!("{WCGW_PROMPT_END}{nonce}");
let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
}
}
#[cfg(unix)] #[test]
fn test_pty_shell_creation() -> Result<()> {
let temp_dir = TempDir::new()?;
let result = PtyShell::new(temp_dir.path(), false);
assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
Ok(())
}
#[cfg(unix)] #[test]
fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut shell = PtyShell::new(temp_dir.path(), false)?;
assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
shell.scratch_bytes = SCRATCH_MAX_BYTES;
shell.offload_dropped_head("dropped beyond the budget");
assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
shell.send_command("echo done")?;
assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
Ok(())
}
#[cfg(unix)] #[test]
fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut shell = PtyShell::new(temp_dir.path(), false)?;
shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
shell.offload_dropped_head("xx");
assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
Ok(())
}
#[cfg(unix)] #[test]
fn test_pty_shell_echo() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut shell = PtyShell::new(temp_dir.path(), false)?;
shell.send_command("echo 'hello pty'")?;
let (output, complete) = shell.read_output(2.0)?;
assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
assert!(complete, "first command must complete: {output}");
assert!(!output.contains("PROMPT_COMMAND"), "bootstrap leaked into output: {output}");
assert_eq!(shell.last_exit_code, Some(0), "{output}");
Ok(())
}
#[cfg(unix)] #[test]
fn agent_paths_are_exported_to_the_pty() -> Result<()> {
let workspace = TempDir::new()?;
let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
let mut shell = PtyShell::new_with_agent_paths(
workspace.path(),
false,
Some(workspace.path()),
Some(&temporary_artifact_dir),
)?;
shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
let (output, _complete) = shell.read_output(2.0)?;
assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
Ok(())
}
#[cfg(unix)] #[test]
fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut shell = PtyShell::new(temp_dir.path(), false)?;
shell.send_command("true")?;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline && !shell.poll_output_nonblocking() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!shell.command_running, "shell did not complete before the deadline");
assert!(
shell.poll_output_nonblocking(),
"a later consumer must still observe the completed state"
);
Ok(())
}
#[cfg(unix)] #[test]
fn test_pty_shell_pwd() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut shell = PtyShell::new(temp_dir.path(), false)?;
shell.send_command("pwd && echo 'pwd_done'")?;
let (output, _complete) = shell.read_output(2.0)?;
assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
Ok(())
}
#[cfg(unix)] #[test]
fn test_pty_resize() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut shell = PtyShell::new(temp_dir.path(), false)?;
let result = shell.resize(120, 40);
assert!(result.is_ok());
let (cols, rows) = shell.get_size();
assert_eq!(cols, 120);
assert_eq!(rows, 40);
Ok(())
}
#[cfg(unix)]
fn pid_alive(pid: i32) -> bool {
u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
}
#[cfg(unix)]
#[test]
fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
let dir = TempDir::new()?;
let pidfile = dir.path().join("child.pid");
let mut shell = PtyShell::new(dir.path(), false)?;
shell
.send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
let mut child_pid = None;
for _ in 0..150 {
let _ = shell.read_output(0.1);
if let Some(pid) =
std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
{
child_pid = Some(pid);
break;
}
thread::sleep(Duration::from_millis(20));
}
let child_pid =
child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
assert!(pid_alive(child_pid), "background child should be running before drop");
drop(shell);
let mut gone = false;
for _ in 0..150 {
if !pid_alive(child_pid) {
gone = true;
break;
}
thread::sleep(Duration::from_millis(20));
}
assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
Ok(())
}
}