Skip to main content

fno_agents/
osc.rs

1//! OSC (Operating System Command) capture for the readiness read loop (E6.1).
2//!
3//! alacritty's `vte::ansi::Processor` parses OSC sequences and dispatches them
4//! to the terminal's event listener - which is `VoidListener` in
5//! [`crate::screen`], so the OSC title/progress strings are discarded. The
6//! manifest engine (E6.2) wants OSC title as a detection region: claude's
7//! braille-spinner "working" signal lives in the window title, where it
8//! survives scrollback, wrap, and resize that break grid-scraping. So this
9//! module re-scans the same byte stream the grid sees and keeps the latest
10//! title (OSC 0/2) and progress (OSC 9;4), reassembling sequences split across
11//! PTY reads.
12//!
13//! Hand-rolled rather than a second `vte` parser: the OSC grammar is a tiny
14//! state machine, herdr captures OSC the same way (`pane/osc.rs`), and an
15//! explicit cross-read buffer is exactly what the reassembly test pins. The
16//! state and buffer are struct fields, so a sequence split across `feed` calls
17//! reassembles with no per-call setup.
18//!
19//! ponytail: deliberately naive about the grammar's dark corners - it handles
20//! the 7-bit `ESC ]` introducer with BEL or `ESC \` (ST) terminators only, and
21//! does not skip DCS/APC/PM/SOS string bodies (`ESC P/_/^/X`). The real grid is
22//! parsed by alacritty's full `vte` processor; this scanner only feeds OSC
23//! detection regions, so its sole failure mode on adversarial/binary input is a
24//! spurious title, never a wrong grid. Harden (C1 forms, string-body skipping)
25//! if a real agent's output trips it.
26
27const BEL: u8 = 0x07;
28const ESC: u8 = 0x1b;
29
30/// Cap on a single OSC body. Titles are short; this only stops a runaway or
31/// binary stream (an unterminated OSC) from growing the buffer without bound.
32const MAX_OSC_BODY: usize = 4096;
33
34/// Parser state for an in-progress OSC sequence. Held across [`OscCapture::feed`]
35/// calls so a sequence split across PTY reads reassembles.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37enum State {
38    /// Outside any escape sequence.
39    #[default]
40    Ground,
41    /// Saw `ESC`; waiting to see if it introduces an OSC (`]`).
42    Escape,
43    /// Inside an OSC body, accumulating until BEL or ST (`ESC \`).
44    Osc,
45    /// Inside an OSC body, saw `ESC`; a following `\` completes the ST terminator.
46    OscEscape,
47}
48
49/// Captures the latest OSC title (OSC 0/2) and progress (OSC 9;4) from a PTY
50/// byte stream. Feed it the same bytes as the grid, then read [`title`] /
51/// [`progress`] from the snapshot. "Latest wins": a new title OSC overwrites the
52/// previous one, mirroring how a terminal's window title behaves.
53///
54/// [`title`]: OscCapture::title
55/// [`progress`]: OscCapture::progress
56#[derive(Debug, Clone, Default)]
57pub struct OscCapture {
58    state: State,
59    buffer: Vec<u8>,
60    /// Set when a body exceeds `MAX_OSC_BODY`. A truncated body's title is
61    /// unknowable, so an overflowed sequence is dropped at the terminator rather
62    /// than published as a bogus prefix. Reset at each new OSC start.
63    overflowed: bool,
64    title: Option<String>,
65    progress: Option<String>,
66}
67
68impl OscCapture {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Feed raw PTY output. Safe to call with an OSC sequence split across
74    /// reads (even mid-multibyte-UTF-8): bytes accumulate in `buffer` and are
75    /// only decoded once the terminator arrives.
76    pub fn feed(&mut self, bytes: &[u8]) {
77        for &b in bytes {
78            match self.state {
79                State::Ground => {
80                    if b == ESC {
81                        self.state = State::Escape;
82                    }
83                }
84                State::Escape => {
85                    if b == b']' {
86                        self.buffer.clear();
87                        self.overflowed = false;
88                        self.state = State::Osc;
89                    } else {
90                        // Some other escape (CSI, plain ESC, ...): we only track
91                        // OSC. Re-arm on a back-to-back ESC so it isn't dropped.
92                        self.state = if b == ESC {
93                            State::Escape
94                        } else {
95                            State::Ground
96                        };
97                    }
98                }
99                State::Osc => match b {
100                    BEL => {
101                        self.finish();
102                        self.state = State::Ground;
103                    }
104                    ESC => self.state = State::OscEscape,
105                    _ => {
106                        if self.buffer.len() < MAX_OSC_BODY {
107                            self.buffer.push(b);
108                        } else {
109                            // Body too long: stop accumulating and mark it so the
110                            // terminator drops it instead of publishing a prefix.
111                            self.overflowed = true;
112                        }
113                    }
114                },
115                State::OscEscape => {
116                    if b == b'\\' {
117                        // ST terminator (ESC \).
118                        self.finish();
119                        self.state = State::Ground;
120                    } else if b == b']' {
121                        // `ESC ]` is a new OSC introducer: an unterminated OSC
122                        // ran straight into the next one. Drop the partial body
123                        // and start the new sequence rather than losing it.
124                        self.buffer.clear();
125                        self.overflowed = false;
126                        self.state = State::Osc;
127                    } else {
128                        // ESC inside the body not followed by `\` or `]`: the
129                        // OSC is interrupted. Drop the partial body and return to
130                        // Ground, re-arming if this byte is itself an ESC (the
131                        // only byte Ground reacts to, so nothing else is lost).
132                        self.buffer.clear();
133                        self.state = if b == ESC {
134                            State::Escape
135                        } else {
136                            State::Ground
137                        };
138                    }
139                }
140            }
141        }
142    }
143
144    /// The most recent OSC window title (OSC 0 or OSC 2), if any.
145    pub fn title(&self) -> Option<&str> {
146        self.title.as_deref()
147    }
148
149    /// The most recent OSC 9;4 progress payload (the part after `9;`), if any.
150    pub fn progress(&self) -> Option<&str> {
151        self.progress.as_deref()
152    }
153
154    /// Parse a completed OSC body (`Ps;Pt...`, terminator already stripped) and
155    /// update the captured title/progress.
156    fn finish(&mut self) {
157        // A body that overflowed MAX_OSC_BODY was truncated mid-stream, so its
158        // title/progress is unknowable: publish nothing rather than a bogus
159        // prefix. (The `ESC ]` restart path still lets a later valid OSC win.)
160        if self.overflowed {
161            self.buffer.clear();
162            self.overflowed = false;
163            return;
164        }
165        // Decode lazily here, so a body split mid-multibyte across feeds is fine.
166        if let Ok(body) = std::str::from_utf8(&self.buffer) {
167            if let Some((code, rest)) = body.split_once(';') {
168                match code {
169                    // OSC 0 (icon name + window title) and OSC 2 (window title)
170                    // set the title. OSC 1 (icon name only) is intentionally not
171                    // treated as the title.
172                    "0" | "2" => self.title = Some(rest.to_string()),
173                    // OSC 9;4;state;pct is the ConEmu / Windows-Terminal progress
174                    // sequence. Bare OSC 9 is an iTerm2 notification (not
175                    // progress), so gate on the "4" subcode. Store the payload
176                    // after "9;" raw; the engine matches it as a region string.
177                    // ponytail: raw payload, no state/pct struct until a rule needs one.
178                    "9" if rest.split(';').next() == Some("4") => {
179                        self.progress = Some(rest.to_string())
180                    }
181                    _ => {}
182                }
183            }
184        }
185        self.buffer.clear();
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn osc_title_split_across_two_feeds_reassembles() {
195        // AC-E6-1: a split OSC sequence across two reads reassembles.
196        let mut osc = OscCapture::new();
197        osc.feed(b"\x1b]2;hel");
198        assert_eq!(osc.title(), None, "not terminated yet, nothing captured");
199        osc.feed(b"lo world\x07");
200        assert_eq!(osc.title(), Some("hello world"));
201    }
202
203    #[test]
204    fn esc_introducer_split_from_bracket() {
205        // The ESC and the `]` land in different reads.
206        let mut osc = OscCapture::new();
207        osc.feed(b"\x1b");
208        osc.feed(b"]2;ok\x07");
209        assert_eq!(osc.title(), Some("ok"));
210    }
211
212    #[test]
213    fn st_terminator_accepted() {
214        // ESC \ (ST) terminates an OSC just like BEL.
215        let mut osc = OscCapture::new();
216        osc.feed(b"\x1b]0;title here\x1b\\");
217        assert_eq!(osc.title(), Some("title here"));
218    }
219
220    #[test]
221    fn braille_spinner_title_survives_mid_codepoint_split() {
222        // claude's "working" signal is a braille spinner in the title. Split the
223        // 3-byte braille codepoint (U+280B = e2 a0 8b) across two feeds.
224        let mut osc = OscCapture::new();
225        osc.feed(b"\x1b]2;\xe2\xa0");
226        osc.feed(b"\x8b Compiling\x07");
227        assert_eq!(osc.title(), Some("\u{280b} Compiling"));
228    }
229
230    #[test]
231    fn osc_9_4_is_progress_but_bare_osc_9_is_not() {
232        let mut osc = OscCapture::new();
233        osc.feed(b"\x1b]9;4;1;50\x07");
234        assert_eq!(osc.progress(), Some("4;1;50"));
235        // A bare OSC 9 (iTerm2 notification) must not be read as progress.
236        let mut other = OscCapture::new();
237        other.feed(b"\x1b]9;build done\x07");
238        assert_eq!(other.progress(), None);
239    }
240
241    #[test]
242    fn latest_title_wins() {
243        let mut osc = OscCapture::new();
244        osc.feed(b"\x1b]2;first\x07\x1b]2;second\x07");
245        assert_eq!(osc.title(), Some("second"));
246    }
247
248    #[test]
249    fn unterminated_osc_running_into_next_osc_captures_the_second() {
250        // An OSC with no BEL/ST, immediately followed by another OSC: the `ESC ]`
251        // mid-body is the next sequence's introducer, not garbage to abort on.
252        let mut osc = OscCapture::new();
253        osc.feed(b"\x1b]2;first\x1b]2;second\x07");
254        assert_eq!(osc.title(), Some("second"));
255    }
256
257    #[test]
258    fn oversized_osc_body_is_dropped_not_published_truncated() {
259        // A body longer than MAX_OSC_BODY is truncated mid-stream; publishing the
260        // prefix would be a bogus title, so the terminator must drop it.
261        let mut osc = OscCapture::new();
262        let mut seq = b"\x1b]2;".to_vec();
263        seq.extend(std::iter::repeat(b'x').take(MAX_OSC_BODY + 100));
264        seq.push(BEL);
265        osc.feed(&seq);
266        assert_eq!(
267            osc.title(),
268            None,
269            "truncated oversized title must not publish"
270        );
271        // A later well-formed OSC still wins (overflow flag reset on new start).
272        osc.feed(b"\x1b]2;ok\x07");
273        assert_eq!(osc.title(), Some("ok"));
274    }
275
276    #[test]
277    fn osc_1_icon_name_is_not_a_title() {
278        let mut osc = OscCapture::new();
279        osc.feed(b"\x1b]1;iconname\x07");
280        assert_eq!(osc.title(), None);
281    }
282
283    #[test]
284    fn interrupted_osc_does_not_capture_garbage() {
285        // An ESC that is not part of an ST aborts the OSC body.
286        let mut osc = OscCapture::new();
287        osc.feed(b"\x1b]2;par\x1b[0mtial\x07");
288        // The body was interrupted by a CSI (`ESC [ 0 m`); no title captured,
289        // and the parser is back in a sane state for the next sequence.
290        assert_eq!(osc.title(), None);
291        osc.feed(b"\x1b]2;clean\x07");
292        assert_eq!(osc.title(), Some("clean"));
293    }
294}