//! Pure status detectors over terminal output. No I/O. use spacesh_proto::status::SurfaceState; /// Scans a byte stream for OSC 133 semantic-prompt markers and yields the /// status each marker implies. Robust to escape sequences split across feeds: /// an incomplete trailing marker is buffered until the next feed. /// /// Markers: ESC ] 133 ; A ST (prompt) → Idle; ; C ST (command output) → Work; /// ; D [;exit] ST (command end) → Done (exit 0) / Error (exit != 0). /// ST is BEL (0x07) or ESC \ (0x1b 0x5c). The `B` marker (input start) is ignored. #[derive(Default)] pub struct Osc133Scanner { buf: Vec, } impl Osc133Scanner { pub fn new() -> Self { Self::default() } pub fn feed(&mut self, bytes: &[u8]) -> Vec { self.buf.extend_from_slice(bytes); let mut out = Vec::new(); let prefix: &[u8] = b"\x1b]133;"; loop { // Find the next marker start. let Some(start) = find(&self.buf, prefix) else { // No marker start. Keep only a possible partial prefix at the tail. self.buf = keep_partial_tail(&self.buf, prefix); break; }; // Drop anything before the marker start. if start > 0 { self.buf.drain(0..start); } // After the prefix, find the terminator (BEL or ESC \). let body_start = prefix.len(); let Some((body_end, term_len)) = find_terminator(&self.buf, body_start) else { break; // incomplete marker; wait for more bytes }; let body = &self.buf[body_start..body_end]; if let Some(state) = classify(body) { out.push(state); } // Consume through the terminator. self.buf.drain(0..body_end + term_len); } out } } /// Classify the `133;` body (e.g. `A`, `C`, `D`, `D;0`, `D;1`). fn classify(body: &[u8]) -> Option { let s = std::str::from_utf8(body).ok()?; let mut parts = s.split(';'); match parts.next()? { "C" => Some(SurfaceState::Work), "A" => Some(SurfaceState::Idle), "D" => { // exit code is the next part, if present. match parts.next() { Some(code) if code != "0" && !code.is_empty() => Some(SurfaceState::Error), _ => Some(SurfaceState::Done), } } _ => None, // B and others: no status } } fn find(hay: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || hay.len() < needle.len() { return None; } hay.windows(needle.len()).position(|w| w == needle) } /// Terminator search from `from`: returns (index_of_terminator, terminator_len). fn find_terminator(hay: &[u8], from: usize) -> Option<(usize, usize)> { let mut i = from; while i < hay.len() { if hay[i] == 0x07 { return Some((i, 1)); } if hay[i] == 0x1b && i + 1 < hay.len() && hay[i + 1] == 0x5c { return Some((i, 2)); } i += 1; } None } /// Keep only the longest suffix of `buf` that is a strict prefix of `needle` /// (a possibly-incomplete marker start), so it can complete on the next feed. fn keep_partial_tail(buf: &[u8], needle: &[u8]) -> Vec { let max = needle.len().saturating_sub(1).min(buf.len()); for n in (1..=max).rev() { let tail = &buf[buf.len() - n..]; if needle.starts_with(tail) { return tail.to_vec(); } } Vec::new() } /// Stateless best-effort heuristics over a window of recent terminal text. pub struct FallbackScanner; impl FallbackScanner { /// Returns a status implied by the tail text, or None for "no change". pub fn scan(text: &str) -> Option { let tail = text.trim_end(); let last_line = tail.lines().last().unwrap_or(""); // Confirmation / input prompts → waiting for the user. let wait_markers = ["(y/n)", "(Y/n)", "(y/N)", "[y/N]", "[Y/n]", "Press enter", "press enter", "❯ 1.", "? "]; if wait_markers.iter().any(|m| last_line.contains(m)) { return Some(SurfaceState::Wait); } // Spinner glyphs at the tail → working. let spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '|', '/', '-', '\\']; if let Some(c) = last_line.chars().rev().find(|c| !c.is_whitespace()) { if spinners.contains(&c) { return Some(SurfaceState::Work); } } None } } #[cfg(test)] mod tests { use super::*; #[test] fn osc133_c_then_d0_gives_work_then_done() { let mut s = Osc133Scanner::new(); let evs = s.feed(b"\x1b]133;C\x07hello\x1b]133;D;0\x07"); assert_eq!(evs, vec![SurfaceState::Work, SurfaceState::Done]); } #[test] fn osc133_d_nonzero_is_error() { let mut s = Osc133Scanner::new(); assert_eq!(s.feed(b"\x1b]133;D;1\x07"), vec![SurfaceState::Error]); } #[test] fn osc133_a_is_idle_and_st_can_be_esc_backslash() { let mut s = Osc133Scanner::new(); assert_eq!(s.feed(b"\x1b]133;A\x1b\\"), vec![SurfaceState::Idle]); } #[test] fn osc133_split_across_feeds_is_buffered() { let mut s = Osc133Scanner::new(); assert_eq!(s.feed(b"\x1b]133;C"), vec![]); // no terminator yet assert_eq!(s.feed(b"\x07"), vec![SurfaceState::Work]); } #[test] fn osc133_split_in_prefix_is_buffered() { let mut s = Osc133Scanner::new(); assert_eq!(s.feed(b"text\x1b]13"), vec![]); // partial prefix retained assert_eq!(s.feed(b"3;C\x07"), vec![SurfaceState::Work]); } #[test] fn osc133_ignores_plain_text() { let mut s = Osc133Scanner::new(); assert_eq!(s.feed(b"just some output\n"), vec![]); assert!(s.feed(b"more\n").is_empty()); } #[test] fn fallback_detects_confirmation_and_spinner() { assert_eq!(FallbackScanner::scan("Apply changes? (y/n)"), Some(SurfaceState::Wait)); assert_eq!(FallbackScanner::scan("building ⠹"), Some(SurfaceState::Work)); assert_eq!(FallbackScanner::scan("normal output"), None); } }