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 vt100::Parser;
18
19/// What changed on the live screen since the client last looked. Lets
20/// `status_check` ship only the delta when piloting a TUI over many polls —
21/// far fewer tokens than re-sending the whole frame each time.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ScreenUpdate {
24    /// No baseline yet (first look) or too much changed — the full screen.
25    Full(Vec<String>),
26    /// Only these `(1-based row, new content)` lines changed since the last look.
27    Diff(Vec<(usize, String)>),
28    /// Nothing changed since the last look.
29    Unchanged,
30}
31
32/// A continuously-fed terminal emulator with a fixed viewport. Wraps a
33/// `vt100::Parser` and exposes just what the PTY live tap needs.
34pub struct LiveTerminal {
35    parser: Parser,
36    /// Snapshot the client saw on its last look — the baseline the next
37    /// [`LiveTerminal::snapshot_diff`] diffs against.
38    last_snapshot: Option<Vec<String>>,
39}
40
41impl LiveTerminal {
42    /// Create a live terminal with a fixed `rows`x`cols` viewport. Dimensions
43    /// are floored at 1 (a 0-sized grid is meaningless and panics downstream).
44    /// Scrollback is left at 0: the snapshot only needs the visible viewport,
45    /// and the PTY keeps its own raw `line_ring` for deeper scrollback.
46    pub fn new(rows: u16, cols: u16) -> Self {
47        Self { parser: Parser::new(rows.max(1), cols.max(1), 0), last_snapshot: None }
48    }
49
50    /// Feed raw PTY bytes. The parser is persistent, so escape sequences split
51    /// across chunk boundaries decode correctly.
52    pub fn feed(&mut self, bytes: &[u8]) {
53        self.parser.process(bytes);
54    }
55
56    /// Whether the alternate screen buffer is currently active (vim, htop, and
57    /// the Ink-based claude/codex TUIs use it).
58    pub fn in_alt_screen(&self) -> bool {
59        self.parser.screen().alternate_screen()
60    }
61
62    /// Resize the viewport (rows, cols), floored at 1.
63    pub fn resize(&mut self, rows: u16, cols: u16) {
64        self.parser.screen_mut().set_size(rows.max(1), cols.max(1));
65    }
66
67    /// Snapshot the consolidated visible screen as plain-text lines (ANSI
68    /// already resolved by the emulator), trailing blank lines trimmed.
69    /// `max_lines` of 0 returns the whole viewport; otherwise the last N lines.
70    pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
71        let contents = self.parser.screen().contents();
72        let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
73        while lines.last().is_some_and(|l| l.trim().is_empty()) {
74            lines.pop();
75        }
76        if max_lines > 0 && lines.len() > max_lines {
77            lines = lines.split_off(lines.len() - max_lines);
78        }
79        lines
80    }
81
82    /// Cursor position as `(row, col)`, 0-based, from the live screen — tells the
83    /// agent where focus sits in a menu/form it's piloting.
84    pub fn cursor_position(&self) -> (u16, u16) {
85        self.parser.screen().cursor_position()
86    }
87
88    /// Diff the current screen against the snapshot the client last saw, update
89    /// the baseline, and return only what changed. Positional (the terminal is a
90    /// grid that redraws in place), 1-based rows to match the rest of winx.
91    /// Returns the full screen on the first look or when more than `threshold`
92    /// lines changed (a diff that big isn't worth the framing).
93    pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
94        let current = self.snapshot(max_lines);
95        let update = match self.last_snapshot.as_ref() {
96            Some(previous) => {
97                let changed = diff_lines(previous, &current);
98                if changed.is_empty() {
99                    ScreenUpdate::Unchanged
100                } else if changed.len() <= threshold {
101                    ScreenUpdate::Diff(changed)
102                } else {
103                    ScreenUpdate::Full(current.clone())
104                }
105            }
106            None => ScreenUpdate::Full(current.clone()),
107        };
108        self.last_snapshot = Some(current);
109        update
110    }
111}
112
113/// Positional line diff: each `(1-based row, new content)` where the two frames
114/// differ. A blanked/removed line shows up as that row with empty content.
115fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
116    let rows = previous.len().max(current.len());
117    (0..rows)
118        .filter_map(|i| {
119            let was = previous.get(i).map_or("", String::as_str);
120            let now = current.get(i).map_or("", String::as_str);
121            (was != now).then(|| (i + 1, now.to_string()))
122        })
123        .collect()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
132        // The agy pattern: draw a frame, move the cursor back up, erase below
133        // and redraw. A growing-buffer emulator stacks "Generating" and "DONE";
134        // a fixed viewport consolidates to just the latest frame.
135        let mut t = LiveTerminal::new(10, 40);
136        t.feed(b"line one\r\nGenerating...\r\n"); // frame 1, cursor now on row 2
137        t.feed(b"\x1b[2A"); // cursor up 2 -> back to "line one"
138        t.feed(b"\x1b[Jline one\r\nDONE\r\n"); // erase-below + redraw frame 2
139        let joined = t.snapshot(0).join("\n");
140        assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
141        assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
142    }
143
144    #[test]
145    fn viewport_is_fixed_old_lines_scroll_off() {
146        let mut t = LiveTerminal::new(3, 40); // only 3 visible rows
147        for i in 0..10 {
148            t.feed(format!("row{i}\r\n").as_bytes());
149        }
150        let snap = t.snapshot(0);
151        assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
152        assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
153        assert!(
154            !snap.iter().any(|l| l.contains("row0")),
155            "oldest row should have scrolled off a fixed viewport: {snap:?}"
156        );
157    }
158
159    #[test]
160    fn detects_alternate_screen_toggle() {
161        let mut t = LiveTerminal::new(10, 40);
162        assert!(!t.in_alt_screen());
163        t.feed(b"\x1b[?1049h");
164        assert!(t.in_alt_screen());
165        t.feed(b"\x1b[?1049l");
166        assert!(!t.in_alt_screen());
167    }
168
169    #[test]
170    fn snapshot_last_n_lines() {
171        let mut t = LiveTerminal::new(10, 40);
172        t.feed(b"a\r\nb\r\nc\r\nd\r\n");
173        assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
174    }
175
176    #[test]
177    fn cursor_position_tracks_feed() {
178        let mut t = LiveTerminal::new(10, 40);
179        t.feed(b"abc");
180        assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
181    }
182
183    #[test]
184    fn snapshot_diff_full_then_unchanged_then_one_line() {
185        let mut t = LiveTerminal::new(5, 40);
186        t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
187        // First look: no baseline -> full frame.
188        assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
189        // Nothing fed since -> Unchanged.
190        assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
191        // Overwrite row 2 (1-based) in place -> a single-line diff.
192        t.feed(b"\x1b[2;1Hbeta2");
193        let update = t.snapshot_diff(0, 10);
194        assert!(
195            matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
196            "expected a one-line diff, got {update:?}"
197        );
198        if let ScreenUpdate::Diff(changed) = &update {
199            assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
200            assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
201        }
202    }
203
204    #[test]
205    fn snapshot_diff_big_change_falls_back_to_full() {
206        let mut t = LiveTerminal::new(10, 40);
207        t.feed(b"seed\r\n");
208        let _ = t.snapshot_diff(0, 1); // establish baseline
209        for i in 0..8 {
210            t.feed(format!("line{i}\r\n").as_bytes());
211        }
212        // More than `threshold` lines changed -> full frame, not a giant diff.
213        assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
214    }
215}