Skip to main content

fno_agents/
screen.rs

1//! Terminal-grid construction behind the [`ScreenView`] seam (Wave 2).
2//!
3//! Wave 1 defined [`crate::readiness::ScreenView`] as the read-only shape every
4//! [`crate::readiness::ReadinessDetector`] inspects, and deliberately left the
5//! grid *construction* to Wave 2 "so the substrate stays decoupled from the
6//! terminal-emulator crate." This module is that construction: it feeds raw PTY
7//! output bytes (ANSI escapes, cursor moves, partial sequences) through a
8//! terminal-state parser and snapshots the rendered grid into an
9//! [`OwnedScreen`] a [`ScreenView`] borrows from.
10//!
11//! ## Terminal-emulator crate choice
12//!
13//! This module uses [`alacritty_terminal`] (the crate the original design
14//! named). An earlier revision used `vt100` on a "dependency weight"
15//! rationale - that `alacritty_terminal` drags in winit-adjacent + Windows
16//! GUI crates. That rationale was **wrong** for `alacritty_terminal` 0.26:
17//! its transitive tree is `vte` (the same VT parser `vt100` wraps) plus
18//! `parking_lot`, `polling`, `rustix-openpty`, `regex-automata`, and a
19//! cfg-gated `windows-sys` (FFI, compiles to nothing off Windows) - no
20//! winit, no GUI stack. The richer cell model (`Flags`, `NamedColor`/`Rgb`,
21//! `Dimensions`) is exactly what the mux/agent surfaces
22//! needs, so the whole crate standardized on it (ab-3c063856 review).
23//!
24//! The [`crate::readiness::ReadinessDetector`] trait is unchanged: it still
25//! operates over [`ScreenView`], so the emulator crate is an implementation
26//! detail of this file. Used headless via `Term<VoidListener>` +
27//! `vte::ansi::Processor` - no event loop, no rendering half of the tree.
28
29use alacritty_terminal::event::VoidListener;
30use alacritty_terminal::grid::Dimensions;
31use alacritty_terminal::index::{Column, Line};
32use alacritty_terminal::term::{Config, Term};
33use alacritty_terminal::vte::ansi::Processor;
34
35use crate::readiness::ScreenView;
36
37/// Default grid size used when the daemon has not yet observed a PTY winsize.
38/// 24x80 is the historical terminal default and matches the drive UX fallback
39/// ("no initial resize within 2s; using 24x80 default").
40pub const DEFAULT_ROWS: u16 = 24;
41pub const DEFAULT_COLS: u16 = 80;
42
43/// A `Dimensions` impl for constructing / resizing a headless alacritty
44/// [`Term`]. `screen_lines` is the visible viewport height; `total_lines`
45/// equals it because we keep zero scrollback (the readiness path renders only
46/// the visible screen), so sizing stays identical across surfaces.
47#[derive(Debug, Clone, Copy)]
48pub(crate) struct GridSize {
49    pub rows: usize,
50    pub cols: usize,
51}
52
53impl Dimensions for GridSize {
54    fn total_lines(&self) -> usize {
55        self.rows
56    }
57    fn screen_lines(&self) -> usize {
58        self.rows
59    }
60    fn columns(&self) -> usize {
61        self.cols
62    }
63}
64
65/// Build a zero-scrollback alacritty config. The readiness seam renders only
66/// the visible screen, so scrollback history would be wasted retention
67/// (vt100's `Parser::new(.., 0)` had the same intent).
68pub(crate) fn visible_only_config() -> Config {
69    Config {
70        scrolling_history: 0,
71        ..Config::default()
72    }
73}
74
75/// A terminal grid fed incrementally with raw PTY bytes. The daemon (Wave 3)
76/// owns one per PTY-managed agent and feeds it from the PTY drainer's ring; a
77/// [`ReadinessDetector`](crate::readiness::ReadinessDetector) inspects its
78/// [`snapshot`](TerminalGrid::snapshot) to decide whether the CLI is ready.
79pub struct TerminalGrid {
80    term: Term<VoidListener>,
81    processor: Processor,
82    // OSC title/progress capture (E6.1). `processor` parses OSC sequences but
83    // dispatches them to the `VoidListener` (discarded), so a parallel scanner
84    // keeps the OSC strings the manifest engine wants as detection regions.
85    osc: crate::osc::OscCapture,
86    rows: u16,
87    cols: u16,
88}
89
90impl TerminalGrid {
91    /// Construct a grid of the given size. Zero dimensions are clamped to 1 so
92    /// the parser never panics on a degenerate winsize.
93    pub fn new(rows: u16, cols: u16) -> Self {
94        let rows = rows.max(1);
95        let cols = cols.max(1);
96        let size = GridSize {
97            rows: rows as usize,
98            cols: cols as usize,
99        };
100        TerminalGrid {
101            term: Term::new(visible_only_config(), &size, VoidListener),
102            processor: Processor::new(),
103            osc: crate::osc::OscCapture::new(),
104            rows,
105            cols,
106        }
107    }
108
109    /// Construct a grid at the 24x80 default size.
110    pub fn with_default_size() -> Self {
111        Self::new(DEFAULT_ROWS, DEFAULT_COLS)
112    }
113
114    /// Feed raw PTY output. Safe to call with partial escape sequences split
115    /// across reads; the underlying `vte` parser buffers incomplete sequences
116    /// between `advance` calls.
117    pub fn feed(&mut self, bytes: &[u8]) {
118        // `term` and `processor` are disjoint fields, so both mutable borrows
119        // are allowed.
120        self.processor.advance(&mut self.term, bytes);
121        // Same bytes, second pass: capture OSC title/progress the grid parser
122        // throws away. Two passes over the stream is O(2n); titles are short.
123        self.osc.feed(bytes);
124    }
125
126    /// Resize the grid, mirroring a PTY winsize change. Dimensions are clamped
127    /// to 1.
128    pub fn resize(&mut self, rows: u16, cols: u16) {
129        let rows = rows.max(1);
130        let cols = cols.max(1);
131        self.term.resize(GridSize {
132            rows: rows as usize,
133            cols: cols as usize,
134        });
135        self.rows = rows;
136        self.cols = cols;
137    }
138
139    /// Snapshot the current screen into an owned holder a [`ScreenView`] can
140    /// borrow from. `visible_text` is plain text (no formatting): one line per
141    /// grid row with trailing blank cells trimmed, exactly what a human would
142    /// see and what the readiness detectors substring-match against. Cursor is
143    /// **0-indexed** `(row, col)` (preserved from the prior vt100 contract).
144    pub fn snapshot(&self) -> OwnedScreen {
145        let grid = self.term.grid();
146        let cursor_point = grid.cursor.point;
147        // alacritty reports the cursor 0-indexed via the inner `.0`; keep that
148        // (the old vt100 `cursor_position()` was 0-indexed too).
149        let cursor_row = cursor_point.line.0.max(0) as usize;
150        let cursor_col = cursor_point.column.0;
151
152        // Build one trimmed string per row, then drop trailing blank rows and
153        // join with '\n'. This reproduces vt100 `contents()` semantics exactly
154        // (trailing blank cells per row trimmed AND trailing blank rows
155        // dropped), which the readiness detectors and the daemon's settled-
156        // reply extraction (daemon.rs) substring-match / equality-check
157        // against. A 1-row "done ❯" screen must read "done ❯", not
158        // "done ❯\n\n\n…".
159        let mut rows: Vec<String> = Vec::with_capacity(self.rows as usize);
160        for row_idx in 0..(self.rows as usize) {
161            let line = Line(row_idx as i32);
162            let mut row = String::with_capacity(self.cols as usize);
163            for col_idx in 0..(self.cols as usize) {
164                row.push(grid[line][Column(col_idx)].c);
165            }
166            while row.ends_with(' ') {
167                row.pop();
168            }
169            rows.push(row);
170        }
171        while rows.last().map(|r| r.is_empty()).unwrap_or(false) {
172            rows.pop();
173        }
174        let text = rows.join("\n");
175
176        OwnedScreen {
177            text,
178            cursor_row,
179            cursor_col,
180            osc_title: self.osc.title().map(str::to_string),
181            osc_progress: self.osc.progress().map(str::to_string),
182        }
183    }
184}
185
186/// Owned snapshot of a rendered grid. Exists because [`ScreenView`] borrows its
187/// `visible_text` as `&str`, while the parser yields an owned `String`.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct OwnedScreen {
190    pub text: String,
191    pub cursor_row: usize,
192    pub cursor_col: usize,
193    /// Latest OSC window title (OSC 0/2), captured from the byte stream (E6.1).
194    /// A detection region for the manifest engine; `None` until a title OSC is
195    /// seen.
196    pub osc_title: Option<String>,
197    /// Latest OSC 9;4 progress payload, if any (E6.1).
198    pub osc_progress: Option<String>,
199}
200
201impl OwnedScreen {
202    /// Borrow this snapshot as the read-only [`ScreenView`] a detector inspects.
203    pub fn view(&self) -> ScreenView<'_> {
204        ScreenView {
205            visible_text: &self.text,
206            cursor_row: self.cursor_row,
207            cursor_col: self.cursor_col,
208            osc_title: self.osc_title.as_deref(),
209            osc_progress: self.osc_progress.as_deref(),
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn plain_text_renders_and_cursor_advances() {
220        let mut grid = TerminalGrid::with_default_size();
221        grid.feed(b"hello");
222        let owned = grid.snapshot();
223        assert!(owned.text.starts_with("hello"));
224        // Cursor sits just past the written text on row 0.
225        assert_eq!(owned.cursor_row, 0);
226        assert_eq!(owned.cursor_col, 5);
227    }
228
229    #[test]
230    fn ansi_escapes_are_interpreted_not_echoed() {
231        let mut grid = TerminalGrid::new(4, 20);
232        // Write "AAA", then a CSI cursor-home + clear-line, then "B". The grid
233        // must reflect the rendered result, not the raw escape bytes.
234        grid.feed(b"AAA\x1b[1;1H\x1b[2KB");
235        let owned = grid.snapshot();
236        assert!(
237            !owned.text.contains('\x1b'),
238            "raw escape leaked into visible text: {:?}",
239            owned.text
240        );
241        assert!(
242            owned.text.starts_with('B'),
243            "cursor-home + clear should leave 'B' at the top-left, got {:?}",
244            owned.text
245        );
246    }
247
248    #[test]
249    fn partial_escape_split_across_feeds_is_buffered() {
250        let mut grid = TerminalGrid::new(4, 20);
251        // Split a CSI sequence (\x1b[1;1H) across two feeds.
252        grid.feed(b"X\x1b[1");
253        grid.feed(b";1HY");
254        let owned = grid.snapshot();
255        assert!(!owned.text.contains('\x1b'));
256        // The completed home sequence repositions to top-left so 'Y' overwrites 'X'.
257        assert!(owned.text.starts_with('Y'), "got {:?}", owned.text);
258    }
259
260    #[test]
261    fn resize_does_not_panic_and_view_roundtrips() {
262        let mut grid = TerminalGrid::with_default_size();
263        grid.resize(0, 0); // clamped to 1x1
264        grid.feed(b"q");
265        let owned = grid.snapshot();
266        let view = owned.view();
267        assert_eq!(view.visible_text, owned.text);
268        assert_eq!(view.cursor_row, owned.cursor_row);
269    }
270
271    #[test]
272    fn osc_title_exposed_on_snapshot_and_reassembled_across_feeds() {
273        // AC-E6-1 at the read-loop level: an OSC title split across two feeds
274        // reassembles, is exposed on the snapshot (owned + borrowed view), and
275        // is NOT echoed into the visible grid (the grid parser consumes it).
276        let mut grid = TerminalGrid::with_default_size();
277        // OSC 2 set-title "⠋ Compiling" (braille spinner = claude "working"
278        // signal), split mid-codepoint, then plain "hello" to the grid.
279        grid.feed(b"\x1b]2;\xe2\xa0\x8b Compil");
280        grid.feed(b"ing\x07hello");
281        let owned = grid.snapshot();
282        assert_eq!(owned.osc_title.as_deref(), Some("\u{280b} Compiling"));
283        assert!(
284            owned.text.starts_with("hello"),
285            "OSC bytes must not leak into the grid, got {:?}",
286            owned.text
287        );
288        // Exposed through the borrowed detector seam too.
289        let view = owned.view();
290        assert_eq!(view.osc_title, Some("\u{280b} Compiling"));
291    }
292
293    #[test]
294    fn prompt_glyph_survives_to_visible_tail() {
295        // Readiness detectors check `visible_text.trim_end().ends_with('❯')`.
296        // Confirm a prompt glyph drawn at the end of a line is the last
297        // non-blank char in the snapshot.
298        let mut grid = TerminalGrid::new(3, 20);
299        grid.feed("\u{276f} ".as_bytes()); // "❯ "
300        let owned = grid.snapshot();
301        assert!(
302            owned
303                .text
304                .lines()
305                .next()
306                .unwrap()
307                .trim_end()
308                .ends_with('\u{276f}'),
309            "prompt glyph should be the visible tail, got {:?}",
310            owned.text
311        );
312    }
313}