wraith/src-tauri/src/mcp/scrollback.rs
Vantz Stockwell a3a7116f00
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m52s
feat: MCP Phase 1 — scrollback buffer, terminal_read, terminal_execute
Infrastructure for the Wraith Terminal MCP server:

- ScrollbackBuffer: 64KB circular buffer per session with ANSI stripping
- ScrollbackRegistry: DashMap registry shared between output loops and MCP
- SSH output loop feeds scrollback in addition to emitting events
- PTY output loop feeds scrollback in addition to emitting events
- mcp_terminal_read: read last N lines from any session (ANSI stripped)
- mcp_terminal_execute: send command + marker, capture output until marker
- mcp_list_sessions: enumerate all active SSH sessions with metadata

8 new scrollback tests (ring buffer, ANSI strip, line limiting).
95 total tests, zero warnings.

Bridge binary and auto-config injection to follow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:00:32 -04:00

196 lines
6.0 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]) {
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);
}
}