winx-code-agent 0.2.352

Remote-first Rust MCP runtime with Streamable HTTP, durable PTYs, and guarded agent tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Live terminal emulator for piloting interactive TUIs, backed by the `vt100`
//! crate.
//!
//! Unlike the hand-rolled [`crate::state::terminal::Screen`] (a buffer that
//! *grows* on every linefeed), this models a **fixed viewport of `rows` lines**
//! with proper scrolling — the model a real terminal uses. Inline TUIs that
//! redraw by moving the cursor up and rewriting with erase-line (the `agy` /
//! Antigravity CLI, which renders in the main screen rather than the alternate
//! one) consolidate cleanly here instead of stacking ghost frames. See the
//! `inline_redraw_*` test for the exact regression this fixes.
//!
//! Only the *live* PTY tap uses this. One-shot rendering of finished command
//! output (`render_terminal_output`) stays on the growing-buffer `Screen`,
//! which is the right model there: it must keep every line of output, not clip
//! to a viewport.

use std::num::NonZeroU16;
use vt100::Parser;

/// Floor for the viewport dimensions. vt100 0.16.2 underflows (`grid.rs`:
/// `prev_pos.row -= scrolled` and `size.cols - width`) on a 1-row or 1-col grid
/// when output triggers a wrap+scroll — a debug panic, silent `u16` wrap in
/// release. A real PTY is never that small, but `resize` can be asked for it, so
/// we clamp to the smallest size vt100 handles safely.
const MIN_VIEWPORT: u16 = 2;

/// Floor a requested grid dimension at [`MIN_VIEWPORT`] as the non-zero
/// size `vt100` requires.
fn grid_dimension(value: u16) -> NonZeroU16 {
    NonZeroU16::new(value.max(MIN_VIEWPORT)).unwrap_or(NonZeroU16::MIN)
}

/// What changed on the live screen since the client last looked. Lets
/// `status_check` ship only the delta when piloting a TUI over many polls —
/// far fewer tokens than re-sending the whole frame each time.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenUpdate {
    /// No baseline yet (first look) or too much changed — the full screen.
    Full(Vec<String>),
    /// Only these `(1-based row, new content)` lines changed since the last look.
    Diff(Vec<(usize, String)>),
    /// Nothing changed since the last look.
    Unchanged,
}

/// The latest OSC title/progress evidence scraped from the raw PTY stream.
/// Coding-agent TUIs signal their state out-of-band here — claude/codex put a
/// spinner glyph in the OSC 0/2 window title while generating, and several
/// agents emit `ConEmu`-style OSC 9;4 progress — and the manifest-driven turn
/// recognizers ([`crate::state::turn_manifest`]) read both as detection
/// regions. vt100 exposes neither, so a second, OSC-only `vte` pass collects
/// them. Mirrors herdr's retention semantics: the latest update wins, and an
/// empty title update clears (`""` also means "never seen").
#[derive(Debug, Default, Clone)]
struct OscSignals {
    /// Payload of the last OSC 0/2 (icon+title / title) sequence.
    title: String,
    /// Payload of the last OSC 9 sequence after the `9;` prefix — `ConEmu`
    /// progress arrives as `4;<state>;<percent>` (manifest rules anchor on
    /// `^4;...`), other OSC 9 notifications keep their raw text.
    progress: String,
}

impl vte::Perform for OscSignals {
    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
        let Some(&code) = params.first() else { return };
        if code == b"0" || code == b"2" {
            self.title = join_osc_params(&params[1..]);
        } else if code == b"9" {
            self.progress = join_osc_params(&params[1..]);
        }
    }
}

/// Re-join OSC parameters that `vte` split on `;` — titles may legitimately
/// contain semicolons.
fn join_osc_params(params: &[&[u8]]) -> String {
    params.iter().map(|param| String::from_utf8_lossy(param)).collect::<Vec<_>>().join(";")
}

/// A continuously-fed terminal emulator with a fixed viewport. Wraps a
/// `vt100::Parser` and exposes just what the PTY live tap needs.
pub struct LiveTerminal {
    parser: Parser,
    /// Snapshot the client saw on its last look — the baseline the next
    /// [`LiveTerminal::snapshot_diff`] diffs against.
    last_snapshot: Option<Vec<String>>,
    /// OSC-only second parser: persistent so sequences split across feed
    /// chunks decode correctly, and deliberately NOT rebuilt on `resize` —
    /// the retained title/progress must survive viewport changes.
    osc_parser: vte::Parser,
    osc: OscSignals,
}

