use super::{DetectContext, Status, StatusDetector};
const BOTTOM_REGION_LINES: usize = 12;
pub struct ClaudeDetector;
impl StatusDetector for ClaudeDetector {
fn name(&self) -> &'static str {
"claude"
}
fn priority(&self) -> u8 {
100
}
fn detect(&self, ctx: &DetectContext<'_>) -> Status {
let bottom = bottom_region(ctx.plain);
if !looks_like_claude(ctx.plain, &bottom) {
return Status::Unknown;
}
if has_prompt_marker(&bottom) {
return Status::Waiting;
}
if has_thinking_marker(&bottom) || has_spinner_title(ctx.ansi) {
return Status::Running;
}
if has_prompt_box(&bottom) {
return Status::Waiting;
}
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 bottom_region(plain: &str) -> String {
let mut lines: Vec<&str> = plain
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(BOTTOM_REGION_LINES)
.collect();
lines.reverse();
lines.join("\n")
}
fn looks_like_claude(plain: &str, bottom: &str) -> bool {
if bottom.contains('╭') && bottom.contains('╰') {
return true;
}
plain.contains("? for shortcuts")
|| plain.contains("▐▛███▜▌") || plain.contains("Claude Code")
}
fn has_prompt_marker(region: &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)",
];
if region.contains('❯') {
return true;
}
PROMPTS.iter().any(|p| region.contains(p))
}
fn has_thinking_marker(region: &str) -> bool {
const VERBS: &[&str] = &[
"Thinking",
"Pondering",
"Reviewing",
"Synthesizing",
"Computing",
"Formulating",
"Contemplating",
"Analyzing",
"Reasoning",
"Crafting",
"Considering",
"Working",
];
VERBS
.iter()
.any(|v| region.contains(&format!("{}…", v)) || region.contains(&format!("{}...", v)))
}
fn has_prompt_box(region: &str) -> bool {
let has_top = region.contains('╭');
let has_bot = region.contains('╰');
let has_input = region
.lines()
.any(|l| l.trim_start().starts_with("│ >") || l.trim_start().starts_with("│>"));
has_top && has_bot && has_input
}
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_box_alone_is_waiting() {
let pane = "\
Claude Code v2.1.152
some prior output
╭──────────────────────────────╮
│ > │
╰──────────────────────────────╯
? for shortcuts
";
let ctx = ctx_plain(pane);
assert_eq!(ClaudeDetector.detect(&ctx), Status::Waiting);
}
#[test]
fn confirm_prompt_yields_waiting() {
let pane = "\
Claude Code v2.1.152
running tool…
Do you want to proceed? (y/n)
❯ 1. Yes
2. No
";
let ctx = ctx_plain(pane);
assert_eq!(ClaudeDetector.detect(&ctx), Status::Waiting);
}
#[test]
fn thinking_in_bottom_region_yields_running() {
let pane = "\
Claude Code session
some output
✻ Thinking… (3s · esc to interrupt)
╭──────────────────────────────╮
│ > my prompt │
╰──────────────────────────────╯
";
let ctx = ctx_plain(pane);
assert_eq!(ClaudeDetector.detect(&ctx), Status::Running);
}
#[test]
fn stale_thinking_in_scrollback_does_not_trigger_running() {
let pane = format!(
"Claude Code v2.1.152\n· Thinking…\n{}\n╭──────╮\n│ > │\n╰──────╯\n",
"filler line\n".repeat(20)
);
let ctx = ctx_plain(&pane);
assert_eq!(ClaudeDetector.detect(&ctx), Status::Waiting);
}
#[test]
fn spinner_title_yields_running() {
let mut ansi: Vec<u8> = Vec::new();
ansi.extend_from_slice(b"Claude Code\n\x1b]0;\xe2\xa0\x8b Working\x07");
ansi.extend_from_slice("╭──╮\n│ > │\n╰──╯".as_bytes());
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? for shortcuts\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
);
}
}