//! Per-session scrollback buffer for MCP terminal_read. //! //! A thread-safe circular buffer that stores the last N bytes of terminal //! output. Both SSH and PTY output loops write to it. The MCP tools read //! from it without touching xterm.js or the frontend. use std::sync::Mutex; const DEFAULT_CAPACITY: usize = 64 * 1024; // 64KB per session /// Thread-safe circular buffer for terminal output. pub struct ScrollbackBuffer { inner: Mutex, } struct RingBuffer { data: Vec, capacity: usize, /// Write position (wraps around) write_pos: usize, /// Total bytes written (for detecting wrap) total_written: usize, } impl ScrollbackBuffer { pub fn new() -> Self { Self::with_capacity(DEFAULT_CAPACITY) } pub fn with_capacity(capacity: usize) -> Self { Self { inner: Mutex::new(RingBuffer { data: vec![0u8; capacity], capacity, write_pos: 0, total_written: 0, }), } } /// Append bytes to the buffer. Old data is overwritten when full. pub fn push(&self, bytes: &[u8]) { let mut buf = self.inner.lock().unwrap(); for &b in bytes { let pos = buf.write_pos; buf.data[pos] = b; buf.write_pos = (pos + 1) % buf.capacity; buf.total_written += 1; } } /// Read the last `n` lines from the buffer, with ANSI escape codes stripped. pub fn read_lines(&self, n: usize) -> String { let raw = self.read_raw(); let text = strip_ansi(&raw); let lines: Vec<&str> = text.lines().collect(); let start = lines.len().saturating_sub(n); lines[start..].join("\n") } /// Read all buffered content as raw bytes (ordered oldest→newest). pub fn read_raw(&self) -> String { let buf = self.inner.lock().unwrap(); let bytes = if buf.total_written >= buf.capacity { // Buffer has wrapped — read from write_pos to end, then start to write_pos let mut out = Vec::with_capacity(buf.capacity); out.extend_from_slice(&buf.data[buf.write_pos..]); out.extend_from_slice(&buf.data[..buf.write_pos]); out } else { // Buffer hasn't wrapped yet buf.data[..buf.write_pos].to_vec() }; String::from_utf8_lossy(&bytes).to_string() } /// Total bytes written since creation. pub fn total_written(&self) -> usize { self.inner.lock().unwrap().total_written } } /// Strip ANSI escape sequences from text. fn strip_ansi(input: &str) -> String { let mut output = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\x1b' { // ESC sequence — consume until terminator if let Some(&next) = chars.peek() { if next == '[' { chars.next(); // consume '[' // CSI sequence — consume until letter while let Some(&c) = chars.peek() { chars.next(); if c.is_ascii_alphabetic() || c == '~' || c == '@' { break; } } } else if next == ']' { chars.next(); // consume ']' // OSC sequence — consume until BEL or ST while let Some(&c) = chars.peek() { chars.next(); if c == '\x07' { break; } if c == '\x1b' { if chars.peek() == Some(&'\\') { chars.next(); } break; } } } else { chars.next(); // consume single-char escape } } } else if ch == '\r' { // Skip carriage returns for cleaner output continue; } else { output.push(ch); } } output } #[cfg(test)] mod tests { use super::*; #[test] fn push_and_read_basic() { let buf = ScrollbackBuffer::new(); buf.push(b"hello world\n"); let lines = buf.read_lines(10); assert!(lines.contains("hello world")); } #[test] fn read_lines_limits_output() { let buf = ScrollbackBuffer::new(); buf.push(b"line1\nline2\nline3\nline4\nline5\n"); let lines = buf.read_lines(2); assert!(!lines.contains("line3")); assert!(lines.contains("line4")); assert!(lines.contains("line5")); } #[test] fn circular_buffer_wraps() { let buf = ScrollbackBuffer::with_capacity(16); buf.push(b"AAAAAAAAAAAAAAAA"); // fill 16 bytes buf.push(b"BBBB"); // overwrite first 4 let raw = buf.read_raw(); assert!(raw.starts_with("AAAAAAAAAAAA")); // 12 A's remain assert!(raw.ends_with("BBBB")); } #[test] fn strip_ansi_removes_csi() { let input = "\x1b[32mgreen\x1b[0m normal"; assert_eq!(strip_ansi(input), "green normal"); } #[test] fn strip_ansi_removes_osc() { let input = "\x1b]0;title\x07text"; assert_eq!(strip_ansi(input), "text"); } #[test] fn strip_ansi_preserves_plain_text() { let input = "no escapes here\njust text"; assert_eq!(strip_ansi(input), "no escapes here\njust text"); } #[test] fn empty_buffer_returns_empty() { let buf = ScrollbackBuffer::new(); assert_eq!(buf.read_lines(10), ""); assert_eq!(buf.total_written(), 0); } #[test] fn total_written_tracks_all_bytes() { let buf = ScrollbackBuffer::with_capacity(8); buf.push(b"12345678"); // 8 bytes buf.push(b"ABCD"); // 4 more, wraps assert_eq!(buf.total_written(), 12); } }