mod approval;
mod constants;
mod spinner;
#[cfg(test)]
mod tests;
use regex::Regex;
use tracing::trace;
use crate::agents::{AgentStatus, AgentType};
use super::{DetectionConfidence, DetectionContext, DetectionResult, StatusDetector};
use crate::detectors::common::safe_tail;
use constants::*;
pub struct ClaudeCodeDetector {
file_edit_pattern: Regex,
file_create_pattern: Regex,
file_delete_pattern: Regex,
bash_pattern: Regex,
mcp_pattern: Regex,
general_approval_pattern: Regex,
choice_pattern: Regex,
}
impl ClaudeCodeDetector {
pub fn new() -> Self {
Self {
file_edit_pattern: Regex::new(
r"(?i)(Edit|Write|Modify)\s+.*?\?|Do you want to (edit|write|modify)|Allow.*?edit",
)
.expect("Invalid file_edit_pattern regex"),
file_create_pattern: Regex::new(
r"(?i)Create\s+.*?\?|Do you want to create|Allow.*?create",
)
.expect("Invalid file_create_pattern regex"),
file_delete_pattern: Regex::new(
r"(?i)Delete\s+.*?\?|Do you want to delete|Allow.*?delete",
)
.expect("Invalid file_delete_pattern regex"),
bash_pattern: Regex::new(
r"(?i)(Run|Execute)\s+(command|bash|shell)|Do you want to run|Allow.*?(command|bash)|run this command",
)
.expect("Invalid bash_pattern regex"),
mcp_pattern: Regex::new(r"(?i)MCP\s+tool|Do you want to use.*?MCP|Allow.*?MCP")
.expect("Invalid mcp_pattern regex"),
general_approval_pattern: Regex::new(
r"(?i)\[y/n\]|\[Y/n\]|\[yes/no\]|\(Y\)es\s*/\s*\(N\)o|Yes\s*/\s*No|y/n|Allow\?|Do you want to (allow|proceed|continue|run|execute)",
)
.expect("Invalid general_approval_pattern regex"),
choice_pattern: Regex::new(r"^\s*(?:[>❯›]\s*)?(\d+)\.\s+(.+)$")
.expect("Invalid choice_pattern regex"),
}
}
}
impl Default for ClaudeCodeDetector {
fn default() -> Self {
Self::new()
}
}
impl StatusDetector for ClaudeCodeDetector {
fn detect_status(&self, title: &str, content: &str) -> AgentStatus {
self.detect_status_with_reason(title, content, &DetectionContext::default())
.status
}
fn detect_status_with_context(
&self,
title: &str,
content: &str,
context: &DetectionContext,
) -> AgentStatus {
self.detect_status_with_reason(title, content, context)
.status
}
fn detect_status_with_reason(
&self,
title: &str,
content: &str,
context: &DetectionContext,
) -> DetectionResult {
if let Some((approval_type, details, rule)) = self.detect_approval(content) {
trace!(rule, "detect_status: approval detected");
let matched = safe_tail(content, 200);
return DetectionResult::new(
AgentStatus::AwaitingApproval {
approval_type,
details,
},
rule,
DetectionConfidence::High,
)
.with_matched_text(matched);
}
trace!("detect_status: no approval detected, continuing to title/content checks");
{
let title_activity = title
.chars()
.skip_while(|c| matches!(*c, '\u{2800}'..='\u{28FF}') || c.is_whitespace())
.collect::<String>();
if title.chars().any(|c| matches!(c, '\u{2800}'..='\u{28FF}')) {
return DetectionResult::new(
AgentStatus::Processing {
activity: title_activity,
},
"title_braille_spinner_fast_path",
DetectionConfidence::High,
)
.with_matched_text(title);
}
}
if let Some(message) = self.detect_error(content) {
return DetectionResult::new(
AgentStatus::Error {
message: message.clone(),
},
"error_pattern",
DetectionConfidence::High,
)
.with_matched_text(&message);
}
if Self::has_in_progress_tasks(content) {
return DetectionResult::new(
AgentStatus::Processing {
activity: "Tasks running".to_string(),
},
"tasks_in_progress",
DetectionConfidence::High,
);
}
if title.contains('✽') && title.to_lowercase().contains("compacting") {
return DetectionResult::new(
AgentStatus::Processing {
activity: "Compacting...".to_string(),
},
"title_compacting",
DetectionConfidence::High,
)
.with_matched_text(title);
}
{
let recent = safe_tail(content, 1000);
if recent.contains("Conversation compacted") {
for line in recent
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(15)
{
let trimmed = line.trim();
let first_char = trimmed.chars().next().unwrap_or('\0');
if (CONTENT_SPINNER_CHARS.contains(&first_char) || first_char == '*')
&& trimmed.contains("Conversation compacted")
{
return DetectionResult::new(
AgentStatus::Idle,
"content_conversation_compacted",
DetectionConfidence::High,
)
.with_matched_text(trimmed);
}
}
}
}
if let Some((activity, is_builtin)) = Self::detect_content_spinner(content, context) {
let confidence = if is_builtin {
DetectionConfidence::High
} else {
DetectionConfidence::Medium
};
return DetectionResult::new(
AgentStatus::Processing {
activity: activity.clone(),
},
"content_spinner_verb",
confidence,
)
.with_matched_text(&activity);
}
if let Some(matched) = Self::detect_turn_duration(content) {
return DetectionResult::new(
AgentStatus::Idle,
"turn_duration_completed",
DetectionConfidence::High,
)
.with_matched_text(&matched);
}
if title.contains(IDLE_INDICATOR) {
trace!(
title,
"detect_status: title_idle_indicator (approval was not detected)"
);
return DetectionResult::new(
AgentStatus::Idle,
"title_idle_indicator",
DetectionConfidence::High,
)
.with_matched_text(title);
}
if let Some(activity) = Self::detect_custom_spinner_verb(title, context) {
return DetectionResult::new(
AgentStatus::Processing { activity },
"custom_spinner_verb",
DetectionConfidence::Medium,
)
.with_matched_text(title);
}
if !Self::should_skip_default_spinners(context)
&& title.chars().any(|c| PROCESSING_SPINNERS.contains(&c))
{
let activity = title
.chars()
.skip_while(|c| PROCESSING_SPINNERS.contains(c) || c.is_whitespace())
.collect::<String>();
return DetectionResult::new(
AgentStatus::Processing { activity },
"braille_spinner",
DetectionConfidence::Medium,
)
.with_matched_text(title);
}
DetectionResult::new(
AgentStatus::Processing {
activity: String::new(),
},
"fallback_no_indicator",
DetectionConfidence::Low,
)
}
fn detect_context_warning(&self, content: &str) -> Option<u8> {
for line in content.lines().rev().take(30) {
if line.contains("Context left until auto-compact:") {
if let Some(pct_str) = line.split(':').next_back() {
let pct_str = pct_str.trim().trim_end_matches('%');
if let Ok(pct) = pct_str.parse::<u8>() {
return Some(pct);
}
}
}
}
None
}
fn agent_type(&self) -> AgentType {
AgentType::ClaudeCode
}
fn approval_keys(&self) -> &str {
"Enter"
}
}