Skip to main content

tmai_core/detectors/
claude_code.rs

1use regex::Regex;
2use tracing::trace;
3
4use crate::agents::{AgentMode, AgentStatus, AgentType, ApprovalType};
5use crate::config::SpinnerVerbsMode;
6
7use super::{DetectionConfidence, DetectionContext, DetectionResult, StatusDetector};
8
9/// Idle indicator - ✳ appears when Claude Code is waiting for input
10const IDLE_INDICATOR: char = '✳';
11
12/// Processing spinner characters used in terminal title
13///
14/// Claude Code uses only ⠂ (U+2802) and ⠐ (U+2810) as title spinners.
15/// The remaining Braille/circle patterns are kept for compatibility with
16/// other agents or future changes, but are not used by Claude Code v2.1.39.
17const PROCESSING_SPINNERS: &[char] = &[
18    // Claude Code actual spinners (2 frames, 960ms interval)
19    '⠂', '⠐', // Legacy Braille patterns (kept for other agents / future compatibility)
20    '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⠿', '⠾', '⠽', '⠻', '⠟', '⠯', '⠷', '⠳', '⠱',
21    '⠰', // Circle spinners
22    '◐', '◓', '◑', '◒',
23];
24
25/// Past-tense verbs used in turn duration display (e.g., "✻ Cooked for 1m 6s")
26/// These indicate a completed turn and should be detected as Idle.
27const TURN_DURATION_VERBS: &[&str] = &[
28    "Baked",
29    "Brewed",
30    "Churned",
31    "Cogitated",
32    "Cooked",
33    "Crunched",
34    "Sautéed",
35    "Worked",
36];
37
38/// Content-area spinner characters (decorative asterisks used by Claude Code)
39/// These appear in content as "✶ Spinning…", "✻ Working…", "✢ Thinking…", etc.
40/// Claude Code animates through these characters, so all variants must be covered.
41/// The full rotation includes: ✶ ✻ ✽ ✢ · * ✳ (and possibly ✹ ✧)
42/// Note: ✳ is also the IDLE_INDICATOR in title, but in content it appears as a
43/// spinner char (macOS/Ghostty). The detect_content_spinner() function requires
44/// uppercase verb + ellipsis after the char, so title-based idle detection is unaffected.
45const CONTENT_SPINNER_CHARS: &[char] = &['✶', '✻', '✽', '✹', '✧', '✢', '·', '✳'];
46
47/// Built-in spinner verbs used by Claude Code (v2.1.41, 185 verbs)
48///
49/// These are the default verbs that appear in content spinners like "✶ Spinning…".
50/// When a verb from this list is detected, confidence is elevated to High.
51/// Custom verbs from settings remain at Medium confidence.
52const BUILTIN_SPINNER_VERBS: &[&str] = &[
53    "Accomplishing",
54    "Actioning",
55    "Actualizing",
56    "Architecting",
57    "Baking",
58    "Beaming",
59    "Beboppin'",
60    "Befuddling",
61    "Billowing",
62    "Blanching",
63    "Bloviating",
64    "Boogieing",
65    "Boondoggling",
66    "Booping",
67    "Bootstrapping",
68    "Brewing",
69    "Burrowing",
70    "Calculating",
71    "Canoodling",
72    "Caramelizing",
73    "Cascading",
74    "Catapulting",
75    "Cerebrating",
76    "Channeling",
77    "Channelling",
78    "Choreographing",
79    "Churning",
80    "Clauding",
81    "Coalescing",
82    "Cogitating",
83    "Combobulating",
84    "Composing",
85    "Computing",
86    "Concocting",
87    "Considering",
88    "Contemplating",
89    "Cooking",
90    "Crafting",
91    "Creating",
92    "Crunching",
93    "Crystallizing",
94    "Cultivating",
95    "Deciphering",
96    "Deliberating",
97    "Determining",
98    "Dilly-dallying",
99    "Discombobulating",
100    "Doing",
101    "Doodling",
102    "Drizzling",
103    "Ebbing",
104    "Effecting",
105    "Elucidating",
106    "Embellishing",
107    "Enchanting",
108    "Envisioning",
109    "Evaporating",
110    "Fermenting",
111    "Fiddle-faddling",
112    "Finagling",
113    "Flambéing",
114    "Flibbertigibbeting",
115    "Flowing",
116    "Flummoxing",
117    "Fluttering",
118    "Forging",
119    "Forming",
120    "Frolicking",
121    "Frosting",
122    "Gallivanting",
123    "Galloping",
124    "Garnishing",
125    "Generating",
126    "Germinating",
127    "Gitifying",
128    "Grooving",
129    "Gusting",
130    "Harmonizing",
131    "Hashing",
132    "Hatching",
133    "Herding",
134    "Honking",
135    "Hullaballooing",
136    "Hyperspacing",
137    "Ideating",
138    "Imagining",
139    "Improvising",
140    "Incubating",
141    "Inferring",
142    "Infusing",
143    "Ionizing",
144    "Jitterbugging",
145    "Julienning",
146    "Kneading",
147    "Leavening",
148    "Levitating",
149    "Lollygagging",
150    "Manifesting",
151    "Marinating",
152    "Meandering",
153    "Metamorphosing",
154    "Misting",
155    "Moonwalking",
156    "Moseying",
157    "Mulling",
158    "Mustering",
159    "Musing",
160    "Nebulizing",
161    "Nesting",
162    "Newspapering",
163    "Noodling",
164    "Nucleating",
165    "Orbiting",
166    "Orchestrating",
167    "Osmosing",
168    "Perambulating",
169    "Percolating",
170    "Perusing",
171    "Philosophising",
172    "Photosynthesizing",
173    "Pollinating",
174    "Pondering",
175    "Pontificating",
176    "Pouncing",
177    "Precipitating",
178    "Prestidigitating",
179    "Processing",
180    "Proofing",
181    "Propagating",
182    "Puttering",
183    "Puzzling",
184    "Quantumizing",
185    "Razzle-dazzling",
186    "Razzmatazzing",
187    "Recombobulating",
188    "Reticulating",
189    "Roosting",
190    "Ruminating",
191    "Sautéing",
192    "Scampering",
193    "Schlepping",
194    "Scurrying",
195    "Seasoning",
196    "Shenaniganing",
197    "Shimmying",
198    "Simmering",
199    "Skedaddling",
200    "Sketching",
201    "Slithering",
202    "Smooshing",
203    "Sock-hopping",
204    "Spelunking",
205    "Spinning",
206    "Sprouting",
207    "Stewing",
208    "Sublimating",
209    "Swirling",
210    "Swooping",
211    "Symbioting",
212    "Synthesizing",
213    "Tempering",
214    "Thinking",
215    "Thundering",
216    "Tinkering",
217    "Tomfoolering",
218    "Topsy-turvying",
219    "Transfiguring",
220    "Transmuting",
221    "Twisting",
222    "Undulating",
223    "Unfurling",
224    "Unravelling",
225    "Vibing",
226    "Waddling",
227    "Wandering",
228    "Warping",
229    "Whatchamacalliting",
230    "Whirlpooling",
231    "Whirring",
232    "Whisking",
233    "Wibbling",
234    "Working",
235    "Wrangling",
236    "Zesting",
237    "Zigzagging",
238];
239
240/// Detector for Claude Code CLI
241pub struct ClaudeCodeDetector {
242    // Approval patterns
243    file_edit_pattern: Regex,
244    file_create_pattern: Regex,
245    file_delete_pattern: Regex,
246    bash_pattern: Regex,
247    mcp_pattern: Regex,
248    general_approval_pattern: Regex,
249    // Choice pattern for AskUserQuestion
250    choice_pattern: Regex,
251    // Error patterns
252    error_pattern: Regex,
253}
254
255/// Strip box-drawing characters (U+2500-U+257F) and everything after them from choice text.
256/// Handles preview box borders like │, ┌, ┐, └, ┘, etc.
257fn strip_box_drawing(text: &str) -> &str {
258    if let Some(pos) = text.find(|c: char| ('\u{2500}'..='\u{257F}').contains(&c)) {
259        text[..pos].trim()
260    } else {
261        text
262    }
263}
264
265impl ClaudeCodeDetector {
266    pub fn new() -> Self {
267        Self {
268            file_edit_pattern: Regex::new(
269                r"(?i)(Edit|Write|Modify)\s+.*?\?|Do you want to (edit|write|modify)|Allow.*?edit",
270            )
271            .expect("Invalid file_edit_pattern regex"),
272            file_create_pattern: Regex::new(
273                r"(?i)Create\s+.*?\?|Do you want to create|Allow.*?create",
274            )
275            .expect("Invalid file_create_pattern regex"),
276            file_delete_pattern: Regex::new(
277                r"(?i)Delete\s+.*?\?|Do you want to delete|Allow.*?delete",
278            )
279            .expect("Invalid file_delete_pattern regex"),
280            bash_pattern: Regex::new(
281                r"(?i)(Run|Execute)\s+(command|bash|shell)|Do you want to run|Allow.*?(command|bash)|run this command",
282            )
283            .expect("Invalid bash_pattern regex"),
284            mcp_pattern: Regex::new(r"(?i)MCP\s+tool|Do you want to use.*?MCP|Allow.*?MCP")
285                .expect("Invalid mcp_pattern regex"),
286            general_approval_pattern: Regex::new(
287                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)",
288            )
289            .expect("Invalid general_approval_pattern regex"),
290            // Choice pattern: handles "> 1. Option" or "  1. Option" or "❯ 1. Option" or "› 1. Option"
291            choice_pattern: Regex::new(r"^\s*(?:[>❯›]\s*)?(\d+)\.\s+(.+)$")
292                .expect("Invalid choice_pattern regex"),
293            error_pattern: Regex::new(r"(?i)(?:^|\n)\s*(?:Error|ERROR|error:|✗|❌)")
294                .expect("Invalid error_pattern regex"),
295        }
296    }
297
298    /// Check if a line is a horizontal separator (─── pattern)
299    /// Claude Code's TUI uses these to delimit the input area.
300    fn is_horizontal_separator(line: &str) -> bool {
301        let trimmed = line.trim();
302        // Must be long enough to be a real separator (not a short dash)
303        trimmed.len() >= 10 && trimmed.chars().all(|c| c == '─')
304    }
305
306    /// Detect AskUserQuestion with numbered choices
307    fn detect_user_question(&self, content: &str) -> Option<(ApprovalType, String)> {
308        let lines: Vec<&str> = content.lines().collect();
309        if lines.is_empty() {
310            return None;
311        }
312
313        // Strip trailing empty lines (tmux capture-pane pads with blank lines)
314        let effective_len = lines
315            .iter()
316            .rposition(|line| !line.trim().is_empty())
317            .map(|i| i + 1)
318            .unwrap_or(lines.len());
319        let lines = &lines[..effective_len];
320
321        // Strategy: Use horizontal separator lines (───) as boundaries.
322        // Claude Code's TUI encloses the input area between two ─── separators.
323        // When AskUserQuestion is displayed, choices appear between these separators.
324        // This is robust regardless of preview box size.
325        let separator_indices: Vec<usize> = lines
326            .iter()
327            .enumerate()
328            .rev()
329            .filter(|(_, line)| Self::is_horizontal_separator(line))
330            .map(|(i, _)| i)
331            .take(2)
332            .collect();
333
334        let check_lines = if separator_indices.len() == 2 {
335            // 1st from bottom = lower separator, 2nd from bottom = upper separator
336            let lower_sep = separator_indices[0];
337            let upper_sep = separator_indices[1];
338            if lower_sep > upper_sep + 1 {
339                &lines[upper_sep + 1..lower_sep]
340            } else {
341                &lines[lines.len().saturating_sub(25)..lines.len()]
342            }
343        } else {
344            // Fallback: no separators found (e.g. wrap mode output without TUI chrome).
345            // Use window-based approach with prompt detection.
346            let last_prompt_idx = lines.iter().rposition(|line| {
347                let trimmed = line.trim();
348                if trimmed == "❯" || trimmed == "›" {
349                    return true;
350                }
351                if trimmed.starts_with('❯') || trimmed.starts_with('›') {
352                    let after_marker = trimmed
353                        .trim_start_matches('❯')
354                        .trim_start_matches('›')
355                        .trim_start();
356                    if after_marker
357                        .chars()
358                        .next()
359                        .map(|c| c.is_ascii_digit())
360                        .unwrap_or(false)
361                    {
362                        return false;
363                    }
364                    return trimmed.len() < 3;
365                }
366                false
367            });
368            let search_end = last_prompt_idx.unwrap_or(lines.len());
369            let search_start = if search_end == lines.len() {
370                lines.len().saturating_sub(25)
371            } else {
372                search_end.saturating_sub(25)
373            };
374            &lines[search_start..search_end]
375        };
376
377        if check_lines.is_empty() {
378            return None;
379        }
380
381        let mut choices = Vec::new();
382        let mut question = String::new();
383        let mut first_choice_idx = None;
384        let mut last_choice_idx = None;
385        let mut is_multi_select = false;
386        let mut cursor_position: usize = 0;
387
388        // Check for multi-select indicators in the content
389        for line in check_lines.iter() {
390            let lower = line.to_lowercase();
391            if lower.contains("space to")
392                || lower.contains("toggle")
393                || lower.contains("select all")
394                || lower.contains("multi")
395            {
396                is_multi_select = true;
397                break;
398            }
399        }
400
401        // [ ] checkbox format detection
402        if !is_multi_select {
403            for line in check_lines.iter() {
404                if let Some(cap) = self.choice_pattern.captures(line) {
405                    let choice_text = cap[2].trim();
406                    if choice_text.starts_with("[ ]")
407                        || choice_text.starts_with("[x]")
408                        || choice_text.starts_with("[X]")
409                        || choice_text.starts_with("[×]")
410                        || choice_text.starts_with("[✔]")
411                    {
412                        is_multi_select = true;
413                        break;
414                    }
415                }
416            }
417        }
418
419        if !is_multi_select {
420            for line in check_lines.iter() {
421                let lower = line.to_lowercase();
422                // "Enter to select" in preview-mode footer is NOT multi-select
423                // Multi-select footer uses "space to toggle" (already detected above)
424                if lower.contains("複数選択") {
425                    is_multi_select = true;
426                    break;
427                }
428            }
429        }
430
431        // Store all found choice sets, keep the last valid one
432        let mut best_choices: Vec<String> = Vec::new();
433        let mut best_first_idx: Option<usize> = None;
434        let mut best_last_idx: Option<usize> = None;
435        let mut best_cursor_position: usize = 0;
436
437        for (i, line) in check_lines.iter().enumerate() {
438            let trimmed = line.trim();
439
440            // Skip UI elements (box drawing characters)
441            if trimmed.starts_with('│')
442                || trimmed.starts_with('├')
443                || trimmed.starts_with('└')
444                || trimmed.starts_with('┌')
445                || trimmed.starts_with('─')
446                || trimmed.starts_with('✻')
447                || trimmed.starts_with('╌')
448            {
449                continue;
450            }
451
452            // Check for numbered choices (e.g., "1. Option text" or "> 1. Option text")
453            if let Some(cap) = self.choice_pattern.captures(line) {
454                if let Ok(num) = cap[1].parse::<u32>() {
455                    // Strip preview box content (box-drawing chars) before extracting label
456                    let choice_text = strip_box_drawing(cap[2].trim());
457                    if num as usize == choices.len() + 1 {
458                        let label = choice_text
459                            .split('(')
460                            .next()
461                            .unwrap_or(choice_text)
462                            .trim()
463                            .to_string();
464                        choices.push(label);
465                        if first_choice_idx.is_none() {
466                            first_choice_idx = Some(i);
467                        }
468                        last_choice_idx = Some(i);
469
470                        // Check if this line has cursor marker (❯, ›, or >)
471                        if trimmed.starts_with('❯')
472                            || trimmed.starts_with('›')
473                            || trimmed.starts_with('>')
474                        {
475                            cursor_position = num as usize;
476                        }
477                    } else if num == 1 {
478                        // New choice set starting - save current if valid (must have cursor marker)
479                        if choices.len() >= 2 && cursor_position > 0 {
480                            best_choices = choices.clone();
481                            best_first_idx = first_choice_idx;
482                            best_last_idx = last_choice_idx;
483                            best_cursor_position = cursor_position;
484                        }
485                        // Start new choice set
486                        choices.clear();
487                        let label = choice_text
488                            .split('(')
489                            .next()
490                            .unwrap_or(choice_text)
491                            .trim()
492                            .to_string();
493                        choices.push(label);
494                        first_choice_idx = Some(i);
495                        last_choice_idx = Some(i);
496                        cursor_position = if trimmed.starts_with('❯')
497                            || trimmed.starts_with('›')
498                            || trimmed.starts_with('>')
499                        {
500                            1
501                        } else {
502                            0
503                        };
504                    }
505                }
506            }
507        }
508
509        // Use the last valid choice set (must have cursor marker to be AskUserQuestion)
510        if choices.len() >= 2 && cursor_position > 0 {
511            best_choices = choices;
512            best_first_idx = first_choice_idx;
513            best_last_idx = last_choice_idx;
514            best_cursor_position = cursor_position;
515        }
516
517        // Restore best choices
518        choices = best_choices;
519        first_choice_idx = best_first_idx;
520        last_choice_idx = best_last_idx;
521        cursor_position = best_cursor_position;
522
523        // Choices must be near the end (allow for UI hints like "Enter to select").
524        // When separator-bounded, the region is already precise so distance is measured
525        // within that bounded area. For fallback (no separators), use a tighter threshold.
526        let used_separators = separator_indices.len() == 2;
527        let max_distance: usize = if used_separators {
528            // Separator-bounded: the entire region is the input area, so large
529            // preview boxes are expected. Allow generous distance.
530            check_lines.len()
531        } else {
532            // Fallback: use the last non-empty line as effective end
533            20
534        };
535        if let Some(last_idx) = last_choice_idx {
536            let effective_end = check_lines
537                .iter()
538                .rposition(|line| !line.trim().is_empty())
539                .map(|i| i + 1)
540                .unwrap_or(check_lines.len());
541            if effective_end - last_idx > max_distance {
542                return None;
543            }
544        }
545
546        // Find the question before choices
547        if let Some(first_idx) = first_choice_idx {
548            for j in (0..first_idx).rev() {
549                let prev = check_lines[j].trim();
550                if prev.is_empty() {
551                    continue;
552                }
553                if prev.ends_with('?') || prev.ends_with('?') {
554                    question = prev.to_string();
555                    break;
556                }
557                if question.is_empty() {
558                    question = prev.to_string();
559                }
560                if first_idx - j > 5 {
561                    break;
562                }
563            }
564        }
565
566        if choices.len() >= 2 {
567            // Filter out Claude Code settings menus (model selection, etc.)
568            // These show "Enter to confirm" footer instead of "Esc to cancel · Tab to amend"
569            let tail_lines: Vec<&str> = lines
570                .iter()
571                .rev()
572                .filter(|l| !l.trim().is_empty())
573                .take(8)
574                .copied()
575                .collect();
576            let tail_text = tail_lines.join(" ");
577            if tail_text.contains("Enter to confirm") {
578                return None;
579            }
580
581            // Default cursor to 1 if not detected
582            let cursor = if cursor_position == 0 {
583                1
584            } else {
585                cursor_position
586            };
587            Some((
588                ApprovalType::UserQuestion {
589                    choices,
590                    multi_select: is_multi_select,
591                    cursor_position: cursor,
592                },
593                question,
594            ))
595        } else {
596            None
597        }
598    }
599
600    /// Detect "Do you want to proceed?" style approval (1. Yes / 2. Yes, don't ask / 3. No)
601    ///
602    /// Returns extracted choices when found. This allows number key navigation
603    /// even when the cursor marker (❯) is not captured by tmux capture-pane.
604    fn detect_proceed_prompt(content: &str) -> Option<Vec<String>> {
605        // Filter out empty lines and take last 15 non-empty lines (in original order)
606        let check_lines: Vec<&str> = content
607            .lines()
608            .filter(|line| !line.trim().is_empty())
609            .collect::<Vec<_>>()
610            .into_iter()
611            .rev()
612            .take(15)
613            .collect::<Vec<_>>()
614            .into_iter()
615            .rev()
616            .collect();
617
618        let mut has_yes = false;
619        let mut has_no = false;
620
621        for line in &check_lines {
622            let trimmed = line.trim();
623            // Pattern: "1. Yes" or "❯ 1. Yes" or "> 1. Yes"
624            if trimmed.contains("1.") && trimmed.contains("Yes") {
625                has_yes = true;
626            }
627            // Pattern: "2. No" or "3. No"
628            if (trimmed.contains("2. No") || trimmed.contains("3. No")) && trimmed.len() < 20 {
629                has_no = true;
630            }
631        }
632
633        if !(has_yes && has_no) {
634            return None;
635        }
636
637        // Extract numbered choices
638        let mut choices = Vec::new();
639        for line in &check_lines {
640            let clean = line
641                .trim()
642                .trim_start_matches('❯')
643                .trim_start_matches('›')
644                .trim_start_matches('>')
645                .trim();
646            if let Some(dot_pos) = clean.find(". ") {
647                if let Ok(num) = clean[..dot_pos].trim().parse::<usize>() {
648                    if num == choices.len() + 1 {
649                        // Strip preview box content (box-drawing chars) before extracting label
650                        let choice_text = strip_box_drawing(clean[dot_pos + 2..].trim());
651                        let label = choice_text
652                            .split('(')
653                            .next()
654                            .unwrap_or(choice_text)
655                            .trim()
656                            .to_string();
657                        choices.push(label);
658                    }
659                }
660            }
661        }
662
663        if choices.len() >= 2 {
664            Some(choices)
665        } else {
666            None
667        }
668    }
669
670    /// Extract question text from content (e.g., "Do you want to proceed?")
671    fn extract_question_text(content: &str) -> String {
672        content
673            .lines()
674            .rev()
675            .take(20)
676            .find(|line| {
677                let t = line.trim();
678                !t.is_empty() && (t.ends_with('?') || t.ends_with('?'))
679            })
680            .map(|l| l.trim().to_string())
681            .unwrap_or_else(|| "Do you want to proceed?".to_string())
682    }
683
684    /// Detect Yes/No button-style approval
685    fn detect_yes_no_buttons(&self, lines: &[&str]) -> bool {
686        let check_lines: Vec<&str> = lines.iter().rev().take(8).copied().collect();
687
688        let mut has_yes = false;
689        let mut has_no = false;
690        let mut yes_line_idx: Option<usize> = None;
691        let mut no_line_idx: Option<usize> = None;
692
693        for (idx, line) in check_lines.iter().enumerate() {
694            let trimmed = line.trim();
695
696            if trimmed.is_empty() || trimmed.len() > 50 {
697                continue;
698            }
699
700            // Check for "Yes" button
701            if (trimmed == "Yes" || trimmed.starts_with("Yes,") || trimmed.starts_with("Yes "))
702                && trimmed.len() < 40
703            {
704                has_yes = true;
705                yes_line_idx = Some(idx);
706            }
707
708            // Check for "No" button
709            if (trimmed == "No" || trimmed.starts_with("No,") || trimmed.starts_with("No "))
710                && trimmed.len() < 40
711            {
712                has_no = true;
713                no_line_idx = Some(idx);
714            }
715        }
716
717        // Both Yes and No must be present and close together (within 4 lines)
718        if has_yes && has_no {
719            if let (Some(y_idx), Some(n_idx)) = (yes_line_idx, no_line_idx) {
720                let distance = y_idx.abs_diff(n_idx);
721                return distance <= 4;
722            }
723        }
724
725        false
726    }
727
728    /// Detect approval request in content, returning the rule name that matched
729    fn detect_approval(&self, content: &str) -> Option<(ApprovalType, String, &'static str)> {
730        let lines: Vec<&str> = content.lines().collect();
731        if lines.is_empty() {
732            return None;
733        }
734
735        // Strip trailing empty lines (tmux capture-pane pads with blank lines)
736        let effective_len = lines
737            .iter()
738            .rposition(|line| !line.trim().is_empty())
739            .map(|i| i + 1)
740            .unwrap_or(lines.len());
741        let lines = &lines[..effective_len];
742
743        // Check last ~12 lines (narrowed from 20 to reduce false positives)
744        let check_start = lines.len().saturating_sub(12);
745        let recent_lines = &lines[check_start..];
746        let _recent = recent_lines.join("\n");
747
748        // Check for AskUserQuestion first (highest priority)
749        if let Some((approval_type, details)) = self.detect_user_question(content) {
750            return Some((approval_type, details, "user_question_numbered_choices"));
751        }
752
753        // Check for "1. Yes / 2. ... / 3. No" style proceed prompt
754        let proceed_choices = Self::detect_proceed_prompt(content);
755        let has_proceed_prompt = proceed_choices.is_some();
756
757        // Check for button-style approval
758        let has_yes_no_buttons = self.detect_yes_no_buttons(recent_lines);
759
760        // Check for text-format approval
761        let last_lines: Vec<&str> = recent_lines.iter().rev().take(10).copied().collect();
762        let last_text = last_lines.join("\n");
763        let has_text_approval = self.general_approval_pattern.is_match(&last_text);
764
765        if !has_proceed_prompt && !has_yes_no_buttons && !has_text_approval {
766            trace!(
767                "detect_approval: no approval pattern found (user_question=None, proceed={}, buttons={}, text={})",
768                has_proceed_prompt, has_yes_no_buttons, has_text_approval
769            );
770            return None;
771        }
772
773        // If proceed_prompt extracted choices, return as UserQuestion for number key support
774        if let Some(choices) = proceed_choices {
775            let question = Self::extract_question_text(content);
776            return Some((
777                ApprovalType::UserQuestion {
778                    choices,
779                    multi_select: false,
780                    cursor_position: 1,
781                },
782                question,
783                "proceed_prompt",
784            ));
785        }
786
787        // Determine which rule matched
788        let rule = if has_yes_no_buttons {
789            "yes_no_buttons"
790        } else {
791            "yes_no_text_pattern"
792        };
793
794        // Determine approval type
795        let context = safe_tail(content, 1500);
796
797        if self.file_edit_pattern.is_match(context) {
798            let details = self.extract_file_path(context).unwrap_or_default();
799            return Some((ApprovalType::FileEdit, details, rule));
800        }
801
802        if self.file_create_pattern.is_match(context) {
803            let details = self.extract_file_path(context).unwrap_or_default();
804            return Some((ApprovalType::FileCreate, details, rule));
805        }
806
807        if self.file_delete_pattern.is_match(context) {
808            let details = self.extract_file_path(context).unwrap_or_default();
809            return Some((ApprovalType::FileDelete, details, rule));
810        }
811
812        if self.bash_pattern.is_match(context) {
813            let details = self.extract_command(context).unwrap_or_default();
814            return Some((ApprovalType::ShellCommand, details, rule));
815        }
816
817        if self.mcp_pattern.is_match(context) {
818            return Some((ApprovalType::McpTool, "MCP tool call".to_string(), rule));
819        }
820
821        Some((
822            ApprovalType::Other("Pending approval".to_string()),
823            String::new(),
824            rule,
825        ))
826    }
827
828    /// Detect error in content
829    fn detect_error(&self, content: &str) -> Option<String> {
830        let recent = safe_tail(content, 500);
831        if self.error_pattern.is_match(recent) {
832            // Extract error message
833            for line in recent.lines().rev() {
834                let trimmed = line.trim();
835                if trimmed.to_lowercase().contains("error")
836                    || trimmed.contains('✗')
837                    || trimmed.contains('❌')
838                {
839                    return Some(trimmed.to_string());
840                }
841            }
842            return Some("Error detected".to_string());
843        }
844        None
845    }
846
847    fn extract_file_path(&self, content: &str) -> Option<String> {
848        let path_pattern =
849            Regex::new(r"(?m)(?:file|path)[:\s]+([^\s\n]+)|([./][\w/.-]+\.\w+)").ok()?;
850        path_pattern
851            .captures(content)
852            .and_then(|c| c.get(1).or(c.get(2)))
853            .map(|m| m.as_str().to_string())
854    }
855
856    fn extract_command(&self, content: &str) -> Option<String> {
857        let cmd_pattern =
858            Regex::new(r"(?m)(?:command|run)[:\s]+`([^`]+)`|```(?:bash|sh)?\n([^`]+)```").ok()?;
859        cmd_pattern
860            .captures(content)
861            .and_then(|c| c.get(1).or(c.get(2)))
862            .map(|m| m.as_str().trim().to_string())
863    }
864
865    /// Check if content contains Tasks list with in-progress tasks
866    /// ◼ indicates an in-progress task in Claude Code's task list
867    fn has_in_progress_tasks(content: &str) -> bool {
868        // Look for the Tasks header pattern and in-progress indicator
869        let recent = safe_tail(content, 2000);
870
871        // Check for Tasks header with in_progress count > 0
872        for line in recent.lines() {
873            let trimmed = line.trim();
874            // Match task summary formats:
875            // - "Tasks (X done, Y in progress, Z open)" (Teams/Plan format)
876            // - "N tasks (X done, Y in progress, Z open)" (internal task list)
877            // - "N task (X done, Y in progress, Z open)" (singular)
878            let is_task_summary = trimmed.contains("in progress")
879                && (trimmed.starts_with("Tasks (")
880                    || trimmed.contains(" tasks (")
881                    || trimmed.contains(" task ("));
882            if is_task_summary {
883                // Check if there's at least 1 in progress
884                if let Some(start) = trimmed.find(", ") {
885                    if let Some(end) = trimmed[start + 2..].find(" in progress") {
886                        let num_str = &trimmed[start + 2..start + 2 + end];
887                        if let Ok(count) = num_str.parse::<u32>() {
888                            if count > 0 {
889                                return true;
890                            }
891                        }
892                    }
893                }
894            }
895            // Check for ◼ indicator (in-progress task)
896            // Formats: "◼ #N task name" (Teams) or "◼ task name" (internal)
897            if trimmed.starts_with('◼') {
898                return true;
899            }
900        }
901        false
902    }
903
904    /// Check if title matches custom spinner verbs from settings
905    ///
906    /// Returns Some(activity) if a custom verb matches, None otherwise.
907    fn detect_custom_spinner_verb(title: &str, context: &DetectionContext) -> Option<String> {
908        let settings_cache = context.settings_cache?;
909        let settings = settings_cache.get_settings(context.cwd)?;
910        let spinner_config = settings.spinner_verbs?;
911
912        if spinner_config.verbs.is_empty() {
913            return None;
914        }
915
916        // Check if title starts with any custom verb
917        for verb in &spinner_config.verbs {
918            if title.starts_with(verb) {
919                // Extract activity text after the verb
920                let activity = title
921                    .strip_prefix(verb)
922                    .map(|s| s.trim_start())
923                    .unwrap_or("")
924                    .to_string();
925                return Some(activity);
926            }
927        }
928
929        None
930    }
931
932    /// Detect turn duration completion pattern (e.g., "✻ Cooked for 1m 6s")
933    ///
934    /// When Claude Code finishes a turn, it displays a line like "✻ Cooked for 1m 6s"
935    /// using a past-tense verb. This is a definitive Idle indicator.
936    ///
937    /// Only checks the last 5 non-empty lines to avoid matching residual
938    /// turn duration messages from previous turns while a new turn is active.
939    fn detect_turn_duration(content: &str) -> Option<String> {
940        for line in content
941            .lines()
942            .rev()
943            .filter(|line| !line.trim().is_empty())
944            .take(5)
945        {
946            let trimmed = line.trim();
947
948            // Check for content spinner char at the start (Unicode only, not plain *)
949            let first_char = match trimmed.chars().next() {
950                Some(c) => c,
951                None => continue,
952            };
953
954            if !CONTENT_SPINNER_CHARS.contains(&first_char) {
955                continue;
956            }
957
958            let rest = trimmed[first_char.len_utf8()..].trim_start();
959
960            // Check for past-tense verb + " for " + duration pattern
961            for verb in TURN_DURATION_VERBS {
962                if let Some(after_verb) = rest.strip_prefix(verb) {
963                    if after_verb.starts_with(" for ") {
964                        return Some(trimmed.to_string());
965                    }
966                }
967            }
968        }
969        None
970    }
971
972    /// Detect active spinner verbs in content area
973    ///
974    /// Claude Code shows spinner activity like "✶ Spinning…", "✻ Levitating…", "* Working…"
975    /// in the content. Active spinners contain "…" (ellipsis), while completed ones show
976    /// "✻ Crunched for 6m 5s" (past tense + time, no ellipsis).
977    ///
978    /// Returns (matched_text, is_builtin_verb) — builtin verbs get High confidence,
979    /// unknown/custom verbs get Medium confidence.
980    ///
981    /// This is critical for detecting processing when the title still shows ✳ (idle),
982    /// e.g. during /compact or title update lag.
983    fn detect_content_spinner(content: &str, context: &DetectionContext) -> Option<(String, bool)> {
984        // If idle prompt ❯ is near the end (last 5 non-empty lines), any spinner above is a past residual
985        let has_idle_prompt = content
986            .lines()
987            .rev()
988            .filter(|line| !line.trim().is_empty())
989            .take(5)
990            .any(|line| {
991                let trimmed = line.trim();
992                trimmed == "❯" || trimmed == "›"
993            });
994        if has_idle_prompt {
995            trace!("detect_content_spinner: skipped due to idle prompt (❯/›) in last 5 non-empty lines");
996            return None;
997        }
998
999        // Check last 15 non-empty lines (skip empty lines entirely).
1000        // Claude Code TUI has status bar (3 lines) + separators + empty padding,
1001        // so using raw line count can miss spinners beyond the window.
1002        for line in content
1003            .lines()
1004            .rev()
1005            .filter(|line| !line.trim().is_empty())
1006            .take(15)
1007        {
1008            let trimmed = line.trim();
1009
1010            let first_char = match trimmed.chars().next() {
1011                Some(c) => c,
1012                None => continue,
1013            };
1014
1015            // Check for decorative asterisk chars or plain '*'
1016            let is_spinner_char = CONTENT_SPINNER_CHARS.contains(&first_char) || first_char == '*';
1017            if !is_spinner_char {
1018                continue;
1019            }
1020
1021            let rest = trimmed[first_char.len_utf8()..].trim_start();
1022
1023            // Must start with uppercase letter (verb) and contain ellipsis (active)
1024            let starts_upper = rest
1025                .chars()
1026                .next()
1027                .map(|c| c.is_uppercase())
1028                .unwrap_or(false);
1029            let has_ellipsis = rest.contains('…') || rest.contains("...");
1030
1031            if starts_upper && has_ellipsis {
1032                // Extract the verb (first word) and check against builtin/custom lists
1033                let verb = rest.split_whitespace().next().unwrap_or("");
1034                // Strip trailing ellipsis from verb if present (e.g., "Spinning…")
1035                let verb_clean = verb.trim_end_matches('…').trim_end_matches("...");
1036                let is_builtin = BUILTIN_SPINNER_VERBS.contains(&verb_clean);
1037                // Also check custom spinnerVerbs from Claude Code settings
1038                let is_custom = if !is_builtin {
1039                    context
1040                        .settings_cache
1041                        .and_then(|cache| cache.get_settings(context.cwd))
1042                        .and_then(|s| s.spinner_verbs)
1043                        .map(|config| config.verbs.iter().any(|v| v == verb_clean))
1044                        .unwrap_or(false)
1045                } else {
1046                    false
1047                };
1048                return Some((trimmed.to_string(), is_builtin || is_custom));
1049            }
1050        }
1051        None
1052    }
1053
1054    /// Detect permission mode from title icon
1055    ///
1056    /// Claude Code displays mode icons in the terminal title:
1057    /// - ⏸ (U+23F8) = Plan mode
1058    /// - ⇢ (U+21E2) = Delegate mode
1059    /// - ⏵⏵ (U+23F5 x2) = Auto-approve (acceptEdits/bypassPermissions/dontAsk)
1060    pub fn detect_mode(title: &str) -> AgentMode {
1061        if title.contains('\u{23F8}') {
1062            AgentMode::Plan
1063        } else if title.contains('\u{21E2}') {
1064            AgentMode::Delegate
1065        } else if title.contains("\u{23F5}\u{23F5}") {
1066            AgentMode::AutoApprove
1067        } else {
1068            AgentMode::Default
1069        }
1070    }
1071
1072    /// Check if we should skip default Braille spinner detection
1073    ///
1074    /// Returns true if mode is "replace" and custom verbs are configured.
1075    fn should_skip_default_spinners(context: &DetectionContext) -> bool {
1076        let settings_cache = match context.settings_cache {
1077            Some(cache) => cache,
1078            None => return false,
1079        };
1080
1081        let settings = match settings_cache.get_settings(context.cwd) {
1082            Some(s) => s,
1083            None => return false,
1084        };
1085
1086        matches!(
1087            settings.spinner_verbs,
1088            Some(ref config) if config.mode == SpinnerVerbsMode::Replace && !config.verbs.is_empty()
1089        )
1090    }
1091}
1092
1093impl Default for ClaudeCodeDetector {
1094    fn default() -> Self {
1095        Self::new()
1096    }
1097}
1098
1099impl StatusDetector for ClaudeCodeDetector {
1100    fn detect_status(&self, title: &str, content: &str) -> AgentStatus {
1101        self.detect_status_with_reason(title, content, &DetectionContext::default())
1102            .status
1103    }
1104
1105    fn detect_status_with_context(
1106        &self,
1107        title: &str,
1108        content: &str,
1109        context: &DetectionContext,
1110    ) -> AgentStatus {
1111        self.detect_status_with_reason(title, content, context)
1112            .status
1113    }
1114
1115    fn detect_status_with_reason(
1116        &self,
1117        title: &str,
1118        content: &str,
1119        context: &DetectionContext,
1120    ) -> DetectionResult {
1121        // 1. Check for AskUserQuestion or approval (highest priority)
1122        if let Some((approval_type, details, rule)) = self.detect_approval(content) {
1123            trace!(rule, "detect_status: approval detected");
1124            let matched = safe_tail(content, 200);
1125            return DetectionResult::new(
1126                AgentStatus::AwaitingApproval {
1127                    approval_type,
1128                    details,
1129                },
1130                rule,
1131                DetectionConfidence::High,
1132            )
1133            .with_matched_text(matched);
1134        }
1135        trace!("detect_status: no approval detected, continuing to title/content checks");
1136
1137        // 1.5 Fast path: Braille spinner in title → Processing (skip content parsing)
1138        //     Any character in the Braille Patterns block (U+2800..=U+28FF) indicates
1139        //     active processing. This avoids expensive content analysis when the title
1140        //     already provides a definitive signal.
1141        //     Approval detection (step 1) is always checked first.
1142        {
1143            let title_activity = title
1144                .chars()
1145                .skip_while(|c| matches!(*c, '\u{2800}'..='\u{28FF}') || c.is_whitespace())
1146                .collect::<String>();
1147            if title.chars().any(|c| matches!(c, '\u{2800}'..='\u{28FF}')) {
1148                return DetectionResult::new(
1149                    AgentStatus::Processing {
1150                        activity: title_activity,
1151                    },
1152                    "title_braille_spinner_fast_path",
1153                    DetectionConfidence::High,
1154                )
1155                .with_matched_text(title);
1156            }
1157        }
1158
1159        // 2. Check for errors
1160        if let Some(message) = self.detect_error(content) {
1161            return DetectionResult::new(
1162                AgentStatus::Error {
1163                    message: message.clone(),
1164                },
1165                "error_pattern",
1166                DetectionConfidence::High,
1167            )
1168            .with_matched_text(&message);
1169        }
1170
1171        // 3. Check for Tasks list with in-progress tasks (◼)
1172        if Self::has_in_progress_tasks(content) {
1173            return DetectionResult::new(
1174                AgentStatus::Processing {
1175                    activity: "Tasks running".to_string(),
1176                },
1177                "tasks_in_progress",
1178                DetectionConfidence::High,
1179            );
1180        }
1181
1182        // 4. Check for Compacting (✽ Compacting conversation)
1183        if title.contains('✽') && title.to_lowercase().contains("compacting") {
1184            return DetectionResult::new(
1185                AgentStatus::Processing {
1186                    activity: "Compacting...".to_string(),
1187                },
1188                "title_compacting",
1189                DetectionConfidence::High,
1190            )
1191            .with_matched_text(title);
1192        }
1193
1194        // 5. Content-based "Conversation compacted" detection → Idle
1195        //    e.g., "✻ Conversation compacted (ctrl+o for history)"
1196        {
1197            let recent = safe_tail(content, 1000);
1198            if recent.contains("Conversation compacted") {
1199                // Verify it's a spinner-prefixed line (not just any text mentioning it)
1200                for line in recent
1201                    .lines()
1202                    .rev()
1203                    .filter(|l| !l.trim().is_empty())
1204                    .take(15)
1205                {
1206                    let trimmed = line.trim();
1207                    let first_char = trimmed.chars().next().unwrap_or('\0');
1208                    if (CONTENT_SPINNER_CHARS.contains(&first_char) || first_char == '*')
1209                        && trimmed.contains("Conversation compacted")
1210                    {
1211                        return DetectionResult::new(
1212                            AgentStatus::Idle,
1213                            "content_conversation_compacted",
1214                            DetectionConfidence::High,
1215                        )
1216                        .with_matched_text(trimmed);
1217                    }
1218                }
1219            }
1220        }
1221
1222        // 6. Content-based spinner detection (overrides title idle)
1223        //    Catches cases where title still shows ✳ but content has active spinner
1224        //    e.g. during /compact, or title update lag
1225        if let Some((activity, is_builtin)) = Self::detect_content_spinner(content, context) {
1226            let confidence = if is_builtin {
1227                DetectionConfidence::High
1228            } else {
1229                DetectionConfidence::Medium
1230            };
1231            return DetectionResult::new(
1232                AgentStatus::Processing {
1233                    activity: activity.clone(),
1234                },
1235                "content_spinner_verb",
1236                confidence,
1237            )
1238            .with_matched_text(&activity);
1239        }
1240
1241        // 7. Check for turn duration completion (e.g., "✻ Cooked for 1m 6s")
1242        //    Placed after content spinner so active spinners take priority over
1243        //    residual turn duration messages from previous turns.
1244        if let Some(matched) = Self::detect_turn_duration(content) {
1245            return DetectionResult::new(
1246                AgentStatus::Idle,
1247                "turn_duration_completed",
1248                DetectionConfidence::High,
1249            )
1250            .with_matched_text(&matched);
1251        }
1252
1253        // 8. Title-based detection: ✳ in title = Idle
1254        if title.contains(IDLE_INDICATOR) {
1255            trace!(
1256                title,
1257                "detect_status: title_idle_indicator (approval was not detected)"
1258            );
1259            return DetectionResult::new(
1260                AgentStatus::Idle,
1261                "title_idle_indicator",
1262                DetectionConfidence::High,
1263            )
1264            .with_matched_text(title);
1265        }
1266
1267        // 9. Check for custom spinner verbs from settings
1268        if let Some(activity) = Self::detect_custom_spinner_verb(title, context) {
1269            return DetectionResult::new(
1270                AgentStatus::Processing { activity },
1271                "custom_spinner_verb",
1272                DetectionConfidence::Medium,
1273            )
1274            .with_matched_text(title);
1275        }
1276
1277        // 10. Default Braille spinner detection (unless mode is "replace")
1278        if !Self::should_skip_default_spinners(context)
1279            && title.chars().any(|c| PROCESSING_SPINNERS.contains(&c))
1280        {
1281            let activity = title
1282                .chars()
1283                .skip_while(|c| PROCESSING_SPINNERS.contains(c) || c.is_whitespace())
1284                .collect::<String>();
1285            return DetectionResult::new(
1286                AgentStatus::Processing { activity },
1287                "braille_spinner",
1288                DetectionConfidence::Medium,
1289            )
1290            .with_matched_text(title);
1291        }
1292
1293        // No indicator - default to Processing
1294        DetectionResult::new(
1295            AgentStatus::Processing {
1296                activity: String::new(),
1297            },
1298            "fallback_no_indicator",
1299            DetectionConfidence::Low,
1300        )
1301    }
1302
1303    fn detect_context_warning(&self, content: &str) -> Option<u8> {
1304        // Look for "Context left until auto-compact: XX%"
1305        for line in content.lines().rev().take(30) {
1306            if line.contains("Context left until auto-compact:") {
1307                // Extract percentage
1308                if let Some(pct_str) = line.split(':').next_back() {
1309                    let pct_str = pct_str.trim().trim_end_matches('%');
1310                    if let Ok(pct) = pct_str.parse::<u8>() {
1311                        return Some(pct);
1312                    }
1313                }
1314            }
1315        }
1316        None
1317    }
1318
1319    fn agent_type(&self) -> AgentType {
1320        AgentType::ClaudeCode
1321    }
1322
1323    fn approval_keys(&self) -> &str {
1324        // Claude Code uses cursor-based selection UI
1325        // Cursor is already on "Yes", just press Enter to confirm
1326        "Enter"
1327    }
1328    // Note: Rejection removed - use number keys, input mode, or passthrough mode
1329}
1330
1331/// Get the last n bytes of a string safely
1332fn safe_tail(s: &str, n: usize) -> &str {
1333    if s.len() <= n {
1334        s
1335    } else {
1336        let start = s.len() - n;
1337        // Find a valid UTF-8 boundary
1338        let start = s
1339            .char_indices()
1340            .map(|(i, _)| i)
1341            .find(|&i| i >= start)
1342            .unwrap_or(s.len());
1343        &s[start..]
1344    }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350
1351    #[test]
1352    fn test_idle_with_asterisk() {
1353        let detector = ClaudeCodeDetector::new();
1354        let status = detector.detect_status("✳ Claude Code", "some content");
1355        assert!(matches!(status, AgentStatus::Idle));
1356    }
1357
1358    #[test]
1359    fn test_processing_with_spinner() {
1360        let detector = ClaudeCodeDetector::new();
1361        let status = detector.detect_status("⠋ Processing task", "some content");
1362        assert!(matches!(status, AgentStatus::Processing { .. }));
1363    }
1364
1365    #[test]
1366    fn test_yes_no_button_approval() {
1367        let detector = ClaudeCodeDetector::new();
1368        let content = r#"
1369Do you want to allow this action?
1370
1371  Yes
1372  Yes, and don't ask again for this session
1373  No
1374"#;
1375        let status = detector.detect_status("✳ Claude Code", content);
1376        assert!(matches!(status, AgentStatus::AwaitingApproval { .. }));
1377    }
1378
1379    #[test]
1380    fn test_no_false_positive_for_prompt() {
1381        let detector = ClaudeCodeDetector::new();
1382        // ❯ alone should not trigger approval
1383        let content = "Some previous output\n\n❯ ";
1384        let status = detector.detect_status("✳ Claude Code", content);
1385        assert!(matches!(status, AgentStatus::Idle));
1386    }
1387
1388    #[test]
1389    fn test_numbered_choices() {
1390        let detector = ClaudeCodeDetector::new();
1391        // AskUserQuestion always has ❯ cursor on the selected option line
1392        let content = r#"
1393Which option do you prefer?
1394
1395❯ 1. Option A
1396  2. Option B
1397  3. Option C
1398"#;
1399        let status = detector.detect_status("✳ Claude Code", content);
1400        match status {
1401            AgentStatus::AwaitingApproval { approval_type, .. } => {
1402                assert!(matches!(approval_type, ApprovalType::UserQuestion { .. }));
1403            }
1404            _ => panic!("Expected AwaitingApproval with UserQuestion"),
1405        }
1406    }
1407
1408    #[test]
1409    fn test_numbered_list_not_detected_as_question() {
1410        let detector = ClaudeCodeDetector::new();
1411        // Regular numbered list without ❯ cursor should NOT be detected as AskUserQuestion
1412        let content = r#"
1413Here are the changes:
1414
14151. Fixed the bug
14162. Added tests
14173. Updated docs
1418"#;
1419        let status = detector.detect_status("✳ Claude Code", content);
1420        // Should be Idle, not AwaitingApproval
1421        assert!(matches!(status, AgentStatus::Idle));
1422    }
1423
1424    #[test]
1425    fn test_numbered_choices_with_cursor() {
1426        let detector = ClaudeCodeDetector::new();
1427        // Format with > cursor marker on selected option
1428        let content = r#"
1429Which option do you prefer?
1430
1431> 1. Option A
1432  2. Option B
1433  3. Option C
1434
14351436"#;
1437        let status = detector.detect_status("✳ Claude Code", content);
1438        match status {
1439            AgentStatus::AwaitingApproval { approval_type, .. } => {
1440                if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1441                    assert_eq!(choices.len(), 3);
1442                } else {
1443                    panic!("Expected UserQuestion");
1444                }
1445            }
1446            _ => panic!("Expected AwaitingApproval with UserQuestion"),
1447        }
1448    }
1449
1450    #[test]
1451    fn test_numbered_choices_with_descriptions() {
1452        let detector = ClaudeCodeDetector::new();
1453        // Real AskUserQuestion format with multi-line options
1454        let content = r#"
1455───────────────────────────────────────────────────────────────────────────────
1456 ☐ 動作確認
1457
1458数字キーで選択できますか?
1459
1460❯ 1. 1番: 動作した
1461     数字キーで1を押して選択できた
1462  2. 2番: まだ動かない
1463     数字キーが反応しない
1464  3. 3番: 別の問題
1465     他の問題が発生した
1466  4. Type something.
1467"#;
1468        let status = detector.detect_status("✳ Claude Code", content);
1469        match status {
1470            AgentStatus::AwaitingApproval { approval_type, .. } => {
1471                if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1472                    assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
1473                } else {
1474                    panic!("Expected UserQuestion, got {:?}", approval_type);
1475                }
1476            }
1477            _ => panic!("Expected AwaitingApproval, got {:?}", status),
1478        }
1479    }
1480
1481    #[test]
1482    fn test_would_you_like_to_proceed() {
1483        let detector = ClaudeCodeDetector::new();
1484        let content = r#"Would you like to proceed?
1485
1486 ❯ 1. Yes, clear context and auto-accept edits (shift+tab)
1487   2. Yes, auto-accept edits
1488   3. Yes, manually approve edits
1489   4. Type here to tell Claude what to change"#;
1490        let status = detector.detect_status("✳ Claude Code", content);
1491        match status {
1492            AgentStatus::AwaitingApproval { approval_type, .. } => {
1493                if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1494                    assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
1495                } else {
1496                    panic!("Expected UserQuestion, got {:?}", approval_type);
1497                }
1498            }
1499            _ => panic!("Expected AwaitingApproval, got {:?}", status),
1500        }
1501    }
1502
1503    #[test]
1504    fn test_would_you_like_to_proceed_with_footer() {
1505        let detector = ClaudeCodeDetector::new();
1506        // Real captured content with UI footer
1507        let content = r#"   - 環境変数未設定時に警告ログが出ることを確認
1508
1509 ---
1510 完了条件
1511
1512 - getInvitationLink ヘルパー関数を作成
1513 - queries.ts と mutations.ts でヘルパー関数を使用
1514 - 型チェック・リント・テストがパス
1515 - Issue #62 の関連項目をクローズ
1516╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
1517
1518 Would you like to proceed?
1519
1520 ❯ 1. Yes, clear context and auto-accept edits (shift+tab)
1521   2. Yes, auto-accept edits
1522   3. Yes, manually approve edits
1523   4. Type here to tell Claude what to change
1524
1525 ctrl-g to edit in Micro · .claude/plans/eventual-humming-hellman.md"#;
1526        let status = detector.detect_status("✳ Claude Code", content);
1527        match status {
1528            AgentStatus::AwaitingApproval { approval_type, .. } => {
1529                if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1530                    assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
1531                } else {
1532                    panic!("Expected UserQuestion, got {:?}", approval_type);
1533                }
1534            }
1535            _ => panic!("Expected AwaitingApproval, got {:?}", status),
1536        }
1537    }
1538
1539    #[test]
1540    fn test_numbered_choices_with_ui_hints() {
1541        let detector = ClaudeCodeDetector::new();
1542        // Real format with UI hints at the bottom
1543        let content = r#"
1544───────────────────────────────────────────────────────────────────────────────
1545 ☐ コンテンツ取得
1546
1547デバッグのため、コンテンツを貼り付けてもらえますか?
1548
1549❯ 1. 貼り付ける
1550     「その他」でコンテンツを入力
1551  2. 別のアプローチ
1552     デバッグモードを追加して原因を特定
1553  3. Type something.
1554
1555───────────────────────────────────────────────────────────────────────────────
1556  Chat about this
1557
1558Enter to select · ↑/↓ to navigate · Esc to cancel
1559"#;
1560        let status = detector.detect_status("✳ Claude Code", content);
1561        match status {
1562            AgentStatus::AwaitingApproval { approval_type, .. } => {
1563                if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1564                    assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
1565                } else {
1566                    panic!("Expected UserQuestion, got {:?}", approval_type);
1567                }
1568            }
1569            _ => panic!("Expected AwaitingApproval, got {:?}", status),
1570        }
1571    }
1572
1573    #[test]
1574    fn test_tasks_in_progress_detected_as_processing() {
1575        let detector = ClaudeCodeDetector::new();
1576        // Tasks list with in_progress tasks should be Processing, not Idle
1577        let content = r#"
1578  Tasks (0 done, 2 in progress, 8 open) · ctrl+t to hide tasks
1579  ◼ #1 T1: helpers仕様書の作成
1580  ◼ #2 T2: Result型仕様書の作成
1581  ◻ #3 T3: past-medication-record-edit更新
1582  ◻ #4 T4: medication-history更新
1583  ◻ #10 T10: OVERVIEW更新 › blocked by #9
1584"#;
1585        // Even with ✳ in title, should be Processing due to in-progress tasks
1586        let status = detector.detect_status("✳ Claude Code", content);
1587        assert!(
1588            matches!(status, AgentStatus::Processing { .. }),
1589            "Expected Processing, got {:?}",
1590            status
1591        );
1592    }
1593
1594    #[test]
1595    fn test_tasks_in_progress_internal_format() {
1596        let detector = ClaudeCodeDetector::new();
1597        // Claude Code internal task format: "N tasks (X done, Y in progress, Z open)"
1598        // Note: uses lowercase "tasks" with number prefix, and ◼ without #N
1599        let content = r#"
1600  7 tasks (6 done, 1 in progress, 0 open)
1601  ✔ Fix 1: screen_context の機密情報サニタイズ
1602  ✔ Fix 2: in_flight/cooldowns の TOCTOU 修正
1603  ◼ 検証: cargo fmt, clippy, test, build
1604  ✔ Fix 4: judge.rs の stdout truncation
1605"#;
1606        let status = detector.detect_status("✳ Claude Code", content);
1607        assert!(
1608            matches!(status, AgentStatus::Processing { .. }),
1609            "Expected Processing for internal task format, got {:?}",
1610            status
1611        );
1612    }
1613
1614    #[test]
1615    fn test_tasks_in_progress_indicator_without_hash() {
1616        let detector = ClaudeCodeDetector::new();
1617        // ◼ without #N should also be detected
1618        let content = "Some output\n  ◼ Running tests\n  ✔ Build passed\n";
1619        let status = detector.detect_status("✳ Claude Code", content);
1620        assert!(
1621            matches!(status, AgentStatus::Processing { .. }),
1622            "Expected Processing for ◼ without #N, got {:?}",
1623            status
1624        );
1625    }
1626
1627    #[test]
1628    fn test_tasks_all_done_is_idle() {
1629        let detector = ClaudeCodeDetector::new();
1630        // Tasks list with all done (no in_progress) should be Idle
1631        let content = r#"
1632  Tasks (10 done, 0 in progress, 0 open) · ctrl+t to hide tasks
1633  ✔ #1 T1: helpers仕様書の作成
1634  ✔ #2 T2: Result型仕様書の作成
1635  ✔ #3 T3: past-medication-record-edit更新
1636"#;
1637        let status = detector.detect_status("✳ Claude Code", content);
1638        assert!(
1639            matches!(status, AgentStatus::Idle),
1640            "Expected Idle, got {:?}",
1641            status
1642        );
1643    }
1644
1645    #[test]
1646    fn test_tasks_all_done_internal_format_is_idle() {
1647        let detector = ClaudeCodeDetector::new();
1648        // Internal format with all tasks done
1649        let content = r#"
1650  7 tasks (7 done, 0 in progress, 0 open)
1651  ✔ Fix 1: screen_context の機密情報サニタイズ
1652  ✔ Fix 2: in_flight/cooldowns の TOCTOU 修正
1653"#;
1654        let status = detector.detect_status("✳ Claude Code", content);
1655        assert!(
1656            matches!(status, AgentStatus::Idle),
1657            "Expected Idle for all-done internal format, got {:?}",
1658            status
1659        );
1660    }
1661
1662    #[test]
1663    fn test_web_search_approval() {
1664        let detector = ClaudeCodeDetector::new();
1665        let content = r#"● Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1666
1667● Explore(プロジェクト構造の調査)
1668  ⎿  Done (11 tool uses · 85.3k tokens · 51s)
1669
1670───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1671 Tool use
1672
1673   Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1674   Claude wants to search the web for: MCP Apps iframe UI Model Context Protocol 2026
1675
1676 Do you want to proceed?
1677 ❯ 1. Yes
1678   2. Yes, and don't ask again for Web Search commands in /home/trustdelta/works/conversation-handoff-mcp
1679   3. No
1680
1681 Esc to cancel · Tab to add additional instructions"#;
1682        let status = detector.detect_status("✳ Claude Code", content);
1683        assert!(
1684            matches!(status, AgentStatus::AwaitingApproval { .. }),
1685            "Expected AwaitingApproval, got {:?}",
1686            status
1687        );
1688    }
1689
1690    #[test]
1691    fn test_proceed_prompt_detection() {
1692        let detector = ClaudeCodeDetector::new();
1693        let content = r#"
1694 Do you want to proceed?
1695 ❯ 1. Yes
1696   2. Yes, and don't ask again for Web Search commands
1697   3. No
1698
1699 Esc to cancel"#;
1700        let status = detector.detect_status("✳ Claude Code", content);
1701        assert!(
1702            matches!(status, AgentStatus::AwaitingApproval { .. }),
1703            "Expected AwaitingApproval, got {:?}",
1704            status
1705        );
1706    }
1707
1708    #[test]
1709    fn test_actual_captured_content() {
1710        let detector = ClaudeCodeDetector::new();
1711        // Content with ❯ appearing both as user prompt and selection cursor
1712        let content = "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\n\
1713❯ MCP Appsが公開された、テスト\n\
1714Line8\nLine9\nLine10\n\
1715Line11\nLine12\nLine13\nLine14\nLine15\n\
1716 Tool use\n\
1717   Web Search(\"test\")\n\
1718\n\
1719 Do you want to proceed?\n\
1720 ❯ 1. Yes\n\
1721   2. No\n\
1722\n\
1723 Esc to cancel";
1724        let status = detector.detect_status("✳ Claude Code", content);
1725        assert!(
1726            matches!(status, AgentStatus::AwaitingApproval { .. }),
1727            "Expected AwaitingApproval, got {:?}",
1728            status
1729        );
1730    }
1731
1732    #[test]
1733    fn test_web_search_with_full_capture() {
1734        let detector = ClaudeCodeDetector::new();
1735        // Full capture from actual tmux pane - includes welcome screen
1736        let content = r#"╭─── Claude Code v2.1.17 ─────────────────────────────────────────────────────────────────────────────────────────────╮
1737│                                                     │ Tips for getting started                                      │
1738│             Welcome back trust.delta!               │ Run /init to create a CLAUDE.md file with instructions for Cl…│
1739│                                                     │                                                               │
1740│                                                     │ ───────────────────────────────────────────────────────────── │
1741│                      ▐▛███▜▌                        │ Recent activity                                               │
1742│                     ▝▜█████▛▘                       │ No recent activity                                            │
1743│                       ▘▘ ▝▝                         │                                                               │
1744│  Opus 4.5 · Claude Max · trust.delta@gmail.com's    │                                                               │
1745│  Organization                                       │                                                               │
1746│          ~/works/conversation-handoff-mcp           │                                                               │
1747╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
1748
1749❯ MCP Appsが公開された、mcpにiframeでuiを追加できる様子。実験がてらアプデが止まってたconversation-handoff-mcpに組
1750  み込んでみようと思います
1751
1752● MCP Appsは興味深い新機能ですね。まずMCP Appsの仕様と現在のconversation-handoff-mcpの状態を調査しましょう。
1753
1754● Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1755
1756● Explore(プロジェクト構造の調査)
1757  ⎿  Done (11 tool uses · 85.3k tokens · 51s)
1758
1759───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1760 Tool use
1761
1762   Web Search("MCP Apps iframe UI Model Context Protocol 2026")
1763   Claude wants to search the web for: MCP Apps iframe UI Model Context Protocol 2026
1764
1765 Do you want to proceed?
1766 ❯ 1. Yes
1767   2. Yes, and don't ask again for Web Search commands in /home/trustdelta/works/conversation-handoff-mcp
1768   3. No
1769
1770 Esc to cancel · Tab to add additional instructions"#;
1771        let status = detector.detect_status("✳ Claude Code", content);
1772        assert!(
1773            matches!(status, AgentStatus::AwaitingApproval { .. }),
1774            "Expected AwaitingApproval, got {:?}",
1775            status
1776        );
1777    }
1778
1779    #[test]
1780    fn test_proceed_prompt_without_cursor_returns_user_question() {
1781        let detector = ClaudeCodeDetector::new();
1782        // 3-choice approval WITHOUT cursor marker ❯
1783        let content = r#"
1784 Tool use
1785
1786   Bash("ls -la")
1787
1788 Do you want to proceed?
1789   1. Yes
1790   2. Yes, and don't ask again for Bash commands
1791   3. No
1792
1793 Esc to cancel"#;
1794        let status = detector.detect_status("✳ Claude Code", content);
1795        match status {
1796            AgentStatus::AwaitingApproval { approval_type, .. } => {
1797                if let ApprovalType::UserQuestion {
1798                    choices,
1799                    multi_select,
1800                    cursor_position,
1801                } = approval_type
1802                {
1803                    assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
1804                    assert!(!multi_select);
1805                    assert_eq!(cursor_position, 1);
1806                    assert!(choices[0].contains("Yes"));
1807                    assert!(choices[2].contains("No"));
1808                } else {
1809                    panic!(
1810                        "Expected UserQuestion for cursor-less proceed prompt, got {:?}",
1811                        approval_type
1812                    );
1813                }
1814            }
1815            _ => panic!("Expected AwaitingApproval, got {:?}", status),
1816        }
1817    }
1818
1819    #[test]
1820    fn test_proceed_prompt_2_choice_without_cursor() {
1821        let detector = ClaudeCodeDetector::new();
1822        // Simple 2-choice without cursor
1823        let content = r#" Do you want to proceed?
1824   1. Yes
1825   2. No"#;
1826        let status = detector.detect_status("✳ Claude Code", content);
1827        match status {
1828            AgentStatus::AwaitingApproval { approval_type, .. } => {
1829                if let ApprovalType::UserQuestion { choices, .. } = approval_type {
1830                    assert_eq!(choices.len(), 2, "Expected 2 choices, got {:?}", choices);
1831                } else {
1832                    panic!("Expected UserQuestion, got {:?}", approval_type);
1833                }
1834            }
1835            _ => panic!("Expected AwaitingApproval, got {:?}", status),
1836        }
1837    }
1838
1839    #[test]
1840    fn test_custom_spinner_verb_detection_replace_mode() {
1841        use crate::config::ClaudeSettingsCache;
1842
1843        let detector = ClaudeCodeDetector::new();
1844        let cache = ClaudeSettingsCache::new();
1845
1846        // Manually inject settings for testing (since we can't create real files in unit tests)
1847        // We'll test the detection logic directly
1848
1849        // Test that custom verb is detected when present in title
1850        let context = DetectionContext {
1851            cwd: None, // No cwd means no settings loaded
1852            settings_cache: Some(&cache),
1853        };
1854
1855        // Without settings, should fall back to default spinner detection
1856        let status =
1857            detector.detect_status_with_context("Thinking about code", "content", &context);
1858        // Should be Processing (no indicator found, but also no settings to check)
1859        assert!(
1860            matches!(status, AgentStatus::Processing { .. }),
1861            "Expected Processing, got {:?}",
1862            status
1863        );
1864    }
1865
1866    #[test]
1867    fn test_default_spinner_still_works_without_settings() {
1868        let detector = ClaudeCodeDetector::new();
1869        let context = DetectionContext::default();
1870
1871        // Braille spinner should still be detected without settings
1872        let status = detector.detect_status_with_context("⠋ Working on task", "content", &context);
1873        match status {
1874            AgentStatus::Processing { activity } => {
1875                assert_eq!(activity, "Working on task");
1876            }
1877            _ => panic!("Expected Processing, got {:?}", status),
1878        }
1879    }
1880
1881    #[test]
1882    fn test_simple_yes_no_proceed() {
1883        let detector = ClaudeCodeDetector::new();
1884        // Exact format reported by user as being detected as Idle
1885        let content = r#" Do you want to proceed?
1886 ❯ 1. Yes
1887   2. No"#;
1888        let status = detector.detect_status("✳ Claude Code", content);
1889        assert!(
1890            matches!(status, AgentStatus::AwaitingApproval { .. }),
1891            "Expected AwaitingApproval, got {:?}",
1892            status
1893        );
1894    }
1895
1896    #[test]
1897    fn test_content_spinner_overrides_title_idle() {
1898        let detector = ClaudeCodeDetector::new();
1899        // Title shows ✳ (idle) but content has active spinner and no bare ❯ prompt
1900        // - should be Processing
1901        let content = r#"
1902✻ Cogitated for 2m 6s
1903
1904❯ コミットしてdev-log
1905
1906✶ Spinning… (37s · ↑ 38 tokens)
1907
1908Some other output here
1909"#;
1910        let result = detector.detect_status_with_reason(
1911            "✳ Git commit dev-log",
1912            content,
1913            &DetectionContext::default(),
1914        );
1915        assert!(
1916            matches!(result.status, AgentStatus::Processing { .. }),
1917            "Expected Processing when content has active spinner, got {:?}",
1918            result.status
1919        );
1920        assert_eq!(result.reason.rule, "content_spinner_verb");
1921        // "Spinning" is a builtin verb, so confidence is High
1922        assert_eq!(result.reason.confidence, DetectionConfidence::High);
1923    }
1924
1925    #[test]
1926    fn test_content_spinner_with_four_teardrop() {
1927        let detector = ClaudeCodeDetector::new();
1928        // ✢ (U+2722) is another spinner char Claude Code uses
1929        // No bare ❯ prompt at end, so spinner should be detected
1930        let content = "Some output\n\n✢ Bootstrapping… (1m 27s)\n\nMore output\n";
1931        let result = detector.detect_status_with_reason(
1932            "✳ Task name",
1933            content,
1934            &DetectionContext::default(),
1935        );
1936        assert!(
1937            matches!(result.status, AgentStatus::Processing { .. }),
1938            "Expected Processing for ✢ spinner, got {:?}",
1939            result.status
1940        );
1941        assert_eq!(result.reason.rule, "content_spinner_verb");
1942    }
1943
1944    #[test]
1945    fn test_content_spinner_with_plain_asterisk() {
1946        let detector = ClaudeCodeDetector::new();
1947        // Plain * spinner should also be detected
1948        // No bare ❯ prompt at end, so spinner should be detected
1949        let content = "Some output\n\n* Perambulating…\n\nMore output\n";
1950        let result = detector.detect_status_with_reason(
1951            "✳ Task name",
1952            content,
1953            &DetectionContext::default(),
1954        );
1955        assert!(
1956            matches!(result.status, AgentStatus::Processing { .. }),
1957            "Expected Processing for * spinner, got {:?}",
1958            result.status
1959        );
1960    }
1961
1962    #[test]
1963    fn test_completed_spinner_not_detected_as_active() {
1964        let detector = ClaudeCodeDetector::new();
1965        // Completed spinners (past tense, no ellipsis) should NOT trigger processing
1966        let content = "Some output\n\n✻ Crunched for 6m 5s\n\n❯ \n";
1967        let result = detector.detect_status_with_reason(
1968            "✳ Task name",
1969            content,
1970            &DetectionContext::default(),
1971        );
1972        assert!(
1973            matches!(result.status, AgentStatus::Idle),
1974            "Expected Idle for completed spinner, got {:?}",
1975            result.status
1976        );
1977    }
1978
1979    #[test]
1980    fn test_detect_status_with_context_backwards_compatible() {
1981        let detector = ClaudeCodeDetector::new();
1982        let context = DetectionContext::default();
1983
1984        // Test that detect_status and detect_status_with_context give same results
1985        // when context is empty
1986        let title = "✳ Claude Code";
1987        let content = "some content";
1988
1989        let status1 = detector.detect_status(title, content);
1990        let status2 = detector.detect_status_with_context(title, content, &context);
1991
1992        // Both should be Idle
1993        assert!(matches!(status1, AgentStatus::Idle));
1994        assert!(matches!(status2, AgentStatus::Idle));
1995    }
1996
1997    #[test]
1998    fn test_multi_select_with_trailing_empty_lines() {
1999        let detector = ClaudeCodeDetector::new();
2000        // Real capture-pane output: AskUserQuestion with multi-select checkboxes,
2001        // followed by many trailing empty lines (tmux pads to terminal height).
2002        // This previously failed because check_lines.len() - last_choice_idx > 15.
2003        let content = "\
2004今日の作業内容を教えてください(複数選択可)\n\
2005\n\
2006❯ 1. [ ] 機能実装\n\
2007  --audit モードの実装\n\
2008  2. [ ] ドキュメント更新\n\
2009  CHANGELOG, README, CLAUDE.md更新\n\
2010  3. [ ] CI/CD構築\n\
2011  タグプッシュ時の自動npm publishワークフロー作成\n\
2012  4. [ ] リリース\n\
2013  v0.7.0のnpm publish\n\
2014  5. [ ] Type something\n\
2015     Submit\n\
2016──────────────────────────────────────────\n\
2017  6. Chat about this\n\
2018\n\
2019Enter to select · ↑/↓ to navigate · Esc to cancel\n\
2020\n\n\n\n\n\n\n\n\n\n\n\n\n\n";
2021        let status = detector.detect_status("✳ Dev Log", content);
2022        assert!(
2023            matches!(status, AgentStatus::AwaitingApproval { .. }),
2024            "Should detect AskUserQuestion despite trailing empty lines, got {:?}",
2025            status
2026        );
2027        if let AgentStatus::AwaitingApproval { approval_type, .. } = status {
2028            if let ApprovalType::UserQuestion {
2029                choices,
2030                multi_select,
2031                cursor_position,
2032                ..
2033            } = approval_type
2034            {
2035                assert_eq!(choices.len(), 6, "Expected 6 choices, got {:?}", choices);
2036                // Note: multi_select detection relies on English keywords ("space to", "toggle")
2037                // which aren't present in this Japanese UI. The [ ] checkboxes are visual-only.
2038                let _ = multi_select;
2039                assert_eq!(cursor_position, 1);
2040            } else {
2041                panic!("Expected UserQuestion, got {:?}", approval_type);
2042            }
2043        }
2044    }
2045
2046    #[test]
2047    fn test_content_spinner_not_detected_when_idle_prompt_present() {
2048        let detector = ClaudeCodeDetector::new();
2049        // Old spinner text above idle prompt should NOT trigger processing
2050        let content = "Some output\n\n✽ Forging… (2m 3s)\n\nMore output\n\n❯ \n";
2051        let result = detector.detect_status_with_reason(
2052            "✳ Task name",
2053            content,
2054            &DetectionContext::default(),
2055        );
2056        assert!(
2057            matches!(result.status, AgentStatus::Idle),
2058            "Expected Idle when ❯ prompt is present below old spinner, got {:?}",
2059            result.status
2060        );
2061    }
2062
2063    #[test]
2064    fn test_actual_title_spinner_chars() {
2065        let detector = ClaudeCodeDetector::new();
2066        // ⠂ (U+2802) and ⠐ (U+2810) are the actual Claude Code title spinner frames
2067        for (spinner, label) in [('⠂', "U+2802"), ('⠐', "U+2810")] {
2068            let title = format!("{} Working on task", spinner);
2069            let result = detector.detect_status_with_reason(
2070                &title,
2071                "some content",
2072                &DetectionContext::default(),
2073            );
2074            assert!(
2075                matches!(result.status, AgentStatus::Processing { .. }),
2076                "Expected Processing for {} ({}), got {:?}",
2077                spinner,
2078                label,
2079                result.status
2080            );
2081            assert_eq!(
2082                result.reason.rule, "title_braille_spinner_fast_path",
2083                "Expected title_braille_spinner_fast_path rule for {} ({})",
2084                spinner, label
2085            );
2086        }
2087    }
2088
2089    #[test]
2090    fn test_content_spinner_with_empty_line_padding() {
2091        let detector = ClaudeCodeDetector::new();
2092        // Spinner line followed by many empty lines (TUI padding)
2093        let content = "Some output\n\n✶ Bootstrapping… (5s)\n\n\n\n\n\n\n\n\n\n\n\n";
2094        let result = detector.detect_status_with_reason(
2095            "✳ Task name",
2096            content,
2097            &DetectionContext::default(),
2098        );
2099        assert!(
2100            matches!(result.status, AgentStatus::Processing { .. }),
2101            "Expected Processing when spinner is followed by empty line padding, got {:?}",
2102            result.status
2103        );
2104        assert_eq!(result.reason.rule, "content_spinner_verb");
2105    }
2106
2107    #[test]
2108    fn test_content_spinner_beyond_old_window() {
2109        let detector = ClaudeCodeDetector::new();
2110        // Spinner line with >15 lines after it (mix of empty and non-empty status bar lines)
2111        // Previously the 15-line raw window would miss this spinner
2112        let mut content = String::from("Some output\n\n✻ Levitating… (10s)\n");
2113        // Add 10 empty lines + 3 status bar lines + 5 empty lines = 18 trailing lines
2114        for _ in 0..10 {
2115            content.push('\n');
2116        }
2117        content.push_str("───────────────────────\n");
2118        content.push_str("  ctrl-g to edit\n");
2119        content.push_str("  Status bar line\n");
2120        for _ in 0..5 {
2121            content.push('\n');
2122        }
2123        let result = detector.detect_status_with_reason(
2124            "✳ Task name",
2125            &content,
2126            &DetectionContext::default(),
2127        );
2128        assert!(
2129            matches!(result.status, AgentStatus::Processing { .. }),
2130            "Expected Processing when spinner is beyond old 15-line window, got {:?}",
2131            result.status
2132        );
2133        assert_eq!(result.reason.rule, "content_spinner_verb");
2134    }
2135
2136    #[test]
2137    fn test_idle_prompt_detection_with_empty_lines() {
2138        let detector = ClaudeCodeDetector::new();
2139        // ❯ prompt with empty lines after it should still be detected as idle
2140        let content = "Some output\n\n✶ Spinning… (5s)\n\nMore output\n\n❯ \n\n\n\n\n\n\n\n\n\n\n";
2141        let result = detector.detect_status_with_reason(
2142            "✳ Task name",
2143            content,
2144            &DetectionContext::default(),
2145        );
2146        assert!(
2147            matches!(result.status, AgentStatus::Idle),
2148            "Expected Idle when ❯ prompt is present (even with empty line padding), got {:?}",
2149            result.status
2150        );
2151    }
2152
2153    #[test]
2154    fn test_content_spinner_with_idle_indicator_char() {
2155        let detector = ClaudeCodeDetector::new();
2156        // ✳ used as content spinner on macOS/Ghostty (same char as IDLE_INDICATOR)
2157        // Should be detected as Processing when used with uppercase verb + ellipsis
2158        let content = "Some output\n\n✳ Ruminating… (3s)\n\nMore output\n";
2159        let result = detector.detect_status_with_reason(
2160            "Claude Code", // non-Braille title so fast path doesn't intercept
2161            content,
2162            &DetectionContext::default(),
2163        );
2164        assert!(
2165            matches!(result.status, AgentStatus::Processing { .. }),
2166            "Expected Processing for ✳ content spinner, got {:?}",
2167            result.status
2168        );
2169        assert_eq!(result.reason.rule, "content_spinner_verb");
2170    }
2171
2172    #[test]
2173    fn test_multi_select_windows_checkbox() {
2174        let detector = ClaudeCodeDetector::new();
2175        // Windows/fallback uses [×] for checked checkbox
2176        let content = r#"
2177Which items to include?
2178
2179❯ 1. [×] Feature A
2180  2. [ ] Feature B
2181  3. [×] Feature C
2182  4. Type something.
2183"#;
2184        let status = detector.detect_status("✳ Claude Code", content);
2185        match status {
2186            AgentStatus::AwaitingApproval { approval_type, .. } => {
2187                if let ApprovalType::UserQuestion {
2188                    choices,
2189                    multi_select,
2190                    ..
2191                } = approval_type
2192                {
2193                    assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
2194                    assert!(multi_select, "Expected multi_select=true for [×] checkbox");
2195                } else {
2196                    panic!("Expected UserQuestion, got {:?}", approval_type);
2197                }
2198            }
2199            _ => panic!("Expected AwaitingApproval, got {:?}", status),
2200        }
2201    }
2202
2203    #[test]
2204    fn test_mode_detection_plan() {
2205        assert_eq!(
2206            ClaudeCodeDetector::detect_mode("⏸ ✳ Claude Code"),
2207            AgentMode::Plan
2208        );
2209        assert_eq!(
2210            ClaudeCodeDetector::detect_mode("⏸ ⠂ Working on task"),
2211            AgentMode::Plan
2212        );
2213    }
2214
2215    #[test]
2216    fn test_mode_detection_delegate() {
2217        assert_eq!(
2218            ClaudeCodeDetector::detect_mode("⇢ ✳ Claude Code"),
2219            AgentMode::Delegate
2220        );
2221    }
2222
2223    #[test]
2224    fn test_mode_detection_auto_approve() {
2225        assert_eq!(
2226            ClaudeCodeDetector::detect_mode("⏵⏵ ✳ Claude Code"),
2227            AgentMode::AutoApprove
2228        );
2229        assert_eq!(
2230            ClaudeCodeDetector::detect_mode("⏵⏵ ⠐ Processing"),
2231            AgentMode::AutoApprove
2232        );
2233    }
2234
2235    #[test]
2236    fn test_mode_detection_default() {
2237        assert_eq!(
2238            ClaudeCodeDetector::detect_mode("✳ Claude Code"),
2239            AgentMode::Default
2240        );
2241        assert_eq!(
2242            ClaudeCodeDetector::detect_mode("⠂ Working"),
2243            AgentMode::Default
2244        );
2245    }
2246
2247    #[test]
2248    fn test_turn_duration_cooked() {
2249        let detector = ClaudeCodeDetector::new();
2250        // "✻ Cooked for 1m 6s" = completed turn → Idle
2251        let content = "Some output\n\n✻ Cooked for 1m 6s\n\nSome status bar\n";
2252        let result = detector.detect_status_with_reason(
2253            "✳ Task name",
2254            content,
2255            &DetectionContext::default(),
2256        );
2257        assert!(
2258            matches!(result.status, AgentStatus::Idle),
2259            "Expected Idle for turn duration, got {:?}",
2260            result.status
2261        );
2262        assert_eq!(result.reason.rule, "turn_duration_completed");
2263        assert_eq!(result.reason.confidence, DetectionConfidence::High);
2264    }
2265
2266    #[test]
2267    fn test_turn_duration_brewed() {
2268        let detector = ClaudeCodeDetector::new();
2269        let content = "Output\n\n✻ Brewed for 42s\n\n";
2270        let result = detector.detect_status_with_reason(
2271            "✳ Claude Code",
2272            content,
2273            &DetectionContext::default(),
2274        );
2275        assert!(
2276            matches!(result.status, AgentStatus::Idle),
2277            "Expected Idle for Brewed duration, got {:?}",
2278            result.status
2279        );
2280        assert_eq!(result.reason.rule, "turn_duration_completed");
2281    }
2282
2283    #[test]
2284    fn test_turn_duration_sauteed() {
2285        let detector = ClaudeCodeDetector::new();
2286        // Sautéed with accent
2287        let content = "Output\n\n✶ Sautéed for 3m 12s\n\n";
2288        let result = detector.detect_status_with_reason(
2289            "✳ Claude Code",
2290            content,
2291            &DetectionContext::default(),
2292        );
2293        assert!(
2294            matches!(result.status, AgentStatus::Idle),
2295            "Expected Idle for Sautéed duration, got {:?}",
2296            result.status
2297        );
2298    }
2299
2300    #[test]
2301    fn test_turn_duration_does_not_match_active_spinner() {
2302        let detector = ClaudeCodeDetector::new();
2303        // Active spinner (with ellipsis) should NOT be matched as turn duration
2304        let content = "Output\n\n✻ Cooking… (5s)\n\n";
2305        let result = detector.detect_status_with_reason(
2306            "✳ Claude Code",
2307            content,
2308            &DetectionContext::default(),
2309        );
2310        // Should be Processing (content spinner), not Idle
2311        assert!(
2312            matches!(result.status, AgentStatus::Processing { .. }),
2313            "Expected Processing for active spinner, got {:?}",
2314            result.status
2315        );
2316    }
2317
2318    #[test]
2319    fn test_conversation_compacted_in_content() {
2320        let detector = ClaudeCodeDetector::new();
2321        let content =
2322            "Some output\n\n✻ Conversation compacted (ctrl+o for history)\n\nStatus bar\n";
2323        let result = detector.detect_status_with_reason(
2324            "✳ Claude Code",
2325            content,
2326            &DetectionContext::default(),
2327        );
2328        assert!(
2329            matches!(result.status, AgentStatus::Idle),
2330            "Expected Idle for Conversation compacted, got {:?}",
2331            result.status
2332        );
2333        assert_eq!(result.reason.rule, "content_conversation_compacted");
2334        assert_eq!(result.reason.confidence, DetectionConfidence::High);
2335    }
2336
2337    #[test]
2338    fn test_conversation_compacted_title_still_works() {
2339        let detector = ClaudeCodeDetector::new();
2340        // Title-based compacting detection should still work
2341        let content = "Some content\n";
2342        let result = detector.detect_status_with_reason(
2343            "✽ Compacting conversation",
2344            content,
2345            &DetectionContext::default(),
2346        );
2347        assert!(
2348            matches!(result.status, AgentStatus::Processing { .. }),
2349            "Expected Processing for title compacting, got {:?}",
2350            result.status
2351        );
2352        assert_eq!(result.reason.rule, "title_compacting");
2353    }
2354
2355    #[test]
2356    fn test_builtin_spinner_verb_high_confidence() {
2357        let detector = ClaudeCodeDetector::new();
2358        // Builtin verb "Spinning" should get High confidence
2359        let content = "Some output\n\n✶ Spinning… (5s)\n\nMore output\n";
2360        let result = detector.detect_status_with_reason(
2361            "Claude Code", // non-Braille title so fast path doesn't intercept
2362            content,
2363            &DetectionContext::default(),
2364        );
2365        assert!(
2366            matches!(result.status, AgentStatus::Processing { .. }),
2367            "Expected Processing, got {:?}",
2368            result.status
2369        );
2370        assert_eq!(result.reason.rule, "content_spinner_verb");
2371        assert_eq!(result.reason.confidence, DetectionConfidence::High);
2372    }
2373
2374    #[test]
2375    fn test_unknown_spinner_verb_medium_confidence() {
2376        let detector = ClaudeCodeDetector::new();
2377        // Unknown verb should get Medium confidence
2378        let content = "Some output\n\n✶ Zazzlefrazzing… (5s)\n\nMore output\n";
2379        let result = detector.detect_status_with_reason(
2380            "Claude Code", // non-Braille title so fast path doesn't intercept
2381            content,
2382            &DetectionContext::default(),
2383        );
2384        assert!(
2385            matches!(result.status, AgentStatus::Processing { .. }),
2386            "Expected Processing, got {:?}",
2387            result.status
2388        );
2389        assert_eq!(result.reason.rule, "content_spinner_verb");
2390        assert_eq!(result.reason.confidence, DetectionConfidence::Medium);
2391    }
2392
2393    #[test]
2394    fn test_builtin_verb_flambeing_with_accent() {
2395        let detector = ClaudeCodeDetector::new();
2396        // "Flambéing" with accent should match as builtin
2397        let content = "Output\n\n✻ Flambéing… (2s)\n\n";
2398        let result = detector.detect_status_with_reason(
2399            "⠂ Task name",
2400            content,
2401            &DetectionContext::default(),
2402        );
2403        assert_eq!(result.reason.confidence, DetectionConfidence::High);
2404    }
2405
2406    #[test]
2407    fn test_windows_ascii_radio_buttons() {
2408        let detector = ClaudeCodeDetector::new();
2409        // Windows ASCII radio buttons: ( ) and (*) — single-select (not multi)
2410        let content = r#"
2411Which option?
2412
2413❯ 1. (*) Option A
2414  2. ( ) Option B
2415  3. ( ) Option C
2416"#;
2417        let status = detector.detect_status("✳ Claude Code", content);
2418        match status {
2419            AgentStatus::AwaitingApproval { approval_type, .. } => {
2420                if let ApprovalType::UserQuestion {
2421                    choices,
2422                    multi_select,
2423                    ..
2424                } = approval_type
2425                {
2426                    assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
2427                    assert!(
2428                        !multi_select,
2429                        "Expected multi_select=false for (*) radio buttons (single-select)"
2430                    );
2431                } else {
2432                    panic!("Expected UserQuestion, got {:?}", approval_type);
2433                }
2434            }
2435            _ => panic!("Expected AwaitingApproval, got {:?}", status),
2436        }
2437    }
2438
2439    #[test]
2440    fn test_preview_format_with_single_right_angle() {
2441        let detector = ClaudeCodeDetector::new();
2442        // AskUserQuestion with preview panel: › cursor marker + right-side │ box
2443        let content = r#"
2444Which approach do you prefer?
2445
2446  1. Base directories          ┌──────────────────────┐
2447› 2. Bookmark style            │ # config.toml        │
2448  3. Both                      │ [create_process]     │
2449  4. Default input             │ directories = [...]  │
2450                               └──────────────────────┘
2451
2452  Chat about this
2453
2454Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel
2455"#;
2456        let status = detector.detect_status("✳ Claude Code", content);
2457        match status {
2458            AgentStatus::AwaitingApproval { approval_type, .. } => {
2459                if let ApprovalType::UserQuestion {
2460                    choices,
2461                    multi_select,
2462                    cursor_position,
2463                } = approval_type
2464                {
2465                    assert_eq!(choices.len(), 4, "Expected 4 choices, got {:?}", choices);
2466                    assert_eq!(cursor_position, 2, "Cursor should be on choice 2");
2467                    assert!(
2468                        !multi_select,
2469                        "Preview format should not be detected as multi-select"
2470                    );
2471                    // Verify preview box content is stripped from choice text
2472                    assert!(
2473                        !choices[0].contains('│'),
2474                        "Choice text should not contain box chars: {:?}",
2475                        choices[0]
2476                    );
2477                } else {
2478                    panic!("Expected UserQuestion, got {:?}", approval_type);
2479                }
2480            }
2481            _ => panic!("Expected AwaitingApproval, got {:?}", status),
2482        }
2483    }
2484
2485    #[test]
2486    fn test_preview_format_large_box() {
2487        let detector = ClaudeCodeDetector::new();
2488        // AskUserQuestion with a large preview panel (10+ lines)
2489        // Enclosed between horizontal separators like real tmux capture-pane output
2490        let content = r#"
2491Previous conversation...
2492
2493────────────────────────────────────────────────────────────────────────
2494Which configuration format do you prefer?
2495
2496  1. TOML format              ┌──────────────────────────────┐
2497› 2. YAML format              │ # Example YAML config        │
2498  3. JSON format              │ server:                      │
2499                              │   host: localhost             │
2500                              │   port: 8080                 │
2501                              │   workers: 4                 │
2502                              │ database:                    │
2503                              │   url: postgres://localhost   │
2504                              │   pool_size: 10              │
2505                              │   timeout: 30s               │
2506                              │ logging:                     │
2507                              │   level: info                │
2508                              │   format: json               │
2509                              └──────────────────────────────┘
2510
2511────────────────────────────────────────────────────────────────────────
2512  Chat about this
2513
2514Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel
2515"#;
2516        // Simulate tmux padding: add trailing empty lines
2517        let mut padded = content.to_string();
2518        for _ in 0..30 {
2519            padded.push('\n');
2520        }
2521
2522        let status = detector.detect_status("✳ Claude Code", &padded);
2523        match status {
2524            AgentStatus::AwaitingApproval { approval_type, .. } => {
2525                if let ApprovalType::UserQuestion {
2526                    choices,
2527                    multi_select,
2528                    cursor_position,
2529                } = approval_type
2530                {
2531                    assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
2532                    assert_eq!(cursor_position, 2, "Cursor should be on choice 2");
2533                    assert!(
2534                        !multi_select,
2535                        "Preview format should not be detected as multi-select"
2536                    );
2537                    assert!(
2538                        !choices[0].contains('│'),
2539                        "Choice text should not contain box chars: {:?}",
2540                        choices[0]
2541                    );
2542                } else {
2543                    panic!("Expected UserQuestion, got {:?}", approval_type);
2544                }
2545            }
2546            _ => panic!(
2547                "Expected AwaitingApproval for large preview box, got {:?}",
2548                status
2549            ),
2550        }
2551    }
2552
2553    #[test]
2554    fn test_preview_format_very_large_box_real_capture() {
2555        let detector = ClaudeCodeDetector::new();
2556        // Real-world capture: AskUserQuestion with a very large preview (25+ lines)
2557        // where choices are far above the preview box in the content.
2558        // This simulates the actual tmux capture-pane output structure.
2559        // Simulates real tmux capture-pane: previous conversation above,
2560        // then two ─── separators enclosing the input area with choices + preview.
2561        let content = "\
2562Previous conversation output here...
2563
2564✻ Worked for 30s
2565
2566────────────────────────────────────────────────────────────────────────────────
2567  Capture Feedback
2568
2569伝えたいことを教えてください。大きいプレビューにカーソルを合わせて確認してください。
2570
2571  1. Option A                   ┌──────────────────────────────────────────┐
2572› 2. Option B                   │ # Large Preview B                        │
2573  3. Option C                   │                                          │
2574                                │ ## Database Schema                       │
2575                                │                                          │
2576                                │ ```sql                                   │
2577                                │ CREATE TABLE users (                     │
2578                                │   id UUID PRIMARY KEY,                   │
2579                                │   email TEXT UNIQUE,                     │
2580                                │   name TEXT,                             │
2581                                │   created_at TIMESTAMPTZ                 │
2582                                │ );                                       │
2583                                │                                          │
2584                                │ CREATE TABLE teams (                     │
2585                                │   id UUID PRIMARY KEY,                   │
2586                                │   name TEXT,                             │
2587                                │   owner_id UUID REFERENCES users         │
2588                                │ );                                       │
2589                                │                                          │
2590                                │ CREATE TABLE members (                   │
2591                                │   team_id UUID REFERENCES teams,         │
2592                                │   user_id UUID REFERENCES users,         │
2593                                │   role TEXT DEFAULT 'member',            │
2594                                │   PRIMARY KEY (team_id, user_id)         │
2595                                │ );                                       │
2596                                │ ```                                      │
2597                                └──────────────────────────────────────────┘
2598
2599                                Notes: press n to add notes
2600
2601────────────────────────────────────────────────────────────────────────────────
2602  Chat about this
2603
2604Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel
2605";
2606        let status = detector.detect_status("✳ Claude Code", content);
2607        match status {
2608            AgentStatus::AwaitingApproval { approval_type, .. } => {
2609                if let ApprovalType::UserQuestion {
2610                    choices,
2611                    cursor_position,
2612                    ..
2613                } = approval_type
2614                {
2615                    assert_eq!(choices.len(), 3, "Expected 3 choices, got {:?}", choices);
2616                    assert_eq!(cursor_position, 2, "Cursor should be on choice 2");
2617                } else {
2618                    panic!("Expected UserQuestion, got {:?}", approval_type);
2619                }
2620            }
2621            _ => panic!(
2622                "Expected AwaitingApproval for very large preview box, got {:?}",
2623                status
2624            ),
2625        }
2626    }
2627}