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/// Reads a rendered screen (ANSI already stripped, one `String` per line) and
52/// classifies the current turn state for a specific kind of TUI.
53pub trait TurnRecognizer: Send + Sync {
54    /// Stable identifier, surfaced in the status footer.
55    fn name(&self) -> &'static str;
56    /// Classify the current screen.
57    fn detect(&self, screen: &[String]) -> TurnState;
58}
59
60/// Build the recognizer for a hint string (`auto` | `claude` | `codex` |
61/// `generic`). Unknown hints fall back to `auto`.
62pub fn recognizer_for(hint: &str) -> Box<dyn TurnRecognizer> {
63    match hint.trim().to_ascii_lowercase().as_str() {
64        "claude" => Box::new(ClaudeRecognizer),
65        "codex" => Box::new(CodexRecognizer),
66        "antigravity" | "agy" | "gemini" => Box::new(AntigravityRecognizer),
67        "generic" | "none" | "off" => Box::new(GenericRecognizer),
68        "configurable" => configurable_from_env(),
69        _ => Box::new(AutoRecognizer),
70    }
71}
72
73/// Build a [`GenericConfigurable`] from the `WINX_TURN_RECOGNIZER_CONFIG` env var
74/// (JSON markers), or fall back to the generic quiescence recognizer when it is
75/// unset, empty, or invalid.
76fn configurable_from_env() -> Box<dyn TurnRecognizer> {
77    if let Ok(json) = std::env::var("WINX_TURN_RECOGNIZER_CONFIG") {
78        if let Ok(markers) = serde_json::from_str::<crate::types::TurnRecognizerMarkers>(&json) {
79            if !markers.is_empty() {
80                return Box::new(GenericConfigurable::new(&markers));
81            }
82        }
83    }
84    Box::new(GenericRecognizer)
85}
86
87// --- shared helpers ---------------------------------------------------------
88
89/// Whether any line on the screen contains `needle` (case-insensitive).
90///
91/// Busy markers are scanned across the WHOLE screen, not just the tail: a long
92/// response can push the footer's "esc to interrupt" out of the last lines, and
93/// missing it would flip a still-generating turn to "ready" mid-stream and
94/// corrupt the next input. A rare false-busy (the model literally printing the
95/// phrase) only delays the turn — strictly the safer error.
96fn any_line_contains(screen: &[String], needle: &str) -> bool {
97    screen.iter().any(|l| l.to_lowercase().contains(needle))
98}
99
100/// Last `n` non-blank lines, lowercased and joined — used for the dialog
101/// phrase scan ("do you want", "trust this folder", ...) which is naturally
102/// localized to the bottom of the screen.
103fn tail_lower(screen: &[String], n: usize) -> String {
104    let mut lines: Vec<String> = screen
105        .iter()
106        .rev()
107        .filter(|l| !l.trim().is_empty())
108        .take(n)
109        .map(|l| l.to_lowercase())
110        .collect();
111    lines.reverse();
112    lines.join("\n")
113}
114
115// --- Claude TUI -------------------------------------------------------------
116
117/// Recognizer for the `claude` CLI (Ink/React TUI). Markers verified against a
118/// real claude v2.1.x session captured through winx:
119///   * **Busy**: the footer shows `esc to interrupt` while generating. We rely
120///     on this and NOT on spinner glyphs — claude renders inline, so spent
121///     spinner lines ("✽ Gusting… (3s)") linger in the scrollback after the
122///     turn ends and would otherwise read as a false "busy".
123///   * **`AwaitingApproval`**: a confirmation/permission dialog, recognized by its
124///     prompt phrasing ("do you want", "trust this folder", "Esc to cancel", …).
125///   * **`AwaitingInput`**: the `❯` input box is present and neither of the above.
126pub struct ClaudeRecognizer;
127
128impl ClaudeRecognizer {
129    fn is_approval(screen: &[String]) -> bool {
130        // Phrase-based: these are the actual prompts claude shows for the
131        // trust-folder check and tool-permission dialogs. We deliberately do
132        // NOT key off a bare "1./2." numbered menu — ordinary numbered prose in
133        // a normal answer ("1. Agile…", "2. Waterfall…") would false-positive.
134        let joined = tail_lower(screen, 30);
135        joined.contains("do you want")
136            || joined.contains("would you like")
137            || joined.contains("trust this folder")
138            || joined.contains("enter to confirm")
139            || joined.contains("esc to cancel")
140    }
141
142    fn has_input_box(screen: &[String]) -> bool {
143        // The Ink prompt char. Only consulted once we've ruled out Busy/approval,
144        // so a lingering `❯` from earlier prompts can't mask an active turn.
145        screen.iter().any(|l| l.contains('❯'))
146    }
147}
148
149impl TurnRecognizer for ClaudeRecognizer {
150    fn name(&self) -> &'static str {
151        "claude"
152    }
153
154    fn detect(&self, screen: &[String]) -> TurnState {
155        // Order matters: Busy and approval are checked before the input box so a
156        // stale `❯` never shadows an active or blocked turn.
157        if any_line_contains(screen, "esc to interrupt") {
158            return TurnState::Busy;
159        }
160        if Self::is_approval(screen) {
161            return TurnState::AwaitingApproval;
162        }
163        if Self::has_input_box(screen) {
164            return TurnState::AwaitingInput;
165        }
166        TurnState::Unknown
167    }
168}
169
170// --- Codex CLI --------------------------------------------------------------
171
172/// Recognizer for the `codex` CLI (Ink TUI). Markers verified against a real
173/// codex-cli 0.139.0 (gpt-5.5) session captured through winx:
174///   * **Busy**: the footer shows `• Working (Ns • esc to interrupt)` while
175///     generating — the same `esc to interrupt` anchor claude uses.
176///   * **`AwaitingApproval`**: the command-confirmation dialog ("Would you like
177///     to run the following command?", "Press enter to confirm or esc to
178///     cancel") shown when codex must escalate (e.g. `-a untrusted`).
179///   * **`AwaitingInput`**: the input box, whose prompt glyph is `›` (U+203A) —
180///     distinct from claude's `❯` and from a bare `>`.
181pub struct CodexRecognizer;
182
183impl CodexRecognizer {
184    fn is_approval(screen: &[String]) -> bool {
185        // Phrase-based and localized to the bottom of the screen. The `›`
186        // selection glyph also appears in this dialog, so it MUST be classified
187        // as approval (checked before the input box), not as plain input.
188        let joined = tail_lower(screen, 30);
189        joined.contains("would you like to run")
190            || joined.contains("press enter to confirm")
191            || joined.contains("yes, proceed")
192    }
193
194    fn has_input_box(screen: &[String]) -> bool {
195        // Codex's Ink prompt char is `›` (U+203A). Only consulted once Busy and
196        // approval are ruled out, so a lingering `›` can't mask an active turn.
197        screen.iter().any(|l| l.trim_start().starts_with('›'))
198    }
199}
200
201impl TurnRecognizer for CodexRecognizer {
202    fn name(&self) -> &'static str {
203        "codex"
204    }
205
206    fn detect(&self, screen: &[String]) -> TurnState {
207        // Order matters: Busy and approval before the input box so a stale `›`
208        // never shadows an active or blocked turn.
209        //
210        // `preparing to execute` covers the transient where codex is running a
211        // tool call: it briefly drops the `esc to interrupt` footer while the
212        // submitted prompt's `›` still lingers in the history — without this it
213        // would read as a false AwaitingInput mid-execution.
214        if any_line_contains(screen, "esc to interrupt")
215            || any_line_contains(screen, "preparing to execute")
216        {
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// --- Antigravity CLI --------------------------------------------------------
230
231/// Recognizer for the `agy` (Antigravity CLI, Gemini) TUI. **Best-effort.**
232/// Unlike claude/codex (Ink, alt-screen + in-place redraw), agy renders in
233/// append mode inside winx's PTY: spent `Generating…` / `esc to cancel` frames
234/// ghost into later screens and are never cleared. We therefore lead with the
235/// idle hint `? for shortcuts`, which only sits at the *bottom* of the screen
236/// when input is actually ready — a busy turn pushes it out of the tail — and
237/// only then fall back to the busy markers. Markers verified against a real
238/// agy 1.0.8 (Gemini 3.5 Flash) session captured through winx.
239///
240/// Deliberately NOT part of [`AutoRecognizer`]: its markers (`esc to cancel`,
241/// `generating`) are generic enough to pollute claude/codex detection, so it is
242/// opt-in only via the `agy`/`antigravity` hint — mirroring [`GenericRecognizer`].
243pub struct AntigravityRecognizer;
244
245impl TurnRecognizer for AntigravityRecognizer {
246    fn name(&self) -> &'static str {
247        "antigravity"
248    }
249
250    fn detect(&self, screen: &[String]) -> TurnState {
251        // Idle hint first: `? for shortcuts` only sits at the bottom when the
252        // input box is ready. A busy turn pushes it out of the tail, and under
253        // append-mode rendering spent busy markers ghost into the idle screen —
254        // so leading with the idle hint (scoped to the tail) is what survives
255        // the ghosting. Only then fall back to the busy markers.
256        if tail_lower(screen, 4).contains("? for shortcuts") {
257            return TurnState::AwaitingInput;
258        }
259        if any_line_contains(screen, "generating") || any_line_contains(screen, "esc to cancel") {
260            return TurnState::Busy;
261        }
262        TurnState::Unknown
263    }
264}
265
266// --- Generic ----------------------------------------------------------------
267
268/// No app knowledge: always [`TurnState::Unknown`] so the caller decides purely
269/// by quiescence. Correct (if conservative) for any TUI.
270pub struct GenericRecognizer;
271
272impl TurnRecognizer for GenericRecognizer {
273    fn name(&self) -> &'static str {
274        "generic"
275    }
276
277    fn detect(&self, _screen: &[String]) -> TurnState {
278        TurnState::Unknown
279    }
280}
281
282// --- Configurable from markers ----------------------------------------------
283
284/// A recognizer built from marker strings/regexes in config
285/// ([`crate::types::TurnRecognizerMarkers`]), so `wait_for_turn` can pilot an
286/// arbitrary TUI without bespoke code. Same scan discipline as the built-ins:
287/// busy across the whole screen (a long response can push its footer out of the
288/// tail), approval/input tail-scoped so a stale scrollback prompt can't fire.
289pub struct GenericConfigurable {
290    busy: Vec<Regex>,
291    approval: Vec<Regex>,
292    input: Vec<Regex>,
293}
294
295impl GenericConfigurable {
296    pub fn new(markers: &crate::types::TurnRecognizerMarkers) -> Self {
297        Self {
298            busy: compile_markers(&markers.busy),
299            approval: compile_markers(&markers.awaiting_approval),
300            input: compile_markers(&markers.awaiting_input),
301        }
302    }
303}
304
305/// Compile each marker as a case-insensitive regex, falling back to a literal
306/// substring when the pattern doesn't compile; unusable markers are dropped.
307fn compile_markers(patterns: &[String]) -> Vec<Regex> {
308    patterns
309        .iter()
310        .filter_map(|pattern| {
311            Regex::new(&format!("(?i){pattern}"))
312                .or_else(|_| Regex::new(&format!("(?i){}", regex::escape(pattern))))
313                .ok()
314        })
315        .collect()
316}
317
318impl TurnRecognizer for GenericConfigurable {
319    fn name(&self) -> &'static str {
320        "configurable"
321    }
322
323    fn detect(&self, screen: &[String]) -> TurnState {
324        let whole = screen.join("\n");
325        if self.busy.iter().any(|re| re.is_match(&whole)) {
326            return TurnState::Busy;
327        }
328        let tail = tail_lower(screen, 30);
329        if self.approval.iter().any(|re| re.is_match(&tail)) {
330            return TurnState::AwaitingApproval;
331        }
332        if self.input.iter().any(|re| re.is_match(&tail)) {
333            return TurnState::AwaitingInput;
334        }
335        TurnState::Unknown
336    }
337}
338
339// --- Auto -------------------------------------------------------------------
340
341/// Runs every app-specific recognizer and combines their verdicts by priority:
342/// `Busy` > `AwaitingApproval` > `AwaitingInput` > `Unknown`. A recognizer that
343/// sees the app working therefore always wins over one that only sees a
344/// (possibly stale) prompt glyph — so `auto` never reports a busy app as ready,
345/// while still falling back to `Unknown` (quiescence) for an unknown TUI.
346pub struct AutoRecognizer;
347
348impl TurnRecognizer for AutoRecognizer {
349    fn name(&self) -> &'static str {
350        "auto"
351    }
352
353    fn detect(&self, screen: &[String]) -> TurnState {
354        let states = [ClaudeRecognizer.detect(screen), CodexRecognizer.detect(screen)];
355        for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
356            if states.contains(&want) {
357                return want;
358            }
359        }
360        TurnState::Unknown
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn lines(s: &[&str]) -> Vec<String> {
369        s.iter().map(|x| (*x).to_string()).collect()
370    }
371
372    #[test]
373    fn configurable_classifies_by_marker_case_insensitively() {
374        let markers = crate::types::TurnRecognizerMarkers {
375            busy: vec!["working".to_string()],
376            awaiting_input: vec![r"\$".to_string()],
377            awaiting_approval: vec!["confirm".to_string()],
378        };
379        let recognizer = GenericConfigurable::new(&markers);
380        assert_eq!(recognizer.detect(&lines(&["WORKING on it"])), TurnState::Busy);
381        assert_eq!(recognizer.detect(&lines(&["user@host:~$ "])), TurnState::AwaitingInput);
382        assert_eq!(
383            recognizer.detect(&lines(&["Please Confirm (y/n)"])),
384            TurnState::AwaitingApproval
385        );
386        assert_eq!(recognizer.detect(&lines(&["just some text"])), TurnState::Unknown);
387    }
388
389    #[test]
390    fn configurable_busy_beats_a_stale_prompt() {
391        let markers = crate::types::TurnRecognizerMarkers {
392            busy: vec!["esc to interrupt".to_string()],
393            awaiting_input: vec![">".to_string()],
394            awaiting_approval: vec![],
395        };
396        let recognizer = GenericConfigurable::new(&markers);
397        // Both a prompt glyph and a busy marker on screen → Busy must win.
398        assert_eq!(recognizer.detect(&lines(&["> ", "esc to interrupt"])), TurnState::Busy);
399    }
400
401    // Fixtures below are taken from a real `claude` v2.1.159 session captured
402    // through winx's PTY.
403
404    #[test]
405    fn claude_busy_via_esc_to_interrupt() {
406        let screen = lines(&[
407            "❯ responda exatamente uma palavra: PONG",
408            "",
409            "* Hatching…",
410            "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
411        ]);
412        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
413        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
414    }
415
416    #[test]
417    fn claude_long_response_busy_even_with_stale_prompt() {
418        // Regression for the review finding: on a long response the footer can
419        // be far from the bottom and an old `❯` lingers in the scrollback.
420        // Scanning the whole screen for "esc to interrupt" keeps it Busy, so we
421        // never send input mid-stream.
422        let mut screen = vec![
423            "❯ old prompt from a previous turn".to_string(),
424            String::new(),
425            "●─here is a long answer".to_string(),
426        ];
427        screen.push("⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt".to_string());
428        for i in 0..40 {
429            screen.push(format!("streamed answer line {i}"));
430        }
431        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
432        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
433    }
434
435    #[test]
436    fn claude_idle_after_answer_is_input_not_busy() {
437        // claude renders inline, so spent spinner lines linger in the scrollback
438        // after the turn finishes. Once the footer drops "esc to interrupt" and
439        // the `❯` box is back, the turn IS over. (Real post-answer screen.)
440        let screen = lines(&[
441            "❯ Responda com exatamente uma palavra, em maiúsculas: PONG",
442            "· Gusting…",
443            "●─PONG",
444            "✽ Gusting… (3s · ↓ 1 tokens)",
445            "✻ Baked for 4s",
446            "❯",
447            "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
448        ]);
449        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
450    }
451
452    #[test]
453    fn claude_numbered_prose_is_not_approval() {
454        // Regression: a normal answer containing a numbered list whose text
455        // happens to contain "allows"/"known" must NOT read as an approval.
456        let screen = lines(&[
457            "❯ compare the methodologies",
458            "1. Agile - allows iterative delivery",
459            "2. Waterfall - assumes requirements are known up front",
460            "❯",
461            "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
462        ]);
463        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
464    }
465
466    #[test]
467    fn claude_awaiting_approval_trust_prompt() {
468        // The real "trust this folder?" approval shown on a fresh directory.
469        let screen = lines(&[
470            "Quick safety check: Is this a project you created or one you trust?",
471            "❯ 1. Yes, I trust this folder",
472            "  2. No, exit",
473            "Enter to confirm · Esc to cancel",
474        ]);
475        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
476    }
477
478    #[test]
479    fn claude_awaiting_approval_tool_menu() {
480        let screen = lines(&[
481            "⏺ Bash(rm -rf /tmp/x)",
482            "Do you want to proceed?",
483            "❯ 1. Yes",
484            "  2. No, and tell Claude what to do differently",
485        ]);
486        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
487    }
488
489    #[test]
490    fn auto_surfaces_codex_input_when_claude_is_blind() {
491        // A real codex idle screen: claude's recognizer doesn't know the `›`
492        // glyph and returns Unknown, but auto must still surface codex's
493        // AwaitingInput rather than collapsing to Unknown.
494        let screen = lines(&["• PONG", "› Run /review on my current changes"]);
495        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Unknown);
496        assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
497        assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingInput);
498    }
499
500    #[test]
501    fn auto_stays_busy_with_stale_prompt_glyphs() {
502        // Both a stale claude `❯` and a codex `›` can linger while a turn
503        // streams; the `esc to interrupt` footer must keep auto Busy, never
504        // letting a leftover prompt glyph flip it to ready.
505        let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
506        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
507    }
508
509    // Codex fixtures below are from a real `codex` (codex-cli 0.139.0, gpt-5.5)
510    // session captured through winx's PTY.
511
512    #[test]
513    fn codex_busy_via_working_footer() {
514        let screen = lines(&[
515            "› escreva um poema longo sobre o oceano",
516            "",
517            "• Working (1s • esc to interrupt)",
518            "",
519            "  gpt-5.5 high · ~/Documents/projects/winx-code-agent",
520        ]);
521        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
522        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
523    }
524
525    #[test]
526    fn codex_idle_input_box_uses_angle_prompt() {
527        // Regression: codex's Ink prompt glyph is `›` (U+203A), not claude's `❯`
528        // nor a bare `>`. The old recognizer missed it and fell back to Unknown,
529        // forcing pure quiescence. Now it reports AwaitingInput explicitly.
530        let screen = lines(&[
531            "• PONG",
532            "",
533            "› Run /review on my current changes",
534            "",
535            "  gpt-5.5 high · ~/Documents/projects/winx-code-agent",
536        ]);
537        assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
538    }
539
540    #[test]
541    fn codex_awaiting_approval_command_dialog() {
542        // The real approval dialog codex shows under `-a untrusted`. Note the
543        // `›` selection glyph also appears here, so approval MUST be checked
544        // before the input box or it would read as AwaitingInput.
545        let screen = lines(&[
546            "  Would you like to run the following command?",
547            "",
548            "  $ touch /tmp/x.txt",
549            "",
550            "› 1. Yes, proceed (y)",
551            "  2. Yes, and don't ask again (p)",
552            "  3. No, and tell Codex what to do differently (esc)",
553            "",
554            "  Press enter to confirm or esc to cancel",
555        ]);
556        assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingApproval);
557        assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingApproval);
558    }
559
560    #[test]
561    fn codex_preparing_to_execute_is_busy_not_input() {
562        // Regression from a live session: while codex prepares/runs a tool call
563        // it briefly shows no `esc to interrupt` footer, and the just-submitted
564        // prompt's `›` lingers in the history above. That transient must read as
565        // Busy, not a false AwaitingInput, or a pilot would inject mid-execution.
566        let screen = lines(&[
567            "› execute o comando de shell 'touch /tmp/x.txt'",
568            "• Vou executar exatamente esse comando",
569            "• Preparing to execute shell command  9",
570        ]);
571        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
572    }
573
574    #[test]
575    fn codex_busy_wins_over_stale_angle_prompt() {
576        // A busy codex still shows the `›` input box; "esc to interrupt" must
577        // keep it Busy so we never send input mid-stream.
578        let screen = lines(&["› an earlier prompt", "• Working (3s • esc to interrupt)"]);
579        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
580    }
581
582    // Antigravity (`agy` 1.0.8, Gemini 3.5 Flash) fixtures captured through
583    // winx. agy renders in append mode, so these intentionally include the ghost
584    // `Generating…` / `esc to cancel` frames that linger after the turn — the
585    // recognizer must see through them.
586
587    #[test]
588    fn antigravity_busy_via_generating_and_esc_to_cancel() {
589        let screen = lines(&[
590            "> responda com uma palavra: PONG    Gemini 3.5 Flash (Medium)",
591            "⣾  Generating...",
592            "  PONG",
593            "esc to cancel    Gemini 3.5 Flash (Medium)",
594        ]);
595        assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::Busy);
596    }
597
598    #[test]
599    fn antigravity_idle_via_shortcuts_hint_despite_ghost_frames() {
600        // The real idle screen still carries ghost `Generating…`/`esc to cancel`
601        // lines from the finished turn, but `? for shortcuts` sits at the bottom
602        // only when input is ready — so it must win over the ghosts.
603        let screen = lines(&[
604            "  PONG",
605            "⣯  Generating..",
606            "esc to cancel    Gemini 3.5 Flash (Medium)",
607            "? for shortcuts",
608        ]);
609        assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
610    }
611
612    #[test]
613    fn antigravity_is_opt_in_not_part_of_auto() {
614        // agy's best-effort markers would pollute claude/codex, so it is opt-in
615        // only and auto must stay blind to its idle hint.
616        let screen = lines(&["? for shortcuts"]);
617        assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
618        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
619    }
620
621    #[test]
622    fn generic_is_always_unknown() {
623        let screen = lines(&["$ ls", "file.txt", "$"]);
624        assert_eq!(GenericRecognizer.detect(&screen), TurnState::Unknown);
625    }
626
627    #[test]
628    fn auto_falls_back_to_unknown_for_plain_shell() {
629        let screen = lines(&["building...", "done", "user@host:~$"]);
630        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
631    }
632
633    #[test]
634    fn turn_state_ready_semantics() {
635        assert!(TurnState::AwaitingInput.is_ready());
636        assert!(TurnState::AwaitingApproval.is_ready());
637        assert!(!TurnState::Busy.is_ready());
638        assert!(!TurnState::Unknown.is_ready());
639    }
640}