use bytes::Bytes;
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{debug, trace, warn};
use crate::errors::{Error, Result};
const STDIN_BUFFER: usize = 16 * 1024;
#[cfg(not(unix))]
const RESIZE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TerminalSize {
pub cols: u16,
pub rows: u16,
}
impl Default for TerminalSize {
fn default() -> Self {
Self { cols: 80, rows: 24 }
}
}
impl TerminalSize {
pub fn current() -> Self {
crossterm::terminal::size()
.map(|(cols, rows)| Self { cols, rows })
.unwrap_or_default()
}
}
#[must_use = "raw mode is restored as soon as the guard is dropped"]
#[derive(Debug)]
pub struct RawModeGuard {
was_raw: bool,
}
impl RawModeGuard {
pub fn enter() -> Result<Self> {
let was_raw = crossterm::terminal::is_raw_mode_enabled()?;
if !was_raw {
crossterm::terminal::enable_raw_mode()?;
}
Ok(Self { was_raw })
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if !self.was_raw {
if let Err(e) = crossterm::terminal::disable_raw_mode() {
eprintln!("warning: could not restore the terminal ({e}); run `reset`");
}
}
}
}
#[derive(Debug, Clone)]
pub enum TerminalEvent {
Input(Bytes),
Resize(TerminalSize),
Eof,
}
#[derive(Debug)]
pub struct TerminalReader {
events: mpsc::Receiver<TerminalEvent>,
running: Arc<AtomicBool>,
}
impl TerminalReader {
pub fn start() -> Self {
let (tx, events) = mpsc::channel(64);
let running = Arc::new(AtomicBool::new(true));
spawn_stdin_thread(tx.clone(), Arc::clone(&running));
spawn_resize_watcher(tx, Arc::clone(&running));
Self { events, running }
}
pub async fn next(&mut self) -> Option<TerminalEvent> {
self.events.recv().await
}
pub fn stop(&self) {
self.running.store(false, Ordering::Release);
}
}
impl Drop for TerminalReader {
fn drop(&mut self) {
self.stop();
}
}
fn spawn_stdin_thread(tx: mpsc::Sender<TerminalEvent>, running: Arc<AtomicBool>) {
std::thread::Builder::new()
.name("ssm-stdin".into())
.spawn(move || {
let mut stdin = io::stdin().lock();
let mut buf = vec![0u8; STDIN_BUFFER];
while running.load(Ordering::Acquire) {
match stdin.read(&mut buf) {
Ok(0) => {
debug!("stdin reached EOF");
let _ = tx.blocking_send(TerminalEvent::Eof);
break;
}
Ok(n) => {
trace!(bytes = n, "read from stdin");
let chunk = Bytes::copy_from_slice(&buf[..n]);
if tx.blocking_send(TerminalEvent::Input(chunk)).is_err() {
break; }
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => {
warn!(error = %e, "stdin read failed");
let _ = tx.blocking_send(TerminalEvent::Eof);
break;
}
}
}
debug!("stdin thread finished");
})
.expect("spawning the stdin thread must succeed");
}
#[cfg(unix)]
fn spawn_resize_watcher(tx: mpsc::Sender<TerminalEvent>, running: Arc<AtomicBool>) {
tokio::spawn(async move {
use tokio::signal::unix::{signal, SignalKind};
let mut winch = match signal(SignalKind::window_change()) {
Ok(stream) => stream,
Err(e) => {
warn!(error = %e, "could not watch SIGWINCH; resizes will not propagate");
return;
}
};
let mut last = TerminalSize::current();
while running.load(Ordering::Acquire) {
if winch.recv().await.is_none() {
break;
}
let size = TerminalSize::current();
if size != last {
last = size;
debug!(cols = size.cols, rows = size.rows, "terminal resized");
if tx.send(TerminalEvent::Resize(size)).await.is_err() {
break;
}
}
}
});
}
#[cfg(not(unix))]
fn spawn_resize_watcher(tx: mpsc::Sender<TerminalEvent>, running: Arc<AtomicBool>) {
tokio::spawn(async move {
let mut last = TerminalSize::current();
let mut ticker = tokio::time::interval(RESIZE_POLL_INTERVAL);
while running.load(Ordering::Acquire) {
ticker.tick().await;
let size = TerminalSize::current();
if size != last {
last = size;
if tx.send(TerminalEvent::Resize(size)).await.is_err() {
break;
}
}
}
});
}
pub fn write_output(data: &[u8]) -> Result<()> {
let mut stdout = io::stdout().lock();
stdout.write_all(data)?;
stdout.flush()?;
Ok(())
}
pub fn is_terminal() -> bool {
use std::io::IsTerminal;
io::stdin().is_terminal() && io::stdout().is_terminal()
}
pub(crate) fn require_terminal() -> Result<()> {
if is_terminal() {
Ok(())
} else {
Err(Error::Config(
"an interactive shell needs stdin and stdout attached to a terminal; \
use Session::send and Session::output for piped or headless use"
.into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_size_is_the_classic_terminal() {
assert_eq!(TerminalSize::default(), TerminalSize { cols: 80, rows: 24 });
}
#[test]
fn size_serializes_to_the_agent_wire_format() {
let json = serde_json::to_string(&TerminalSize {
cols: 120,
rows: 40,
})
.unwrap();
assert_eq!(json, r#"{"cols":120,"rows":40}"#);
}
#[test]
fn size_round_trips() {
let size = TerminalSize {
cols: 200,
rows: 60,
};
let json = serde_json::to_vec(&size).unwrap();
assert_eq!(serde_json::from_slice::<TerminalSize>(&json).unwrap(), size);
}
#[test]
fn require_terminal_refuses_a_non_tty() {
if !is_terminal() {
let err = require_terminal().unwrap_err();
assert!(err.to_string().contains("terminal"), "{err}");
}
}
#[test]
fn current_size_falls_back_without_a_terminal() {
let size = TerminalSize::current();
assert!(size.cols > 0 && size.rows > 0);
}
}