use crate::ssp::SyncState;
use serde::{Deserialize, Serialize};
mod server;
pub use server::{Progress, ServerTerminal, UNHANDLED_OSC_MAX_LEN, UNHANDLED_OSC_RING};
pub const DEFAULT_ROWS: u16 = 24;
pub const DEFAULT_COLS: u16 = 80;
pub const MIN_DIM: u16 = 2;
pub const MAX_DIM: u16 = 1000;
pub(crate) const MAX_TITLE_LEN: usize = 256;
pub const MAXIMUM_CLIPBOARD_SIZE: usize = 16 * 1024;
#[must_use]
pub fn clamp_dims(rows: u16, cols: u16) -> (u16, u16) {
(rows.clamp(MIN_DIM, MAX_DIM), cols.clamp(MIN_DIM, MAX_DIM))
}
pub(crate) fn process_contained<C: vt100::Callbacks>(
parser: &mut vt100::Parser<C>,
bytes: &[u8],
) -> bool {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parser.process(bytes))).is_ok()
}
fn blank_screen(rows: u16, cols: u16) -> vt100::Screen {
vt100::Parser::new(rows, cols, 0).screen().clone()
}
fn capped_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect()
}
}
fn capped_bytes(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or("").to_string()
}
pub struct TerminalScreen {
screen: vt100::Screen,
echo_ack: u64,
title: String,
icon: String,
clipboard: String,
bell_count: u64,
exit_code: Option<u32>,
parser: Option<Box<vt100::Parser>>,
}
impl Clone for TerminalScreen {
fn clone(&self) -> Self {
Self {
screen: self.screen.clone(),
echo_ack: self.echo_ack,
title: self.title.clone(),
icon: self.icon.clone(),
clipboard: self.clipboard.clone(),
bell_count: self.bell_count,
exit_code: self.exit_code,
parser: None,
}
}
}
impl std::fmt::Debug for TerminalScreen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TerminalScreen")
.field("size", &self.screen.size())
.field("echo_ack", &self.echo_ack)
.field("title", &self.title)
.field("icon", &self.icon)
.field("clipboard", &self.clipboard)
.field("bell_count", &self.bell_count)
.field("exit_code", &self.exit_code)
.finish_non_exhaustive()
}
}
impl Default for TerminalScreen {
fn default() -> Self {
Self {
screen: blank_screen(DEFAULT_ROWS, DEFAULT_COLS),
echo_ack: 0,
title: String::new(),
icon: String::new(),
clipboard: String::new(),
bell_count: 0,
exit_code: None,
parser: None,
}
}
}
impl TerminalScreen {
pub fn from_bytes(rows: u16, cols: u16, bytes: &[u8]) -> Self {
let mut p = vt100::Parser::new(rows, cols, 0);
p.process(bytes);
Self {
screen: p.screen().clone(),
echo_ack: 0,
title: String::new(),
icon: String::new(),
clipboard: String::new(),
bell_count: 0,
exit_code: None,
parser: None,
}
}
pub fn screen(&self) -> &vt100::Screen {
&self.screen
}
pub fn exit_code(&self) -> Option<u32> {
self.exit_code
}
pub fn size(&self) -> (u16, u16) {
self.screen.size()
}
pub fn echo_ack(&self) -> u64 {
self.echo_ack
}
pub fn set_echo_ack(&mut self, ack: u64) {
self.echo_ack = ack;
}
pub fn title(&self) -> &str {
&self.title
}
pub fn icon(&self) -> &str {
&self.icon
}
pub fn clipboard(&self) -> &str {
&self.clipboard
}
pub fn bell_count(&self) -> u64 {
self.bell_count
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScreenDiff {
pub resize: Option<(u16, u16)>,
pub echo_ack: u64,
pub title: Option<String>,
pub icon: Option<String>,
pub clipboard: Option<String>,
pub bell_count: u64,
pub exit_code: Option<u32>,
pub vt: Vec<u8>,
}
impl SyncState for TerminalScreen {
type Diff = ScreenDiff;
const RECV_DECODE_LIMIT: usize = crate::wire::MAX_DECOMPRESSED;
const RECEIVE_BUDGET_UNITS: usize = 8 * (MAX_DIM as usize) * (MAX_DIM as usize);
fn resource_units(&self) -> usize {
let (rows, cols) = self.screen.size();
((rows as usize) * (cols as usize))
.saturating_add(self.title.len())
.saturating_add(self.icon.len())
.saturating_add(self.clipboard.len())
}
fn diff_from(&self, base: &Self) -> Self::Diff {
let resized = self.size() != base.size();
let vt = if resized {
self.screen.state_formatted()
} else {
self.screen.state_diff(&base.screen)
};
ScreenDiff {
resize: resized.then(|| self.size()),
echo_ack: self.echo_ack,
title: (self.title != base.title).then(|| self.title.clone()),
icon: (self.icon != base.icon).then(|| self.icon.clone()),
clipboard: (self.clipboard != base.clipboard).then(|| self.clipboard.clone()),
bell_count: self.bell_count,
exit_code: self.exit_code,
vt,
}
}
fn apply(&mut self, diff: &Self::Diff) {
if let Some((rows, cols)) = diff.resize {
let (rows, cols) = clamp_dims(rows, cols);
let mut p = Box::new(vt100::Parser::new(rows, cols, 0));
if !process_contained(&mut p, &diff.vt) {
return;
}
self.screen = p.screen().clone();
self.parser = Some(p);
} else {
if self.parser.is_none() {
let (rows, cols) = self.screen.size();
let mut p = Box::new(vt100::Parser::new(rows, cols, 0));
if !process_contained(&mut p, &self.screen.state_formatted()) {
return; }
self.parser = Some(p);
}
let Some(parser) = self.parser.as_mut() else {
return; };
if !diff.vt.is_empty() && !process_contained(parser, &diff.vt) {
self.parser = None;
return;
}
self.screen = parser.screen().clone();
}
self.echo_ack = self.echo_ack.max(diff.echo_ack);
self.bell_count = self.bell_count.max(diff.bell_count);
if let Some(title) = &diff.title {
self.title = capped_chars(title, MAX_TITLE_LEN);
}
if let Some(icon) = &diff.icon {
self.icon = capped_chars(icon, MAX_TITLE_LEN);
}
if let Some(clipboard) = &diff.clipboard {
self.clipboard = capped_bytes(clipboard, MAXIMUM_CLIPBOARD_SIZE);
}
if diff.exit_code.is_some() {
self.exit_code = diff.exit_code;
}
}
}
impl PartialEq for TerminalScreen {
fn eq(&self, other: &Self) -> bool {
self.echo_ack == other.echo_ack
&& self.title == other.title
&& self.icon == other.icon
&& self.clipboard == other.clipboard
&& self.bell_count == other.bell_count
&& self.exit_code == other.exit_code
&& self.screen.size() == other.screen.size()
&& self.screen.state_diff(&other.screen).is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssp::testkit::{LinkParams, SimHarness};
fn screen_from(rows: u16, cols: u16, bytes: &[u8]) -> TerminalScreen {
TerminalScreen::from_bytes(rows, cols, bytes)
}
#[test]
fn diff_apply_roundtrip_simple() {
let base = TerminalScreen::default();
let target = screen_from(24, 80, b"hello \x1b[31mworld\x1b[m");
let diff = target.diff_from(&base);
let mut c = base;
c.apply(&diff);
assert_eq!(c, target);
}
#[test]
fn diff_apply_roundtrip_incremental() {
let a = screen_from(24, 80, b"line one\r\nline two");
let b = screen_from(24, 80, b"line one\r\nline two\r\nline three\x1b[1;1Hedited");
let diff = b.diff_from(&a);
assert!(!diff.vt.is_empty());
assert!(diff.resize.is_none());
let mut c = a;
c.apply(&diff);
assert_eq!(c, b);
}
#[test]
fn resize_roundtrip_full_repaint() {
let a = screen_from(24, 80, b"small screen content here");
let b = screen_from(
40,
120,
b"now a much wider and taller screen\r\nwith two lines",
);
let diff = b.diff_from(&a);
assert_eq!(diff.resize, Some((40, 120)));
let mut c = a;
c.apply(&diff);
assert_eq!(c, b);
assert_eq!(c.size(), (40, 120));
}
#[test]
fn clamp_dims_bounds_both_extremes() {
assert_eq!(
clamp_dims(65000, 65000),
(MAX_DIM, MAX_DIM),
"huge -> MAX_DIM"
);
assert_eq!(clamp_dims(0, 0), (MIN_DIM, MIN_DIM), "zero -> MIN_DIM");
assert_eq!(clamp_dims(24, 80), (24, 80), "in-range passes through");
assert_eq!(
clamp_dims(0, 5000),
(MIN_DIM, MAX_DIM),
"mixed clamps each axis"
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
#[test]
fn apply_is_panic_free_and_holds_invariants(
vt in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..4096),
resize in proptest::option::of((proptest::prelude::any::<u16>(), proptest::prelude::any::<u16>())),
title in proptest::option::of(".{0,512}"),
icon in proptest::option::of(".{0,512}"),
clipboard in proptest::option::of(".{0,40000}"),
echo_ack in proptest::prelude::any::<u64>(),
bell_count in proptest::prelude::any::<u64>(),
exit_code in proptest::option::of(proptest::prelude::any::<u32>()),
) {
let diff = ScreenDiff { resize, echo_ack, title, icon, clipboard, bell_count, exit_code, vt };
let mut screen = TerminalScreen::default();
screen.apply(&diff); let (rows, cols) = screen.size();
proptest::prop_assert!((MIN_DIM..=MAX_DIM).contains(&rows), "rows {rows} escaped the clamp");
proptest::prop_assert!((MIN_DIM..=MAX_DIM).contains(&cols), "cols {cols} escaped the clamp");
proptest::prop_assert!(screen.title.chars().count() <= MAX_TITLE_LEN);
proptest::prop_assert!(screen.icon.chars().count() <= MAX_TITLE_LEN);
proptest::prop_assert!(screen.clipboard.len() <= MAXIMUM_CLIPBOARD_SIZE);
}
}
#[test]
fn client_apply_clamps_oom_resize() {
let mut c = TerminalScreen::default();
let diff = ScreenDiff {
resize: Some((65000, 65000)),
echo_ack: 0,
title: None,
icon: None,
clipboard: None,
bell_count: 0,
exit_code: None,
vt: b"hello".to_vec(),
};
c.apply(&diff); assert_eq!(c.size(), (MAX_DIM, MAX_DIM), "client clamps a giant resize");
}
#[test]
fn client_apply_clamps_zero_resize() {
let mut c = TerminalScreen::default();
let diff = ScreenDiff {
resize: Some((0, 0)),
echo_ack: 0,
title: None,
icon: None,
clipboard: None,
bell_count: 0,
exit_code: None,
vt: "AAAA日本🦀\r\nBBBB\r\n".repeat(8).into_bytes(),
};
c.apply(&diff); assert_eq!(
c.size(),
(MIN_DIM, MIN_DIM),
"client clamps a zero-dimension resize"
);
}
#[test]
fn client_apply_caps_oversized_title_and_clipboard() {
let mut c = TerminalScreen::default();
let big_title = "T".repeat(MAX_TITLE_LEN + 1000);
let big_clip = "C".repeat(MAXIMUM_CLIPBOARD_SIZE + 1000);
let diff = ScreenDiff {
resize: None,
echo_ack: 0,
title: Some(big_title),
icon: Some("I".repeat(MAX_TITLE_LEN + 5)),
clipboard: Some(big_clip),
bell_count: 0,
exit_code: None,
vt: Vec::new(),
};
c.apply(&diff);
assert_eq!(
c.title().chars().count(),
MAX_TITLE_LEN,
"title capped client-side"
);
assert_eq!(
c.icon().chars().count(),
MAX_TITLE_LEN,
"icon capped client-side"
);
assert!(
c.clipboard().len() <= MAXIMUM_CLIPBOARD_SIZE,
"clipboard capped client-side"
);
}
#[test]
fn capped_bytes_never_splits_utf8() {
let s = "a".repeat(MAXIMUM_CLIPBOARD_SIZE - 1) + "é"; let out = capped_bytes(&s, MAXIMUM_CLIPBOARD_SIZE);
assert!(out.len() <= MAXIMUM_CLIPBOARD_SIZE);
assert_eq!(
out.len(),
MAXIMUM_CLIPBOARD_SIZE - 1,
"the straddling scalar is dropped whole"
);
}
#[test]
fn equal_screens_compare_equal() {
let a = screen_from(24, 80, b"identical");
let b = screen_from(24, 80, b"identical");
assert_eq!(a, b);
assert!(a.diff_from(&b).vt.is_empty());
}
#[test]
fn icon_and_clipboard_roundtrip_and_resist_collapse() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"\x1b]1;myicon\x07\x1b]2;mytitle\x07\x1b]52;c;aGk=\x07");
let target = emu.snapshot();
assert_eq!(target.icon(), "myicon");
assert_eq!(target.clipboard(), "aGk=");
let base = TerminalScreen::default();
let diff = target.diff_from(&base);
assert_eq!(diff.icon.as_deref(), Some("myicon"));
assert_eq!(diff.clipboard.as_deref(), Some("aGk="));
let mut c = base.clone();
c.apply(&diff);
assert_eq!(c, target, "icon + clipboard reconstruct via diff/apply");
assert_ne!(base, c);
}
#[test]
fn wide_chars_and_emoji_roundtrip() {
let base = TerminalScreen::default();
let target = screen_from(24, 80, "日本語 café 🦀 e\u{0301}".as_bytes());
let diff = target.diff_from(&base);
let mut c = base;
c.apply(&diff);
assert_eq!(c, target);
}
#[test]
fn converges_over_lossy_link() {
let mut h =
SimHarness::<TerminalScreen, TerminalScreen>::new(LinkParams::lossy(), 77, 1200);
let mut emu = ServerTerminal::new(24, 80, 0);
for i in 0..30u32 {
emu.process(format!("\r\nframe {i} of output").as_bytes());
*h.a_mut() = emu.snapshot();
h.run_steps(5);
}
let final_snap = emu.snapshot();
h.run_until(20_000, move |h| *h.b_view_of_a() == final_snap);
}
#[test]
fn many_incremental_applies_without_reclone_track_server() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"line 0\r\n");
let mut client = emu.snapshot();
let mut base = client.clone();
for i in 1..=20 {
emu.process(format!("line {i}\r\n").as_bytes());
let target = emu.snapshot();
let diff = target.diff_from(&base);
assert!(
diff.resize.is_none(),
"no resize -> incremental (persistent) path"
);
client.apply(&diff); base = target.clone();
assert_eq!(
client, base,
"client must track server after incremental diff {i}"
);
}
}
#[test]
fn exit_code_propagates_through_diff_apply() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"bye");
emu.set_exit_code(42);
let target = emu.snapshot();
assert_eq!(target.exit_code(), Some(42));
let base = TerminalScreen::default();
let diff = target.diff_from(&base);
assert_eq!(diff.exit_code, Some(42));
let mut c = base.clone();
c.apply(&diff);
assert_eq!(c.exit_code(), Some(42));
assert_ne!(
base, c,
"a state carrying an exit code differs from one without"
);
}
fn roundtrip(rows: u16, cols: u16, bytes: &[u8]) -> TerminalScreen {
let mut emu = ServerTerminal::new(rows, cols, 0);
emu.process(bytes);
let target = emu.snapshot();
let base = TerminalScreen::default();
let diff = target.diff_from(&base);
let mut client = base;
client.apply(&diff);
assert_eq!(
client, target,
"client must reconstruct the server screen exactly"
);
client
}
fn row_text(s: &vt100::Screen, row: u16) -> String {
let (_, cols) = s.size();
(0..cols)
.map(|c| match s.cell(row, c).map(vt100::Cell::contents) {
Some(g) if !g.is_empty() => g.to_string(),
_ => " ".to_string(),
})
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn attributes_survive_roundtrip() {
let bytes = b"\x1b[1mB\x1b[m\x1b[4mU\x1b[m\x1b[7mR\x1b[m\x1b[3mI\x1b[m\
\x1b[31mC\x1b[m\x1b[38;5;208mP\x1b[m\x1b[38;2;10;20;30mT\x1b[m";
let c = roundtrip(24, 80, bytes);
let s = c.screen();
assert!(s.cell(0, 0).unwrap().bold(), "bold");
assert!(s.cell(0, 1).unwrap().underline(), "underline");
assert!(s.cell(0, 2).unwrap().inverse(), "inverse");
assert!(s.cell(0, 3).unwrap().italic(), "italic");
assert_eq!(
s.cell(0, 4).unwrap().fgcolor(),
vt100::Color::Idx(1),
"16-color red"
);
assert_eq!(
s.cell(0, 5).unwrap().fgcolor(),
vt100::Color::Idx(208),
"256-color"
);
assert_eq!(
s.cell(0, 6).unwrap().fgcolor(),
vt100::Color::Rgb(10, 20, 30),
"truecolor"
);
}
#[test]
fn cursor_motion_roundtrip() {
let bytes = b"\x1b[H\x1b[J\x1b[1;1HA\x1b[1;10HB\x1b[4;1HC\x1b[24;1Hdone";
let c = roundtrip(24, 80, bytes);
let s = c.screen();
assert_eq!(s.cell(0, 0).unwrap().contents(), "A");
assert_eq!(s.cell(0, 9).unwrap().contents(), "B");
assert_eq!(s.cell(3, 0).unwrap().contents(), "C");
assert_eq!(row_text(s, 23), "done");
}
#[test]
fn scroll_up_down_roundtrip() {
let mut bytes = Vec::from(&b"\x1b[H\x1b[J"[..]);
for i in 1..=24 {
bytes.extend_from_slice(format!("\x1b[{i};1Hline{i}").as_bytes());
}
bytes.extend_from_slice(b"\x1b[4S\x1b[2T");
let c = roundtrip(24, 80, &bytes);
let s = c.screen();
assert_eq!(
row_text(s, 0),
"",
"two blank rows pushed in at the top after SD 2"
);
assert_eq!(
row_text(s, 2),
"line5",
"line5 reached the top after SU 4, then down 2"
);
assert_eq!(row_text(s, 21), "line24", "last line still present");
}
#[test]
fn insert_delete_lines_roundtrip_no_panic() {
let mut bytes = Vec::from(&b"\x1b[H\x1b[J"[..]);
for i in 1..=24 {
bytes.extend_from_slice(format!("\x1b[{i};1Hrow{i}").as_bytes());
}
for n in [0u32, 1, 2, 22, 26] {
bytes.extend_from_slice(format!("\x1b[3;1H\x1b[{n}L").as_bytes());
bytes.extend_from_slice(format!("\x1b[3;1H\x1b[{n}M").as_bytes());
}
let _ = roundtrip(24, 80, &bytes); }
#[test]
fn back_and_forward_tab_unsupported_but_roundtrip_clean() {
let c = roundtrip(24, 80, b"hello, wurld\x1b[Zo");
assert_eq!(
row_text(c.screen(), 0),
"hello, wurldo",
"CBT currently a no-op in vt100"
);
let c2 = roundtrip(24, 80, b"ab\x1b[Itab");
assert_eq!(
row_text(c2.screen(), 0),
"abtab",
"CHT currently a no-op in vt100"
);
}
#[test]
fn column_80_no_premature_wrap_roundtrip() {
let mut bytes = Vec::from(&b"\x1b[H\x1b[J"[..]);
bytes.resize(bytes.len() + 80, b'E'); bytes.extend_from_slice(b"\r\nM");
let c = roundtrip(24, 80, &bytes);
let s = c.screen();
assert_eq!(row_text(s, 0), "E".repeat(80), "80 chars fill row 0");
assert_eq!(
s.cell(1, 0).unwrap().contents(),
"M",
"M lands on row 1, no spurious wrap row"
);
}
#[test]
fn wrap_across_incremental_frames() {
let mut emu = ServerTerminal::new(24, 80, 0);
emu.process(b"\x1b[H\x1b[J");
emu.process(&[b'a'; 80]); let frame_n = emu.snapshot();
let mut client = TerminalScreen::default();
client.apply(&frame_n.diff_from(&TerminalScreen::default()));
assert_eq!(client, frame_n);
emu.process(b"b"); let frame_n1 = emu.snapshot();
let diff = frame_n1.diff_from(&frame_n);
assert!(
diff.resize.is_none(),
"incremental path, not a full repaint"
);
client.apply(&diff);
assert_eq!(
client, frame_n1,
"wrap across frames must reconstruct incrementally"
);
assert_eq!(
client.screen().cell(1, 0).unwrap().contents(),
"b",
"wrapped char on row 1"
);
}
#[test]
fn combining_mark_after_erase_does_not_panic() {
let _ = roundtrip(24, 80, b"0\x1b[1J\xcc\xb4");
}
#[test]
fn combining_mark_on_blank_line_roundtrip() {
let c = roundtrip(24, 80, b"abc\n\xcc\x82\ndef\n");
let contents = c.screen().contents();
assert!(contents.contains("abc") && contents.contains("def"));
}
#[test]
fn latin1_supplement_roundtrip() {
let c = roundtrip(24, 80, "àáâãäåæçèéêëìíîïñòóôõöøùúûüýþÿ".as_bytes());
assert!(c.screen().contents().contains("àáâãä"));
}
}