1use once_cell::sync::Lazy;
6use regex::Regex;
7use std::time::{Duration, Instant};
8
9use crate::ipc::protocol::{WrapApprovalType, WrapState};
10
11const PROCESSING_TIMEOUT_MS: u64 = 200; const APPROVAL_SETTLE_MS: u64 = 500; const INPUT_ECHO_GRACE_MS: u64 = 300; pub struct Analyzer {
18 last_output: Instant,
20 last_input: Instant,
22 output_buffer: String,
24 max_buffer_size: usize,
26 pending_approval: Option<(WrapApprovalType, String)>,
28 pending_approval_at: Option<Instant>,
30 pid: u32,
32 patterns: AnalyzerPatterns,
34 team_name: Option<String>,
36 team_member_name: Option<String>,
38 is_team_lead: bool,
40}
41
42struct AnalyzerPatterns {
44 choice_pattern: Regex,
46 #[allow(dead_code)]
48 yes_no_pattern: Regex,
49 general_approval: Regex,
51 file_edit: Regex,
53 file_create: Regex,
54 file_delete: Regex,
55 shell_command: Regex,
57 mcp_tool: Regex,
59}
60
61impl Default for AnalyzerPatterns {
62 fn default() -> Self {
63 Self {
64 choice_pattern: Regex::new(r"^\s*(?:[>❯›]\s*)?(\d+)\.\s+(.+)$")
65 .expect("Invalid choice_pattern"),
66 yes_no_pattern: Regex::new(r"(?i)\b(Yes|No)\b")
67 .expect("Invalid yes_no_pattern"),
68 general_approval: Regex::new(
69 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"
70 ).expect("Invalid general_approval"),
71 file_edit: Regex::new(
72 r"(?i)(Edit|Write|Modify)\s+.*?\?|Do you want to (edit|write|modify)|Allow.*?edit"
73 ).expect("Invalid file_edit"),
74 file_create: Regex::new(
75 r"(?i)Create\s+.*?\?|Do you want to create|Allow.*?create"
76 ).expect("Invalid file_create"),
77 file_delete: Regex::new(
78 r"(?i)Delete\s+.*?\?|Do you want to delete|Allow.*?delete"
79 ).expect("Invalid file_delete"),
80 shell_command: Regex::new(
81 r"(?i)(Run|Execute)\s+(command|bash|shell)|Do you want to run|Allow.*?(command|bash)|run this command"
82 ).expect("Invalid shell_command"),
83 mcp_tool: Regex::new(r"(?i)MCP\s+tool|Do you want to use.*?MCP|Allow.*?MCP")
84 .expect("Invalid mcp_tool"),
85 }
86 }
87}
88
89fn strip_box_drawing(text: &str) -> &str {
92 if let Some(pos) = text.find(|c: char| ('\u{2500}'..='\u{257F}').contains(&c)) {
93 text[..pos].trim()
94 } else {
95 text
96 }
97}
98
99impl Analyzer {
100 pub fn new(pid: u32) -> Self {
102 let team_name = std::env::var("CLAUDE_CODE_TASK_LIST_ID").ok();
104 let team_member_name = std::env::var("CLAUDE_AGENT_NAME").ok();
105 let is_team_lead = team_member_name
106 .as_deref()
107 .map(|n| n == "team-lead" || n == "lead" || n.ends_with("-lead"))
108 .unwrap_or(false);
109
110 let now = Instant::now();
111 Self {
112 last_output: now,
113 last_input: now,
114 output_buffer: String::with_capacity(8192),
115 max_buffer_size: 16384,
116 pending_approval: None,
117 pending_approval_at: None,
118 pid,
119 patterns: AnalyzerPatterns::default(),
120 team_name,
121 team_member_name,
122 is_team_lead,
123 }
124 }
125
126 pub fn team_name(&self) -> Option<&String> {
128 self.team_name.as_ref()
129 }
130
131 pub fn team_member_name(&self) -> Option<&String> {
133 self.team_member_name.as_ref()
134 }
135
136 pub fn is_team_lead(&self) -> bool {
138 self.is_team_lead
139 }
140
141 pub fn process_output(&mut self, data: &str) {
143 self.last_output = Instant::now();
144
145 let clean = strip_ansi(data);
149
150 self.output_buffer.push_str(&clean);
152 if self.output_buffer.len() > self.max_buffer_size {
153 let drain_to = self.output_buffer.len() - self.max_buffer_size / 2;
154 let drain_to = self
156 .output_buffer
157 .char_indices()
158 .map(|(i, _)| i)
159 .find(|&i| i >= drain_to)
160 .unwrap_or(drain_to);
161 self.output_buffer.drain(..drain_to);
162 }
163
164 self.detect_approval_pattern();
166 }
167
168 pub fn process_input(&mut self, _data: &str) {
173 self.last_input = Instant::now();
174 self.pending_approval = None;
176 self.pending_approval_at = None;
177 self.output_buffer.clear();
179 }
180
181 pub fn get_state(&self) -> WrapState {
183 let now = Instant::now();
184 let since_output = now.duration_since(self.last_output);
185 let since_input = now.duration_since(self.last_input);
186
187 let mut state;
188
189 let in_echo_grace = since_input < Duration::from_millis(INPUT_ECHO_GRACE_MS);
192 if since_output < Duration::from_millis(PROCESSING_TIMEOUT_MS) && !in_echo_grace {
193 state = WrapState::processing(self.pid);
194 } else if let Some((ref approval_type, ref details)) = self.pending_approval {
195 if let Some(detected_at) = self.pending_approval_at {
197 let since_detected = now.duration_since(detected_at);
198 if since_detected >= Duration::from_millis(APPROVAL_SETTLE_MS) {
199 state = match approval_type {
201 WrapApprovalType::UserQuestion => {
202 let (choices, multi_select, cursor_pos) = self.extract_choices();
203 WrapState::user_question(self.pid, choices, multi_select, cursor_pos)
204 }
205 _ => WrapState::awaiting_approval(
206 self.pid,
207 approval_type.clone(),
208 Some(details.clone()),
209 ),
210 };
211 } else {
212 state = WrapState::processing(self.pid);
214 }
215 } else {
216 state = WrapState::idle(self.pid);
217 }
218 } else {
219 state = WrapState::idle(self.pid);
221 state.last_output = instant_to_millis(self.last_output);
222 state.last_input = instant_to_millis(self.last_input);
223 }
224
225 state.team_name = self.team_name.clone();
227 state.team_member_name = self.team_member_name.clone();
228 state.is_team_lead = self.is_team_lead;
229
230 state
231 }
232
233 fn detect_approval_pattern(&mut self) {
235 let content = &self.output_buffer;
236
237 if self.detect_user_question(content) {
239 if self.pending_approval.is_none()
240 || !matches!(
241 self.pending_approval,
242 Some((WrapApprovalType::UserQuestion, _))
243 )
244 {
245 self.pending_approval = Some((WrapApprovalType::UserQuestion, String::new()));
246 self.pending_approval_at = Some(Instant::now());
247 }
248 return;
249 }
250
251 if self.detect_proceed_prompt(content) {
253 if self.pending_approval.is_none()
254 || !matches!(
255 self.pending_approval,
256 Some((WrapApprovalType::UserQuestion, _))
257 )
258 {
259 self.pending_approval = Some((WrapApprovalType::UserQuestion, String::new()));
260 self.pending_approval_at = Some(Instant::now());
261 }
262 return;
263 }
264
265 if self.detect_yes_no_approval(content) {
267 let approval_type = self.determine_approval_type(content);
268 if self.pending_approval.is_none() {
269 self.pending_approval = Some((approval_type, String::new()));
270 self.pending_approval_at = Some(Instant::now());
271 }
272 return;
273 }
274
275 if self.patterns.general_approval.is_match(content) {
277 let lines: Vec<&str> = content.lines().collect();
278 if let Some(last_few) = lines.get(lines.len().saturating_sub(10)..) {
279 let recent = last_few.join("\n");
280 if self.patterns.general_approval.is_match(&recent) {
281 let approval_type = self.determine_approval_type(content);
282 if self.pending_approval.is_none() {
283 self.pending_approval = Some((approval_type, String::new()));
284 self.pending_approval_at = Some(Instant::now());
285 }
286 return;
287 }
288 }
289 }
290
291 self.pending_approval = None;
293 self.pending_approval_at = None;
294 }
295
296 fn detect_user_question(&self, content: &str) -> bool {
298 let lines: Vec<&str> = content.lines().collect();
299 if lines.len() < 3 {
300 return false;
301 }
302
303 let separator_indices: Vec<usize> = lines
306 .iter()
307 .enumerate()
308 .rev()
309 .filter(|(_, line)| {
310 let trimmed = line.trim();
311 trimmed.len() >= 10 && trimmed.chars().all(|c| c == '─')
312 })
313 .map(|(i, _)| i)
314 .take(2)
315 .collect();
316
317 let check_lines =
318 if separator_indices.len() == 2 && separator_indices[0] > separator_indices[1] + 1 {
319 &lines[separator_indices[1] + 1..separator_indices[0]]
320 } else {
321 let check_start = lines.len().saturating_sub(25);
323 &lines[check_start..]
324 };
325
326 let mut consecutive_choices = 0;
327 let mut has_cursor = false;
328 let mut expected_num = 1u32;
329
330 for line in check_lines {
331 if let Some(cap) = self.patterns.choice_pattern.captures(line) {
332 if let Ok(num) = cap[1].parse::<u32>() {
333 if num == expected_num {
334 consecutive_choices += 1;
335 expected_num += 1;
336
337 let trimmed = line.trim();
339 if trimmed.starts_with('❯')
340 || trimmed.starts_with('›')
341 || trimmed.starts_with('>')
342 {
343 has_cursor = true;
344 }
345 } else if num == 1 {
346 consecutive_choices = 1;
348 expected_num = 2;
349 has_cursor = line.trim().starts_with('❯')
350 || line.trim().starts_with('›')
351 || line.trim().starts_with('>');
352 }
353 }
354 }
355 }
356
357 consecutive_choices >= 2 && has_cursor
359 }
360
361 fn detect_proceed_prompt(&self, content: &str) -> bool {
369 let lines: Vec<&str> = content.lines().collect();
370 let check_start = lines.len().saturating_sub(15);
371 let check_lines = &lines[check_start..];
372
373 let mut has_yes = false;
374 let mut has_no = false;
375
376 for line in check_lines {
377 let trimmed = line.trim();
378 if trimmed.contains("1.") && trimmed.contains("Yes") {
379 has_yes = true;
380 }
381 if (trimmed.contains("2. No") || trimmed.contains("3. No")) && trimmed.len() < 20 {
382 has_no = true;
383 }
384 }
385
386 has_yes && has_no
387 }
388
389 fn detect_yes_no_approval(&self, content: &str) -> bool {
391 let lines: Vec<&str> = content.lines().collect();
392 if lines.len() < 2 {
393 return false;
394 }
395
396 let check_start = lines.len().saturating_sub(8);
397 let check_lines = &lines[check_start..];
398
399 let mut has_yes = false;
400 let mut has_no = false;
401 let mut yes_line_idx = None;
402 let mut no_line_idx = None;
403
404 for (idx, line) in check_lines.iter().enumerate() {
405 let trimmed = line.trim();
406 if trimmed.is_empty() || trimmed.len() > 50 {
407 continue;
408 }
409
410 if (trimmed == "Yes" || trimmed.starts_with("Yes,") || trimmed.starts_with("Yes "))
411 && trimmed.len() < 40
412 {
413 has_yes = true;
414 yes_line_idx = Some(idx);
415 }
416
417 if (trimmed == "No" || trimmed.starts_with("No,") || trimmed.starts_with("No "))
418 && trimmed.len() < 40
419 {
420 has_no = true;
421 no_line_idx = Some(idx);
422 }
423 }
424
425 if has_yes && has_no {
427 if let (Some(y_idx), Some(n_idx)) = (yes_line_idx, no_line_idx) {
428 let distance = y_idx.abs_diff(n_idx);
429 return distance <= 4;
430 }
431 }
432
433 false
434 }
435
436 fn determine_approval_type(&self, content: &str) -> WrapApprovalType {
442 let recent = if content.len() > 2000 {
444 let start = content.len() - 2000;
445 let start = content
447 .char_indices()
448 .map(|(i, _)| i)
449 .find(|&i| i >= start)
450 .unwrap_or(start);
451 &content[start..]
452 } else {
453 content
454 };
455
456 if self.patterns.file_edit.is_match(recent) {
457 return WrapApprovalType::FileEdit;
458 }
459 if self.patterns.file_create.is_match(recent) {
461 return WrapApprovalType::FileEdit;
462 }
463 if self.patterns.file_delete.is_match(recent) {
464 return WrapApprovalType::FileEdit;
465 }
466 if self.patterns.shell_command.is_match(recent) {
467 return WrapApprovalType::ShellCommand;
468 }
469 if self.patterns.mcp_tool.is_match(recent) {
470 return WrapApprovalType::McpTool;
471 }
472
473 WrapApprovalType::YesNo
474 }
475
476 fn extract_choices(&self) -> (Vec<String>, bool, usize) {
478 let lines: Vec<&str> = self.output_buffer.lines().collect();
479 let check_start = lines.len().saturating_sub(25);
480 let check_lines = &lines[check_start..];
481
482 let mut choices = Vec::new();
483 let mut multi_select = false;
484 let mut cursor_position = 0usize;
485 let mut expected_num = 1u32;
486
487 for line in check_lines {
489 let lower = line.to_lowercase();
490 if lower.contains("space to") || lower.contains("toggle") || lower.contains("multi") {
491 multi_select = true;
492 break;
493 }
494 }
495
496 if !multi_select {
498 for line in check_lines {
499 if let Some(cap) = self.patterns.choice_pattern.captures(line) {
500 let choice_text = cap[2].trim();
501 if choice_text.starts_with("[ ]")
502 || choice_text.starts_with("[x]")
503 || choice_text.starts_with("[X]")
504 || choice_text.starts_with("[✔]")
505 {
506 multi_select = true;
507 break;
508 }
509 }
510 }
511 }
512
513 if !multi_select {
514 for line in check_lines {
515 let lower = line.to_lowercase();
516 if lower.contains("複数選択") || lower.contains("enter to select") {
517 multi_select = true;
518 break;
519 }
520 }
521 }
522
523 for line in check_lines {
525 if let Some(cap) = self.patterns.choice_pattern.captures(line) {
526 if let Ok(num) = cap[1].parse::<u32>() {
527 if num == expected_num {
528 let choice_text = strip_box_drawing(cap[2].trim());
530 let label = choice_text
532 .split('(')
533 .next()
534 .unwrap_or(choice_text)
535 .trim()
536 .to_string();
537 choices.push(label);
538
539 let trimmed = line.trim();
541 if trimmed.starts_with('❯')
542 || trimmed.starts_with('›')
543 || trimmed.starts_with('>')
544 {
545 cursor_position = num as usize;
546 }
547
548 expected_num += 1;
549 } else if num == 1 {
550 choices.clear();
552 let choice_text = strip_box_drawing(cap[2].trim());
554 let label = choice_text
555 .split('(')
556 .next()
557 .unwrap_or(choice_text)
558 .trim()
559 .to_string();
560 choices.push(label);
561 cursor_position = if line.trim().starts_with('❯')
562 || line.trim().starts_with('›')
563 || line.trim().starts_with('>')
564 {
565 1
566 } else {
567 0
568 };
569 expected_num = 2;
570 }
571 }
572 }
573 }
574
575 if cursor_position == 0 && !choices.is_empty() {
577 cursor_position = 1;
578 }
579
580 (choices, multi_select, cursor_position)
581 }
582
583 pub fn clear_buffer(&mut self) {
585 self.output_buffer.clear();
586 self.pending_approval = None;
587 self.pending_approval_at = None;
588 }
589}
590
591fn strip_ansi(input: &str) -> String {
593 static OSC_RE: Lazy<Regex> =
594 Lazy::new(|| Regex::new(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").unwrap());
595 static CSI_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap());
596
597 let without_osc = OSC_RE.replace_all(input, "");
598 CSI_RE.replace_all(&without_osc, "").to_string()
599}
600
601fn instant_to_millis(instant: Instant) -> u64 {
603 use std::time::{SystemTime, UNIX_EPOCH};
604 let now_instant = Instant::now();
605 let now_system = SystemTime::now();
606 let elapsed = now_instant.duration_since(instant);
607 let system_time = now_system - elapsed;
608 system_time
609 .duration_since(UNIX_EPOCH)
610 .unwrap_or_default()
611 .as_millis() as u64
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn test_analyzer_creation() {
620 let analyzer = Analyzer::new(1234);
621 assert_eq!(analyzer.pid, 1234);
622 }
623
624 #[test]
625 fn test_process_output_updates_timestamp() {
626 let mut analyzer = Analyzer::new(1234);
627 let before = analyzer.last_output;
628 std::thread::sleep(std::time::Duration::from_millis(10));
629 analyzer.process_output("test");
630 assert!(analyzer.last_output > before);
631 }
632
633 #[test]
634 fn test_process_input_clears_approval() {
635 let mut analyzer = Analyzer::new(1234);
636 analyzer.pending_approval = Some((WrapApprovalType::YesNo, String::new()));
637 analyzer.pending_approval_at = Some(Instant::now());
638 analyzer.process_input("y");
639 assert!(analyzer.pending_approval.is_none());
640 }
641
642 #[test]
643 fn test_process_input_clears_output_buffer() {
644 let mut analyzer = Analyzer::new(1234);
645 analyzer.process_output("some output data");
646 assert!(!analyzer.output_buffer.is_empty());
647 analyzer.process_input("y");
648 assert!(analyzer.output_buffer.is_empty());
649 }
650
651 #[test]
652 fn test_detect_user_question() {
653 let mut analyzer = Analyzer::new(1234);
654 let content = r#"
655Which option?
656
657❯ 1. Option A
658 2. Option B
659 3. Option C
660"#;
661 analyzer.process_output(content);
662 assert!(analyzer.detect_user_question(&analyzer.output_buffer));
663 }
664
665 #[test]
666 fn test_detect_yes_no_buttons() {
667 let mut analyzer = Analyzer::new(1234);
668 let content = r#"
669Do you want to proceed?
670
671 Yes
672 No
673"#;
674 analyzer.process_output(content);
675 assert!(analyzer.detect_yes_no_approval(&analyzer.output_buffer));
676 }
677
678 #[test]
679 fn test_extract_choices() {
680 let mut analyzer = Analyzer::new(1234);
681 let content = r#"
682Which option?
683
684❯ 1. Option A
685 2. Option B
686 3. Option C
687"#;
688 analyzer.process_output(content);
689 let (choices, multi_select, cursor) = analyzer.extract_choices();
690 assert_eq!(choices, vec!["Option A", "Option B", "Option C"]);
691 assert!(!multi_select);
692 assert_eq!(cursor, 1);
693 }
694
695 #[test]
696 fn test_simple_yes_no_user_question() {
697 let mut analyzer = Analyzer::new(1234);
698 let content = r#" Do you want to proceed?
700 ❯ 1. Yes
701 2. No"#;
702 analyzer.process_output(content);
703
704 let lines: Vec<&str> = content.lines().collect();
706 for line in &lines {
707 let matched = analyzer.patterns.choice_pattern.captures(line);
708 eprintln!(
709 "Line: {:?} -> Match: {:?}",
710 line,
711 matched.map(|c| c[0].to_string())
712 );
713 }
714
715 let detected = analyzer.detect_user_question(&analyzer.output_buffer);
716 assert!(detected, "Should detect as UserQuestion");
717 }
718
719 #[test]
720 fn test_strip_ansi_removes_color_codes() {
721 let input = "\x1b[36m❯\x1b[0m 1. Option A";
722 let result = strip_ansi(input);
723 assert_eq!(result, "❯ 1. Option A");
724 }
725
726 #[test]
727 fn test_detect_user_question_with_ansi_colors() {
728 let mut analyzer = Analyzer::new(1234);
729 let content = "Which option?\n\n\
731 \x1b[36m❯\x1b[0m \x1b[36m1.\x1b[0m Option A\n\
732 \x1b[2m \x1b[0m\x1b[2m2.\x1b[0m Option B\n\
733 \x1b[2m \x1b[0m\x1b[2m3.\x1b[0m Option C\n";
734 analyzer.process_output(content);
735
736 assert!(
738 analyzer.detect_user_question(&analyzer.output_buffer),
739 "Should detect AskUserQuestion even when raw output has ANSI codes"
740 );
741
742 let (choices, _, cursor) = analyzer.extract_choices();
743 assert_eq!(choices.len(), 3);
744 assert_eq!(choices[0], "Option A");
745 assert_eq!(cursor, 1);
746 }
747
748 #[test]
749 fn test_detect_user_question_with_ansi_yes_no() {
750 let mut analyzer = Analyzer::new(1234);
751 let content = " Do you want to proceed?\n\
753 \x1b[36m ❯\x1b[0m \x1b[36m1.\x1b[0m Yes\n\
754 \x1b[2m \x1b[0m\x1b[2m2.\x1b[0m No\n";
755 analyzer.process_output(content);
756
757 assert!(
758 analyzer.detect_user_question(&analyzer.output_buffer),
759 "Should detect Yes/No UserQuestion with ANSI codes"
760 );
761 }
762
763 #[test]
764 fn test_process_output_buffer_is_plain_text() {
765 let mut analyzer = Analyzer::new(1234);
766 analyzer.process_output("\x1b[1;32mHello\x1b[0m \x1b[31mWorld\x1b[0m");
767 assert_eq!(analyzer.output_buffer, "Hello World");
768 }
769
770 #[test]
771 fn test_detect_multi_select_with_checkboxes() {
772 let mut analyzer = Analyzer::new(1234);
773 let content = "Which features do you want to enable?\n\n\
775 ❯ 1. [ ] Feature A\n\
776 2. [ ] Feature B\n\
777 3. [ ] Feature C\n\
778 \n(Press Space to toggle, Enter to submit)\n";
779 analyzer.process_output(content);
780
781 assert!(
782 analyzer.detect_user_question(&analyzer.output_buffer),
783 "Should detect multi-select UserQuestion with checkboxes"
784 );
785
786 let (choices, multi_select, cursor) = analyzer.extract_choices();
787 assert_eq!(choices.len(), 3);
788 assert!(multi_select, "Should detect multi_select from toggle hint");
789 assert_eq!(cursor, 1);
790 }
791}