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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,23 @@ impl GridSurface {
|
|||||||
pub fn term(&self) -> &Term<VoidListener> {
|
pub fn term(&self) -> &Term<VoidListener> {
|
||||||
&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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
pub mod detect;
|
||||||
pub mod grid;
|
pub mod grid;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
pub mod presets;
|
pub mod presets;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
|
|
||||||
|
pub use detect::{FallbackScanner, Osc133Scanner};
|
||||||
pub use grid::GridSurface;
|
pub use grid::GridSurface;
|
||||||
pub use snapshot::Snapshot;
|
pub use snapshot::Snapshot;
|
||||||
|
|||||||
Reference in New Issue
Block a user