winx_code_agent/state/
terminal.rs1use regex::Regex;
2use std::collections::{HashMap, VecDeque};
3use std::sync::{Arc, LazyLock, 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; #[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) struct TerminalRender {
23 pub(crate) lines: Vec<String>,
24 pub(crate) output_truncated: bool,
25}
26
27type CacheEntryMap = HashMap<u64, (TerminalRender, Instant)>;
34
35fn hash_terminal_text(text: &str) -> u64 {
37 use std::hash::{Hash, Hasher};
38 let mut hasher = std::collections::hash_map::DefaultHasher::new();
39 text.hash(&mut hasher);
40 hasher.finish()
41}
42
43#[derive(Debug, Default)]
46struct CacheInner {
47 map: CacheEntryMap,
48 order: VecDeque<u64>,
50}
51
52#[derive(Debug, Clone)]
54struct TerminalCache {
55 inner: Arc<RwLock<CacheInner>>,
56 max_entries: usize,
58 ttl: u64,
60}
61
62impl TerminalCache {
63 fn new(max_entries: usize, ttl: u64) -> Self {
65 Self { inner: Arc::new(RwLock::new(CacheInner::default())), max_entries, ttl }
66 }
67
68 fn get(&self, text: &str) -> Option<TerminalRender> {
70 let key = hash_terminal_text(text);
71 let inner = self.inner.read().ok()?;
72 let (value, timestamp) = inner.map.get(&key)?;
73 if timestamp.elapsed().as_secs() < self.ttl {
74 Some(value.clone())
75 } else {
76 None
77 }
78 }
79
80 fn insert(&self, text: &str, value: TerminalRender) {
82 let key = hash_terminal_text(text);
83 let Ok(mut inner) = self.inner.write() else {
84 return;
85 };
86 if inner.map.insert(key, (value, Instant::now())).is_none() {
87 inner.order.push_back(key);
88 }
89 while inner.order.len() > self.max_entries {
90 let Some(old) = inner.order.pop_front() else {
91 break;
92 };
93 inner.map.remove(&old);
94 }
95 }
96
97 fn cleanup(&self) {
99 let Ok(mut inner) = self.inner.write() else {
100 return;
101 };
102 let ttl = self.ttl;
103 let CacheInner { map, order } = &mut *inner;
104 map.retain(|_, (_, timestamp)| timestamp.elapsed().as_secs() < ttl);
105 order.retain(|key| map.contains_key(key));
106 }
107}
108
109static TERMINAL_CACHE: LazyLock<TerminalCache> =
111 LazyLock::new(|| TerminalCache::new(100, CACHE_TTL));
112
113pub fn render_terminal_output(text: &str) -> Vec<String> {
115 render_terminal_output_detailed(text).lines
116}
117
118pub(crate) fn render_terminal_output_detailed(text: &str) -> TerminalRender {
119 if let Some(cached) = TERMINAL_CACHE.get(text) {
121 return cached;
122 }
123
124 let result = render_via_vt100(text);
125
126 if text.len() < MAX_OUTPUT_SIZE {
128 TERMINAL_CACHE.insert(text, result.clone());
129 }
130
131 if rand::random::<u32>().is_multiple_of(100) {
133 TERMINAL_CACHE.cleanup();
134 }
135
136 result
137}
138
139#[derive(Default)]
140struct RenderCallbacks {
141 dropped_rows: usize,
142 dropped_text: bool,
143}
144
145impl vt100::Callbacks for RenderCallbacks {
146 fn on_scroll(&mut self, contents: vt100::capture::RowContents<'_>, _alternate_screen: bool) {
147 self.dropped_rows = self.dropped_rows.saturating_add(1);
148 if !self.dropped_text {
149 let mut row = String::new();
152 let _ = contents.write_formatted_basic(
153 &mut row,
154 &mut vt100::capture::BasicFormattedCaptureState::default(),
155 );
156 self.dropped_text =
157 vt100::capture::basic_formatted_to_plain(&row).any(|part| !part.trim().is_empty());
158 }
159 }
160}
161
162fn render_via_vt100(text: &str) -> TerminalRender {
167 let bytes = normalize_lf_to_crlf(text);
173
174 let mut row_count = text.lines().count().saturating_add(2).clamp(1, DEFAULT_MAX_SCREEN_LINES);
179 let cols = u16::try_from(DEFAULT_COLUMNS).unwrap_or(u16::MAX);
180 let cols = std::num::NonZeroU16::new(cols).unwrap_or(std::num::NonZeroU16::MIN);
181
182 loop {
183 let rows = u16::try_from(row_count).unwrap_or(u16::MAX);
184 let rows = std::num::NonZeroU16::new(rows).unwrap_or(std::num::NonZeroU16::MIN);
185 let mut parser =
186 vt100::Parser::new_with_callbacks(rows, cols, 0, RenderCallbacks::default());
187 parser.process(&bytes);
188 let dropped_rows = parser.callbacks().dropped_rows;
189 if dropped_rows > 0 && row_count < DEFAULT_MAX_SCREEN_LINES {
190 row_count = row_count
191 .saturating_mul(2)
192 .max(row_count.saturating_add(dropped_rows))
193 .min(DEFAULT_MAX_SCREEN_LINES);
194 continue;
195 }
196
197 let output_truncated = parser.callbacks().dropped_text;
200 let mut lines: Vec<String> =
201 parser.screen().contents().lines().map(|line| line.trim_end().to_string()).collect();
202 while lines.last().is_some_and(String::is_empty) {
203 lines.pop();
204 }
205 return TerminalRender { lines, output_truncated };
206 }
207}
208
209fn normalize_lf_to_crlf(text: &str) -> Vec<u8> {
212 let src = text.as_bytes();
213 let mut out = Vec::with_capacity(src.len() + 16);
214 let mut prev = 0u8;
215 for &byte in src {
216 if byte == b'\n' && prev != b'\r' {
217 out.push(b'\r');
218 }
219 out.push(byte);
220 prev = byte;
221 }
222 out
223}
224
225pub fn strip_ansi_codes(input: &str) -> String {
227 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
228
229 if !input.contains('\u{1b}') {
231 return input.to_string();
232 }
233
234 #[allow(clippy::expect_used)]
249 let re = RE.get_or_init(|| {
250 Regex::new(
251 r"\x1b\[[0-9;:?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[=>MNcD78]",
252 )
253 .expect("ANSI strip regex must compile")
254 });
255 let cleaned = re.replace_all(input, "").into_owned();
256 cleaned.replace('\u{1b}', "")
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn terminal_cache_round_trips() {
266 let cache = TerminalCache::new(10, 60);
267 let rendered = TerminalRender {
268 lines: vec!["line1".to_string(), "line2".to_string()],
269 output_truncated: true,
270 };
271 cache.insert("test", rendered.clone());
272 assert_eq!(cache.get("test"), Some(rendered));
273 assert_eq!(cache.get("unknown"), None);
274 }
275
276 #[test]
277 fn visual_row_limit_reports_only_actual_overflow() {
278 let exact = "A".repeat(DEFAULT_MAX_SCREEN_LINES * DEFAULT_COLUMNS);
279 let rendered = render_terminal_output_detailed(&exact);
280 assert_eq!(rendered.lines.join("\n"), exact);
281 assert!(!rendered.output_truncated);
282
283 for suffix in ["B", "\n"] {
284 let rendered = render_terminal_output_detailed(&format!("{exact}{suffix}"));
285 assert!(rendered.output_truncated, "suffix {suffix:?} must scroll one row");
286 assert!(rendered.lines.join("\n").len() <= DEFAULT_MAX_SCREEN_LINES * DEFAULT_COLUMNS);
287 }
288 }
289
290 #[test]
291 fn scrolling_blank_rows_does_not_report_lost_text() {
292 let rendered = render_terminal_output_detailed("\x1b[999B\nFINAL");
293 assert_eq!(rendered.lines.last().map(String::as_str), Some("FINAL"));
294 assert!(!rendered.output_truncated);
295 }
296
297 #[test]
298 fn discarded_text_is_reported_even_after_screen_replacement() {
299 let overflowing = "row\n".repeat(DEFAULT_MAX_SCREEN_LINES + 1);
300 for (input, expected) in [
301 (format!("{overflowing}\x1b[2J\x1b[HFINAL"), "FINAL"),
302 (format!("PRIMARY\x1b[?1049h{overflowing}\x1b[?1049l"), "PRIMARY"),
303 ] {
304 let rendered = render_terminal_output_detailed(&input);
305 assert_eq!(rendered.lines, vec![expected]);
306 assert!(rendered.output_truncated);
307 }
308 }
309
310 #[test]
311 fn normalize_lf_inserts_cr_only_when_missing() {
312 assert_eq!(normalize_lf_to_crlf("a\nb"), b"a\r\nb");
313 assert_eq!(normalize_lf_to_crlf("a\r\nb"), b"a\r\nb");
314 assert_eq!(normalize_lf_to_crlf("plain"), b"plain");
315 }
316
317 use proptest::prelude::*;
318
319 proptest! {
320 #[test]
323 fn strip_ansi_codes_leaves_no_esc(text in prop::collection::vec(any::<char>(), 0..256)) {
324 let s: String = text.into_iter().collect();
325 let out = strip_ansi_codes(&s);
326 prop_assert!(!out.contains('\u{1b}'), "ESC survived stripping: {out:?}");
327 }
328
329 #[test]
331 fn strip_ansi_codes_is_identity_without_esc(
332 text in prop::collection::vec(
333 any::<char>().prop_filter("no esc", |c| *c != '\u{1b}'),
334 0..256,
335 ),
336 ) {
337 let s: String = text.into_iter().collect();
338 prop_assert_eq!(strip_ansi_codes(&s), s);
339 }
340 }
341}