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/// Floor for the viewport dimensions. vt100 0.16.2 underflows (`grid.rs`:
20/// `prev_pos.row -= scrolled` and `size.cols - width`) on a 1-row or 1-col grid
21/// when output triggers a wrap+scroll — a debug panic, silent `u16` wrap in
22/// release. A real PTY is never that small, but `resize` can be asked for it, so
23/// we clamp to the smallest size vt100 handles safely.
24const MIN_VIEWPORT: u16 = 2;
25
26/// What changed on the live screen since the client last looked. Lets
27/// `status_check` ship only the delta when piloting a TUI over many polls —
28/// far fewer tokens than re-sending the whole frame each time.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ScreenUpdate {
31 /// No baseline yet (first look) or too much changed — the full screen.
32 Full(Vec<String>),
33 /// Only these `(1-based row, new content)` lines changed since the last look.
34 Diff(Vec<(usize, String)>),
35 /// Nothing changed since the last look.
36 Unchanged,
37}
38
39/// A continuously-fed terminal emulator with a fixed viewport. Wraps a
40/// `vt100::Parser` and exposes just what the PTY live tap needs.
41pub struct LiveTerminal {
42 parser: Parser,
43 /// Snapshot the client saw on its last look — the baseline the next
44 /// [`LiveTerminal::snapshot_diff`] diffs against.
45 last_snapshot: Option<Vec<String>>,
46}
47
48impl LiveTerminal {
49 /// Create a live terminal with a fixed `rows`x`cols` viewport. Dimensions
50 /// are floored at [`MIN_VIEWPORT`] (a 0/1-sized grid underflows vt100).
51 /// Scrollback is left at 0: the snapshot only needs the visible viewport,
52 /// and the PTY keeps its own raw `line_ring` for deeper scrollback.
53 pub fn new(rows: u16, cols: u16) -> Self {
54 Self {
55 parser: Parser::new(rows.max(MIN_VIEWPORT), cols.max(MIN_VIEWPORT), 0),
56 last_snapshot: None,
57 }
58 }
59
60 /// Feed raw PTY bytes. The parser is persistent, so escape sequences split
61 /// across chunk boundaries decode correctly.
62 pub fn feed(&mut self, bytes: &[u8]) {
63 self.parser.process(bytes);
64 }
65
66 /// Whether the alternate screen buffer is currently active (vim, htop, and
67 /// the Ink-based claude/codex TUIs use it).
68 pub fn in_alt_screen(&self) -> bool {
69 self.parser.screen().alternate_screen()
70 }
71
72 /// Resize the viewport (rows, cols), floored at [`MIN_VIEWPORT`].
73 ///
74 /// We deliberately do **not** call vt100's `set_size`: its column-shrink
75 /// reflow underflows / `unwrap`s on perfectly ordinary content (fuzz-found),
76 /// and since the release build is `panic = "abort"` such a panic would take
77 /// the whole MCP server down. Instead we rebuild the parser at the new size
78 /// and replay the visible text — plain rendered lines can't retrigger the
79 /// reflow bug, and TUIs redraw on the SIGWINCH that accompanies a resize.
80 pub fn resize(&mut self, rows: u16, cols: u16) {
81 let rows = rows.max(MIN_VIEWPORT);
82 let cols = cols.max(MIN_VIEWPORT);
83 let preserved = self.snapshot(0);
84 self.parser = Parser::new(rows, cols, 0);
85 if !preserved.is_empty() {
86 self.parser.process(preserved.join("\r\n").as_bytes());
87 }
88 // The baseline no longer matches the new geometry; force a full next diff.
89 self.last_snapshot = None;
90 }
91
92 /// Snapshot the consolidated visible screen as plain-text lines (ANSI
93 /// already resolved by the emulator), trailing blank lines trimmed.
94 /// `max_lines` of 0 returns the whole viewport; otherwise the last N lines.
95 pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
96 let contents = self.parser.screen().contents();
97 let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
98 while lines.last().is_some_and(|l| l.trim().is_empty()) {
99 lines.pop();
100 }
101 if max_lines > 0 && lines.len() > max_lines {
102 lines = lines.split_off(lines.len() - max_lines);
103 }
104 lines
105 }
106
107 /// Cursor position as `(row, col)`, 0-based, from the live screen — tells the
108 /// agent where focus sits in a menu/form it's piloting.
109 pub fn cursor_position(&self) -> (u16, u16) {
110 self.parser.screen().cursor_position()
111 }
112
113 /// Diff the current screen against the snapshot the client last saw, update
114 /// the baseline, and return only what changed. Positional (the terminal is a
115 /// grid that redraws in place), 1-based rows to match the rest of winx.
116 /// Returns the full screen on the first look or when more than `threshold`
117 /// lines changed (a diff that big isn't worth the framing).
118 pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
119 let current = self.snapshot(max_lines);
120 let update = match self.last_snapshot.as_ref() {
121 Some(previous) => {
122 let changed = diff_lines(previous, ¤t);
123 if changed.is_empty() {
124 ScreenUpdate::Unchanged
125 } else if changed.len() <= threshold {
126 ScreenUpdate::Diff(changed)
127 } else {
128 ScreenUpdate::Full(current.clone())
129 }
130 }
131 None => ScreenUpdate::Full(current.clone()),
132 };
133 self.last_snapshot = Some(current);
134 update
135 }
136}
137
138/// Positional line diff: each `(1-based row, new content)` where the two frames
139/// differ. A blanked/removed line shows up as that row with empty content.
140fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
141 let rows = previous.len().max(current.len());
142 (0..rows)
143 .filter_map(|i| {
144 let was = previous.get(i).map_or("", String::as_str);
145 let now = current.get(i).map_or("", String::as_str);
146 (was != now).then(|| (i + 1, now.to_string()))
147 })
148 .collect()
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
157 // The agy pattern: draw a frame, move the cursor back up, erase below
158 // and redraw. A growing-buffer emulator stacks "Generating" and "DONE";
159 // a fixed viewport consolidates to just the latest frame.
160 let mut t = LiveTerminal::new(10, 40);
161 t.feed(b"line one\r\nGenerating...\r\n"); // frame 1, cursor now on row 2
162 t.feed(b"\x1b[2A"); // cursor up 2 -> back to "line one"
163 t.feed(b"\x1b[Jline one\r\nDONE\r\n"); // erase-below + redraw frame 2
164 let joined = t.snapshot(0).join("\n");
165 assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
166 assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
167 }
168
169 #[test]
170 fn viewport_is_fixed_old_lines_scroll_off() {
171 let mut t = LiveTerminal::new(3, 40); // only 3 visible rows
172 for i in 0..10 {
173 t.feed(format!("row{i}\r\n").as_bytes());
174 }
175 let snap = t.snapshot(0);
176 assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
177 assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
178 assert!(
179 !snap.iter().any(|l| l.contains("row0")),
180 "oldest row should have scrolled off a fixed viewport: {snap:?}"
181 );
182 }
183
184 #[test]
185 fn detects_alternate_screen_toggle() {
186 let mut t = LiveTerminal::new(10, 40);
187 assert!(!t.in_alt_screen());
188 t.feed(b"\x1b[?1049h");
189 assert!(t.in_alt_screen());
190 t.feed(b"\x1b[?1049l");
191 assert!(!t.in_alt_screen());
192 }
193
194 #[test]
195 fn snapshot_last_n_lines() {
196 let mut t = LiveTerminal::new(10, 40);
197 t.feed(b"a\r\nb\r\nc\r\nd\r\n");
198 assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
199 }
200
201 #[test]
202 fn cursor_position_tracks_feed() {
203 let mut t = LiveTerminal::new(10, 40);
204 t.feed(b"abc");
205 assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
206 }
207
208 #[test]
209 fn snapshot_diff_full_then_unchanged_then_one_line() {
210 let mut t = LiveTerminal::new(5, 40);
211 t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
212 // First look: no baseline -> full frame.
213 assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
214 // Nothing fed since -> Unchanged.
215 assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
216 // Overwrite row 2 (1-based) in place -> a single-line diff.
217 t.feed(b"\x1b[2;1Hbeta2");
218 let update = t.snapshot_diff(0, 10);
219 assert!(
220 matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
221 "expected a one-line diff, got {update:?}"
222 );
223 if let ScreenUpdate::Diff(changed) = &update {
224 assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
225 assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
226 }
227 }
228
229 #[test]
230 fn snapshot_diff_big_change_falls_back_to_full() {
231 let mut t = LiveTerminal::new(10, 40);
232 t.feed(b"seed\r\n");
233 let _ = t.snapshot_diff(0, 1); // establish baseline
234 for i in 0..8 {
235 t.feed(format!("line{i}\r\n").as_bytes());
236 }
237 // More than `threshold` lines changed -> full frame, not a giant diff.
238 assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
239 }
240
241 use proptest::prelude::*;
242
243 proptest! {
244 // The live tap is fed untrusted program output. Feeding arbitrary byte
245 // chunks (split anywhere, so escape sequences straddle boundaries) must
246 // never panic and must keep snapshot/cursor inside the fixed viewport.
247 #[test]
248 fn feed_arbitrary_bytes_never_panics_and_stays_bounded(
249 rows in 1u16..40,
250 cols in 1u16..120,
251 chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..48), 0..24),
252 ) {
253 // Effective viewport after the MIN_VIEWPORT floor (see `new`).
254 let eff_rows = rows.max(MIN_VIEWPORT);
255 let eff_cols = cols.max(MIN_VIEWPORT);
256 let mut t = LiveTerminal::new(rows, cols);
257 for chunk in &chunks {
258 t.feed(chunk);
259 }
260 // Snapshot never exceeds the viewport height.
261 let full = t.snapshot(0);
262 prop_assert!(full.len() <= eff_rows as usize, "snapshot {} rows > viewport {eff_rows}", full.len());
263 // Cursor stays inside the grid (col may rest at `cols` on a pending wrap).
264 let (cr, cc) = t.cursor_position();
265 prop_assert!(cr < eff_rows, "cursor row {cr} >= rows {eff_rows}");
266 prop_assert!(cc <= eff_cols, "cursor col {cc} > cols {eff_cols}");
267 // `max_lines` is honored.
268 prop_assert!(t.snapshot(3).len() <= 3);
269 // Diffing never panics and respects the threshold cap.
270 let _ = t.snapshot_diff(0, 4);
271 if let ScreenUpdate::Diff(changed) = t.snapshot_diff(0, 4) {
272 prop_assert!(changed.len() <= 4);
273 }
274 }
275
276 // Resizing to arbitrary dimensions (including row/col swaps) after a
277 // feed never panics and re-bounds the snapshot to the new viewport.
278 #[test]
279 fn resize_after_feed_never_panics(
280 r1 in 1u16..30, c1 in 1u16..80,
281 r2 in 1u16..30, c2 in 1u16..80,
282 data in prop::collection::vec(any::<u8>(), 0..256),
283 ) {
284 let eff_r2 = r2.max(MIN_VIEWPORT) as usize;
285 let mut t = LiveTerminal::new(r1, c1);
286 t.feed(&data);
287 t.resize(r2, c2);
288 prop_assert!(t.snapshot(0).len() <= eff_r2);
289 // Feeding again after the resize is still bounded by the new height.
290 t.feed(&data);
291 prop_assert!(t.snapshot(0).len() <= eff_r2);
292 }
293 }
294}