Skip to main content

tmai_core/detectors/
codex.rs

1use regex::Regex;
2
3use crate::agents::{AgentStatus, AgentType, ApprovalType};
4
5use super::{DetectionConfidence, DetectionContext, DetectionResult, StatusDetector};
6
7/// Detector for Codex CLI
8pub struct CodexDetector {
9    approval_pattern: Regex,
10    error_pattern: Regex,
11    working_elapsed_pattern: Regex,
12    context_left_pattern: Regex,
13}
14
15impl CodexDetector {
16    /// Create a new CodexDetector with compiled regex patterns
17    pub fn new() -> Self {
18        Self {
19            // Only match explicit approval prompts, not general text containing these words
20            approval_pattern: Regex::new(
21                r"(?i)\[y/n\]|\[Y/n\]|\[yes/no\]|^\s*Yes\s*/\s*No\s*$|\[Approve\]|\[Confirm\]|\[Allow\]|\[Proceed\]",
22            )
23            .unwrap(),
24            error_pattern: Regex::new(r"(?i)(?:^|\n)\s*(?:Error|ERROR|error:|✗|❌)").unwrap(),
25            working_elapsed_pattern: Regex::new(r"Working.*\(\d+[smh]").unwrap(),
26            context_left_pattern: Regex::new(r"(\d+)% context left").unwrap(),
27        }
28    }
29
30    /// Detect approval patterns in content, returning (ApprovalType, details) if found
31    fn detect_approval(&self, content: &str) -> Option<(ApprovalType, String, &'static str)> {
32        let lines: Vec<&str> = content.lines().collect();
33        let check_start = lines.len().saturating_sub(30);
34        let recent_lines = &lines[check_start..];
35
36        // Check for confirm footer as a reinforcing signal
37        let has_confirm_footer = recent_lines
38            .iter()
39            .any(|l| l.contains("Press Enter to confirm or Esc to cancel"));
40
41        // 1. Specific approval patterns (High confidence)
42        for line in recent_lines {
43            let trimmed = line.trim();
44            if trimmed.contains("Would you like to run the following command?") {
45                return Some((
46                    ApprovalType::ShellCommand,
47                    trimmed.to_string(),
48                    "exec_approval",
49                ));
50            }
51            if trimmed.contains("Would you like to make the following edits?") {
52                return Some((
53                    ApprovalType::FileEdit,
54                    trimmed.to_string(),
55                    "patch_approval",
56                ));
57            }
58            if trimmed.contains("needs your approval") {
59                return Some((ApprovalType::McpTool, trimmed.to_string(), "mcp_approval"));
60            }
61            if trimmed.contains("Do you want to approve access to") {
62                return Some((
63                    ApprovalType::Other("Network".to_string()),
64                    trimmed.to_string(),
65                    "network_approval",
66                ));
67            }
68        }
69
70        // 2. Codex approval choices pattern (High confidence)
71        //    "Yes, proceed" with [y], "Yes, and don't ask again" with [p]/[a],
72        //    "No, and tell Codex" with [Esc/n]
73        if let Some(rule) = self.detect_codex_choices(recent_lines) {
74            return Some((
75                ApprovalType::Other("Codex approval".to_string()),
76                String::new(),
77                rule,
78            ));
79        }
80
81        // 3. Numbered choices (user question pattern)
82        if let Some(question) = self.detect_numbered_choices(recent_lines) {
83            return Some((question.0, question.1, "codex_numbered_choices"));
84        }
85
86        // 4. Generic [y/n] approval patterns
87        for line in recent_lines {
88            // Skip tip/hint lines and footer
89            if line.contains("Tip:")
90                || line.contains("Tips:")
91                || line.contains("% context left")
92                || line.contains("? for shortcuts")
93            {
94                continue;
95            }
96
97            if self.approval_pattern.is_match(line) {
98                return Some((
99                    ApprovalType::Other("Codex approval".to_string()),
100                    String::new(),
101                    "codex_approval_pattern",
102                ));
103            }
104        }
105
106        // 5. Confirm footer alone (without other approval signals) as a weaker signal
107        if has_confirm_footer {
108            return Some((
109                ApprovalType::Other("Codex approval".to_string()),
110                String::new(),
111                "confirm_footer",
112            ));
113        }
114
115        None
116    }
117
118    /// Detect Codex-specific approval choice lines
119    ///
120    /// Looks for patterns like:
121    /// - "Yes, proceed" with `[y]`
122    /// - "Yes, and don't ask again" with `[p]` or `[a]`
123    /// - "No, and tell Codex" with `[Esc/n]`
124    fn detect_codex_choices(&self, lines: &[&str]) -> Option<&'static str> {
125        let mut has_yes_proceed = false;
126        let mut has_no_tell = false;
127
128        for line in lines {
129            let trimmed = line.trim();
130            if (trimmed.contains("Yes, proceed") || trimmed.contains("Yes, and don't ask again"))
131                && (trimmed.contains("[y]") || trimmed.contains("[p]") || trimmed.contains("[a]"))
132            {
133                has_yes_proceed = true;
134            }
135            if trimmed.contains("No, and tell Codex") && trimmed.contains("[Esc/n]") {
136                has_no_tell = true;
137            }
138        }
139
140        if has_yes_proceed || has_no_tell {
141            Some("codex_choice_pattern")
142        } else {
143            None
144        }
145    }
146
147    /// Detect numbered choices pattern (e.g., "1. Option", "2. Option")
148    fn detect_numbered_choices(&self, lines: &[&str]) -> Option<(ApprovalType, String)> {
149        let mut choices: Vec<String> = Vec::new();
150        let mut question_text = String::new();
151        let mut found_prompt = false;
152
153        // Scan from end to find prompt, then look for choices above it
154        for line in lines.iter().rev() {
155            let trimmed = line.trim();
156
157            // Skip footer lines
158            if trimmed.contains("% context left") || trimmed.starts_with('?') || trimmed.is_empty()
159            {
160                continue;
161            }
162
163            // Found input prompt - mark and continue looking for choices above
164            if trimmed.starts_with('›') {
165                found_prompt = true;
166                continue;
167            }
168
169            // Look for numbered choices (1. xxx, 2. xxx, etc.)
170            if let Some(choice) = self.parse_numbered_choice(trimmed) {
171                choices.push(choice);
172            } else if !choices.is_empty() {
173                // We've collected choices, now check for question text
174                if trimmed.ends_with('?') || trimmed.ends_with('?') {
175                    question_text = trimmed.to_string();
176                }
177                break;
178            }
179        }
180
181        // If we found numbered choices with a prompt, return as UserQuestion
182        if choices.len() >= 2 && found_prompt {
183            // Reverse choices since we collected them bottom-up
184            choices.reverse();
185            return Some((
186                ApprovalType::UserQuestion {
187                    choices,
188                    multi_select: false,
189                    cursor_position: 0,
190                },
191                question_text,
192            ));
193        }
194
195        None
196    }
197
198    /// Parse a numbered choice line (e.g., "1. Fix bug" -> "Fix bug")
199    fn parse_numbered_choice(&self, line: &str) -> Option<String> {
200        let trimmed = line.trim();
201        // Match patterns like "1. text", "2. text", etc.
202        if trimmed.len() >= 3 {
203            let first_char = trimmed.chars().next()?;
204            if first_char.is_ascii_digit() {
205                let rest = &trimmed[1..];
206                if rest.starts_with(". ") || rest.starts_with(".") {
207                    let choice_text = rest.trim_start_matches(['.', '.', ' ']).trim();
208                    if !choice_text.is_empty() {
209                        return Some(choice_text.to_string());
210                    }
211                }
212            }
213        }
214        None
215    }
216
217    fn detect_error(&self, content: &str) -> Option<String> {
218        let lines: Vec<&str> = content.lines().collect();
219        let check_start = lines.len().saturating_sub(10);
220        let recent = lines[check_start..].join("\n");
221
222        if self.error_pattern.is_match(&recent) {
223            for line in lines.iter().rev().take(10) {
224                if line.to_lowercase().contains("error") {
225                    return Some(line.trim().to_string());
226                }
227            }
228            return Some("Error detected".to_string());
229        }
230        None
231    }
232}
233
234impl Default for CodexDetector {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240impl StatusDetector for CodexDetector {
241    fn detect_status(&self, title: &str, content: &str) -> AgentStatus {
242        self.detect_status_with_reason(title, content, &DetectionContext::default())
243            .status
244    }
245
246    fn detect_status_with_reason(
247        &self,
248        title: &str,
249        content: &str,
250        _context: &DetectionContext,
251    ) -> DetectionResult {
252        // 1-4. Check for approval requests (specific patterns, codex choices, numbered choices, generic [y/n])
253        if let Some((approval_type, details, rule)) = self.detect_approval(content) {
254            return DetectionResult::new(
255                AgentStatus::AwaitingApproval {
256                    approval_type,
257                    details: details.clone(),
258                },
259                rule,
260                DetectionConfidence::High,
261            )
262            .with_matched_text(&details);
263        }
264
265        // 5. Check for errors
266        if let Some(message) = self.detect_error(content) {
267            return DetectionResult::new(
268                AgentStatus::Error {
269                    message: message.clone(),
270                },
271                "codex_error_pattern",
272                DetectionConfidence::High,
273            )
274            .with_matched_text(&message);
275        }
276
277        // Content-based detection for Codex CLI
278        let lines: Vec<&str> = content.lines().collect();
279        let recent_lines: Vec<&str> = lines.iter().rev().take(15).copied().collect();
280
281        // 6. Working + elapsed time pattern (High confidence)
282        for line in &recent_lines {
283            let trimmed = line.trim();
284            if self.working_elapsed_pattern.is_match(trimmed) {
285                return DetectionResult::new(
286                    AgentStatus::Processing {
287                        activity: trimmed.to_string(),
288                    },
289                    "working_elapsed_time",
290                    DetectionConfidence::High,
291                )
292                .with_matched_text(trimmed);
293            }
294        }
295
296        // 7. Spinner detection (Medium confidence)
297        for line in &recent_lines {
298            let trimmed = line.trim();
299
300            if trimmed.starts_with('⠋')
301                || trimmed.starts_with('⠙')
302                || trimmed.starts_with('⠹')
303                || trimmed.starts_with('⠸')
304                || trimmed.starts_with('⠼')
305                || trimmed.starts_with('⠴')
306                || trimmed.starts_with('⠦')
307                || trimmed.starts_with('⠧')
308                || trimmed.starts_with('⠇')
309                || trimmed.starts_with('⠏')
310            {
311                return DetectionResult::new(
312                    AgentStatus::Processing {
313                        activity: trimmed.to_string(),
314                    },
315                    "codex_spinner",
316                    DetectionConfidence::Medium,
317                )
318                .with_matched_text(trimmed);
319            }
320        }
321
322        // 8. "esc to interrupt" (Medium confidence)
323        for line in &recent_lines {
324            let trimmed = line.trim();
325            if trimmed.contains("esc to interrupt") {
326                return DetectionResult::new(
327                    AgentStatus::Processing {
328                        activity: String::new(),
329                    },
330                    "codex_esc_to_interrupt",
331                    DetectionConfidence::Medium,
332                )
333                .with_matched_text(trimmed);
334            }
335        }
336
337        // 9. Thinking/Generating text (Medium confidence)
338        for line in &recent_lines {
339            let trimmed = line.trim();
340            if trimmed.contains("Thinking") || trimmed.contains("Generating") {
341                return DetectionResult::new(
342                    AgentStatus::Processing {
343                        activity: trimmed.to_string(),
344                    },
345                    "codex_thinking",
346                    DetectionConfidence::Medium,
347                )
348                .with_matched_text(trimmed);
349            }
350        }
351
352        // Title-based detection
353        let title_lower = title.to_lowercase();
354        if title_lower.contains("idle") || title_lower.contains("ready") {
355            return DetectionResult::new(
356                AgentStatus::Idle,
357                "codex_title_idle",
358                DetectionConfidence::Medium,
359            )
360            .with_matched_text(title);
361        }
362
363        if title_lower.contains("working") || title_lower.contains("processing") {
364            return DetectionResult::new(
365                AgentStatus::Processing {
366                    activity: title.to_string(),
367                },
368                "codex_title_processing",
369                DetectionConfidence::Medium,
370            )
371            .with_matched_text(title);
372        }
373
374        // 10. Idle detection indicators
375        let mut prompt_line_idx: Option<usize> = None;
376        let mut footer_line_idx: Option<usize> = None;
377
378        for (idx, line) in recent_lines.iter().enumerate() {
379            let trimmed = line.trim();
380            if trimmed.contains("% context left") {
381                footer_line_idx = Some(idx);
382            }
383            if trimmed.starts_with('›') {
384                prompt_line_idx = Some(idx);
385                break;
386            }
387        }
388
389        // Prompt + footer together
390        if let (Some(prompt_idx), Some(footer_idx)) = (prompt_line_idx, footer_line_idx) {
391            if prompt_idx > footer_idx {
392                let between = &recent_lines[footer_idx + 1..prompt_idx];
393                let only_empty_or_hints = between
394                    .iter()
395                    .all(|l| l.trim().is_empty() || l.trim().starts_with('?'));
396                if only_empty_or_hints {
397                    return DetectionResult::new(
398                        AgentStatus::Idle,
399                        "codex_prompt_footer",
400                        DetectionConfidence::Medium,
401                    );
402                }
403            }
404        }
405
406        // Slash menu
407        let has_slash_menu = recent_lines.iter().any(|line| {
408            let trimmed = line.trim();
409            trimmed.starts_with("/model")
410                || trimmed.starts_with("/permissions")
411                || trimmed.starts_with("/experimental")
412                || trimmed.starts_with("/skills")
413                || trimmed.starts_with("/review")
414                || trimmed.starts_with("/rename")
415                || trimmed.starts_with("/new")
416                || trimmed.starts_with("/resume")
417                || trimmed.starts_with("/help")
418        });
419
420        if has_slash_menu {
421            return DetectionResult::new(
422                AgentStatus::Idle,
423                "codex_slash_menu",
424                DetectionConfidence::Medium,
425            );
426        }
427
428        // Prompt only
429        if prompt_line_idx.is_some() {
430            return DetectionResult::new(
431                AgentStatus::Idle,
432                "codex_prompt_only",
433                DetectionConfidence::Medium,
434            );
435        }
436
437        // Footer only
438        if footer_line_idx.is_some() {
439            DetectionResult::new(
440                AgentStatus::Idle,
441                "codex_footer_only",
442                DetectionConfidence::Low,
443            )
444        } else {
445            // 11. Fallback - Processing (Low confidence)
446            DetectionResult::new(
447                AgentStatus::Processing {
448                    activity: String::new(),
449                },
450                "codex_fallback_processing",
451                DetectionConfidence::Low,
452            )
453        }
454    }
455
456    fn agent_type(&self) -> AgentType {
457        AgentType::CodexCli
458    }
459
460    fn approval_keys(&self) -> &str {
461        "Enter"
462    }
463
464    /// Detect context warning from Codex footer (e.g., "83% context left")
465    fn detect_context_warning(&self, content: &str) -> Option<u8> {
466        let lines: Vec<&str> = content.lines().collect();
467        let check_start = lines.len().saturating_sub(5);
468        for line in &lines[check_start..] {
469            if let Some(caps) = self.context_left_pattern.captures(line) {
470                if let Some(m) = caps.get(1) {
471                    if let Ok(pct) = m.as_str().parse::<u8>() {
472                        return Some(pct);
473                    }
474                }
475            }
476        }
477        None
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_idle_detection_with_title() {
487        let detector = CodexDetector::new();
488        let status = detector.detect_status("Codex - Idle", "Some content");
489        assert!(matches!(status, AgentStatus::Idle));
490    }
491
492    #[test]
493    fn test_idle_with_prompt_and_footer() {
494        let detector = CodexDetector::new();
495        // Codex uses › as input prompt, followed by footer
496        let content = r#"
497Some suggestions here
498
499› Improve documentation in @filename
500
501  ? for shortcuts                                                                                   98% context left"#;
502        let status = detector.detect_status("DESKTOP-LG7DUPN", content);
503        assert!(
504            matches!(status, AgentStatus::Idle),
505            "Expected Idle, got {:?}",
506            status
507        );
508    }
509
510    #[test]
511    fn test_user_question_with_numbered_choices() {
512        let detector = CodexDetector::new();
513        // Codex shows numbered choices when asking user a question
514        let content = r#"
515次に進めるなら、どれから着手しますか?
516
517  1. Fix the bug
518  2. Add new feature
519  3. Refactor code
520  4. Write tests
521
522523
524  ? for shortcuts                                                                                   83% context left"#;
525        let status = detector.detect_status("", content);
526        assert!(
527            matches!(
528                status,
529                AgentStatus::AwaitingApproval {
530                    approval_type: ApprovalType::UserQuestion { .. },
531                    ..
532                }
533            ),
534            "Expected AwaitingApproval with UserQuestion, got {:?}",
535            status
536        );
537
538        // Verify choices are extracted
539        if let AgentStatus::AwaitingApproval {
540            approval_type: ApprovalType::UserQuestion { choices, .. },
541            ..
542        } = status
543        {
544            assert_eq!(choices.len(), 4);
545            assert_eq!(choices[0], "Fix the bug");
546        }
547    }
548
549    #[test]
550    fn test_processing_with_spinner() {
551        let detector = CodexDetector::new();
552        // Codex shows spinner during processing
553        let content = r#"
554› Generate a summary
555
556⠋ Thinking...
557
558  ? for shortcuts                                                                                   83% context left"#;
559        let status = detector.detect_status("", content);
560        assert!(
561            matches!(status, AgentStatus::Processing { .. }),
562            "Expected Processing, got {:?}",
563            status
564        );
565    }
566
567    #[test]
568    fn test_processing_with_esc_to_interrupt() {
569        let detector = CodexDetector::new();
570        // Codex shows "esc to interrupt" during processing
571        let content = r#"
572› Fix the bug
573
574  Reading files...
575
576  esc to interrupt                                                                                   83% context left"#;
577        let status = detector.detect_status("", content);
578        assert!(
579            matches!(status, AgentStatus::Processing { .. }),
580            "Expected Processing, got {:?}",
581            status
582        );
583    }
584
585    #[test]
586    fn test_idle_with_footer_only() {
587        let detector = CodexDetector::new();
588        // Footer visible without clear prompt - assume idle
589        let content = "Some content\n  ? for shortcuts                        50% context left";
590        let status = detector.detect_status("", content);
591        assert!(matches!(status, AgentStatus::Idle));
592    }
593
594    #[test]
595    fn test_approval_detection() {
596        let detector = CodexDetector::new();
597        let content = "Do you want to proceed? [y/n]";
598        let status = detector.detect_status("Codex", content);
599        assert!(matches!(status, AgentStatus::AwaitingApproval { .. }));
600    }
601
602    #[test]
603    fn test_idle_with_slash_command_menu() {
604        let detector = CodexDetector::new();
605        // When user types "/" the slash command menu appears
606        let content = r#"
607› /
608
609  /model         choose what model and reasoning effort to use
610  /permissions   choose what Codex is allowed to do
611  /experimental  toggle experimental features
612  /skills        use skills to improve how Codex performs specific tasks
613  /review        review my current changes and find issues
614  /rename        rename the current thread
615  /new           start a new chat during a conversation
616  /resume        resume a saved chat"#;
617        let status = detector.detect_status("", content);
618        assert!(
619            matches!(status, AgentStatus::Idle),
620            "Expected Idle when slash menu is shown, got {:?}",
621            status
622        );
623    }
624
625    #[test]
626    fn test_idle_with_prompt_only() {
627        let detector = CodexDetector::new();
628        // Prompt visible but footer scrolled out
629        let content = r#"
630Some long response text...
631
632› "#;
633        let status = detector.detect_status("", content);
634        assert!(
635            matches!(status, AgentStatus::Idle),
636            "Expected Idle when prompt is visible, got {:?}",
637            status
638        );
639    }
640
641    #[test]
642    fn test_working_elapsed_time() {
643        let detector = CodexDetector::new();
644        let content = "Working (3s \u{2022} esc to interrupt)";
645        let result = detector.detect_status_with_reason("", content, &DetectionContext::default());
646        assert!(
647            matches!(result.status, AgentStatus::Processing { .. }),
648            "Expected Processing, got {:?}",
649            result.status
650        );
651        assert_eq!(result.reason.rule, "working_elapsed_time");
652        assert_eq!(result.reason.confidence, DetectionConfidence::High);
653    }
654
655    #[test]
656    fn test_exec_approval() {
657        let detector = CodexDetector::new();
658        let content = "Would you like to run the following command?\n\n  ls -la\n\nPress Enter to confirm or Esc to cancel";
659        let status = detector.detect_status("", content);
660        assert!(
661            matches!(
662                status,
663                AgentStatus::AwaitingApproval {
664                    approval_type: ApprovalType::ShellCommand,
665                    ..
666                }
667            ),
668            "Expected AwaitingApproval with ShellCommand, got {:?}",
669            status
670        );
671    }
672
673    #[test]
674    fn test_patch_approval() {
675        let detector = CodexDetector::new();
676        let content = "Would you like to make the following edits?\n\n  src/main.rs\n  + fn new_function() {}";
677        let status = detector.detect_status("", content);
678        assert!(
679            matches!(
680                status,
681                AgentStatus::AwaitingApproval {
682                    approval_type: ApprovalType::FileEdit,
683                    ..
684                }
685            ),
686            "Expected AwaitingApproval with FileEdit, got {:?}",
687            status
688        );
689    }
690
691    #[test]
692    fn test_mcp_approval() {
693        let detector = CodexDetector::new();
694        let content = "The tool 'web_search' needs your approval to run.";
695        let status = detector.detect_status("", content);
696        assert!(
697            matches!(
698                status,
699                AgentStatus::AwaitingApproval {
700                    approval_type: ApprovalType::McpTool,
701                    ..
702                }
703            ),
704            "Expected AwaitingApproval with McpTool, got {:?}",
705            status
706        );
707    }
708
709    #[test]
710    fn test_network_approval() {
711        let detector = CodexDetector::new();
712        let content = "Do you want to approve access to api.example.com?";
713        let status = detector.detect_status("", content);
714        assert!(
715            matches!(
716                status,
717                AgentStatus::AwaitingApproval {
718                    approval_type: ApprovalType::Other(ref s),
719                    ..
720                } if s == "Network"
721            ),
722            "Expected AwaitingApproval with Other(Network), got {:?}",
723            status
724        );
725    }
726
727    #[test]
728    fn test_codex_choice_pattern() {
729        let detector = CodexDetector::new();
730        let content = r#"
731Would you like to run the following command?
732
733  npm install express
734
735  Yes, proceed                      [y]
736  Yes, and don't ask again          [a]
737  No, and tell Codex why            [Esc/n]
738"#;
739        let status = detector.detect_status("", content);
740        assert!(
741            matches!(status, AgentStatus::AwaitingApproval { .. }),
742            "Expected AwaitingApproval, got {:?}",
743            status
744        );
745    }
746
747    #[test]
748    fn test_context_warning() {
749        let detector = CodexDetector::new();
750        let content =
751            "Some output\n\n  ? for shortcuts                                 83% context left";
752        let result = detector.detect_context_warning(content);
753        assert_eq!(result, Some(83));
754    }
755
756    #[test]
757    fn test_context_warning_none() {
758        let detector = CodexDetector::new();
759        let content = "Some output without context info";
760        let result = detector.detect_context_warning(content);
761        assert_eq!(result, None);
762    }
763
764    #[test]
765    fn test_confirm_footer() {
766        let detector = CodexDetector::new();
767        // Confirm footer alone should trigger approval detection
768        let content = "Some content here\n\nPress Enter to confirm or Esc to cancel";
769        let result = detector.detect_status_with_reason("", content, &DetectionContext::default());
770        assert!(
771            matches!(result.status, AgentStatus::AwaitingApproval { .. }),
772            "Expected AwaitingApproval, got {:?}",
773            result.status
774        );
775        assert_eq!(result.reason.rule, "confirm_footer");
776    }
777}