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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user