1use regex::Regex;
2
3use crate::agents::{AgentStatus, AgentType, ApprovalType};
4
5use super::{DetectionConfidence, DetectionContext, DetectionResult, StatusDetector};
6
7pub 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 pub fn new() -> Self {
18 Self {
19 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 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 let has_confirm_footer = recent_lines
38 .iter()
39 .any(|l| l.contains("Press Enter to confirm or Esc to cancel"));
40
41 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 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 if let Some(question) = self.detect_numbered_choices(recent_lines) {
83 return Some((question.0, question.1, "codex_numbered_choices"));
84 }
85
86 for line in recent_lines {
88 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 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 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 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 for line in lines.iter().rev() {
155 let trimmed = line.trim();
156
157 if trimmed.contains("% context left") || trimmed.starts_with('?') || trimmed.is_empty()
159 {
160 continue;
161 }
162
163 if trimmed.starts_with('›') {
165 found_prompt = true;
166 continue;
167 }
168
169 if let Some(choice) = self.parse_numbered_choice(trimmed) {
171 choices.push(choice);
172 } else if !choices.is_empty() {
173 if trimmed.ends_with('?') || trimmed.ends_with('?') {
175 question_text = trimmed.to_string();
176 }
177 break;
178 }
179 }
180
181 if choices.len() >= 2 && found_prompt {
183 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 fn parse_numbered_choice(&self, line: &str) -> Option<String> {
200 let trimmed = line.trim();
201 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 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 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 let lines: Vec<&str> = content.lines().collect();
279 let recent_lines: Vec<&str> = lines.iter().rev().take(15).copied().collect();
280
281 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 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 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 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 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 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 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 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 if prompt_line_idx.is_some() {
430 return DetectionResult::new(
431 AgentStatus::Idle,
432 "codex_prompt_only",
433 DetectionConfidence::Medium,
434 );
435 }
436
437 if footer_line_idx.is_some() {
439 DetectionResult::new(
440 AgentStatus::Idle,
441 "codex_footer_only",
442 DetectionConfidence::Low,
443 )
444 } else {
445 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 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 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 let content = r#"
515次に進めるなら、どれから着手しますか?
516
517 1. Fix the bug
518 2. Add new feature
519 3. Refactor code
520 4. Write tests
521
522›
523
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 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 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 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 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 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 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 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}