Skip to main content

winx_code_agent/state/
turn.rs

1//! Turn detection for interactive TUIs piloted through the PTY.
2//!
3//! "Has the program finished this turn and is it ready for the next input?" is
4//! the core question when driving a full-screen TUI (the `claude` CLI, `codex`,
5//! a REPL, ...) like a human operator. We answer it with a hybrid signal:
6//!
7//!   * a generic **quiescence** check (the rendered screen stopped changing),
8//!     handled by the caller, plus
9//!   * pluggable per-app **recognizers** here that read the rendered screen and
10//!     classify it as [`TurnState::Busy`], [`TurnState::AwaitingInput`] or
11//!     [`TurnState::AwaitingApproval`].
12//!
13//! Recognizers operate on already-rendered, ANSI-stripped screen lines, so they
14//! never deal with escape codes — only with the visible text a human sees.
15
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18
19/// What an interactive program is doing right now, inferred from the screen.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TurnState {
23    /// Actively producing output (spinner / "esc to interrupt" / streaming).
24    Busy,
25    /// Idle at an input prompt, ready for the next message.
26    AwaitingInput,
27    /// Blocked on a confirmation/permission dialog needing an operator choice.
28    AwaitingApproval,
29    /// The recognizer can't tell from the screen alone (defer to quiescence).
30    Unknown,
31}
32
33impl TurnState {
34    /// A short, stable label for status footers.
35    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    /// Whether this state means the turn is over and the operator may act
45    /// (send the next prompt, or answer a prompt).
46    pub fn is_ready(self) -> bool {
47        matches!(self, TurnState::AwaitingInput | TurnState::AwaitingApproval)
48    }
49}
50
51/// Everything a recognizer may read: the rendered, ANSI-stripped screen lines
52/// plus the latest OSC 0/2 title and OSC 9 progress payload captured from the
53/// raw PTY stream. OSC strings are empty when the tap has seen none — the
54/// manifest engine then behaves exactly like a screen-only detector.
55#[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    /// A screen-only input (no OSC evidence).
64    pub fn screen_only(screen: &'a [String]) -> Self {
65        Self { screen, osc_title: "", osc_progress: "" }
66    }
67}
68
69/// Reads a rendered screen (ANSI already stripped, one `String` per line) and
70/// classifies the current turn state for a specific kind of TUI.
71pub trait TurnRecognizer: Send + Sync {
72    /// Stable identifier, surfaced in the status footer.
73    fn name(&self) -> &'static str;
74    /// Classify the current screen.
75    fn detect(&self, screen: &[String]) -> TurnState;
76    /// Classify with the full input (screen + OSC evidence). Screen-only
77    /// recognizers get this for free; the manifest recognizer overrides it to
78    /// use OSC title/progress rules.
79    fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
80        self.detect(input.screen)
81    }
82}
83
84/// Build the recognizer for a hint string. Hints resolve in this order:
85///   * `generic`/`none`/`off` and `configurable` keep their dedicated
86///     recognizers;
87///   * `auto` (and unknown hints) combine the claude + codex manifests with
88///     the legacy hand-written recognizers as fallback;
89///   * anything matching a bundled agent-manifest id or alias (`claude`,
90///     `codex`, `gemini`, `agy`, `cursor`, `opencode`, `grok`, `copilot`, ...)
91///     gets that manifest, with the legacy recognizer (where one exists)
92///     backing it up when no manifest rule fires.
93pub 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        // `gemini` was historically an alias for the antigravity recognizer;
105        // it now resolves to the gemini manifest but keeps those markers as
106        // the fallback layer.
107        "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
116/// The `auto` recognizer: claude + codex manifests (each backed by its legacy
117/// recognizer), combined by priority. Falls back to the purely hand-written
118/// [`AutoRecognizer`] only if a bundled manifest failed to load.
119fn 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
132/// Build a [`GenericConfigurable`] from the `WINX_TURN_RECOGNIZER_CONFIG` env var
133/// (JSON markers), or fall back to the generic quiescence recognizer when it is
134/// unset, empty, or invalid.
135fn 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
146// --- shared helpers ---------------------------------------------------------
147
148/// Whether any line on the screen contains `needle` (case-insensitive).
149///
150/// Busy markers are scanned across the WHOLE screen, not just the tail: a long
151/// response can push the footer's "esc to interrupt" out of the last lines, and
152/// missing it would flip a still-generating turn to "ready" mid-stream and
153/// corrupt the next input. A rare false-busy (the model literally printing the
154/// phrase) only delays the turn — strictly the safer error.
155fn any_line_contains(screen: &[String], needle: &str) -> bool {
156    screen.iter().any(|l| l.to_lowercase().contains(needle))
157}
158
159/// Last `n` non-blank lines, lowercased and joined — used for the dialog
160/// phrase scan ("do you want", "trust this folder", ...) which is naturally
161/// localized to the bottom of the screen.
162fn 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
174// --- Claude TUI -------------------------------------------------------------
175
176/// Recognizer for the `claude` CLI (Ink/React TUI). Markers verified against a
177/// real claude v2.1.x session captured through winx:
178///   * **Busy**: the footer shows `esc to interrupt` while generating. We rely
179///     on this and NOT on spinner glyphs — claude renders inline, so spent
180///     spinner lines ("✽ Gusting… (3s)") linger in the scrollback after the
181///     turn ends and would otherwise read as a false "busy".
182///   * **`AwaitingApproval`**: a confirmation/permission dialog, recognized by its
183///     prompt phrasing ("do you want", "trust this folder", "Esc to cancel", …).
184///   * **`AwaitingInput`**: the `❯` input box is present and neither of the above.
185pub struct ClaudeRecognizer;
186
187impl ClaudeRecognizer {
188    fn is_approval(screen: &[String]) -> bool {
189        // Phrase-based: these are the actual prompts claude shows for the
190        // trust-folder check and tool-permission dialogs. We deliberately do
191        // NOT key off a bare "1./2." numbered menu — ordinary numbered prose in
192        // a normal answer ("1. Agile…", "2. Waterfall…") would false-positive.
193        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        // The Ink prompt char. Only consulted once we've ruled out Busy/approval,
203        // so a lingering `❯` from earlier prompts can't mask an active turn.
204        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        // Order matters: Busy and approval are checked before the input box so a
215        // stale `❯` never shadows an active or blocked turn.
216        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
229// --- Codex CLI --------------------------------------------------------------
230
231/// Recognizer for the `codex` CLI (Ink TUI). Markers verified against a real
232/// codex-cli 0.139.0 (gpt-5.5) session captured through winx:
233///   * **Busy**: the footer shows `• Working (Ns • esc to interrupt)` while
234///     generating — the same `esc to interrupt` anchor claude uses.
235///   * **`AwaitingApproval`**: the command-confirmation dialog ("Would you like
236///     to run the following command?", "Press enter to confirm or esc to
237///     cancel") shown when codex must escalate (e.g. `-a untrusted`).
238///   * **`AwaitingInput`**: the input box, whose prompt glyph is `›` (U+203A) —
239///     distinct from claude's `❯` and from a bare `>`.
240pub struct CodexRecognizer;
241
242impl CodexRecognizer {
243    fn is_approval(screen: &[String]) -> bool {
244        // Phrase-based and localized to the bottom of the screen. The `›`
245        // selection glyph also appears in this dialog, so it MUST be classified
246        // as approval (checked before the input box), not as plain input.
247        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        // Codex's Ink prompt char is `›` (U+203A). Only consulted once Busy and
255        // approval are ruled out, so a lingering `›` can't mask an active turn.
256        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        // Order matters: Busy and approval before the input box so a stale `›`
267        // never shadows an active or blocked turn.
268        //
269        // `preparing to execute` covers the transient where codex is running a
270        // tool call: it briefly drops the `esc to interrupt` footer while the
271        // submitted prompt's `›` still lingers in the history — without this it
272        // would read as a false AwaitingInput mid-execution.
273        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
288// --- Antigravity CLI --------------------------------------------------------
289
290/// Recognizer for the `agy` (Antigravity CLI, Gemini) TUI. **Best-effort.**
291/// Unlike claude/codex (Ink, alt-screen + in-place redraw), agy renders in
292/// append mode inside winx's PTY: spent `Generating…` / `esc to cancel` frames
293/// ghost into later screens and are never cleared. We therefore lead with the
294/// idle hint `? for shortcuts`, which only sits at the *bottom* of the screen
295/// when input is actually ready — a busy turn pushes it out of the tail — and
296/// only then fall back to the busy markers. Markers verified against a real
297/// agy 1.0.8 (Gemini 3.5 Flash) session captured through winx.
298///
299/// Deliberately NOT part of [`AutoRecognizer`]: its markers (`esc to cancel`,
300/// `generating`) are generic enough to pollute claude/codex detection, so it is
301/// opt-in only via the `agy`/`antigravity` hint — mirroring [`GenericRecognizer`].
302pub 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        // Idle hint first: `? for shortcuts` only sits at the bottom when the
311        // input box is ready. A busy turn pushes it out of the tail, and under
312        // append-mode rendering spent busy markers ghost into the idle screen —
313        // so leading with the idle hint (scoped to the tail) is what survives
314        // the ghosting. Only then fall back to the busy markers.
315        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
325// --- Generic ----------------------------------------------------------------
326
327/// No app knowledge: always [`TurnState::Unknown`] so the caller decides purely
328/// by quiescence. Correct (if conservative) for any TUI.
329pub 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
341// --- Configurable from markers ----------------------------------------------
342
343/// A recognizer built from marker strings/regexes in config
344/// ([`crate::types::TurnRecognizerMarkers`]), so `wait_for_turn` can pilot an
345/// arbitrary TUI without bespoke code. Same scan discipline as the built-ins:
346/// busy across the whole screen (a long response can push its footer out of the
347/// tail), approval/input tail-scoped so a stale scrollback prompt can't fire.
348pub 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
364/// Compile each marker as a case-insensitive regex, falling back to a literal
365/// substring when the pattern doesn't compile; unusable markers are dropped.
366fn 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
398// --- Manifest-driven ---------------------------------------------------------
399
400/// A recognizer backed by a data-driven agent manifest (see
401/// [`crate::state::turn_manifest`]) with an optional legacy recognizer behind
402/// it. A matched manifest rule is authoritative — including an explicit
403/// `unknown` match (e.g. a transcript-viewer screen), which must NOT fall
404/// through to the legacy recognizer: those screens show stale content that
405/// legacy markers would misread. Only a complete manifest miss delegates.
406pub 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
445/// Manifest-driven `auto`: claude + codex manifests evaluated side by side,
446/// verdicts combined by priority (`Busy` > `AwaitingApproval` >
447/// `AwaitingInput` > `Unknown`) — same arbitration as the legacy
448/// [`AutoRecognizer`], so a busy app always wins over a stale prompt glyph.
449struct 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
473// --- Auto -------------------------------------------------------------------
474
475/// Runs every app-specific recognizer and combines their verdicts by priority:
476/// `Busy` > `AwaitingApproval` > `AwaitingInput` > `Unknown`. A recognizer that
477/// sees the app working therefore always wins over one that only sees a
478/// (possibly stale) prompt glyph — so `auto` never reports a busy app as ready,
479/// while still falling back to `Unknown` (quiescence) for an unknown TUI.
480pub 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        // Both a prompt glyph and a busy marker on screen → Busy must win.
532        assert_eq!(recognizer.detect(&lines(&["> ", "esc to interrupt"])), TurnState::Busy);
533    }
534
535    // Fixtures below are taken from a real `claude` v2.1.159 session captured
536    // through winx's PTY.
537
538    #[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        // Regression for the review finding: on a long response the footer can
553        // be far from the bottom and an old `❯` lingers in the scrollback.
554        // Scanning the whole screen for "esc to interrupt" keeps it Busy, so we
555        // never send input mid-stream.
556        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        // claude renders inline, so spent spinner lines linger in the scrollback
572        // after the turn finishes. Once the footer drops "esc to interrupt" and
573        // the `❯` box is back, the turn IS over. (Real post-answer screen.)
574        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        // Regression: a normal answer containing a numbered list whose text
589        // happens to contain "allows"/"known" must NOT read as an approval.
590        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        // The real "trust this folder?" approval shown on a fresh directory.
603        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        // A real codex idle screen: claude's recognizer doesn't know the `›`
626        // glyph and returns Unknown, but auto must still surface codex's
627        // AwaitingInput rather than collapsing to Unknown.
628        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        // Both a stale claude `❯` and a codex `›` can linger while a turn
637        // streams; the `esc to interrupt` footer must keep auto Busy, never
638        // letting a leftover prompt glyph flip it to ready.
639        let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
640        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
641    }
642
643    // Codex fixtures below are from a real `codex` (codex-cli 0.139.0, gpt-5.5)
644    // session captured through winx's PTY.
645
646    #[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        // Regression: codex's Ink prompt glyph is `›` (U+203A), not claude's `❯`
662        // nor a bare `>`. The old recognizer missed it and fell back to Unknown,
663        // forcing pure quiescence. Now it reports AwaitingInput explicitly.
664        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        // The real approval dialog codex shows under `-a untrusted`. Note the
677        // `›` selection glyph also appears here, so approval MUST be checked
678        // before the input box or it would read as AwaitingInput.
679        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        // Regression from a live session: while codex prepares/runs a tool call
697        // it briefly shows no `esc to interrupt` footer, and the just-submitted
698        // prompt's `›` lingers in the history above. That transient must read as
699        // Busy, not a false AwaitingInput, or a pilot would inject mid-execution.
700        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        // A busy codex still shows the `›` input box; "esc to interrupt" must
711        // keep it Busy so we never send input mid-stream.
712        let screen = lines(&["› an earlier prompt", "• Working (3s • esc to interrupt)"]);
713        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
714    }
715
716    // Antigravity (`agy` 1.0.8, Gemini 3.5 Flash) fixtures captured through
717    // winx. agy renders in append mode, so these intentionally include the ghost
718    // `Generating…` / `esc to cancel` frames that linger after the turn — the
719    // recognizer must see through them.
720
721    #[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        // The real idle screen still carries ghost `Generating…`/`esc to cancel`
735        // lines from the finished turn, but `? for shortcuts` sits at the bottom
736        // only when input is ready — so it must win over the ghosts.
737        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        // agy's best-effort markers would pollute claude/codex, so it is opt-in
749        // only and auto must stay blind to its idle hint.
750        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    // --- manifest-driven recognizer_for wiring ------------------------------
776
777    #[test]
778    fn recognizer_for_resolves_manifest_hints() {
779        // Core hints resolve to their manifests (name = manifest id)...
780        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        // ...as do agents winx never had hand-written recognizers for.
785        assert_eq!(recognizer_for("cursor").name(), "cursor");
786        assert_eq!(recognizer_for("opencode").name(), "opencode");
787        assert_eq!(recognizer_for("ghcs").name(), "copilot");
788        // Non-manifest hints keep their historical routing.
789        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        // No manifest rule fires on this minimal screen (no prompt box rules,
797        // no dialogs), so the legacy `❯` marker must still surface input.
798        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        // The 2.x claude prompt box sits between two horizontal rules — a
806        // shape only the manifest knows. The legacy recognizer also says
807        // AwaitingInput here (via `❯`), but the manifest's approval/working
808        // coverage beyond this is what the fixture tests below rely on.
809        let screen = lines(&["●─PONG", "──────────", "❯ ", "──────────", "  ⏵⏵ accept edits on"]);
810        assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingInput);
811
812        // A permission dialog the legacy phrase list misses ("Do you want to
813        // proceed?" + numbered menu after a horizontal rule).
814        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        // A transcript viewer shows STALE content — the legacy recognizer
828        // would read the old "esc to interrupt" as Busy, but the manifest's
829        // transcript_viewer rule (state=unknown) is authoritative: defer to
830        // quiescence instead.
831        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        // Screen alone looks idle (stale ❯), but the OSC title still carries
843        // the braille spinner claude renders while generating.
844        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        // Same screen without the title falls to the legacy `❯` marker.
849        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        // Codex working footer → Busy through the manifest members.
858        let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
859        assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Busy);
860        // Codex idle prompt only the legacy fallback knows → AwaitingInput.
861        let screen = lines(&["• PONG", "› Run /review on my current changes"]);
862        assert_eq!(recognizer_for("auto").detect(&screen), TurnState::AwaitingInput);
863        // Plain shell → Unknown (quiescence decides).
864        let screen = lines(&["building...", "done", "user@host:~$"]);
865        assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Unknown);
866    }
867}