use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
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)
}
}
#[derive(Debug, Clone, Copy)]
pub struct TurnInput<'a> {
pub screen: &'a [String],
pub osc_title: &'a str,
pub osc_progress: &'a str,
}
impl<'a> TurnInput<'a> {
pub fn screen_only(screen: &'a [String]) -> Self {
Self { screen, osc_title: "", osc_progress: "" }
}
}
pub trait TurnRecognizer: Send + Sync {
fn name(&self) -> &'static str;
fn detect(&self, screen: &[String]) -> TurnState;
fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
self.detect(input.screen)
}
}
pub fn recognizer_for(hint: &str) -> Box<dyn TurnRecognizer> {
let hint = hint.trim().to_ascii_lowercase();
match hint.as_str() {
"generic" | "none" | "off" => return Box::new(GenericRecognizer),
"configurable" => return configurable_from_env(),
"auto" => return auto_recognizer(),
_ => {}
}
let legacy: Option<Box<dyn TurnRecognizer>> = match hint.as_str() {
"claude" => Some(Box::new(ClaudeRecognizer)),
"codex" => Some(Box::new(CodexRecognizer)),
"antigravity" | "agy" | "gemini" => Some(Box::new(AntigravityRecognizer)),
_ => None,
};
match crate::state::turn_manifest::manifest_for(&hint) {
Some(manifest) => Box::new(ManifestRecognizer { manifest, fallback: legacy }),
None => legacy.unwrap_or_else(auto_recognizer),
}
}
fn auto_recognizer() -> Box<dyn TurnRecognizer> {
use crate::state::turn_manifest::manifest_for;
let (Some(claude), Some(codex)) = (manifest_for("claude"), manifest_for("codex")) else {
return Box::new(AutoRecognizer);
};
Box::new(ManifestAutoRecognizer {
members: [
ManifestRecognizer { manifest: claude, fallback: Some(Box::new(ClaudeRecognizer)) },
ManifestRecognizer { manifest: codex, fallback: Some(Box::new(CodexRecognizer)) },
],
})
}
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 ManifestRecognizer {
manifest: &'static crate::state::turn_manifest::CompiledManifest,
fallback: Option<Box<dyn TurnRecognizer>>,
}
impl TurnRecognizer for ManifestRecognizer {
fn name(&self) -> &'static str {
let manifest: &'static crate::state::turn_manifest::CompiledManifest = self.manifest;
manifest.id()
}
fn detect(&self, screen: &[String]) -> TurnState {
self.detect_input(&TurnInput::screen_only(screen))
}
fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
let joined = input.screen.join("\n");
let verdict = self.manifest.detect(crate::state::turn_manifest::DetectionInput {
screen: &joined,
osc_title: input.osc_title,
osc_progress: input.osc_progress,
});
if let Some((rule_id, priority)) = verdict.rule {
tracing::trace!(
manifest = self.name(),
rule = rule_id,
priority,
state = verdict.state.as_str(),
"turn manifest rule matched"
);
return verdict.state;
}
match &self.fallback {
Some(fallback) => fallback.detect_input(input),
None => TurnState::Unknown,
}
}
}
struct ManifestAutoRecognizer {
members: [ManifestRecognizer; 2],
}
impl TurnRecognizer for ManifestAutoRecognizer {
fn name(&self) -> &'static str {
"auto"
}
fn detect(&self, screen: &[String]) -> TurnState {
self.detect_input(&TurnInput::screen_only(screen))
}
fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
let states = self.members.each_ref().map(|member| member.detect_input(input));
for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
if states.contains(&want) {
return want;
}
}
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());
}
#[test]
fn recognizer_for_resolves_manifest_hints() {
assert_eq!(recognizer_for("claude").name(), "claude");
assert_eq!(recognizer_for("codex").name(), "codex");
assert_eq!(recognizer_for("gemini").name(), "gemini");
assert_eq!(recognizer_for("agy").name(), "agy");
assert_eq!(recognizer_for("cursor").name(), "cursor");
assert_eq!(recognizer_for("opencode").name(), "opencode");
assert_eq!(recognizer_for("ghcs").name(), "copilot");
assert_eq!(recognizer_for("auto").name(), "auto");
assert_eq!(recognizer_for("generic").name(), "generic");
assert_eq!(recognizer_for("something-else").name(), "auto");
}
#[test]
fn manifest_claude_falls_back_to_legacy_markers_on_a_miss() {
let screen = lines(&["● answered", "❯"]);
let recognizer = recognizer_for("claude");
assert_eq!(recognizer.detect(&screen), TurnState::AwaitingInput);
}
#[test]
fn manifest_rules_beat_legacy_on_current_claude_screens() {
let screen = lines(&["●─PONG", "──────────", "❯ ", "──────────", " ⏵⏵ accept edits on"]);
assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingInput);
let screen = lines(&[
"⏺ Bash(cargo test)",
"──────────",
"Do you want to proceed?",
"❯ 1. Yes",
" 2. No, and tell Claude what to do differently",
"Esc to cancel",
]);
assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingApproval);
}
#[test]
fn manifest_explicit_unknown_does_not_fall_through_to_legacy() {
let screen = lines(&[
"⏺ old tool output esc to interrupt",
"Showing detailed transcript",
"ctrl+o to toggle",
]);
assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
assert_eq!(recognizer_for("claude").detect(&screen), TurnState::Unknown);
}
#[test]
fn osc_title_spinner_keeps_claude_busy_over_an_idle_looking_screen() {
let screen = lines(&["❯ old prompt"]);
let recognizer = recognizer_for("claude");
let input = TurnInput { screen: &screen, osc_title: "⠋ claude", osc_progress: "" };
assert_eq!(recognizer.detect_input(&input), TurnState::Busy);
assert_eq!(
recognizer.detect_input(&TurnInput::screen_only(&screen)),
TurnState::AwaitingInput
);
}
#[test]
fn auto_combines_manifest_and_legacy_verdicts() {
let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Busy);
let screen = lines(&["• PONG", "› Run /review on my current changes"]);
assert_eq!(recognizer_for("auto").detect(&screen), TurnState::AwaitingInput);
let screen = lines(&["building...", "done", "user@host:~$"]);
assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Unknown);
}
}