use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnState {
Busy,
AwaitingInput,
AwaitingApproval,
Unknown,
}
impl TurnState {
pub fn as_str(self) -> &'static str {
match self {
TurnState::Busy => "busy",
TurnState::AwaitingInput => "awaiting_input",
TurnState::AwaitingApproval => "awaiting_approval",
TurnState::Unknown => "unknown",
}
}
pub fn is_ready(self) -> bool {
matches!(self, TurnState::AwaitingInput | TurnState::AwaitingApproval)
}
}
pub trait TurnRecognizer: Send + Sync {
fn name(&self) -> &'static str;
fn detect(&self, screen: &[String]) -> TurnState;
}
pub fn recognizer_for(hint: &str) -> Box<dyn TurnRecognizer> {
match hint.trim().to_ascii_lowercase().as_str() {
"claude" => Box::new(ClaudeRecognizer),
"codex" => Box::new(CodexRecognizer),
"antigravity" | "agy" | "gemini" => Box::new(AntigravityRecognizer),
"generic" | "none" | "off" => Box::new(GenericRecognizer),
"configurable" => configurable_from_env(),
_ => Box::new(AutoRecognizer),
}
}
fn configurable_from_env() -> Box<dyn TurnRecognizer> {
if let Ok(json) = std::env::var("WINX_TURN_RECOGNIZER_CONFIG") {
if let Ok(markers) = serde_json::from_str::<crate::types::TurnRecognizerMarkers>(&json) {
if !markers.is_empty() {
return Box::new(GenericConfigurable::new(&markers));
}
}
}
Box::new(GenericRecognizer)
}
fn any_line_contains(screen: &[String], needle: &str) -> bool {
screen.iter().any(|l| l.to_lowercase().contains(needle))
}
fn tail_lower(screen: &[String], n: usize) -> String {
let mut lines: Vec<String> = screen
.iter()
.rev()
.filter(|l| !l.trim().is_empty())
.take(n)
.map(|l| l.to_lowercase())
.collect();
lines.reverse();
lines.join("\n")
}
pub struct ClaudeRecognizer;
impl ClaudeRecognizer {
fn is_approval(screen: &[String]) -> bool {
let joined = tail_lower(screen, 30);
joined.contains("do you want")
|| joined.contains("would you like")
|| joined.contains("trust this folder")
|| joined.contains("enter to confirm")
|| joined.contains("esc to cancel")
}
fn has_input_box(screen: &[String]) -> bool {
screen.iter().any(|l| l.contains('❯'))
}
}
impl TurnRecognizer for ClaudeRecognizer {
fn name(&self) -> &'static str {
"claude"
}
fn detect(&self, screen: &[String]) -> TurnState {
if any_line_contains(screen, "esc to interrupt") {
return TurnState::Busy;
}
if Self::is_approval(screen) {
return TurnState::AwaitingApproval;
}
if Self::has_input_box(screen) {
return TurnState::AwaitingInput;
}
TurnState::Unknown
}
}
pub struct CodexRecognizer;
impl CodexRecognizer {
fn is_approval(screen: &[String]) -> bool {
let joined = tail_lower(screen, 30);
joined.contains("would you like to run")
|| joined.contains("press enter to confirm")
|| joined.contains("yes, proceed")
}
fn has_input_box(screen: &[String]) -> bool {
screen.iter().any(|l| l.trim_start().starts_with('›'))
}
}
impl TurnRecognizer for CodexRecognizer {
fn name(&self) -> &'static str {
"codex"
}
fn detect(&self, screen: &[String]) -> TurnState {
if any_line_contains(screen, "esc to interrupt")
|| any_line_contains(screen, "preparing to execute")
{
return TurnState::Busy;
}
if Self::is_approval(screen) {
return TurnState::AwaitingApproval;
}
if Self::has_input_box(screen) {
return TurnState::AwaitingInput;
}
TurnState::Unknown
}
}
pub struct AntigravityRecognizer;
impl TurnRecognizer for AntigravityRecognizer {
fn name(&self) -> &'static str {
"antigravity"
}
fn detect(&self, screen: &[String]) -> TurnState {
if tail_lower(screen, 4).contains("? for shortcuts") {
return TurnState::AwaitingInput;
}
if any_line_contains(screen, "generating") || any_line_contains(screen, "esc to cancel") {
return TurnState::Busy;
}
TurnState::Unknown
}
}
pub struct GenericRecognizer;
impl TurnRecognizer for GenericRecognizer {
fn name(&self) -> &'static str {
"generic"
}
fn detect(&self, _screen: &[String]) -> TurnState {
TurnState::Unknown
}
}
pub struct GenericConfigurable {
busy: Vec<Regex>,
approval: Vec<Regex>,
input: Vec<Regex>,
}
impl GenericConfigurable {
pub fn new(markers: &crate::types::TurnRecognizerMarkers) -> Self {
Self {
busy: compile_markers(&markers.busy),
approval: compile_markers(&markers.awaiting_approval),
input: compile_markers(&markers.awaiting_input),
}
}
}
fn compile_markers(patterns: &[String]) -> Vec<Regex> {
patterns
.iter()
.filter_map(|pattern| {
Regex::new(&format!("(?i){pattern}"))
.or_else(|_| Regex::new(&format!("(?i){}", regex::escape(pattern))))
.ok()
})
.collect()
}
impl TurnRecognizer for GenericConfigurable {
fn name(&self) -> &'static str {
"configurable"
}
fn detect(&self, screen: &[String]) -> TurnState {
let whole = screen.join("\n");
if self.busy.iter().any(|re| re.is_match(&whole)) {
return TurnState::Busy;
}
let tail = tail_lower(screen, 30);
if self.approval.iter().any(|re| re.is_match(&tail)) {
return TurnState::AwaitingApproval;
}
if self.input.iter().any(|re| re.is_match(&tail)) {
return TurnState::AwaitingInput;
}
TurnState::Unknown
}
}
pub struct AutoRecognizer;
impl TurnRecognizer for AutoRecognizer {
fn name(&self) -> &'static str {
"auto"
}
fn detect(&self, screen: &[String]) -> TurnState {
let states = [ClaudeRecognizer.detect(screen), CodexRecognizer.detect(screen)];
for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
if states.contains(&want) {
return want;
}
}
TurnState::Unknown
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(s: &[&str]) -> Vec<String> {
s.iter().map(|x| (*x).to_string()).collect()
}
#[test]
fn configurable_classifies_by_marker_case_insensitively() {
let markers = crate::types::TurnRecognizerMarkers {
busy: vec!["working".to_string()],
awaiting_input: vec![r"\$".to_string()],
awaiting_approval: vec!["confirm".to_string()],
};
let recognizer = GenericConfigurable::new(&markers);
assert_eq!(recognizer.detect(&lines(&["WORKING on it"])), TurnState::Busy);
assert_eq!(recognizer.detect(&lines(&["user@host:~$ "])), TurnState::AwaitingInput);
assert_eq!(
recognizer.detect(&lines(&["Please Confirm (y/n)"])),
TurnState::AwaitingApproval
);
assert_eq!(recognizer.detect(&lines(&["just some text"])), TurnState::Unknown);
}
#[test]
fn configurable_busy_beats_a_stale_prompt() {
let markers = crate::types::TurnRecognizerMarkers {
busy: vec!["esc to interrupt".to_string()],
awaiting_input: vec![">".to_string()],
awaiting_approval: vec![],
};
let recognizer = GenericConfigurable::new(&markers);
assert_eq!(recognizer.detect(&lines(&["> ", "esc to interrupt"])), TurnState::Busy);
}
#[test]
fn claude_busy_via_esc_to_interrupt() {
let screen = lines(&[
"❯ responda exatamente uma palavra: PONG",
"",
"* Hatching…",
"⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn claude_long_response_busy_even_with_stale_prompt() {
let mut screen = vec![
"❯ old prompt from a previous turn".to_string(),
String::new(),
"●─here is a long answer".to_string(),
];
screen.push("⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt".to_string());
for i in 0..40 {
screen.push(format!("streamed answer line {i}"));
}
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn claude_idle_after_answer_is_input_not_busy() {
let screen = lines(&[
"❯ Responda com exatamente uma palavra, em maiúsculas: PONG",
"· Gusting…",
"●─PONG",
"✽ Gusting… (3s · ↓ 1 tokens)",
"✻ Baked for 4s",
"❯",
"⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
}
#[test]
fn claude_numbered_prose_is_not_approval() {
let screen = lines(&[
"❯ compare the methodologies",
"1. Agile - allows iterative delivery",
"2. Waterfall - assumes requirements are known up front",
"❯",
"⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
}
#[test]
fn claude_awaiting_approval_trust_prompt() {
let screen = lines(&[
"Quick safety check: Is this a project you created or one you trust?",
"❯ 1. Yes, I trust this folder",
" 2. No, exit",
"Enter to confirm · Esc to cancel",
]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
}
#[test]
fn claude_awaiting_approval_tool_menu() {
let screen = lines(&[
"⏺ Bash(rm -rf /tmp/x)",
"Do you want to proceed?",
"❯ 1. Yes",
" 2. No, and tell Claude what to do differently",
]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
}
#[test]
fn auto_surfaces_codex_input_when_claude_is_blind() {
let screen = lines(&["• PONG", "› Run /review on my current changes"]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Unknown);
assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingInput);
}
#[test]
fn auto_stays_busy_with_stale_prompt_glyphs() {
let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn codex_busy_via_working_footer() {
let screen = lines(&[
"› escreva um poema longo sobre o oceano",
"",
"• Working (1s • esc to interrupt)",
"",
" gpt-5.5 high · ~/Documents/projects/winx-code-agent",
]);
assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn codex_idle_input_box_uses_angle_prompt() {
let screen = lines(&[
"• PONG",
"",
"› Run /review on my current changes",
"",
" gpt-5.5 high · ~/Documents/projects/winx-code-agent",
]);
assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
}
#[test]
fn codex_awaiting_approval_command_dialog() {
let screen = lines(&[
" Would you like to run the following command?",
"",
" $ touch /tmp/x.txt",
"",
"› 1. Yes, proceed (y)",
" 2. Yes, and don't ask again (p)",
" 3. No, and tell Codex what to do differently (esc)",
"",
" Press enter to confirm or esc to cancel",
]);
assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingApproval);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingApproval);
}
#[test]
fn codex_preparing_to_execute_is_busy_not_input() {
let screen = lines(&[
"› execute o comando de shell 'touch /tmp/x.txt'",
"• Vou executar exatamente esse comando",
"• Preparing to execute shell command 9",
]);
assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn codex_busy_wins_over_stale_angle_prompt() {
let screen = lines(&["› an earlier prompt", "• Working (3s • esc to interrupt)"]);
assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn antigravity_busy_via_generating_and_esc_to_cancel() {
let screen = lines(&[
"> responda com uma palavra: PONG Gemini 3.5 Flash (Medium)",
"⣾ Generating...",
" PONG",
"esc to cancel Gemini 3.5 Flash (Medium)",
]);
assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::Busy);
}
#[test]
fn antigravity_idle_via_shortcuts_hint_despite_ghost_frames() {
let screen = lines(&[
" PONG",
"⣯ Generating..",
"esc to cancel Gemini 3.5 Flash (Medium)",
"? for shortcuts",
]);
assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
}
#[test]
fn antigravity_is_opt_in_not_part_of_auto() {
let screen = lines(&["? for shortcuts"]);
assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
}
#[test]
fn generic_is_always_unknown() {
let screen = lines(&["$ ls", "file.txt", "$"]);
assert_eq!(GenericRecognizer.detect(&screen), TurnState::Unknown);
}
#[test]
fn auto_falls_back_to_unknown_for_plain_shell() {
let screen = lines(&["building...", "done", "user@host:~$"]);
assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
}
#[test]
fn turn_state_ready_semantics() {
assert!(TurnState::AwaitingInput.is_ready());
assert!(TurnState::AwaitingApproval.is_ready());
assert!(!TurnState::Busy.is_ready());
assert!(!TurnState::Unknown.is_ready());
}
}