Skip to main content

winx_code_agent/state/
live_terminal.rs

1//! Live terminal emulator for piloting interactive TUIs, backed by the `vt100`
2//! crate.
3//!
4//! Unlike the hand-rolled [`crate::state::terminal::Screen`] (a buffer that
5//! *grows* on every linefeed), this models a **fixed viewport of `rows` lines**
6//! with proper scrolling — the model a real terminal uses. Inline TUIs that
7//! redraw by moving the cursor up and rewriting with erase-line (the `agy` /
8//! Antigravity CLI, which renders in the main screen rather than the alternate
9//! one) consolidate cleanly here instead of stacking ghost frames. See the
10//! `inline_redraw_*` test for the exact regression this fixes.
11//!
12//! Only the *live* PTY tap uses this. One-shot rendering of finished command
13//! output (`render_terminal_output`) stays on the growing-buffer `Screen`,
14//! which is the right model there: it must keep every line of output, not clip
15//! to a viewport.
16
17use std::num::NonZeroU16;
18use vt100::Parser;
19
20/// Floor for the viewport dimensions. vt100 0.16.2 underflows (`grid.rs`:
21/// `prev_pos.row -= scrolled` and `size.cols - width`) on a 1-row or 1-col grid
22/// when output triggers a wrap+scroll — a debug panic, silent `u16` wrap in
23/// release. A real PTY is never that small, but `resize` can be asked for it, so
24/// we clamp to the smallest size vt100 handles safely.
25const MIN_VIEWPORT: u16 = 2;
26
27/// Floor a requested grid dimension at [`MIN_VIEWPORT`] as the non-zero
28/// size `vt100` requires.
29fn grid_dimension(value: u16) -> NonZeroU16 {
30    NonZeroU16::new(value.max(MIN_VIEWPORT)).unwrap_or(NonZeroU16::MIN)
31}
32
33/// What changed on the live screen since the client last looked. Lets
34/// `status_check` ship only the delta when piloting a TUI over many polls —
35/// far fewer tokens than re-sending the whole frame each time.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ScreenUpdate {
38    /// No baseline yet (first look) or too much changed — the full screen.
39    Full(Vec<String>),
40    /// Only these `(1-based row, new content)` lines changed since the last look.
41    Diff(Vec<(usize, String)>),
42    /// Nothing changed since the last look.
43    Unchanged,
44}
45
46/// The latest OSC title/progress evidence scraped from the raw PTY stream.
47/// Coding-agent TUIs signal their state out-of-band here — claude/codex put a
48/// spinner glyph in the OSC 0/2 window title while generating, and several
49/// agents emit `ConEmu`-style OSC 9;4 progress — and the manifest-driven turn
50/// recognizers ([`crate::state::turn_manifest`]) read both as detection
51/// regions. vt100 exposes neither, so a second, OSC-only `vte` pass collects
52/// them. Mirrors herdr's retention semantics: the latest update wins, and an
53/// empty title update clears (`""` also means "never seen").
54#[derive(Debug, Default, Clone)]
55struct OscSignals {
56    /// Payload of the last OSC 0/2 (icon+title / title) sequence.
57    title: String,
58    /// Payload of the last OSC 9 sequence after the `9;` prefix — `ConEmu`
59    /// progress arrives as `4;<state>;<percent>` (manifest rules anchor on
60    /// `^4;...`), other OSC 9 notifications keep their raw text.
61    progress: String,
62}
63
64impl vte::Perform for OscSignals {
65    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
66        let Some(&code) = params.first() else { return };
67        if code == b"0" || code == b"2" {
68            self.title = join_osc_params(&params[1..]);
69        } else if code == b"9" {
70            self.progress = join_osc_params(&params[1..]);
71        }
72    }
73}
74
75/// Re-join OSC parameters that `vte` split on `;` — titles may legitimately
76/// contain semicolons.
77fn join_osc_params(params: &[&[u8]]) -> String {
78    params.iter().map(|param| String::from_utf8_lossy(param)).collect::<Vec<_>>().join(";")
79}
80
81/// A continuously-fed terminal emulator with a fixed viewport. Wraps a
82/// `vt100::Parser` and exposes just what the PTY live tap needs.
83pub struct LiveTerminal {
84    parser: Parser,
85    /// Snapshot the client saw on its last look — the baseline the next
86    /// [`LiveTerminal::snapshot_diff`] diffs against.
87    last_snapshot: Option<Vec<String>>,
88    /// OSC-only second parser: persistent so sequences split across feed
89    /// chunks decode correctly, and deliberately NOT rebuilt on `resize` —
90    /// the retained title/progress must survive viewport changes.
91    osc_parser: vte::Parser,
92    osc: OscSignals,
93}
94
95impl LiveTerminal {
96    /// Create a live terminal with a fixed `rows`x`cols` viewport. Dimensions
97    /// are floored at [`MIN_VIEWPORT`] (a 0/1-sized grid underflows vt100).
98    /// Scrollback is left at 0: the snapshot only needs the visible viewport,
99    /// and the PTY keeps its own raw `line_ring` for deeper scrollback.
100    pub fn new(rows: u16, cols: u16) -> Self {
101        Self {
102            parser: Parser::new(grid_dimension(rows), grid_dimension(cols), 0),
103            last_snapshot: None,
104            osc_parser: vte::Parser::new(),
105            osc: OscSignals::default(),
106        }
107    }
108
109    /// Feed raw PTY bytes. The parser is persistent, so escape sequences split
110    /// across chunk boundaries decode correctly.
111    pub fn feed(&mut self, bytes: &[u8]) {
112        self.osc_parser.advance(&mut self.osc, bytes);
113        self.parser.process(bytes);
114    }
115
116    /// The latest OSC 0/2 title, or `""` when none was seen (or the last
117    /// update was an empty clear).
118    pub fn osc_title(&self) -> &str {
119        &self.osc.title
120    }
121
122    /// The latest OSC 9 payload (after `9;`), or `""` when none was seen.
123    /// `ConEmu` progress reads `4;<state>;<percent>`.
124    pub fn osc_progress(&self) -> &str {
125        &self.osc.progress
126    }
127
128    /// Drop retained OSC title/progress evidence. Called when a new command is
129    /// submitted so a stale title from the previous foreground program can't
130    /// leak into the next program's turn detection.
131    pub fn clear_osc(&mut self) {
132        self.osc = OscSignals::default();
133    }
134
135    /// Whether the alternate screen buffer is currently active (vim, htop, and
136    /// the Ink-based claude/codex TUIs use it).
137    pub fn in_alt_screen(&self) -> bool {
138        self.parser.screen().alternate_screen()
139    }
140
141    /// Resize the viewport (rows, cols), floored at [`MIN_VIEWPORT`].
142    ///
143    /// We deliberately do **not** call vt100's `set_size`: its column-shrink
144    /// reflow underflows / `unwrap`s on perfectly ordinary content (fuzz-found),
145    /// and since the release build is `panic = "abort"` such a panic would take
146    /// the whole MCP server down. Instead we rebuild the parser at the new size
147    /// and replay the visible text — plain rendered lines can't retrigger the
148    /// reflow bug, and TUIs redraw on the SIGWINCH that accompanies a resize.
149    pub fn resize(&mut self, rows: u16, cols: u16) {
150        let rows = grid_dimension(rows);
151        let cols = grid_dimension(cols);
152        let preserved = self.snapshot(0);
153        self.parser = Parser::new(rows, cols, 0);
154        if !preserved.is_empty() {
155            self.parser.process(preserved.join("\r\n").as_bytes());
156        }
157        // The baseline no longer matches the new geometry; force a full next diff.
158        self.last_snapshot = None;
159    }
160
161    /// Snapshot the consolidated visible screen as plain-text lines (ANSI
162    /// already resolved by the emulator), trailing blank lines trimmed.
163    /// `max_lines` of 0 returns the whole viewport; otherwise the last N lines.
164    pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
165        let contents = self.parser.screen().contents();
166        let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
167        while lines.last().is_some_and(|l| l.trim().is_empty()) {
168            lines.pop();
169        }
170        if max_lines > 0 && lines.len() > max_lines {
171            lines = lines.split_off(lines.len() - max_lines);
172        }
173        lines
174    }
175
176    /// Cursor position as `(row, col)`, 0-based, from the live screen — tells the
177    /// agent where focus sits in a menu/form it's piloting.
178    pub fn cursor_position(&self) -> (u16, u16) {
179        self.parser.screen().cursor_position()
180    }
181
182    /// Visible text on the cursor row, left of the cursor. A program blocked on
183    /// a line prompt (`Password: `, `[y/N] `) leaves the cursor right after it.
184    pub fn cursor_line_prefix(&self) -> String {
185        let screen = self.parser.screen();
186        let (row, col) = screen.cursor_position();
187        if col == 0 {
188            return String::new();
189        }
190        screen.contents_between(row, 0, row, col)
191    }
192
193    /// Diff the current screen against the snapshot the client last saw, update
194    /// the baseline, and return only what changed. Positional (the terminal is a
195    /// grid that redraws in place), 1-based rows to match the rest of winx.
196    /// Returns the full screen on the first look or when more than `threshold`
197    /// lines changed (a diff that big isn't worth the framing).
198    pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
199        let current = self.snapshot(max_lines);
200        let update = match self.last_snapshot.as_ref() {
201            Some(previous) => {
202                let changed = diff_lines(previous, &current);
203                if changed.is_empty() {
204                    ScreenUpdate::Unchanged
205                } else if changed.len() <= threshold {
206                    ScreenUpdate::Diff(changed)
207                } else {
208                    ScreenUpdate::Full(current.clone())
209                }
210            }
211            None => ScreenUpdate::Full(current.clone()),
212        };
213        self.last_snapshot = Some(current);
214        update
215    }
216}
217
218/// Positional line diff: each `(1-based row, new content)` where the two frames
219/// differ. A blanked/removed line shows up as that row with empty content.
220fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
221    let rows = previous.len().max(current.len());
222    (0..rows)
223        .filter_map(|i| {
224            let was = previous.get(i).map_or("", String::as_str);
225            let now = current.get(i).map_or("", String::as_str);
226            (was != now).then(|| (i + 1, now.to_string()))
227        })
228        .collect()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
237        // The agy pattern: draw a frame, move the cursor back up, erase below
238        // and redraw. A growing-buffer emulator stacks "Generating" and "DONE";
239        // a fixed viewport consolidates to just the latest frame.
240        let mut t = LiveTerminal::new(10, 40);
241        t.feed(b"line one\r\nGenerating...\r\n"); // frame 1, cursor now on row 2
242        t.feed(b"\x1b[2A"); // cursor up 2 -> back to "line one"
243        t.feed(b"\x1b[Jline one\r\nDONE\r\n"); // erase-below + redraw frame 2
244        let joined = t.snapshot(0).join("\n");
245        assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
246        assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
247    }
248
249    #[test]
250    fn viewport_is_fixed_old_lines_scroll_off() {
251        let mut t = LiveTerminal::new(3, 40); // only 3 visible rows
252        for i in 0..10 {
253            t.feed(format!("row{i}\r\n").as_bytes());
254        }
255        let snap = t.snapshot(0);
256        assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
257        assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
258        assert!(
259            !snap.iter().any(|l| l.contains("row0")),
260            "oldest row should have scrolled off a fixed viewport: {snap:?}"
261        );
262    }
263
264    #[test]
265    fn detects_alternate_screen_toggle() {
266        let mut t = LiveTerminal::new(10, 40);
267        assert!(!t.in_alt_screen());
268        t.feed(b"\x1b[?1049h");
269        assert!(t.in_alt_screen());
270        t.feed(b"\x1b[?1049l");
271        assert!(!t.in_alt_screen());
272    }
273
274    #[test]
275    fn osc_title_and_progress_are_captured_and_cleared() {
276        let mut t = LiveTerminal::new(10, 40);
277        assert_eq!(t.osc_title(), "");
278        assert_eq!(t.osc_progress(), "");
279
280        // OSC 0 (BEL-terminated) — the claude spinner-in-title pattern.
281        t.feed(b"\x1b]0;\xe2\xa0\x8b claude \x07");
282        assert_eq!(t.osc_title(), "⠋ claude ");
283
284        // OSC 2 (ST-terminated) replaces it; semicolons in the title survive.
285        t.feed(b"\x1b]2;a;b\x1b\\");
286        assert_eq!(t.osc_title(), "a;b");
287
288        // `ConEmu` progress: OSC 9;4;<state>;<percent> retains "4;...".
289        t.feed(b"\x1b]9;4;1;42\x07");
290        assert_eq!(t.osc_progress(), "4;1;42");
291
292        // An empty title update clears (herdr retention semantics).
293        t.feed(b"\x1b]0;\x07");
294        assert_eq!(t.osc_title(), "");
295
296        t.feed(b"\x1b]2;back\x07");
297        t.clear_osc();
298        assert_eq!(t.osc_title(), "");
299        assert_eq!(t.osc_progress(), "");
300    }
301
302    #[test]
303    fn osc_sequence_split_across_feeds_decodes() {
304        let mut t = LiveTerminal::new(10, 40);
305        t.feed(b"\x1b]2;spl");
306        t.feed(b"it title\x07");
307        assert_eq!(t.osc_title(), "split title");
308    }
309
310    #[test]
311    fn osc_state_survives_resize() {
312        let mut t = LiveTerminal::new(10, 40);
313        t.feed(b"\x1b]2;kept\x07\x1b]9;4;0\x07");
314        t.resize(20, 80);
315        assert_eq!(t.osc_title(), "kept");
316        assert_eq!(t.osc_progress(), "4;0");
317    }
318
319    #[test]
320    fn snapshot_last_n_lines() {
321        let mut t = LiveTerminal::new(10, 40);
322        t.feed(b"a\r\nb\r\nc\r\nd\r\n");
323        assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
324    }
325
326    #[test]
327    fn cursor_position_tracks_feed() {
328        let mut t = LiveTerminal::new(10, 40);
329        t.feed(b"abc");
330        assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
331    }
332
333    #[test]
334    fn cursor_line_prefix_reads_the_pending_prompt() {
335        let mut t = LiveTerminal::new(10, 40);
336        assert_eq!(t.cursor_line_prefix(), "");
337        t.feed(b"building\r\nPassword: ");
338        assert_eq!(t.cursor_line_prefix(), "Password: ");
339        t.feed(b"\r\n");
340        assert_eq!(t.cursor_line_prefix(), "");
341    }
342
343    #[test]
344    fn snapshot_diff_full_then_unchanged_then_one_line() {
345        let mut t = LiveTerminal::new(5, 40);
346        t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
347        // First look: no baseline -> full frame.
348        assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
349        // Nothing fed since -> Unchanged.
350        assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
351        // Overwrite row 2 (1-based) in place -> a single-line diff.
352        t.feed(b"\x1b[2;1Hbeta2");
353        let update = t.snapshot_diff(0, 10);
354        assert!(
355            matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
356            "expected a one-line diff, got {update:?}"
357        );
358        if let ScreenUpdate::Diff(changed) = &update {
359            assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
360            assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
361        }
362    }
363
364    #[test]
365    fn snapshot_diff_big_change_falls_back_to_full() {
366        let mut t = LiveTerminal::new(10, 40);
367        t.feed(b"seed\r\n");
368        let _ = t.snapshot_diff(0, 1); // establish baseline
369        for i in 0..8 {
370            t.feed(format!("line{i}\r\n").as_bytes());
371        }
372        // More than `threshold` lines changed -> full frame, not a giant diff.
373        assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
374    }
375
376    use proptest::prelude::*;
377
378    proptest! {
379        // The live tap is fed untrusted program output. Feeding arbitrary byte
380        // chunks (split anywhere, so escape sequences straddle boundaries) must
381        // never panic and must keep snapshot/cursor inside the fixed viewport.
382        #[test]
383        fn feed_arbitrary_bytes_never_panics_and_stays_bounded(
384            rows in 1u16..40,
385            cols in 1u16..120,
386            chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..48), 0..24),
387        ) {
388            // Effective viewport after the MIN_VIEWPORT floor (see `new`).
389            let eff_rows = rows.max(MIN_VIEWPORT);
390            let eff_cols = cols.max(MIN_VIEWPORT);
391            let mut t = LiveTerminal::new(rows, cols);
392            for chunk in &chunks {
393                t.feed(chunk);
394            }
395            // Snapshot never exceeds the viewport height.
396            let full = t.snapshot(0);
397            prop_assert!(full.len() <= eff_rows as usize, "snapshot {} rows > viewport {eff_rows}", full.len());
398            // Cursor stays inside the grid (col may rest at `cols` on a pending wrap).
399            let (cr, cc) = t.cursor_position();
400            prop_assert!(cr < eff_rows, "cursor row {cr} >= rows {eff_rows}");
401            prop_assert!(cc <= eff_cols, "cursor col {cc} > cols {eff_cols}");
402            // `max_lines` is honored.
403            prop_assert!(t.snapshot(3).len() <= 3);
404            // Diffing never panics and respects the threshold cap.
405            let _ = t.snapshot_diff(0, 4);
406            if let ScreenUpdate::Diff(changed) = t.snapshot_diff(0, 4) {
407                prop_assert!(changed.len() <= 4);
408            }
409        }
410
411        // Resizing to arbitrary dimensions (including row/col swaps) after a
412        // feed never panics and re-bounds the snapshot to the new viewport.
413        #[test]
414        fn resize_after_feed_never_panics(
415            r1 in 1u16..30, c1 in 1u16..80,
416            r2 in 1u16..30, c2 in 1u16..80,
417            data in prop::collection::vec(any::<u8>(), 0..256),
418        ) {
419            let eff_r2 = r2.max(MIN_VIEWPORT) as usize;
420            let mut t = LiveTerminal::new(r1, c1);
421            t.feed(&data);
422            t.resize(r2, c2);
423            prop_assert!(t.snapshot(0).len() <= eff_r2);
424            // Feeding again after the resize is still bounded by the new height.
425            t.feed(&data);
426            prop_assert!(t.snapshot(0).len() <= eff_r2);
427        }
428    }
429}