use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use crate::emu::{Emulator, InputModes, Query, Stop, Vt100Emulator};
use crate::error::{Error, Result};
use crate::keys::Input;
use crate::keys::{mouse_legacy, mouse_sgr};
use crate::screen::{MouseMode, Screen};
use crate::wait::{next_backoff, Expired, Monitor, INITIAL_BACKOFF, POLL_CAP};
const DRAIN_GRACE: Duration = Duration::from_millis(500);
static PTY_LIFECYCLE: Mutex<()> = Mutex::new(());
fn pty_lifecycle_guard() -> std::sync::MutexGuard<'static, ()> {
PTY_LIFECYCLE.lock().unwrap_or_else(PoisonError::into_inner)
}
type SharedWriter = Arc<Mutex<Option<Box<dyn Write + Send>>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scroll {
Up,
Down,
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Signal {
Int,
Term,
Hup,
Quit,
Usr1,
Usr2,
Kill,
}
#[cfg(unix)]
impl Signal {
fn raw(self) -> libc::c_int {
match self {
Signal::Int => libc::SIGINT,
Signal::Term => libc::SIGTERM,
Signal::Hup => libc::SIGHUP,
Signal::Quit => libc::SIGQUIT,
Signal::Usr1 => libc::SIGUSR1,
Signal::Usr2 => libc::SIGUSR2,
Signal::Kill => libc::SIGKILL,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitStatus {
code: u32,
success: bool,
signal: Option<Box<str>>,
}
impl ExitStatus {
fn from_pty(status: &portable_pty::ExitStatus) -> Self {
Self {
code: status.exit_code(),
success: status.success(),
signal: status.signal().map(Into::into),
}
}
#[must_use]
pub fn success(&self) -> bool {
self.success
}
#[must_use]
pub fn code(&self) -> u32 {
self.code
}
#[must_use]
pub fn signal(&self) -> Option<&str> {
self.signal.as_deref()
}
}
impl fmt::Display for ExitStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.signal {
Some(signal) => write!(f, "killed by signal: {signal} (code {})", self.code),
None => write!(f, "exit code {}", self.code),
}
}
}
struct EmuState {
emu: Box<dyn Emulator>,
last_activity: Instant,
eof: bool,
generation: u64,
snapshot_cache: Option<(u64, Screen)>,
frames_seen: u64,
last_frame: Option<Screen>,
respond: bool,
background: (u8, u8, u8),
unanswered: Option<String>,
}
impl EmuState {
fn new(emu: Box<dyn Emulator>, respond: bool, background: (u8, u8, u8)) -> Self {
Self {
emu,
last_activity: Instant::now(),
eof: false,
generation: 0,
snapshot_cache: None,
frames_seen: 0,
last_frame: None,
respond,
background,
unanswered: None,
}
}
fn answer(&mut self, query: &Query) -> Option<Vec<u8>> {
fn osc_color(code: u8, (r, g, b): (u8, u8, u8), st: bool) -> Vec<u8> {
let widen = |v: u8| u16::from(v) << 8 | u16::from(v);
let terminator = if st { "\x1b\\" } else { "\x07" };
format!(
"\x1b]{code};rgb:{:04x}/{:04x}/{:04x}{terminator}",
widen(r),
widen(g),
widen(b)
)
.into_bytes()
}
if !self.respond {
self.unanswered = Some(query_shape(query));
return None;
}
let reply = match query {
Query::CursorPosition { private } => {
let (row, col, _) = self.emu.snapshot().cursor();
let prefix = if *private { "?" } else { "" };
format!("\x1b[{prefix}{};{}R", row + 1, col + 1).into_bytes()
}
Query::OperatingStatus => b"\x1b[0n".to_vec(),
Query::PrimaryDa => b"\x1b[?62;22c".to_vec(),
Query::SecondaryDa => b"\x1b[>1;10;0c".to_vec(),
Query::TextAreaSize => {
let screen = self.emu.snapshot();
format!("\x1b[8;{};{}t", screen.rows(), screen.cols()).into_bytes()
}
Query::OscColor {
code: 11,
st_terminated,
} => osc_color(11, self.background, *st_terminated),
Query::OscColor {
code,
st_terminated,
} => osc_color(*code, (0xff, 0xff, 0xff), *st_terminated),
Query::Unanswerable(shape) => {
self.unanswered = Some(shape.clone());
return None;
}
};
Some(reply)
}
fn touch(&mut self) {
self.last_activity = Instant::now();
self.generation += 1;
}
fn snapshot(&mut self) -> Screen {
if let Some((generation, screen)) = &self.snapshot_cache {
if *generation == self.generation {
return screen.clone();
}
}
let screen = self.emu.snapshot();
self.snapshot_cache = Some((self.generation, screen.clone()));
screen
}
fn query_note(&self) -> String {
self.unanswered.as_ref().map_or_else(String::new, |shape| {
format!(
" — note: the application queried the terminal ({shape}) \
and received no answer; if it is blocked waiting for that \
reply, this is the cause"
)
})
}
fn peek_snapshot(&self) -> Screen {
if let Some((generation, screen)) = &self.snapshot_cache {
if *generation == self.generation {
return screen.clone();
}
}
self.emu.snapshot()
}
}
#[derive(Debug, Clone)]
pub struct TerminalBuilder {
cols: u16,
rows: u16,
timeout: Duration,
args: Vec<OsString>,
env_clear: bool,
envs: Vec<(OsString, OsString)>,
cwd: Option<PathBuf>,
answer_queries: bool,
background: (u8, u8, u8),
}
impl Default for TerminalBuilder {
fn default() -> Self {
Self {
cols: 80,
rows: 24,
timeout: Duration::from_secs(5),
args: Vec::new(),
env_clear: false,
envs: Vec::new(),
cwd: None,
answer_queries: true,
background: (0, 0, 0),
}
}
}
impl TerminalBuilder {
#[must_use]
pub fn size(mut self, cols: u16, rows: u16) -> Self {
self.cols = cols;
self.rows = rows;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
self.args.push(arg.as_ref().to_os_string());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.args
.extend(args.into_iter().map(|a| a.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.envs
.push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
self
}
#[must_use]
pub fn env_clear(mut self) -> Self {
self.env_clear = true;
self
}
#[must_use]
pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
self.cwd = Some(dir.as_ref().to_path_buf());
self
}
#[must_use]
pub fn answer_queries(mut self, answer: bool) -> Self {
self.answer_queries = answer;
self
}
#[must_use]
pub fn background_rgb(mut self, r: u8, g: u8, b: u8) -> Self {
self.background = (r, g, b);
self
}
pub fn spawn(self, program: impl AsRef<OsStr>) -> Result<Terminal> {
let program = program.as_ref();
let command_desc = std::iter::once(program)
.chain(self.args.iter().map(OsString::as_os_str))
.map(|s| s.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ");
let lifecycle = pty_lifecycle_guard();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: self.rows,
cols: self.cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| Error::Pty(format!("openpty failed: {e}")))?;
let mut cmd = CommandBuilder::new(program);
cmd.args(&self.args);
if let Some(dir) = &self.cwd {
cmd.cwd(dir);
}
if self.env_clear {
cmd.env_clear();
}
if !self.envs.iter().any(|(k, _)| k == "TERM") {
cmd.env("TERM", "xterm-256color");
}
for (key, value) in &self.envs {
cmd.env(key, value);
}
let reader = pair
.master
.try_clone_reader()
.map_err(|e| Error::Pty(format!("cloning PTY reader failed: {e}")))?;
let writer = pair
.master
.take_writer()
.map_err(|e| Error::Pty(format!("taking PTY writer failed: {e}")))?;
let shared = Arc::new(Monitor::new(EmuState::new(
Box::new(Vt100Emulator::new(self.rows, self.cols)),
self.answer_queries,
self.background,
)));
let writer: SharedWriter = Arc::new(Mutex::new(Some(writer)));
let reader_shared = Arc::clone(&shared);
let reader_writer = Arc::clone(&writer);
thread::Builder::new()
.name("termlens-pty-reader".into())
.spawn(move || reader_loop(reader, &reader_shared, &reader_writer))
.map_err(Error::Io)?;
let child = pair.slave.spawn_command(cmd).map_err(|e| Error::Spawn {
command: command_desc.clone(),
reason: e.to_string(),
})?;
drop(pair.slave);
drop(lifecycle);
Ok(Terminal {
child,
master: Some(pair.master),
writer,
shared,
default_timeout: self.timeout,
exit_status: None,
command_desc,
})
}
}
fn query_shape(query: &Query) -> String {
match query {
Query::CursorPosition { private: false } => "^[[6n".into(),
Query::CursorPosition { private: true } => "^[[?6n".into(),
Query::OperatingStatus => "^[[5n".into(),
Query::PrimaryDa => "^[[c".into(),
Query::SecondaryDa => "^[[>c".into(),
Query::TextAreaSize => "^[[18t".into(),
Query::OscColor { code, .. } => format!("^[]{code};?"),
Query::Unanswerable(shape) => shape.clone(),
}
}
fn reader_loop(
mut reader: Box<dyn Read + Send>,
shared: &Monitor<EmuState>,
writer: &SharedWriter,
) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let replies = shared.mutate(|state| {
let mut replies: Vec<Vec<u8>> = Vec::new();
let mut offset = 0;
while offset < n {
let processed = state.emu.process(&buf[offset..n]);
offset += processed.consumed;
match processed.stop {
Some(Stop::FrameComplete) => {
state.frames_seen += 1;
state.last_frame = Some(state.emu.snapshot());
}
Some(Stop::Query(query)) => {
if let Some(reply) = state.answer(&query) {
replies.push(reply);
}
}
None => {}
}
}
state.touch();
replies
});
for reply in replies {
let mut writer = writer.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(writer) = writer.as_mut() {
let _ = writer.write_all(&reply).and_then(|()| writer.flush());
}
}
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
shared.mutate(|state| {
state.eof = true;
state.touch();
});
}
pub struct Terminal {
child: Box<dyn portable_pty::Child + Send + Sync>,
master: Option<Box<dyn portable_pty::MasterPty + Send>>,
writer: SharedWriter,
shared: Arc<Monitor<EmuState>>,
default_timeout: Duration,
exit_status: Option<ExitStatus>,
command_desc: String,
}
impl Terminal {
#[must_use]
pub fn builder() -> TerminalBuilder {
TerminalBuilder::default()
}
#[must_use]
pub fn screen(&self) -> Screen {
self.shared.lock().snapshot()
}
pub fn send(&mut self, key: impl Input + fmt::Debug) {
let application_cursor = self.input_modes().application_cursor;
self.write_or_panic(&key.encode_modal(application_cursor), &format!("{key:?}"));
}
pub fn send_str(&mut self, s: &str) {
self.write_or_panic(s.as_bytes(), "literal text");
}
pub fn paste(&mut self, text: &str) {
if self.input_modes().bracketed_paste {
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
self.write_or_panic(&bytes, "a bracketed paste");
} else {
self.write_or_panic(text.as_bytes(), "a paste");
}
}
pub fn click(&mut self, col: u16, row: u16) -> Result<()> {
let modes = self.input_modes();
let press_only = match modes.mouse {
MouseMode::None => {
return Err(Error::Input(
"the application has not enabled mouse tracking \
(no CSI ?9/?1000/?1002/?1003 h was seen)"
.into(),
))
}
MouseMode::Press => true,
MouseMode::PressRelease | MouseMode::ButtonMotion | MouseMode::AnyMotion => false,
};
let mut bytes = self.mouse_report(&modes, 0, col, row, true)?;
if !press_only {
bytes.extend(self.mouse_report(&modes, 0, col, row, false)?);
}
self.write_or_panic(&bytes, "a mouse click");
Ok(())
}
pub fn scroll(&mut self, col: u16, row: u16, direction: Scroll) -> Result<()> {
let modes = self.input_modes();
if modes.mouse == MouseMode::None {
return Err(Error::Input(
"the application has not enabled mouse tracking \
(no CSI ?9/?1000/?1002/?1003 h was seen)"
.into(),
));
}
let button = match direction {
Scroll::Up => 64,
Scroll::Down => 65,
};
let bytes = self.mouse_report(&modes, button, col, row, true)?;
self.write_or_panic(&bytes, "a mouse scroll");
Ok(())
}
fn input_modes(&self) -> InputModes {
self.shared.lock().emu.input_modes()
}
fn mouse_report(
&self,
modes: &InputModes,
button: u8,
col: u16,
row: u16,
press: bool,
) -> Result<Vec<u8>> {
if modes.sgr_mouse {
return Ok(mouse_sgr(button, col, row, press));
}
if col > 222 || row > 222 {
return Err(Error::Input(format!(
"({col}, {row}) is unrepresentable in the legacy mouse \
encoding the application selected (max 222)"
)));
}
let button = if press { button } else { 3 };
Ok(mouse_legacy(button, col, row))
}
fn write_or_panic(&mut self, bytes: &[u8], what: &str) {
let result = {
let mut writer = self.writer.lock().unwrap_or_else(PoisonError::into_inner);
match writer.as_mut() {
Some(writer) => writer.write_all(bytes).and_then(|()| writer.flush()),
None => Err(io::Error::new(io::ErrorKind::BrokenPipe, "pty closed")),
}
};
if let Err(e) = result {
panic!(
"termlens: failed to send {what} to `{}` ({e})\n--- screen ---\n{}",
self.command_desc,
self.screen()
);
}
}
pub fn wait_until(&mut self, predicate: impl FnMut(&Screen) -> bool) -> Result<()> {
self.wait_until_deadline(predicate, self.default_timeout)
}
pub fn wait_until_for(
&mut self,
predicate: impl FnMut(&Screen) -> bool,
timeout: Duration,
) -> Result<()> {
self.wait_until_deadline(predicate, timeout)
}
fn wait_until_deadline(
&mut self,
mut predicate: impl FnMut(&Screen) -> bool,
timeout: Duration,
) -> Result<()> {
const WHAT: &str = "the screen predicate to hold";
let deadline = Instant::now() + timeout;
let mut seen_generation = None;
let outcome = self.shared.wait_until(deadline, |state| {
if seen_generation == Some(state.generation) {
return None;
}
seen_generation = Some(state.generation);
let screen = state.peek_snapshot();
if predicate(&screen) {
return Some(Ok(()));
}
if state.eof {
return Some(Err(Error::Eof {
waiting_for: WHAT.into(),
screen,
}));
}
None
});
match outcome {
Ok(inner) => inner,
Err(Expired) => Err(Error::Timeout {
waiting_for: format!("{WHAT}{}", self.shared.lock().query_note()),
timeout,
screen: self.screen(),
}),
}
}
pub fn wait_frame(&mut self, mut predicate: impl FnMut(&Screen) -> bool) -> Result<()> {
const WHAT: &str = "a complete frame matching the predicate";
let deadline = Instant::now() + self.default_timeout;
let mut seen_frame = None;
let outcome = self.shared.wait_until(deadline, |state| {
if state.frames_seen > 0 && seen_frame != Some(state.frames_seen) {
seen_frame = Some(state.frames_seen);
let frame = state
.last_frame
.clone()
.expect("frames_seen > 0 implies a stored frame");
if predicate(&frame) {
return Some(Ok(()));
}
}
if state.eof {
return Some(Err(Error::Eof {
waiting_for: WHAT.into(),
screen: state.peek_snapshot(),
}));
}
None
});
match outcome {
Ok(inner) => inner,
Err(Expired) => {
let (frames, screen) = {
let mut guard = self.shared.lock();
let screen = guard.last_frame.clone().unwrap_or_else(|| guard.snapshot());
(guard.frames_seen, screen)
};
let waiting_for = if frames == 0 {
"a complete frame — but the application never emitted a \
DEC 2026 synchronized update. wait_frame needs repaints \
bracketed in BeginSynchronizedUpdate/EndSynchronizedUpdate; \
for other apps use wait_until (docs/DESIGN.md §2)"
.to_owned()
} else {
format!("{WHAT} ({frames} complete frames observed)")
};
Err(Error::Timeout {
waiting_for,
timeout: self.default_timeout,
screen,
})
}
}
}
pub fn wait_idle(&mut self, quiet: Duration) -> Result<()> {
let deadline = Instant::now() + self.default_timeout;
let mut guard = self.shared.lock();
loop {
if guard.eof {
return Ok(());
}
let elapsed = guard.last_activity.elapsed();
if elapsed >= quiet && !guard.emu.mid_sequence() && !guard.emu.in_sync_update() {
return Ok(());
}
let now = Instant::now();
if now >= deadline {
let screen = guard.peek_snapshot();
let note = guard.query_note();
drop(guard);
return Err(Error::Timeout {
waiting_for: format!("{quiet:?} of output silence{note}"),
timeout: self.default_timeout,
screen,
});
}
let sleep = if elapsed < quiet {
quiet - elapsed
} else {
POLL_CAP
}
.min(deadline - now)
.max(Duration::from_millis(1));
guard = self.shared.wait_timeout(guard, sleep);
}
}
#[must_use]
pub fn pid(&self) -> Option<u32> {
self.child.process_id()
}
#[cfg(unix)]
pub fn signal(&mut self, signal: Signal) -> Result<()> {
if let Some(status) = &self.exit_status {
return Err(Error::Input(format!(
"cannot deliver {signal:?} to `{}`: it already exited ({status})",
self.command_desc
)));
}
let Some(pid) = self.pid() else {
return Err(Error::Input(format!(
"cannot deliver {signal:?} to `{}`: the platform reports no pid",
self.command_desc
)));
};
let pid = libc::pid_t::try_from(pid)
.map_err(|_| Error::Input(format!("pid {pid} exceeds the platform's pid range")))?;
#[allow(unsafe_code)]
let rc = unsafe { libc::kill(pid, signal.raw()) };
if rc == 0 {
Ok(())
} else {
Err(Error::Io(io::Error::last_os_error()))
}
}
pub fn wait_exit(&mut self) -> Result<ExitStatus> {
if let Some(status) = self.exit_status.clone() {
return Ok(status);
}
let deadline = Instant::now() + self.default_timeout;
let mut backoff = INITIAL_BACKOFF;
loop {
if let Some(status) = self.child.try_wait().map_err(Error::Io)? {
let status = ExitStatus::from_pty(&status);
self.exit_status = Some(status.clone());
let _ = self
.shared
.wait_until(Instant::now() + DRAIN_GRACE, |state| {
state.eof.then_some(())
});
return Ok(status);
}
let now = Instant::now();
if now >= deadline {
return Err(Error::Timeout {
waiting_for: format!(
"`{}` to exit{}",
self.command_desc,
self.shared.lock().query_note()
),
timeout: self.default_timeout,
screen: self.screen(),
});
}
thread::sleep(backoff.min(deadline - now));
backoff = next_backoff(backoff);
}
}
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
self.master
.as_ref()
.expect("master lives until drop")
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| Error::Pty(format!("resize failed: {e}")))?;
self.shared.mutate(|state| {
state.emu.set_size(rows, cols);
state.touch();
});
Ok(())
}
}
impl fmt::Debug for Terminal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Terminal")
.field("command", &self.command_desc)
.field("default_timeout", &self.default_timeout)
.field("exit_status", &self.exit_status)
.finish_non_exhaustive()
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _lifecycle = pty_lifecycle_guard();
if self.exit_status.is_none() {
let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
if !already_exited {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
drop(
self.writer
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take(),
);
drop(self.master.take());
}
}