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::ssh::native;
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 environment_from_target(target: &str) -> Option<&str> {
match *target.split(':').collect::<Vec<_>>().as_slice() {
["agent", environment, agent] if !environment.is_empty() && !agent.is_empty() => {
Some(environment)
}
_ => None,
}
}
pub struct Session {
pub agent_id: String,
pub agent_name: String,
#[allow(dead_code)]
pub harness: String,
pub durable_name: String,
pub ssh_target: String,
pub identity: Option<std::path::PathBuf>,
pub relay_opts: Vec<String>,
parser: Arc<Mutex<vt100::Parser>>,
writer: Box<dyn Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
master: Box<dyn portable_pty::MasterPty + Send>,
ended: Arc<AtomicBool>,
size: (u16, u16),
scroll: usize,
}
impl Session {
pub fn environment_id(&self) -> Option<&str> {
environment_from_target(&self.ssh_target)
}
#[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 pty = NativePtySystem::default()
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("Failed to allocate a pty for the agent session")?;
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);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let child = pty
.slave
.spawn_command(cmd)
.context("Failed to start ssh for the agent session")?;
drop(pty.slave);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 4000)));
let ended = Arc::new(AtomicBool::new(false));
let mut reader = pty
.master
.try_clone_reader()
.context("Failed to read the agent session")?;
let writer = pty
.master
.take_writer()
.context("Failed to write to the agent session")?;
{
let parser = parser.clone();
let ended = ended.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if let Ok(mut parser) = parser.lock() {
parser.process(&buf[..n]);
}
notify();
}
}
}
ended.store(true, Ordering::Relaxed);
notify();
});
}
Ok(Self {
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,
size: (rows, cols),
scroll: 0,
})
}
pub fn ended(&self) -> bool {
self.ended.load(Ordering::Relaxed)
}
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.set_size(rows, cols);
let ceiling = rows.saturating_sub(1) as usize;
if self.scroll > ceiling {
self.scroll = ceiling;
parser.set_scrollback(ceiling);
}
}
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;
}
let _ = self.writer.write_all(&pointer_report(kind, at, encoding));
let _ = self.writer.flush();
true
}
pub fn scroll_by(&mut self, delta: isize) {
let Ok(mut parser) = self.parser.lock() else {
return;
};
let ceiling = self.size.0.saturating_sub(1) as isize;
let wanted = (self.scroll as isize + delta).clamp(0, ceiling.max(0)) as usize;
parser.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));
}
let _ = self.writer.write_all(&out);
let _ = self.writer.flush();
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.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();
let _ = self.writer.write_all(bytes);
let _ = self.writer.flush();
}
pub fn send_key(&mut self, key: KeyEvent) {
if let Some(bytes) = encode_key(key) {
self.send(&bytes);
}
}
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 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(vt100::Parser::new(24, 80, 4000)));
let writer = 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 {
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)),
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),
]
}
}
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[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 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");
for _ in 0..40 {
if session
.with_screen(|s| s.contents_between(0, 0, 0, u16::MAX))
.is_some_and(|line| line.contains("railway.com"))
{
break;
}
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());
}
#[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);
}
#[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"));
}
#[test]
fn environment_is_read_out_of_a_relay_target() {
assert_eq!(
environment_from_target("agent:env-123:agent-456"),
Some("env-123")
);
}
#[test]
fn other_target_shapes_are_not_guessed_at() {
assert_eq!(environment_from_target("sbx:env-123:sandbox-456"), None);
assert_eq!(environment_from_target("agent:env-123"), None);
assert_eq!(environment_from_target("agent::agent-456"), None);
assert_eq!(environment_from_target("agent:env-123:"), None);
assert_eq!(environment_from_target("some-service-instance"), None);
}
}