use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::{Config as TermConfig, Osc52, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor, Rgb};
use tracing::warn;
use super::frame::{CellData, CellFrame, CursorState, DiffEngine, RowData, decscusr_code};
const VT_SCROLLBACK_LINES: usize = 1000;
const MAX_RESPONSE_ENTRIES: usize = 64;
const MAX_RESPONSE_BYTES: usize = 8 * 1024;
const RESPONSE_CLOSURE_EST_BYTES: usize = 32;
#[derive(Default)]
struct ResponseSink {
inner: Mutex<SinkInner>,
}
#[derive(Default)]
struct SinkInner {
queue: VecDeque<PendingResponse>,
bytes: usize,
title: String,
}
enum PendingResponse {
Bytes(Vec<u8>),
Color(usize, Arc<dyn Fn(Rgb) -> String + Sync + Send + 'static>),
TextArea(Arc<dyn Fn(WindowSize) -> String + Sync + Send + 'static>),
}
fn response_len(resp: &PendingResponse) -> usize {
match resp {
PendingResponse::Bytes(b) => b.len(),
PendingResponse::Color(..) | PendingResponse::TextArea(_) => RESPONSE_CLOSURE_EST_BYTES,
}
}
impl ResponseSink {
fn push(&self, resp: PendingResponse) {
let len = response_len(&resp);
if len > MAX_RESPONSE_BYTES {
warn!("vt response entry exceeds {MAX_RESPONSE_BYTES}B cap, dropped");
return;
}
let mut g = self.inner.lock().unwrap();
while g.queue.len() >= MAX_RESPONSE_ENTRIES || g.bytes + len > MAX_RESPONSE_BYTES {
let Some(oldest) = g.queue.pop_front() else { break };
g.bytes -= response_len(&oldest);
warn!(
"vt response buffer overflow (cap {MAX_RESPONSE_ENTRIES} entries / {MAX_RESPONSE_BYTES}B), oldest dropped"
);
}
g.queue.push_back(resp);
g.bytes += len;
}
}
struct VtEventListener {
sink: Arc<ResponseSink>,
}
impl EventListener for VtEventListener {
fn send_event(&self, event: Event) {
match event {
Event::Title(title) => self.sink.inner.lock().unwrap().title = title,
Event::ResetTitle => self.sink.inner.lock().unwrap().title.clear(),
Event::PtyWrite(text) => self.sink.push(PendingResponse::Bytes(text.into_bytes())),
Event::ColorRequest(index, fmt) => self.sink.push(PendingResponse::Color(index, fmt)),
Event::TextAreaSizeRequest(fmt) => self.sink.push(PendingResponse::TextArea(fmt)),
_ => {}
}
}
}
struct VtSize {
rows: usize,
cols: usize,
}
impl Dimensions for VtSize {
fn total_lines(&self) -> usize {
self.rows + VT_SCROLLBACK_LINES
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
const FALLBACK_COLOR: Rgb = Rgb { r: 0xd8, g: 0xd8, b: 0xd8 };
#[derive(Clone, Copy, PartialEq)]
pub struct CellStyle {
fg: Option<Color>,
bg: Option<Color>,
flags: Flags,
}
impl Default for CellStyle {
fn default() -> Self {
Self { fg: None, bg: None, flags: Flags::empty() }
}
}
impl CellStyle {
pub fn of(cell: &Cell) -> Self {
Self { fg: normalize_color(cell.fg), bg: normalize_color(cell.bg), flags: cell.flags }
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
fn normalize_color(c: Color) -> Option<Color> {
let Color::Named(n) = c else { return Some(c) };
match n {
NamedColor::Black
| NamedColor::Red
| NamedColor::Green
| NamedColor::Yellow
| NamedColor::Blue
| NamedColor::Magenta
| NamedColor::Cyan
| NamedColor::White
| NamedColor::BrightBlack
| NamedColor::BrightRed
| NamedColor::BrightGreen
| NamedColor::BrightYellow
| NamedColor::BrightBlue
| NamedColor::BrightMagenta
| NamedColor::BrightCyan
| NamedColor::BrightWhite => Some(c),
_ => None,
}
}
pub fn sgr_body(style: &CellStyle) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for (flag, code) in [
(Flags::BOLD, "1"),
(Flags::DIM, "2"),
(Flags::ITALIC, "3"),
(Flags::UNDERLINE, "4"),
(Flags::INVERSE, "7"),
(Flags::HIDDEN, "8"),
(Flags::STRIKEOUT, "9"),
] {
if style.flags.contains(flag) {
parts.push(code.to_string());
}
}
if let Some(c) = style.fg {
let p = color_sgr(c, true);
if !p.is_empty() {
parts.push(p);
}
}
if let Some(c) = style.bg {
let p = color_sgr(c, false);
if !p.is_empty() {
parts.push(p);
}
}
(!parts.is_empty()).then(|| parts.join(";"))
}
pub fn color_sgr(c: Color, foreground: bool) -> String {
let std = if foreground { 30 } else { 40 };
let bright = if foreground { 90 } else { 100 };
let ext = if foreground { 38 } else { 48 };
match c {
Color::Named(n) => {
let idx: u8 = match n {
NamedColor::Black => 0,
NamedColor::Red => 1,
NamedColor::Green => 2,
NamedColor::Yellow => 3,
NamedColor::Blue => 4,
NamedColor::Magenta => 5,
NamedColor::Cyan => 6,
NamedColor::White => 7,
NamedColor::BrightBlack => 8,
NamedColor::BrightRed => 9,
NamedColor::BrightGreen => 10,
NamedColor::BrightYellow => 11,
NamedColor::BrightBlue => 12,
NamedColor::BrightMagenta => 13,
NamedColor::BrightCyan => 14,
_ => return String::new(),
};
if idx < 8 { (std + idx).to_string() } else { (bright + idx - 8).to_string() }
}
Color::Indexed(i) => format!("{ext};5;{i}"),
Color::Spec(rgb) => format!("{ext};2;{};{};{}", rgb.r, rgb.g, rgb.b),
}
}
pub struct VtState {
term: Term<VtEventListener>,
processor: Processor,
sink: Arc<ResponseSink>,
rows: u16,
cols: u16,
diff_engine: DiffEngine,
last_cursor: Mutex<Option<(i32, u16, u8, bool)>>,
}
impl VtState {
pub fn new(rows: u16, cols: u16) -> Self {
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
let config = TermConfig {
scrolling_history: VT_SCROLLBACK_LINES,
osc52: Osc52::Disabled,
..Default::default()
};
let sink = Arc::new(ResponseSink::default());
let size = VtSize { rows, cols };
let term = Term::new(config, &size, VtEventListener { sink: Arc::clone(&sink) });
Self {
term,
processor: Processor::new(),
sink,
rows: rows as u16,
cols: cols as u16,
diff_engine: DiffEngine::with_rows(rows),
last_cursor: Mutex::new(None),
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.processor.advance(&mut self.term, bytes);
}
pub fn take_responses(&mut self) -> Vec<u8> {
let pending: Vec<PendingResponse> = {
let mut g = self.sink.inner.lock().unwrap();
g.bytes = 0;
g.queue.drain(..).collect()
};
let mut out = Vec::new();
for resp in pending {
match resp {
PendingResponse::Bytes(b) => out.extend_from_slice(&b),
PendingResponse::Color(index, fmt) => {
let rgb = self.term.colors()[index].unwrap_or(FALLBACK_COLOR);
out.extend_from_slice(fmt(rgb).as_bytes());
}
PendingResponse::TextArea(fmt) => {
let ws = WindowSize {
num_lines: self.rows,
num_cols: self.cols,
cell_width: 0,
cell_height: 0,
};
out.extend_from_slice(fmt(ws).as_bytes());
}
}
}
out
}
pub fn title(&self) -> String {
self.sink.inner.lock().unwrap().title.clone()
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
self.rows = rows as u16;
self.cols = cols as u16;
self.term.resize(VtSize { rows, cols });
self.diff_engine.resize(rows);
self.diff_engine.invalidate();
if let Ok(mut lc) = self.last_cursor.lock() {
lc.take();
}
}
pub fn capture_visible(&self) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = Column(self.term.columns());
let mut out = String::new();
for row in 0..rows {
let mut line = String::new();
for cell in &grid[Line(row as i32)][Column(0)..cols] {
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
line.push(cell.c);
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
pub fn renderable_cursor_for_test(&self) -> (i32, u16) {
let rc = self.term.renderable_content();
(rc.cursor.point.line.0, rc.cursor.point.column.0 as u16)
}
pub fn render_screen(&self) -> Vec<u8> {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let mut out: Vec<u8> = Vec::with_capacity(rows * cols * 8 + 64);
let mut cur = CellStyle::default();
for row in 0..rows {
let cells: Vec<(char, CellStyle)> = grid[Line(row as i32)][Column(0)..Column(cols)]
.iter()
.filter(|c| {
!c.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
})
.map(|c| (c.c, CellStyle::of(c)))
.collect();
let trimmed =
cells.iter().rev().take_while(|(ch, st)| *ch == ' ' && st.is_default()).count();
for (ch, st) in &cells[..cells.len() - trimmed] {
if *st != cur {
out.extend_from_slice(b"\x1b[0m");
if let Some(body) = sgr_body(st) {
out.push(b'\x1b');
out.push(b'[');
out.extend_from_slice(body.as_bytes());
out.push(b'm');
}
cur = *st;
}
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
if row + 1 < rows {
out.extend_from_slice(b"\r\n");
}
}
if cur != CellStyle::default() {
out.extend_from_slice(b"\x1b[0m");
}
let rc = self.term.renderable_content();
let point = rc.cursor.point;
out.extend_from_slice(
format!("\x1b[{};{}H", point.line.0 + 1, point.column.0 + 1).as_bytes(),
);
match rc.cursor.shape {
CursorShape::Hidden => out.extend_from_slice(b"\x1b[?25l"),
_ => out.extend_from_slice(b"\x1b[?25h"),
}
out
}
pub fn encode_cell_frame(&mut self, session_id: &str) -> String {
self.encode_frame_body(session_id, false)
}
pub fn encode_overlay_frame(&mut self, session_id: &str) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let out_rows: Vec<RowData> =
(0..rows).map(|r| self.encode_row_static(grid, cols, r)).collect();
let rc = self.term.renderable_content();
let cursor = Some(CursorState {
row: rc.cursor.point.line.0 + 1,
col: (rc.cursor.point.column.0 + 1) as u16,
shape: Some(decscusr_code(rc.cursor.shape)),
visible: !matches!(rc.cursor.shape, CursorShape::Hidden),
});
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full: true,
cursor,
overlay: true,
row_indices: None,
rows: out_rows,
};
let json = serde_json::to_string(&frame).expect("CellFrame serialization must not fail");
crate::engine::pty::metrics::record_cell_frame_bytes(json.len());
json
}
pub fn invalidate_diff(&mut self) {
self.diff_engine.invalidate();
if let Ok(mut lc) = self.last_cursor.lock() {
lc.take();
}
}
pub fn mode(&self) -> TermMode {
*self.term.mode()
}
fn encode_frame_body(&mut self, session_id: &str, overlay: bool) -> String {
use crate::engine::pty::frame::{CellFrame, RowData, hash_row};
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let full = self.diff_engine.is_untracked();
let mut row_hashes: Vec<u64> = Vec::with_capacity(rows);
for r in 0..rows {
row_hashes.push(hash_row(&self.row_cells(grid, cols, r)));
}
let changed_indices = self.diff_engine.changed_rows_from(&row_hashes);
let out_rows: Vec<RowData> =
changed_indices.iter().map(|&r| self.encode_row_static(grid, cols, r)).collect();
let rc = self.term.renderable_content();
let cursor_key = (
rc.cursor.point.line.0 + 1,
(rc.cursor.point.column.0 + 1) as u16,
decscusr_code(rc.cursor.shape),
!matches!(rc.cursor.shape, CursorShape::Hidden),
);
let cursor = {
let mut last = self.last_cursor.lock().unwrap();
let changed = last.map(|l| l != cursor_key).unwrap_or(true);
if changed {
*last = Some(cursor_key);
Some(CursorState {
row: cursor_key.0,
col: cursor_key.1,
shape: Some(cursor_key.2),
visible: cursor_key.3,
})
} else {
None
}
};
let row_indices = if full { None } else { Some(changed_indices) };
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full,
cursor,
overlay,
row_indices,
rows: out_rows,
};
let json = serde_json::to_string(&frame).expect("CellFrame serialization must not fail");
crate::engine::pty::metrics::record_cell_frame_bytes(json.len());
json
}
fn row_cells(
&self,
grid: &alacritty_terminal::grid::Grid<Cell>,
cols: usize,
row: usize,
) -> Vec<CellData> {
let mut cells: Vec<CellData> = Vec::with_capacity(cols);
for col in 0..cols {
let cell_ref = &grid[Line(row as i32)][Column(col)];
if cell_ref.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
cells.push(CellData { sgr: String::new(), ch: String::new(), skip: true });
} else {
let style = CellStyle::of(cell_ref);
let sgr = sgr_body(&style).unwrap_or_default();
let ch = cell_ref.c.to_string();
cells.push(CellData { sgr, ch, skip: false });
}
}
cells
}
fn encode_row_static(
&self,
grid: &alacritty_terminal::grid::Grid<Cell>,
cols: usize,
row: usize,
) -> RowData {
let mut cells: Vec<CellData> = Vec::with_capacity(cols);
for col in 0..cols {
let cell_ref = &grid[Line(row as i32)][Column(col)];
if cell_ref.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
cells.push(CellData { sgr: String::new(), ch: String::new(), skip: true });
} else {
let style = CellStyle::of(cell_ref);
let sgr = sgr_body(&style).unwrap_or_default();
let ch = cell_ref.c.to_string();
cells.push(CellData { sgr, ch, skip: false });
}
}
RowData { cells }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vt(rows: u16, cols: u16) -> VtState {
VtState::new(rows, cols)
}
#[test]
fn capture_strips_ansi_and_keeps_text() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;32mgreen bold\x1b[0m plain\r\n");
v.feed(b"second line\r\n");
let cap = v.capture_visible();
assert!(cap.starts_with("green bold plain\nsecond line\n"), "got: {cap:?}");
assert!(!cap.contains('\x1b'), "capture must not contain escape bytes");
}
#[test]
fn carriage_return_overwrites_in_grid() {
let mut v = vt(24, 80);
v.feed(b"AAAA\rBB\r\n");
let cap = v.capture_visible();
assert!(cap.starts_with("BBAA\n"), "CR overwrite wrong: {cap:?}");
}
#[test]
fn osc2_sets_title() {
let mut v = vt(24, 80);
v.feed(b"\x1b]2;my agent task\x07");
assert_eq!(v.title(), "my agent task");
}
#[test]
fn resize_preserves_content() {
let mut v = vt(24, 80);
v.feed(b"keep me\r\n");
v.resize(30, 120);
let cap = v.capture_visible();
assert!(cap.contains("keep me"), "content lost after resize: {cap:?}");
}
#[test]
fn split_escape_sequence_across_feeds() {
let mut v = vt(24, 80);
v.feed(b"\x1b[3");
v.feed(b"1mred\x1b[0m");
let cap = v.capture_visible();
assert!(cap.starts_with("red\n"), "split sequence mishandled: {cap:?}");
}
#[test]
fn alt_screen_toggle_restores_main() {
let mut v = vt(24, 80);
v.feed(b"main screen\r\n");
v.feed(b"\x1b[?1049h"); v.feed(b"alt content\r\n");
let alt_cap = v.capture_visible();
assert!(alt_cap.contains("alt content"), "alt screen missing content: {alt_cap:?}");
v.feed(b"\x1b[?1049l"); let cap = v.capture_visible();
assert!(cap.contains("main screen"), "main screen lost after toggle: {cap:?}");
assert!(!cap.contains("alt content"), "alt content leaked into main screen");
}
#[test]
fn wide_chars_not_duplicated_in_capture() {
let mut v = vt(24, 80);
v.feed("终端宽度测试\r\n".as_bytes());
let cap = v.capture_visible();
assert!(cap.starts_with("终端宽度测试\n"), "got: {cap:?}");
assert_eq!(cap.matches("终端宽度测试").count(), 1, "wide char duplicated: {cap:?}");
}
#[test]
fn dsr_generates_response_for_drain() {
let mut v = vt(24, 80);
v.feed(b"\x1b[6n"); let resp = v.take_responses();
let text = String::from_utf8_lossy(&resp);
assert!(text.starts_with("\x1b["), "DSR response missing: {text:?}");
assert!(text.ends_with('R'), "DSR response must be CPR: {text:?}");
assert!(v.take_responses().is_empty(), "responses must drain");
}
#[test]
fn response_buffer_overflow_drops_oldest() {
let sink = ResponseSink::default();
for _ in 0..(MAX_RESPONSE_ENTRIES + 10) {
sink.push(PendingResponse::Bytes(vec![b'x'; 4]));
}
let g = sink.inner.lock().unwrap();
assert_eq!(g.queue.len(), MAX_RESPONSE_ENTRIES);
assert!(g.bytes <= MAX_RESPONSE_BYTES);
}
#[test]
fn response_buffer_byte_cap_enforced() {
let sink = ResponseSink::default();
for _ in 0..8 {
sink.push(PendingResponse::Bytes(vec![b'y'; MAX_RESPONSE_BYTES / 3]));
}
let g = sink.inner.lock().unwrap();
assert!(g.bytes <= MAX_RESPONSE_BYTES, "byte cap violated: {}", g.bytes);
assert!(!g.queue.is_empty(), "entries within cap must be kept");
}
#[test]
fn oversized_response_entry_rejected() {
let sink = ResponseSink::default();
sink.push(PendingResponse::Bytes(vec![b'z'; MAX_RESPONSE_BYTES + 1]));
let g = sink.inner.lock().unwrap();
assert!(g.queue.is_empty(), "oversized entry must be rejected, not queued");
}
#[test]
fn render_screen_emits_styled_text_with_reset() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;31mRED\x1b[0m tail\r\n");
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("\x1b[0m\x1b[1;31mRED\x1b[0m tail"), "got: {r:?}");
}
#[test]
fn render_screen_trims_only_default_trailing_blank() {
let mut v = vt(24, 80);
v.feed(b"abc\r\n"); v.feed(b"\x1b[41mA \x1b[0m\r\n"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("abc\r\n"), "default trailing blank must be trimmed: {r:?}");
assert!(r.contains("\x1b[41mA \r\n"), "colored trailing blank must be kept: {r:?}");
}
#[test]
fn render_screen_restores_cursor_position_and_visibility() {
let mut v = vt(24, 80);
v.feed(b"abc\r\nxy"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.ends_with("\x1b[2;3H\x1b[?25h"), "cursor restore wrong: {r:?}");
v.feed(b"\x1b[?25l"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.ends_with("\x1b[?25l"), "hidden cursor not synced: {r:?}");
}
#[test]
fn render_screen_wide_chars_once() {
let mut v = vt(24, 80);
v.feed("终端宽度测试".as_bytes());
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert_eq!(r.matches("终端宽度测试").count(), 1, "wide char duplicated: {r:?}");
}
#[test]
fn render_screen_shows_active_screen() {
let mut v = vt(24, 80);
v.feed(b"main screen\r\n");
v.feed(b"\x1b[?1049h"); v.feed(b"alt content");
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("alt content"), "alt screen missing: {r:?}");
assert!(!r.contains("main screen"), "main screen leaked into render: {r:?}");
}
#[test]
fn render_screen_roundtrip_reproduces_visible_screen() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;1Hheader line\x1b[K"); v.feed(b"\x1b[2;1H\x1b[32mbody: ok\x1b[0m");
v.feed(b"\x1b[5;10Hprogress: 42%");
let frame = v.render_screen();
let mut client = vt(24, 80);
client.feed(&frame);
assert_eq!(
client.capture_visible(),
v.capture_visible(),
"replay frame diverges from server grid"
);
assert_eq!(
client.term.renderable_content().cursor.point,
v.term.renderable_content().cursor.point
);
}
#[test]
fn encode_cell_frame_produces_valid_json() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["t"], "cell_frame");
assert_eq!(parsed["session_id"], "test-session");
assert_eq!(parsed["width"], 80);
assert_eq!(parsed["height"], 24);
assert_eq!(parsed["full"], true);
assert_eq!(parsed["overlay"], false);
assert!(parsed["cursor"].is_object(), "cursor must be present");
assert_eq!(parsed["rows"].as_array().unwrap().len(), 24);
}
#[test]
fn encode_cell_frame_includes_sgr_for_styled_cells() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;31mRED\x1b[0m");
let json = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let rows = parsed["rows"].as_array().unwrap();
let cells = rows[0]["cells"].as_array().unwrap();
assert_eq!(cells[0]["ch"].as_str(), Some("R"));
assert_eq!(cells[0]["sgr"].as_str(), Some("1;31"));
assert_eq!(cells[0]["skip"].as_bool(), Some(false));
let cursor = parsed["cursor"].as_object().unwrap();
assert_eq!(cursor["col"], 4);
}
#[test]
fn encode_cell_frame_sets_skip_for_wide_char_spacers() {
let mut v = vt(24, 80);
v.feed("你好".as_bytes()); let json = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let cells = parsed["rows"][0]["cells"].as_array().unwrap();
assert_eq!(cells[0]["ch"].as_str(), Some("你"));
assert_eq!(cells[0]["skip"].as_bool(), Some(false));
assert_eq!(cells[1]["skip"].as_bool(), Some(true));
assert_eq!(cells[2]["ch"].as_str(), Some("好"));
assert_eq!(cells[2]["skip"].as_bool(), Some(false));
assert_eq!(cells[3]["skip"].as_bool(), Some(true));
}
#[test]
fn encode_cell_frame_empty_screen_has_80_default_cells() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24);
let cells = rows[0]["cells"].as_array().unwrap();
assert_eq!(cells.len(), 80);
for cell in cells.iter() {
assert_eq!(cell["skip"].as_bool(), Some(false));
assert!(
cell.get("sgr").is_none()
|| cell["sgr"].as_str().map(|s| s.is_empty()).unwrap_or(true)
);
}
}
#[test]
fn first_cell_frame_is_full() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "first frame must be full");
assert!(parsed["row_indices"].is_null(), "full frame must not have row_indices");
}
#[test]
fn unchanged_second_frame_is_diff() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n");
let _ = v.encode_cell_frame("test-session"); let json = v.encode_cell_frame("test-session"); let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false, "unchanged second frame should be diff");
assert!(parsed.get("row_indices").is_some(), "diff frame must have row_indices");
let indices = parsed["row_indices"].as_array().unwrap();
assert!(indices.is_empty(), "unchanged rows must produce empty diff");
}
#[test]
fn changed_row_appears_in_row_indices() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n"); let _ = v.encode_cell_frame("test-session"); v.feed(b"\x1b[5;1Hchanged"); let json = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false);
let indices = parsed["row_indices"].as_array().unwrap();
assert_eq!(indices.len(), 1, "one row changed");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1, "one row in diff");
assert_eq!(rows[0]["cells"][0]["ch"].as_str(), Some("c"));
}
#[test]
fn cursor_omitted_when_unchanged() {
let mut v = vt(24, 80);
v.feed(b"hello");
let json1 = v.encode_cell_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json1).expect("must be valid JSON");
assert!(parsed["cursor"].is_object(), "first frame must include cursor");
let _ = v.encode_cell_frame("test-session"); let json2 = v.encode_cell_frame("test-session");
let parsed2: serde_json::Value = serde_json::from_str(&json2).expect("must be valid JSON");
assert!(parsed2["cursor"].is_null(), "cursor must be omitted when unchanged");
}
#[test]
fn invalidate_diff_forces_next_frame_full() {
let mut v = vt(24, 80);
v.feed(b"a\r\n");
let _ = v.encode_cell_frame("ts"); let _ = v.encode_cell_frame("ts"); v.invalidate_diff(); let json = v.encode_cell_frame("ts");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "after invalidate_diff must be full");
assert!(parsed["row_indices"].is_null());
}
#[test]
fn diff_engine_resizes_on_vt_resize() {
let mut v = vt(24, 80);
let _ = v.encode_cell_frame("ts"); let _ = v.encode_cell_frame("ts"); v.resize(40, 80);
let json = v.encode_cell_frame("ts");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "after resize must be full");
assert_eq!(parsed["height"], 40);
}
#[test]
fn overlay_frame_includes_shape_in_cursor() {
let mut v = vt(24, 80);
v.feed(b"hello");
let json = v.encode_overlay_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["overlay"], true);
assert_eq!(parsed["full"], true);
let cursor = parsed["cursor"].as_object().unwrap();
assert!(cursor.get("shape").is_some(), "overlay cursor must include shape");
}
}