Files
spaceshell/crates/spacesh-core/src/detect.rs
T
2026-06-09 22:59:24 +07:00

180 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}