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/// Plain terminal text and whether nonblank rows were discarded during replay.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub(crate) struct TerminalRender {
23    pub(crate) lines: Vec<String>,
24    pub(crate) output_truncated: bool,
25}
26
27/// Map from a 64-bit text hash to (rendered output, insertion time).
28///
29/// Keyed by a hash of the source text rather than the text itself: a terminal
30/// dump can be hundreds of KB, and storing it as the map key (plus cloning it
31/// on every eviction) was the dominant cost. A hash collision only causes a
32/// stale render to be recomputed, never corruption.
33type CacheEntryMap = HashMap<u64, (TerminalRender, Instant)>;
34
35/// Hash a chunk of terminal text into the cache key space.
36fn 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/// Inner cache state behind a single lock so the map and the eviction queue can
44/// never drift out of sync.
45#[derive(Debug, Default)]
46struct CacheInner {
47    map: CacheEntryMap,
48    /// Keys in insertion order, for O(1) FIFO eviction (no O(n log n) sort).
49    order: VecDeque<u64>,
50}
51
52/// Caching system for terminal output rendering
53#[derive(Debug, Clone)]
54struct TerminalCache {
55    inner: Arc<RwLock<CacheInner>>,
56    /// Maximum number of entries in the cache
57    max_entries: usize,
58    /// Time-to-live for cache entries in seconds
59    ttl: u64,
60}
61
62impl TerminalCache {
63    /// Create a new terminal cache
64    fn new(max_entries: usize, ttl: u64) -> Self {
65        Self { inner: Arc::new(RwLock::new(CacheInner::default())), max_entries, ttl }
66    }
67
68    /// Get a cached value if available and not expired
69    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    /// Insert a value into the cache, evicting the oldest entries if over cap.
81    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    /// Clear expired entries from the cache
98    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
109// Initialize the global terminal cache.
110static TERMINAL_CACHE: LazyLock<TerminalCache> =
111    LazyLock::new(|| TerminalCache::new(100, CACHE_TTL));
112
113/// Render terminal output with line wrapping
114pub 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    // Check cache first.
120    if let Some(cached) = TERMINAL_CACHE.get(text) {
121        return cached;
122    }
123
124    let result = render_via_vt100(text);
125
126    // Cache the result for future use (only if reasonably sized).
127    if text.len() < MAX_OUTPUT_SIZE {
128        TERMINAL_CACHE.insert(text, result.clone());
129    }
130
131    // Periodically clean up expired cache entries.
132    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            // Cursor movement can scroll an empty row without losing any text.
150            // Inspect only until the first nonblank row is lost in this pass.
151            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
162/// Render finished command output to plain-text lines via vt100, applying cursor
163/// movements so readline echo / in-place redraws collapse exactly as a real
164/// terminal would. One-shot — a fresh parser per call. This is NOT the live PTY
165/// emulator (that is [`crate::state::live_terminal::LiveTerminal`]).
166fn render_via_vt100(text: &str) -> TerminalRender {
167    // vt100 treats a bare LF as line-feed-only (the cursor keeps its column, so
168    // output stair-steps). The old hand-rolled engine treated LF as CR+LF, and
169    // the wcgw incremental path (`bash_command.rs`) re-feeds already-rendered
170    // text joined by '\n'. Normalize so multi-line output doesn't stair-step and
171    // a re-render stays idempotent.
172    let bytes = normalize_lf_to_crlf(text);
173
174    // Start small for ordinary output. Newline count alone underestimates wrapped
175    // lines (JSON, compiler diagnostics, Unicode), so let vt100 detect overflow.
176    // Replaying from scratch preserves cursor and clear-screen semantics; merely
177    // resizing after a lossy pass cannot recover those operations.
178    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        // Only the final pass determines loss. Keep the flag even if a later
198        // clear-screen or alternate-screen exit hides the surviving text.
199        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
209/// Insert a CR before any LF that lacks one. PTY data already arrives as CRLF;
210/// this only fixes rendered text re-fed through [`render_terminal_output`].
211fn 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
225/// Strip ANSI escape codes from a string using a robust regex
226pub fn strip_ansi_codes(input: &str) -> String {
227    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
228
229    // Fast path: no ESC byte means nothing to strip.
230    if !input.contains('\u{1b}') {
231        return input.to_string();
232    }
233
234    // Cover the full escape grammar, not just SGR colors. Interactive programs
235    // (python/node REPLs, psql, readline) emit far more than colors:
236    //   - CSI: ESC [ <params> <intermediates> <final> (cursor moves, `?2004h`
237    //          bracketed-paste, `2K` erase, `0m` reset, ...)
238    //   - OSC: ESC ] ... (BEL | ST)                    (window-title sets)
239    //   - 2-byte ESC sequences (ESC =, ESC >, ESC M, ...)
240    // Without OSC/CSI-cursor coverage the scrollback leaked raw bracketed-paste
241    // and `]0;user@host` noise into the model's view. Cached so we don't
242    // recompile the pattern on every rendered line.
243    // `.expect`, not `.ok()`: a compile-time-literal regex that fails is a dev
244    // bug. The old `OnceLock<Option<Regex>>` + `.ok()` froze `None` on first use
245    // and then silently stopped stripping CSI/OSC params for the rest of the
246    // process — leaking raw escape sequences into the model's view. This is the
247    // production strip used by bash_command/pty; fail loud instead.
248    #[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    // Defensive: drop any stray ESC the pattern didn't consume (partial seqs).
257    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        // Any input — including malformed/partial escape sequences and raw ESC
321        // bytes — must be stripped to leave no ESC behind, and never panic.
322        #[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        // ESC-free input is returned verbatim (the fast path must not mangle).
330        #[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}