1use regex::Regex;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TurnState {
23 Busy,
25 AwaitingInput,
27 AwaitingApproval,
29 Unknown,
31}
32
33impl TurnState {
34 pub fn as_str(self) -> &'static str {
36 match self {
37 TurnState::Busy => "busy",
38 TurnState::AwaitingInput => "awaiting_input",
39 TurnState::AwaitingApproval => "awaiting_approval",
40 TurnState::Unknown => "unknown",
41 }
42 }
43
44 pub fn is_ready(self) -> bool {
47 matches!(self, TurnState::AwaitingInput | TurnState::AwaitingApproval)
48 }
49}
50
51#[derive(Debug, Clone, Copy)]
56pub struct TurnInput<'a> {
57 pub screen: &'a [String],
58 pub osc_title: &'a str,
59 pub osc_progress: &'a str,
60}
61
62impl<'a> TurnInput<'a> {
63 pub fn screen_only(screen: &'a [String]) -> Self {
65 Self { screen, osc_title: "", osc_progress: "" }
66 }
67}
68
69pub trait TurnRecognizer: Send + Sync {
72 fn name(&self) -> &'static str;
74 fn detect(&self, screen: &[String]) -> TurnState;
76 fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
80 self.detect(input.screen)
81 }
82}
83
84pub fn recognizer_for(hint: &str) -> Box<dyn TurnRecognizer> {
94 let hint = hint.trim().to_ascii_lowercase();
95 match hint.as_str() {
96 "generic" | "none" | "off" => return Box::new(GenericRecognizer),
97 "configurable" => return configurable_from_env(),
98 "auto" => return auto_recognizer(),
99 _ => {}
100 }
101 let legacy: Option<Box<dyn TurnRecognizer>> = match hint.as_str() {
102 "claude" => Some(Box::new(ClaudeRecognizer)),
103 "codex" => Some(Box::new(CodexRecognizer)),
104 "antigravity" | "agy" | "gemini" => Some(Box::new(AntigravityRecognizer)),
108 _ => None,
109 };
110 match crate::state::turn_manifest::manifest_for(&hint) {
111 Some(manifest) => Box::new(ManifestRecognizer { manifest, fallback: legacy }),
112 None => legacy.unwrap_or_else(auto_recognizer),
113 }
114}
115
116fn auto_recognizer() -> Box<dyn TurnRecognizer> {
120 use crate::state::turn_manifest::manifest_for;
121 let (Some(claude), Some(codex)) = (manifest_for("claude"), manifest_for("codex")) else {
122 return Box::new(AutoRecognizer);
123 };
124 Box::new(ManifestAutoRecognizer {
125 members: [
126 ManifestRecognizer { manifest: claude, fallback: Some(Box::new(ClaudeRecognizer)) },
127 ManifestRecognizer { manifest: codex, fallback: Some(Box::new(CodexRecognizer)) },
128 ],
129 })
130}
131
132fn configurable_from_env() -> Box<dyn TurnRecognizer> {
136 if let Ok(json) = std::env::var("WINX_TURN_RECOGNIZER_CONFIG") {
137 if let Ok(markers) = serde_json::from_str::<crate::types::TurnRecognizerMarkers>(&json) {
138 if !markers.is_empty() {
139 return Box::new(GenericConfigurable::new(&markers));
140 }
141 }
142 }
143 Box::new(GenericRecognizer)
144}
145
146fn any_line_contains(screen: &[String], needle: &str) -> bool {
156 screen.iter().any(|l| l.to_lowercase().contains(needle))
157}
158
159fn tail_lower(screen: &[String], n: usize) -> String {
163 let mut lines: Vec<String> = screen
164 .iter()
165 .rev()
166 .filter(|l| !l.trim().is_empty())
167 .take(n)
168 .map(|l| l.to_lowercase())
169 .collect();
170 lines.reverse();
171 lines.join("\n")
172}
173
174pub struct ClaudeRecognizer;
186
187impl ClaudeRecognizer {
188 fn is_approval(screen: &[String]) -> bool {
189 let joined = tail_lower(screen, 30);
194 joined.contains("do you want")
195 || joined.contains("would you like")
196 || joined.contains("trust this folder")
197 || joined.contains("enter to confirm")
198 || joined.contains("esc to cancel")
199 }
200
201 fn has_input_box(screen: &[String]) -> bool {
202 screen.iter().any(|l| l.contains('❯'))
205 }
206}
207
208impl TurnRecognizer for ClaudeRecognizer {
209 fn name(&self) -> &'static str {
210 "claude"
211 }
212
213 fn detect(&self, screen: &[String]) -> TurnState {
214 if any_line_contains(screen, "esc to interrupt") {
217 return TurnState::Busy;
218 }
219 if Self::is_approval(screen) {
220 return TurnState::AwaitingApproval;
221 }
222 if Self::has_input_box(screen) {
223 return TurnState::AwaitingInput;
224 }
225 TurnState::Unknown
226 }
227}
228
229pub struct CodexRecognizer;
241
242impl CodexRecognizer {
243 fn is_approval(screen: &[String]) -> bool {
244 let joined = tail_lower(screen, 30);
248 joined.contains("would you like to run")
249 || joined.contains("press enter to confirm")
250 || joined.contains("yes, proceed")
251 }
252
253 fn has_input_box(screen: &[String]) -> bool {
254 screen.iter().any(|l| l.trim_start().starts_with('›'))
257 }
258}
259
260impl TurnRecognizer for CodexRecognizer {
261 fn name(&self) -> &'static str {
262 "codex"
263 }
264
265 fn detect(&self, screen: &[String]) -> TurnState {
266 if any_line_contains(screen, "esc to interrupt")
274 || any_line_contains(screen, "preparing to execute")
275 {
276 return TurnState::Busy;
277 }
278 if Self::is_approval(screen) {
279 return TurnState::AwaitingApproval;
280 }
281 if Self::has_input_box(screen) {
282 return TurnState::AwaitingInput;
283 }
284 TurnState::Unknown
285 }
286}
287
288pub struct AntigravityRecognizer;
303
304impl TurnRecognizer for AntigravityRecognizer {
305 fn name(&self) -> &'static str {
306 "antigravity"
307 }
308
309 fn detect(&self, screen: &[String]) -> TurnState {
310 if tail_lower(screen, 4).contains("? for shortcuts") {
316 return TurnState::AwaitingInput;
317 }
318 if any_line_contains(screen, "generating") || any_line_contains(screen, "esc to cancel") {
319 return TurnState::Busy;
320 }
321 TurnState::Unknown
322 }
323}
324
325pub struct GenericRecognizer;
330
331impl TurnRecognizer for GenericRecognizer {
332 fn name(&self) -> &'static str {
333 "generic"
334 }
335
336 fn detect(&self, _screen: &[String]) -> TurnState {
337 TurnState::Unknown
338 }
339}
340
341pub struct GenericConfigurable {
349 busy: Vec<Regex>,
350 approval: Vec<Regex>,
351 input: Vec<Regex>,
352}
353
354impl GenericConfigurable {
355 pub fn new(markers: &crate::types::TurnRecognizerMarkers) -> Self {
356 Self {
357 busy: compile_markers(&markers.busy),
358 approval: compile_markers(&markers.awaiting_approval),
359 input: compile_markers(&markers.awaiting_input),
360 }
361 }
362}
363
364fn compile_markers(patterns: &[String]) -> Vec<Regex> {
367 patterns
368 .iter()
369 .filter_map(|pattern| {
370 Regex::new(&format!("(?i){pattern}"))
371 .or_else(|_| Regex::new(&format!("(?i){}", regex::escape(pattern))))
372 .ok()
373 })
374 .collect()
375}
376
377impl TurnRecognizer for GenericConfigurable {
378 fn name(&self) -> &'static str {
379 "configurable"
380 }
381
382 fn detect(&self, screen: &[String]) -> TurnState {
383 let whole = screen.join("\n");
384 if self.busy.iter().any(|re| re.is_match(&whole)) {
385 return TurnState::Busy;
386 }
387 let tail = tail_lower(screen, 30);
388 if self.approval.iter().any(|re| re.is_match(&tail)) {
389 return TurnState::AwaitingApproval;
390 }
391 if self.input.iter().any(|re| re.is_match(&tail)) {
392 return TurnState::AwaitingInput;
393 }
394 TurnState::Unknown
395 }
396}
397
398pub struct ManifestRecognizer {
407 manifest: &'static crate::state::turn_manifest::CompiledManifest,
408 fallback: Option<Box<dyn TurnRecognizer>>,
409}
410
411impl TurnRecognizer for ManifestRecognizer {
412 fn name(&self) -> &'static str {
413 let manifest: &'static crate::state::turn_manifest::CompiledManifest = self.manifest;
414 manifest.id()
415 }
416
417 fn detect(&self, screen: &[String]) -> TurnState {
418 self.detect_input(&TurnInput::screen_only(screen))
419 }
420
421 fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
422 let joined = input.screen.join("\n");
423 let verdict = self.manifest.detect(crate::state::turn_manifest::DetectionInput {
424 screen: &joined,
425 osc_title: input.osc_title,
426 osc_progress: input.osc_progress,
427 });
428 if let Some((rule_id, priority)) = verdict.rule {
429 tracing::trace!(
430 manifest = self.name(),
431 rule = rule_id,
432 priority,
433 state = verdict.state.as_str(),
434 "turn manifest rule matched"
435 );
436 return verdict.state;
437 }
438 match &self.fallback {
439 Some(fallback) => fallback.detect_input(input),
440 None => TurnState::Unknown,
441 }
442 }
443}
444
445struct ManifestAutoRecognizer {
450 members: [ManifestRecognizer; 2],
451}
452
453impl TurnRecognizer for ManifestAutoRecognizer {
454 fn name(&self) -> &'static str {
455 "auto"
456 }
457
458 fn detect(&self, screen: &[String]) -> TurnState {
459 self.detect_input(&TurnInput::screen_only(screen))
460 }
461
462 fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
463 let states = self.members.each_ref().map(|member| member.detect_input(input));
464 for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
465 if states.contains(&want) {
466 return want;
467 }
468 }
469 TurnState::Unknown
470 }
471}
472
473pub struct AutoRecognizer;
481
482impl TurnRecognizer for AutoRecognizer {
483 fn name(&self) -> &'static str {
484 "auto"
485 }
486
487 fn detect(&self, screen: &[String]) -> TurnState {
488 let states = [ClaudeRecognizer.detect(screen), CodexRecognizer.detect(screen)];
489 for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
490 if states.contains(&want) {
491 return want;
492 }
493 }
494 TurnState::Unknown
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn lines(s: &[&str]) -> Vec<String> {
503 s.iter().map(|x| (*x).to_string()).collect()
504 }
505
506 #[test]
507 fn configurable_classifies_by_marker_case_insensitively() {
508 let markers = crate::types::TurnRecognizerMarkers {
509 busy: vec!["working".to_string()],
510 awaiting_input: vec![r"\$".to_string()],
511 awaiting_approval: vec!["confirm".to_string()],
512 };
513 let recognizer = GenericConfigurable::new(&markers);
514 assert_eq!(recognizer.detect(&lines(&["WORKING on it"])), TurnState::Busy);
515 assert_eq!(recognizer.detect(&lines(&["user@host:~$ "])), TurnState::AwaitingInput);
516 assert_eq!(
517 recognizer.detect(&lines(&["Please Confirm (y/n)"])),
518 TurnState::AwaitingApproval
519 );
520 assert_eq!(recognizer.detect(&lines(&["just some text"])), TurnState::Unknown);
521 }
522
523 #[test]
524 fn configurable_busy_beats_a_stale_prompt() {
525 let markers = crate::types::TurnRecognizerMarkers {
526 busy: vec!["esc to interrupt".to_string()],
527 awaiting_input: vec![">".to_string()],
528 awaiting_approval: vec![],
529 };
530 let recognizer = GenericConfigurable::new(&markers);
531 assert_eq!(recognizer.detect(&lines(&["> ", "esc to interrupt"])), TurnState::Busy);
533 }
534
535 #[test]
539 fn claude_busy_via_esc_to_interrupt() {
540 let screen = lines(&[
541 "❯ responda exatamente uma palavra: PONG",
542 "",
543 "* Hatching…",
544 "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
545 ]);
546 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
547 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
548 }
549
550 #[test]
551 fn claude_long_response_busy_even_with_stale_prompt() {
552 let mut screen = vec![
557 "❯ old prompt from a previous turn".to_string(),
558 String::new(),
559 "●─here is a long answer".to_string(),
560 ];
561 screen.push("⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt".to_string());
562 for i in 0..40 {
563 screen.push(format!("streamed answer line {i}"));
564 }
565 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
566 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
567 }
568
569 #[test]
570 fn claude_idle_after_answer_is_input_not_busy() {
571 let screen = lines(&[
575 "❯ Responda com exatamente uma palavra, em maiúsculas: PONG",
576 "· Gusting…",
577 "●─PONG",
578 "✽ Gusting… (3s · ↓ 1 tokens)",
579 "✻ Baked for 4s",
580 "❯",
581 "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
582 ]);
583 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
584 }
585
586 #[test]
587 fn claude_numbered_prose_is_not_approval() {
588 let screen = lines(&[
591 "❯ compare the methodologies",
592 "1. Agile - allows iterative delivery",
593 "2. Waterfall - assumes requirements are known up front",
594 "❯",
595 "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
596 ]);
597 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
598 }
599
600 #[test]
601 fn claude_awaiting_approval_trust_prompt() {
602 let screen = lines(&[
604 "Quick safety check: Is this a project you created or one you trust?",
605 "❯ 1. Yes, I trust this folder",
606 " 2. No, exit",
607 "Enter to confirm · Esc to cancel",
608 ]);
609 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
610 }
611
612 #[test]
613 fn claude_awaiting_approval_tool_menu() {
614 let screen = lines(&[
615 "⏺ Bash(rm -rf /tmp/x)",
616 "Do you want to proceed?",
617 "❯ 1. Yes",
618 " 2. No, and tell Claude what to do differently",
619 ]);
620 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
621 }
622
623 #[test]
624 fn auto_surfaces_codex_input_when_claude_is_blind() {
625 let screen = lines(&["• PONG", "› Run /review on my current changes"]);
629 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Unknown);
630 assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
631 assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingInput);
632 }
633
634 #[test]
635 fn auto_stays_busy_with_stale_prompt_glyphs() {
636 let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
640 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
641 }
642
643 #[test]
647 fn codex_busy_via_working_footer() {
648 let screen = lines(&[
649 "› escreva um poema longo sobre o oceano",
650 "",
651 "• Working (1s • esc to interrupt)",
652 "",
653 " gpt-5.5 high · ~/Documents/projects/winx-code-agent",
654 ]);
655 assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
656 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
657 }
658
659 #[test]
660 fn codex_idle_input_box_uses_angle_prompt() {
661 let screen = lines(&[
665 "• PONG",
666 "",
667 "› Run /review on my current changes",
668 "",
669 " gpt-5.5 high · ~/Documents/projects/winx-code-agent",
670 ]);
671 assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
672 }
673
674 #[test]
675 fn codex_awaiting_approval_command_dialog() {
676 let screen = lines(&[
680 " Would you like to run the following command?",
681 "",
682 " $ touch /tmp/x.txt",
683 "",
684 "› 1. Yes, proceed (y)",
685 " 2. Yes, and don't ask again (p)",
686 " 3. No, and tell Codex what to do differently (esc)",
687 "",
688 " Press enter to confirm or esc to cancel",
689 ]);
690 assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingApproval);
691 assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingApproval);
692 }
693
694 #[test]
695 fn codex_preparing_to_execute_is_busy_not_input() {
696 let screen = lines(&[
701 "› execute o comando de shell 'touch /tmp/x.txt'",
702 "• Vou executar exatamente esse comando",
703 "• Preparing to execute shell command 9",
704 ]);
705 assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
706 }
707
708 #[test]
709 fn codex_busy_wins_over_stale_angle_prompt() {
710 let screen = lines(&["› an earlier prompt", "• Working (3s • esc to interrupt)"]);
713 assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
714 }
715
716 #[test]
722 fn antigravity_busy_via_generating_and_esc_to_cancel() {
723 let screen = lines(&[
724 "> responda com uma palavra: PONG Gemini 3.5 Flash (Medium)",
725 "⣾ Generating...",
726 " PONG",
727 "esc to cancel Gemini 3.5 Flash (Medium)",
728 ]);
729 assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::Busy);
730 }
731
732 #[test]
733 fn antigravity_idle_via_shortcuts_hint_despite_ghost_frames() {
734 let screen = lines(&[
738 " PONG",
739 "⣯ Generating..",
740 "esc to cancel Gemini 3.5 Flash (Medium)",
741 "? for shortcuts",
742 ]);
743 assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
744 }
745
746 #[test]
747 fn antigravity_is_opt_in_not_part_of_auto() {
748 let screen = lines(&["? for shortcuts"]);
751 assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
752 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
753 }
754
755 #[test]
756 fn generic_is_always_unknown() {
757 let screen = lines(&["$ ls", "file.txt", "$"]);
758 assert_eq!(GenericRecognizer.detect(&screen), TurnState::Unknown);
759 }
760
761 #[test]
762 fn auto_falls_back_to_unknown_for_plain_shell() {
763 let screen = lines(&["building...", "done", "user@host:~$"]);
764 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
765 }
766
767 #[test]
768 fn turn_state_ready_semantics() {
769 assert!(TurnState::AwaitingInput.is_ready());
770 assert!(TurnState::AwaitingApproval.is_ready());
771 assert!(!TurnState::Busy.is_ready());
772 assert!(!TurnState::Unknown.is_ready());
773 }
774
775 #[test]
778 fn recognizer_for_resolves_manifest_hints() {
779 assert_eq!(recognizer_for("claude").name(), "claude");
781 assert_eq!(recognizer_for("codex").name(), "codex");
782 assert_eq!(recognizer_for("gemini").name(), "gemini");
783 assert_eq!(recognizer_for("agy").name(), "agy");
784 assert_eq!(recognizer_for("cursor").name(), "cursor");
786 assert_eq!(recognizer_for("opencode").name(), "opencode");
787 assert_eq!(recognizer_for("ghcs").name(), "copilot");
788 assert_eq!(recognizer_for("auto").name(), "auto");
790 assert_eq!(recognizer_for("generic").name(), "generic");
791 assert_eq!(recognizer_for("something-else").name(), "auto");
792 }
793
794 #[test]
795 fn manifest_claude_falls_back_to_legacy_markers_on_a_miss() {
796 let screen = lines(&["● answered", "❯"]);
799 let recognizer = recognizer_for("claude");
800 assert_eq!(recognizer.detect(&screen), TurnState::AwaitingInput);
801 }
802
803 #[test]
804 fn manifest_rules_beat_legacy_on_current_claude_screens() {
805 let screen = lines(&["●─PONG", "──────────", "❯ ", "──────────", " ⏵⏵ accept edits on"]);
810 assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingInput);
811
812 let screen = lines(&[
815 "⏺ Bash(cargo test)",
816 "──────────",
817 "Do you want to proceed?",
818 "❯ 1. Yes",
819 " 2. No, and tell Claude what to do differently",
820 "Esc to cancel",
821 ]);
822 assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingApproval);
823 }
824
825 #[test]
826 fn manifest_explicit_unknown_does_not_fall_through_to_legacy() {
827 let screen = lines(&[
832 "⏺ old tool output esc to interrupt",
833 "Showing detailed transcript",
834 "ctrl+o to toggle",
835 ]);
836 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
837 assert_eq!(recognizer_for("claude").detect(&screen), TurnState::Unknown);
838 }
839
840 #[test]
841 fn osc_title_spinner_keeps_claude_busy_over_an_idle_looking_screen() {
842 let screen = lines(&["❯ old prompt"]);
845 let recognizer = recognizer_for("claude");
846 let input = TurnInput { screen: &screen, osc_title: "⠋ claude", osc_progress: "" };
847 assert_eq!(recognizer.detect_input(&input), TurnState::Busy);
848 assert_eq!(
850 recognizer.detect_input(&TurnInput::screen_only(&screen)),
851 TurnState::AwaitingInput
852 );
853 }
854
855 #[test]
856 fn auto_combines_manifest_and_legacy_verdicts() {
857 let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
859 assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Busy);
860 let screen = lines(&["• PONG", "› Run /review on my current changes"]);
862 assert_eq!(recognizer_for("auto").detect(&screen), TurnState::AwaitingInput);
863 let screen = lines(&["building...", "done", "user@host:~$"]);
865 assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Unknown);
866 }
867}