use once_cell::sync::Lazy;
use regex::Regex;
use std::time::{Duration, Instant};
use crate::ipc::protocol::{WrapApprovalType, WrapState};
const PROCESSING_TIMEOUT_MS: u64 = 200; const APPROVAL_SETTLE_MS: u64 = 500; const INPUT_ECHO_GRACE_MS: u64 = 300;
pub struct Analyzer {
last_output: Instant,
last_input: Instant,
output_buffer: String,
max_buffer_size: usize,
pending_approval: Option<(WrapApprovalType, String)>,
pending_approval_at: Option<Instant>,
pid: u32,
patterns: AnalyzerPatterns,
team_name: Option<String>,
team_member_name: Option<String>,
is_team_lead: bool,
}
struct AnalyzerPatterns {
choice_pattern: Regex,
#[allow(dead_code)]
yes_no_pattern: Regex,
general_approval: Regex,
file_edit: Regex,
file_create: Regex,
file_delete: Regex,
shell_command: Regex,
mcp_tool: Regex,
}
impl Default for AnalyzerPatterns {
fn default() -> Self {
Self {
choice_pattern: Regex::new(r"^\s*(?:[>❯›]\s*)?(\d+)\.\s+(.+)$")
.expect("Invalid choice_pattern"),
yes_no_pattern: Regex::new(r"(?i)\b(Yes|No)\b")
.expect("Invalid yes_no_pattern"),
general_approval: 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"
).expect("Invalid general_approval"),
file_edit: Regex::new(
r"(?i)(Edit|Write|Modify)\s+.*?\?|Do you want to (edit|write|modify)|Allow.*?edit"
).expect("Invalid file_edit"),
file_create: Regex::new(
r"(?i)Create\s+.*?\?|Do you want to create|Allow.*?create"
).expect("Invalid file_create"),
file_delete: Regex::new(
r"(?i)Delete\s+.*?\?|Do you want to delete|Allow.*?delete"
).expect("Invalid file_delete"),
shell_command: Regex::new(
r"(?i)(Run|Execute)\s+(command|bash|shell)|Do you want to run|Allow.*?(command|bash)|run this command"
).expect("Invalid shell_command"),
mcp_tool: Regex::new(r"(?i)MCP\s+tool|Do you want to use.*?MCP|Allow.*?MCP")
.expect("Invalid mcp_tool"),
}
}
}
use crate::detectors::common::strip_box_drawing;
impl Analyzer {
pub fn new(pid: u32) -> Self {
let team_name = std::env::var("CLAUDE_CODE_TASK_LIST_ID").ok();
let team_member_name = std::env::var("CLAUDE_AGENT_NAME").ok();
let is_team_lead = team_member_name
.as_deref()
.map(|n| n == "team-lead" || n == "lead" || n.ends_with("-lead"))
.unwrap_or(false);
let now = Instant::now();
Self {
last_output: now,
last_input: now,
output_buffer: String::with_capacity(8192),
max_buffer_size: 16384,
pending_approval: None,
pending_approval_at: None,
pid,
patterns: AnalyzerPatterns::default(),
team_name,
team_member_name,
is_team_lead,
}
}
pub fn team_name(&self) -> Option<&String> {
self.team_name.as_ref()
}
pub fn team_member_name(&self) -> Option<&String> {
self.team_member_name.as_ref()
}
pub fn is_team_lead(&self) -> bool {
self.is_team_lead
}
pub fn process_output(&mut self, data: &str) {
self.last_output = Instant::now();
let clean = strip_ansi(data);
self.output_buffer.push_str(&clean);
if self.output_buffer.len() > self.max_buffer_size {
let drain_to = self.output_buffer.len() - self.max_buffer_size / 2;
let drain_to = self
.output_buffer
.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= drain_to)
.unwrap_or(drain_to);
self.output_buffer.drain(..drain_to);
}
self.detect_approval_pattern();
}
pub fn process_input(&mut self, _data: &str) {
self.last_input = Instant::now();
self.pending_approval = None;
self.pending_approval_at = None;
self.output_buffer.clear();
}
pub fn get_state(&self) -> WrapState {
let now = Instant::now();
let since_output = now.duration_since(self.last_output);
let since_input = now.duration_since(self.last_input);
let mut state;
let in_echo_grace = since_input < Duration::from_millis(INPUT_ECHO_GRACE_MS);
if since_output < Duration::from_millis(PROCESSING_TIMEOUT_MS) && !in_echo_grace {
state = WrapState::processing(self.pid);
} else if let Some((ref approval_type, ref details)) = self.pending_approval {
if let Some(detected_at) = self.pending_approval_at {
let since_detected = now.duration_since(detected_at);
if since_detected >= Duration::from_millis(APPROVAL_SETTLE_MS) {
state = match approval_type {
WrapApprovalType::UserQuestion => {
let (choices, multi_select, cursor_pos) = self.extract_choices();
WrapState::user_question(self.pid, choices, multi_select, cursor_pos)
}
_ => WrapState::awaiting_approval(
self.pid,
approval_type.clone(),
Some(details.clone()),
),
};
} else {
state = WrapState::processing(self.pid);
}
} else {
state = WrapState::idle(self.pid);
}
} else {
state = WrapState::idle(self.pid);
state.last_output = instant_to_millis(self.last_output);
state.last_input = instant_to_millis(self.last_input);
}
state.team_name = self.team_name.clone();
state.team_member_name = self.team_member_name.clone();
state.is_team_lead = self.is_team_lead;
state
}
fn detect_approval_pattern(&mut self) {
let content = &self.output_buffer;
if self.detect_user_question(content) {
if self.pending_approval.is_none()
|| !matches!(
self.pending_approval,
Some((WrapApprovalType::UserQuestion, _))
)
{
self.pending_approval = Some((WrapApprovalType::UserQuestion, String::new()));
self.pending_approval_at = Some(Instant::now());
}
return;
}
if self.detect_proceed_prompt(content) {
if self.pending_approval.is_none()
|| !matches!(
self.pending_approval,
Some((WrapApprovalType::UserQuestion, _))
)
{
self.pending_approval = Some((WrapApprovalType::UserQuestion, String::new()));
self.pending_approval_at = Some(Instant::now());
}
return;
}
if self.detect_yes_no_approval(content) {
let approval_type = self.determine_approval_type(content);
if self.pending_approval.is_none() {
self.pending_approval = Some((approval_type, String::new()));
self.pending_approval_at = Some(Instant::now());
}
return;
}
if self.patterns.general_approval.is_match(content) {
let lines: Vec<&str> = content.lines().collect();
if let Some(last_few) = lines.get(lines.len().saturating_sub(10)..) {
let recent = last_few.join("\n");
if self.patterns.general_approval.is_match(&recent) {
let approval_type = self.determine_approval_type(content);
if self.pending_approval.is_none() {
self.pending_approval = Some((approval_type, String::new()));
self.pending_approval_at = Some(Instant::now());
}
return;
}
}
}
self.pending_approval = None;
self.pending_approval_at = None;
}
fn detect_user_question(&self, content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
if lines.len() < 3 {
return false;
}
let separator_indices: Vec<usize> = lines
.iter()
.enumerate()
.rev()
.filter(|(_, line)| {
let trimmed = line.trim();
trimmed.len() >= 10 && trimmed.chars().all(|c| c == '─')
})
.map(|(i, _)| i)
.take(2)
.collect();
let check_lines =
if separator_indices.len() == 2 && separator_indices[0] > separator_indices[1] + 1 {
&lines[separator_indices[1] + 1..separator_indices[0]]
} else {
let check_start = lines.len().saturating_sub(25);
&lines[check_start..]
};
let mut consecutive_choices = 0;
let mut has_cursor = false;
let mut expected_num = 1u32;
for line in check_lines {
if let Some(cap) = self.patterns.choice_pattern.captures(line) {
if let Ok(num) = cap[1].parse::<u32>() {
if num == expected_num {
consecutive_choices += 1;
expected_num += 1;
let trimmed = line.trim();
if trimmed.starts_with('❯')
|| trimmed.starts_with('›')
|| trimmed.starts_with('>')
{
has_cursor = true;
}
} else if num == 1 {
consecutive_choices = 1;
expected_num = 2;
has_cursor = line.trim().starts_with('❯')
|| line.trim().starts_with('›')
|| line.trim().starts_with('>');
}
}
}
}
consecutive_choices >= 2 && has_cursor
}
fn detect_proceed_prompt(&self, content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
let check_start = lines.len().saturating_sub(15);
let check_lines = &lines[check_start..];
let mut has_yes = false;
let mut has_no = false;
for line in check_lines {
let trimmed = line.trim();
if trimmed.contains("1.") && trimmed.contains("Yes") {
has_yes = true;
}
if (trimmed.contains("2. No") || trimmed.contains("3. No")) && trimmed.len() < 20 {
has_no = true;
}
}
has_yes && has_no
}
fn detect_yes_no_approval(&self, content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
if lines.len() < 2 {
return false;
}
let check_start = lines.len().saturating_sub(8);
let check_lines = &lines[check_start..];
let mut has_yes = false;
let mut has_no = false;
let mut yes_line_idx = None;
let mut no_line_idx = None;
for (idx, line) in check_lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.len() > 50 {
continue;
}
if (trimmed == "Yes" || trimmed.starts_with("Yes,") || trimmed.starts_with("Yes "))
&& trimmed.len() < 40
{
has_yes = true;
yes_line_idx = Some(idx);
}
if (trimmed == "No" || trimmed.starts_with("No,") || trimmed.starts_with("No "))
&& trimmed.len() < 40
{
has_no = true;
no_line_idx = Some(idx);
}
}
if has_yes && has_no {
if let (Some(y_idx), Some(n_idx)) = (yes_line_idx, no_line_idx) {
let distance = y_idx.abs_diff(n_idx);
return distance <= 4;
}
}
false
}
fn determine_approval_type(&self, content: &str) -> WrapApprovalType {
let recent = if content.len() > 2000 {
let start = content.len() - 2000;
let start = content
.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= start)
.unwrap_or(start);
&content[start..]
} else {
content
};
if self.patterns.file_edit.is_match(recent) {
return WrapApprovalType::FileEdit;
}
if self.patterns.file_create.is_match(recent) {
return WrapApprovalType::FileEdit;
}
if self.patterns.file_delete.is_match(recent) {
return WrapApprovalType::FileEdit;
}
if self.patterns.shell_command.is_match(recent) {
return WrapApprovalType::ShellCommand;
}
if self.patterns.mcp_tool.is_match(recent) {
return WrapApprovalType::McpTool;
}
WrapApprovalType::YesNo
}
fn extract_choices(&self) -> (Vec<String>, bool, usize) {
let lines: Vec<&str> = self.output_buffer.lines().collect();
let check_start = lines.len().saturating_sub(25);
let check_lines = &lines[check_start..];
let mut choices = Vec::new();
let mut multi_select = false;
let mut cursor_position = 0usize;
let mut expected_num = 1u32;
for line in check_lines {
let lower = line.to_lowercase();
if lower.contains("space to") || lower.contains("toggle") || lower.contains("multi") {
multi_select = true;
break;
}
}
if !multi_select {
for line in check_lines {
if let Some(cap) = self.patterns.choice_pattern.captures(line) {
let choice_text = cap[2].trim();
if choice_text.starts_with("[ ]")
|| choice_text.starts_with("[x]")
|| choice_text.starts_with("[X]")
|| choice_text.starts_with("[✔]")
{
multi_select = true;
break;
}
}
}
}
if !multi_select {
for line in check_lines {
let lower = line.to_lowercase();
if lower.contains("複数選択") || lower.contains("enter to select") {
multi_select = true;
break;
}
}
}
for line in check_lines {
if let Some(cap) = self.patterns.choice_pattern.captures(line) {
if let Ok(num) = cap[1].parse::<u32>() {
if num == expected_num {
let choice_text = strip_box_drawing(cap[2].trim());
let label = choice_text
.split('(')
.next()
.unwrap_or(choice_text)
.trim()
.to_string();
choices.push(label);
let trimmed = line.trim();
if trimmed.starts_with('❯')
|| trimmed.starts_with('›')
|| trimmed.starts_with('>')
{
cursor_position = num as usize;
}
expected_num += 1;
} else if num == 1 {
choices.clear();
let choice_text = strip_box_drawing(cap[2].trim());
let label = choice_text
.split('(')
.next()
.unwrap_or(choice_text)
.trim()
.to_string();
choices.push(label);
cursor_position = if line.trim().starts_with('❯')
|| line.trim().starts_with('›')
|| line.trim().starts_with('>')
{
1
} else {
0
};
expected_num = 2;
}
}
}
}
if cursor_position == 0 && !choices.is_empty() {
cursor_position = 1;
}
(choices, multi_select, cursor_position)
}
pub fn clear_buffer(&mut self) {
self.output_buffer.clear();
self.pending_approval = None;
self.pending_approval_at = None;
}
}
fn strip_ansi(input: &str) -> String {
static OSC_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").unwrap());
static CSI_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap());
let without_osc = OSC_RE.replace_all(input, "");
CSI_RE.replace_all(&without_osc, "").to_string()
}
fn instant_to_millis(instant: Instant) -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
let now_instant = Instant::now();
let now_system = SystemTime::now();
let elapsed = now_instant.duration_since(instant);
let system_time = now_system - elapsed;
system_time
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_analyzer_creation() {
let analyzer = Analyzer::new(1234);
assert_eq!(analyzer.pid, 1234);
}
#[test]
fn test_process_output_updates_timestamp() {
let mut analyzer = Analyzer::new(1234);
let before = analyzer.last_output;
std::thread::sleep(std::time::Duration::from_millis(10));
analyzer.process_output("test");
assert!(analyzer.last_output > before);
}
#[test]
fn test_process_input_clears_approval() {
let mut analyzer = Analyzer::new(1234);
analyzer.pending_approval = Some((WrapApprovalType::YesNo, String::new()));
analyzer.pending_approval_at = Some(Instant::now());
analyzer.process_input("y");
assert!(analyzer.pending_approval.is_none());
}
#[test]
fn test_process_input_clears_output_buffer() {
let mut analyzer = Analyzer::new(1234);
analyzer.process_output("some output data");
assert!(!analyzer.output_buffer.is_empty());
analyzer.process_input("y");
assert!(analyzer.output_buffer.is_empty());
}
#[test]
fn test_detect_user_question() {
let mut analyzer = Analyzer::new(1234);
let content = r#"
Which option?
❯ 1. Option A
2. Option B
3. Option C
"#;
analyzer.process_output(content);
assert!(analyzer.detect_user_question(&analyzer.output_buffer));
}
#[test]
fn test_detect_yes_no_buttons() {
let mut analyzer = Analyzer::new(1234);
let content = r#"
Do you want to proceed?
Yes
No
"#;
analyzer.process_output(content);
assert!(analyzer.detect_yes_no_approval(&analyzer.output_buffer));
}
#[test]
fn test_extract_choices() {
let mut analyzer = Analyzer::new(1234);
let content = r#"
Which option?
❯ 1. Option A
2. Option B
3. Option C
"#;
analyzer.process_output(content);
let (choices, multi_select, cursor) = analyzer.extract_choices();
assert_eq!(choices, vec!["Option A", "Option B", "Option C"]);
assert!(!multi_select);
assert_eq!(cursor, 1);
}
#[test]
fn test_simple_yes_no_user_question() {
let mut analyzer = Analyzer::new(1234);
let content = r#" Do you want to proceed?
❯ 1. Yes
2. No"#;
analyzer.process_output(content);
let lines: Vec<&str> = content.lines().collect();
for line in &lines {
let matched = analyzer.patterns.choice_pattern.captures(line);
eprintln!(
"Line: {:?} -> Match: {:?}",
line,
matched.map(|c| c[0].to_string())
);
}
let detected = analyzer.detect_user_question(&analyzer.output_buffer);
assert!(detected, "Should detect as UserQuestion");
}
#[test]
fn test_strip_ansi_removes_color_codes() {
let input = "\x1b[36m❯\x1b[0m 1. Option A";
let result = strip_ansi(input);
assert_eq!(result, "❯ 1. Option A");
}
#[test]
fn test_detect_user_question_with_ansi_colors() {
let mut analyzer = Analyzer::new(1234);
let content = "Which option?\n\n\
\x1b[36m❯\x1b[0m \x1b[36m1.\x1b[0m Option A\n\
\x1b[2m \x1b[0m\x1b[2m2.\x1b[0m Option B\n\
\x1b[2m \x1b[0m\x1b[2m3.\x1b[0m Option C\n";
analyzer.process_output(content);
assert!(
analyzer.detect_user_question(&analyzer.output_buffer),
"Should detect AskUserQuestion even when raw output has ANSI codes"
);
let (choices, _, cursor) = analyzer.extract_choices();
assert_eq!(choices.len(), 3);
assert_eq!(choices[0], "Option A");
assert_eq!(cursor, 1);
}
#[test]
fn test_detect_user_question_with_ansi_yes_no() {
let mut analyzer = Analyzer::new(1234);
let content = " Do you want to proceed?\n\
\x1b[36m ❯\x1b[0m \x1b[36m1.\x1b[0m Yes\n\
\x1b[2m \x1b[0m\x1b[2m2.\x1b[0m No\n";
analyzer.process_output(content);
assert!(
analyzer.detect_user_question(&analyzer.output_buffer),
"Should detect Yes/No UserQuestion with ANSI codes"
);
}
#[test]
fn test_process_output_buffer_is_plain_text() {
let mut analyzer = Analyzer::new(1234);
analyzer.process_output("\x1b[1;32mHello\x1b[0m \x1b[31mWorld\x1b[0m");
assert_eq!(analyzer.output_buffer, "Hello World");
}
#[test]
fn test_detect_multi_select_with_checkboxes() {
let mut analyzer = Analyzer::new(1234);
let content = "Which features do you want to enable?\n\n\
❯ 1. [ ] Feature A\n\
2. [ ] Feature B\n\
3. [ ] Feature C\n\
\n(Press Space to toggle, Enter to submit)\n";
analyzer.process_output(content);
assert!(
analyzer.detect_user_question(&analyzer.output_buffer),
"Should detect multi-select UserQuestion with checkboxes"
);
let (choices, multi_select, cursor) = analyzer.extract_choices();
assert_eq!(choices.len(), 3);
assert!(multi_select, "Should detect multi_select from toggle hint");
assert_eq!(cursor, 1);
}
}