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}