diff --git a/crates/spacesh-core/src/detect.rs b/crates/spacesh-core/src/detect.rs new file mode 100644 index 0000000..d86f3e4 --- /dev/null +++ b/crates/spacesh-core/src/detect.rs @@ -0,0 +1,179 @@ +//! 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); + } +} diff --git a/crates/spacesh-core/src/grid.rs b/crates/spacesh-core/src/grid.rs index 1f5e361..a4a514d 100644 --- a/crates/spacesh-core/src/grid.rs +++ b/crates/spacesh-core/src/grid.rs @@ -59,6 +59,23 @@ impl GridSurface { pub fn term(&self) -> &Term { &self.term } + + /// The visible grid as text — the last `lines` rows, trailing blanks trimmed. + /// Used by the fallback detector. + pub fn tail_text(&self, lines: usize) -> String { + let size = self.size(); + let start = size.lines.saturating_sub(lines); + let mut out = String::new(); + for line in start..size.lines { + let mut row = String::new(); + for col in 0..size.cols { + row.push(self.char_at(line, col)); + } + out.push_str(row.trim_end()); + out.push('\n'); + } + out + } } #[cfg(test)] diff --git a/crates/spacesh-core/src/lib.rs b/crates/spacesh-core/src/lib.rs index 04b0164..ed2711e 100644 --- a/crates/spacesh-core/src/lib.rs +++ b/crates/spacesh-core/src/lib.rs @@ -1,7 +1,9 @@ +pub mod detect; pub mod grid; pub mod ops; pub mod presets; pub mod snapshot; +pub use detect::{FallbackScanner, Osc133Scanner}; pub use grid::GridSurface; pub use snapshot::Snapshot;