#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaudeUiState {
Working,
AwaitingDecision,
Ready,
Unknown,
}
const SPINNER_CHARS: &[char] = &['✱', '✻', '✷', '✸', '✹', '✺'];
const DECISION_MARKERS: &[&str] = &[
"Enter to select",
"to navigate ·",
"Do you want to proceed",
"Allow this tool",
];
const WORKING_MARKERS: &[&str] = &["esc to interrupt"];
pub fn classify(pane_content: &str) -> ClaudeUiState {
let tail = tail_lines(pane_content, 30);
if DECISION_MARKERS.iter().any(|m| tail.contains(m)) {
return ClaudeUiState::AwaitingDecision;
}
if WORKING_MARKERS.iter().any(|m| tail.contains(m)) {
return ClaudeUiState::Working;
}
if has_ready_prompt(&tail) {
return ClaudeUiState::Ready;
}
if tail.chars().any(|c| SPINNER_CHARS.contains(&c)) && !has_ready_prompt(&tail) {
return ClaudeUiState::Working;
}
ClaudeUiState::Unknown
}
fn has_ready_prompt(tail: &str) -> bool {
tail.lines().any(|line| {
let mut chars = line.trim_start().chars();
if chars.next() != Some('❯') {
return false;
}
match chars.next() {
None => true, Some(c) if c.is_whitespace() => !chars
.find(|c| !c.is_whitespace())
.is_some_and(|c| c.is_ascii_digit()),
_ => false,
}
})
}
fn tail_lines(s: &str, n: usize) -> String {
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_ready_state_from_real_capture() {
let pane = "\
/cross-review now, or (c) build anti-pattern hook.
─────────────────────────────────────────────
❯
─────────────────────────────────────────────
Opus 4.7 (1M context) dotfiles master | +1008-68
━━━━╌╌╌╌╌╌╌╌╌╌╌ 32% | $19.08 | 6h14m | 5h14% 7d35%
⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents
new task? /clear
";
assert_eq!(classify(pane), ClaudeUiState::Ready);
}
#[test]
fn classify_decision_state_from_real_capture() {
let pane = "\
3. 스크린샷 공유해주면 확인 가능
4. Type something.
───────────────────────────────────────────────────────────
5. Chat about this
Enter to select · ↑/↓ to navigate · Esc to cancel
";
assert_eq!(classify(pane), ClaudeUiState::AwaitingDecision);
}
#[test]
fn classify_working_state_with_interrupt_hint() {
let pane = "\
✱ Baking the response...
(esc to interrupt)
";
assert_eq!(classify(pane), ClaudeUiState::Working);
}
#[test]
fn classify_working_state_with_spinner_only() {
let pane = "\
✻ Baked for 1m 26s
⎿ Some intermediate output
";
assert_eq!(classify(pane), ClaudeUiState::Working);
}
#[test]
fn classify_unknown_for_plain_shell() {
let pane = "\
$ ls
file1 file2 file3
$
";
assert_eq!(classify(pane), ClaudeUiState::Unknown);
}
#[test]
fn has_ready_prompt_rejects_numbered_selection_cursor() {
let tail = "❯ 1. First option\n 2. Second option";
assert!(!has_ready_prompt(tail));
}
#[test]
fn has_ready_prompt_accepts_empty_chat_prompt() {
assert!(has_ready_prompt("❯ "));
assert!(has_ready_prompt(" ❯ "));
assert!(has_ready_prompt("❯ some draft text"));
}
#[test]
fn classify_decision_wins_over_spinner() {
let pane = "\
✻ Mentioned in a message
3. Option three
Enter to select · ↑/↓ to navigate · Esc to cancel
";
assert_eq!(classify(pane), ClaudeUiState::AwaitingDecision);
}
#[test]
fn tail_lines_returns_last_n() {
let s = "a\nb\nc\nd\ne";
assert_eq!(tail_lines(s, 2), "d\ne");
assert_eq!(tail_lines(s, 10), "a\nb\nc\nd\ne");
}
}