impl LiveTerminal {
    /// Create a live terminal with a fixed `rows`x`cols` viewport. Dimensions
    /// are floored at [`MIN_VIEWPORT`] (a 0/1-sized grid underflows vt100).
    /// Scrollback is left at 0: the snapshot only needs the visible viewport,
    /// and the PTY keeps its own raw `line_ring` for deeper scrollback.
    pub fn new(rows: u16, cols: u16) -> Self {
        Self {
            parser: Parser::new(grid_dimension(rows), grid_dimension(cols), 0),
            last_snapshot: None,
            osc_parser: vte::Parser::new(),
            osc: OscSignals::default(),
        }
    }

    /// Feed raw PTY bytes. The parser is persistent, so escape sequences split
    /// across chunk boundaries decode correctly.
    pub fn feed(&mut self, bytes: &[u8]) {
        self.osc_parser.advance(&mut self.osc, bytes);
        self.parser.process(bytes);
    }

    /// The latest OSC 0/2 title, or `""` when none was seen (or the last
    /// update was an empty clear).
    pub fn osc_title(&self) -> &str {
        &self.osc.title
    }

    /// The latest OSC 9 payload (after `9;`), or `""` when none was seen.
    /// `ConEmu` progress reads `4;<state>;<percent>`.
    pub fn osc_progress(&self) -> &str {
        &self.osc.progress
    }

    /// Drop retained OSC title/progress evidence. Called when a new command is
    /// submitted so a stale title from the previous foreground program can't
    /// leak into the next program's turn detection.
    pub fn clear_osc(&mut self) {
        self.osc = OscSignals::default();
    }

    /// Whether the alternate screen buffer is currently active (vim, htop, and
    /// the Ink-based claude/codex TUIs use it).
    pub fn in_alt_screen(&self) -> bool {
        self.parser.screen().alternate_screen()
    }

    /// Resize the viewport (rows, cols), floored at [`MIN_VIEWPORT`].
    ///
    /// We deliberately do **not** call vt100's `set_size`: its column-shrink
    /// reflow underflows / `unwrap`s on perfectly ordinary content (fuzz-found),
    /// and since the release build is `panic = "abort"` such a panic would take
    /// the whole MCP server down. Instead we rebuild the parser at the new size
    /// and replay the visible text — plain rendered lines can't retrigger the
    /// reflow bug, and TUIs redraw on the SIGWINCH that accompanies a resize.
    pub fn resize(&mut self, rows: u16, cols: u16) {
        let rows = grid_dimension(rows);
        let cols = grid_dimension(cols);
        let preserved = self.snapshot(0);
        self.parser = Parser::new(rows, cols, 0);
        if !preserved.is_empty() {
            self.parser.process(preserved.join("\r\n").as_bytes());
        }
        // The baseline no longer matches the new geometry; force a full next diff.
        self.last_snapshot = None;
    }

    /// Snapshot the consolidated visible screen as plain-text lines (ANSI
    /// already resolved by the emulator), trailing blank lines trimmed.
    /// `max_lines` of 0 returns the whole viewport; otherwise the last N lines.
    pub fn snapshot(&self, max_lines: usize) -> Vec<String> {
        let contents = self.parser.screen().contents();
        let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
        while lines.last().is_some_and(|l| l.trim().is_empty()) {
            lines.pop();
        }
        if max_lines > 0 && lines.len() > max_lines {
            lines = lines.split_off(lines.len() - max_lines);
        }
        lines
    }

    /// Cursor position as `(row, col)`, 0-based, from the live screen — tells the
    /// agent where focus sits in a menu/form it's piloting.
    pub fn cursor_position(&self) -> (u16, u16) {
        self.parser.screen().cursor_position()
    }

    /// Diff the current screen against the snapshot the client last saw, update
    /// the baseline, and return only what changed. Positional (the terminal is a
    /// grid that redraws in place), 1-based rows to match the rest of winx.
    /// Returns the full screen on the first look or when more than `threshold`
    /// lines changed (a diff that big isn't worth the framing).
    pub fn snapshot_diff(&mut self, max_lines: usize, threshold: usize) -> ScreenUpdate {
        let current = self.snapshot(max_lines);
        let update = match self.last_snapshot.as_ref() {
            Some(previous) => {
                let changed = diff_lines(previous, &current);
                if changed.is_empty() {
                    ScreenUpdate::Unchanged
                } else if changed.len() <= threshold {
                    ScreenUpdate::Diff(changed)
                } else {
                    ScreenUpdate::Full(current.clone())
                }
            }
            None => ScreenUpdate::Full(current.clone()),
        };
        self.last_snapshot = Some(current);
        update
    }
}

