use vt100::Parser;
const MIN_VIEWPORT: u16 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenUpdate {
Full(Vec<String>),
Diff(Vec<(usize, String)>),
Unchanged,
}
#[derive(Debug, Default, Clone)]
struct OscSignals {
title: String,
progress: String,
}
impl vte::Perform for OscSignals {
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
let Some(&code) = params.first() else { return };
if code == b"0" || code == b"2" {
self.title = join_osc_params(¶ms[1..]);
} else if code == b"9" {
self.progress = join_osc_params(¶ms[1..]);
}
}
}
fn join_osc_params(params: &[&[u8]]) -> String {
params.iter().map(|param| String::from_utf8_lossy(param)).collect::<Vec<_>>().join(";")
}
pub struct LiveTerminal {
parser: Parser,
last_snapshot: Option<Vec<String>>,
osc_parser: vte::Parser,
osc: OscSignals,
}
impl LiveTerminal {
pub fn new(rows: u16, cols: u16) -> Self {
Self {
parser: Parser::new(rows.max(MIN_VIEWPORT), cols.max(MIN_VIEWPORT), 0),
last_snapshot: None,
osc_parser: vte::Parser::new(),
osc: OscSignals::default(),
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.osc_parser.advance(&mut self.osc, bytes);
self.parser.process(bytes);
}
pub fn osc_title(&self) -> &str {
&self.osc.title
}
pub fn osc_progress(&self) -> &str {
&self.osc.progress
}
pub fn clear_osc(&mut self) {
self.osc = OscSignals::default();
}
pub fn in_alt_screen(&self) -> bool {
self.parser.screen().alternate_screen()
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows.max(MIN_VIEWPORT);
let cols = cols.max(MIN_VIEWPORT);
let preserved = self.snapshot(0);
self.parser = Parser::new(rows, cols, 0);
if !preserved.is_empty() {
self.parser.process(preserved.join("\r\n").as_bytes());
}
self.last_snapshot = None;
}
pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
let contents = self.parser.screen().contents();
let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
if max_lines > 0 && lines.len() > max_lines {
lines = lines.split_off(lines.len() - max_lines);
}
lines
}
pub fn cursor_position(&self) -> (u16, u16) {
self.parser.screen().cursor_position()
}
pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
let current = self.snapshot(max_lines);
let update = match self.last_snapshot.as_ref() {
Some(previous) => {
let changed = diff_lines(previous, ¤t);
if changed.is_empty() {
ScreenUpdate::Unchanged
} else if changed.len() <= threshold {
ScreenUpdate::Diff(changed)
} else {
ScreenUpdate::Full(current.clone())
}
}
None => ScreenUpdate::Full(current.clone()),
};
self.last_snapshot = Some(current);
update
}
}
fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
let rows = previous.len().max(current.len());
(0..rows)
.filter_map(|i| {
let was = previous.get(i).map_or("", String::as_str);
let now = current.get(i).map_or("", String::as_str);
(was != now).then(|| (i + 1, now.to_string()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"line one\r\nGenerating...\r\n"); t.feed(b"\x1b[2A"); t.feed(b"\x1b[Jline one\r\nDONE\r\n"); let joined = t.snapshot(0).join("\n");
assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
}
#[test]
fn viewport_is_fixed_old_lines_scroll_off() {
let mut t = LiveTerminal::new(3, 40); for i in 0..10 {
t.feed(format!("row{i}\r\n").as_bytes());
}
let snap = t.snapshot(0);
assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
assert!(
!snap.iter().any(|l| l.contains("row0")),
"oldest row should have scrolled off a fixed viewport: {snap:?}"
);
}
#[test]
fn detects_alternate_screen_toggle() {
let mut t = LiveTerminal::new(10, 40);
assert!(!t.in_alt_screen());
t.feed(b"\x1b[?1049h");
assert!(t.in_alt_screen());
t.feed(b"\x1b[?1049l");
assert!(!t.in_alt_screen());
}
#[test]
fn osc_title_and_progress_are_captured_and_cleared() {
let mut t = LiveTerminal::new(10, 40);
assert_eq!(t.osc_title(), "");
assert_eq!(t.osc_progress(), "");
t.feed(b"\x1b]0;\xe2\xa0\x8b claude \x07");
assert_eq!(t.osc_title(), "⠋ claude ");
t.feed(b"\x1b]2;a;b\x1b\\");
assert_eq!(t.osc_title(), "a;b");
t.feed(b"\x1b]9;4;1;42\x07");
assert_eq!(t.osc_progress(), "4;1;42");
t.feed(b"\x1b]0;\x07");
assert_eq!(t.osc_title(), "");
t.feed(b"\x1b]2;back\x07");
t.clear_osc();
assert_eq!(t.osc_title(), "");
assert_eq!(t.osc_progress(), "");
}
#[test]
fn osc_sequence_split_across_feeds_decodes() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"\x1b]2;spl");
t.feed(b"it title\x07");
assert_eq!(t.osc_title(), "split title");
}
#[test]
fn osc_state_survives_resize() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"\x1b]2;kept\x07\x1b]9;4;0\x07");
t.resize(20, 80);
assert_eq!(t.osc_title(), "kept");
assert_eq!(t.osc_progress(), "4;0");
}
#[test]
fn snapshot_last_n_lines() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"a\r\nb\r\nc\r\nd\r\n");
assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
}
#[test]
fn cursor_position_tracks_feed() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"abc");
assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
}
#[test]
fn snapshot_diff_full_then_unchanged_then_one_line() {
let mut t = LiveTerminal::new(5, 40);
t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
t.feed(b"\x1b[2;1Hbeta2");
let update = t.snapshot_diff(0, 10);
assert!(
matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
"expected a one-line diff, got {update:?}"
);
if let ScreenUpdate::Diff(changed) = &update {
assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
}
}
#[test]
fn snapshot_diff_big_change_falls_back_to_full() {
let mut t = LiveTerminal::new(10, 40);
t.feed(b"seed\r\n");
let _ = t.snapshot_diff(0, 1); for i in 0..8 {
t.feed(format!("line{i}\r\n").as_bytes());
}
assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
}
use proptest::prelude::*;
proptest! {
#[test]
fn feed_arbitrary_bytes_never_panics_and_stays_bounded(
rows in 1u16..40,
cols in 1u16..120,
chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..48), 0..24),
) {
let eff_rows = rows.max(MIN_VIEWPORT);
let eff_cols = cols.max(MIN_VIEWPORT);
let mut t = LiveTerminal::new(rows, cols);
for chunk in &chunks {
t.feed(chunk);
}
let full = t.snapshot(0);
prop_assert!(full.len() <= eff_rows as usize, "snapshot {} rows > viewport {eff_rows}", full.len());
let (cr, cc) = t.cursor_position();
prop_assert!(cr < eff_rows, "cursor row {cr} >= rows {eff_rows}");
prop_assert!(cc <= eff_cols, "cursor col {cc} > cols {eff_cols}");
prop_assert!(t.snapshot(3).len() <= 3);
let _ = t.snapshot_diff(0, 4);
if let ScreenUpdate::Diff(changed) = t.snapshot_diff(0, 4) {
prop_assert!(changed.len() <= 4);
}
}
#[test]
fn resize_after_feed_never_panics(
r1 in 1u16..30, c1 in 1u16..80,
r2 in 1u16..30, c2 in 1u16..80,
data in prop::collection::vec(any::<u8>(), 0..256),
) {
let eff_r2 = r2.max(MIN_VIEWPORT) as usize;
let mut t = LiveTerminal::new(r1, c1);
t.feed(&data);
t.resize(r2, c2);
prop_assert!(t.snapshot(0).len() <= eff_r2);
t.feed(&data);
prop_assert!(t.snapshot(0).len() <= eff_r2);
}
}
}