use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use nix::sys::termios::{self, SetArg, Termios};
use std::os::unix::io::{AsRawFd, BorrowedFd, OwnedFd};
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::time::{Duration, Instant};
static MASTER_FD: AtomicI32 = AtomicI32::new(-1);
static STATUS_BAR_ACTIVE: AtomicBool = AtomicBool::new(false);
static SIGWINCH_PENDING: AtomicBool = AtomicBool::new(false);
const RESIZE_REDRAW_DELAY: Duration = Duration::from_millis(75);
pub fn set_sigwinch_pending() {
SIGWINCH_PENDING.store(true, Ordering::SeqCst);
}
#[cfg(test)]
pub(crate) fn take_sigwinch_pending_for_test() -> bool {
SIGWINCH_PENDING.swap(false, Ordering::SeqCst)
}
pub fn resize_pty() {
let master = MASTER_FD.load(Ordering::SeqCst);
if master < 0 {
return;
}
let mut ws = unsafe { std::mem::zeroed::<nix::libc::winsize>() };
let ret = unsafe {
nix::libc::ioctl(
nix::libc::STDOUT_FILENO,
nix::libc::TIOCGWINSZ,
&mut ws,
)
};
if ret != 0 || ws.ws_row == 0 || ws.ws_col == 0 {
return;
}
if STATUS_BAR_ACTIVE.load(Ordering::SeqCst) {
if ws.ws_row < 2 {
return;
}
ws.ws_row -= 1;
}
unsafe {
nix::libc::ioctl(master, nix::libc::TIOCSWINSZ, &ws);
}
}
fn forward_sigwinch() {
let master = MASTER_FD.load(Ordering::SeqCst);
if master < 0 {
return;
}
let mut pgrp: nix::libc::pid_t = 0;
let ret =
unsafe { nix::libc::ioctl(master, nix::libc::TIOCGPGRP, &mut pgrp) };
if ret == 0 && pgrp > 0 {
unsafe {
nix::libc::kill(-pgrp, nix::libc::SIGWINCH);
}
}
}
fn enter_raw_mode() -> Result<Termios, String> {
let stdin = std::io::stdin();
let saved =
termios::tcgetattr(&stdin).map_err(|e| format!("tcgetattr: {e}"))?;
let mut raw = saved.clone();
termios::cfmakeraw(&mut raw);
termios::tcsetattr(&stdin, SetArg::TCSANOW, &raw)
.map_err(|e| format!("tcsetattr raw: {e}"))?;
Ok(saved)
}
fn restore_mode(saved: &Termios) {
let stdin = std::io::stdin();
let _ = termios::tcsetattr(&stdin, SetArg::TCSANOW, saved);
}
struct RawModeGuard(Option<Termios>);
impl RawModeGuard {
fn new(saved: Termios) -> Self {
Self(Some(saved))
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if let Some(saved) = self.0.take() {
restore_mode(&saved);
}
}
}
fn set_initial_size(fd: &OwnedFd, rows: u16, cols: u16) {
let ws = nix::libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
unsafe {
nix::libc::ioctl(fd.as_raw_fd(), nix::libc::TIOCSWINSZ, &ws);
}
}
fn set_scroll_region(fd: i32, content_rows: u16) {
let seq = format!("\x1b[1;{content_rows}r");
write_all_raw(fd, seq.as_bytes());
}
pub fn parse_resize_redraw_key(spec: &str) -> Result<Option<Vec<u8>>, String> {
let normalized = spec
.trim()
.to_ascii_lowercase()
.replace(['_', '+', ' '], "-");
if normalized.is_empty()
|| matches!(normalized.as_str(), "off" | "none" | "disabled")
{
return Ok(None);
}
let parts: Vec<&str> =
normalized.split('-').filter(|p| !p.is_empty()).collect();
if parts.len() < 2 {
return Err("expected values like ctrl-l or disabled".into());
}
let mut has_ctrl = false;
let mut has_shift = false;
let mut key = None;
for part in parts {
match part {
"ctrl" | "control" => has_ctrl = true,
"shift" => has_shift = true,
k if k.len() == 1 && k.as_bytes()[0].is_ascii_alphabetic() => {
key = Some(k.as_bytes()[0].to_ascii_uppercase());
}
other => {
return Err(format!("unsupported modifier or key {other:?}"));
}
}
}
if !has_ctrl {
return Err("only ctrl-based redraw keys are supported".into());
}
let Some(key) = key else {
return Err("missing final letter key".into());
};
if has_shift {
}
Ok(Some(vec![key & 0x1f]))
}
struct IoLoop<'a> {
parser: vt100::Parser,
prev_screen: vt100::Screen,
content_rows: u16,
content_cols: u16,
pending_redraw: bool,
pending_resize_redraw_at: Option<Instant>,
was_alt_screen: bool,
stdout: i32,
master_raw: i32,
init_rows: u16,
init_cols: u16,
resize_redraw_key: Option<&'a [u8]>,
osc: OscForwarder,
terminal_passthrough: bool,
primary_filter: TerminalFilter,
status_bar: bool,
}
impl<'a> IoLoop<'a> {
fn new(
master_raw: i32,
init_rows: u16,
init_cols: u16,
resize_redraw_key: Option<&'a [u8]>,
terminal_passthrough: bool,
status_bar: bool,
) -> Self {
let content_rows = if status_bar { init_rows - 1 } else { init_rows };
let content_cols = init_cols;
let parser = vt100::Parser::new(content_rows, content_cols, 0);
let prev_screen = parser.screen().clone();
Self {
parser,
prev_screen,
content_rows,
content_cols,
pending_redraw: false,
pending_resize_redraw_at: None,
was_alt_screen: false,
stdout: nix::libc::STDOUT_FILENO,
master_raw,
init_rows,
init_cols,
resize_redraw_key,
osc: OscForwarder::new(terminal_passthrough),
terminal_passthrough,
primary_filter: TerminalFilter::new(),
status_bar,
}
}
fn prime_terminal(&self) {
if !self.status_bar {
return;
}
let pos = format!("\x1b[{};1H", self.init_rows);
write_all_raw(self.stdout, pos.as_bytes());
for _ in 0..self.content_rows {
write_all_raw(self.stdout, b"\n");
}
write_all_raw(self.stdout, b"\x1b[H\x1b[J");
set_scroll_region(self.stdout, self.content_rows);
}
fn handle_sigwinch(&mut self) {
if !SIGWINCH_PENDING.swap(false, Ordering::SeqCst) {
return;
}
let (rows, cols) =
real_term_size().unwrap_or((self.init_rows, self.init_cols));
if rows == 0 || (self.status_bar && rows < 2) {
return;
}
let old_content_rows = self.content_rows;
self.content_rows = if self.status_bar { rows - 1 } else { rows };
self.content_cols = cols;
self.parser
.screen_mut()
.set_size(self.content_rows, self.content_cols);
let screen = self.parser.screen();
let on_alt = screen.alternate_screen();
if on_alt {
write_all_raw(self.stdout, b"\x1b[r\x1b[H\x1b[J");
let output = screen.state_formatted();
write_all_raw(self.stdout, &output);
} else {
write_all_raw(self.stdout, b"\x1b[r");
if self.status_bar && old_content_rows < self.content_rows {
let seq = format!("\x1b[{};1H\x1b[2K", old_content_rows + 1);
write_all_raw(self.stdout, seq.as_bytes());
}
if self.status_bar {
set_scroll_region(self.stdout, self.content_rows);
}
let (row, col) = screen.cursor_position();
let seq = format!("\x1b[{};{}H", row + 1, col + 1);
write_all_raw(self.stdout, seq.as_bytes());
}
self.prev_screen = screen.clone();
self.was_alt_screen = on_alt;
if self.status_bar {
crate::statusbar::update_terminal_state(screen);
crate::statusbar::redraw();
}
resize_pty();
forward_sigwinch();
if !on_alt && self.resize_redraw_key.is_some() {
self.pending_resize_redraw_at =
Some(Instant::now() + RESIZE_REDRAW_DELAY);
}
if self.status_bar {
let _ = crate::statusbar::take_requests();
}
self.pending_redraw = false;
}
fn handle_master_read(&mut self, buf: &[u8]) -> std::ops::ControlFlow<()> {
self.parser.process(buf);
let screen = self.parser.screen();
let now_alt = screen.alternate_screen();
if (now_alt || now_alt != self.was_alt_screen)
&& self.terminal_passthrough
{
forward_terminal_queries(self.stdout, buf);
}
if now_alt {
self.osc.feed(self.stdout, buf);
}
if now_alt != self.was_alt_screen {
if now_alt {
write_all_raw(self.stdout, b"\x1b[r");
} else {
if self.status_bar {
set_scroll_region(self.stdout, self.content_rows);
}
}
write_all_raw(self.stdout, b"\x1b[H\x1b[J");
let output = screen.state_formatted();
write_all_raw(self.stdout, &output);
self.was_alt_screen = now_alt;
} else if now_alt {
let diff = screen.state_diff(&self.prev_screen);
write_all_raw(self.stdout, &diff);
} else {
if self.terminal_passthrough {
write_all_raw(self.stdout, buf);
} else {
let filtered = self.primary_filter.feed(buf);
write_all_raw(self.stdout, &filtered);
}
if self.status_bar && ends_at_ground_state(buf) {
let (row, col) = self.parser.screen().cursor_position();
set_scroll_region(self.stdout, self.content_rows);
let seq = format!("\x1b[{};{}H", row + 1, col + 1);
write_all_raw(self.stdout, seq.as_bytes());
}
}
self.prev_screen = screen.clone();
if self.status_bar {
crate::statusbar::update_terminal_state(screen);
self.pending_redraw = true;
}
if self.resize_redraw_key.is_some()
&& self.pending_resize_redraw_at.is_some()
{
self.pending_resize_redraw_at =
Some(Instant::now() + RESIZE_REDRAW_DELAY);
}
std::ops::ControlFlow::Continue(())
}
fn drain_on_hup(&mut self, buf: &mut [u8]) {
loop {
match nix::unistd::read(self.master_raw, buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
self.parser.process(&buf[..n]);
}
}
}
let screen = self.parser.screen();
let diff = screen.state_diff(&self.prev_screen);
write_all_raw(self.stdout, &diff);
write_all_raw(self.stdout, b"\x1b[r");
if self.status_bar {
crate::statusbar::redraw();
}
}
fn flush_when_idle(&mut self) {
if self.status_bar && self.pending_redraw {
crate::statusbar::redraw();
self.pending_redraw = false;
}
self.maybe_inject_resize_key();
}
fn maybe_inject_resize_key(&mut self) {
if let Some(deadline) = self.pending_resize_redraw_at
&& Instant::now() >= deadline
{
if let Some(key) = self.resize_redraw_key {
write_all_raw(self.master_raw, key);
}
self.pending_resize_redraw_at = None;
}
}
}
fn io_loop(
master: &OwnedFd,
init_rows: u16,
init_cols: u16,
resize_redraw_key: Option<&[u8]>,
terminal_passthrough: bool,
status_bar: bool,
) {
let stdin_fd = std::io::stdin().as_raw_fd();
let master_raw = master.as_raw_fd();
let stdin_bfd = unsafe { BorrowedFd::borrow_raw(stdin_fd) };
let master_bfd = unsafe { BorrowedFd::borrow_raw(master_raw) };
let mut buf = [0u8; 8192];
let mut state = IoLoop::new(
master_raw,
init_rows,
init_cols,
resize_redraw_key,
terminal_passthrough,
status_bar,
);
if status_bar {
crate::statusbar::update_terminal_state(state.parser.screen());
}
state.prime_terminal();
loop {
state.handle_sigwinch();
let mut fds = [
PollFd::new(stdin_bfd, PollFlags::POLLIN),
PollFd::new(master_bfd, PollFlags::POLLIN),
];
if status_bar && crate::statusbar::take_requests() {
state.pending_redraw = true;
}
match poll(&mut fds, PollTimeout::from(100_u16)) {
Ok(0) => {
state.flush_when_idle();
continue;
}
Err(nix::errno::Errno::EINTR) => continue,
Err(_) => break,
Ok(_) => {}
}
let mut should_break = false;
if let Some(revents) = fds[1].revents() {
if revents.contains(PollFlags::POLLIN) {
match nix::unistd::read(master_raw, &mut buf) {
Ok(0) => {
should_break = true;
}
Ok(n) => {
let _ = state.handle_master_read(&buf[..n]);
}
Err(nix::errno::Errno::EINTR) => {}
Err(nix::errno::Errno::EIO) => should_break = true,
Err(_) => should_break = true,
}
}
if !should_break
&& (revents.contains(PollFlags::POLLHUP)
|| revents.contains(PollFlags::POLLERR))
{
state.drain_on_hup(&mut buf);
should_break = true;
}
}
if should_break {
break;
}
let child_quiet = !matches!(
fds[1].revents(),
Some(r) if r.contains(PollFlags::POLLIN)
);
if child_quiet {
state.flush_when_idle();
}
if let Some(revents) = fds[0].revents()
&& revents.contains(PollFlags::POLLIN)
{
match nix::unistd::read(stdin_fd, &mut buf) {
Ok(0) => break,
Ok(n) => {
if terminal_passthrough
|| !looks_like_terminal_reply(&buf[..n])
{
write_all_raw(master_raw, &buf[..n]);
}
}
Err(nix::errno::Errno::EINTR) => {}
Err(_) => break,
}
}
}
crate::output::terminal_reset();
}
fn csi_final_forwardable(final_b: u8, prefix: Option<u8>) -> bool {
match final_b {
b'u' => matches!(prefix, Some(b'>' | b'<' | b'?')),
b'c' => true,
b'q' => prefix == Some(b'>'),
_ => false,
}
}
fn forward_terminal_queries(fd: i32, data: &[u8]) {
let mut st: u8 = 0;
let mut start: usize = 0;
let mut prefix: Option<u8> = None;
for (i, &b) in data.iter().enumerate() {
match st {
0 => {
if b == 0x1b {
start = i;
prefix = None;
st = 1;
}
}
1 => {
if b == b'[' {
st = 2;
} else {
st = 0;
}
}
2 => {
if b == b'>' || b == b'<' || b == b'?' {
prefix = Some(b);
st = 3;
} else if (0x40..=0x7e).contains(&b) {
if csi_final_forwardable(b, prefix) {
write_all_raw(fd, &data[start..=i]);
}
st = 0;
} else {
st = 3; }
}
3 => {
if (0x40..=0x7e).contains(&b) {
if csi_final_forwardable(b, prefix) {
write_all_raw(fd, &data[start..=i]);
}
st = 0;
}
}
_ => st = 0,
}
}
}
fn looks_like_terminal_reply(data: &[u8]) -> bool {
(data.starts_with(b"\x1b[") || data.starts_with(&[0x9b]))
&& data.last().is_some_and(|b| matches!(b, b'R' | b'c' | b'n'))
|| data.starts_with(b"\x1bP")
|| data
.first()
.is_some_and(|b| matches!(b, 0x90 | 0x9d | 0x9e | 0x9f))
}
enum FilterState {
Ground,
Esc,
Csi,
String,
StringEsc,
}
struct TerminalFilter {
state: FilterState,
pending: Vec<u8>,
}
impl TerminalFilter {
fn new() -> Self {
Self {
state: FilterState::Ground,
pending: Vec::new(),
}
}
fn feed(&mut self, data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
for &b in data {
match self.state {
FilterState::Ground => {
if b == 0x1b {
self.pending.push(b);
self.state = FilterState::Esc;
} else if b == 0x9b {
self.pending.push(b);
self.state = FilterState::Csi;
} else if matches!(b, 0x90 | 0x9d | 0x9e | 0x9f) {
self.state = FilterState::String;
} else {
out.push(b);
}
}
FilterState::Esc => match b {
b'[' => {
self.pending.push(b);
self.state = FilterState::Csi;
}
b']' | b'P' | b'X' | b'^' | b'_' => {
self.pending.clear();
self.state = FilterState::String;
}
_ => {
self.pending.push(b);
out.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Ground;
}
},
FilterState::Csi => {
self.pending.push(b);
if (0x40..=0x7e).contains(&b) {
let prefix = self
.pending
.get(2)
.copied()
.filter(|p| matches!(p, b'>' | b'<' | b'?'));
if !csi_final_forwardable(b, prefix) && b != b'n' {
out.extend_from_slice(&self.pending);
}
self.pending.clear();
self.state = FilterState::Ground;
} else if self.pending.len() > OSC_MAX_LEN {
self.pending.clear();
self.state = FilterState::Ground;
}
}
FilterState::String => match b {
0x07 | 0x9c => self.state = FilterState::Ground,
0x1b => self.state = FilterState::StringEsc,
_ => {}
},
FilterState::StringEsc => {
self.state = if b == b'\\' || b == 0x9c {
FilterState::Ground
} else if b == 0x1b {
FilterState::StringEsc
} else {
FilterState::String
};
}
}
}
out
}
}
const OSC_MAX_LEN: usize = 1 << 20;
const OSC_FORWARD_CMDS: &[&[u8]] = &[b"0", b"1", b"2", b"52"];
enum OscState {
Ground,
Esc,
Body,
BodyEsc,
}
struct OscForwarder {
state: OscState,
buf: Vec<u8>,
enabled: bool,
}
impl OscForwarder {
fn new(enabled: bool) -> Self {
Self {
state: OscState::Ground,
buf: Vec::new(),
enabled,
}
}
fn reset(&mut self) {
self.state = OscState::Ground;
self.buf.clear();
}
fn flush(&mut self, fd: i32) {
if self.enabled && Self::is_forwardable(&self.buf) {
write_all_raw(fd, &self.buf);
}
self.reset();
}
fn is_forwardable(buf: &[u8]) -> bool {
let body = match buf.strip_prefix(b"\x1b]") {
Some(b) => b,
None => return false,
};
let end = body
.iter()
.position(|&b| b == b';' || b == 0x07 || b == 0x1b)
.unwrap_or(body.len());
let cmd = &body[..end];
if OSC_FORWARD_CMDS.contains(&cmd) {
return true;
}
if matches!(cmd, b"10" | b"11" | b"12") {
return body.get(end) == Some(&b';')
&& body.get(end + 1) == Some(&b'?');
}
false
}
fn feed(&mut self, fd: i32, data: &[u8]) {
for &b in data {
self.push(fd, b);
}
}
fn push(&mut self, fd: i32, b: u8) {
match self.state {
OscState::Ground => {
if b == 0x1b {
self.buf.push(b);
self.state = OscState::Esc;
}
}
OscState::Esc => {
if b == b']' {
self.buf.push(b);
self.state = OscState::Body;
} else if b == 0x1b {
self.buf.clear();
self.buf.push(b);
} else {
self.reset();
}
}
OscState::Body => match b {
0x07 => {
self.buf.push(b);
self.flush(fd);
}
0x1b => {
self.buf.push(b);
self.state = OscState::BodyEsc;
}
_ => {
self.buf.push(b);
if self.buf.len() > OSC_MAX_LEN {
self.reset();
}
}
},
OscState::BodyEsc => {
if b == b'\\' {
self.buf.push(b);
self.flush(fd);
} else if b == 0x1b {
self.buf.push(b);
} else {
self.reset();
}
}
}
}
}
fn ends_at_ground_state(data: &[u8]) -> bool {
let mut st: u8 = 0;
for &b in data {
st = match (st, b) {
(0, 0x1b) => 1,
(0, _) => 0,
(1, b'[') => 2,
(1, b']' | b'P' | b'X' | b'^' | b'_') => 3,
(1, 0x20..=0x2f) => 1, (1, _) => 0, (2, 0x40..=0x7e) => 0, (2, _) => 2, (3, 0x07) => 0, (3, 0x1b) => 1, (3, _) => 3,
_ => 0,
};
}
st == 0
}
fn write_all_raw(fd: i32, data: &[u8]) {
let mut off = 0;
while off < data.len() {
let n = unsafe {
nix::libc::write(
fd,
data[off..].as_ptr().cast::<nix::libc::c_void>(),
data.len() - off,
)
};
if n <= 0 {
break;
}
off += n as usize;
}
}
#[allow(dead_code)]
pub fn stdout_needs_filter() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal()
}
pub struct Pty(nix::pty::OpenptyResult);
pub fn open() -> Result<Pty, String> {
nix::pty::openpty(None, None)
.map(Pty)
.map_err(|e| format!("openpty: {e}"))
}
impl Pty {
#[cfg(all(test, target_os = "macos"))]
pub fn slave_try_clone(&self) -> std::io::Result<OwnedFd> {
self.0.slave.try_clone()
}
pub fn slave_path(&self) -> Option<std::path::PathBuf> {
use std::os::unix::ffi::OsStrExt;
let raw = unsafe { nix::libc::ptsname(self.0.master.as_raw_fd()) };
if raw.is_null() {
return None;
}
let bytes = unsafe { std::ffi::CStr::from_ptr(raw) }.to_bytes();
Some(std::path::PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
}
}
pub fn run_with_config(
pty: Pty,
cmd: &mut std::process::Command,
resize_redraw_key: Option<&[u8]>,
config: &crate::config::Config,
status_bar: bool,
) -> Result<i32, String> {
run_with_passthrough(
pty,
cmd,
resize_redraw_key,
config.terminal_passthrough_enabled(),
status_bar,
)
}
fn run_with_passthrough(
pty: Pty,
cmd: &mut std::process::Command,
resize_redraw_key: Option<&[u8]>,
terminal_passthrough: bool,
status_bar: bool,
) -> Result<i32, String> {
use std::os::unix::process::CommandExt;
let (rows, cols) = real_term_size().unwrap_or((24, 80));
if rows == 0 || (status_bar && rows < 2) {
return Err("Terminal too small for status bar".into());
}
let master = pty.0.master;
let slave = pty.0.slave;
let master_raw = master.as_raw_fd();
unsafe {
let flags = nix::libc::fcntl(master_raw, nix::libc::F_GETFD);
nix::libc::fcntl(
master_raw,
nix::libc::F_SETFD,
flags | nix::libc::FD_CLOEXEC,
);
}
set_initial_size(&master, if status_bar { rows - 1 } else { rows }, cols);
let saved = enter_raw_mode()?;
let raw_mode_guard = RawModeGuard::new(saved);
let slave_raw = slave.as_raw_fd();
unsafe {
cmd.pre_exec(move || {
if nix::libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if nix::libc::ioctl(
slave_raw,
nix::libc::TIOCSCTTY as nix::libc::c_ulong,
0,
) == -1
{
return Err(std::io::Error::last_os_error());
}
nix::libc::dup2(slave_raw, 0);
nix::libc::dup2(slave_raw, 1);
nix::libc::dup2(slave_raw, 2);
if slave_raw > 2 {
nix::libc::close(slave_raw);
}
Ok(())
});
}
let child = cmd
.spawn()
.map_err(|e| format!("Failed to start sandbox: {e}"))?;
let pid = child.id() as i32;
crate::signals::set_child_pid(pid);
MASTER_FD.store(master_raw, Ordering::SeqCst);
STATUS_BAR_ACTIVE.store(status_bar, Ordering::SeqCst);
drop(slave);
io_loop(
&master,
rows,
cols,
resize_redraw_key,
terminal_passthrough,
status_bar,
);
MASTER_FD.store(-1, Ordering::SeqCst);
STATUS_BAR_ACTIVE.store(false, Ordering::SeqCst);
drop(master);
drop(raw_mode_guard);
let exit_code = crate::signals::wait_child(pid);
std::mem::forget(child);
Ok(exit_code)
}
fn real_term_size() -> Option<(u16, u16)> {
let mut ws = unsafe { std::mem::zeroed::<nix::libc::winsize>() };
let ret = unsafe {
nix::libc::ioctl(
nix::libc::STDOUT_FILENO,
nix::libc::TIOCGWINSZ,
&mut ws,
)
};
if ret == 0 && ws.ws_row > 0 && ws.ws_col > 0 {
Some((ws.ws_row, ws.ws_col))
} else {
None
}
}
#[cfg(test)]
mod tests {
#[test]
fn vt100_screen_tracks_content() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"Hello, world!");
let screen = parser.screen();
let row = screen.rows(0, 80).next().unwrap();
assert!(row.starts_with("Hello, world!"));
}
#[test]
fn vt100_diff_produces_output() {
let mut parser = vt100::Parser::new(24, 80, 0);
let prev = parser.screen().clone();
parser.process(b"test output");
let diff = parser.screen().contents_diff(&prev);
assert!(!diff.is_empty());
}
#[test]
fn vt100_resize_preserves_content() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"line1\r\nline2\r\nline3");
parser.screen_mut().set_size(30, 100);
let screen = parser.screen();
let row0 = screen.rows(0, 100).next().unwrap();
assert!(row0.starts_with("line1"));
}
#[test]
fn vt100_primary_screen_repaint_after_resize_contains_content() {
let mut parser = vt100::Parser::new(4, 20, 0);
parser.process(b"line1\r\nline2\r\nline3");
parser.screen_mut().set_size(6, 30);
let output = parser.screen().state_formatted();
assert!(!parser.screen().alternate_screen());
assert!(!output.is_empty());
assert!(String::from_utf8_lossy(&output).contains("line1"));
assert!(String::from_utf8_lossy(&output).contains("line2"));
}
#[test]
fn parse_resize_redraw_key_ctrl_l() {
assert_eq!(
super::parse_resize_redraw_key("ctrl-l").unwrap(),
Some(vec![0x0c])
);
}
#[test]
fn parse_resize_redraw_key_ctrl_shift_l_aliases_ctrl_l() {
assert_eq!(
super::parse_resize_redraw_key("ctrl-shift-l").unwrap(),
Some(vec![0x0c])
);
}
#[test]
fn parse_resize_redraw_key_disabled() {
assert_eq!(super::parse_resize_redraw_key("disabled").unwrap(), None);
}
#[test]
fn parse_resize_redraw_key_rejects_unknown() {
assert!(super::parse_resize_redraw_key("alt-l").is_err());
}
#[test]
fn vt100_state_diff_includes_mode_changes() {
let mut parser = vt100::Parser::new(24, 80, 0);
let prev = parser.screen().clone();
parser.process(b"\x1b[?2004h");
let diff = parser.screen().state_diff(&prev);
assert!(parser.screen().bracketed_paste());
assert!(!prev.bracketed_paste());
assert!(!diff.is_empty());
}
#[test]
fn vt100_alt_screen_tracking() {
let mut parser = vt100::Parser::new(24, 80, 0);
assert!(!parser.screen().alternate_screen());
parser.process(b"\x1b[?1049h");
assert!(parser.screen().alternate_screen());
parser.process(b"\x1b[?1049l");
assert!(!parser.screen().alternate_screen());
}
#[test]
fn vt100_mouse_mode_tracking() {
let mut parser = vt100::Parser::new(24, 80, 0);
assert_eq!(
parser.screen().mouse_protocol_mode(),
vt100::MouseProtocolMode::None
);
parser.process(b"\x1b[?1003h");
assert_eq!(
parser.screen().mouse_protocol_mode(),
vt100::MouseProtocolMode::AnyMotion
);
}
#[test]
fn vt100_cursor_visibility() {
let mut parser = vt100::Parser::new(24, 80, 0);
assert!(!parser.screen().hide_cursor());
parser.process(b"\x1b[?25l");
assert!(parser.screen().hide_cursor());
parser.process(b"\x1b[?25h");
assert!(!parser.screen().hide_cursor());
}
use super::ends_at_ground_state;
use super::forward_terminal_queries;
use std::os::unix::io::AsRawFd;
fn capture_kbd_forward(data: &[u8]) -> Vec<u8> {
let (r, w) = nix::unistd::pipe().unwrap();
forward_terminal_queries(w.as_raw_fd(), data);
drop(w);
let mut out = vec![0u8; 256];
let n = nix::unistd::read(r.as_raw_fd(), &mut out).unwrap_or(0);
out.truncate(n);
out
}
fn capture_kbd_forward_enabled(data: &[u8], enabled: bool) -> Vec<u8> {
let (r, w) = nix::unistd::pipe().unwrap();
if enabled {
forward_terminal_queries(w.as_raw_fd(), data);
}
drop(w);
let mut out = vec![0u8; 256];
let n = nix::unistd::read(r.as_raw_fd(), &mut out).unwrap_or(0);
out.truncate(n);
out
}
#[test]
fn kbd_push_mode_forwarded() {
let out = capture_kbd_forward(b"\x1b[>1u");
assert_eq!(out, b"\x1b[>1u");
}
#[test]
fn terminal_queries_are_denied_without_passthrough() {
assert!(capture_kbd_forward_enabled(b"\x1b[>1u", false).is_empty());
}
#[test]
fn kbd_pop_mode_forwarded() {
let out = capture_kbd_forward(b"\x1b[<u");
assert_eq!(out, b"\x1b[<u");
}
#[test]
fn kbd_query_mode_forwarded() {
let out = capture_kbd_forward(b"\x1b[?u");
assert_eq!(out, b"\x1b[?u");
}
#[test]
fn kbd_mixed_with_other_csi() {
let data = b"\x1b[31m\x1b[>1u\x1b[H";
let out = capture_kbd_forward(data);
assert_eq!(out, b"\x1b[>1u");
}
#[test]
fn kbd_no_false_positives() {
let out = capture_kbd_forward(b"\x1b[31m\x1b[H\x1b[J");
assert!(out.is_empty());
}
#[test]
fn kbd_plain_text_ignored() {
let out = capture_kbd_forward(b"hello world");
assert!(out.is_empty());
}
#[test]
fn primary_device_attributes_forwarded() {
assert_eq!(capture_kbd_forward(b"\x1b[c"), b"\x1b[c");
assert_eq!(capture_kbd_forward(b"\x1b[0c"), b"\x1b[0c");
}
#[test]
fn secondary_device_attributes_forwarded() {
assert_eq!(capture_kbd_forward(b"\x1b[>c"), b"\x1b[>c");
}
#[test]
fn xtversion_query_forwarded() {
assert_eq!(capture_kbd_forward(b"\x1b[>q"), b"\x1b[>q");
}
#[test]
fn unrelated_csi_finals_not_forwarded() {
assert!(capture_kbd_forward(b"\x1b[31m").is_empty());
assert!(capture_kbd_forward(b"\x1b[6n").is_empty());
assert!(capture_kbd_forward(b"\x1b[2J").is_empty());
}
#[test]
fn plain_csi_u_not_forwarded() {
assert!(capture_kbd_forward(b"\x1b[u").is_empty());
assert!(capture_kbd_forward(b"\x1b[1;5u").is_empty());
}
#[test]
fn osc_title_split_across_reads_forwarded_whole() {
let seq = b"\x1b]2;opencode session\x07";
assert_eq!(capture_osc52(&[&seq[..6], &seq[6..]]), seq);
}
fn capture_osc52(chunks: &[&[u8]]) -> Vec<u8> {
let (r, w) = nix::unistd::pipe().unwrap();
let mut fwd = super::OscForwarder::new(true);
for chunk in chunks {
fwd.feed(w.as_raw_fd(), chunk);
}
drop(w);
let mut out = vec![0u8; 8192];
let n = nix::unistd::read(r.as_raw_fd(), &mut out).unwrap_or(0);
out.truncate(n);
out
}
#[test]
fn osc52_bel_terminated_forwarded() {
let seq = b"\x1b]52;c;aGVsbG8=\x07";
assert_eq!(capture_osc52(&[seq]), seq);
}
#[test]
fn osc52_st_terminated_forwarded() {
let seq = b"\x1b]52;c;aGVsbG8=\x1b\\";
assert_eq!(capture_osc52(&[seq]), seq);
}
#[test]
fn osc52_query_forwarded() {
let seq = b"\x1b]52;c;?\x07";
assert_eq!(capture_osc52(&[seq]), seq);
}
#[test]
fn osc52_split_across_reads_forwarded_whole() {
let seq = b"\x1b]52;c;aGVsbG8gd29ybGQ=\x07";
assert_eq!(capture_osc52(&[&seq[..7], &seq[7..]]), seq);
}
#[test]
fn osc52_embedded_in_other_output_extracted() {
let data = b"some text\x1b[31m\x1b]52;c;Zm9v\x07more";
assert_eq!(capture_osc52(&[data]), b"\x1b]52;c;Zm9v\x07");
}
#[test]
fn osc_window_title_forwarded() {
let t0 = b"\x1b]0;opencode\x07";
assert_eq!(capture_osc52(&[t0]), t0);
let t2 = b"\x1b]2;opencode\x1b\\";
assert_eq!(capture_osc52(&[t2]), t2);
}
#[test]
fn osc_color_query_forwarded() {
let q = b"\x1b]11;?\x07";
assert_eq!(capture_osc52(&[q]), q);
let q10 = b"\x1b]10;?\x1b\\";
assert_eq!(capture_osc52(&[q10]), q10);
}
#[test]
fn osc_color_set_not_forwarded() {
let set = b"\x1b]11;rgb:0000/0000/0000\x07";
assert!(capture_osc52(&[set]).is_empty());
}
#[test]
fn osc_unlisted_command_not_forwarded() {
let out = capture_osc52(&[b"\x1b]4;1;rgb:ff/00/00\x07"]);
assert!(out.is_empty());
}
#[test]
fn osc52_plain_text_ignored() {
assert!(capture_osc52(&[b"just plain text"]).is_empty());
}
#[test]
fn osc52_oversized_payload_dropped() {
let mut seq = b"\x1b]52;c;".to_vec();
seq.extend(std::iter::repeat_n(b'A', super::OSC_MAX_LEN + 16));
seq.push(0x07);
assert!(capture_osc52(&[&seq]).is_empty());
}
#[test]
fn osc_title_and_clipboard_denied_without_passthrough() {
let (r, w) = nix::unistd::pipe().unwrap();
let mut fwd = super::OscForwarder::new(false);
fwd.feed(w.as_raw_fd(), b"\x1b]2;title\x07\x1b]52;c;data\x07");
drop(w);
let mut out = [0; 1];
assert_eq!(nix::unistd::read(r.as_raw_fd(), &mut out).unwrap_or(0), 0);
}
#[test]
fn primary_filter_keeps_display_controls_and_strips_queries() {
let mut filter = super::TerminalFilter::new();
let out =
filter.feed(b"text\x1b[31mred\x1b[2;4H\x1b]52;c;data\x07\x1b[c");
assert_eq!(out, b"text\x1b[31mred\x1b[2;4H");
}
#[test]
fn primary_filter_strips_c1_terminal_strings_and_queries() {
let mut filter = super::TerminalFilter::new();
let out = filter.feed(b"text\x9d52;c;data\x07\x9b?1;2cvisible");
assert_eq!(out, b"textvisible");
assert!(super::looks_like_terminal_reply(b"\x9b12;4R"));
assert!(super::looks_like_terminal_reply(b"\x9d52;c;reply\x07"));
}
#[test]
fn primary_filter_c1_st_terminates_c1_osc() {
let mut filter = super::TerminalFilter::new();
let out = filter.feed(b"text\x9d2;title\x9cafter");
assert_eq!(out, b"textafter");
}
#[test]
fn primary_filter_c1_st_terminates_all_c1_introducers() {
for introducer in [0x90u8, 0x9d, 0x9e, 0x9f] {
let mut filter = super::TerminalFilter::new();
let seq = [introducer, b'p', b'!', 0x9c, b'o', b'k'];
assert_eq!(
filter.feed(&seq),
b"ok",
"introducer {introducer:#04x} not terminated by C1 ST"
);
}
}
#[test]
fn primary_filter_c1_st_split_across_reads() {
let mut filter = super::TerminalFilter::new();
assert_eq!(filter.feed(b"\x9d0;evil"), b"");
assert_eq!(filter.feed(b"title\x9cresumed"), b"resumed");
}
#[test]
fn primary_filter_output_resumes_after_c1_st() {
let mut filter = super::TerminalFilter::new();
let out = filter.feed(b"\x9fpayload\x9c\x1b[31mred");
assert_eq!(out, b"\x1b[31mred");
}
#[test]
fn primary_filter_c1_st_after_string_esc() {
let mut filter = super::TerminalFilter::new();
let out = filter.feed(b"\x9d0;t\x1b\x9cok");
assert_eq!(out, b"ok");
}
#[test]
fn terminal_reply_detection_is_conservative() {
assert!(super::looks_like_terminal_reply(b"\x1b[12;4R"));
assert!(!super::looks_like_terminal_reply(b"\x1b[A"));
}
#[test]
fn ground_state_plain_text() {
assert!(ends_at_ground_state(b"hello world"));
}
#[test]
fn ground_state_complete_csi() {
assert!(ends_at_ground_state(b"\x1b[31m"));
assert!(ends_at_ground_state(b"\x1b[38;2;255;0;0mRed"));
}
#[test]
fn ground_state_incomplete_csi() {
assert!(!ends_at_ground_state(b"\x1b[38;2;255"));
assert!(!ends_at_ground_state(b"\x1b["));
assert!(!ends_at_ground_state(b"\x1b"));
}
#[test]
fn ground_state_text_then_incomplete() {
assert!(!ends_at_ground_state(b"hello\x1b[31"));
}
#[test]
fn ground_state_osc_complete() {
assert!(ends_at_ground_state(b"\x1b]0;title\x07"));
assert!(ends_at_ground_state(b"\x1b]0;title\x1b\\"));
}
#[test]
fn ground_state_osc_incomplete() {
assert!(!ends_at_ground_state(b"\x1b]0;title"));
}
#[test]
fn ground_state_empty() {
assert!(ends_at_ground_state(b""));
}
#[test]
fn ground_state_single_char_escape() {
assert!(ends_at_ground_state(b"\x1b7"));
}
#[test]
fn ground_state_scroll_region_reset() {
assert!(ends_at_ground_state(b"\x1b[r"));
assert!(ends_at_ground_state(
b"\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\x1b[r"
));
}
#[test]
fn allocated_pty_resolves_a_slave_device_path() {
let pty = super::open().expect("openpty");
let path = pty.slave_path().expect("slave path");
assert!(
path.starts_with("/dev/"),
"unexpected slave device path: {}",
path.display()
);
assert!(
path.exists(),
"slave device does not exist: {}",
path.display()
);
}
#[test]
fn ground_state_cup_sequence() {
assert!(ends_at_ground_state(b"\x1b[12;1H"));
assert!(ends_at_ground_state(b"\x1b[1;80H"));
}
}