Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,24 @@
[package]
license = "Apache-2.0"
name = "ptyctl"
version = "0.1.0"
edition.workspace = true
description = "Headless PTY controller built on alacritty_terminal"
publish = false
[dependencies]
alacritty_terminal = { workspace = true }
portable-pty = { workspace = true }
terminput = { workspace = true }
tokio = { workspace = true, features = ["full"] }
axum = { workspace = true, features = ["ws"] }
futures-util = { workspace = true }
tower-http = { workspace = true, features = ["cors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full", "test-util"] }

View file

@ -0,0 +1,193 @@
//! Vim-style key notation parser.
//!
//! Converts strings like `"hello<CR>"`, `"<C-c>"`, `"<Esc>:wq<CR>"` into
//! raw terminal byte sequences using the `terminput` crate.
use std::io;
use anyhow::{Result, bail};
use terminput::{Encoding, Event, KeyCode, KeyEvent, KeyModifiers};
/// Parse a vim-notation key string into raw terminal bytes.
///
/// # Examples
/// - `"hello"` -> literal bytes for h, e, l, l, o
/// - `"<CR>"` or `"<Enter>"` -> `\r`
/// - `"<C-c>"` -> Ctrl+C (0x03)
/// - `"<Esc>:wq<CR>"` -> ESC, :, w, q, CR
/// - `"<Up><Up><CR>"` -> up arrow, up arrow, CR
pub fn parse_keys(input: &str) -> Result<Vec<u8>> {
let events = parse_to_events(input)?;
let mut bytes = Vec::new();
let mut buf = [0u8; 64];
for event in events {
let ev = Event::Key(event);
match ev.encode(&mut buf, Encoding::Xterm) {
Ok(n) => bytes.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == io::ErrorKind::Unsupported => {
// Some keys may not be encodable; skip them.
}
Err(e) => return Err(e.into()),
}
}
Ok(bytes)
}
/// Parse vim notation into a sequence of `KeyEvent`s.
fn parse_to_events(input: &str) -> Result<Vec<KeyEvent>> {
let mut events = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch == '<' {
// Try to parse a special key notation.
let start_pos: String = chars.clone().collect();
if let Some(end) = start_pos.find('>') {
let notation = &start_pos[1..end]; // between < and >
// Consume chars including the >.
for _ in 0..=end {
chars.next();
}
let event = parse_special(notation)?;
events.push(event);
} else {
// No closing '>', treat '<' as literal.
chars.next();
events.push(key(KeyCode::Char('<'), KeyModifiers::NONE));
}
} else {
chars.next();
events.push(key(KeyCode::Char(ch), KeyModifiers::NONE));
}
}
Ok(events)
}
/// Helper to build a KeyEvent with modifiers.
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
let mut ev = KeyEvent::new(code);
ev.modifiers = modifiers;
ev
}
/// Parse the content between `<` and `>` as a special key.
fn parse_special(notation: &str) -> Result<KeyEvent> {
let lower = notation.to_lowercase();
// Parse modifiers: C-, M-/A-, S- (in any order).
let mut modifiers = KeyModifiers::NONE;
let mut remaining = lower.as_str();
loop {
if let Some(rest) = remaining.strip_prefix("c-") {
modifiers |= KeyModifiers::CTRL;
remaining = rest;
} else if let Some(rest) = remaining
.strip_prefix("m-")
.or(remaining.strip_prefix("a-"))
{
modifiers |= KeyModifiers::ALT;
remaining = rest;
} else if let Some(rest) = remaining.strip_prefix("s-") {
modifiers |= KeyModifiers::SHIFT;
remaining = rest;
} else {
break;
}
}
let code = match remaining {
"cr" | "enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"bs" | "backspace" => KeyCode::Backspace,
"tab" => KeyCode::Tab,
"space" | "spc" => KeyCode::Char(' '),
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" => KeyCode::PageDown,
"insert" | "ins" => KeyCode::Insert,
"delete" | "del" => KeyCode::Delete,
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
"lt" => KeyCode::Char('<'),
"gt" => KeyCode::Char('>'),
"bar" => KeyCode::Char('|'),
"bslash" => KeyCode::Char('\\'),
s if s.len() == 1 => {
let c = s.chars().next().unwrap();
// For Shift+letter with no other modifiers, uppercase it (vim behavior).
if modifiers == KeyModifiers::SHIFT && c.is_ascii_alphabetic() {
modifiers = KeyModifiers::NONE;
KeyCode::Char(c.to_ascii_uppercase())
} else {
KeyCode::Char(c)
}
}
_ => bail!("unknown key notation: <{notation}>"),
};
Ok(key(code, modifiers))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_literal_text() {
let bytes = parse_keys("hello").unwrap();
assert_eq!(bytes, b"hello");
}
#[test]
fn test_enter() {
let bytes = parse_keys("<CR>").unwrap();
assert_eq!(bytes, b"\r");
}
#[test]
fn test_escape() {
let bytes = parse_keys("<Esc>").unwrap();
assert_eq!(bytes, b"\x1b");
}
#[test]
fn test_ctrl_c() {
let bytes = parse_keys("<C-c>").unwrap();
assert_eq!(bytes, b"\x03");
}
#[test]
fn test_mixed() {
let bytes = parse_keys("hello<CR>").unwrap();
assert_eq!(&bytes[..5], b"hello");
assert_eq!(bytes[5], b'\r');
}
#[test]
fn test_case_insensitive() {
let a = parse_keys("<cr>").unwrap();
let b = parse_keys("<CR>").unwrap();
let c = parse_keys("<Cr>").unwrap();
assert_eq!(a, b);
assert_eq!(b, c);
}
}

View file

@ -0,0 +1,13 @@
//! ptyctl — Headless PTY controller built on alacritty_terminal.
//!
//! Provides programmatic control of terminal sessions: spawn processes
//! in a PTY, send keystrokes, read screen content as text/styled/HTML,
//! and expose it all via HTTP REST API.
pub mod keys;
pub mod pty;
pub mod server;
pub mod session;
pub mod styled;
pub mod term;
pub mod wait;

View file

@ -0,0 +1,155 @@
//! PTY wrapper using `portable-pty` for cross-platform pseudoterminal support.
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
/// Configuration for spawning a PTY session.
#[derive(Debug, Clone)]
pub struct PtyConfig {
/// Command and arguments to run.
pub command: Vec<String>,
/// Terminal width in columns.
pub cols: u16,
/// Terminal height in rows.
pub rows: u16,
/// Working directory.
pub cwd: Option<PathBuf>,
/// Additional environment variables.
pub env: HashMap<String, String>,
}
/// Handle to a running PTY session.
pub struct PtyHandle {
master: Box<dyn MasterPty + Send>,
child: Box<dyn portable_pty::Child + Send>,
reader: Box<dyn Read + Send>,
writer: Box<dyn Write + Send>,
}
/// Resize-capable master half of a dismantled [`PtyHandle`].
///
/// portable-pty's unix master is not `Sync` (interior `RefCell`), so it sits
/// behind a mutex that is only held for the synchronous resize ioctl.
pub struct PtyMaster {
master: std::sync::Mutex<Box<dyn MasterPty + Send>>,
}
impl PtyMaster {
/// Resize the PTY (TIOCSWINSZ; the kernel delivers SIGWINCH to the child).
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
self.master
.lock()
.unwrap()
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("failed to resize PTY")
}
}
/// Child-process half of a dismantled [`PtyHandle`].
pub struct PtyChild {
child: Box<dyn portable_pty::Child + Send>,
}
impl PtyChild {
/// Check if the child process is still alive.
pub fn is_alive(&mut self) -> bool {
self.child.try_wait().ok().flatten().is_none()
}
/// Get the child process ID.
pub fn pid(&self) -> Option<u32> {
self.child.process_id()
}
/// Wait for the child to exit and return the exit code.
pub fn wait(&mut self) -> Result<u32> {
let status = self.child.wait().context("failed to wait for child")?;
Ok(status.exit_code())
}
/// Kill the child process.
pub fn kill(&mut self) -> Result<()> {
self.child.kill().context("failed to kill child process")
}
}
impl PtyHandle {
/// Spawn a new process in a PTY.
pub fn spawn(config: &PtyConfig) -> Result<Self> {
let pty_system = native_pty_system();
let pty_size = PtySize {
rows: config.rows,
cols: config.cols,
pixel_width: 0,
pixel_height: 0,
};
let pair = pty_system.openpty(pty_size).context("failed to open PTY")?;
let mut cmd = CommandBuilder::new(&config.command[0]);
if config.command.len() > 1 {
cmd.args(&config.command[1..]);
}
if let Some(ref cwd) = config.cwd {
cmd.cwd(cwd);
}
for (key, value) in &config.env {
cmd.env(key, value);
}
// Set TERM for proper terminal detection.
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let child = pair
.slave
.spawn_command(cmd)
.context("failed to spawn command in PTY")?;
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")?;
Ok(Self {
master: pair.master,
child,
reader,
writer,
})
}
/// Dismantle into the master half (kept for resize), the child half
/// (moved into a waiter task), and the reader/writer streams.
pub fn into_parts(
self,
) -> (
PtyMaster,
PtyChild,
Box<dyn Read + Send>,
Box<dyn Write + Send>,
) {
(
PtyMaster {
master: std::sync::Mutex::new(self.master),
},
PtyChild { child: self.child },
self.reader,
self.writer,
)
}
}