/// Positional line diff: each `(1-based row, new content)` where the two frames
/// differ. A blanked/removed line shows up as that row with empty content.
fn diff_lines(previous: &[String], current: &[String]) -> Vec<(usize, String)> {
    let rows = previous.len().max(current.len());
    (0..rows)
        .filter_map(|i| {
            let was = previous.get(i).map_or("", String::as_str);
            let now = current.get(i).map_or("", String::as_str);
            (was != now).then(|| (i + 1, now.to_string()))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inline_redraw_with_cursor_up_consolidates_no_ghosting() {
        // The agy pattern: draw a frame, move the cursor back up, erase below
        // and redraw. A growing-buffer emulator stacks "Generating" and "DONE";
        // a fixed viewport consolidates to just the latest frame.
        let mut t = LiveTerminal::new(10, 40);
        t.feed(b"line one\r\nGenerating...\r\n"); // frame 1, cursor now on row 2
        t.feed(b"\x1b[2A"); // cursor up 2 -> back to "line one"
        t.feed(b"\x1b[Jline one\r\nDONE\r\n"); // erase-below + redraw frame 2
        let joined = t.snapshot(0).join("\n");
        assert!(joined.contains("DONE"), "missing latest frame: {joined:?}");
        assert!(!joined.contains("Generating"), "ghost frame leaked: {joined:?}");
    }

    #[test]
    fn viewport_is_fixed_old_lines_scroll_off() {
        let mut t = LiveTerminal::new(3, 40); // only 3 visible rows
        for i in 0..10 {
            t.feed(format!("row{i}\r\n").as_bytes());
        }
        let snap = t.snapshot(0);
        assert!(snap.len() <= 3, "viewport overflowed its row count: {snap:?}");
        assert!(snap.iter().any(|l| l.contains("row9")), "newest row missing: {snap:?}");
        assert!(
            !snap.iter().any(|l| l.contains("row0")),
            "oldest row should have scrolled off a fixed viewport: {snap:?}"
        );
    }

    #[test]
    fn detects_alternate_screen_toggle() {
        let mut t = LiveTerminal::new(10, 40);
        assert!(!t.in_alt_screen());
        t.feed(b"\x1b[?1049h");
        assert!(t.in_alt_screen());
        t.feed(b"\x1b[?1049l");
        assert!(!t.in_alt_screen());
    }

    #[test]
    fn osc_title_and_progress_are_captured_and_cleared() {
        let mut t = LiveTerminal::new(10, 40);
        assert_eq!(t.osc_title(), "");
        assert_eq!(t.osc_progress(), "");

        // OSC 0 (BEL-terminated) — the claude spinner-in-title pattern.
        t.feed(b"\x1b]0;\xe2\xa0\x8b claude \x07");
        assert_eq!(t.osc_title(), "⠋ claude ");

        // OSC 2 (ST-terminated) replaces it; semicolons in the title survive.
        t.feed(b"\x1b]2;a;b\x1b\\");
        assert_eq!(t.osc_title(), "a;b");

        // `ConEmu` progress: OSC 9;4;<state>;<percent> retains "4;...".
        t.feed(b"\x1b]9;4;1;42\x07");
        assert_eq!(t.osc_progress(), "4;1;42");

        // An empty title update clears (herdr retention semantics).
        t.feed(b"\x1b]0;\x07");
        assert_eq!(t.osc_title(), "");

        t.feed(b"\x1b]2;back\x07");
        t.clear_osc();
        assert_eq!(t.osc_title(), "");
        assert_eq!(t.osc_progress(), "");
    }

    #[test]
    fn osc_sequence_split_across_feeds_decodes() {
        let mut t = LiveTerminal::new(10, 40);
        t.feed(b"\x1b]2;spl");
        t.feed(b"it title\x07");
        assert_eq!(t.osc_title(), "split title");
    }

    #[test]
    fn osc_state_survives_resize() {
        let mut t = LiveTerminal::new(10, 40);
        t.feed(b"\x1b]2;kept\x07\x1b]9;4;0\x07");
        t.resize(20, 80);
        assert_eq!(t.osc_title(), "kept");
        assert_eq!(t.osc_progress(), "4;0");
    }

    #[test]
    fn snapshot_last_n_lines() {
        let mut t = LiveTerminal::new(10, 40);
        t.feed(b"a\r\nb\r\nc\r\nd\r\n");
        assert_eq!(t.snapshot(2), vec!["c".to_string(), "d".to_string()]);
    }

    #[test]
    fn cursor_position_tracks_feed() {
        let mut t = LiveTerminal::new(10, 40);
        t.feed(b"abc");
        assert_eq!(t.cursor_position(), (0, 3), "cursor should sit after 'abc' on row 0");
    }

    #[test]
    fn snapshot_diff_full_then_unchanged_then_one_line() {
        let mut t = LiveTerminal::new(5, 40);
        t.feed(b"alpha\r\nbeta\r\ngamma\r\n");
        // First look: no baseline -> full frame.
        assert!(matches!(t.snapshot_diff(0, 10), ScreenUpdate::Full(_)));
        // Nothing fed since -> Unchanged.
        assert_eq!(t.snapshot_diff(0, 10), ScreenUpdate::Unchanged);
        // Overwrite row 2 (1-based) in place -> a single-line diff.
        t.feed(b"\x1b[2;1Hbeta2");
        let update = t.snapshot_diff(0, 10);
        assert!(
            matches!(&update, ScreenUpdate::Diff(changed) if changed.len() == 1),
            "expected a one-line diff, got {update:?}"
        );
        if let ScreenUpdate::Diff(changed) = &update {
            assert_eq!(changed.first().map(|c| c.0), Some(2), "1-based row 2");
            assert!(changed.first().is_some_and(|c| c.1.contains("beta2")), "got {changed:?}");
        }
    }

    #[test]
    fn snapshot_diff_big_change_falls_back_to_full() {
        let mut t = LiveTerminal::new(10, 40);
        t.feed(b"seed\r\n");
        let _ = t.snapshot_diff(0, 1); // establish baseline
        for i in 0..8 {
            t.feed(format!("line{i}\r\n").as_bytes());
        }
        // More than `threshold` lines changed -> full frame, not a giant diff.
        assert!(matches!(t.snapshot_diff(0, 1), ScreenUpdate::Full(_)));
    }

    use proptest::prelude::*;

    proptest! {
        // The live tap is fed untrusted program output. Feeding arbitrary byte
        // chunks (split anywhere, so escape sequences straddle boundaries) must
        // never panic and must keep snapshot/cursor inside the fixed viewport.
        #[test]
        fn feed_arbitrary_bytes_never_panics_and_stays_bounded(
            rows in 1u16..40,
            cols in 1u16..120,
            chunks in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..48), 0..24),
        ) {
            // Effective viewport after the MIN_VIEWPORT floor (see `new`).
            let eff_rows = rows.max(MIN_VIEWPORT);
            let eff_cols = cols.max(MIN_VIEWPORT);
            let mut t = LiveTerminal::new(rows, cols);
            for chunk in &chunks {
                t.feed(chunk);
            }
            // Snapshot never exceeds the viewport height.
            let full = t.snapshot(0);
            prop_assert!(full.len() <= eff_rows as usize, "snapshot {} rows > viewport {eff_rows}", full.len());
            // Cursor stays inside the grid (col may rest at `cols` on a pending wrap).
            let (cr, cc) = t.cursor_position();
            prop_assert!(cr < eff_rows, "cursor row {cr} >= rows {eff_rows}");
            prop_assert!(cc <= eff_cols, "cursor col {cc} > cols {eff_cols}");
            // `max_lines` is honored.
            prop_assert!(t.snapshot(3).len() <= 3);
            // Diffing never panics and respects the threshold cap.
            let _ = t.snapshot_diff(0, 4);
            if let ScreenUpdate::Diff(changed) = t.snapshot_diff(0, 4) {
                prop_assert!(changed.len() <= 4);
            }
        }

        // Resizing to arbitrary dimensions (including row/col swaps) after a
        // feed never panics and re-bounds the snapshot to the new viewport.
        #[test]
        fn resize_after_feed_never_panics(
            r1 in 1u16..30, c1 in 1u16..80,
            r2 in 1u16..30, c2 in 1u16..80,
            data in prop::collection::vec(any::<u8>(), 0..256),
        ) {
            let eff_r2 = r2.max(MIN_VIEWPORT) as usize;
            let mut t = LiveTerminal::new(r1, c1);
            t.feed(&data);
            t.resize(r2, c2);
            prop_assert!(t.snapshot(0).len() <= eff_r2);
            // Feeding again after the resize is still bounded by the new height.
            t.feed(&data);
            prop_assert!(t.snapshot(0).len() <= eff_r2);
        }
    }
}