Skip to main content

winx_code_agent/state/
terminal.rs

1use regex::Regex;
2use std::collections::{HashMap, VecDeque};
3use std::sync::{Arc, LazyLock, RwLock};
4use std::time::Instant;
5
6/// Maximum number of lines to keep in the screen buffer
7pub const MAX_SCREEN_LINES: usize = 10000;
8/// Default maximum number of lines to keep in the screen buffer
9pub const DEFAULT_MAX_SCREEN_LINES: usize = 500;
10/// Maximum number of columns for the screen. Must match the PTY width
11/// (`pty::DEFAULT_COLS`) so emulator-rendered scrollback wraps exactly where the
12/// real terminal did, instead of silently re-wrapping long lines at a narrower
13/// width.
14const DEFAULT_COLUMNS: usize = 200;
15/// Maximum output size in bytes to prevent excessive memory usage
16pub const MAX_OUTPUT_SIZE: usize = 500_000;
17/// Maximum cache entry lifetime in seconds
18const CACHE_TTL: u64 = 300; // 5 minutes
19
20/// Map from a 64-bit text hash to (rendered output, insertion time).
21///
22/// Keyed by a hash of the source text rather than the text itself: a terminal
23/// dump can be hundreds of KB, and storing it as the map key (plus cloning it
24/// on every eviction) was the dominant cost. A hash collision only causes a
25/// stale render to be recomputed, never corruption.
26type CacheEntryMap = HashMap<u64, (Vec<String>, Instant)>;
27
28/// Hash a chunk of terminal text into the cache key space.
29fn 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/// Inner cache state behind a single lock so the map and the eviction queue can
37/// never drift out of sync.
38#[derive(Debug, Default)]
39struct CacheInner {
40    map: CacheEntryMap,
41    /// Keys in insertion order, for O(1) FIFO eviction (no O(n log n) sort).
42    order: VecDeque<u64>,
43}
44
45/// Caching system for terminal output rendering
46#[derive(Debug, Clone)]
47struct TerminalCache {
48    inner: Arc<RwLock<CacheInner>>,
49    /// Maximum number of entries in the cache
50    max_entries: usize,
51    /// Time-to-live for cache entries in seconds
52    ttl: u64,
53}
54
55impl TerminalCache {
56    /// Create a new terminal cache
57    fn new(max_entries: usize, ttl: u64) -> Self {
58        Self { inner: Arc::new(RwLock::new(CacheInner::default())), max_entries, ttl }
59    }
60
61    /// Get a cached value if available and not expired
62    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    /// Insert a value into the cache, evicting the oldest entries if over cap.
74    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    /// Clear expired entries from the cache
91    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
102// Initialize the global terminal cache.
103static TERMINAL_CACHE: LazyLock<TerminalCache> =
104    LazyLock::new(|| TerminalCache::new(100, CACHE_TTL));
105
106/// Render terminal output with line wrapping
107pub fn render_terminal_output(text: &str) -> Vec<String> {
108    // Check cache first.
109    if let Some(cached) = TERMINAL_CACHE.get(text) {
110        return cached;
111    }
112
113    let result = render_via_vt100(text);
114
115    // Cache the result for future use (only if reasonably sized).
116    if text.len() < MAX_OUTPUT_SIZE {
117        TERMINAL_CACHE.insert(text, result.clone());
118    }
119
120    // Periodically clean up expired cache entries.
121    if rand::random::<u32>().is_multiple_of(100) {
122        TERMINAL_CACHE.cleanup();
123    }
124
125    result
126}
127
128/// Render finished command output to plain-text lines via vt100, applying cursor
129/// movements so readline echo / in-place redraws collapse exactly as a real
130/// terminal would. One-shot — a fresh parser per call. This is NOT the live PTY
131/// emulator (that is [`crate::state::live_terminal::LiveTerminal`]).
132fn render_via_vt100(text: &str) -> Vec<String> {
133    // vt100 treats a bare LF as line-feed-only (the cursor keeps its column, so
134    // output stair-steps). The old hand-rolled engine treated LF as CR+LF, and
135    // the wcgw incremental path (`bash_command.rs`) re-feeds already-rendered
136    // text joined by '\n'. Normalize so multi-line output doesn't stair-step and
137    // a re-render stays idempotent.
138    let bytes = normalize_lf_to_crlf(text);
139
140    // Size the viewport to the input so short output (the overwhelming majority
141    // of calls) doesn't pay for a giant grid allocation. Cap at
142    // DEFAULT_MAX_SCREEN_LINES: longer output keeps its LAST lines and drops the
143    // oldest — the same cap the old Screen enforced, now via vt100 scroll with
144    // scrollback disabled.
145    let line_estimate = text.lines().count().saturating_add(2);
146    let rows = u16::try_from(line_estimate.clamp(1, DEFAULT_MAX_SCREEN_LINES)).unwrap_or(u16::MAX);
147    let cols = u16::try_from(DEFAULT_COLUMNS).unwrap_or(u16::MAX);
148    let rows = std::num::NonZeroU16::new(rows).unwrap_or(std::num::NonZeroU16::MIN);
149    let cols = std::num::NonZeroU16::new(cols).unwrap_or(std::num::NonZeroU16::MIN);
150
151    let mut parser = vt100::Parser::new(rows, cols, 0);
152    parser.process(&bytes);
153
154    // vt100's `contents()` already yields plain text with escapes resolved, so no
155    // post-strip is needed. Trim trailing whitespace per line and drop trailing
156    // blank lines, matching the old `Screen::display()` shape.
157    let mut lines: Vec<String> =
158        parser.screen().contents().lines().map(|line| line.trim_end().to_string()).collect();
159    while lines.last().is_some_and(String::is_empty) {
160        lines.pop();
161    }
162    lines
163}
164
165/// Insert a CR before any LF that lacks one. PTY data already arrives as CRLF;
166/// this only fixes rendered text re-fed through [`render_terminal_output`].
167fn normalize_lf_to_crlf(text: &str) -> Vec<u8> {
168    let src = text.as_bytes();
169    let mut out = Vec::with_capacity(src.len() + 16);
170    let mut prev = 0u8;
171    for &byte in src {
172        if byte == b'\n' && prev != b'\r' {
173            out.push(b'\r');
174        }
175        out.push(byte);
176        prev = byte;
177    }
178    out
179}
180
181/// Strip ANSI escape codes from a string using a robust regex
182pub fn strip_ansi_codes(input: &str) -> String {
183    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
184
185    // Fast path: no ESC byte means nothing to strip.
186    if !input.contains('\u{1b}') {
187        return input.to_string();
188    }
189
190    // Cover the full escape grammar, not just SGR colors. Interactive programs
191    // (python/node REPLs, psql, readline) emit far more than colors:
192    //   - CSI: ESC [ <params> <intermediates> <final> (cursor moves, `?2004h`
193    //          bracketed-paste, `2K` erase, `0m` reset, ...)
194    //   - OSC: ESC ] ... (BEL | ST)                    (window-title sets)
195    //   - 2-byte ESC sequences (ESC =, ESC >, ESC M, ...)
196    // Without OSC/CSI-cursor coverage the scrollback leaked raw bracketed-paste
197    // and `]0;user@host` noise into the model's view. Cached so we don't
198    // recompile the pattern on every rendered line.
199    // `.expect`, not `.ok()`: a compile-time-literal regex that fails is a dev
200    // bug. The old `OnceLock<Option<Regex>>` + `.ok()` froze `None` on first use
201    // and then silently stopped stripping CSI/OSC params for the rest of the
202    // process — leaking raw escape sequences into the model's view. This is the
203    // production strip used by bash_command/pty; fail loud instead.
204    #[allow(clippy::expect_used)]
205    let re = RE.get_or_init(|| {
206        Regex::new(
207            r"\x1b\[[0-9;:?<>=!]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[=>MNcD78]",
208        )
209        .expect("ANSI strip regex must compile")
210    });
211    let cleaned = re.replace_all(input, "").into_owned();
212    // Defensive: drop any stray ESC the pattern didn't consume (partial seqs).
213    cleaned.replace('\u{1b}', "")
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn terminal_cache_round_trips() {
222        let cache = TerminalCache::new(10, 60);
223        cache.insert("test", vec!["line1".to_string(), "line2".to_string()]);
224        assert_eq!(cache.get("test"), Some(vec!["line1".to_string(), "line2".to_string()]));
225        assert_eq!(cache.get("unknown"), None);
226    }
227
228    #[test]
229    fn normalize_lf_inserts_cr_only_when_missing() {
230        assert_eq!(normalize_lf_to_crlf("a\nb"), b"a\r\nb");
231        assert_eq!(normalize_lf_to_crlf("a\r\nb"), b"a\r\nb");
232        assert_eq!(normalize_lf_to_crlf("plain"), b"plain");
233    }
234
235    use proptest::prelude::*;
236
237    proptest! {
238        // Any input — including malformed/partial escape sequences and raw ESC
239        // bytes — must be stripped to leave no ESC behind, and never panic.
240        #[test]
241        fn strip_ansi_codes_leaves_no_esc(text in prop::collection::vec(any::<char>(), 0..256)) {
242            let s: String = text.into_iter().collect();
243            let out = strip_ansi_codes(&s);
244            prop_assert!(!out.contains('\u{1b}'), "ESC survived stripping: {out:?}");
245        }
246
247        // ESC-free input is returned verbatim (the fast path must not mangle).
248        #[test]
249        fn strip_ansi_codes_is_identity_without_esc(
250            text in prop::collection::vec(
251                any::<char>().prop_filter("no esc", |c| *c != '\u{1b}'),
252                0..256,
253            ),
254        ) {
255            let s: String = text.into_iter().collect();
256            prop_assert_eq!(strip_ansi_codes(&s), s);
257        }
258    }
259}