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