Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
24
crates/codegen/ptyctl/Cargo.toml
Normal file
24
crates/codegen/ptyctl/Cargo.toml
Normal 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"] }
|
||||
193
crates/codegen/ptyctl/src/keys.rs
Normal file
193
crates/codegen/ptyctl/src/keys.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
crates/codegen/ptyctl/src/lib.rs
Normal file
13
crates/codegen/ptyctl/src/lib.rs
Normal 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;
|
||||
155
crates/codegen/ptyctl/src/pty.rs
Normal file
155
crates/codegen/ptyctl/src/pty.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
495
crates/codegen/ptyctl/src/server.rs
Normal file
495
crates/codegen/ptyctl/src/server.rs
Normal 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(¶ms);
|
||||
|
||||
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(¬ation).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;
|
||||
}
|
||||
}
|
||||
}
|
||||
606
crates/codegen/ptyctl/src/session.rs
Normal file
606
crates/codegen/ptyctl/src/session.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
316
crates/codegen/ptyctl/src/styled.rs
Normal file
316
crates/codegen/ptyctl/src/styled.rs
Normal 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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
358
crates/codegen/ptyctl/src/term.rs
Normal file
358
crates/codegen/ptyctl/src/term.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
213
crates/codegen/ptyctl/src/wait.rs
Normal file
213
crates/codegen/ptyctl/src/wait.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue