winx_code_agent/state/
terminal.rs1use regex::Regex;
2use std::collections::{HashMap, VecDeque};
3use std::sync::{Arc, RwLock};
4use std::time::Instant;
5
6pub const MAX_SCREEN_LINES: usize = 10000;
8pub const DEFAULT_MAX_SCREEN_LINES: usize = 500;
10const DEFAULT_COLUMNS: usize = 200;
15pub const MAX_OUTPUT_SIZE: usize = 500_000;
17const CACHE_TTL: u64 = 300; type CacheEntryMap = HashMap<u64, (Vec<String>, Instant)>;
27
28fn hash_terminal_text(text: &str) -> u64 {
30 use std::hash::{Hash, Hasher};
31 let mut hasher = std::collections::hash_map::DefaultHasher::new();
32 text.hash(&mut hasher);
33 hasher.finish()
34}
35
36#[derive(Debug, Default)]
39struct CacheInner {
40 map: CacheEntryMap,
41 order: VecDeque<u64>,
43}
44
45#[derive(Debug, Clone)]
47struct TerminalCache {
48 inner: Arc<RwLock<CacheInner>>,
49 max_entries: usize,
51 ttl: u64,
53}
54
55impl TerminalCache {
56 fn new(max_entries: usize, ttl: u64) -> Self {
58 Self { inner: Arc::new(RwLock::new(CacheInner::default())), max_entries, ttl }
59 }
60
61 fn get(&self, text: &str) -> Option<Vec<String>> {
63 let key = hash_terminal_text(text);
64 let inner = self.inner.read().ok()?;
65 let (value, timestamp) = inner.map.get(&key)?;
66 if timestamp.elapsed().as_secs() < self.ttl {
67 Some(value.clone())
68 } else {
69 None
70 }
71 }
72
73 fn insert(&self, text: &str, value: Vec<String>) {
75 let key = hash_terminal_text(text);
76 let Ok(mut inner) = self.inner.write() else {
77 return;
78 };
79 if inner.map.insert(key, (value, Instant::now())).is_none() {
80 inner.order.push_back(key);
81 }
82 while inner.order.len() > self.max_entries {
83 let Some(old) = inner.order.pop_front() else {
84 break;
85 };
86 inner.map.remove(&old);
87 }
88 }
89
90 fn cleanup(&self) {
92 let Ok(mut inner) = self.inner.write() else {
93 return;
94 };
95 let ttl = self.ttl;
96 let CacheInner { map, order } = &mut *inner;
97 map.retain(|_, (_, timestamp)| timestamp.elapsed().as_secs() < ttl);
98 order.retain(|key| map.contains_key(key));
99 }
100}
101
102lazy_static::lazy_static! {
104 static ref TERMINAL_CACHE: TerminalCache = TerminalCache::new(100, CACHE_TTL);
105}
106
107pub fn render_terminal_output(text: &str) -> Vec<String> {
109 if let Some(cached) = TERMINAL_CACHE.get(text) {
111 return cached;
112 }
113
114 let result = render_via_vt100(text);
115
116 if text.len() < MAX_OUTPUT_SIZE {
118 TERMINAL_CACHE.insert(text, result.clone());
119 }
120
121 if rand::random::<u32>() % 100 == 0 {
123 TERMINAL_CACHE.cleanup();
124 }
125
126 result
127}
128
129fn render_via_vt100(text: &str) -> Vec<String> {
134 let bytes = normalize_lf_to_crlf(text);
140
141 let line_estimate = text.lines().count().saturating_add(2);
147 let rows = u16::try_from(line_estimate.clamp(1, DEFAULT_MAX_SCREEN_LINES)).unwrap_or(u16::MAX);
148 let cols = u16::try_from(DEFAULT_COLUMNS).unwrap_or(u16::MAX);
149
150 let mut parser = vt100::Parser::new(rows, cols, 0);
151 parser.process(&bytes);
152
153 let mut lines: Vec<String> =
157 parser.screen().contents().lines().map(|line| line.trim_end().to_string()).collect();
158 while lines.last().is_some_and(String::is_empty) {
159 lines.pop();
160 }
161 lines
162}
163
164fn normalize_lf_to_crlf(text: &str) -> Vec<u8> {
167 let src = text.as_bytes();
168 let mut out = Vec::with_capacity(src.len() + 16);
169 let mut prev = 0u8;
170 for &byte in src {
171 if byte == b'\n' && prev != b'\r' {
172 out.push(b'\r');
173 }
174 out.push(byte);
175 prev = byte;
176 }
177 out
178}
179
180pub fn strip_ansi_codes(input: &str) -> String {
182 static RE: std::sync::OnceLock<Option<Regex>> = std::sync::OnceLock::new();
183
184 if !input.contains('\u{1b}') {
186 return input.to_string();
187 }
188
189 let re = RE.get_or_init(|| {
199 Regex::new(
200 r"\x1b\[[0-9;:?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[=>MNcD78]",
201 )
202 .ok()
203 });
204 let cleaned = match re {
205 Some(re) => re.replace_all(input, "").into_owned(),
206 None => input.to_string(),
207 };
208 cleaned.replace('\u{1b}', "")
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn terminal_cache_round_trips() {
218 let cache = TerminalCache::new(10, 60);
219 cache.insert("test", vec!["line1".to_string(), "line2".to_string()]);
220 assert_eq!(cache.get("test"), Some(vec!["line1".to_string(), "line2".to_string()]));
221 assert_eq!(cache.get("unknown"), None);
222 }
223
224 #[test]
225 fn normalize_lf_inserts_cr_only_when_missing() {
226 assert_eq!(normalize_lf_to_crlf("a\nb"), b"a\r\nb");
227 assert_eq!(normalize_lf_to_crlf("a\r\nb"), b"a\r\nb");
228 assert_eq!(normalize_lf_to_crlf("plain"), b"plain");
229 }
230
231 use proptest::prelude::*;
232
233 proptest! {
234 #[test]
237 fn strip_ansi_codes_leaves_no_esc(text in prop::collection::vec(any::<char>(), 0..256)) {
238 let s: String = text.into_iter().collect();
239 let out = strip_ansi_codes(&s);
240 prop_assert!(!out.contains('\u{1b}'), "ESC survived stripping: {out:?}");
241 }
242
243 #[test]
245 fn strip_ansi_codes_is_identity_without_esc(
246 text in prop::collection::vec(
247 any::<char>().prop_filter("no esc", |c| *c != '\u{1b}'),
248 0..256,
249 ),
250 ) {
251 let s: String = text.into_iter().collect();
252 prop_assert_eq!(strip_ansi_codes(&s), s);
253 }
254 }
255}