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::Flags;
use alacritty_terminal::term::{Config as TermConfig, Osc52, Term};
use alacritty_terminal::vte::ansi::{Processor, Rgb};
use tracing::warn;
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 };
pub struct VtState {
term: Term<VtEventListener>,
processor: Processor,
sink: Arc<ResponseSink>,
rows: u16,
cols: u16,
}
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 }
}
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 });
}
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)]
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");
}
}