use regex::Regex;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, LazyLock, RwLock};
use std::time::Instant;
pub const MAX_SCREEN_LINES: usize = 10000;
pub const DEFAULT_MAX_SCREEN_LINES: usize = 500;
const DEFAULT_COLUMNS: usize = 200;
pub const MAX_OUTPUT_SIZE: usize = 500_000;
const CACHE_TTL: u64 = 300;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TerminalRender {
pub(crate) lines: Vec<String>,
pub(crate) output_truncated: bool,
}
type CacheEntryMap = HashMap<u64, (TerminalRender, Instant)>;
fn hash_terminal_text(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Default)]
struct CacheInner {
map: CacheEntryMap,
order: VecDeque<u64>,
}
#[derive(Debug, Clone)]
struct TerminalCache {
inner: Arc<RwLock<CacheInner>>,
max_entries: usize,
ttl: u64,
}
impl TerminalCache {
fn new(max_entries: usize, ttl: u64) -> Self {
Self { inner: Arc::new(RwLock::new(CacheInner::default())), max_entries, ttl }
}
fn get(&self, text: &str) -> Option<TerminalRender> {
let key = hash_terminal_text(text);
let inner = self.inner.read().ok()?;
let (value, timestamp) = inner.map.get(&key)?;
if timestamp.elapsed().as_secs() < self.ttl {
Some(value.clone())
} else {
None
}
}
fn insert(&self, text: &str, value: TerminalRender) {
let key = hash_terminal_text(text);
let Ok(mut inner) = self.inner.write() else {
return;
};
if inner.map.insert(key, (value, Instant::now())).is_none() {
inner.order.push_back(key);
}
while inner.order.len() > self.max_entries {
let Some(old) = inner.order.pop_front() else {
break;
};
inner.map.remove(&old);
}
}
fn cleanup(&self) {
let Ok(mut inner) = self.inner.write() else {
return;
};
let ttl = self.ttl;
let CacheInner { map, order } = &mut *inner;
map.retain(|_, (_, timestamp)| timestamp.elapsed().as_secs() < ttl);
order.retain(|key| map.contains_key(key));
}
}
static TERMINAL_CACHE: LazyLock<TerminalCache> =
LazyLock::new(|| TerminalCache::new(100, CACHE_TTL));
pub fn render_terminal_output(text: &str) -> Vec<String> {
render_terminal_output_detailed(text).lines
}
pub(crate) fn render_terminal_output_detailed(text: &str) -> TerminalRender {
if let Some(cached) = TERMINAL_CACHE.get(text) {
return cached;
}
let result = render_via_vt100(text);
if text.len() < MAX_OUTPUT_SIZE {
TERMINAL_CACHE.insert(text, result.clone());
}
if rand::random::<u32>().is_multiple_of(100) {
TERMINAL_CACHE.cleanup();
}
result
}
#[derive(Default)]
struct RenderCallbacks {
dropped_rows: usize,
dropped_text: bool,
}
impl vt100::Callbacks for RenderCallbacks {
fn on_scroll(&mut self, contents: vt100::capture::RowContents<'_>, _alternate_screen: bool) {
self.dropped_rows = self.dropped_rows.saturating_add(1);
if !self.dropped_text {
let mut row = String::new();
let _ = contents.write_formatted_basic(
&mut row,
&mut vt100::capture::BasicFormattedCaptureState::default(),
);
self.dropped_text =
vt100::capture::basic_formatted_to_plain(&row).any(|part| !part.trim().is_empty());
}
}
}
fn render_via_vt100(text: &str) -> TerminalRender {
let bytes = normalize_lf_to_crlf(text);
let mut row_count = text.lines().count().saturating_add(2).clamp(1, DEFAULT_MAX_SCREEN_LINES);
let cols = u16::try_from(DEFAULT_COLUMNS).unwrap_or(u16::MAX);
let cols = std::num::NonZeroU16::new(cols).unwrap_or(std::num::NonZeroU16::MIN);
loop {
let rows = u16::try_from(row_count).unwrap_or(u16::MAX);
let rows = std::num::NonZeroU16::new(rows).unwrap_or(std::num::NonZeroU16::MIN);
let mut parser =
vt100::Parser::new_with_callbacks(rows, cols, 0, RenderCallbacks::default());
parser.process(&bytes);
let dropped_rows = parser.callbacks().dropped_rows;
if dropped_rows > 0 && row_count < DEFAULT_MAX_SCREEN_LINES {
row_count = row_count
.saturating_mul(2)
.max(row_count.saturating_add(dropped_rows))
.min(DEFAULT_MAX_SCREEN_LINES);
continue;
}
let output_truncated = parser.callbacks().dropped_text;
let mut lines: Vec<String> =
parser.screen().contents().lines().map(|line| line.trim_end().to_string()).collect();
while lines.last().is_some_and(String::is_empty) {
lines.pop();
}
return TerminalRender { lines, output_truncated };
}
}
fn normalize_lf_to_crlf(text: &str) -> Vec<u8> {
let src = text.as_bytes();
let mut out = Vec::with_capacity(src.len() + 16);
let mut prev = 0u8;
for &byte in src {
if byte == b'\n' && prev != b'\r' {
out.push(b'\r');
}
out.push(byte);
prev = byte;
}
out
}
pub fn strip_ansi_codes(input: &str) -> String {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
if !input.contains('\u{1b}') {
return input.to_string();
}
#[allow(clippy::expect_used)]
let re = RE.get_or_init(|| {
Regex::new(
r"\x1b\[[0-9;:?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[=>MNcD78]",
)
.expect("ANSI strip regex must compile")
});
let cleaned = re.replace_all(input, "").into_owned();
cleaned.replace('\u{1b}', "")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_cache_round_trips() {
let cache = TerminalCache::new(10, 60);
let rendered = TerminalRender {
lines: vec!["line1".to_string(), "line2".to_string()],
output_truncated: true,
};
cache.insert("test", rendered.clone());
assert_eq!(cache.get("test"), Some(rendered));
assert_eq!(cache.get("unknown"), None);
}
#[test]
fn visual_row_limit_reports_only_actual_overflow() {
let exact = "A".repeat(DEFAULT_MAX_SCREEN_LINES * DEFAULT_COLUMNS);
let rendered = render_terminal_output_detailed(&exact);
assert_eq!(rendered.lines.join("\n"), exact);
assert!(!rendered.output_truncated);
for suffix in ["B", "\n"] {
let rendered = render_terminal_output_detailed(&format!("{exact}{suffix}"));
assert!(rendered.output_truncated, "suffix {suffix:?} must scroll one row");
assert!(rendered.lines.join("\n").len() <= DEFAULT_MAX_SCREEN_LINES * DEFAULT_COLUMNS);
}
}
#[test]
fn scrolling_blank_rows_does_not_report_lost_text() {
let rendered = render_terminal_output_detailed("\x1b[999B\nFINAL");
assert_eq!(rendered.lines.last().map(String::as_str), Some("FINAL"));
assert!(!rendered.output_truncated);
}
#[test]
fn discarded_text_is_reported_even_after_screen_replacement() {
let overflowing = "row\n".repeat(DEFAULT_MAX_SCREEN_LINES + 1);
for (input, expected) in [
(format!("{overflowing}\x1b[2J\x1b[HFINAL"), "FINAL"),
(format!("PRIMARY\x1b[?1049h{overflowing}\x1b[?1049l"), "PRIMARY"),
] {
let rendered = render_terminal_output_detailed(&input);
assert_eq!(rendered.lines, vec![expected]);
assert!(rendered.output_truncated);
}
}
#[test]
fn normalize_lf_inserts_cr_only_when_missing() {
assert_eq!(normalize_lf_to_crlf("a\nb"), b"a\r\nb");
assert_eq!(normalize_lf_to_crlf("a\r\nb"), b"a\r\nb");
assert_eq!(normalize_lf_to_crlf("plain"), b"plain");
}
use proptest::prelude::*;
proptest! {
#[test]
fn strip_ansi_codes_leaves_no_esc(text in prop::collection::vec(any::<char>(), 0..256)) {
let s: String = text.into_iter().collect();
let out = strip_ansi_codes(&s);
prop_assert!(!out.contains('\u{1b}'), "ESC survived stripping: {out:?}");
}
#[test]
fn strip_ansi_codes_is_identity_without_esc(
text in prop::collection::vec(
any::<char>().prop_filter("no esc", |c| *c != '\u{1b}'),
0..256,
),
) {
let s: String = text.into_iter().collect();
prop_assert_eq!(strip_ansi_codes(&s), s);
}
}
}