use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use crate::commands::cloud_agent::{client_sessions, codex};
use crate::commands::ssh::native;
use crate::vt100;
use super::terminal_palette;
type PaneParser = vt100::Parser<PaletteReplies>;
fn pane_parser(rows: u16, cols: u16, scrollback: usize) -> PaneParser {
PaneParser::new_with_callbacks(
rows,
cols,
scrollback,
PaletteReplies {
colors: terminal_palette::cached(),
replies: Vec::new(),
},
)
}
struct PaletteReplies {
colors: Option<terminal_palette::DefaultColors>,
replies: Vec<u8>,
}
impl vt100::Callbacks for PaletteReplies {
fn unhandled_osc(&mut self, _: &mut vt100::Screen, params: &[&[u8]]) {
let Some(colors) = &self.colors else {
return;
};
let (code, color) = match params {
[b"10", b"?"] => (10, &colors.fg),
[b"11", b"?"] => (11, &colors.bg),
_ => return,
};
self.replies.extend_from_slice(
format!(
"\x1b]{code};rgb:{:04x}/{:04x}/{:04x}\x1b\\",
color.r, color.g, color.b
)
.as_bytes(),
);
}
}
pub fn durable_name(harness: &str) -> String {
use rand::Rng;
let suffix: String = (0..6)
.map(|_| {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
ALPHABET[rand::thread_rng().gen_range(0..ALPHABET.len())] as char
})
.collect();
format!("{harness}-{suffix}")
}
fn dsr_reply(chunk: &[u8], screen: &vt100::Screen) -> Option<Vec<u8>> {
const QUERY: &[u8] = b"\x1b[6n";
chunk.windows(QUERY.len()).any(|w| w == QUERY).then(|| {
let (row, col) = screen.cursor_position();
format!("\x1b[{};{}R", row + 1, col + 1).into_bytes()
})
}
fn da1_reply(chunk: &[u8]) -> Option<Vec<u8>> {
const REPLY: &[u8] = b"\x1b[?62;22c";
let mut i = 0;
while let Some(at) = chunk[i..].windows(2).position(|w| w == b"\x1b[") {
let seq = &chunk[i + at + 2..];
let query = match seq.first() {
Some(b'c') => true,
Some(b'0') => seq.get(1) == Some(&b'c'),
_ => false,
};
if query {
return Some(REPLY.to_vec());
}
i += at + 2;
}
None
}
fn kitty_scan(chunk: &[u8], kitty: &AtomicBool) -> Option<Vec<u8>> {
let mut reply = None;
let mut i = 0;
while let Some(at) = chunk[i..].windows(2).position(|w| w == b"\x1b[") {
let seq = &chunk[i + at + 2..];
let Some(end) = seq.iter().position(|b| *b == b'u') else {
break;
};
match seq.first() {
Some(b'?') if seq[1..end].iter().all(u8::is_ascii_digit) => {
let flags = u8::from(kitty.load(Ordering::Relaxed));
reply = Some(format!("\x1b[?{flags}u").into_bytes());
}
Some(b'>') if seq[1..end].iter().all(u8::is_ascii_digit) => {
let flags: u32 = std::str::from_utf8(&seq[1..end])
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
kitty.store(flags != 0, Ordering::Relaxed);
}
Some(b'<') if seq[1..end].iter().all(u8::is_ascii_digit) => {
kitty.store(false, Ordering::Relaxed);
}
_ => {}
}
i += at + 2;
}
reply
}
#[derive(Default)]
struct TerminalReplies {
pending: Vec<u8>,
}
impl TerminalReplies {
fn process(&mut self, bytes: &[u8], parser: &mut PaneParser, kitty: &AtomicBool) -> Vec<u8> {
let mut replies = Vec::new();
let mut parsed = 0;
for (i, &byte) in bytes.iter().enumerate() {
if byte == 0x1b {
self.pending.clear();
self.pending.push(byte);
} else if self.pending == b"\x1b" {
if byte == b'[' {
self.pending.push(byte);
} else {
self.pending.clear();
}
} else if !self.pending.is_empty() {
self.pending.push(byte);
if (0x40..=0x7e).contains(&byte) {
parser.process(&bytes[parsed..=i]);
parsed = i + 1;
replies.append(&mut parser.callbacks_mut().replies);
if let Some(reply) = kitty_scan(&self.pending, kitty) {
replies.extend(reply);
}
if let Some(reply) = da1_reply(&self.pending) {
replies.extend(reply);
}
if let Some(reply) = dsr_reply(&self.pending, parser.screen()) {
replies.extend(reply);
}
self.pending.clear();
} else if !(0x20..=0x3f).contains(&byte) || self.pending.len() > 64 {
self.pending.clear();
}
}
}
parser.process(&bytes[parsed..]);
replies.append(&mut parser.callbacks_mut().replies);
replies
}
}
const DURABLE_BANNER_MARKER: &[u8] = b"Railway durable session";
const BANNER_GIVE_UP_AFTER: usize = 32 * 1024;
struct BannerFilter {
pending: Vec<u8>,
seen: usize,
done: bool,
name: Option<String>,
}
impl BannerFilter {
fn new() -> Self {
Self {
pending: Vec::new(),
seen: 0,
done: false,
name: None,
}
}
fn find_marker(haystack: &[u8]) -> Option<usize> {
if haystack.len() < DURABLE_BANNER_MARKER.len() {
return None;
}
haystack
.windows(DURABLE_BANNER_MARKER.len())
.position(|w| w == DURABLE_BANNER_MARKER)
}
fn strip_complete_lines(&mut self, data: &mut Vec<u8>) -> bool {
let mut removed = false;
while let Some(pos) = Self::find_marker(data) {
let rest = &data[pos..];
let end_rel = rest
.iter()
.position(|b| *b == b'\n')
.or_else(|| rest.iter().position(|b| *b == b'\r'));
let Some(end_rel) = end_rel else {
break;
};
let end = pos + end_rel + 1;
if self.name.is_none() {
let value = String::from_utf8_lossy(&rest[DURABLE_BANNER_MARKER.len()..end_rel]);
if let Some(name) = value
.trim_start_matches([' ', ':'])
.split_whitespace()
.next()
.map(|name| name.trim_end_matches('.'))
&& client_sessions::validate_id(name).is_ok()
{
self.name = Some(name.into());
}
}
let start = if data[..pos].iter().all(|b| *b == b'\r' || *b == b'\n') {
0
} else {
pos
};
data.drain(start..end);
removed = true;
}
removed
}
fn push(&mut self, chunk: &[u8]) -> Vec<u8> {
if self.done {
return chunk.to_vec();
}
self.seen += chunk.len();
let mut data = std::mem::take(&mut self.pending);
data.extend_from_slice(chunk);
if self.strip_complete_lines(&mut data) {
self.done = true;
return data;
}
if self.seen >= BANNER_GIVE_UP_AFTER {
self.done = true;
return data;
}
let split = if let Some(pos) = Self::find_marker(&data) {
pos
} else {
let held = (1..DURABLE_BANNER_MARKER.len())
.rev()
.find(|&len| data.ends_with(&DURABLE_BANNER_MARKER[..len]))
.unwrap_or(0);
data.len() - held
};
self.pending = data.split_off(split);
data
}
fn flush(&mut self) -> Vec<u8> {
if self.done {
return std::mem::take(&mut self.pending);
}
self.done = true;
let mut data = std::mem::take(&mut self.pending);
while let Some(pos) = Self::find_marker(&data) {
let start = if data[..pos].iter().all(|b| *b == b'\r' || *b == b'\n') {
0
} else {
pos
};
data.drain(start..);
}
data
}
}
pub struct Session {
pub agent_id: String,
pub agent_name: String,
pub harness: String,
pub durable_name: String,
pub console_name: Option<String>,
announced_console: Arc<Mutex<Option<String>>>,
pub client_id: Option<String>,
pub client_thread: Option<client_sessions::Thread>,
pub client_bridge: Option<codex::bridge::Bridge>,
pub opencode_bridge: Option<crate::commands::cloud_agent::opencode::bridge::Bridge>,
pub ssh_target: String,
pub identity: Option<std::path::PathBuf>,
pub relay_opts: Vec<String>,
parser: Arc<Mutex<PaneParser>>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
master: Box<dyn portable_pty::MasterPty + Send>,
ended: Arc<AtomicBool>,
exit_status: Option<bool>,
reattach: bool,
spawned_at: std::time::Instant,
got_output: Arc<AtomicBool>,
kitty_keys: Arc<AtomicBool>,
last_input: Option<std::time::Instant>,
size: (u16, u16),
scroll: usize,
}
impl Session {
pub(super) fn sync_console_name(&mut self) {
if self.client_bridge.is_none()
&& self.opencode_bridge.is_none()
&& let Some(name) = self
.announced_console
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
self.console_name = Some(name);
}
}
fn write_raw(&self, bytes: &[u8]) {
if let Ok(mut writer) = self.writer.lock() {
let _ = writer.write_all(bytes);
let _ = writer.flush();
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn(
agent_id: String,
agent_name: String,
harness: String,
ssh_target: &str,
identity: Option<&std::path::Path>,
relay_opts: &[String],
remote_cmd: &str,
reattach: bool,
durable_session: &str,
rows: u16,
cols: u16,
notify: impl Fn() + Send + 'static,
) -> Result<Self> {
let mut cmd = CommandBuilder::new("ssh");
cmd.arg("-tt");
for arg in native::relay_port_args() {
cmd.arg(arg);
}
for opt in relay_opts {
cmd.arg(opt);
}
if let Some(identity) = identity {
cmd.arg("-i");
cmd.arg(identity);
}
cmd.arg("-o");
cmd.arg(format!(
"SetEnv RAILWAY_DURABLE_SESSION_NAME={durable_session}"
));
cmd.arg(native::relay_destination(ssh_target));
if !reattach {
cmd.arg(remote_cmd);
}
Self::spawn_pty(
agent_id,
agent_name,
harness,
ssh_target,
identity,
relay_opts,
reattach,
durable_session,
cmd,
rows,
cols,
notify,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_client(
agent_id: String,
agent_name: String,
binary: &std::path::Path,
connection: &client_sessions::Connection,
client_url: Option<&str>,
thread_id: Option<&str>,
prompt: Option<&str>,
rows: u16,
cols: u16,
notify: impl Fn() + Send + 'static,
) -> Result<Self> {
let mut cmd = CommandBuilder::new(binary);
let mut local_connection = connection.clone();
if let Some(url) = client_url {
match &mut local_connection {
client_sessions::Connection::Codex(c) => c.url = url.into(),
client_sessions::Connection::OpenCode(c, _) => c.url = url.into(),
}
}
cmd.args(local_connection.args(thread_id));
if let Some(prompt) = prompt {
match connection {
client_sessions::Connection::Codex(_) => {
cmd.args(["--", prompt]);
}
client_sessions::Connection::OpenCode(_, true) => {
cmd.args(["--prompt", prompt]);
}
_ => {}
}
}
match connection {
client_sessions::Connection::Codex(c) => {
cmd.env(codex::TOKEN_ENV, &c.token);
cmd.env("CODEX_HOME", codex::local::client_home(c)?);
}
client_sessions::Connection::OpenCode(c, _) => {
cmd.env("OPENCODE_SERVER_USERNAME", &c.username);
cmd.env("OPENCODE_SERVER_PASSWORD", &c.password);
}
}
let name = client_sessions::name(connection.harness(), &agent_id, thread_id);
Self::spawn_pty(
agent_id,
agent_name,
connection.harness().into(),
"",
None,
&[],
false,
&name,
cmd,
rows,
cols,
notify,
)
}
#[allow(clippy::too_many_arguments)]
fn spawn_pty(
agent_id: String,
agent_name: String,
harness: String,
ssh_target: &str,
identity: Option<&std::path::Path>,
relay_opts: &[String],
reattach: bool,
durable_session: &str,
mut cmd: CommandBuilder,
rows: u16,
cols: u16,
notify: impl Fn() + Send + 'static,
) -> Result<Self> {
let pty = NativePtySystem::default()
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("Failed to allocate a pty for the agent session")?;
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let child = pty
.slave
.spawn_command(cmd)
.context("Failed to start the agent client")?;
drop(pty.slave);
let parser = Arc::new(Mutex::new(pane_parser(rows, cols, 4000)));
let ended = Arc::new(AtomicBool::new(false));
let got_output = Arc::new(AtomicBool::new(false));
let kitty_keys = Arc::new(AtomicBool::new(false));
let announced_console = Arc::new(Mutex::new(None));
let mut reader = pty
.master
.try_clone_reader()
.context("Failed to read the agent session")?;
let writer: Arc<Mutex<Box<dyn Write + Send>>> = Arc::new(Mutex::new(
pty.master
.take_writer()
.context("Failed to write to the agent session")?,
));
{
let parser = parser.clone();
let ended = ended.clone();
let writer = writer.clone();
let got_output = got_output.clone();
let kitty_keys = kitty_keys.clone();
let announced_console = announced_console.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 65536];
let mut banner = BannerFilter::new();
let mut terminal_replies = TerminalReplies::default();
let mut feed = |bytes: &[u8],
parser: &Arc<Mutex<PaneParser>>,
kitty_keys: &Arc<AtomicBool>,
writer: &Arc<Mutex<Box<dyn Write + Send>>>,
got_output: &Arc<AtomicBool>,
notify: &dyn Fn()| {
if bytes.is_empty() {
return;
}
got_output.store(true, Ordering::Relaxed);
let replies = parser
.lock()
.map(|mut parser| terminal_replies.process(bytes, &mut parser, kitty_keys))
.unwrap_or_default();
if !replies.is_empty() {
if let Ok(mut writer) = writer.lock() {
let _ = writer.write_all(&replies);
let _ = writer.flush();
}
}
notify();
};
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let filtered = banner.push(&buf[..n]);
if let Some(name) = banner.name.take() {
*announced_console.lock().unwrap_or_else(|e| e.into_inner()) =
Some(name);
notify();
}
feed(
&filtered,
&parser,
&kitty_keys,
&writer,
&got_output,
¬ify,
);
}
}
}
let tail = banner.flush();
feed(&tail, &parser, &kitty_keys, &writer, &got_output, ¬ify);
ended.store(true, Ordering::Relaxed);
notify();
});
}
Ok(Self {
announced_console,
console_name: None,
client_id: None,
client_thread: None,
client_bridge: None,
opencode_bridge: None,
agent_id,
agent_name,
harness,
durable_name: durable_session.to_string(),
ssh_target: ssh_target.to_string(),
identity: identity.map(|p| p.to_path_buf()),
relay_opts: relay_opts.to_vec(),
parser,
writer,
child,
master: pty.master,
ended,
exit_status: None,
reattach,
spawned_at: std::time::Instant::now(),
got_output,
kitty_keys,
last_input: None,
size: (rows, cols),
scroll: 0,
})
}
pub const STALL_AFTER: std::time::Duration = std::time::Duration::from_secs(5);
pub fn stalled(&self) -> bool {
self.reattach
&& !self.got_output.load(Ordering::Relaxed)
&& !self.ended()
&& self.spawned_at.elapsed() >= Self::STALL_AFTER
}
pub fn stall_remaining(&self) -> Option<std::time::Duration> {
if !self.reattach || self.got_output.load(Ordering::Relaxed) || self.ended() {
return None;
}
Self::STALL_AFTER.checked_sub(self.spawned_at.elapsed())
}
pub fn open_for(&self) -> std::time::Duration {
self.spawned_at.elapsed()
}
pub fn ended(&self) -> bool {
self.ended.load(Ordering::Relaxed)
}
pub fn finished(&mut self) -> bool {
self.ended() && self.exit_success() == Some(true)
}
pub fn dropped(&mut self) -> bool {
self.ended() && self.exit_success() == Some(false)
}
pub fn awaiting_exit_status(&self) -> bool {
self.ended() && self.exit_status.is_none()
}
fn exit_success(&mut self) -> Option<bool> {
if self.exit_status.is_none() {
self.exit_status = match self.child.try_wait() {
Ok(None) => None,
Ok(Some(status)) => Some(status.success()),
Err(_) => Some(false),
};
}
self.exit_status
}
pub fn environment_id(&self) -> Option<String> {
let mut parts = self.ssh_target.split(':');
match (parts.next()?, parts.next()) {
("agent", Some(env)) if !env.is_empty() => Some(env.to_string()),
_ => None,
}
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows.max(1);
let cols = cols.max(1);
if self.size == (rows, cols) {
return;
}
self.size = (rows, cols);
if let Ok(mut parser) = self.parser.lock() {
parser.screen_mut().set_size(rows, cols);
self.scroll = parser.screen().scrollback();
}
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
pub fn last_line(&self) -> Option<String> {
self.with_screen(|screen| {
let (rows, _) = screen.size();
(0..rows).rev().find_map(|row| {
let text: String = screen.contents_between(row, 0, row, u16::MAX);
let trimmed = text.trim();
let bare_prompt = trimmed
.trim_start_matches(['>', '$', '#', '·', '❯', '▌', '│', '╰', '─'])
.trim()
.is_empty();
(!bare_prompt).then(|| trimmed.to_string())
})
})
.flatten()
}
pub fn url_at(&self, row: u16, col: u16) -> Option<String> {
self.with_screen(|screen| {
let (rows, cols) = screen.size();
if row >= rows || col >= cols {
return None;
}
let mut start = row;
while start > 0 && screen.row_wrapped(start - 1) {
start -= 1;
}
let mut end = row;
while end + 1 < rows && screen.row_wrapped(end) {
end += 1;
}
let mut text = String::new();
let mut index = None;
for r in start..=end {
for c in 0..cols {
if r == row && c == col {
index = Some(text.chars().count());
}
match screen.cell(r, c).map(|cell| cell.contents()) {
Some(s) if !s.is_empty() => text.push_str(s),
_ => text.push(' '),
}
}
}
url_in(&text, index?)
})?
}
pub fn with_screen<T>(&self, f: impl FnOnce(&vt100::Screen) -> T) -> Option<T> {
self.parser.lock().ok().map(|parser| f(parser.screen()))
}
pub fn wants_mouse(&self) -> bool {
self.with_screen(|screen| screen.mouse_protocol_mode() != vt100::MouseProtocolMode::None)
.unwrap_or(false)
}
pub fn pointer(&mut self, kind: Pointer, at: (u16, u16)) -> bool {
use vt100::MouseProtocolMode as Mode;
let Some((mode, encoding)) = self.with_screen(|screen| {
(
screen.mouse_protocol_mode(),
screen.mouse_protocol_encoding(),
)
}) else {
return false;
};
let wanted = match (mode, kind) {
(Mode::None, _) => false,
(Mode::Press, Pointer::Press) => true,
(Mode::Press, _) => false,
(Mode::PressRelease, Pointer::Drag) => false,
(_, _) => true,
};
if !wanted {
return false;
}
self.write_raw(&pointer_report(kind, at, encoding));
true
}
pub fn scroll_by(&mut self, delta: isize) {
let Ok(mut parser) = self.parser.lock() else {
return;
};
let wanted = (self.scroll as isize).saturating_add(delta).max(0) as usize;
parser.screen_mut().set_scrollback(wanted);
self.scroll = parser.screen().scrollback();
}
pub fn scrolled_back(&self) -> bool {
self.scroll > 0
}
pub fn scroll(&mut self, up: bool, lines: usize, at: (u16, u16)) {
let Some((mode, encoding, alternate)) = self.with_screen(|screen| {
(
screen.mouse_protocol_mode(),
screen.mouse_protocol_encoding(),
screen.alternate_screen(),
)
}) else {
return;
};
if mode != vt100::MouseProtocolMode::None {
let mut out = Vec::new();
for _ in 0..lines {
out.extend_from_slice(&wheel_report(up, at, encoding));
}
self.write_raw(&out);
return;
}
if alternate {
return;
}
self.scroll_by(if up {
lines as isize
} else {
-(lines as isize)
});
}
fn scroll_to_live(&mut self) {
if self.scroll == 0 {
return;
}
self.scroll = 0;
if let Ok(mut parser) = self.parser.lock() {
parser.screen_mut().set_scrollback(0);
}
}
pub fn scrollable(&self) -> bool {
self.with_screen(|screen| {
screen.mouse_protocol_mode() != vt100::MouseProtocolMode::None
|| !screen.alternate_screen()
})
.unwrap_or(false)
}
pub fn send(&mut self, bytes: &[u8]) {
self.scroll_to_live();
self.last_input = Some(std::time::Instant::now());
self.write_raw(bytes);
}
pub fn input_within(&self, window: std::time::Duration) -> bool {
self.last_input.is_some_and(|at| at.elapsed() < window)
}
pub fn age(&self) -> std::time::Duration {
self.spawned_at.elapsed()
}
pub fn send_key(&mut self, key: KeyEvent) {
if let Some(bytes) = encode_key_for(key, self.kitty_keys.load(Ordering::Relaxed)) {
self.send(&bytes);
}
}
pub fn send_paste(&mut self, text: &str) {
let bracketed = self.with_screen(|s| s.bracketed_paste()).unwrap_or(false);
self.send(&encode_paste(text, bracketed));
}
pub fn detach(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn url_in(line: &str, col: usize) -> Option<String> {
let chars: Vec<char> = line.chars().collect();
if col >= chars.len() || chars[col].is_whitespace() {
return None;
}
let start = chars[..col]
.iter()
.rposition(|c| c.is_whitespace())
.map(|i| i + 1)
.unwrap_or(0);
let end = chars[col..]
.iter()
.position(|c| c.is_whitespace())
.map(|i| col + i)
.unwrap_or(chars.len());
let word: String = chars[start..end].iter().collect();
let word = word.trim_start_matches(['(', '[', '{', '<', '\'', '"']);
let mut url = word.trim_end_matches(['.', ',', ';', ':', '!', '?', '>', '\'', '"']);
while url.ends_with(')') && url.matches('(').count() < url.matches(')').count() {
url = &url[..url.len() - 1];
}
while url.ends_with(']') && url.matches('[').count() < url.matches(']').count() {
url = &url[..url.len() - 1];
}
let known = url.starts_with("http://") || url.starts_with("https://");
(known && url.len() > "https://".len()).then(|| url.to_string())
}
#[cfg(test)]
impl Session {
pub fn end_for_test(&mut self) {
self.ended.store(true, Ordering::Relaxed);
self.exit_status = Some(true);
}
pub fn end_dropped_for_test(&mut self) {
self.ended.store(true, Ordering::Relaxed);
self.exit_status = Some(false);
}
pub fn mark_ended(&self) {
self.ended.store(true, Ordering::Relaxed);
}
pub fn touch_input_for_test(&mut self) {
self.last_input = Some(std::time::Instant::now());
}
pub fn backdate_spawn_for_test(&mut self, by: std::time::Duration) {
self.spawned_at = std::time::Instant::now() - by;
}
pub fn for_test(agent_id: &str, agent_name: &str) -> Result<Self> {
let pty = NativePtySystem::default().openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let child = pty.slave.spawn_command(CommandBuilder::new("cat"))?;
drop(pty.slave);
let parser = Arc::new(Mutex::new(pane_parser(24, 80, 4000)));
let writer: Arc<Mutex<Box<dyn Write + Send>>> =
Arc::new(Mutex::new(pty.master.take_writer()?));
let mut reader = pty.master.try_clone_reader()?;
{
let parser = parser.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 {
break;
}
if let Ok(mut parser) = parser.lock() {
parser.process(&buf[..n]);
}
}
});
}
Ok(Self {
console_name: None,
announced_console: Arc::new(Mutex::new(None)),
client_id: None,
client_thread: None,
client_bridge: None,
opencode_bridge: None,
agent_id: agent_id.to_string(),
agent_name: agent_name.to_string(),
harness: "claude".to_string(),
durable_name: "test".to_string(),
ssh_target: "agent:test:test".to_string(),
identity: None,
relay_opts: Vec::new(),
parser,
writer,
child,
master: pty.master,
ended: Arc::new(AtomicBool::new(false)),
exit_status: None,
reattach: false,
spawned_at: std::time::Instant::now(),
got_output: Arc::new(AtomicBool::new(true)),
kitty_keys: Arc::new(AtomicBool::new(false)),
last_input: None,
size: (24, 80),
scroll: 0,
})
}
}
impl Drop for Session {
fn drop(&mut self) {
self.detach();
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Pointer {
Press,
Drag,
Release,
}
fn pointer_button(kind: Pointer) -> u16 {
match kind {
Pointer::Press | Pointer::Release => 0,
Pointer::Drag => 32,
}
}
fn pointer_report(
kind: Pointer,
at: (u16, u16),
encoding: vt100::MouseProtocolEncoding,
) -> Vec<u8> {
let button = pointer_button(kind);
let (col, row) = (at.0.max(1), at.1.max(1));
match encoding {
vt100::MouseProtocolEncoding::Sgr => {
let final_byte = if kind == Pointer::Release { 'm' } else { 'M' };
format!("\x1b[<{button};{col};{row}{final_byte}").into_bytes()
}
_ => {
let clamp = |v: u16| (v.min(223) + 32) as u8;
let button = if kind == Pointer::Release { 3 } else { button };
vec![
0x1b,
b'[',
b'M',
(button + 32) as u8,
clamp(col),
clamp(row),
]
}
}
}
fn wheel_report(up: bool, at: (u16, u16), encoding: vt100::MouseProtocolEncoding) -> Vec<u8> {
let button: u16 = if up { 64 } else { 65 };
let (col, row) = (at.0.max(1), at.1.max(1));
match encoding {
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
_ => {
let clamp = |v: u16| (v.min(223) + 32) as u8;
vec![
0x1b,
b'[',
b'M',
(button + 32) as u8,
clamp(col),
clamp(row),
]
}
}
}
fn encode_key_for(key: KeyEvent, kitty: bool) -> Option<Vec<u8>> {
if kitty
&& key.code == KeyCode::Enter
&& key
.modifiers
.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::CONTROL)
{
let m = 1
+ u8::from(key.modifiers.contains(KeyModifiers::SHIFT))
+ 2 * u8::from(key.modifiers.contains(KeyModifiers::ALT))
+ 4 * u8::from(key.modifiers.contains(KeyModifiers::CONTROL));
return Some(format!("\x1b[13;{m}u").into_bytes());
}
if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT) {
return Some(b"\x1b\r".to_vec());
}
encode_key(key)
}
pub fn encode_key(key: KeyEvent) -> Option<Vec<u8>> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let mut out: Vec<u8> = match key.code {
KeyCode::Char(c) if ctrl => {
let byte = match c.to_ascii_lowercase() {
c @ 'a'..='z' => (c as u8) - b'a' + 1,
'@' | ' ' => 0,
'[' => 27,
'\\' => 28,
']' => 29,
'^' => 30,
'_' | '?' => 31,
_ => return None,
};
vec![byte]
}
KeyCode::Char(c) => c.to_string().into_bytes(),
KeyCode::Enter => vec![b'\r'],
KeyCode::Tab => vec![b'\t'],
KeyCode::BackTab => b"\x1b[Z".to_vec(),
KeyCode::Backspace => vec![0x7f],
KeyCode::Esc => vec![0x1b],
KeyCode::Up => b"\x1b[A".to_vec(),
KeyCode::Down => b"\x1b[B".to_vec(),
KeyCode::Right => b"\x1b[C".to_vec(),
KeyCode::Left => b"\x1b[D".to_vec(),
KeyCode::Home => b"\x1b[H".to_vec(),
KeyCode::End => b"\x1b[F".to_vec(),
KeyCode::PageUp => b"\x1b[5~".to_vec(),
KeyCode::PageDown => b"\x1b[6~".to_vec(),
KeyCode::Insert => b"\x1b[2~".to_vec(),
KeyCode::Delete => b"\x1b[3~".to_vec(),
KeyCode::F(n @ 1..=4) => vec![0x1b, b'O', b'P' + (n - 1)],
KeyCode::F(n @ 5..=12) => {
let code = match n {
5 => 15,
6 => 17,
7 => 18,
8 => 19,
9 => 20,
10 => 21,
11 => 23,
_ => 24,
};
format!("\x1b[{code}~").into_bytes()
}
_ => return None,
};
if alt {
out.insert(0, 0x1b);
}
let _ = shift;
Some(out)
}
pub fn encode_paste(text: &str, bracketed: bool) -> Vec<u8> {
let text = text.replace("\x1b[201~", "");
let text = text.replace("\r\n", "\r").replace('\n', "\r");
if !bracketed {
return text.into_bytes();
}
let mut bytes = Vec::with_capacity(text.len() + 12);
bytes.extend_from_slice(b"\x1b[200~");
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
bytes
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
#[ignore = "requires an installed Codex binary"]
fn real_codex_scrollback_survives_panel_focus_and_a_lost_resize_release() {
use crate::commands::cloud_agent::tui::{
app::{App, ManageFocus, MouseAction},
ui,
};
use ratatui::{Terminal, backend::TestBackend};
let binary = std::env::var("RAILWAY_TEST_CODEX_BIN").expect("set RAILWAY_TEST_CODEX_BIN");
let root = tempfile::tempdir().unwrap();
let directory = root.path().canonicalize().unwrap();
std::fs::write(root.path().join("config.toml"), format!(
"check_for_update_on_startup = false\nmodel_provider = \"scroll_probe\"\nmodel = \"test\"\n[model_providers.scroll_probe]\nname = \"Scroll probe\"\nbase_url = \"http://127.0.0.1:9/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\n[projects.{}]\ntrust_level = \"trusted\"\n",
serde_json::to_string(&directory.to_string_lossy()).unwrap()
)).unwrap();
let mut cmd = CommandBuilder::new(binary);
cmd.env("CODEX_HOME", root.path());
cmd.arg("-C");
cmd.arg(&directory);
let pane = Session::spawn_pty(
"ca_1".into(),
"codex-scroll-probe".into(),
"codex".into(),
"",
None,
&[],
false,
"codex-test",
cmd,
34,
102,
|| {},
)
.unwrap();
let mut app = App::new(Vec::new(), None, Some("codex"), None, None, true);
app.attach_session(pane, "ca_1".into());
let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap();
terminal
.draw(|f| app.panes = ui::render_with_layout(&app, f).0)
.unwrap();
let rect = app.panes.session;
app.sessions[0].resize(rect.h, rect.w);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while !app.sessions[0]
.with_screen(|s| s.contents().contains("test default"))
.unwrap_or(false)
{
assert!(
std::time::Instant::now() < deadline,
"Codex startup: {:?}",
app.sessions[0].last_line()
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
for _ in 0..6 {
app.sessions[0].send(b"/status");
std::thread::sleep(std::time::Duration::from_millis(50));
app.sessions[0].send_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
std::thread::sleep(std::time::Duration::from_millis(200));
}
assert!(!app.sessions[0].wants_mouse());
assert!(
!app.sessions[0]
.with_screen(|s| s.alternate_screen())
.unwrap()
);
let live = app.sessions[0].with_screen(|s| s.contents()).unwrap();
for _ in 0..8 {
app.on_mouse(MouseAction::ScrollUp, rect.x + 4, rect.y + 4);
}
assert!(
app.sessions[0].scrolled_back(),
"Codex history must scroll through the app handler: active={:?}, focus={:?}, screen={live}",
app.active,
app.focus
);
assert_ne!(app.sessions[0].with_screen(|s| s.contents()).unwrap(), live);
let offset = app.sessions[0].scroll;
std::thread::sleep(std::time::Duration::from_millis(200));
terminal
.draw(|f| app.panes = ui::render_with_layout(&app, f).0)
.unwrap();
assert_eq!(
app.sessions[0].scroll, offset,
"redrawing preserves the history view"
);
for _ in 0..8 {
app.on_mouse(MouseAction::ScrollDown, rect.x + 4, rect.y + 4);
}
assert!(!app.sessions[0].scrolled_back());
let divider = app.panes.sidebar_divider;
app.on_mouse(MouseAction::Down, divider.x, divider.y);
app.on_mouse(MouseAction::Drag, divider.x + 4, divider.y);
app.on_mouse(MouseAction::ScrollUp, rect.x + 8, rect.y + 4);
assert!(app.sessions[0].scrolled_back());
assert!(!app.resizing_sidebar());
assert_eq!(app.focus, ManageFocus::Session);
}
#[cfg(unix)]
#[test]
fn codex_local_pty_handles_remote_args_auth_input_and_resize() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let binary = root.path().join("fake codex");
std::fs::write(&binary, r#"#!/usr/bin/env python3
import hashlib, json, os, pathlib, sys
assert os.isatty(0) and os.isatty(1)
assert os.environ['RAILWAY_CODEX_SERVER_TOKEN'] == "secret ' $(echo injected)"
backend = hashlib.sha256(b'wss://agent.example.com:443').hexdigest()[:16]
assert pathlib.Path(os.environ['CODEX_HOME']) == pathlib.Path.home() / '.railway/codex-client' / backend
assert sys.argv[1:] == ['-c', 'check_for_update_on_startup=false', '--remote', 'ws://127.0.0.1:54321', '--remote-auth-token-env', 'RAILWAY_CODEX_SERVER_TOKEN', '--cd', '/app/a project', 'resume', 'thread-1']
print('Codex ready', flush=True)
assert input() == 'hello'
size = os.get_terminal_size()
assert (size.lines, size.columns) == (30, 100), size
print('Codex complete', flush=True)
"#).unwrap();
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap();
let connection = codex::Connection {
url: "wss://agent.example.com:443".into(),
token: "secret ' $(echo injected)".into(),
directory: "/app/a project".into(),
version: "0.153.4".into(),
reused: true,
};
let mut pane = Session::spawn_client(
"agent-id".into(),
"box".into(),
&binary,
&client_sessions::Connection::Codex(connection),
Some("ws://127.0.0.1:54321"),
Some("thread-1"),
None,
24,
80,
|| {},
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !pane
.with_screen(|s| s.contents().contains("Codex ready"))
.unwrap_or(false)
{
assert!(
std::time::Instant::now() < deadline,
"{:?}",
pane.last_line()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
pane.resize(30, 100);
pane.write_raw(b"hello\n");
while !pane.finished() {
assert!(
std::time::Instant::now() < deadline,
"{:?}",
pane.last_line()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
pane.with_screen(|s| s.contents().contains("Codex complete"))
.unwrap()
);
assert_eq!(
pane.durable_name,
client_sessions::name("codex", "agent-id", Some("thread-1"))
);
assert!(pane.relay_opts.is_empty());
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[cfg(unix)]
#[test]
fn both_opencode_clients_resume_the_requested_thread_inside_a_resizable_pty() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let binary = root.path().join("fake opencode");
std::fs::write(
&binary,
r#"#!/usr/bin/env python3
import os, sys
assert os.isatty(0) and os.isatty(1)
assert os.environ['OPENCODE_SERVER_USERNAME'] == 'opencode'
assert os.environ['OPENCODE_SERVER_PASSWORD'] == "secret ' $(echo injected)"
assert sys.argv[-2:] == ['--session', 'ses_thread1']
if sys.argv[1] == 'attach':
assert sys.argv[2:-2] == ['https://agent.example.com', '--dir', '/app/a project']
else:
assert sys.argv[1:-2] == ['--server', 'https://agent.example.com', '--auto']
print('ready', flush=True)
assert input() == 'hello'
size = os.get_terminal_size()
assert (size.lines, size.columns) == (30, 100)
"#,
)
.unwrap();
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap();
for beta in [false, true] {
let c = crate::commands::cloud_agent::opencode::Connection {
url: "https://agent.example.com".into(),
username: "opencode".into(),
password: "secret ' $(echo injected)".into(),
directory: "/app/a project".into(),
reused: true,
};
let mut pane = Session::spawn_client(
"vm".into(),
"box".into(),
&binary,
&client_sessions::Connection::OpenCode(c, beta),
None,
Some("ses_thread1"),
None,
24,
80,
|| {},
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !pane
.with_screen(|s| s.contents().contains("ready"))
.unwrap_or(false)
{
assert!(
std::time::Instant::now() < deadline,
"{:?}",
pane.last_line()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
pane.resize(30, 100);
pane.write_raw(b"hello\n");
while !pane.finished() {
assert!(
std::time::Instant::now() < deadline,
"{:?}",
pane.last_line()
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(
pane.durable_name,
client_sessions::name(
if beta { "opencode2" } else { "opencode" },
"vm",
Some("ses_thread1")
)
);
}
}
fn drain_filter(chunks: &[&[u8]]) -> Vec<u8> {
let mut filter = BannerFilter::new();
let mut out = Vec::new();
for chunk in chunks {
out.extend_from_slice(&filter.push(chunk));
}
out.extend_from_slice(&filter.flush());
out
}
#[test]
fn the_relay_announcement_never_reaches_the_emulator() {
let out = drain_filter(&[b"Railway durable session: claude-abc123\r\nhello"]);
assert_eq!(out, b"hello");
}
#[test]
fn a_leading_blank_line_around_the_announcement_goes_with_it() {
let out = drain_filter(&[b"\r\nRailway durable session: claude-abc123\r\nhello"]);
assert_eq!(out, b"hello");
}
#[test]
fn a_banner_split_across_reads_is_still_removed() {
let out = drain_filter(&[b"Railway durable sess", b"ion: claude-abc123\r\nhello"]);
assert_eq!(out, b"hello");
}
#[test]
fn banner_bytes_are_held_back_not_fed_as_a_fragment() {
let mut filter = BannerFilter::new();
assert!(filter.push(b"Railway dur").is_empty());
let mut out = filter.push(b"able session: x-1\r\nok");
out.extend_from_slice(&filter.flush());
assert_eq!(out, b"ok");
assert_eq!(filter.name.as_deref(), Some("x-1"));
}
#[test]
fn relay_petname_updates_transport_without_replacing_the_thread() {
let mut filter = BannerFilter::new();
filter.push(b"Railway durable session exact-petname. Use CTRL + \\ then D to detach\r\n");
let mut pane = Session::for_test("agent", "box").unwrap();
pane.durable_name = client_sessions::draft_name("claude", "agent", "pane");
*pane.announced_console.lock().unwrap() = filter.name.take();
pane.sync_console_name();
assert_eq!(pane.console_name.as_deref(), Some("exact-petname"));
assert_eq!(
pane.durable_name,
client_sessions::draft_name("claude", "agent", "pane")
);
filter.push(b"Railway durable session forged\r\n");
assert!(filter.name.is_none());
}
#[test]
fn ordinary_output_flows_through_untouched() {
let out = drain_filter(&[b"\x1b[?1049h\x1b[Hhello\r\nworld"]);
assert_eq!(out, b"\x1b[?1049h\x1b[Hhello\r\nworld");
}
#[test]
fn terminal_startup_queries_are_answered_without_waiting_for_more_output() {
const BURST: &[u8] = b"\x1b[?2004h\x1b[?1004h\x1b[?u\x1b[c\x1b[6n";
for banner in [
b"".as_slice(),
b"Railway durable session: railway-test\r\n",
b"Railway durable session railway-test. Use CTRL + \\ then D to detach\r\n",
b"An unrecognized relay greeting\r\n",
] {
for chunk_size in 1..=BURST.len() {
let mut filter = BannerFilter::new();
let mut queries = TerminalReplies::default();
let mut parser = pane_parser(24, 80, 0);
let kitty = AtomicBool::new(false);
let mut replies = Vec::new();
for chunk in banner.chunks(chunk_size).chain(BURST.chunks(chunk_size)) {
let bytes = filter.push(chunk);
replies.extend(queries.process(&bytes, &mut parser, &kitty));
}
assert!(replies.starts_with(b"\x1b[?0u\x1b[?62;22c"));
assert!(replies.ends_with(b";1R"), "{replies:?}");
}
}
}
#[test]
fn cursor_queries_are_answered_in_order_at_their_position() {
let mut parser = pane_parser(24, 80, 0);
let replies = TerminalReplies::default().process(
b"hello\x1b[6n\r\nworld\x1b[6n",
&mut parser,
&AtomicBool::new(false),
);
assert_eq!(replies, b"\x1b[1;6R\x1b[2;6R");
}
#[test]
fn codex_palette_probe_preserves_host_colors_and_reply_order_across_reads() {
use terminal_colorsaurus::Color;
for (fg, bg) in [
(
Color::rgb(0xeeee, 0xdddd, 0xcccc),
Color::rgb(0x1234, 0x2345, 0x3456),
),
(
Color::rgb(0x1111, 0x2222, 0x3333),
Color::rgb(0xffff, 0xfafa, 0xefef),
),
] {
for terminator in ["\x07", "\x1b\\"] {
let burst = format!(
"hi\x1b[6n\x1b]10;?{terminator}\x1b]11;?{terminator}\x1b[0c\x1b[?u\r\nbye\x1b[6n"
);
let expected = format!(
"\x1b[1;3R\x1b]10;rgb:{:04x}/{:04x}/{:04x}\x1b\\\x1b]11;rgb:{:04x}/{:04x}/{:04x}\x1b\\\x1b[?62;22c\x1b[?0u\x1b[2;4R",
fg.r, fg.g, fg.b, bg.r, bg.g, bg.b
);
for chunk_size in 1..=burst.len() {
let mut parser = pane_parser(24, 80, 0);
parser.callbacks_mut().colors = Some(terminal_palette::DefaultColors {
fg: fg.clone(),
bg: bg.clone(),
});
let mut queries = TerminalReplies::default();
let mut filter = BannerFilter::new();
let kitty = AtomicBool::new(false);
let mut replies = Vec::new();
for chunk in burst.as_bytes().chunks(chunk_size) {
replies.extend(queries.process(&filter.push(chunk), &mut parser, &kitty));
}
assert_eq!(replies, expected.as_bytes(), "chunk size {chunk_size}");
assert_eq!(parser.screen().contents(), "hi\nbye");
assert!(parser.callbacks().replies.is_empty());
}
}
}
}
#[test]
fn palette_queries_need_no_following_csi_and_color_setters_stay_local() {
use terminal_colorsaurus::Color;
let mut parser = pane_parser(24, 80, 0);
parser.callbacks_mut().colors = Some(terminal_palette::DefaultColors {
fg: Color::rgb(0xffff, 0xffff, 0xffff),
bg: Color::rgb(0x1234, 0x2345, 0x3456),
});
let mut queries = TerminalReplies::default();
let kitty = AtomicBool::new(false);
assert!(queries.process(
b"\x1b]11;rgb:ffff/ffff/ffff\x07\x1b]0;title\x07\x1b]8;;https://example.com\x1b\\\x1b]52;c;?\x07",
&mut parser,
&kitty,
).is_empty());
assert_eq!(
queries.process(b"\x1b]11;?\x1b\\", &mut parser, &kitty),
b"\x1b]11;rgb:1234/2345/3456\x1b\\"
);
assert!(parser.screen().contents().is_empty());
}
#[test]
fn unavailable_host_palette_does_not_invent_colors_or_block_other_queries() {
let mut parser = pane_parser(24, 80, 0);
parser.callbacks_mut().colors = None;
let replies = TerminalReplies::default().process(
b"\x1b]10;?\x07\x1b]11;?\x1b\\\x1b[c\x1b[?u\x1b[6n",
&mut parser,
&AtomicBool::new(false),
);
assert_eq!(replies, b"\x1b[?62;22c\x1b[?0u\x1b[1;1R");
assert!(parser.screen().contents().is_empty());
}
#[test]
fn identical_text_after_the_announcement_is_kept() {
let out = drain_filter(&[
b"Railway durable session: one\r\n",
b"echo Railway durable session: two\r\n",
]);
assert_eq!(out, b"echo Railway durable session: two\r\n");
}
#[test]
fn kitty_query_push_and_pop_are_tracked() {
let kitty = AtomicBool::new(false);
let reply = kitty_scan(b"setup\x1b[?u more", &kitty);
assert_eq!(reply.as_deref(), Some(b"\x1b[?0u".as_slice()));
assert!(!kitty.load(Ordering::Relaxed));
assert_eq!(kitty_scan(b"\x1b[>1u", &kitty), None);
assert!(kitty.load(Ordering::Relaxed));
let reply = kitty_scan(b"\x1b[?u", &kitty);
assert_eq!(reply.as_deref(), Some(b"\x1b[?1u".as_slice()));
kitty_scan(b"\x1b[>0u", &kitty);
assert!(!kitty.load(Ordering::Relaxed));
kitty_scan(b"\x1b[>1u", &kitty);
kitty_scan(b"\x1b[<1u", &kitty);
assert!(!kitty.load(Ordering::Relaxed));
assert_eq!(kitty_scan(b"\x1b[38;5;2mgreen up\x1b[0m", &kitty), None);
assert!(!kitty.load(Ordering::Relaxed));
}
#[test]
fn modified_enter_is_csi_u_encoded_once_kitty_is_active() {
let enter = |m| KeyEvent::new(KeyCode::Enter, m);
let bytes = |key, kitty| encode_key_for(key, kitty).unwrap();
assert_eq!(bytes(enter(KeyModifiers::SHIFT), true), b"\x1b[13;2u");
assert_eq!(bytes(enter(KeyModifiers::ALT), true), b"\x1b[13;3u");
assert_eq!(bytes(enter(KeyModifiers::CONTROL), true), b"\x1b[13;5u");
assert_eq!(
bytes(enter(KeyModifiers::SHIFT | KeyModifiers::CONTROL), true),
b"\x1b[13;6u"
);
assert_eq!(bytes(enter(KeyModifiers::NONE), true), b"\r");
assert_eq!(bytes(enter(KeyModifiers::NONE), false), b"\r");
assert_eq!(bytes(enter(KeyModifiers::SHIFT), false), b"\x1b\r");
assert_eq!(bytes(enter(KeyModifiers::ALT), false), b"\x1b\r");
assert_eq!(bytes(enter(KeyModifiers::CONTROL), false), b"\r");
assert_eq!(bytes(key(KeyCode::Char('a')), true), b"a");
assert_eq!(bytes(key(KeyCode::Tab), true), b"\t");
}
#[cfg(unix)]
#[test]
fn the_kitty_encoding_goes_out_on_the_wire() {
let mut session = Session::for_test("ca", "test").unwrap();
session.kitty_keys.store(true, Ordering::Relaxed);
session.send_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
for _ in 0..40 {
if session
.with_screen(|s| s.contents().contains("[13;2u"))
.unwrap_or(false)
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
session
.with_screen(|s| s.contents().contains("[13;2u"))
.unwrap_or(false),
"expected the kitty encoding on the wire"
);
}
#[test]
fn the_environment_is_read_back_out_of_the_relay_target() {
let session = Session::for_test("ca", "test").unwrap();
assert_eq!(session.environment_id().as_deref(), Some("test"));
}
#[test]
fn text_and_enter_encode_as_themselves() {
assert_eq!(encode_key(key(KeyCode::Char('a'))).unwrap(), b"a");
assert_eq!(encode_key(key(KeyCode::Char('~'))).unwrap(), "~".as_bytes());
assert_eq!(encode_key(key(KeyCode::Enter)).unwrap(), b"\r");
assert_eq!(encode_key(key(KeyCode::Backspace)).unwrap(), &[0x7f]);
}
#[test]
fn control_chords_encode_to_control_bytes() {
let ctrl = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL);
assert_eq!(encode_key(ctrl('c')).unwrap(), &[0x03]);
assert_eq!(encode_key(ctrl('d')).unwrap(), &[0x04]);
assert_eq!(encode_key(ctrl('a')).unwrap(), &[0x01]);
assert_eq!(encode_key(ctrl('z')).unwrap(), &[0x1a]);
assert_eq!(encode_key(ctrl('C')).unwrap(), &[0x03]);
}
#[test]
fn arrows_and_function_keys_use_xterm_sequences() {
assert_eq!(encode_key(key(KeyCode::Up)).unwrap(), b"\x1b[A");
assert_eq!(encode_key(key(KeyCode::Left)).unwrap(), b"\x1b[D");
assert_eq!(encode_key(key(KeyCode::PageUp)).unwrap(), b"\x1b[5~");
assert_eq!(encode_key(key(KeyCode::F(1))).unwrap(), b"\x1bOP");
assert_eq!(encode_key(key(KeyCode::F(5))).unwrap(), b"\x1b[15~");
assert_eq!(encode_key(key(KeyCode::BackTab)).unwrap(), b"\x1b[Z");
}
#[test]
fn alt_prefixes_an_escape() {
let alt_b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT);
assert_eq!(encode_key(alt_b).unwrap(), b"\x1bb");
}
#[test]
fn keys_a_terminal_would_not_send_produce_nothing() {
assert!(encode_key(key(KeyCode::Null)).is_none());
assert!(encode_key(KeyEvent::new(KeyCode::CapsLock, KeyModifiers::NONE)).is_none());
}
#[test]
fn paste_encodes_for_the_mode_the_program_asked_for() {
assert_eq!(encode_paste("ship it", true), b"\x1b[200~ship it\x1b[201~");
assert_eq!(encode_paste("ship it", false), b"ship it");
assert_eq!(encode_paste("a\r\nb\nc", false), b"a\rb\rc");
assert_eq!(encode_paste("a\nb", true), b"\x1b[200~a\rb\x1b[201~");
}
#[test]
fn paste_cannot_smuggle_its_own_end_marker() {
assert_eq!(
encode_paste("safe\x1b[201~rm -rf /\r", true),
b"\x1b[200~saferm -rf /\r\x1b[201~"
);
}
#[test]
fn a_url_is_found_under_any_of_its_characters() {
let line = " see https://railway.com/project/abc for the deploy";
let url = "https://railway.com/project/abc";
let first = line.find(url).unwrap();
for col in first..first + url.len() {
assert_eq!(url_in(line, col).as_deref(), Some(url), "at {col}");
}
assert_eq!(url_in(line, 0), None);
assert_eq!(url_in(line, 2), None, "\"see\" is not a link");
assert_eq!(url_in(line, line.len() - 2), None);
}
#[test]
fn trailing_punctuation_is_not_part_of_the_link() {
for (line, want) in [
("open https://railway.com.", "https://railway.com"),
("open https://railway.com,", "https://railway.com"),
("(see https://railway.com)", "https://railway.com"),
("[https://railway.com]", "https://railway.com"),
] {
let col = line.find("https").unwrap() + 3;
assert_eq!(url_in(line, col).as_deref(), Some(want), "{line}");
}
let line = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
assert_eq!(url_in(line, 10).as_deref(), Some(line));
}
#[test]
fn non_links_are_left_alone() {
assert_eq!(url_in("just some words", 5), None);
assert_eq!(url_in("ftp://files.example.com", 4), None, "not a web link");
assert_eq!(url_in("https://", 2), None, "a scheme is not a link");
assert_eq!(url_in("railway.com", 3), None, "no scheme, no click");
assert_eq!(url_in("", 0), None);
assert_eq!(url_in("https://railway.com", 99), None, "past the end");
}
#[test]
fn a_link_on_the_screen_is_found_by_position() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 60);
session.send(b"open https://railway.com/deploy now\r\n");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let line = session
.with_screen(|s| s.contents_between(0, 0, 0, u16::MAX))
.unwrap_or_default();
if line.contains("open https://railway.com/deploy now") {
break;
}
assert!(
std::time::Instant::now() < deadline,
"PTY did not deliver the complete link line: {line:?}"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(
session.url_at(0, 10).as_deref(),
Some("https://railway.com/deploy")
);
assert_eq!(session.url_at(0, 1), None, "not over the link");
assert_eq!(session.url_at(99, 10), None, "off the screen");
}
#[test]
fn a_link_wrapped_across_rows_is_found_whole() {
let url = "https://accounts.example.com/oauth/authorize?client_id=abcdef123456&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Fcallback&scope=openid+profile";
assert!(url.len() > 100, "long enough to wrap a 40-column pane");
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(24, 40);
session.send(format!("{url}\r\n").as_bytes());
let rows = url.len().div_ceil(40) as u16;
for _ in 0..100 {
if session.url_at(rows, 0).is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
for row in 0..rows {
let last = if row == rows - 1 {
(url.len() % 40) as u16
} else {
40
};
for col in 0..last {
assert_eq!(
session.url_at(row, col).as_deref(),
Some(url),
"row {row} col {col}"
);
}
}
}
#[test]
fn wrapping_does_not_invent_links() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(8, 20);
session.send(b"the quick brown fox jumps over the lazy dog\r\n");
std::thread::sleep(std::time::Duration::from_millis(80));
for row in 0..3 {
for col in 0..20 {
assert_eq!(session.url_at(row, col), None, "row {row} col {col}");
}
}
}
#[test]
fn scrolling_changes_what_the_screen_shows() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
for i in 0..40 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("line-39"))
.unwrap_or(false);
if seen {
break;
}
}
let live = session.with_screen(|s| s.contents()).unwrap();
assert!(live.contains("line-39"), "expected the tail:\n{live}");
assert!(!session.scrolled_back());
session.scroll_by(10);
assert!(session.scrolled_back(), "the offset should have moved");
let scrolled = session.with_screen(|s| s.contents()).unwrap();
assert_ne!(
scrolled, live,
"the screen must actually change:\n{scrolled}"
);
session.send(b"x");
assert!(!session.scrolled_back());
}
#[test]
fn scrolling_reaches_the_whole_history() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
for i in 0..120 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("line-119"))
.unwrap_or(false);
if seen {
break;
}
}
session.scroll_by(isize::MAX);
assert!(
session.scroll > 100,
"120 lines through a 6-row pane should leave far more than one \
screen of history, got offset {}",
session.scroll
);
let top = session.with_screen(|s| s.contents()).unwrap();
assert!(
top.contains("line-0"),
"the very first line should be visible at full depth:\n{top}"
);
session.scroll_by(isize::MIN);
assert!(!session.scrolled_back());
let live = session.with_screen(|s| s.contents()).unwrap();
assert!(live.contains("line-119"), "back to the tail:\n{live}");
}
#[test]
fn top_scrolling_regions_preserve_history_and_the_fixed_composer() {
let mut parser = pane_parser(6, 30, 20);
parser.process(b"\x1b[5;1Hcomposer\x1b[6;1Hfooter\x1b[1;4r\x1b[1;1H");
for i in 0..40 {
parser.process(format!("\x1b[31mline-{i:02}\x1b[0m\r\n").as_bytes());
}
assert_eq!(parser.screen().cell(4, 0).unwrap().contents(), "c");
assert_eq!(parser.screen().cell(5, 0).unwrap().contents(), "f");
assert!(parser.screen().contents().contains("line-39"));
parser.screen_mut().set_scrollback(usize::MAX);
assert_eq!(
parser.screen().scrollback(),
20,
"retention remains bounded"
);
assert!(parser.screen().contents().contains("line-17"));
assert_eq!(
parser.screen().cell(0, 0).unwrap().fgcolor(),
vt100::Color::Idx(1)
);
let history = parser.screen().contents();
parser.process(b"line-40\r\n");
assert!(parser.screen().scrollback() > 0);
assert_ne!(
parser.screen().contents(),
history,
"oldest retained row was evicted"
);
parser.screen_mut().set_scrollback(0);
assert!(parser.screen().contents().contains("line-40"));
assert_eq!(parser.screen().cell(4, 0).unwrap().contents(), "c");
assert_eq!(parser.screen().cell(5, 0).unwrap().contents(), "f");
}
#[test]
fn scrolling_below_a_header_and_on_alternate_screens_stays_out_of_history() {
for setup in [
b"\x1b[2;4r\x1b[2;1H".as_slice(),
b"\x1b[?1049h\x1b[1;4r\x1b[1;1H".as_slice(),
] {
let mut parser = pane_parser(6, 30, 20);
parser.process(setup);
for i in 0..40 {
parser.process(format!("line-{i:02}\r\n").as_bytes());
}
parser.screen_mut().set_scrollback(usize::MAX);
assert_eq!(parser.screen().scrollback(), 0);
}
}
#[test]
fn scrolling_walks_past_one_screenful() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
for i in 0..60 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("line-59"))
.unwrap_or(false);
if seen {
break;
}
}
session.scroll(true, 5, (1, 1));
let one = session.scroll;
session.scroll(true, 5, (1, 1));
let two = session.scroll;
session.scroll(true, 5, (1, 1));
let three = session.scroll;
assert!(one < two && two < three, "each notch must go deeper");
assert!(
three > 6,
"three notches should pass the height of the pane, got {three}"
);
let deep = session.with_screen(|s| s.contents()).unwrap();
assert!(
!deep.contains("line-59"),
"the tail should have scrolled out of view:\n{deep}"
);
}
#[test]
fn a_deep_scroll_survives_resize() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(10, 40);
for i in 0..100 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("line-99"))
.unwrap_or(false);
if seen {
break;
}
}
session.scroll_by(60);
assert!(session.scroll > 10, "start well past one screen");
session.resize(4, 40);
assert!(session.scrolled_back(), "the offset survives shrinking");
let shrunk = session.with_screen(|s| s.contents()).unwrap();
assert!(!shrunk.is_empty(), "a shrunk pane still renders history");
session.resize(20, 40);
let grown = session.with_screen(|s| s.contents()).unwrap();
assert!(!grown.is_empty(), "a grown pane still renders history");
session.send(b"x");
assert!(!session.scrolled_back());
}
#[test]
fn scrollback_survives_churn() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(8, 40);
let sizes = [(4u16, 30u16), (12, 60), (6, 40), (24, 80), (8, 40)];
for (round, &(rows, cols)) in sizes.iter().enumerate() {
for i in 0..40 {
session.send(format!("round-{round}-line-{i}\r\n").as_bytes());
}
session.scroll_by(37);
session.resize(rows, cols);
session.scroll_by(-13);
let held = session.scroll;
let history = session
.with_screen(|s| s.scrollback())
.expect("the emulator stays lockable");
assert!(
history >= held,
"the held offset never passes the emulator (held {held}, emulator {history})"
);
assert!(
session.with_screen(|s| s.contents()).is_some(),
"the view renders mid-churn"
);
}
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("round-4-line-39"))
.unwrap_or(false);
if seen {
break;
}
}
session.scroll_by(isize::MAX);
let top = session.with_screen(|s| s.contents()).unwrap();
assert!(
top.contains("round-0-line-"),
"the first round is still reachable at full depth:\n{top}"
);
session.send(b"x");
assert!(!session.scrolled_back(), "typing still snaps back to live");
}
#[test]
fn scrollback_clamps_at_capacity() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
for i in 0..4200 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..300 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("line-4199"))
.unwrap_or(false);
if seen {
break;
}
}
session.scroll_by(isize::MAX);
assert_eq!(
session.scroll, 4000,
"full depth is the retention limit, no further"
);
let top = session.with_screen(|s| s.contents()).unwrap();
assert!(
!top.contains("line-0\r") && !top.contains("line-0\n"),
"the very first lines fell out of retention:\n{top}"
);
assert!(
top.contains("line-"),
"what is shown is still real history:\n{top}"
);
}
#[cfg(unix)]
#[test]
fn a_flood_drains_under_render_contention() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(40, 200);
let line = "x".repeat(196);
let started = std::time::Instant::now();
for i in 0..20_000 {
session.send(format!("{line}\r\n").as_bytes());
if i % 50 == 0 {
let _ = session.with_screen(|s| s.contents());
}
}
session.send(b"FLOOD-DRAINED-MARKER\r\n");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut seen = false;
while std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
if session
.with_screen(|s| s.contents().contains("FLOOD-DRAINED-MARKER"))
.unwrap_or(false)
{
seen = true;
break;
}
}
assert!(seen, "the flood never finished draining");
assert!(!session.ended(), "a flood must not kill the session");
let history = session.with_screen(|s| s.scrollback());
session.scroll_by(isize::MAX);
assert!(
session.scroll <= 4000,
"history is clamped at retention, not the flood's size (scroll {}, scrollback {history:?})",
session.scroll
);
eprintln!(
"flood drained in {:?} ({} lines)",
started.elapsed(),
20_000
);
}
#[cfg(unix)]
#[test]
fn scrolling_an_alternate_screen_reaches_the_application() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
session.send(b"\x1b[?1049h\r\n");
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
if session
.with_screen(|s| s.alternate_screen())
.unwrap_or(false)
{
break;
}
}
assert!(
session.with_screen(|s| s.alternate_screen()).unwrap(),
"the fixture should be on the alternate screen"
);
session.scroll(true, 2, (1, 1));
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
if session.scrolled_back() {
break;
}
}
assert!(
!session.scrolled_back(),
"an alternate screen must not scroll locally"
);
}
#[test]
fn wheel_reports_match_the_terminal_protocol() {
let sgr_up = wheel_report(true, (12, 5), vt100::MouseProtocolEncoding::Sgr);
assert_eq!(String::from_utf8(sgr_up).unwrap(), "\x1b[<64;12;5M");
let sgr_down = wheel_report(false, (1, 1), vt100::MouseProtocolEncoding::Sgr);
assert_eq!(String::from_utf8(sgr_down).unwrap(), "\x1b[<65;1;1M");
let legacy = wheel_report(true, (300, 2), vt100::MouseProtocolEncoding::Default);
assert_eq!(legacy[..3], [0x1b, b'[', b'M']);
assert_eq!(legacy[3], 96, "button 64 plus the 32 offset");
assert_eq!(legacy[4], 255, "clamped to the encodable maximum");
assert_eq!(legacy[5], 34);
}
#[test]
fn dsr_reply_answers_with_the_current_cursor_position() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"hello\r\n\x1b[6n");
let reply = dsr_reply(b"hello\r\n\x1b[6n", parser.screen());
assert_eq!(reply, Some(b"\x1b[2;1R".to_vec()));
}
#[test]
fn dsr_reply_is_none_without_a_query() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"just some output\r\n");
assert_eq!(dsr_reply(b"just some output\r\n", parser.screen()), None);
}
#[test]
fn da1_is_answered_in_either_spelling() {
let reply = Some(b"\x1b[?62;22c".to_vec());
assert_eq!(da1_reply(b"\x1b[c"), reply);
assert_eq!(da1_reply(b"\x1b[0c"), reply);
assert_eq!(da1_reply(b"\x1b[?2004h\x1b[?u\x1b[c\x1b[6n"), reply);
}
#[test]
fn da1_reply_is_none_without_a_query() {
assert_eq!(da1_reply(b"just some output\r\n"), None);
assert_eq!(da1_reply(b"\x1b[?62;22c"), None);
assert_eq!(da1_reply(b"\x1b[38;5;2mcyan code\x1b[0m"), None);
assert_eq!(da1_reply(b"\x1b[2J\x1b[Hcat"), None);
}
#[test]
fn the_harness_startup_burst_gets_every_answer() {
const BURST: &[u8] = b"\x1b[?2004h\x1b[?1004h\x1b[?u\x1b[c\x1b[6n";
let kitty = AtomicBool::new(false);
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(BURST);
let mut replies = kitty_scan(BURST, &kitty).expect("the kitty query is answered");
replies.extend_from_slice(&da1_reply(BURST).expect("DA1 is answered"));
replies.extend_from_slice(
&dsr_reply(BURST, parser.screen()).expect("the cursor query is answered"),
);
assert_eq!(replies, b"\x1b[?0u\x1b[?62;22c\x1b[1;1R");
}
#[cfg(unix)]
#[test]
fn a_mouse_aware_application_receives_the_wheel() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
session.send(b"\x1b[?1049h\x1b[?1000h\x1b[?1006h\r\n");
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
let ready = session
.with_screen(|s| {
s.alternate_screen()
&& s.mouse_protocol_mode() != vt100::MouseProtocolMode::None
})
.unwrap_or(false);
if ready {
break;
}
}
assert!(
session
.with_screen(|s| s.mouse_protocol_mode() != vt100::MouseProtocolMode::None)
.unwrap(),
"the fixture should have mouse reporting on"
);
assert!(session.scrollable(), "the wheel has somewhere to go");
session.scroll(true, 3, (4, 2));
assert!(
!session.scrolled_back(),
"the wheel went to the application, not to local history"
);
}
#[test]
fn pointer_reports_match_the_terminal_protocol() {
use vt100::MouseProtocolEncoding::{Default as Legacy, Sgr};
let press = pointer_report(Pointer::Press, (12, 5), Sgr);
assert_eq!(String::from_utf8(press).unwrap(), "\x1b[<0;12;5M");
let drag = pointer_report(Pointer::Drag, (12, 6), Sgr);
assert_eq!(String::from_utf8(drag).unwrap(), "\x1b[<32;12;6M");
let release = pointer_report(Pointer::Release, (12, 6), Sgr);
assert_eq!(String::from_utf8(release).unwrap(), "\x1b[<0;12;6m");
let legacy = pointer_report(Pointer::Release, (2, 3), Legacy);
assert_eq!(legacy, vec![0x1b, b'[', b'M', 32 + 3, 34, 35]);
}
#[cfg(unix)]
#[test]
fn a_mouse_aware_application_receives_a_click() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
session.send(b"\x1b[?1002h\x1b[?1006h\r\n");
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
if session.wants_mouse() {
break;
}
}
assert!(session.wants_mouse(), "the fixture should want the mouse");
assert!(session.pointer(Pointer::Press, (4, 2)));
assert!(session.pointer(Pointer::Drag, (6, 2)));
assert!(session.pointer(Pointer::Release, (6, 2)));
}
#[test]
fn an_application_without_mouse_reporting_gets_no_clicks() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(!session.wants_mouse());
assert!(!session.pointer(Pointer::Press, (4, 2)));
assert!(!session.pointer(Pointer::Release, (4, 2)));
}
#[cfg(unix)]
#[test]
fn press_only_mode_hears_only_presses() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
session.send(b"\x1b[?9h\r\n");
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
if session.wants_mouse() {
break;
}
}
assert!(session.wants_mouse());
assert!(session.pointer(Pointer::Press, (4, 2)));
assert!(!session.pointer(Pointer::Drag, (5, 2)));
assert!(!session.pointer(Pointer::Release, (5, 2)));
}
#[test]
fn scrolling_cannot_pass_the_emulators_limit() {
let mut session = Session::for_test("ca", "test").unwrap();
session.resize(6, 40);
for i in 0..40 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(20));
if session
.with_screen(|s| s.contents().contains("line-39"))
.unwrap_or(false)
{
break;
}
}
session.scroll_by(10_000);
let contents = session.with_screen(|s| s.contents());
assert!(contents.is_some(), "the screen must still be readable");
session.resize(3, 40);
let contents = session.with_screen(|s| s.contents());
assert!(contents.is_some(), "a shrink must not leave a bad offset");
session.scroll_by(-10_000);
assert!(!session.scrolled_back(), "and back to live");
}
#[test]
fn the_emulator_renders_what_was_written() {
let mut parser = vt100::Parser::new(4, 20, 100);
parser.process(b"hello\r\nworld");
let screen = parser.screen();
assert_eq!(screen.contents().lines().next().unwrap().trim(), "hello");
assert!(screen.contents().contains("world"));
}
}