use super::{DetectContext, Status, StatusDetector};
pub struct ClaudeDetector;
impl StatusDetector for ClaudeDetector {
fn name(&self) -> &'static str {
"claude"
}
fn priority(&self) -> u8 {
100
}
fn detect(&self, ctx: &DetectContext<'_>) -> Status {
if !looks_like_claude(ctx.plain) {
return Status::Unknown;
}
if has_prompt_marker(ctx.plain) {
return Status::Waiting;
}
if has_thinking_marker(ctx.plain) || has_spinner_title(ctx.ansi) {
return Status::Running;
}
let last = tail_non_empty_line(ctx.plain);
if last.is_empty() || last.trim_start().starts_with('>') || last.contains('❯') {
return Status::Waiting;
}
Status::Idle
}
}
fn looks_like_claude(plain: &str) -> bool {
plain.contains("claude")
|| plain.contains("Claude")
|| plain.contains("? for shortcuts")
|| plain.contains("▐▛███▜▌") }
fn has_prompt_marker(plain: &str) -> bool {
const PROMPTS: &[&str] = &[
"Do you want to",
"Would you like to",
"Choose an option",
"Press any key to continue",
"(y/n)",
"(Y/n)",
"(y/N)",
];
PROMPTS.iter().any(|p| plain.contains(p))
}
fn has_thinking_marker(plain: &str) -> bool {
const VERBS: &[&str] = &[
"Thinking",
"Pondering",
"Reviewing",
"Synthesizing",
"Computing",
"Formulating",
"Contemplating",
"Analyzing",
];
VERBS
.iter()
.any(|v| plain.contains(&format!("{}…", v)) || plain.contains(&format!("{}...", v)))
}
fn has_spinner_title(ansi: &[u8]) -> bool {
let s = String::from_utf8_lossy(ansi);
let mut in_title = false;
let mut title = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.next() == Some(']') {
for _ in 0..2 {
chars.next();
}
in_title = true;
title.clear();
}
} else if in_title {
if c == '\x07' || c == '\x1b' {
if title.chars().any(is_braille) {
return true;
}
in_title = false;
} else {
title.push(c);
}
}
}
false
}
fn is_braille(c: char) -> bool {
('\u{2800}'..='\u{28ff}').contains(&c)
}
fn tail_non_empty_line(plain: &str) -> &str {
plain
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::*;
use crate::tmux::detector::{strip_ansi, DetectContext};
fn ctx_plain(s: &str) -> DetectContext<'_> {
let now = SystemTime::now();
DetectContext::from_parts(s.as_bytes(), s, Some(now), now, None, "test")
}
#[test]
fn non_claude_returns_unknown() {
let ctx = ctx_plain("$ ls -la\ntotal 42\n");
assert_eq!(ClaudeDetector.detect(&ctx), Status::Unknown);
}
#[test]
fn prompt_marker_yields_waiting() {
let ctx =
ctx_plain("Claude is ready\n> write some code\n\nDo you want to proceed? (y/n)\n");
assert_eq!(ClaudeDetector.detect(&ctx), Status::Waiting);
}
#[test]
fn thinking_yields_running() {
let ctx = ctx_plain("claude is working\n· Thinking…\n");
assert_eq!(ClaudeDetector.detect(&ctx), Status::Running);
}
#[test]
fn spinner_title_yields_running() {
let ansi = b"Claude\n\x1b]0;\xe2\xa0\x8b Working\x07$ ";
let plain = strip_ansi(ansi);
let now = SystemTime::now();
let ctx = DetectContext::from_parts(ansi, &plain, Some(now), now, None, "test");
assert_eq!(ClaudeDetector.detect(&ctx), Status::Running);
}
#[test]
fn bare_prompt_line_is_waiting() {
let ctx = ctx_plain("Claude Code session\n\n> \n");
assert_eq!(ClaudeDetector.detect(&ctx), Status::Waiting);
}
#[test]
fn settled_output_is_idle() {
let ctx = ctx_plain("Claude Code session\nDone.\n$ ");
let got = ClaudeDetector.detect(&ctx);
assert!(
got == Status::Idle || got == Status::Unknown,
"expected Idle or Unknown, got {:?}",
got
);
}
}