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