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
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct TurnExplanation {
72 pub state: TurnState,
73 pub recognizer: String,
74 pub source: String,
75 pub matched_rule: Option<String>,
76 pub priority: Option<i32>,
77 pub reason: Option<String>,
78}
79
80pub trait TurnRecognizer: Send + Sync {
83 fn name(&self) -> &'static str;
85 fn detect(&self, screen: &[String]) -> TurnState;
87 fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
91 self.detect(input.screen)
92 }
93 fn explain_input(&self, input: &TurnInput<'_>) -> TurnExplanation {
95 let state = self.detect_input(input);
96 TurnExplanation {
97 state,
98 recognizer: self.name().to_string(),
99 source: "recognizer".to_string(),
100 matched_rule: None,
101 priority: None,
102 reason: (state == TurnState::Unknown).then(|| "no recognized evidence".to_string()),
103 }
104 }
105}
106
107pub fn recognizer_for(hint: &str) -> Box<dyn TurnRecognizer> {
117 let hint = hint.trim().to_ascii_lowercase();
118 match hint.as_str() {
119 "generic" | "none" | "off" => return Box::new(GenericRecognizer),
120 "configurable" => return configurable_from_env(),
121 "auto" => return auto_recognizer(),
122 _ => {}
123 }
124 let legacy: Option<Box<dyn TurnRecognizer>> = match hint.as_str() {
125 "claude" => Some(Box::new(ClaudeRecognizer)),
126 "codex" => Some(Box::new(CodexRecognizer)),
127 "antigravity" | "agy" | "gemini" => Some(Box::new(AntigravityRecognizer)),
131 _ => None,
132 };
133 match crate::state::turn_manifest::manifest_for(&hint) {
134 Some(manifest) => Box::new(ManifestRecognizer { manifest, fallback: legacy }),
135 None => legacy.unwrap_or_else(auto_recognizer),
136 }
137}
138
139fn auto_recognizer() -> Box<dyn TurnRecognizer> {
143 use crate::state::turn_manifest::manifest_for;
144 let (Some(claude), Some(codex)) = (manifest_for("claude"), manifest_for("codex")) else {
145 return Box::new(AutoRecognizer);
146 };
147 Box::new(ManifestAutoRecognizer {
148 members: [
149 ManifestRecognizer { manifest: claude, fallback: Some(Box::new(ClaudeRecognizer)) },
150 ManifestRecognizer { manifest: codex, fallback: Some(Box::new(CodexRecognizer)) },
151 ],
152 })
153}
154
155fn configurable_from_env() -> Box<dyn TurnRecognizer> {
159 if let Ok(json) = std::env::var("WINX_TURN_RECOGNIZER_CONFIG") {
160 if let Ok(markers) = serde_json::from_str::<crate::types::TurnRecognizerMarkers>(&json) {
161 if !markers.is_empty() {
162 return Box::new(GenericConfigurable::new(&markers));
163 }
164 }
165 }
166 Box::new(GenericRecognizer)
167}
168
169fn any_line_contains(screen: &[String], needle: &str) -> bool {
179 screen.iter().any(|l| l.to_lowercase().contains(needle))
180}
181
182fn tail_lower(screen: &[String], n: usize) -> String {
186 let mut lines: Vec<String> = screen
187 .iter()
188 .rev()
189 .filter(|l| !l.trim().is_empty())
190 .take(n)
191 .map(|l| l.to_lowercase())
192 .collect();
193 lines.reverse();
194 lines.join("\n")
195}
196
197pub struct ClaudeRecognizer;
209
210impl ClaudeRecognizer {
211 fn is_approval(screen: &[String]) -> bool {
212 let joined = tail_lower(screen, 30);
217 joined.contains("do you want")
218 || joined.contains("would you like")
219 || joined.contains("trust this folder")
220 || joined.contains("enter to confirm")
221 || joined.contains("esc to cancel")
222 }
223
224 fn has_input_box(screen: &[String]) -> bool {
225 screen.iter().any(|l| l.contains('❯'))
228 }
229}
230
231impl TurnRecognizer for ClaudeRecognizer {
232 fn name(&self) -> &'static str {
233 "claude"
234 }
235
236 fn detect(&self, screen: &[String]) -> TurnState {
237 if any_line_contains(screen, "esc to interrupt") {
240 return TurnState::Busy;
241 }
242 if Self::is_approval(screen) {
243 return TurnState::AwaitingApproval;
244 }
245 if Self::has_input_box(screen) {
246 return TurnState::AwaitingInput;
247 }
248 TurnState::Unknown
249 }
250}
251
252pub struct CodexRecognizer;
264
265impl CodexRecognizer {
266 fn is_approval(screen: &[String]) -> bool {
267 let joined = tail_lower(screen, 30);
271 joined.contains("would you like to run")
272 || joined.contains("press enter to confirm")
273 || joined.contains("yes, proceed")
274 }
275
276 fn has_input_box(screen: &[String]) -> bool {
277 screen.iter().any(|l| l.trim_start().starts_with('›'))
280 }
281}
282
283impl TurnRecognizer for CodexRecognizer {
284 fn name(&self) -> &'static str {
285 "codex"
286 }
287
288 fn detect(&self, screen: &[String]) -> TurnState {
289 if any_line_contains(screen, "esc to interrupt")
297 || any_line_contains(screen, "preparing to execute")
298 {
299 return TurnState::Busy;
300 }
301 if Self::is_approval(screen) {
302 return TurnState::AwaitingApproval;
303 }
304 if Self::has_input_box(screen) {
305 return TurnState::AwaitingInput;
306 }
307 TurnState::Unknown
308 }
309}
310
311pub struct AntigravityRecognizer;
326
327impl TurnRecognizer for AntigravityRecognizer {
328 fn name(&self) -> &'static str {
329 "antigravity"
330 }
331
332 fn detect(&self, screen: &[String]) -> TurnState {
333 if tail_lower(screen, 4).contains("? for shortcuts") {
339 return TurnState::AwaitingInput;
340 }
341 if any_line_contains(screen, "generating") || any_line_contains(screen, "esc to cancel") {
342 return TurnState::Busy;
343 }
344 TurnState::Unknown
345 }
346}
347
348pub struct GenericRecognizer;
353
354impl TurnRecognizer for GenericRecognizer {
355 fn name(&self) -> &'static str {
356 "generic"
357 }
358
359 fn detect(&self, _screen: &[String]) -> TurnState {
360 TurnState::Unknown
361 }
362}
363
364pub struct GenericConfigurable {
372 busy: Vec<Regex>,
373 approval: Vec<Regex>,
374 input: Vec<Regex>,
375}
376
377impl GenericConfigurable {
378 pub fn new(markers: &crate::types::TurnRecognizerMarkers) -> Self {
379 Self {
380 busy: compile_markers(&markers.busy),
381 approval: compile_markers(&markers.awaiting_approval),
382 input: compile_markers(&markers.awaiting_input),
383 }
384 }
385}
386
387fn compile_markers(patterns: &[String]) -> Vec<Regex> {
390 patterns
391 .iter()
392 .filter_map(|pattern| {
393 Regex::new(&format!("(?i){pattern}"))
394 .or_else(|_| Regex::new(&format!("(?i){}", regex::escape(pattern))))
395 .ok()
396 })
397 .collect()
398}
399
400impl TurnRecognizer for GenericConfigurable {
401 fn name(&self) -> &'static str {
402 "configurable"
403 }
404
405 fn detect(&self, screen: &[String]) -> TurnState {
406 let whole = screen.join("\n");
407 if self.busy.iter().any(|re| re.is_match(&whole)) {
408 return TurnState::Busy;
409 }
410 let tail = tail_lower(screen, 30);
411 if self.approval.iter().any(|re| re.is_match(&tail)) {
412 return TurnState::AwaitingApproval;
413 }
414 if self.input.iter().any(|re| re.is_match(&tail)) {
415 return TurnState::AwaitingInput;
416 }
417 TurnState::Unknown
418 }
419}
420
421pub struct ManifestRecognizer {
430 manifest: &'static crate::state::turn_manifest::CompiledManifest,
431 fallback: Option<Box<dyn TurnRecognizer>>,
432}
433
434impl TurnRecognizer for ManifestRecognizer {
435 fn name(&self) -> &'static str {
436 let manifest: &'static crate::state::turn_manifest::CompiledManifest = self.manifest;
437 manifest.id()
438 }
439
440 fn detect(&self, screen: &[String]) -> TurnState {
441 self.detect_input(&TurnInput::screen_only(screen))
442 }
443
444 fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
445 self.explain_input(input).state
446 }
447
448 fn explain_input(&self, input: &TurnInput<'_>) -> TurnExplanation {
449 let joined = input.screen.join("\n");
450 let verdict = self.manifest.detect(crate::state::turn_manifest::DetectionInput {
451 screen: &joined,
452 osc_title: input.osc_title,
453 osc_progress: input.osc_progress,
454 });
455 if let Some((rule_id, priority)) = verdict.rule {
456 return TurnExplanation {
457 state: verdict.state,
458 recognizer: self.name().to_string(),
459 source: "manifest".to_string(),
460 matched_rule: Some(rule_id.to_string()),
461 priority: Some(priority),
462 reason: (verdict.state == TurnState::Unknown)
463 .then(|| "matching manifest rule explicitly selected unknown".to_string()),
464 };
465 }
466 match &self.fallback {
467 Some(fallback) => {
468 let mut explanation = fallback.explain_input(input);
469 explanation.source = "fallback".to_string();
470 explanation.reason =
471 Some("no manifest rule matched; legacy recognizer used".to_string());
472 explanation
473 }
474 None => TurnExplanation {
475 state: TurnState::Unknown,
476 recognizer: self.name().to_string(),
477 source: "manifest".to_string(),
478 matched_rule: None,
479 priority: None,
480 reason: Some("no manifest rule matched and no fallback exists".to_string()),
481 },
482 }
483 }
484}
485
486struct ManifestAutoRecognizer {
491 members: [ManifestRecognizer; 2],
492}
493
494impl TurnRecognizer for ManifestAutoRecognizer {
495 fn name(&self) -> &'static str {
496 "auto"
497 }
498
499 fn detect(&self, screen: &[String]) -> TurnState {
500 self.detect_input(&TurnInput::screen_only(screen))
501 }
502
503 fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
504 self.explain_input(input).state
505 }
506
507 fn explain_input(&self, input: &TurnInput<'_>) -> TurnExplanation {
508 let explanations = self.members.each_ref().map(|member| member.explain_input(input));
509 for want in [
510 TurnState::Busy,
511 TurnState::AwaitingApproval,
512 TurnState::AwaitingInput,
513 TurnState::Unknown,
514 ] {
515 if let Some(explanation) =
516 explanations.iter().find(|explanation| explanation.state == want)
517 {
518 return explanation.clone();
519 }
520 }
521 unreachable!("both recognizers always classify a turn")
522 }
523}
524
525pub struct AutoRecognizer;
533
534impl TurnRecognizer for AutoRecognizer {
535 fn name(&self) -> &'static str {
536 "auto"
537 }
538
539 fn detect(&self, screen: &[String]) -> TurnState {
540 let states = [ClaudeRecognizer.detect(screen), CodexRecognizer.detect(screen)];
541 for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
542 if states.contains(&want) {
543 return want;
544 }
545 }
546 TurnState::Unknown
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn lines(s: &[&str]) -> Vec<String> {
555 s.iter().map(|x| (*x).to_string()).collect()
556 }
557
558 #[test]
559 fn configurable_classifies_by_marker_case_insensitively() {
560 let markers = crate::types::TurnRecognizerMarkers {
561 busy: vec!["working".to_string()],
562 awaiting_input: vec![r"\$".to_string()],
563 awaiting_approval: vec!["confirm".to_string()],
564 };
565 let recognizer = GenericConfigurable::new(&markers);
566 assert_eq!(recognizer.detect(&lines(&["WORKING on it"])), TurnState::Busy);
567 assert_eq!(recognizer.detect(&lines(&["user@host:~$ "])), TurnState::AwaitingInput);
568 assert_eq!(
569 recognizer.detect(&lines(&["Please Confirm (y/n)"])),
570 TurnState::AwaitingApproval
571 );
572 assert_eq!(recognizer.detect(&lines(&["just some text"])), TurnState::Unknown);
573 }
574
575 #[test]
576 fn configurable_busy_beats_a_stale_prompt() {
577 let markers = crate::types::TurnRecognizerMarkers {
578 busy: vec!["esc to interrupt".to_string()],
579 awaiting_input: vec![">".to_string()],
580 awaiting_approval: vec![],
581 };
582 let recognizer = GenericConfigurable::new(&markers);
583 assert_eq!(recognizer.detect(&lines(&["> ", "esc to interrupt"])), TurnState::Busy);
585 }
586
587 #[test]
591 fn claude_busy_via_esc_to_interrupt() {
592 let screen = lines(&[
593 "❯ responda exatamente uma palavra: PONG",
594 "",
595 "* Hatching…",
596 "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
597 ]);
598 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
599 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
600 }
601
602 #[test]
603 fn claude_long_response_busy_even_with_stale_prompt() {
604 let mut screen = vec![
609 "❯ old prompt from a previous turn".to_string(),
610 String::new(),
611 "●─here is a long answer".to_string(),
612 ];
613 screen.push("⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt".to_string());
614 for i in 0..40 {
615 screen.push(format!("streamed answer line {i}"));
616 }
617 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
618 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
619 }
620
621 #[test]
622 fn claude_idle_after_answer_is_input_not_busy() {
623 let screen = lines(&[
627 "❯ Responda com exatamente uma palavra, em maiúsculas: PONG",
628 "· Gusting…",
629 "●─PONG",
630 "✽ Gusting… (3s · ↓ 1 tokens)",
631 "✻ Baked for 4s",
632 "❯",
633 "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
634 ]);
635 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
636 }
637
638 #[test]
639 fn claude_numbered_prose_is_not_approval() {
640 let screen = lines(&[
643 "❯ compare the methodologies",
644 "1. Agile - allows iterative delivery",
645 "2. Waterfall - assumes requirements are known up front",
646 "❯",
647 "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
648 ]);
649 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
650 }
651
652 #[test]
653 fn claude_awaiting_approval_trust_prompt() {
654 let screen = lines(&[
656 "Quick safety check: Is this a project you created or one you trust?",
657 "❯ 1. Yes, I trust this folder",
658 " 2. No, exit",
659 "Enter to confirm · Esc to cancel",
660 ]);
661 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
662 }
663
664 #[test]
665 fn claude_awaiting_approval_tool_menu() {
666 let screen = lines(&[
667 "⏺ Bash(rm -rf /tmp/x)",
668 "Do you want to proceed?",
669 "❯ 1. Yes",
670 " 2. No, and tell Claude what to do differently",
671 ]);
672 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
673 }
674
675 #[test]
676 fn auto_surfaces_codex_input_when_claude_is_blind() {
677 let screen = lines(&["• PONG", "› Run /review on my current changes"]);
681 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Unknown);
682 assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
683 assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingInput);
684 }
685
686 #[test]
687 fn auto_stays_busy_with_stale_prompt_glyphs() {
688 let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
692 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
693 }
694
695 #[test]
699 fn codex_busy_via_working_footer() {
700 let screen = lines(&[
701 "› escreva um poema longo sobre o oceano",
702 "",
703 "• Working (1s • esc to interrupt)",
704 "",
705 " gpt-5.5 high · ~/Documents/projects/winx-code-agent",
706 ]);
707 assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
708 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
709 }
710
711 #[test]
712 fn codex_idle_input_box_uses_angle_prompt() {
713 let screen = lines(&[
717 "• PONG",
718 "",
719 "› Run /review on my current changes",
720 "",
721 " gpt-5.5 high · ~/Documents/projects/winx-code-agent",
722 ]);
723 assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
724 }
725
726 #[test]
727 fn codex_awaiting_approval_command_dialog() {
728 let screen = lines(&[
732 " Would you like to run the following command?",
733 "",
734 " $ touch /tmp/x.txt",
735 "",
736 "› 1. Yes, proceed (y)",
737 " 2. Yes, and don't ask again (p)",
738 " 3. No, and tell Codex what to do differently (esc)",
739 "",
740 " Press enter to confirm or esc to cancel",
741 ]);
742 assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingApproval);
743 assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingApproval);
744 }
745
746 #[test]
747 fn codex_preparing_to_execute_is_busy_not_input() {
748 let screen = lines(&[
753 "› execute o comando de shell 'touch /tmp/x.txt'",
754 "• Vou executar exatamente esse comando",
755 "• Preparing to execute shell command 9",
756 ]);
757 assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
758 }
759
760 #[test]
761 fn codex_busy_wins_over_stale_angle_prompt() {
762 let screen = lines(&["› an earlier prompt", "• Working (3s • esc to interrupt)"]);
765 assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
766 }
767
768 #[test]
774 fn antigravity_busy_via_generating_and_esc_to_cancel() {
775 let screen = lines(&[
776 "> responda com uma palavra: PONG Gemini 3.5 Flash (Medium)",
777 "⣾ Generating...",
778 " PONG",
779 "esc to cancel Gemini 3.5 Flash (Medium)",
780 ]);
781 assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::Busy);
782 }
783
784 #[test]
785 fn antigravity_idle_via_shortcuts_hint_despite_ghost_frames() {
786 let screen = lines(&[
790 " PONG",
791 "⣯ Generating..",
792 "esc to cancel Gemini 3.5 Flash (Medium)",
793 "? for shortcuts",
794 ]);
795 assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
796 }
797
798 #[test]
799 fn antigravity_is_opt_in_not_part_of_auto() {
800 let screen = lines(&["? for shortcuts"]);
803 assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
804 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
805 }
806
807 #[test]
808 fn generic_is_always_unknown() {
809 let screen = lines(&["$ ls", "file.txt", "$"]);
810 assert_eq!(GenericRecognizer.detect(&screen), TurnState::Unknown);
811 }
812
813 #[test]
814 fn auto_falls_back_to_unknown_for_plain_shell() {
815 let screen = lines(&["building...", "done", "user@host:~$"]);
816 assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
817 }
818
819 #[test]
820 fn turn_state_ready_semantics() {
821 assert!(TurnState::AwaitingInput.is_ready());
822 assert!(TurnState::AwaitingApproval.is_ready());
823 assert!(!TurnState::Busy.is_ready());
824 assert!(!TurnState::Unknown.is_ready());
825 }
826
827 #[test]
830 fn recognizer_for_resolves_manifest_hints() {
831 assert_eq!(recognizer_for("claude").name(), "claude");
833 assert_eq!(recognizer_for("codex").name(), "codex");
834 assert_eq!(recognizer_for("gemini").name(), "gemini");
835 assert_eq!(recognizer_for("agy").name(), "agy");
836 assert_eq!(recognizer_for("cursor").name(), "cursor");
838 assert_eq!(recognizer_for("opencode").name(), "opencode");
839 assert_eq!(recognizer_for("ghcs").name(), "copilot");
840 assert_eq!(recognizer_for("auto").name(), "auto");
842 assert_eq!(recognizer_for("generic").name(), "generic");
843 assert_eq!(recognizer_for("something-else").name(), "auto");
844 }
845
846 #[test]
847 fn manifest_claude_falls_back_to_legacy_markers_on_a_miss() {
848 let screen = lines(&["● answered", "❯"]);
851 let recognizer = recognizer_for("claude");
852 assert_eq!(recognizer.detect(&screen), TurnState::AwaitingInput);
853 }
854
855 #[test]
856 fn manifest_rules_beat_legacy_on_current_claude_screens() {
857 let screen = lines(&["●─PONG", "──────────", "❯ ", "──────────", " ⏵⏵ accept edits on"]);
862 assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingInput);
863
864 let screen = lines(&[
867 "⏺ Bash(cargo test)",
868 "──────────",
869 "Do you want to proceed?",
870 "❯ 1. Yes",
871 " 2. No, and tell Claude what to do differently",
872 "Esc to cancel",
873 ]);
874 assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingApproval);
875 }
876
877 #[test]
878 fn manifest_explicit_unknown_does_not_fall_through_to_legacy() {
879 let screen = lines(&[
884 "⏺ old tool output esc to interrupt",
885 "Showing detailed transcript",
886 "ctrl+o to toggle",
887 ]);
888 assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
889 assert_eq!(recognizer_for("claude").detect(&screen), TurnState::Unknown);
890 }
891
892 #[test]
893 fn osc_title_spinner_keeps_claude_busy_over_an_idle_looking_screen() {
894 let screen = lines(&["❯ old prompt"]);
897 let recognizer = recognizer_for("claude");
898 let input = TurnInput { screen: &screen, osc_title: "⠋ claude", osc_progress: "" };
899 assert_eq!(recognizer.detect_input(&input), TurnState::Busy);
900 assert_eq!(
902 recognizer.detect_input(&TurnInput::screen_only(&screen)),
903 TurnState::AwaitingInput
904 );
905 }
906
907 #[test]
908 fn auto_combines_manifest_and_legacy_verdicts() {
909 let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
911 assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Busy);
912 let screen = lines(&["• PONG", "› Run /review on my current changes"]);
914 assert_eq!(recognizer_for("auto").detect(&screen), TurnState::AwaitingInput);
915 let screen = lines(&["building...", "done", "user@host:~$"]);
917 assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Unknown);
918 }
919}
920
921#[cfg(test)]
922mod workflow_tests;