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(¶ms[1..]);
69 } else if code == b"9" {
70 self.progress = join_osc_params(¶ms[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, ¤t);
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}