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}