Skip to main content

tmai_core/wrap/
analyzer.rs

1//! Output analyzer for PTY wrapper
2//!
3//! Analyzes agent output to determine current state.
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7use std::time::{Duration, Instant};
8
9use crate::ipc::protocol::{WrapApprovalType, WrapState};
10
11/// Thresholds for state detection
12const PROCESSING_TIMEOUT_MS: u64 = 200; // Output within this time = Processing
13const APPROVAL_SETTLE_MS: u64 = 500; // Wait this long after output stops before declaring Approval
14const INPUT_ECHO_GRACE_MS: u64 = 300; // After input, ignore output for this duration (echo suppression)
15
16/// Analyzer for PTY output
17pub struct Analyzer {
18    /// Last output timestamp
19    last_output: Instant,
20    /// Last input timestamp
21    last_input: Instant,
22    /// Recent output buffer (for pattern matching)
23    output_buffer: String,
24    /// Maximum buffer size
25    max_buffer_size: usize,
26    /// Current detected approval type (if any)
27    pending_approval: Option<(WrapApprovalType, String)>,
28    /// When pending approval was first detected
29    pending_approval_at: Option<Instant>,
30    /// Process ID
31    pid: u32,
32    /// Compiled patterns
33    patterns: AnalyzerPatterns,
34    /// Team name (if this agent is part of a team)
35    team_name: Option<String>,
36    /// Team member name
37    team_member_name: Option<String>,
38    /// Whether this agent is the team lead
39    is_team_lead: bool,
40}
41
42/// Pre-compiled regex patterns
43struct AnalyzerPatterns {
44    /// Numbered choice pattern: "1. Option" or "> 1. Option" or "❯ 1. Option"
45    choice_pattern: Regex,
46    /// Yes/No button pattern (kept for future use)
47    #[allow(dead_code)]
48    yes_no_pattern: Regex,
49    /// General approval pattern [y/n], etc.
50    general_approval: Regex,
51    /// File edit/create/delete patterns
52    file_edit: Regex,
53    file_create: Regex,
54    file_delete: Regex,
55    /// Shell command pattern
56    shell_command: Regex,
57    /// MCP tool pattern
58    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
89/// Strip box-drawing characters (U+2500-U+257F) and everything after them from choice text.
90/// Handles preview box borders like │, ┌, ┐, └, ┘, etc.
91fn 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    /// Create a new analyzer
101    pub fn new(pid: u32) -> Self {
102        // Detect team info from environment variables
103        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    /// Get team name
127    pub fn team_name(&self) -> Option<&String> {
128        self.team_name.as_ref()
129    }
130
131    /// Get team member name
132    pub fn team_member_name(&self) -> Option<&String> {
133        self.team_member_name.as_ref()
134    }
135
136    /// Whether this agent is the team lead
137    pub fn is_team_lead(&self) -> bool {
138        self.is_team_lead
139    }
140
141    /// Process output data
142    pub fn process_output(&mut self, data: &str) {
143        self.last_output = Instant::now();
144
145        // Strip ANSI escape codes before buffering for clean pattern matching.
146        // PTY output from ink-based CLIs (e.g. Claude Code) includes color codes
147        // like \x1b[36m❯\x1b[0m that break regex patterns.
148        let clean = strip_ansi(data);
149
150        // Append to buffer, truncating old data if necessary
151        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            // Find UTF-8 boundary
155            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        // Check for approval patterns
165        self.detect_approval_pattern();
166    }
167
168    /// Process input data
169    ///
170    /// Clears pending approval and output buffer to prevent re-triggering
171    /// approval detection from stale output after user input.
172    pub fn process_input(&mut self, _data: &str) {
173        self.last_input = Instant::now();
174        // Clear pending approval on input (user responded)
175        self.pending_approval = None;
176        self.pending_approval_at = None;
177        // Clear output buffer to prevent re-detecting approval from old output
178        self.output_buffer.clear();
179    }
180
181    /// Get current state
182    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        // If output is still flowing, we're processing
190        // But skip if we're in the input echo grace period (output is likely just echo)
191        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            // Check for approval that has settled
196            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                    // Approval has settled, return it
200                    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                    // Still settling, show as processing
213                    state = WrapState::processing(self.pid);
214                }
215            } else {
216                state = WrapState::idle(self.pid);
217            }
218        } else {
219            // No approval detected, output stopped - we're idle
220            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        // Apply team information
226        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    /// Detect approval patterns in the output buffer
234    fn detect_approval_pattern(&mut self) {
235        let content = &self.output_buffer;
236
237        // Check for AskUserQuestion first (highest priority)
238        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        // Check for proceed-prompt style (numbered Yes/No without cursor)
252        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        // Check for Yes/No buttons
266        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        // Check for general approval patterns
276        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        // No approval pattern found
292        self.pending_approval = None;
293        self.pending_approval_at = None;
294    }
295
296    /// Detect AskUserQuestion with numbered choices
297    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        // Use horizontal separator lines (───) as boundaries when available.
304        // Claude Code's TUI encloses the input area between two ─── separators.
305        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                // Fallback: window-based approach
322                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                        // Check for cursor marker
338                        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                        // New choice set
347                        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        // Need at least 2 choices with cursor marker
358        consecutive_choices >= 2 && has_cursor
359    }
360
361    /// Detect proceed-prompt style approval (numbered Yes/No without cursor marker)
362    ///
363    /// Catches cases where `detect_user_question` misses due to missing ❯ cursor,
364    /// allowing number key navigation for 3-choice approvals like:
365    ///   1. Yes
366    ///   2. Yes, and don't ask again for ...
367    ///   3. No
368    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    /// Detect Yes/No button-style approval
390    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        // Both Yes and No must be present and close together
426        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    /// Determine the type of approval being requested
437    ///
438    /// Note: File create and delete operations are intentionally classified as `FileEdit`
439    /// for UI simplicity. The distinction between edit/create/delete is not significant
440    /// for user interaction - all require the same y/n approval flow.
441    fn determine_approval_type(&self, content: &str) -> WrapApprovalType {
442        // Get recent content for matching (respecting UTF-8 boundaries)
443        let recent = if content.len() > 2000 {
444            let start = content.len() - 2000;
445            // Find UTF-8 character boundary
446            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        // File create/delete are intentionally grouped with FileEdit for UI consistency
460        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    /// Extract choices from AskUserQuestion
477    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        // Check for multi-select indicators
488        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        // [ ] checkbox format detection
497        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        // Extract choices
524        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                        // Strip preview box content (box-drawing chars) before extracting label
529                        let choice_text = strip_box_drawing(cap[2].trim());
530                        // Strip Japanese description in parentheses
531                        let label = choice_text
532                            .split('(')
533                            .next()
534                            .unwrap_or(choice_text)
535                            .trim()
536                            .to_string();
537                        choices.push(label);
538
539                        // Check for cursor
540                        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                        // New choice set, start over
551                        choices.clear();
552                        // Strip preview box content (box-drawing chars) before extracting label
553                        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        // Default cursor to 1 if not detected
576        if cursor_position == 0 && !choices.is_empty() {
577            cursor_position = 1;
578        }
579
580        (choices, multi_select, cursor_position)
581    }
582
583    /// Clear the output buffer
584    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
591/// Strip ANSI escape sequences (OSC and CSI) from text
592fn 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
601/// Convert Instant to Unix milliseconds (approximate)
602fn 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        // Exact format reported by user
699        let content = r#" Do you want to proceed?
700 ❯ 1. Yes
701   2. No"#;
702        analyzer.process_output(content);
703
704        // Debug: print choice pattern match
705        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        // Simulates ink (Claude Code) output with ANSI color codes
730        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        // ANSI codes should be stripped by process_output, so detection works
737        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        // Yes/No prompt with ANSI codes (common Claude Code approval)
752        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        // Multi-select format with [ ] checkboxes (Claude Code AskUserQuestion)
774        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}