pub mod claude;
pub mod codex;
pub mod generic;
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Status {
Running,
Waiting,
Idle,
#[allow(dead_code)]
Error,
#[default]
Unknown,
}
impl Status {
pub fn glyph(self) -> &'static str {
match self {
Status::Running => "●",
Status::Waiting => "◐",
Status::Idle => "○",
Status::Error => "✕",
Status::Unknown => "·",
}
}
}
pub struct DetectContext<'a> {
pub ansi: &'a [u8],
pub plain: &'a str,
pub activity_age: Duration,
pub previous: Option<Status>,
#[allow(dead_code)]
pub session_name: &'a str,
}
impl<'a> DetectContext<'a> {
pub fn from_parts(
ansi: &'a [u8],
plain: &'a str,
last_activity: Option<SystemTime>,
now: SystemTime,
previous: Option<Status>,
session_name: &'a str,
) -> Self {
let activity_age = match last_activity {
Some(ts) => now.duration_since(ts).unwrap_or(Duration::ZERO),
None => Duration::from_secs(u64::MAX / 2),
};
Self {
ansi,
plain,
activity_age,
previous,
session_name,
}
}
}
pub trait StatusDetector: Send + Sync {
fn name(&self) -> &'static str;
fn detect(&self, ctx: &DetectContext<'_>) -> Status;
fn priority(&self) -> u8;
}
pub struct DetectorRegistry {
detectors: Vec<Box<dyn StatusDetector>>,
}
impl DetectorRegistry {
pub fn new() -> Self {
Self {
detectors: Vec::new(),
}
}
pub fn register(mut self, d: Box<dyn StatusDetector>) -> Self {
self.detectors.push(d);
self.detectors
.sort_by_key(|d| std::cmp::Reverse(d.priority()));
self
}
pub fn default_stack() -> Self {
Self::new()
.register(Box::new(claude::ClaudeDetector))
.register(Box::new(codex::CodexDetector))
.register(Box::new(generic::GenericDetector))
}
pub fn detect(&self, ctx: &DetectContext<'_>) -> Status {
for d in &self.detectors {
let s = d.detect(ctx);
if s != Status::Unknown {
return s;
}
}
Status::Unknown
}
}
impl Default for DetectorRegistry {
fn default() -> Self {
Self::default_stack()
}
}
pub fn strip_ansi(bytes: &[u8]) -> String {
let s = String::from_utf8_lossy(bytes);
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.peek() {
Some('[') => {
chars.next();
for ch in chars.by_ref() {
if ('\x40'..='\x7e').contains(&ch) {
break;
}
}
}
Some(']') => {
chars.next();
while let Some(ch) = chars.next() {
if ch == '\x07' {
break;
}
if ch == '\x1b' {
let _ = chars.next();
break;
}
}
}
Some(&c2) if ('\x40'..='\x5f').contains(&c2) => {
chars.next();
}
_ => {}
}
continue;
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_removes_csi() {
let s = strip_ansi(b"\x1b[31mred\x1b[0m plain");
assert_eq!(s, "red plain");
}
#[test]
fn strip_ansi_removes_osc_bel_terminated() {
let s = strip_ansi(b"before\x1b]0;title\x07after");
assert_eq!(s, "beforeafter");
}
#[test]
fn strip_ansi_removes_osc_st_terminated() {
let s = strip_ansi(b"before\x1b]0;title\x1b\\after");
assert_eq!(s, "beforeafter");
}
#[test]
fn strip_ansi_preserves_unicode() {
let s = strip_ansi("日本語 \x1b[1mbold\x1b[0m".as_bytes());
assert_eq!(s, "日本語 bold");
}
#[test]
fn registry_picks_first_non_unknown_by_priority() {
struct Fake {
name: &'static str,
prio: u8,
answer: Status,
}
impl StatusDetector for Fake {
fn name(&self) -> &'static str {
self.name
}
fn priority(&self) -> u8 {
self.prio
}
fn detect(&self, _: &DetectContext<'_>) -> Status {
self.answer
}
}
let r = DetectorRegistry::new()
.register(Box::new(Fake {
name: "low",
prio: 10,
answer: Status::Idle,
}))
.register(Box::new(Fake {
name: "high_unknown",
prio: 100,
answer: Status::Unknown,
}))
.register(Box::new(Fake {
name: "mid",
prio: 50,
answer: Status::Running,
}));
let now = SystemTime::now();
let ctx = DetectContext::from_parts(b"", "", Some(now), now, None, "x");
assert_eq!(r.detect(&ctx), Status::Running);
}
}