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/// Evidence for the winning turn classification. Child text is never process state.
70#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct TurnExplanation {
72    pub state: TurnState,
73    pub recognizer: String,
74    pub source: String,
75    pub matched_rule: Option<String>,
76    pub priority: Option<i32>,
77    pub reason: Option<String>,
78}
79
80/// Reads a rendered screen (ANSI already stripped, one `String` per line) and
81/// classifies the current turn state for a specific kind of TUI.
82pub trait TurnRecognizer: Send + Sync {
83    /// Stable identifier, surfaced in the status footer.
84    fn name(&self) -> &'static str;
85    /// Classify the current screen.
86    fn detect(&self, screen: &[String]) -> TurnState;
87    /// Classify with the full input (screen + OSC evidence). Screen-only
88    /// recognizers get this for free; the manifest recognizer overrides it to
89    /// use OSC title/progress rules.
90    fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
91        self.detect(input.screen)
92    }
93    /// Explain an ordinary recognizer without requiring external implementations to change.
94    fn explain_input(&self, input: &TurnInput<'_>) -> TurnExplanation {
95        let state = self.detect_input(input);
96        TurnExplanation {
97            state,
98            recognizer: self.name().to_string(),
99            source: "recognizer".to_string(),
100            matched_rule: None,
101            priority: None,
102            reason: (state == TurnState::Unknown).then(|| "no recognized evidence".to_string()),
103        }
104    }
105}
106
107/// Build the recognizer for a hint string. Hints resolve in this order:
108///   * `generic`/`none`/`off` and `configurable` keep their dedicated
109///     recognizers;
110///   * `auto` (and unknown hints) combine the claude + codex manifests with
111///     the legacy hand-written recognizers as fallback;
112///   * anything matching a bundled agent-manifest id or alias (`claude`,
113///     `codex`, `gemini`, `agy`, `cursor`, `opencode`, `grok`, `copilot`, ...)
114///     gets that manifest, with the legacy recognizer (where one exists)
115///     backing it up when no manifest rule fires.
116pub fn recognizer_for(hint: &str) -> Box<dyn TurnRecognizer> {
117    let hint = hint.trim().to_ascii_lowercase();
118    match hint.as_str() {
119        "generic" | "none" | "off" => return Box::new(GenericRecognizer),
120        "configurable" => return configurable_from_env(),
121        "auto" => return auto_recognizer(),
122        _ => {}
123    }
124    let legacy: Option<Box<dyn TurnRecognizer>> = match hint.as_str() {
125        "claude" => Some(Box::new(ClaudeRecognizer)),
126        "codex" => Some(Box::new(CodexRecognizer)),
127        // `gemini` was historically an alias for the antigravity recognizer;
128        // it now resolves to the gemini manifest but keeps those markers as
129        // the fallback layer.
130        "antigravity" | "agy" | "gemini" => Some(Box::new(AntigravityRecognizer)),
131        _ => None,
132    };
133    match crate::state::turn_manifest::manifest_for(&hint) {
134        Some(manifest) => Box::new(ManifestRecognizer { manifest, fallback: legacy }),
135        None => legacy.unwrap_or_else(auto_recognizer),
136    }
137}
138
139/// The `auto` recognizer: claude + codex manifests (each backed by its legacy
140/// recognizer), combined by priority. Falls back to the purely hand-written
141/// [`AutoRecognizer`] only if a bundled manifest failed to load.
142fn auto_recognizer() -> Box<dyn TurnRecognizer> {
143    use crate::state::turn_manifest::manifest_for;
144    let (Some(claude), Some(codex)) = (manifest_for("claude"), manifest_for("codex")) else {
145        return Box::new(AutoRecognizer);
146    };
147    Box::new(ManifestAutoRecognizer {
148        members: [
149            ManifestRecognizer { manifest: claude, fallback: Some(Box::new(ClaudeRecognizer)) },
150            ManifestRecognizer { manifest: codex, fallback: Some(Box::new(CodexRecognizer)) },
151        ],
152    })
153}
154
155/// Build a [`GenericConfigurable`] from the `WINX_TURN_RECOGNIZER_CONFIG` env var
156/// (JSON markers), or fall back to the generic quiescence recognizer when it is
157/// unset, empty, or invalid.
158fn configurable_from_env() -> Box<dyn TurnRecognizer> {
159    if let Ok(json) = std::env::var("WINX_TURN_RECOGNIZER_CONFIG") {
160        if let Ok(markers) = serde_json::from_str::<crate::types::TurnRecognizerMarkers>(&json) {
161            if !markers.is_empty() {
162                return Box::new(GenericConfigurable::new(&markers));
163            }
164        }
165    }
166    Box::new(GenericRecognizer)
167}
168
169// --- shared helpers ---------------------------------------------------------
170
171/// Whether any line on the screen contains `needle` (case-insensitive).
172///
173/// Busy markers are scanned across the WHOLE screen, not just the tail: a long
174/// response can push the footer's "esc to interrupt" out of the last lines, and
175/// missing it would flip a still-generating turn to "ready" mid-stream and
176/// corrupt the next input. A rare false-busy (the model literally printing the
177/// phrase) only delays the turn — strictly the safer error.
178fn any_line_contains(screen: &[String], needle: &str) -> bool {
179    screen.iter().any(|l| l.to_lowercase().contains(needle))
180}
181
182/// Last `n` non-blank lines, lowercased and joined — used for the dialog
183/// phrase scan ("do you want", "trust this folder", ...) which is naturally
184/// localized to the bottom of the screen.
185fn tail_lower(screen: &[String], n: usize) -> String {
186    let mut lines: Vec<String> = screen
187        .iter()
188        .rev()
189        .filter(|l| !l.trim().is_empty())
190        .take(n)
191        .map(|l| l.to_lowercase())
192        .collect();
193    lines.reverse();
194    lines.join("\n")
195}
196
197// --- Claude TUI -------------------------------------------------------------
198
199/// Recognizer for the `claude` CLI (Ink/React TUI). Markers verified against a
200/// real claude v2.1.x session captured through winx:
201///   * **Busy**: the footer shows `esc to interrupt` while generating. We rely
202///     on this and NOT on spinner glyphs — claude renders inline, so spent
203///     spinner lines ("✽ Gusting… (3s)") linger in the scrollback after the
204///     turn ends and would otherwise read as a false "busy".
205///   * **`AwaitingApproval`**: a confirmation/permission dialog, recognized by its
206///     prompt phrasing ("do you want", "trust this folder", "Esc to cancel", …).
207///   * **`AwaitingInput`**: the `❯` input box is present and neither of the above.
208pub struct ClaudeRecognizer;
209
210impl ClaudeRecognizer {
211    fn is_approval(screen: &[String]) -> bool {
212        // Phrase-based: these are the actual prompts claude shows for the
213        // trust-folder check and tool-permission dialogs. We deliberately do
214        // NOT key off a bare "1./2." numbered menu — ordinary numbered prose in
215        // a normal answer ("1. Agile…", "2. Waterfall…") would false-positive.
216        let joined = tail_lower(screen, 30);
217        joined.contains("do you want")
218            || joined.contains("would you like")
219            || joined.contains("trust this folder")
220            || joined.contains("enter to confirm")
221            || joined.contains("esc to cancel")
222    }
223
224    fn has_input_box(screen: &[String]) -> bool {
225        // The Ink prompt char. Only consulted once we've ruled out Busy/approval,
226        // so a lingering `❯` from earlier prompts can't mask an active turn.
227        screen.iter().any(|l| l.contains('❯'))
228    }
229}
230
231impl TurnRecognizer for ClaudeRecognizer {
232    fn name(&self) -> &'static str {
233        "claude"
234    }
235
236    fn detect(&self, screen: &[String]) -> TurnState {
237        // Order matters: Busy and approval are checked before the input box so a
238        // stale `❯` never shadows an active or blocked turn.
239        if any_line_contains(screen, "esc to interrupt") {
240            return TurnState::Busy;
241        }
242        if Self::is_approval(screen) {
243            return TurnState::AwaitingApproval;
244        }
245        if Self::has_input_box(screen) {
246            return TurnState::AwaitingInput;
247        }
248        TurnState::Unknown
249    }
250}
251
252// --- Codex CLI --------------------------------------------------------------
253
254/// Recognizer for the `codex` CLI (Ink TUI). Markers verified against a real
255/// codex-cli 0.139.0 (gpt-5.5) session captured through winx:
256///   * **Busy**: the footer shows `• Working (Ns • esc to interrupt)` while
257///     generating — the same `esc to interrupt` anchor claude uses.
258///   * **`AwaitingApproval`**: the command-confirmation dialog ("Would you like
259///     to run the following command?", "Press enter to confirm or esc to
260///     cancel") shown when codex must escalate (e.g. `-a untrusted`).
261///   * **`AwaitingInput`**: the input box, whose prompt glyph is `›` (U+203A) —
262///     distinct from claude's `❯` and from a bare `>`.
263pub struct CodexRecognizer;
264
265impl CodexRecognizer {
266    fn is_approval(screen: &[String]) -> bool {
267        // Phrase-based and localized to the bottom of the screen. The `›`
268        // selection glyph also appears in this dialog, so it MUST be classified
269        // as approval (checked before the input box), not as plain input.
270        let joined = tail_lower(screen, 30);
271        joined.contains("would you like to run")
272            || joined.contains("press enter to confirm")
273            || joined.contains("yes, proceed")
274    }
275
276    fn has_input_box(screen: &[String]) -> bool {
277        // Codex's Ink prompt char is `›` (U+203A). Only consulted once Busy and
278        // approval are ruled out, so a lingering `›` can't mask an active turn.
279        screen.iter().any(|l| l.trim_start().starts_with('›'))
280    }
281}
282
283impl TurnRecognizer for CodexRecognizer {
284    fn name(&self) -> &'static str {
285        "codex"
286    }
287
288    fn detect(&self, screen: &[String]) -> TurnState {
289        // Order matters: Busy and approval before the input box so a stale `›`
290        // never shadows an active or blocked turn.
291        //
292        // `preparing to execute` covers the transient where codex is running a
293        // tool call: it briefly drops the `esc to interrupt` footer while the
294        // submitted prompt's `›` still lingers in the history — without this it
295        // would read as a false AwaitingInput mid-execution.
296        if any_line_contains(screen, "esc to interrupt")
297            || any_line_contains(screen, "preparing to execute")
298        {
299            return TurnState::Busy;
300        }
301        if Self::is_approval(screen) {
302            return TurnState::AwaitingApproval;
303        }
304        if Self::has_input_box(screen) {
305            return TurnState::AwaitingInput;
306        }
307        TurnState::Unknown
308    }
309}
310
311// --- Antigravity CLI --------------------------------------------------------
312
313/// Recognizer for the `agy` (Antigravity CLI, Gemini) TUI. **Best-effort.**
314/// Unlike claude/codex (Ink, alt-screen + in-place redraw), agy renders in
315/// append mode inside winx's PTY: spent `Generating…` / `esc to cancel` frames
316/// ghost into later screens and are never cleared. We therefore lead with the
317/// idle hint `? for shortcuts`, which only sits at the *bottom* of the screen
318/// when input is actually ready — a busy turn pushes it out of the tail — and
319/// only then fall back to the busy markers. Markers verified against a real
320/// agy 1.0.8 (Gemini 3.5 Flash) session captured through winx.
321///
322/// Deliberately NOT part of [`AutoRecognizer`]: its markers (`esc to cancel`,
323/// `generating`) are generic enough to pollute claude/codex detection, so it is
324/// opt-in only via the `agy`/`antigravity` hint — mirroring [`GenericRecognizer`].
325pub struct AntigravityRecognizer;
326
327impl TurnRecognizer for AntigravityRecognizer {
328    fn name(&self) -> &'static str {
329        "antigravity"
330    }
331
332    fn detect(&self, screen: &[String]) -> TurnState {
333        // Idle hint first: `? for shortcuts` only sits at the bottom when the
334        // input box is ready. A busy turn pushes it out of the tail, and under
335        // append-mode rendering spent busy markers ghost into the idle screen —
336        // so leading with the idle hint (scoped to the tail) is what survives
337        // the ghosting. Only then fall back to the busy markers.
338        if tail_lower(screen, 4).contains("? for shortcuts") {
339            return TurnState::AwaitingInput;
340        }
341        if any_line_contains(screen, "generating") || any_line_contains(screen, "esc to cancel") {
342            return TurnState::Busy;
343        }
344        TurnState::Unknown
345    }
346}
347
348// --- Generic ----------------------------------------------------------------
349
350/// No app knowledge: always [`TurnState::Unknown`] so the caller decides purely
351/// by quiescence. Correct (if conservative) for any TUI.
352pub struct GenericRecognizer;
353
354impl TurnRecognizer for GenericRecognizer {
355    fn name(&self) -> &'static str {
356        "generic"
357    }
358
359    fn detect(&self, _screen: &[String]) -> TurnState {
360        TurnState::Unknown
361    }
362}
363
364// --- Configurable from markers ----------------------------------------------
365
366/// A recognizer built from marker strings/regexes in config
367/// ([`crate::types::TurnRecognizerMarkers`]), so `wait_for_turn` can pilot an
368/// arbitrary TUI without bespoke code. Same scan discipline as the built-ins:
369/// busy across the whole screen (a long response can push its footer out of the
370/// tail), approval/input tail-scoped so a stale scrollback prompt can't fire.
371pub struct GenericConfigurable {
372    busy: Vec<Regex>,
373    approval: Vec<Regex>,
374    input: Vec<Regex>,
375}
376
377impl GenericConfigurable {
378    pub fn new(markers: &crate::types::TurnRecognizerMarkers) -> Self {
379        Self {
380            busy: compile_markers(&markers.busy),
381            approval: compile_markers(&markers.awaiting_approval),
382            input: compile_markers(&markers.awaiting_input),
383        }
384    }
385}
386
387/// Compile each marker as a case-insensitive regex, falling back to a literal
388/// substring when the pattern doesn't compile; unusable markers are dropped.
389fn compile_markers(patterns: &[String]) -> Vec<Regex> {
390    patterns
391        .iter()
392        .filter_map(|pattern| {
393            Regex::new(&format!("(?i){pattern}"))
394                .or_else(|_| Regex::new(&format!("(?i){}", regex::escape(pattern))))
395                .ok()
396        })
397        .collect()
398}
399
400impl TurnRecognizer for GenericConfigurable {
401    fn name(&self) -> &'static str {
402        "configurable"
403    }
404
405    fn detect(&self, screen: &[String]) -> TurnState {
406        let whole = screen.join("\n");
407        if self.busy.iter().any(|re| re.is_match(&whole)) {
408            return TurnState::Busy;
409        }
410        let tail = tail_lower(screen, 30);
411        if self.approval.iter().any(|re| re.is_match(&tail)) {
412            return TurnState::AwaitingApproval;
413        }
414        if self.input.iter().any(|re| re.is_match(&tail)) {
415            return TurnState::AwaitingInput;
416        }
417        TurnState::Unknown
418    }
419}
420
421// --- Manifest-driven ---------------------------------------------------------
422
423/// A recognizer backed by a data-driven agent manifest (see
424/// [`crate::state::turn_manifest`]) with an optional legacy recognizer behind
425/// it. A matched manifest rule is authoritative — including an explicit
426/// `unknown` match (e.g. a transcript-viewer screen), which must NOT fall
427/// through to the legacy recognizer: those screens show stale content that
428/// legacy markers would misread. Only a complete manifest miss delegates.
429pub struct ManifestRecognizer {
430    manifest: &'static crate::state::turn_manifest::CompiledManifest,
431    fallback: Option<Box<dyn TurnRecognizer>>,
432}
433
434impl TurnRecognizer for ManifestRecognizer {
435    fn name(&self) -> &'static str {
436        let manifest: &'static crate::state::turn_manifest::CompiledManifest = self.manifest;
437        manifest.id()
438    }
439
440    fn detect(&self, screen: &[String]) -> TurnState {
441        self.detect_input(&TurnInput::screen_only(screen))
442    }
443
444    fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
445        self.explain_input(input).state
446    }
447
448    fn explain_input(&self, input: &TurnInput<'_>) -> TurnExplanation {
449        let joined = input.screen.join("\n");
450        let verdict = self.manifest.detect(crate::state::turn_manifest::DetectionInput {
451            screen: &joined,
452            osc_title: input.osc_title,
453            osc_progress: input.osc_progress,
454        });
455        if let Some((rule_id, priority)) = verdict.rule {
456            return TurnExplanation {
457                state: verdict.state,
458                recognizer: self.name().to_string(),
459                source: "manifest".to_string(),
460                matched_rule: Some(rule_id.to_string()),
461                priority: Some(priority),
462                reason: (verdict.state == TurnState::Unknown)
463                    .then(|| "matching manifest rule explicitly selected unknown".to_string()),
464            };
465        }
466        match &self.fallback {
467            Some(fallback) => {
468                let mut explanation = fallback.explain_input(input);
469                explanation.source = "fallback".to_string();
470                explanation.reason =
471                    Some("no manifest rule matched; legacy recognizer used".to_string());
472                explanation
473            }
474            None => TurnExplanation {
475                state: TurnState::Unknown,
476                recognizer: self.name().to_string(),
477                source: "manifest".to_string(),
478                matched_rule: None,
479                priority: None,
480                reason: Some("no manifest rule matched and no fallback exists".to_string()),
481            },
482        }
483    }
484}
485
486/// Manifest-driven `auto`: claude + codex manifests evaluated side by side,
487/// verdicts combined by priority (`Busy` > `AwaitingApproval` >
488/// `AwaitingInput` > `Unknown`) — same arbitration as the legacy
489/// [`AutoRecognizer`], so a busy app always wins over a stale prompt glyph.
490struct ManifestAutoRecognizer {
491    members: [ManifestRecognizer; 2],
492}
493
494impl TurnRecognizer for ManifestAutoRecognizer {
495    fn name(&self) -> &'static str {
496        "auto"
497    }
498
499    fn detect(&self, screen: &[String]) -> TurnState {
500        self.detect_input(&TurnInput::screen_only(screen))
501    }
502
503    fn detect_input(&self, input: &TurnInput<'_>) -> TurnState {
504        self.explain_input(input).state
505    }
506
507    fn explain_input(&self, input: &TurnInput<'_>) -> TurnExplanation {
508        let explanations = self.members.each_ref().map(|member| member.explain_input(input));
509        for want in [
510            TurnState::Busy,
511            TurnState::AwaitingApproval,
512            TurnState::AwaitingInput,
513            TurnState::Unknown,
514        ] {
515            if let Some(explanation) =
516                explanations.iter().find(|explanation| explanation.state == want)
517            {
518                return explanation.clone();
519            }
520        }
521        unreachable!("both recognizers always classify a turn")
522    }
523}
524
525// --- Auto -------------------------------------------------------------------
526
527/// Runs every app-specific recognizer and combines their verdicts by priority:
528/// `Busy` > `AwaitingApproval` > `AwaitingInput` > `Unknown`. A recognizer that
529/// sees the app working therefore always wins over one that only sees a
530/// (possibly stale) prompt glyph — so `auto` never reports a busy app as ready,
531/// while still falling back to `Unknown` (quiescence) for an unknown TUI.
532pub struct AutoRecognizer;
533
534impl TurnRecognizer for AutoRecognizer {
535    fn name(&self) -> &'static str {
536        "auto"
537    }
538
539    fn detect(&self, screen: &[String]) -> TurnState {
540        let states = [ClaudeRecognizer.detect(screen), CodexRecognizer.detect(screen)];
541        for want in [TurnState::Busy, TurnState::AwaitingApproval, TurnState::AwaitingInput] {
542            if states.contains(&want) {
543                return want;
544            }
545        }
546        TurnState::Unknown
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn lines(s: &[&str]) -> Vec<String> {
555        s.iter().map(|x| (*x).to_string()).collect()
556    }
557
558    #[test]
559    fn configurable_classifies_by_marker_case_insensitively() {
560        let markers = crate::types::TurnRecognizerMarkers {
561            busy: vec!["working".to_string()],
562            awaiting_input: vec![r"\$".to_string()],
563            awaiting_approval: vec!["confirm".to_string()],
564        };
565        let recognizer = GenericConfigurable::new(&markers);
566        assert_eq!(recognizer.detect(&lines(&["WORKING on it"])), TurnState::Busy);
567        assert_eq!(recognizer.detect(&lines(&["user@host:~$ "])), TurnState::AwaitingInput);
568        assert_eq!(
569            recognizer.detect(&lines(&["Please Confirm (y/n)"])),
570            TurnState::AwaitingApproval
571        );
572        assert_eq!(recognizer.detect(&lines(&["just some text"])), TurnState::Unknown);
573    }
574
575    #[test]
576    fn configurable_busy_beats_a_stale_prompt() {
577        let markers = crate::types::TurnRecognizerMarkers {
578            busy: vec!["esc to interrupt".to_string()],
579            awaiting_input: vec![">".to_string()],
580            awaiting_approval: vec![],
581        };
582        let recognizer = GenericConfigurable::new(&markers);
583        // Both a prompt glyph and a busy marker on screen → Busy must win.
584        assert_eq!(recognizer.detect(&lines(&["> ", "esc to interrupt"])), TurnState::Busy);
585    }
586
587    // Fixtures below are taken from a real `claude` v2.1.159 session captured
588    // through winx's PTY.
589
590    #[test]
591    fn claude_busy_via_esc_to_interrupt() {
592        let screen = lines(&[
593            "❯ responda exatamente uma palavra: PONG",
594            "",
595            "* Hatching…",
596            "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
597        ]);
598        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
599        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
600    }
601
602    #[test]
603    fn claude_long_response_busy_even_with_stale_prompt() {
604        // Regression for the review finding: on a long response the footer can
605        // be far from the bottom and an old `❯` lingers in the scrollback.
606        // Scanning the whole screen for "esc to interrupt" keeps it Busy, so we
607        // never send input mid-stream.
608        let mut screen = vec![
609            "❯ old prompt from a previous turn".to_string(),
610            String::new(),
611            "●─here is a long answer".to_string(),
612        ];
613        screen.push("⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt".to_string());
614        for i in 0..40 {
615            screen.push(format!("streamed answer line {i}"));
616        }
617        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
618        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
619    }
620
621    #[test]
622    fn claude_idle_after_answer_is_input_not_busy() {
623        // claude renders inline, so spent spinner lines linger in the scrollback
624        // after the turn finishes. Once the footer drops "esc to interrupt" and
625        // the `❯` box is back, the turn IS over. (Real post-answer screen.)
626        let screen = lines(&[
627            "❯ Responda com exatamente uma palavra, em maiúsculas: PONG",
628            "· Gusting…",
629            "●─PONG",
630            "✽ Gusting… (3s · ↓ 1 tokens)",
631            "✻ Baked for 4s",
632            "❯",
633            "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
634        ]);
635        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
636    }
637
638    #[test]
639    fn claude_numbered_prose_is_not_approval() {
640        // Regression: a normal answer containing a numbered list whose text
641        // happens to contain "allows"/"known" must NOT read as an approval.
642        let screen = lines(&[
643            "❯ compare the methodologies",
644            "1. Agile - allows iterative delivery",
645            "2. Waterfall - assumes requirements are known up front",
646            "❯",
647            "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents",
648        ]);
649        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingInput);
650    }
651
652    #[test]
653    fn claude_awaiting_approval_trust_prompt() {
654        // The real "trust this folder?" approval shown on a fresh directory.
655        let screen = lines(&[
656            "Quick safety check: Is this a project you created or one you trust?",
657            "❯ 1. Yes, I trust this folder",
658            "  2. No, exit",
659            "Enter to confirm · Esc to cancel",
660        ]);
661        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
662    }
663
664    #[test]
665    fn claude_awaiting_approval_tool_menu() {
666        let screen = lines(&[
667            "⏺ Bash(rm -rf /tmp/x)",
668            "Do you want to proceed?",
669            "❯ 1. Yes",
670            "  2. No, and tell Claude what to do differently",
671        ]);
672        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::AwaitingApproval);
673    }
674
675    #[test]
676    fn auto_surfaces_codex_input_when_claude_is_blind() {
677        // A real codex idle screen: claude's recognizer doesn't know the `›`
678        // glyph and returns Unknown, but auto must still surface codex's
679        // AwaitingInput rather than collapsing to Unknown.
680        let screen = lines(&["• PONG", "› Run /review on my current changes"]);
681        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Unknown);
682        assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
683        assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingInput);
684    }
685
686    #[test]
687    fn auto_stays_busy_with_stale_prompt_glyphs() {
688        // Both a stale claude `❯` and a codex `›` can linger while a turn
689        // streams; the `esc to interrupt` footer must keep auto Busy, never
690        // letting a leftover prompt glyph flip it to ready.
691        let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
692        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
693    }
694
695    // Codex fixtures below are from a real `codex` (codex-cli 0.139.0, gpt-5.5)
696    // session captured through winx's PTY.
697
698    #[test]
699    fn codex_busy_via_working_footer() {
700        let screen = lines(&[
701            "› escreva um poema longo sobre o oceano",
702            "",
703            "• Working (1s • esc to interrupt)",
704            "",
705            "  gpt-5.5 high · ~/Documents/projects/winx-code-agent",
706        ]);
707        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
708        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Busy);
709    }
710
711    #[test]
712    fn codex_idle_input_box_uses_angle_prompt() {
713        // Regression: codex's Ink prompt glyph is `›` (U+203A), not claude's `❯`
714        // nor a bare `>`. The old recognizer missed it and fell back to Unknown,
715        // forcing pure quiescence. Now it reports AwaitingInput explicitly.
716        let screen = lines(&[
717            "• PONG",
718            "",
719            "› Run /review on my current changes",
720            "",
721            "  gpt-5.5 high · ~/Documents/projects/winx-code-agent",
722        ]);
723        assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingInput);
724    }
725
726    #[test]
727    fn codex_awaiting_approval_command_dialog() {
728        // The real approval dialog codex shows under `-a untrusted`. Note the
729        // `›` selection glyph also appears here, so approval MUST be checked
730        // before the input box or it would read as AwaitingInput.
731        let screen = lines(&[
732            "  Would you like to run the following command?",
733            "",
734            "  $ touch /tmp/x.txt",
735            "",
736            "› 1. Yes, proceed (y)",
737            "  2. Yes, and don't ask again (p)",
738            "  3. No, and tell Codex what to do differently (esc)",
739            "",
740            "  Press enter to confirm or esc to cancel",
741        ]);
742        assert_eq!(CodexRecognizer.detect(&screen), TurnState::AwaitingApproval);
743        assert_eq!(AutoRecognizer.detect(&screen), TurnState::AwaitingApproval);
744    }
745
746    #[test]
747    fn codex_preparing_to_execute_is_busy_not_input() {
748        // Regression from a live session: while codex prepares/runs a tool call
749        // it briefly shows no `esc to interrupt` footer, and the just-submitted
750        // prompt's `›` lingers in the history above. That transient must read as
751        // Busy, not a false AwaitingInput, or a pilot would inject mid-execution.
752        let screen = lines(&[
753            "› execute o comando de shell 'touch /tmp/x.txt'",
754            "• Vou executar exatamente esse comando",
755            "• Preparing to execute shell command  9",
756        ]);
757        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
758    }
759
760    #[test]
761    fn codex_busy_wins_over_stale_angle_prompt() {
762        // A busy codex still shows the `›` input box; "esc to interrupt" must
763        // keep it Busy so we never send input mid-stream.
764        let screen = lines(&["› an earlier prompt", "• Working (3s • esc to interrupt)"]);
765        assert_eq!(CodexRecognizer.detect(&screen), TurnState::Busy);
766    }
767
768    // Antigravity (`agy` 1.0.8, Gemini 3.5 Flash) fixtures captured through
769    // winx. agy renders in append mode, so these intentionally include the ghost
770    // `Generating…` / `esc to cancel` frames that linger after the turn — the
771    // recognizer must see through them.
772
773    #[test]
774    fn antigravity_busy_via_generating_and_esc_to_cancel() {
775        let screen = lines(&[
776            "> responda com uma palavra: PONG    Gemini 3.5 Flash (Medium)",
777            "⣾  Generating...",
778            "  PONG",
779            "esc to cancel    Gemini 3.5 Flash (Medium)",
780        ]);
781        assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::Busy);
782    }
783
784    #[test]
785    fn antigravity_idle_via_shortcuts_hint_despite_ghost_frames() {
786        // The real idle screen still carries ghost `Generating…`/`esc to cancel`
787        // lines from the finished turn, but `? for shortcuts` sits at the bottom
788        // only when input is ready — so it must win over the ghosts.
789        let screen = lines(&[
790            "  PONG",
791            "⣯  Generating..",
792            "esc to cancel    Gemini 3.5 Flash (Medium)",
793            "? for shortcuts",
794        ]);
795        assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
796    }
797
798    #[test]
799    fn antigravity_is_opt_in_not_part_of_auto() {
800        // agy's best-effort markers would pollute claude/codex, so it is opt-in
801        // only and auto must stay blind to its idle hint.
802        let screen = lines(&["? for shortcuts"]);
803        assert_eq!(AntigravityRecognizer.detect(&screen), TurnState::AwaitingInput);
804        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
805    }
806
807    #[test]
808    fn generic_is_always_unknown() {
809        let screen = lines(&["$ ls", "file.txt", "$"]);
810        assert_eq!(GenericRecognizer.detect(&screen), TurnState::Unknown);
811    }
812
813    #[test]
814    fn auto_falls_back_to_unknown_for_plain_shell() {
815        let screen = lines(&["building...", "done", "user@host:~$"]);
816        assert_eq!(AutoRecognizer.detect(&screen), TurnState::Unknown);
817    }
818
819    #[test]
820    fn turn_state_ready_semantics() {
821        assert!(TurnState::AwaitingInput.is_ready());
822        assert!(TurnState::AwaitingApproval.is_ready());
823        assert!(!TurnState::Busy.is_ready());
824        assert!(!TurnState::Unknown.is_ready());
825    }
826
827    // --- manifest-driven recognizer_for wiring ------------------------------
828
829    #[test]
830    fn recognizer_for_resolves_manifest_hints() {
831        // Core hints resolve to their manifests (name = manifest id)...
832        assert_eq!(recognizer_for("claude").name(), "claude");
833        assert_eq!(recognizer_for("codex").name(), "codex");
834        assert_eq!(recognizer_for("gemini").name(), "gemini");
835        assert_eq!(recognizer_for("agy").name(), "agy");
836        // ...as do agents winx never had hand-written recognizers for.
837        assert_eq!(recognizer_for("cursor").name(), "cursor");
838        assert_eq!(recognizer_for("opencode").name(), "opencode");
839        assert_eq!(recognizer_for("ghcs").name(), "copilot");
840        // Non-manifest hints keep their historical routing.
841        assert_eq!(recognizer_for("auto").name(), "auto");
842        assert_eq!(recognizer_for("generic").name(), "generic");
843        assert_eq!(recognizer_for("something-else").name(), "auto");
844    }
845
846    #[test]
847    fn manifest_claude_falls_back_to_legacy_markers_on_a_miss() {
848        // No manifest rule fires on this minimal screen (no prompt box rules,
849        // no dialogs), so the legacy `❯` marker must still surface input.
850        let screen = lines(&["● answered", "❯"]);
851        let recognizer = recognizer_for("claude");
852        assert_eq!(recognizer.detect(&screen), TurnState::AwaitingInput);
853    }
854
855    #[test]
856    fn manifest_rules_beat_legacy_on_current_claude_screens() {
857        // The 2.x claude prompt box sits between two horizontal rules — a
858        // shape only the manifest knows. The legacy recognizer also says
859        // AwaitingInput here (via `❯`), but the manifest's approval/working
860        // coverage beyond this is what the fixture tests below rely on.
861        let screen = lines(&["●─PONG", "──────────", "❯ ", "──────────", "  ⏵⏵ accept edits on"]);
862        assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingInput);
863
864        // A permission dialog the legacy phrase list misses ("Do you want to
865        // proceed?" + numbered menu after a horizontal rule).
866        let screen = lines(&[
867            "⏺ Bash(cargo test)",
868            "──────────",
869            "Do you want to proceed?",
870            "❯ 1. Yes",
871            "  2. No, and tell Claude what to do differently",
872            "Esc to cancel",
873        ]);
874        assert_eq!(recognizer_for("claude").detect(&screen), TurnState::AwaitingApproval);
875    }
876
877    #[test]
878    fn manifest_explicit_unknown_does_not_fall_through_to_legacy() {
879        // A transcript viewer shows STALE content — the legacy recognizer
880        // would read the old "esc to interrupt" as Busy, but the manifest's
881        // transcript_viewer rule (state=unknown) is authoritative: defer to
882        // quiescence instead.
883        let screen = lines(&[
884            "⏺ old tool output esc to interrupt",
885            "Showing detailed transcript",
886            "ctrl+o to toggle",
887        ]);
888        assert_eq!(ClaudeRecognizer.detect(&screen), TurnState::Busy);
889        assert_eq!(recognizer_for("claude").detect(&screen), TurnState::Unknown);
890    }
891
892    #[test]
893    fn osc_title_spinner_keeps_claude_busy_over_an_idle_looking_screen() {
894        // Screen alone looks idle (stale ❯), but the OSC title still carries
895        // the braille spinner claude renders while generating.
896        let screen = lines(&["❯ old prompt"]);
897        let recognizer = recognizer_for("claude");
898        let input = TurnInput { screen: &screen, osc_title: "⠋ claude", osc_progress: "" };
899        assert_eq!(recognizer.detect_input(&input), TurnState::Busy);
900        // Same screen without the title falls to the legacy `❯` marker.
901        assert_eq!(
902            recognizer.detect_input(&TurnInput::screen_only(&screen)),
903            TurnState::AwaitingInput
904        );
905    }
906
907    #[test]
908    fn auto_combines_manifest_and_legacy_verdicts() {
909        // Codex working footer → Busy through the manifest members.
910        let screen = lines(&["❯ old", "› old", "• Working (2s • esc to interrupt)"]);
911        assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Busy);
912        // Codex idle prompt only the legacy fallback knows → AwaitingInput.
913        let screen = lines(&["• PONG", "› Run /review on my current changes"]);
914        assert_eq!(recognizer_for("auto").detect(&screen), TurnState::AwaitingInput);
915        // Plain shell → Unknown (quiescence decides).
916        let screen = lines(&["building...", "done", "user@host:~$"]);
917        assert_eq!(recognizer_for("auto").detect(&screen), TurnState::Unknown);
918    }
919}
920
921#[cfg(test)]
922mod workflow_tests;