4ec7dc1a78
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
6.2 KiB
Rust
180 lines
6.2 KiB
Rust
//! 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);
|
||
}
|
||
}
|