feat(core): Osc133Scanner + FallbackScanner status detectors + grid tail_text

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:59:24 +07:00
parent 84a19356e2
commit 4ec7dc1a78
3 changed files with 198 additions and 0 deletions
+179
View File
@@ -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<u8>,
}
impl Osc133Scanner {
pub fn new() -> Self {
Self::default()
}
pub fn feed(&mut self, bytes: &[u8]) -> Vec<SurfaceState> {
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<SurfaceState> {
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<usize> {
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<u8> {
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<SurfaceState> {
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);
}
}