View file

@ -0,0 +1,495 @@
//! HTTP + WebSocket server exposing PTY session control.
use std::sync::Arc;
use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
use crate::session::{PtySession, WaitCondition, WaitOutcome};
use crate::term::ScreenOpts;
/// Shared application state.
type AppState = Arc<Mutex<PtySession>>;
/// Query parameters for screen endpoint.
#[derive(Debug, serde::Deserialize, Default)]
pub struct ScreenParams {
/// Row range, 1-indexed (e.g. "1:5", "5:", ":10", "5").
pub rows: Option<String>,
/// Column range, 1-indexed.
pub cols: Option<String>,
/// Character to show at cursor position.
pub cursor: Option<char>,
/// Output format: "text" (default), "styled", "html".
pub format: Option<String>,
/// Include trailing empty lines.
pub full: Option<bool>,
}
/// Request body for send endpoint.
#[derive(Debug, serde::Deserialize)]
pub struct SendRequest {
pub keys: String,
}
/// Request body for resize endpoint.
#[derive(Debug, serde::Deserialize)]
pub struct ResizeRequest {
pub cols: u16,
pub rows: u16,
}
/// Query parameters for scrollback endpoint.
#[derive(Debug, serde::Deserialize, Default)]
pub struct ScrollbackParams {
/// Number of scrollback lines to return (default: 100).
pub lines: Option<usize>,
}
/// Default wait timeout in milliseconds.
const WAIT_DEFAULT_TIMEOUT_MS: u64 = 10_000;
/// Maximum wait timeout in milliseconds.
const WAIT_MAX_TIMEOUT_MS: u64 = 120_000;
/// Query parameters for wait endpoint — exactly one condition must be set.
#[derive(Debug, serde::Deserialize, Default)]
pub struct WaitParams {
/// Wait until this text appears on screen.
pub text: Option<String>,
/// Wait until this regex matches the screen text.
pub regex: Option<String>,
/// Wait until this text is absent from the screen.
pub gone: Option<String>,
/// Wait until the grid has been unchanged for this many milliseconds.
pub stable_ms: Option<u64>,
/// Timeout in milliseconds (default 10000, capped at 120000).
pub timeout_ms: Option<u64>,
}
/// Incoming WebSocket message from client.
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "type")]
enum WsClientMessage {
/// Send raw input text to the PTY.
#[serde(rename = "input")]
Input { data: String },
/// Send vim-notation keys to the PTY.
#[serde(rename = "keys")]
Keys { keys: String },
/// Resize the terminal.
#[serde(rename = "resize")]
Resize { cols: u16, rows: u16 },
}
/// Build the axum router.
pub fn build_router(session: PtySession) -> Router {
let state: AppState = Arc::new(Mutex::new(session));
Router::new()
.route("/query/screen", get(handle_screen))
.route("/query/cursor", get(handle_cursor))
.route("/query/status", get(handle_status))
.route("/query/scrollback", get(handle_scrollback))
// Top-level on purpose: wait is a synchronization primitive, neither a /query read nor a /control mutation.
.route("/wait", get(handle_wait))
.route("/control/send", post(handle_send))
.route("/control/resize", post(handle_resize))
.route("/control/stop", post(handle_stop))
.route("/ws", get(handle_ws_upgrade))
.layer(CorsLayer::very_permissive())
.with_state(state)
}
/// Parse a range string like "1:5", "5:", ":10", "5" into a Range<usize>.
fn parse_range(s: &str) -> Option<std::ops::Range<usize>> {
if let Some((a, b)) = s.split_once(':') {
let start = if a.is_empty() {
1
} else {
a.parse::<usize>().ok()?
};
let end = if b.is_empty() {
usize::MAX
} else {
b.parse::<usize>().ok()?
};
Some(start..end)
} else {
let n = s.parse::<usize>().ok()?;
Some(n..n + 1)
}
}
fn build_screen_opts(params: &ScreenParams) -> ScreenOpts {
ScreenOpts {
rows: params.rows.as_deref().and_then(parse_range),
cols: params.cols.as_deref().and_then(parse_range),
cursor_char: params.cursor,
include_empty: params.full.unwrap_or(false),
}
}
async fn handle_screen(
State(state): State<AppState>,
Query(params): Query<ScreenParams>,
) -> Response {
let session = state.lock().await;
let opts = build_screen_opts(&params);
let format = params.format.as_deref().unwrap_or("text");
match format {
"styled" => {
let styled = session.screen_styled(&opts).await;
Json(styled).into_response()
}
"html" => {
let html = session.screen_html(&opts).await;
([(axum::http::header::CONTENT_TYPE, "text/html")], html).into_response()
}
_ => {
let output = session.screen(&opts).await;
Json(output).into_response()
}
}
}
async fn handle_cursor(State(state): State<AppState>) -> Json<serde_json::Value> {
let session = state.lock().await;
let cursor = session.cursor().await;
Json(serde_json::json!({
"row": cursor.row,
"col": cursor.col,
}))
}
async fn handle_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let session = state.lock().await;
let status = session.status().await;
Json(serde_json::json!({
"alive": status.alive,
"pid": status.pid,
"exit_code": status.exit_code,
"size": [status.size.0, status.size.1],
"modes": status.modes,
"scrollback_lines": status.scrollback_lines,
}))
}
async fn handle_send(
State(state): State<AppState>,
Json(req): Json<SendRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let session = state.lock().await;
match session.send_keys(&req.keys).await {
Ok(()) => Ok(Json(serde_json::json!({"ok": true}))),
Err(e) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": e.to_string()})),
)),
}
}
async fn handle_resize(
State(state): State<AppState>,
Json(req): Json<ResizeRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let session = state.lock().await;
match session.resize(req.cols, req.rows).await {
Ok(()) => Ok(Json(serde_json::json!({"ok": true}))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)),
}
}
/// Long-poll until a screen condition is met or the timeout elapses.
///
/// Timeout is a normal outcome (200 with `matched: false` + diagnostics),
/// not an error — `--gone`/`--stable_ms` waits time out routinely.
async fn handle_wait(
State(state): State<AppState>,
Query(params): Query<WaitParams>,
) -> Result<Json<WaitOutcome>, (StatusCode, Json<serde_json::Value>)> {
let mut conditions = Vec::new();
if let Some(text) = params.text {
conditions.push(WaitCondition::Text(text));
}
if let Some(pattern) = params.regex {
conditions.push(WaitCondition::Regex(pattern));
}
if let Some(text) = params.gone {
conditions.push(WaitCondition::Gone(text));
}
if let Some(ms) = params.stable_ms {
conditions.push(WaitCondition::StableMs(ms));
}
let (Some(condition), true) = (conditions.pop(), conditions.is_empty()) else {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "exactly one of text, regex, gone, stable_ms is required"
})),
));
};
let timeout_ms = params
.timeout_ms
.unwrap_or(WAIT_DEFAULT_TIMEOUT_MS)
.min(WAIT_MAX_TIMEOUT_MS);
// Clone the wait handles and drop the session guard, or send/screen would block for the whole wait.
let handle = state.lock().await.wait_handle();
match handle
.wait_for(condition, std::time::Duration::from_millis(timeout_ms))
.await
{
Ok(outcome) => Ok(Json(outcome)),
// Alternate format keeps the regex parse detail from the error chain.
Err(e) => Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e:#}")})),
)),
}
}
async fn handle_stop(State(state): State<AppState>) -> Json<serde_json::Value> {
let mut session = state.lock().await;
let _ = session.stop().await;
Json(serde_json::json!({"ok": true}))
}
// -- Scrollback --
async fn handle_scrollback(
State(state): State<AppState>,
Query(params): Query<ScrollbackParams>,
) -> Json<serde_json::Value> {
let session = state.lock().await;
let count = params.lines.unwrap_or(100);
let lines = session.scrollback(count).await;
Json(serde_json::json!({
"count": lines.len(),
"lines": lines,
}))
}
// -- WebSocket streaming --
/// Upgrade an HTTP request to a WebSocket connection.
async fn handle_ws_upgrade(
State(state): State<AppState>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws_connection(socket, state))
}
/// Handle a single WebSocket connection.
///
/// Protocol:
///
/// **Server -> Client:**
/// - Binary frames: raw PTY output bytes (high-throughput streaming)
/// - Text frames: JSON `{"type":"closed","exit_code":N}` on process exit
///
/// **Client -> Server:**
/// - Binary frames: raw bytes written to PTY stdin
/// - Text frames: JSON with `type` field:
/// - `{"type":"input","data":"text"}` — send text to PTY
/// - `{"type":"keys","keys":"<C-c>"}` — vim-notation keystrokes
/// - `{"type":"resize","cols":120,"rows":40}` — resize terminal
async fn handle_ws_connection(socket: WebSocket, state: AppState) {
let (mut ws_tx, mut ws_rx) = socket.split();
// Subscribe to the PTY output broadcast channel.
let session = state.lock().await;
let mut output_rx = session.subscribe();
drop(session);
// Task: forward PTY output -> WebSocket (binary frames).
let state_output = state.clone();
let mut send_task = tokio::spawn(async move {
loop {
match output_rx.recv().await {
Ok(bytes) => {
if ws_tx.send(Message::Binary(bytes.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Client is too slow; send a warning and continue.
let msg = serde_json::json!({
"type": "warning",
"message": format!("dropped {n} output chunks (slow consumer)"),
});
let _ = ws_tx.send(Message::Text(msg.to_string().into())).await;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
// PTY output channel closed — process likely exited.
let session = state_output.lock().await;
let status = session.status().await;
let msg = serde_json::json!({
"type": "closed",
"exit_code": status.exit_code,
});
let _ = ws_tx.send(Message::Text(msg.to_string().into())).await;
break;
}
}
}
});
// Task: receive WebSocket messages -> PTY input / control.
let state_input = state.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await {
match msg {
Message::Binary(bytes) => {
// Raw binary input -> PTY.
let session = state_input.lock().await;
let _ = session.send_bytes(&bytes).await;
}
Message::Text(text) => {
// JSON-structured command.
if let Ok(cmd) = serde_json::from_str::<WsClientMessage>(&text) {
let session = state_input.lock().await;
match cmd {
WsClientMessage::Input { data } => {
let _ = session.send_bytes(data.as_bytes()).await;
}
WsClientMessage::Keys { keys: notation } => {
let _ = session.send_keys(&notation).await;
}
WsClientMessage::Resize { cols, rows } => {
let _ = session.resize(cols, rows).await;
}
}
}
}
Message::Close(_) => break,
_ => {}
}
}
});
// Wait for either task to finish, then abort the other.
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
}
}
#[cfg(all(test, unix))]
mod tests {
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::session::tests::start_session;
/// Send one raw HTTP/1.1 request and read the full response (Connection: close).
async fn http(port: u16, request: &str) -> String {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.expect("connect failed");
stream
.write_all(request.as_bytes())
.await
.expect("write failed");
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.await
.expect("read failed");
String::from_utf8_lossy(&response).into_owned()
}
fn get(path: &str) -> String {
format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
}
fn post_json(path: &str, body: &str) -> String {
format!(
"POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
/// A long-poll /wait must not hold the session mutex: the send and screen
/// requests served mid-wait are exactly what make the wait complete.
#[tokio::test(flavor = "multi_thread")]
async fn wait_does_not_block_other_endpoints() {
let session = start_session(vec!["/bin/sh".into()]).await;
let router = super::build_router(session);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let start = Instant::now();
// Long-poll for text that only appears if /control/send gets through mid-wait.
let wait_task = tokio::spawn(async move {
http(port, &get("/wait?text=WAIT_DONE_77&timeout_ms=30000")).await
});
// /query/screen must answer while the wait is in flight (deadline-polled).
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let resp = http(port, &get("/query/screen")).await;
if resp.starts_with("HTTP/1.1 200") {
break;
}
assert!(
Instant::now() < deadline,
"screen not served during wait: {resp}"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
let resp = http(
port,
&post_json("/control/send", r#"{"keys":"echo WAIT_DONE_77<CR>"}"#),
)
.await;
assert!(
resp.starts_with("HTTP/1.1 200"),
"send not served during wait: {resp}"
);
let wait_resp = wait_task.await.unwrap();
assert!(wait_resp.starts_with("HTTP/1.1 200"), "{wait_resp}");
assert!(wait_resp.contains(r#""matched":true"#), "{wait_resp}");
// A deadlocked handler could only return at its 30s timeout.
assert!(
start.elapsed() < Duration::from_secs(10),
"wait took {:?}",
start.elapsed()
);
// Zero or multiple conditions are usage errors.
let resp = http(port, &get("/wait")).await;
assert!(resp.starts_with("HTTP/1.1 400"), "{resp}");
let resp = http(port, &get("/wait?text=a&gone=b")).await;
assert!(resp.starts_with("HTTP/1.1 400"), "{resp}");
// Bounded shutdown so the shell doesn't outlive the test.
let resp = http(port, &post_json("/control/send", r#"{"keys":"exit<CR>"}"#)).await;
assert!(resp.starts_with("HTTP/1.1 200"), "{resp}");
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let resp = http(port, &get("/query/status")).await;
if resp.contains(r#""alive":false"#) {
break;
}
assert!(Instant::now() < deadline, "shell did not exit: {resp}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}

View file

@ -0,0 +1,606 @@
//! Core PTY session — ties PTY + Terminal + I/O channels together.
use std::collections::VecDeque;
use std::io::Read;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::sync::{Mutex, broadcast, mpsc, watch};
use crate::keys;
use crate::pty::{PtyConfig, PtyHandle, PtyMaster};
use crate::styled::StyledLine;
use crate::term::{
CursorPosition, ScreenOpts, ScreenOutput, ScrollbackLine, SessionListener, Terminal,
TerminalModes,
};
// Re-exported so existing `session::Wait*` paths keep working after the wait-module extraction.
use crate::wait::{RAW_TAIL_CAP, push_raw_tail};
pub use crate::wait::{WaitCondition, WaitDiagnostics, WaitHandle, WaitOutcome};
/// Configuration for starting a session.
#[derive(Debug, Clone)]
pub struct SessionConfig {
pub pty: PtyConfig,
/// Auto-shutdown timeout in seconds (None = no timeout).
pub timeout: Option<u64>,
/// Keep server running after child exits.
pub linger: bool,
}
/// Status of a PTY session.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SessionStatus {
pub alive: bool,
pub pid: Option<u32>,
pub exit_code: Option<u32>,
pub size: (u16, u16),
pub modes: TerminalModes,
pub scrollback_lines: usize,
}
/// A running PTY session.
pub struct PtySession {
terminal: Arc<Mutex<Terminal>>,
/// Master half of the PTY, kept here so resize reaches the real PTY.
master: PtyMaster,
pty_write_tx: mpsc::UnboundedSender<Vec<u8>>,
alive: Arc<AtomicBool>,
exit_code: Arc<std::sync::Mutex<Option<u32>>>,
pid: Option<u32>,
/// Grid generation counter, bumped by the feeder after each `term.feed()`.
generation_rx: watch::Receiver<u64>,
/// Weak so the feeder's exit still drops the sender, signalling "ended" to waiters;
/// lets `resize` bump the generation too (a resize changes the grid without output).
generation_tx: Weak<watch::Sender<u64>>,
/// Last [`RAW_TAIL_CAP`] bytes of raw PTY output for wait-timeout diagnostics.
raw_tail: Arc<std::sync::Mutex<VecDeque<u8>>>,
_shutdown_tx: Option<mpsc::Sender<()>>,
/// Broadcast channel for real-time PTY output streaming (WebSocket).
output_tx: broadcast::Sender<Vec<u8>>,
}
impl PtySession {
/// Start a new PTY session.
pub async fn start(config: SessionConfig) -> Result<Self> {
let cols = config.pty.cols;
let rows = config.pty.rows;
// Spawn the PTY process; keep the master half for resize, only the child half moves into the waiter task.
let pty = PtyHandle::spawn(&config.pty).context("failed to spawn PTY")?;
let (master, mut child, mut reader, mut writer) = pty.into_parts();
let pid = child.pid();
// Channel for terminal-generated PtyWrite responses.
let (pty_response_tx, mut pty_response_rx) = mpsc::unbounded_channel::<Vec<u8>>();
// Channel for user-initiated writes (send_keys, send_bytes).
let (pty_write_tx, mut pty_write_rx) = mpsc::unbounded_channel::<Vec<u8>>();
// Channel for PTY reader -> terminal feeder.
let (pty_read_tx, mut pty_read_rx) = mpsc::unbounded_channel::<Vec<u8>>();
// Broadcast channel for WebSocket streaming (capacity: 256 chunks).
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
// Grid-generation watch for event-driven waits (watch over Notify: check-then-wait is lost-wakeup-free).
// The feeder holds the only strong Arc so its exit still drops the sender ("ended" signal).
let (generation_tx, generation_rx) = watch::channel::<u64>(0);
let generation_tx = Arc::new(generation_tx);
let generation_tx_weak = Arc::downgrade(&generation_tx);
let raw_tail: Arc<std::sync::Mutex<VecDeque<u8>>> =
Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(RAW_TAIL_CAP)));
// Shutdown signal.
let (shutdown_tx, _shutdown_rx) = mpsc::channel::<()>(1);
// Create the terminal.
let listener = SessionListener::new(pty_response_tx);
let terminal = Arc::new(Mutex::new(Terminal::new(cols, rows, listener)));
let alive = Arc::new(AtomicBool::new(true));
let exit_code: Arc<std::sync::Mutex<Option<u32>>> = Arc::new(std::sync::Mutex::new(None));
// --- PTY Reader Thread (blocking) ---
let alive_reader = alive.clone();
std::thread::Builder::new()
.name("pty-reader".into())
.spawn(move || {
let mut buf = [0u8; 65536];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if pty_read_tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(e) => {
if e.kind() != std::io::ErrorKind::WouldBlock {
log::debug!("PTY read error: {e}");
break;
}
}
}
}
alive_reader.store(false, Ordering::SeqCst);
})
.context("failed to spawn PTY reader thread")?;
// --- PTY Writer Task (async) ---
tokio::spawn(async move {
loop {
tokio::select! {
Some(bytes) = pty_write_rx.recv() => {
if let Err(e) = std::io::Write::write_all(&mut writer, &bytes) {
log::debug!("PTY write error: {e}");
break;
}
let _ = std::io::Write::flush(&mut writer);
}
Some(bytes) = pty_response_rx.recv() => {
if let Err(e) = std::io::Write::write_all(&mut writer, &bytes) {
log::debug!("PTY response write error: {e}");
break;
}
let _ = std::io::Write::flush(&mut writer);
}
else => break,
}
}
});
// --- Terminal Feeder Task (async) ---
let terminal_feeder = terminal.clone();
let output_tx_feeder = output_tx.clone();
let raw_tail_feeder = raw_tail.clone();
tokio::spawn(async move {
while let Some(bytes) = pty_read_rx.recv().await {
// Broadcast raw PTY output to all WebSocket subscribers.
// Ignore errors (no active subscribers is fine).
let _ = output_tx_feeder.send(bytes.clone());
push_raw_tail(&raw_tail_feeder, &bytes);
{
let mut term = terminal_feeder.lock().await;
term.feed(&bytes);
}
// Bump only after term.feed(): a waiter woken by the broadcast above would read a stale grid.
generation_tx.send_modify(|g| *g += 1);
}
});
// --- Child Process Waiter ---
let alive_waiter = alive.clone();
let exit_code_waiter = exit_code.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
if !alive_waiter.load(Ordering::SeqCst) {
break;
}
if !child.is_alive() {
if let Ok(code) = child.wait() {
*exit_code_waiter.lock().unwrap() = Some(code);
}
alive_waiter.store(false, Ordering::SeqCst);
break;
}
}
});
Ok(Self {
terminal,
master,
pty_write_tx,
alive,
exit_code,
pid,
generation_rx,
generation_tx: generation_tx_weak,
raw_tail,
_shutdown_tx: Some(shutdown_tx),
output_tx,
})
}
/// Send keystrokes using vim notation (e.g. `"<C-c>"`, `"hello<CR>"`).
pub async fn send_keys(&self, notation: &str) -> Result<()> {
let bytes = keys::parse_keys(notation)?;
self.send_bytes(&bytes).await
}
/// Send raw bytes to the PTY.
pub async fn send_bytes(&self, bytes: &[u8]) -> Result<()> {
self.pty_write_tx
.send(bytes.to_vec())
.map_err(|_| anyhow::anyhow!("PTY write channel closed"))
}
/// Read screen content as plain text.
pub async fn screen(&self, opts: &ScreenOpts) -> ScreenOutput {
let term = self.terminal.lock().await;
term.screen_content(opts)
}
/// Read screen content with style information.
pub async fn screen_styled(&self, opts: &ScreenOpts) -> Vec<StyledLine> {
let term = self.terminal.lock().await;
term.screen_styled(opts)
}
/// Read screen content as HTML.
pub async fn screen_html(&self, opts: &ScreenOpts) -> String {
let term = self.terminal.lock().await;
term.screen_html(opts)
}
/// Get cursor position (1-indexed).
pub async fn cursor(&self) -> CursorPosition {
let term = self.terminal.lock().await;
term.cursor_position()
}
/// Get session status (basic info, no terminal lock needed).
pub fn status_basic(&self) -> (bool, Option<u32>, Option<u32>) {
(
self.alive.load(Ordering::SeqCst),
self.pid,
*self.exit_code.lock().unwrap(),
)
}
/// Get full session status including terminal modes.
pub async fn status(&self) -> SessionStatus {
let (alive, pid, exit_code) = self.status_basic();
let term = self.terminal.lock().await;
// Size comes from the grid under the same lock resize holds, so it can never be torn.
let size = term.size();
SessionStatus {
alive,
pid,
exit_code,
size: (size.cols as u16, size.rows as u16),
modes: term.terminal_modes(),
scrollback_lines: term.scrollback_count(),
}
}
/// Resize the real PTY and the terminal grid.
pub async fn resize(&self, cols: u16, rows: u16) -> Result<()> {
let mut term = self.terminal.lock().await;
// PTY first (fail-fast); the held terminal lock keeps the SIGWINCH redraw out of a stale grid.
self.master.resize(cols, rows)?;
term.resize(cols, rows);
// Bump after the grid resize (matching the feeder's post-feed ordering): reflow/clipping
// changes screen text, so in-flight waits must re-check — and StableMs must restart.
if let Some(generation_tx) = self.generation_tx.upgrade() {
generation_tx.send_modify(|g| *g += 1);
}
Ok(())
}
/// Check if the child process is still alive.
pub fn is_alive(&self) -> bool {
self.alive.load(Ordering::SeqCst)
}
/// Subscribe to real-time PTY output for WebSocket streaming.
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
self.output_tx.subscribe()
}
/// Read scrollback history lines.
pub async fn scrollback(&self, count: usize) -> Vec<ScrollbackLine> {
let term = self.terminal.lock().await;
term.scrollback_lines(count)
}
/// Clone the handles needed to wait without holding any outer session lock.
pub fn wait_handle(&self) -> WaitHandle {
WaitHandle {
terminal: self.terminal.clone(),
generation_rx: self.generation_rx.clone(),
raw_tail: self.raw_tail.clone(),
}
}
/// Wait until `condition` is met or `timeout` elapses.
pub async fn wait_for(
&self,
condition: WaitCondition,
timeout: Duration,
) -> Result<WaitOutcome> {
self.wait_handle().wait_for(condition, timeout).await
}
/// Stop the session.
pub async fn stop(&mut self) -> Result<()> {
if let Some(tx) = self._shutdown_tx.take() {
let _ = tx.send(()).await;
}
Ok(())
}
}
#[cfg(all(test, unix))]
pub(crate) mod tests {
use std::collections::HashMap;
use std::time::{Duration, Instant};
use super::*;
/// Start a session running `command` at 80x24.
pub(crate) async fn start_session(command: Vec<String>) -> PtySession {
PtySession::start(SessionConfig {
pty: PtyConfig {
command,
cols: 80,
rows: 24,
cwd: None,
env: HashMap::new(),
},
timeout: None,
linger: false,
})
.await
.expect("failed to start session")
}
/// Send `keys` then wait (bounded) for the child to exit, so the shell and
/// reader thread don't outlive the test.
pub(crate) async fn shutdown(session: &PtySession, keys: &str) {
session.send_keys(keys).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while session.is_alive() {
assert!(
Instant::now() < deadline,
"child did not exit after {keys:?}"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// Poll the screen until `pred` matches, panicking with the last screen on timeout.
async fn wait_for_screen(session: &PtySession, pred: impl Fn(&str) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let screen = session.screen(&ScreenOpts::default()).await;
let text = screen.lines.join("\n");
if pred(&text) {
return;
}
assert!(
Instant::now() < deadline,
"timed out waiting for screen; last screen:\n{text}"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// The child must observe a resize on its own TTY (TIOCSWINSZ), not just the emulator grid.
#[tokio::test(flavor = "multi_thread")]
async fn resize_reaches_child_process() {
let session = start_session(vec!["/bin/sh".into()]).await;
// `stty size` prints "rows cols" as reported by the child's TTY.
session.send_keys("stty size<CR>").await.unwrap();
wait_for_screen(&session, |s| s.contains("24 80")).await;
session.resize(120, 40).await.unwrap();
let screen = session.screen(&ScreenOpts::default()).await;
assert_eq!((screen.size.cols, screen.size.rows), (120, 40));
session.send_keys("stty size<CR>").await.unwrap();
wait_for_screen(&session, |s| s.contains("40 120")).await;
shutdown(&session, "exit<CR>").await;
}
/// resize() must bump the wait generation: a resize reflows/clips the grid without
/// any PTY output, so in-flight waits would otherwise sleep through the change.
#[tokio::test(flavor = "multi_thread")]
async fn resize_bumps_wait_generation() {
// A child that never writes: the resize is the only possible generation bump.
let session = start_session(vec!["/bin/sleep".into(), "30".into()]).await;
let mut generation_rx = session.wait_handle().generation_rx;
let before = *generation_rx.borrow_and_update();
session.resize(120, 40).await.unwrap();
// The bump happens inside resize(), so it is visible as soon as resize returns.
assert!(
*generation_rx.borrow() > before,
"resize did not bump the wait generation (still {before})"
);
shutdown(&session, "<C-c>").await;
}
/// wait_for(Text) returns as soon as delayed output lands, not at the timeout.
#[tokio::test(flavor = "multi_thread")]
async fn wait_text_matches_delayed_output() {
let session = start_session(vec![
"/bin/sh".into(),
"-c".into(),
"sleep 0.3; echo READY; sleep 30".into(),
])
.await;
let outcome = session
.wait_for(WaitCondition::Text("READY".into()), Duration::from_secs(10))
.await
.unwrap();
assert!(outcome.matched, "expected match: {outcome:?}");
assert!(outcome.diagnostics.is_none());
// Event-driven completion: far below the 10s timeout despite the delayed echo.
assert!(
outcome.elapsed_ms < 5000,
"elapsed_ms = {}",
outcome.elapsed_ms
);
// Interrupt the trailing `sleep 30` so the child exits.
shutdown(&session, "<C-c>").await;
}
/// A wait for absent text times out at the deadline and carries the diagnostic snapshot.
#[tokio::test(flavor = "multi_thread")]
async fn wait_timeout_carries_diagnostics() {
let session = start_session(vec!["/bin/sh".into()]).await;
session
.send_keys("echo hello-from-the-shell<CR>")
.await
.unwrap();
wait_for_screen(&session, |s| s.contains("hello-from-the-shell")).await;
let outcome = session
.wait_for(
WaitCondition::Text("NEVER_APPEARS_123".into()),
Duration::from_millis(1000),
)
.await
.unwrap();
assert!(!outcome.matched);
// Timed out at the deadline (with scheduler tolerance), neither early nor far late.
assert!(
(950..4000).contains(&outcome.elapsed_ms),
"elapsed_ms = {}",
outcome.elapsed_ms
);
let diag = outcome.diagnostics.expect("timeout must carry diagnostics");
assert!(diag.screen.contains("hello-from-the-shell"));
assert!(diag.raw_tail.contains("hello-from-the-shell"));
assert!(diag.generation > 0);
assert!(diag.cursor.row >= 1);
assert!(!diag.ended, "deadline timeout must not be flagged as ended");
// Once the child exits the grid is final: the wait fails fast, flagged `ended`.
shutdown(&session, "exit<CR>").await;
let outcome = session
.wait_for(
WaitCondition::Text("NEVER_APPEARS_123".into()),
Duration::from_secs(10),
)
.await
.unwrap();
assert!(!outcome.matched);
assert!(
outcome.elapsed_ms < 5000,
"fail-fast took {}ms",
outcome.elapsed_ms
);
assert!(outcome.diagnostics.expect("diagnostics on ended").ended);
}
/// wait_for(Gone) matches once the text is cleared from the screen.
#[tokio::test(flavor = "multi_thread")]
async fn wait_gone_matches_after_clear() {
let session = start_session(vec!["/bin/sh".into()]).await;
session.send_keys("echo MARKER_GONE_42<CR>").await.unwrap();
let outcome = session
.wait_for(
WaitCondition::Text("MARKER_GONE_42".into()),
Duration::from_secs(10),
)
.await
.unwrap();
assert!(outcome.matched);
// Clear the screen and home the cursor; the marker must vanish from the grid.
session
.send_keys(r"printf '\033[2J\033[H'<CR>")
.await
.unwrap();
let outcome = session
.wait_for(
WaitCondition::Gone("MARKER_GONE_42".into()),
Duration::from_secs(10),
)
.await
.unwrap();
assert!(outcome.matched, "marker still on screen: {outcome:?}");
shutdown(&session, "exit<CR>").await;
}
/// wait_for(StableMs) matches once output quiesces, never before the window elapses.
#[tokio::test(flavor = "multi_thread")]
async fn wait_stable_matches_after_quiesce() {
let session = start_session(vec!["/bin/sh".into()]).await;
session.send_keys("echo quiesce-now<CR>").await.unwrap();
let outcome = session
.wait_for(WaitCondition::StableMs(400), Duration::from_secs(10))
.await
.unwrap();
assert!(outcome.matched, "screen never stabilized: {outcome:?}");
// A full uninterrupted window is a lower bound on the elapsed time.
assert!(
outcome.elapsed_ms >= 400,
"elapsed_ms = {}",
outcome.elapsed_ms
);
shutdown(&session, "exit<CR>").await;
}
/// wait_for(StableMs) treats a resize as grid activity: the stability window restarts.
#[tokio::test(flavor = "multi_thread")]
async fn wait_stable_restarts_window_on_resize() {
// A child that never writes: the resize is the only grid activity.
let session = start_session(vec!["/bin/sleep".into(), "30".into()]).await;
let wait = tokio::spawn(
session
.wait_handle()
.wait_for(WaitCondition::StableMs(800), Duration::from_secs(10)),
);
// Land the resize inside the first stability window.
tokio::time::sleep(Duration::from_millis(200)).await;
session.resize(100, 30).await.unwrap();
let resized_at = Instant::now();
let outcome = wait.await.unwrap().unwrap();
assert!(outcome.matched, "screen never stabilized: {outcome:?}");
// A full window must elapse after the resize; without the restart the wait
// matches off the original window, well under 800ms after the resize.
assert!(
resized_at.elapsed() >= Duration::from_millis(800),
"stability window did not restart on resize (completed {:?} after it)",
resized_at.elapsed()
);
shutdown(&session, "<C-c>").await;
}
/// wait_for(Regex) matches the screen text; invalid patterns error instead of waiting.
#[tokio::test(flavor = "multi_thread")]
async fn wait_regex_matches() {
let session = start_session(vec!["/bin/sh".into()]).await;
session.send_keys("echo exit code 42<CR>").await.unwrap();
let outcome = session
.wait_for(
WaitCondition::Regex(r"exit code \d+".into()),
Duration::from_secs(10),
)
.await
.unwrap();
assert!(outcome.matched);
assert!(
session
.wait_for(
WaitCondition::Regex("(unclosed".into()),
Duration::from_secs(1)
)
.await
.is_err()
);
shutdown(&session, "exit<CR>").await;
}
}

View file

@ -0,0 +1,316 @@
//! Styled output formatters — styled JSON and HTML rendering.
//!
//! Converts the terminal grid into structured representations that preserve
//! color and text attribute information for LLM consumption.
use alacritty_terminal::grid::Row;
use alacritty_terminal::index::Column;
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::vte::ansi::{Color, NamedColor};
use crate::term::{CursorPosition, ScreenOpts, TerminalSize};
/// A single styled run of text with uniform attributes.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StyledRun {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub bold: bool,
#[serde(skip_serializing_if = "is_false")]
pub italic: bool,
#[serde(skip_serializing_if = "is_false")]
pub underline: bool,
#[serde(skip_serializing_if = "is_false")]
pub strikeout: bool,
#[serde(skip_serializing_if = "is_false")]
pub dim: bool,
#[serde(skip_serializing_if = "is_false")]
pub inverse: bool,
}
fn is_false(v: &bool) -> bool {
!v
}
/// A single line of styled content.
#[derive(Debug, Clone, serde::Serialize)]
pub struct StyledLine {
pub line: usize,
pub runs: Vec<StyledRun>,
}
/// Style attributes for a cell, used for run-length coalescing.
#[derive(Debug, Clone, PartialEq)]
struct CellStyle {
fg: Option<String>,
bg: Option<String>,
bold: bool,
italic: bool,
underline: bool,
strikeout: bool,
dim: bool,
inverse: bool,
}
impl CellStyle {
fn from_cell(cell: &Cell) -> Self {
let flags = cell.flags;
Self {
fg: color_to_css(&cell.fg),
bg: color_to_css(&cell.bg),
bold: flags.contains(Flags::BOLD),
italic: flags.contains(Flags::ITALIC),
underline: flags.intersects(Flags::ALL_UNDERLINES),
strikeout: flags.contains(Flags::STRIKEOUT),
dim: flags.contains(Flags::DIM),
inverse: flags.contains(Flags::INVERSE),
}
}
fn to_run(&self, text: String) -> StyledRun {
StyledRun {
text,
fg: self.fg.clone(),
bg: self.bg.clone(),
bold: self.bold,
italic: self.italic,
underline: self.underline,
strikeout: self.strikeout,
dim: self.dim,
inverse: self.inverse,
}
}
}
/// Extract a styled line from a grid row by coalescing consecutive cells
/// with identical style into runs.
pub fn extract_styled_line(
row: &Row<Cell>,
col_start: usize,
col_end: usize,
line_number: usize,
cursor: &CursorPosition,
opts: &ScreenOpts,
) -> StyledLine {
let mut runs = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CellStyle> = None;
for col_idx in col_start..col_end {
let cell = &row[Column(col_idx)];
// Skip wide char spacers.
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
let style = CellStyle::from_cell(cell);
// Determine the character to emit.
let is_cursor = cursor.row == line_number && cursor.col == col_idx + 1;
let ch = if is_cursor && opts.cursor_char.is_some() {
opts.cursor_char.unwrap()
} else {
cell.c
};
// If style changed, flush the current run.
if let Some(ref cur) = current_style {
if *cur != style {
if !current_text.is_empty() {
runs.push(cur.to_run(std::mem::take(&mut current_text)));
}
current_style = Some(style);
}
} else {
current_style = Some(style);
}
current_text.push(ch);
if let Some(zw) = cell.zerowidth() {
for &c in zw {
current_text.push(c);
}
}
}
// Flush remaining.
if !current_text.is_empty()
&& let Some(ref style) = current_style
{
runs.push(style.to_run(current_text));
}
// Trim trailing whitespace-only runs with default style.
while runs
.last()
.is_some_and(|r| r.text.trim().is_empty() && r.fg.is_none() && r.bg.is_none() && !r.bold)
{
runs.pop();
}
StyledLine {
line: line_number,
runs,
}
}
/// Render styled lines as an HTML document.
pub fn render_html(lines: &[StyledLine], _cursor: &CursorPosition, _size: &TerminalSize) -> String {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
html.push_str("<meta charset=\"utf-8\">\n");
html.push_str("<style>\n");
html.push_str("body { background: #1e1e1e; margin: 0; padding: 16px; }\n");
html.push_str("pre { font-family: 'Menlo', 'Monaco', 'Courier New', monospace; ");
html.push_str("font-size: 14px; line-height: 1.4; color: #d4d4d4; margin: 0; }\n");
html.push_str(".cursor { background: #d4d4d4; color: #1e1e1e; }\n");
html.push_str(".bold { font-weight: bold; }\n");
html.push_str(".italic { font-style: italic; }\n");
html.push_str(".underline { text-decoration: underline; }\n");
html.push_str(".strikeout { text-decoration: line-through; }\n");
html.push_str(".dim { opacity: 0.5; }\n");
html.push_str("</style>\n</head>\n<body>\n<pre>");
for styled_line in lines {
html.push_str("<div class=\"line\">");
for run in &styled_line.runs {
let mut classes = Vec::new();
let mut styles = Vec::new();
if run.bold {
classes.push("bold");
}
if run.italic {
classes.push("italic");
}
if run.underline {
classes.push("underline");
}
if run.strikeout {
classes.push("strikeout");
}
if run.dim {
classes.push("dim");
}
if let Some(ref fg) = run.fg {
styles.push(format!("color:{fg}"));
}
if let Some(ref bg) = run.bg {
styles.push(format!("background:{bg}"));
}
if classes.is_empty() && styles.is_empty() {
html.push_str(&html_escape(&run.text));
} else {
html.push_str("<span");
if !classes.is_empty() {
html.push_str(&format!(" class=\"{}\"", classes.join(" ")));
}
if !styles.is_empty() {
html.push_str(&format!(" style=\"{}\"", styles.join(";")));
}
html.push('>');
html.push_str(&html_escape(&run.text));
html.push_str("</span>");
}
}
html.push_str("</div>\n");
}
html.push_str("</pre>\n</body>\n</html>\n");
html
}
/// Convert a terminal `Color` to a CSS color string.
fn color_to_css(color: &Color) -> Option<String> {
match color {
Color::Spec(rgb) => Some(format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)),
Color::Named(name) => named_color_to_css(name),
Color::Indexed(idx) => Some(indexed_color_to_css(*idx)),
}
}
/// Map named ANSI colors to CSS hex values (standard xterm palette).
fn named_color_to_css(name: &NamedColor) -> Option<String> {
let hex = match name {
NamedColor::Black => "#000000",
NamedColor::Red => "#cd0000",
NamedColor::Green => "#00cd00",
NamedColor::Yellow => "#cdcd00",
NamedColor::Blue => "#0000ee",
NamedColor::Magenta => "#cd00cd",
NamedColor::Cyan => "#00cdcd",
NamedColor::White => "#e5e5e5",
NamedColor::BrightBlack => "#7f7f7f",
NamedColor::BrightRed => "#ff0000",
NamedColor::BrightGreen => "#00ff00",
NamedColor::BrightYellow => "#ffff00",
NamedColor::BrightBlue => "#5c5cff",
NamedColor::BrightMagenta => "#ff00ff",
NamedColor::BrightCyan => "#00ffff",
NamedColor::BrightWhite => "#ffffff",
NamedColor::Foreground | NamedColor::Background | NamedColor::Cursor => return None,
_ => return None,
};
Some(hex.to_string())
}
/// Map 256-color palette index to CSS hex.
fn indexed_color_to_css(idx: u8) -> String {
match idx {
0 => "#000000".into(),
1 => "#cd0000".into(),
2 => "#00cd00".into(),
3 => "#cdcd00".into(),
4 => "#0000ee".into(),
5 => "#cd00cd".into(),
6 => "#00cdcd".into(),
7 => "#e5e5e5".into(),
8 => "#7f7f7f".into(),
9 => "#ff0000".into(),
10 => "#00ff00".into(),
11 => "#ffff00".into(),
12 => "#5c5cff".into(),
13 => "#ff00ff".into(),
14 => "#00ffff".into(),
15 => "#ffffff".into(),
// 216 color cube (indices 16-231).
16..=231 => {
let idx = idx - 16;
let r = idx / 36;
let g = (idx % 36) / 6;
let b = idx % 6;
let to_rgb = |v: u8| if v == 0 { 0u8 } else { 55 + 40 * v };
format!("#{:02x}{:02x}{:02x}", to_rgb(r), to_rgb(g), to_rgb(b))
}
// Grayscale ramp (indices 232-255).
232..=255 => {
let v = 8 + 10 * (idx - 232);
format!("#{:02x}{:02x}{:02x}", v, v, v)
}
}
}
/// Escape HTML special characters.
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}

View file

@ -0,0 +1,358 @@
//! Wrapper around `alacritty_terminal::Term` for headless terminal emulation.
//!
//! Provides a simplified interface for feeding PTY output into the terminal
//! state machine and reading back screen content as text, styled JSON, or HTML.
use std::ops::Range;
use alacritty_terminal::event::{Event, EventListener};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi;
use crate::styled::{self, StyledLine};
/// Event listener that captures `PtyWrite` events for forwarding back to PTY.
///
/// When the terminal emulator needs to respond to device status queries (DSR),
/// color queries, etc., it emits `Event::PtyWrite`. These MUST be forwarded
/// back to the PTY, otherwise programs like vim/tmux will hang waiting for
/// a response.
#[derive(Clone)]
pub struct SessionListener {
pty_write_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
}
impl SessionListener {
pub fn new(pty_write_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>) -> Self {
Self { pty_write_tx }
}
}
impl EventListener for SessionListener {
fn send_event(&self, event: Event) {
if let Event::PtyWrite(text) = event {
let _ = self.pty_write_tx.send(text.into_bytes());
}
}
}
/// Options for querying screen content.
#[derive(Debug, Clone, Default)]
pub struct ScreenOpts {
/// Row range (1-indexed, inclusive). None = all rows.
pub rows: Option<Range<usize>>,
/// Column range (1-indexed, inclusive). None = all columns.
pub cols: Option<Range<usize>>,
/// If set, replace the character at cursor position with this char.
pub cursor_char: Option<char>,
/// Include trailing empty lines (default: trim them).
pub include_empty: bool,
}
/// Plain text screen output.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScreenOutput {
pub lines: Vec<String>,
pub cursor: CursorPosition,
pub size: TerminalSize,
}
/// Cursor position (1-indexed).
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct CursorPosition {
pub row: usize,
pub col: usize,
}
/// Terminal dimensions.
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct TerminalSize {
pub cols: usize,
pub rows: usize,
}
/// Active terminal modes — tells callers what the running program has enabled.
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct TerminalModes {
/// Alternate screen buffer is active (vim, less, htop, etc.).
pub alt_screen: bool,
/// Bracketed paste mode — input pasted between ESC[200~ / ESC[201~.
pub bracketed_paste: bool,
/// Application cursor keys (arrow keys send SS3 instead of CSI).
pub app_cursor: bool,
/// Application keypad mode.
pub app_keypad: bool,
/// Line-wrap mode (auto-wrap at right margin).
pub line_wrap: bool,
/// Origin mode (cursor addressing relative to scroll region).
pub origin: bool,
/// Cursor is visible.
pub show_cursor: bool,
/// Insert mode.
pub insert: bool,
/// LF/NL mode (linefeed also does carriage return).
pub linefeed_newline: bool,
/// Focus in/out reporting enabled.
pub focus_in_out: bool,
/// Mouse click reporting enabled.
pub mouse_reporting: bool,
}
/// A single line from scrollback history.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScrollbackLine {
/// 1-indexed offset from the bottom of scrollback (1 = most recent).
pub offset: usize,
/// Text content of the line.
pub text: String,
}
/// A simple `Dimensions` impl for creating a `Term`.
struct TermDimensions {
columns: usize,
screen_lines: usize,
}
impl TermDimensions {
fn new(columns: usize, screen_lines: usize) -> Self {
Self {
columns,
screen_lines,
}
}
}
impl Dimensions for TermDimensions {
fn total_lines(&self) -> usize {
self.screen_lines
}
fn screen_lines(&self) -> usize {
self.screen_lines
}
fn columns(&self) -> usize {
self.columns
}
}
/// Headless terminal emulator wrapping `alacritty_terminal`.
pub struct Terminal {
term: Term<SessionListener>,
parser: ansi::Processor,
}
impl Terminal {
/// Create a new terminal with the given dimensions.
pub fn new(cols: u16, rows: u16, listener: SessionListener) -> Self {
let size = TermDimensions::new(cols as usize, rows as usize);
let config = Config::default();
let term = Term::new(config, &size, listener);
let parser = ansi::Processor::new();
Self { term, parser }
}
/// Feed raw bytes from PTY output into the terminal emulator.
pub fn feed(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.term, bytes);
}
/// Get the cursor position (1-indexed).
pub fn cursor_position(&self) -> CursorPosition {
let point = self.term.grid().cursor.point;
CursorPosition {
row: point.line.0 as usize + 1,
col: point.column.0 + 1,
}
}
/// Get terminal dimensions.
pub fn size(&self) -> TerminalSize {
TerminalSize {
cols: self.term.columns(),
rows: self.term.screen_lines(),
}
}
/// Resize the terminal.
pub fn resize(&mut self, cols: u16, rows: u16) {
let size = TermDimensions::new(cols as usize, rows as usize);
self.term.resize(size);
}
/// Get the active terminal modes.
pub fn terminal_modes(&self) -> TerminalModes {
let mode = self.term.mode();
TerminalModes {
alt_screen: mode.contains(TermMode::ALT_SCREEN),
bracketed_paste: mode.contains(TermMode::BRACKETED_PASTE),
app_cursor: mode.contains(TermMode::APP_CURSOR),
app_keypad: mode.contains(TermMode::APP_KEYPAD),
line_wrap: mode.contains(TermMode::LINE_WRAP),
origin: mode.contains(TermMode::ORIGIN),
show_cursor: mode.contains(TermMode::SHOW_CURSOR),
insert: mode.contains(TermMode::INSERT),
linefeed_newline: mode.contains(TermMode::LINE_FEED_NEW_LINE),
focus_in_out: mode.contains(TermMode::FOCUS_IN_OUT),
mouse_reporting: mode.intersects(
TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION,
),
}
}
/// Number of lines in the scrollback buffer.
pub fn scrollback_count(&self) -> usize {
self.term.grid().history_size()
}
/// Read scrollback lines. `count` limits how many to return (from the
/// bottom / most-recent). Returns them in chronological order (oldest first).
pub fn scrollback_lines(&self, count: usize) -> Vec<ScrollbackLine> {
let grid = self.term.grid();
let history = grid.history_size();
let n = count.min(history);
let num_cols = grid.columns();
let mut lines = Vec::with_capacity(n);
// history lines are at negative indices: Line(-1) is most recent
for offset in (1..=n).rev() {
let row = &grid[Line(-(offset as i32))];
let mut text = String::new();
for col_idx in 0..num_cols {
let cell = &row[Column(col_idx)];
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
text.push(cell.c);
if let Some(zw) = cell.zerowidth() {
for &c in zw {
text.push(c);
}
}
}
lines.push(ScrollbackLine {
offset,
text: text.trim_end().to_string(),
});
}
lines
}
/// Read screen content as plain text lines.
pub fn screen_content(&self, opts: &ScreenOpts) -> ScreenOutput {
let grid = self.term.grid();
let num_lines = grid.screen_lines();
let num_cols = grid.columns();
let cursor = self.cursor_position();
let (row_start, row_end) = resolve_range(&opts.rows, num_lines);
let (col_start, col_end) = resolve_range(&opts.cols, num_cols);
let mut lines = Vec::new();
for line_idx in row_start..row_end {
let row = &grid[Line(line_idx as i32)];
let mut text = String::new();
for col_idx in col_start..col_end {
let cell = &row[Column(col_idx)];
// Skip wide char spacers.
if cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
// Replace cursor position if requested.
let is_cursor = cursor.row == line_idx + 1 && cursor.col == col_idx + 1;
if is_cursor && opts.cursor_char.is_some() {
text.push(opts.cursor_char.unwrap());
} else {
text.push(cell.c);
if let Some(zw) = cell.zerowidth() {
for &c in zw {
text.push(c);
}
}
}
}
lines.push(text);
}
// Trim trailing empty lines unless include_empty is set.
if !opts.include_empty {
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
}
// Right-trim each line.
for line in &mut lines {
let trimmed = line.trim_end().to_string();
*line = trimmed;
}
ScreenOutput {
lines,
cursor,
size: self.size(),
}
}
/// Read screen content with full style information.
pub fn screen_styled(&self, opts: &ScreenOpts) -> Vec<StyledLine> {
let grid = self.term.grid();
let num_lines = grid.screen_lines();
let num_cols = grid.columns();
let cursor = self.cursor_position();
let (row_start, row_end) = resolve_range(&opts.rows, num_lines);
let (col_start, col_end) = resolve_range(&opts.cols, num_cols);
let mut result = Vec::new();
for line_idx in row_start..row_end {
let row = &grid[Line(line_idx as i32)];
let styled_line =
styled::extract_styled_line(row, col_start, col_end, line_idx + 1, &cursor, opts);
result.push(styled_line);
}
// Trim trailing empty styled lines unless include_empty is set.
if !opts.include_empty {
while result.last().is_some_and(|l| l.runs.is_empty()) {
result.pop();
}
}
result
}
/// Render screen content as HTML.
pub fn screen_html(&self, opts: &ScreenOpts) -> String {
let styled = self.screen_styled(opts);
styled::render_html(&styled, &self.cursor_position(), &self.size())
}
}
/// Resolve an optional 1-indexed range to 0-indexed (start, end).
fn resolve_range(range: &Option<Range<usize>>, max: usize) -> (usize, usize) {
match range {
Some(r) => {
let start = r.start.saturating_sub(1).min(max);
let end = r.end.min(max);
(start, end)
}
None => (0, max),
}
}

View file

@ -0,0 +1,213 @@
//! Event-driven wait/expect primitives over the terminal grid.
//!
//! Waiters never poll: the session's feeder bumps a generation watch after
//! each `term.feed()`, and conditions are re-checked only on bumps.
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use tokio::sync::{Mutex, watch};
use crate::term::{CursorPosition, ScreenOpts, Terminal, TerminalModes};
/// Maximum bytes of recent raw PTY output kept for wait-timeout diagnostics.
pub(crate) const RAW_TAIL_CAP: usize = 2048;
/// A condition `wait_for` blocks on.
#[derive(Debug, Clone)]
pub enum WaitCondition {
/// Text appears on screen (substring of the trimmed screen text).
Text(String),
/// Regex matches the trimmed screen text.
Regex(String),
/// Text is absent from the screen.
Gone(String),
/// No grid update for this many milliseconds.
StableMs(u64),
}
/// Result of a `wait_for` call.
#[derive(Debug, Clone, serde::Serialize)]
pub struct WaitOutcome {
pub matched: bool,
pub elapsed_ms: u64,
/// Present only on timeout, so the agent never needs a follow-up call.
#[serde(flatten)]
pub diagnostics: Option<WaitDiagnostics>,
}
/// Screen snapshot attached to a timed-out wait.
#[derive(Debug, Clone, serde::Serialize)]
pub struct WaitDiagnostics {
/// Trimmed screen text (the exact text the condition was evaluated against).
pub screen: String,
pub cursor: CursorPosition,
pub modes: TerminalModes,
/// Last bytes of raw PTY output (lossy UTF-8, at most [`RAW_TAIL_CAP`] bytes).
pub raw_tail: String,
/// Grid generation at timeout.
pub generation: u64,
/// True when the session's output ended (child exited) before the deadline,
/// so the condition could never have been met.
pub ended: bool,
}
/// Handles needed to wait on screen conditions without holding any outer session lock.
///
/// The HTTP server wraps `PtySession` in a mutex; a long-poll must clone
/// this handle and drop the session guard, or it would block send/screen
/// for the whole wait.
///
/// TODO: the next verb needing lock-free session access must instead make
/// `stop()` take `&self` and switch the server state to `Arc<PtySession>`,
/// deleting this handle — do not clone another field trio.
pub struct WaitHandle {
pub(crate) terminal: Arc<Mutex<Terminal>>,
pub(crate) generation_rx: watch::Receiver<u64>,
pub(crate) raw_tail: Arc<std::sync::Mutex<VecDeque<u8>>>,
}
impl WaitHandle {
/// Wait until `condition` is met or `timeout` elapses.
///
/// Event-driven: the grid is re-checked only when the feeder bumps the
/// generation. Errors only on an invalid regex pattern.
pub async fn wait_for(
mut self,
condition: WaitCondition,
timeout: Duration,
) -> Result<WaitOutcome> {
let start = Instant::now();
let deadline = tokio::time::Instant::now() + timeout;
// Compile once so a bad pattern fails fast instead of on every check.
let regex = match &condition {
WaitCondition::Regex(pattern) => {
Some(regex::Regex::new(pattern).context("invalid regex")?)
}
_ => None,
};
if let WaitCondition::StableMs(window_ms) = condition {
let window = Duration::from_millis(window_ms);
return Ok(self.wait_stable(window, start, deadline).await);
}
loop {
// Mark the current generation seen before checking, so a feed racing the check wakes changed().
self.generation_rx.borrow_and_update();
{
let term = self.terminal.lock().await;
let text = screen_text(&term);
let met = match &condition {
WaitCondition::Text(needle) => text.contains(needle),
WaitCondition::Gone(needle) => !text.contains(needle),
WaitCondition::Regex(_) => regex.as_ref().is_some_and(|re| re.is_match(&text)),
WaitCondition::StableMs(_) => unreachable!("handled above"),
};
if met {
return Ok(WaitOutcome {
matched: true,
elapsed_ms: start.elapsed().as_millis() as u64,
diagnostics: None,
});
}
}
tokio::select! {
changed = self.generation_rx.changed() => {
if changed.is_err() {
// Feeder gone (session over): the grid is final, so fail fast with diagnostics.
return Ok(self.timeout_outcome(start, true).await);
}
}
_ = tokio::time::sleep_until(deadline) => {
return Ok(self.timeout_outcome(start, false).await);
}
}
}
}
/// Wait until the grid has been unchanged for `window`, bounded by `deadline`.
async fn wait_stable(
&mut self,
window: Duration,
start: Instant,
deadline: tokio::time::Instant,
) -> WaitOutcome {
// A dropped sender means no further grid updates: the remaining window always completes.
let mut sender_gone = false;
loop {
self.generation_rx.borrow_and_update();
let window_end = tokio::time::Instant::now() + window;
tokio::select! {
changed = self.generation_rx.changed(), if !sender_gone => {
if changed.is_err() {
sender_gone = true;
}
// Activity: restart the stability window unless out of time.
if tokio::time::Instant::now() >= deadline {
return self.timeout_outcome(start, sender_gone).await;
}
}
_ = tokio::time::sleep_until(window_end.min(deadline)) => {
if window_end <= deadline {
return WaitOutcome {
matched: true,
elapsed_ms: start.elapsed().as_millis() as u64,
diagnostics: None,
};
}
return self.timeout_outcome(start, sender_gone).await;
}
}
}
}
/// Snapshot the screen state for a timed-out wait.
async fn timeout_outcome(&self, start: Instant, ended: bool) -> WaitOutcome {
let (screen, cursor, modes) = {
let term = self.terminal.lock().await;
(
screen_text(&term),
term.cursor_position(),
term.terminal_modes(),
)
};
let raw_tail = {
let tail = self.raw_tail.lock().unwrap();
String::from_utf8_lossy(&tail.iter().copied().collect::<Vec<u8>>()).into_owned()
};
WaitOutcome {
matched: false,
elapsed_ms: start.elapsed().as_millis() as u64,
diagnostics: Some(WaitDiagnostics {
screen,
cursor,
modes,
raw_tail,
generation: *self.generation_rx.borrow(),
ended,
}),
}
}
}
/// Trimmed screen text — identical to what agents see via the screen API.
fn screen_text(term: &Terminal) -> String {
term.screen_content(&ScreenOpts::default()).lines.join("\n")
}
/// Append bytes to the bounded raw-output tail, evicting the oldest bytes.
pub(crate) fn push_raw_tail(tail: &std::sync::Mutex<VecDeque<u8>>, bytes: &[u8]) {
let mut tail = tail.lock().unwrap();
if bytes.len() >= RAW_TAIL_CAP {
tail.clear();
tail.extend(&bytes[bytes.len() - RAW_TAIL_CAP..]);
return;
}
while tail.len() + bytes.len() > RAW_TAIL_CAP {
tail.pop_front();
}
tail.extend(bytes);
}