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    /// Diff the current screen against the snapshot the client last saw, update
183    /// the baseline, and return only what changed. Positional (the terminal is a
184    /// grid that redraws in place), 1-based rows to match the rest of winx.
185    /// Returns the full screen on the first look or when more than `threshold`
186    /// lines changed (a diff that big isn't worth the framing).
187    pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
188        let current = self.snapshot(max_lines);
189        let update = match self.last_snapshot.as_ref() {
190            Some(previous) => {
191                let changed = diff_lines(previous, &current);
192                if changed.is_empty() {
193                    ScreenUpdate::Unchanged
194                } else if changed.len() <= threshold {
195                    ScreenUpdate::Diff(changed)
196                } else {
197                    ScreenUpdate::Full(current.clone())
198                }
199            }
200            None => ScreenUpdate::Full(current.clone()),
201        };
202        self.last_snapshot = Some(current);
203        update
204    }
205}
206
207/// Positional line diff: each `(1-based row, new content)` where the two frames
208/// differ. A blanked/removed line shows up as that row with empty content.
209fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
210    let rows = previous.len().max(current.len());
211    (0..rows)
212        .filter_map(|i| {
213            let was = previous.get(i).map_or("", String::as_str);
214            let now = current.get(i).map_or("", String::as_str);
215            (was != now).then(|| (i + 1, now.to_string()))
216        })
217        .collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
226        // The agy pattern: draw a frame, move the cursor back up, erase below
227        // and redraw. A growing-buffer emulator stacks "Generating" and "DONE";
228        // a fixed viewport consolidates to just the latest frame.
229        let mut t = LiveTerminal::new(10, 40);
230        t.feed(b"line one\r\nGenerating...\r\n"); // frame 1, cursor now on row 2
231        t.feed(b"\x1b[2A"); // cursor up 2 -> back to "line one"
232        t.feed(b"\x1b[Jline one\r\nDONE\r\n"); // erase-below + redraw frame 2
233        let joined = t.snapshot(0).join("\n");
234        assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
235        assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
236    }
237
238    #[test]
239    fn viewport_is_fixed_old_lines_scroll_off() {
240        let mut t = LiveTerminal::new(3, 40); // only 3 visible rows
241        for i in 0..10 {
242            t.feed(format!("row{i}\r\n").as_bytes());
243        }
244        let snap = t.snapshot(0);
245        assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
246        assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
247        assert!(
248            !snap.iter().any(|l| l.contains("row0")),
249            "oldest row should have scrolled off a fixed viewport: {snap:?}"
250        );
251    }
252
253    #[test]
254    fn detects_alternate_screen_toggle() {
255        let mut t = LiveTerminal::new(10, 40);
256        assert!(!t.in_alt_screen());
257        t.feed(b"\x1b[?1049h");
258        assert!(t.in_alt_screen());
259        t.feed(b"\x1b[?1049l");
260        assert!(!t.in_alt_screen());
261    }
262
263    #[test]
264    fn osc_title_and_progress_are_captured_and_cleared() {
265        let mut t = LiveTerminal::new(10, 40);
266        assert_eq!(t.osc_title(), "");
267        assert_eq!(t.osc_progress(), "");
268
269        // OSC 0 (BEL-terminated) — the claude spinner-in-title pattern.
270        t.feed(b"\x1b]0;\xe2\xa0\x8b claude \x07");
271        assert_eq!(t.osc_title(), "⠋ claude ");
272
273        // OSC 2 (ST-terminated) replaces it; semicolons in the title survive.
274        t.feed(b"\x1b]2;a;b\x1b\\");
275        assert_eq!(t.osc_title(), "a;b");
276
277        // `ConEmu` progress: OSC 9;4;<state>;<percent> retains "4;...".
278        t.feed(b"\x1b]9;4;1;42\x07");
279        assert_eq!(t.osc_progress(), "4;1;42");
280
281        // An empty title update clears (herdr retention semantics).
282        t.feed(b"\x1b]0;\x07");
283        assert_eq!(t.osc_title(), "");
284
285        t.feed(b"\x1b]2;back\x07");
286        t.clear_osc();
287        assert_eq!(t.osc_title(), "");
288        assert_eq!(t.osc_progress(), "");
289    }
290
291    #[test]
292    fn osc_sequence_split_across_feeds_decodes() {
293        let mut t = LiveTerminal::new(10, 40);
294        t.feed(b"\x1b]2;spl");
295        t.feed(b"it title\x07");
296        assert_eq!(t.osc_title(), "split title");
297    }
298
299    #[test]
300    fn osc_state_survives_resize() {
301        let mut t = LiveTerminal::new(10, 40);
302        t.feed(b"\x1b]2;kept\x07\x1b]9;4;0\x07");
303        t.resize(20, 80);
304        assert_eq!(t.osc_title(), "kept");
305        assert_eq!(t.osc_progress(), "4;0");
306    }
307
308    #[test]
309    fn snapshot_last_n_lines() {
310        let mut t = LiveTerminal::new(10, 40);
311        t.feed(b"a\r\nb\r\nc\r\nd\r\n");
312        assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
313    }
314
315    #[test]
316    fn cursor_position_tracks_feed() {
317        let mut t = LiveTerminal::new(10, 40);
318        t.feed(b"abc");
319        assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
320    }
321
322    #[test]
323    fn snapshot_diff_full_then_unchanged_then_one_line() {
324        let mut t = LiveTerminal::new(5, 40);
325        t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
326        // First look: no baseline -> full frame.
327        assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
328        // Nothing fed since -> Unchanged.
329        assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
330        // Overwrite row 2 (1-based) in place -> a single-line diff.
331        t.feed(b"\x1b[2;1Hbeta2");
332        let update = t.snapshot_diff(0, 10);
333        assert!(
334            matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
335            "expected a one-line diff, got {update:?}"
336        );
337        if let ScreenUpdate::Diff(changed) = &update {
338            assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
339            assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
340        }
341    }
342
343    #[test]
344    fn snapshot_diff_big_change_falls_back_to_full() {
345        let mut t = LiveTerminal::new(10, 40);
346        t.feed(b"seed\r\n");
347        let _ = t.snapshot_diff(0, 1); // establish baseline
348        for i in 0..8 {
349            t.feed(format!("line{i}\r\n").as_bytes());
350        }
351        // More than `threshold` lines changed -> full frame, not a giant diff.
352        assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
353    }
354
355    use proptest::prelude::*;
356
357    proptest! {
358        // The live tap is fed untrusted program output. Feeding arbitrary byte
359        // chunks (split anywhere, so escape sequences straddle boundaries) must
360        // never panic and must keep snapshot/cursor inside the fixed viewport.
361        #[test]
362        fn feed_arbitrary_bytes_never_panics_and_stays_bounded(
363            rows in 1u16..40,
364            cols in 1u16..120,
365            chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..48), 0..24),
366        ) {
367            // Effective viewport after the MIN_VIEWPORT floor (see `new`).
368            let eff_rows = rows.max(MIN_VIEWPORT);
369            let eff_cols = cols.max(MIN_VIEWPORT);
370            let mut t = LiveTerminal::new(rows, cols);
371            for chunk in &chunks {
372                t.feed(chunk);
373            }
374            // Snapshot never exceeds the viewport height.
375            let full = t.snapshot(0);
376            prop_assert!(full.len() <= eff_rows as usize, "snapshot {} rows > viewport {eff_rows}", full.len());
377            // Cursor stays inside the grid (col may rest at `cols` on a pending wrap).
378            let (cr, cc) = t.cursor_position();
379            prop_assert!(cr < eff_rows, "cursor row {cr} >= rows {eff_rows}");
380            prop_assert!(cc <= eff_cols, "cursor col {cc} > cols {eff_cols}");
381            // `max_lines` is honored.
382            prop_assert!(t.snapshot(3).len() <= 3);
383            // Diffing never panics and respects the threshold cap.
384            let _ = t.snapshot_diff(0, 4);
385            if let ScreenUpdate::Diff(changed) = t.snapshot_diff(0, 4) {
386                prop_assert!(changed.len() <= 4);
387            }
388        }
389
390        // Resizing to arbitrary dimensions (including row/col swaps) after a
391        // feed never panics and re-bounds the snapshot to the new viewport.
392        #[test]
393        fn resize_after_feed_never_panics(
394            r1 in 1u16..30, c1 in 1u16..80,
395            r2 in 1u16..30, c2 in 1u16..80,
396            data in prop::collection::vec(any::<u8>(), 0..256),
397        ) {
398            let eff_r2 = r2.max(MIN_VIEWPORT) as usize;
399            let mut t = LiveTerminal::new(r1, c1);
400            t.feed(&data);
401            t.resize(r2, c2);
402            prop_assert!(t.snapshot(0).len() <= eff_r2);
403            // Feeding again after the resize is still bounded by the new height.
404            t.feed(&data);
405            prop_assert!(t.snapshot(0).len() <= eff_r2);
406        }
407    }
408}