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}