wraith/src-tauri/src/mcp/scrollback.rs
Vantz Stockwell fca6ed023e perf: PERF-1/2/3 scrollback bulk write, RDP binary IPC, settings dedup
- Scrollback: bulk copy_from_slice replaces byte-by-byte loop
- RDP frames: tauri::ipc::Response for zero-overhead binary transfer
- SettingsService: derive Clone, eliminate duplicate instance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:40:08 -04:00

246 lines
7.7 KiB
Rust

//! 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<RingBuffer>,
}
struct RingBuffer {
data: Vec<u8>,
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]) {
if bytes.is_empty() {
return;
}
let mut buf = self.inner.lock().unwrap();
let cap = buf.capacity;
// If input exceeds capacity, only keep the last `cap` bytes
let data = if bytes.len() > cap {
&bytes[bytes.len() - cap..]
} else {
bytes
};
let write_pos = buf.write_pos;
let first_len = (cap - write_pos).min(data.len());
buf.data[write_pos..write_pos + first_len].copy_from_slice(&data[..first_len]);
if first_len < data.len() {
buf.data[..data.len() - first_len].copy_from_slice(&data[first_len..]);
}
buf.write_pos = (write_pos + data.len()) % cap;
buf.total_written += bytes.len();
}
/// 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);
}
#[test]
fn push_empty_is_noop() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"hello");
buf.push(b"");
assert_eq!(buf.total_written(), 5);
assert!(buf.read_raw().contains("hello"));
}
#[test]
fn push_larger_than_capacity() {
let buf = ScrollbackBuffer::with_capacity(4);
buf.push(b"ABCDEFGH"); // 8 bytes into 4-byte buffer
let raw = buf.read_raw();
assert_eq!(raw, "EFGH"); // only last 4 bytes kept
assert_eq!(buf.total_written(), 8);
}
#[test]
fn push_exact_capacity() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"12345678");
let raw = buf.read_raw();
assert_eq!(raw, "12345678");
assert_eq!(buf.total_written(), 8);
}
#[test]
fn push_wrap_around_boundary() {
let buf = ScrollbackBuffer::with_capacity(8);
buf.push(b"123456"); // write_pos = 6
buf.push(b"ABCD"); // wraps: 2 at end, 2 at start
let raw = buf.read_raw();
// Buffer: [C, D, 3, 4, 5, 6, A, B], write_pos=2
// Read from pos 2: "3456AB" + wrap: no, read from write_pos to end then start
assert_eq!(raw, "3456ABCD");
}
}