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}