Skip to main content

winx_code_agent/tools/
bash_command.rs

1//! Implementation of the `BashCommand` tool with WCGW parity.
2//!
3//! This module provides the implementation for the `BashCommand` tool, which is used
4//! to execute shell commands, check command status, and interact with the shell.
5//! Matches the behavior of wcgw Python implementation 1:1.
6
7use anyhow::Context as AnyhowContext;
8use rand::RngExt;
9use regex::Regex;
10use std::collections::HashMap;
11use std::fmt::Write as FmtWrite;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex as StdMutex};
14use std::time::{Duration, Instant};
15use tokio::sync::Mutex;
16use tokio::time::sleep;
17use tracing::{debug, error, info, warn};
18
19use crate::errors::{Result, WinxError};
20use crate::state::bash_state::BashState;
21use crate::state::live_terminal::ScreenUpdate;
22use crate::state::pty::PtyShell;
23use crate::state::terminal::{render_terminal_output, strip_ansi_codes};
24use crate::types::{normalize_thread_id, BashCommand, BashCommandAction, SpecialKey};
25
26type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
27
28// ==================== WCGW-Style Constants ====================
29
30/// Default timeout for command execution (seconds) - matches WCGW Python Config.timeout
31const DEFAULT_TIMEOUT: f64 = 5.0;
32
33/// Extended timeout while output is still being produced - matches WCGW Python `Config.timeout_while_output`
34const TIMEOUT_WHILE_OUTPUT: f64 = 20.0;
35
36/// Number of iterations to wait without new output before giving up - matches WCGW Python `Config.output_wait_patience`
37const OUTPUT_WAIT_PATIENCE: i32 = 3;
38
39/// Async poll interval for adaptive output reads. We drain whatever the reader
40/// thread has queued (non-blocking), release the shell lock, then `await` this
41/// long — yielding the executor instead of pinning it on a blocking sleep.
42const POLL_INTERVAL_MS: u64 = 20;
43
44/// Grace period after the prompt is seen, to capture bytes that land just after
45/// it (mirrors the old `read_output` post-prompt drain) — awaited, not blocked.
46const POST_PROMPT_DRAIN_MS: u64 = 100;
47
48/// Chunk size for sending commands (characters) - matches WCGW Python (64 chars)
49const COMMAND_CHUNK_SIZE: usize = 64;
50
51/// Chunk size for sending text input (characters) - matches WCGW Python (128 chars)
52const TEXT_CHUNK_SIZE: usize = 128;
53
54/// Cheap byte-level safety net. We never even consider token counting if the
55/// raw payload is smaller than this — tokenizing is fast but not free, and
56/// the vast majority of responses are tiny status updates.
57const MAX_OUTPUT_LENGTH: usize = 100_000;
58
59/// Token budget reserved for a single PTY response when token-aware truncation
60/// kicks in. Picked to leave plenty of room for the surrounding context — most
61/// frontier models have 128k+ windows, so 25k for one shell payload is generous
62/// without monopolizing the conversation.
63const MAX_OUTPUT_TOKENS: usize = 25_000;
64
65/// Delay between typing text and the submitting Enter. Ink-based TUIs (Claude
66/// Code) collapse a burst of bytes into a single render tick and drop a CR that
67/// arrives glued to the text — the input box updates but never submits. Sending
68/// the Enter as a separate write after this short pause lets the TUI commit the
69/// typed text first, so `submit: true` reliably submits. ~40ms is imperceptible
70/// yet enough for the event loop to flush; harmless for plain shells.
71const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
72
73/// `screen` diff mode emits a per-line delta only when at most this many lines
74/// changed; beyond that the full frame is cheaper to read than a giant diff.
75const SCREEN_DIFF_THRESHOLD: usize = 10;
76
77/// Tail of `text` at most `max_len` bytes long, snapped up to a char boundary so
78/// we never slice through a multibyte UTF-8 sequence (which would panic).
79fn char_safe_tail(text: &str, max_len: usize) -> &str {
80    if text.len() <= max_len {
81        return text;
82    }
83    let mut start = text.len() - max_len;
84    while start < text.len() && !text.is_char_boundary(start) {
85        start += 1;
86    }
87    &text[start..]
88}
89
90/// Truncate `text` so its Claude token count stays under `max_tokens`.
91///
92/// We tokenize the tail of the string only when the raw byte length already
93/// exceeds the byte cap; otherwise we trust the byte budget and return as-is.
94/// When the tail still overshoots, we keep the last `max_tokens - reserve`
95/// tokens and prepend a "(...truncated)" marker — exactly what wcgw does in
96/// `_incremental_text`.
97fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
98    if text.len() <= MAX_OUTPUT_LENGTH {
99        return std::borrow::Cow::Borrowed(text);
100    }
101
102    let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
103        // Fallback to the byte-based truncation we used before the tokenizer.
104        return std::borrow::Cow::Owned(format!(
105            "(...truncated)\n{}",
106            char_safe_tail(text, MAX_OUTPUT_LENGTH)
107        ));
108    };
109
110    if tokens.len() <= max_tokens {
111        return std::borrow::Cow::Borrowed(text);
112    }
113
114    // Reserve one token slot for the marker overhead.
115    let keep = max_tokens.saturating_sub(1);
116    let tail = &tokens[tokens.len() - keep..];
117    let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
118        // Tokenizer present but decode failed: fall back to a byte tail.
119        char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
120    });
121    std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
122}
123
124/// Message when a command is already running - matches WCGW Python `WAITING_INPUT_MESSAGE`
125const WAITING_INPUT_MESSAGE: &str = "A command is already running. NOTE: You can't run multiple shell commands in main shell, likely a previous program hasn't exited.
1261. Get its output using status check.
1272. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1283. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1294. Interrupt and run the process in background
130";
131
132// ==================== Background Shell Manager ====================
133
134/// Snapshot of a background shell that has exited but whose final output has not
135/// yet been consumed by the caller. We keep it around so the next call (typically
136/// a `status_check`) can return the trailing output before the entry is gone.
137#[derive(Debug, Clone)]
138pub struct ExitedShellInfo {
139    pub last_command: String,
140    pub final_output: String,
141    pub exited_at: Instant,
142}
143
144/// Manages background shell sessions - matches WCGW Python's `background_shells` dict
145#[derive(Debug, Default)]
146pub struct BackgroundShellManager {
147    shells: HashMap<String, SharedPtyShell>,
148    /// Recently exited shells that still owe their final output to the caller.
149    /// Entries are consumed the first time the caller queries the id, then dropped.
150    tombstones: HashMap<String, ExitedShellInfo>,
151}
152
153impl BackgroundShellManager {
154    /// Tombstones older than this are garbage-collected on the next prune pass.
155    const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
156
157    /// Create a new background shell manager
158    pub fn new() -> Self {
159        Self { shells: HashMap::new(), tombstones: HashMap::new() }
160    }
161
162    /// Start a new background shell and return its command ID
163    /// Register an already-built shell and return its id.
164    ///
165    /// The caller builds the `PtyShell` BEFORE taking the manager lock —
166    /// `PtyShell::new` forks+execs and does a ~300ms blocking prompt init, which
167    /// must not run under the global `std::Mutex` (it would serialize every other
168    /// background-shell op behind one slow spawn).
169    pub fn register_shell(&mut self, shell: PtyShell) -> String {
170        let cid = format!("{:010x}", rand::rng().random::<u32>());
171        self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
172        info!("Started background shell with id: {}", cid);
173        cid
174    }
175
176    /// Get a background shell by its command ID
177    pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
178        self.shells.get(bg_command_id).cloned()
179    }
180
181    /// Remove and cleanup a background shell
182    pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
183        if let Some(shell_arc) = self.shells.remove(bg_command_id) {
184            if let Ok(mut guard) = shell_arc.try_lock() {
185                *guard = None;
186            }
187            info!("Removed background shell: {}", bg_command_id);
188            true
189        } else {
190            false
191        }
192    }
193
194    fn prune_finished_shells(&mut self) {
195        // GC old tombstones first.
196        let now = Instant::now();
197        self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
198
199        let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
200
201        for (id, shell_arc) in &self.shells {
202            let Ok(mut guard) = shell_arc.try_lock() else {
203                continue;
204            };
205
206            let Some(shell) = guard.as_mut() else {
207                finished.push((id.clone(), None));
208                continue;
209            };
210
211            if !shell.is_alive() {
212                let tombstone = ExitedShellInfo {
213                    last_command: shell.last_command.clone(),
214                    final_output: std::mem::take(&mut shell.output_buffer),
215                    exited_at: now,
216                };
217                finished.push((id.clone(), Some(tombstone)));
218                continue;
219            }
220
221            // Never prune shells that haven't received a command yet.
222            // The global BG_SHELL_MANAGER is shared across parallel tests; a freshly
223            // spawned shell would otherwise be evicted between start_new_shell and
224            // the first send_command, leading to "Failed to get background shell".
225            if shell.last_command.is_empty() {
226                continue;
227            }
228
229            if shell.command_running {
230                // Non-blocking: draining here must NOT hold the global manager
231                // lock across a 100ms blocking read (that serializes every other
232                // background-shell op behind a single slow PTY).
233                shell.poll_output_nonblocking();
234            }
235
236            if !shell.command_running {
237                let tombstone = ExitedShellInfo {
238                    last_command: shell.last_command.clone(),
239                    final_output: std::mem::take(&mut shell.output_buffer),
240                    exited_at: now,
241                };
242                finished.push((id.clone(), Some(tombstone)));
243            }
244        }
245
246        for (id, tombstone) in finished {
247            self.remove_shell(&id);
248            if let Some(info) = tombstone {
249                self.tombstones.insert(id, info);
250            }
251        }
252    }
253
254    /// Look up the tombstone for a recently-exited shell, if any.
255    ///
256    /// The entry stays in the map until the TTL expires (see
257    /// `prune_finished_shells`), so repeated `status_check` calls on the same
258    /// `bg_command_id` keep returning the cached final output instead of
259    /// flipping to "shell not found" after the first read.
260    pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
261        self.tombstones.get(bg_command_id).cloned()
262    }
263
264    /// Get info about all running background shells - matches WCGW Python `get_bg_running_commandsinfo`
265    pub fn get_running_info(&mut self) -> String {
266        self.prune_finished_shells();
267
268        if self.shells.is_empty() {
269            return "No command running in background.\n".to_string();
270        }
271
272        let mut running = Vec::new();
273        for (id, shell_arc) in &self.shells {
274            if let Ok(guard) = shell_arc.try_lock() {
275                if let Some(bash) = guard.as_ref() {
276                    if bash.command_running {
277                        running
278                            .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
279                    }
280                }
281            } else {
282                running.push(format!("Command: <busy>, bg_command_id: {id}"));
283            }
284        }
285
286        if running.is_empty() {
287            "No command running in background.\n".to_string()
288        } else {
289            format!("Following background commands are attached:\n{}\n", running.join("\n"))
290        }
291    }
292}
293
294// Global background shell manager (thread-safe) - matches WCGW Python's BashState.background_shells
295lazy_static::lazy_static! {
296    static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
297}
298
299/// Lock the global background-shell manager, recovering from poisoning.
300///
301/// A panic while holding this lock (e.g. in the rendering path during a prune)
302/// must NOT permanently brick all background-shell functionality for the rest of
303/// the server's lifetime. The manager's data stays consistent across a panic, so
304/// recovering the inner guard is safe.
305fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
306    BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
307}
308
309// ==================== WCGW-Style Helper Functions ====================
310
311/// Get WCGW-style status string - matches WCGW Python's `get_status()`
312fn get_status(
313    bash_state: &BashState,
314    is_bg: bool,
315    bg_id: Option<&str>,
316    is_running: bool,
317    running_for: Option<&str>,
318    exit_code: Option<i32>,
319) -> String {
320    let mut status = "\n\n---\n\n".to_string();
321
322    if is_bg {
323        if let Some(id) = bg_id {
324            let _ = writeln!(status, "bg_command_id = {id}");
325        }
326    }
327
328    if is_running {
329        status.push_str("status = still running\n");
330        if let Some(duration) = running_for {
331            let _ = writeln!(status, "running for = {duration}");
332        }
333    } else {
334        status.push_str("status = process exited\n");
335        // Exit code of the just-finished command, parsed from the prompt marker
336        // (`──➤<nonce>:<code>`). Lets the agent see failure without grepping stderr.
337        if let Some(code) = exit_code {
338            let _ = writeln!(status, "exit code = {code}");
339        }
340    }
341
342    let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
343
344    if !is_bg {
345        // Add background shell info for main shell - matches WCGW Python
346        {
347            let mut manager = lock_bg_manager();
348            status.push_str("This is the main shell. ");
349            status.push_str(&manager.get_running_info());
350        }
351    }
352
353    status.trim_end().to_string()
354}
355
356/// Process output with WCGW-style incremental text handling - matches WCGW Python _`incremental_text`
357fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
358    let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
359    let text = truncated.as_ref();
360
361    if last_pending_output.is_empty() {
362        let rendered = render_terminal_output(text);
363        return rstrip_lines(&rendered).trim_start().to_string();
364    }
365
366    let last_rendered = render_terminal_output(last_pending_output);
367    if last_rendered.is_empty() {
368        return rstrip_lines(&render_terminal_output(text));
369    }
370
371    // Get text after last pending output. Snap the offset down to a char
372    // boundary: `last_pending_output.len()` is a byte count and may land inside
373    // a multibyte code point of `text`, which would panic on the slice.
374    let text_after_last = if text.len() > last_pending_output.len() {
375        let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
376        &text[cut..]
377    } else {
378        text
379    };
380
381    let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
382    let new_rendered = render_terminal_output(&combined);
383
384    // Get incremental part - matches WCGW Python get_incremental_output
385    let incremental = get_incremental_output(&last_rendered, &new_rendered);
386    rstrip_lines(&incremental)
387}
388
389fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
390    static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
391    // `.expect`, not `.ok()`: a compile-time-literal regex that fails to build is a
392    // dev bug, not a runtime condition. The old `OnceLock<Option<Regex>>` froze
393    // `None` forever on the first failure, silently disabling all CWD tracking for
394    // the rest of the process. Fail loud instead.
395    #[allow(clippy::expect_used)]
396    let prompt_regex = PROMPT_RE
397        .get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
398    let stripped = strip_ansi_codes(output);
399
400    prompt_regex
401        .captures_iter(&stripped)
402        .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
403        .filter(|cwd| !cwd.is_empty())
404        .last()
405        .map(PathBuf::from)
406}
407
408/// Right-strip each line and join - matches WCGW Python rstrip
409fn rstrip_lines(lines: &[String]) -> String {
410    lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
411}
412
413/// Get incremental output between old and new - matches WCGW Python `get_incremental_output`
414fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
415    if old_output.is_empty() {
416        return new_output.to_vec();
417    }
418
419    let nold = old_output.len();
420    let nnew = new_output.len();
421
422    // Find where old output ends in new output
423    for i in (0..nnew).rev() {
424        if new_output[i] != old_output[nold - 1] {
425            continue;
426        }
427
428        let mut matched = true;
429        for j in (0..i).rev() {
430            let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
431            if old_idx < 0 {
432                break;
433            }
434            if new_output[j] != old_output[old_idx as usize] {
435                matched = false;
436                break;
437            }
438        }
439
440        if matched {
441            return new_output[i + 1..].to_vec();
442        }
443    }
444
445    new_output.to_vec()
446}
447
448fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
449    let mut start = 0;
450
451    while start < text.len() {
452        let mut end = (start + chunk_size).min(text.len());
453        while !text.is_char_boundary(end) {
454            end -= 1;
455        }
456        if end == start {
457            end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
458        }
459
460        shell.send_text(&text[start..end]).map_err(|e| {
461            WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
462        })?;
463        start = end;
464    }
465
466    Ok(())
467}
468
469/// Send the submitting Enter as a separate, slightly-delayed write so an
470/// Ink-based TUI doesn't swallow a CR glued to the preceding input. Briefly
471/// drops the shell lock during the pause so the reader thread keeps draining.
472/// See [`SUBMIT_NUDGE_DELAY`].
473async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
474    tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
475    let mut guard = shell_arc.lock().await;
476    let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
477    bash.send_special_key("Enter")
478        .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
479    Ok(())
480}
481
482// ==================== Main Tool Handler ====================
483
484/// Handles the `BashCommand` tool call with WCGW parity
485///
486/// This function processes the `BashCommand` tool call following WCGW Python's
487/// `execute_bash()` function behavior exactly.
488#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
489pub async fn handle_tool_call(
490    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
491    bash_command: BashCommand,
492) -> Result<String> {
493    info!("BashCommand tool called with: {:?}", bash_command);
494
495    let thread_id = normalize_thread_id(&bash_command.thread_id);
496
497    // Check if thread_id is empty
498    if thread_id.is_empty() {
499        error!("Empty thread_id provided in BashCommand");
500        return Err(WinxError::ThreadIdMismatch(
501            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
502        ));
503    }
504
505    // Extract bash_state data
506    let mut bash_state: BashState;
507    {
508        let bash_state_guard = bash_state_arc.lock().await;
509
510        let Some(state) = &*bash_state_guard else {
511            error!("BashState not initialized");
512            return Err(WinxError::BashStateNotInitialized);
513        };
514
515        bash_state = state.clone();
516    }
517
518    // Verify thread ID matches - matches WCGW Python thread_id check
519    if thread_id != bash_state.current_thread_id {
520        // Try to load state from thread_id - matches WCGW Python load_state_from_thread_id
521        // Distinguish "no state for this id" (Ok(false)) from a real load failure
522        // (Err: permission denied, corrupt JSON). The old `.unwrap_or(false)`
523        // collapsed both into the misleading "initialize first" message.
524        let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
525            WinxError::CommandExecutionError(format!(
526                "Failed to load saved bash state for thread_id `{thread_id}`: {e}"
527            ))
528        })?;
529        if !loaded {
530            return Err(WinxError::ThreadIdMismatch(format!(
531                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
532            )));
533        }
534        // Promote the loaded state back to the shared slot. The old code only wrote
535        // `cwd` back at the end, so switching thread_id silently dropped the loaded
536        // whitelist/mode/thread_id — the next command re-cloned the stale state.
537        // load_state_from_disk doesn't touch pty_shell (shells aren't serialized),
538        // so the live shell Arc is preserved through the clone.
539        if let Some(state) = bash_state_arc.lock().await.as_mut() {
540            *state = bash_state.clone();
541        }
542    }
543
544    // Calculate effective timeout - matches WCGW Python
545    // SECURITY: Ensure timeout is not negative to prevent unexpected behavior
546    let timeout_s = bash_command
547        .wait_for_seconds
548        .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
549        .min(TIMEOUT_WHILE_OUTPUT);
550
551    // Execute the action based on type - matches WCGW Python's _execute_bash()
552    let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
553
554    {
555        let mut bash_state_guard = bash_state_arc.lock().await;
556        if let Some(state) = bash_state_guard.as_mut() {
557            state.cwd.clone_from(&bash_state.cwd);
558        }
559    }
560
561    // Remove echo if it's a command - matches WCGW Python
562    match result {
563        Ok(mut output) => {
564            if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
565                let cmd_trimmed = command.trim();
566                if output.starts_with(cmd_trimmed) {
567                    output = output[cmd_trimmed.len()..].to_string();
568                }
569            }
570            Ok(output)
571        }
572        Err(e) => Err(e),
573    }
574}
575
576/// Execute a bash action - matches WCGW Python's _`execute_bash()` function
577#[allow(clippy::too_many_lines)]
578async fn execute_bash_action(
579    bash_state: &mut BashState,
580    action: &BashCommandAction,
581    timeout_s: f64,
582) -> Result<String> {
583    let mut is_bg = false;
584    let mut bg_id: Option<String> = None;
585
586    // Handle bg_command_id routing - matches WCGW Python
587    let bg_shell: Option<SharedPtyShell> = match action {
588        BashCommandAction::Command { .. } => None, // Commands don't use bg_command_id for routing
589        BashCommandAction::StatusCheck { bg_command_id, .. }
590        | BashCommandAction::SendText { bg_command_id, .. }
591        | BashCommandAction::SendSpecials { bg_command_id, .. }
592        | BashCommandAction::SendAscii { bg_command_id, .. }
593        | BashCommandAction::Screen { bg_command_id, .. }
594        | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
595            if let Some(id) = bg_command_id {
596                // Use the recovery helper (poison -> into_inner) like every other
597                // call site, rather than hard-failing this one path on poison.
598                let mut manager = lock_bg_manager();
599                manager.prune_finished_shells();
600
601                if let Some(shell) = manager.get_shell(id) {
602                    is_bg = true;
603                    bg_id = Some(id.clone());
604                    Some(shell)
605                } else if let Some(tombstone) = manager.peek_tombstone(id) {
606                    // Shell already exited. For a status check we can hand back the
607                    // final cached output exactly once. For anything else (send_text,
608                    // send_specials, send_ascii) tell the caller the shell is gone
609                    // and include the captured output so they can recover state.
610                    drop(manager);
611                    return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
612                } else {
613                    // Error message matches WCGW Python
614                    let error = format!(
615                        "No shell found running with command id {}.\n{}",
616                        id,
617                        manager.get_running_info()
618                    );
619                    return Err(WinxError::CommandExecutionError(error));
620                }
621            } else {
622                None
623            }
624        }
625    };
626
627    // Process based on action type - matches WCGW Python _execute_bash dispatch
628    match action {
629        BashCommandAction::Command { command, is_background, allow_multi } => {
630            execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
631        }
632        BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
633            execute_status_check(
634                bash_state,
635                bg_shell,
636                is_bg,
637                bg_id.as_deref(),
638                timeout_s,
639                *scrollback_lines,
640                *verbose,
641            )
642            .await
643        }
644        BashCommandAction::SendText { send_text, submit, .. } => {
645            execute_send_text(
646                bash_state,
647                send_text,
648                *submit,
649                bg_shell,
650                is_bg,
651                bg_id.as_deref(),
652                timeout_s,
653            )
654            .await
655        }
656        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
657            execute_send_specials(
658                bash_state,
659                send_specials,
660                *submit,
661                bg_shell,
662                is_bg,
663                bg_id.as_deref(),
664                timeout_s,
665            )
666            .await
667        }
668        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
669            execute_send_ascii(
670                bash_state,
671                send_ascii,
672                *submit,
673                bg_shell,
674                is_bg,
675                bg_id.as_deref(),
676                timeout_s,
677            )
678            .await
679        }
680        BashCommandAction::Screen { lines, diff, .. } => {
681            execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
682        }
683        BashCommandAction::WaitForTurn {
684            recognizer,
685            quiet_ms,
686            timeout_seconds,
687            lines,
688            wait_through_busy,
689            ..
690        } => {
691            execute_wait_for_turn(
692                bash_state,
693                bg_shell,
694                is_bg,
695                bg_id.as_deref(),
696                recognizer.as_deref(),
697                *quiet_ms,
698                *timeout_seconds,
699                *lines,
700                *wait_through_busy,
701            )
702            .await
703        }
704    }
705}
706
707/// Strip a trailing `| tail ...` from a command (wcgw parity, `strip_tail_pipe`).
708///
709/// LLMs habitually pipe output through `tail`, but we already truncate output
710/// server-side — stripping the pipe avoids hiding the earlier output the model
711/// usually wants. Only a `tail` at the very end of the command is removed.
712///
713/// This matches wcgw by default. Set `WINX_KEEP_TAIL_PIPE=1` to preserve the
714/// pipe instead (winx's original behavior), e.g. when you deliberately want only
715/// the tail of a huge log rather than the server-side truncation.
716fn strip_tail_pipe(command: &str) -> String {
717    strip_tail_pipe_impl(command, keep_tail_pipe())
718}
719
720/// Pure core of [`strip_tail_pipe`], split out so both modes are unit-testable
721/// without touching process-wide env vars (tests run concurrently).
722fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
723    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
724    if keep {
725        return command.to_string();
726    }
727    // `.expect`, not `.ok()`: a compile-time-literal regex that fails to build is
728    // a dev bug. The old `OnceLock<Option<Regex>>` + `.ok()` would freeze `None`
729    // and silently stop stripping `| tail` for the whole process.
730    #[allow(clippy::expect_used)]
731    let re = RE.get_or_init(|| {
732        regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
733            .expect("tail-pipe regex must compile")
734    });
735    match re.find(command) {
736        Some(matched) => command[..matched.start()].trim_end().to_string(),
737        None => command.to_string(),
738    }
739}
740
741/// Whether the user opted out of `| tail` stripping via `WINX_KEEP_TAIL_PIPE`.
742fn keep_tail_pipe() -> bool {
743    std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
744        let value = value.trim();
745        !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
746    })
747}
748
749/// Execute a command - matches WCGW Python's Command handling in _`execute_bash`
750async fn execute_command(
751    bash_state: &mut BashState,
752    command: &str,
753    is_background: bool,
754    allow_multi: bool,
755    timeout_s: f64,
756) -> Result<String> {
757    // wcgw strips a trailing `| tail` before anything else (model_validator).
758    let stripped_command = strip_tail_pipe(command);
759    let command = stripped_command.as_str();
760    debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
761
762    // Check mode permissions - matches WCGW Python bash_command_mode check
763    if !bash_state.is_command_allowed(command) {
764        error!("Command '{}' not allowed in current mode", command);
765        return Err(WinxError::CommandNotAllowed(
766            "Error: BashCommand not allowed in current mode".to_string(),
767        ));
768    }
769
770    // Single-statement guard (wcgw parity). Callers can opt out via
771    // `allow_multi: true` when they knowingly want to chain commands
772    // without wrapping in `bash -lc '...'`.
773    let command = command.trim();
774    if !allow_multi {
775        // The `bash -n -c` fallback inside the parser spawns a shell; only allow
776        // that probe in trusted (wcgw) mode. In restricted modes tree-sitter is
777        // the sole arbiter so we never shell out to vet a command.
778        let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
779        crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
780    }
781
782    // If background execution requested, start new shell - matches WCGW Python is_background handling
783    if is_background {
784        return execute_in_background(bash_state, command, timeout_s).await;
785    }
786
787    // Check if a command is already running - matches WCGW Python state check
788    {
789        let bash_guard = bash_state.pty_shell.lock().await;
790
791        if let Some(ref bash) = *bash_guard {
792            if bash.command_running {
793                return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
794            }
795        }
796    }
797
798    // Initialize bash if needed
799    if bash_state.pty_shell.lock().await.is_none() {
800        bash_state
801            .init_pty_shell()
802            .await
803            .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
804    }
805
806    // Clear prompt before sending - matches WCGW Python clear_to_run.
807    // Drain any leftover output and, if the shell still looks busy, send
808    // Ctrl-C so the new command lands on a fresh prompt instead of being
809    // appended to whatever was hanging on stdin.
810    {
811        let needs_reset = {
812            // Only when a shell exists. clear_to_run_async drains/interrupts WITHOUT
813            // holding the lock across a blocking sleep (was bash.clear_to_run, which
814            // slept up to DEFAULT_TIMEOUT seconds inside the tokio mutex).
815            if bash_state.pty_shell.lock().await.is_some() {
816                if clear_to_run_async(&bash_state.pty_shell, DEFAULT_TIMEOUT).await {
817                    false
818                } else {
819                    warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
820                    true
821                }
822            } else {
823                false
824            }
825        };
826        // wcgw parity: a shell that won't return to a prompt even after Ctrl-C is
827        // recreated, so the new command lands on a fresh prompt instead of being
828        // appended to a hung shell. init_pty_shell rebuilds at the same cwd/mode.
829        if needs_reset {
830            if let Err(e) = bash_state.init_pty_shell().await {
831                warn!("Failed to reset shell after clear_to_run: {e}");
832            }
833        }
834    }
835
836    // Send command in chunks of 64 characters - matches WCGW Python exactly
837    let scratch_root = bash_state.workspace_root.clone();
838    {
839        let mut bash_guard = bash_state.pty_shell.lock().await;
840
841        let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
842
843        bash.output_buffer.clear();
844        bash.output_truncated = false;
845        // Offload an over-long output's dropped head to a scratch file under the
846        // current workspace so the agent can recover it via ReadFiles.
847        bash.reset_scratch();
848        bash.set_scratch_root(&scratch_root);
849        // Mirror send_command's full reset. This foreground path hand-rolls the
850        // field mutation (it chunks the send for WCGW parity) and used to drop
851        // these two: a stale exit code or dedup hash would then leak into the
852        // next status_check — wrong `exit code`, or a false "no new output".
853        bash.last_exit_code = None;
854        bash.last_returned_hash = None;
855        // Send in chunks - matches WCGW Python: for i in range(0, len(command), 64)
856        send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
857
858        // Send linesep to execute - matches WCGW Python bash_state.send(bash_state.linesep, ...)
859        bash.send_special_key("Enter").map_err(|e| {
860            WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
861        })?;
862
863        bash.last_command = command.to_string();
864        bash.command_running = true;
865    }
866
867    // Wait for output with WCGW-style patience handling
868    let shell_arc = bash_state.pty_shell.clone();
869    wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
870}
871
872/// Wait for command output with WCGW-style patience handling - matches WCGW Python expect/wait logic.
873///
874/// Non-blocking drain of queued shell output under the lock; returns whether the
875/// command has completed. The lock is released before returning so the caller can
876/// `await` a poll interval without pinning the executor — unlike the old
877/// `read_output`, which slept (blocking) inside this lock.
878async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
879    let mut guard = shell_arc.lock().await;
880    match guard.as_mut() {
881        Some(bash) => bash.poll_output_nonblocking(),
882        None => true,
883    }
884}
885
886/// Snapshot the shell's accumulated output buffer in one lock acquisition.
887async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
888    let mut guard = shell_arc.lock().await;
889    guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
890}
891
892/// Poll-drain the shell until its prompt returns or `budget_secs` elapses; returns
893/// whether the prompt was seen (i.e. the shell is idle). The lock is released
894/// between polls and the wait is awaited, so the executor is never blocked.
895async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
896    let start = Instant::now();
897    loop {
898        if poll_shell(shell_arc).await {
899            return true;
900        }
901        if start.elapsed().as_secs_f64() >= budget_secs {
902            return false;
903        }
904        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
905    }
906}
907
908/// Async replacement for `PtyShell::clear_to_run`: drain leftover output, and if
909/// the shell still looks busy, send Ctrl-C and re-drain — returning whether the
910/// shell reached an idle prompt. The old sync method ran `read_output` (a blocking
911/// `thread::sleep` loop, up to `DEFAULT_TIMEOUT` seconds) WHILE the caller held the
912/// tokio mutex, pinning the worker on every foreground command. This holds the
913/// lock only for the instantaneous poll/interrupt, never across a wait.
914async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
915    // Phase 1: a quick drain — return the moment the prompt is already back.
916    if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
917        return true;
918    }
919    // Still busy: interrupt (lock held only for the send), then re-drain.
920    {
921        let mut guard = shell_arc.lock().await;
922        if let Some(bash) = guard.as_mut() {
923            if let Err(e) = bash.send_interrupt() {
924                warn!("clear_to_run: failed to send Ctrl-C: {e}");
925            }
926        }
927    }
928    drain_until_prompt(shell_arc, max_wait_secs).await
929}
930
931/// `shell_arc` selects which shell to read from (main shell or a bg shell handle).
932async fn wait_for_output(
933    bash_state: &mut BashState,
934    shell_arc: &SharedPtyShell,
935    timeout_s: f64,
936    is_bg: bool,
937    bg_id: Option<&str>,
938    is_status_check: bool,
939) -> Result<String> {
940    let start = Instant::now();
941    let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
942    let mut last_pending_output = String::new();
943    let mut complete = false;
944
945    // Adaptive polling instead of a blind sleep. wcgw sleeps the full `wait`
946    // budget before reading even once, so a `pwd` that finishes in 10ms still
947    // costs ~5s. Instead we read in short slices and return the moment the
948    // prompt comes back (`read_output` already exits early on prompt + drain),
949    // dropping fast-command latency from seconds to ~100ms. Long-running
950    // commands still consume the whole budget, since we loop until `complete`
951    // or `wait` elapses — identical upper-bound behavior, far snappier floor.
952    // Drain non-blocking, release the lock, then `await` the poll interval. The
953    // old `read_output(slice)` slept up to ~0.5s of blocking `thread::sleep` WHILE
954    // holding this tokio mutex — pinning the worker thread (starving every other
955    // task on it) and the shell lock the whole time. poll + `await` frees both.
956    let mut output = String::new();
957    loop {
958        if start.elapsed().as_secs_f64() >= wait {
959            break;
960        }
961        complete = poll_shell(shell_arc).await;
962        if complete {
963            break;
964        }
965        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
966    }
967    // Post-prompt grace drain (awaited, no lock held), then one buffer snapshot —
968    // the old read_output cloned the whole buffer on every slice (performance-2).
969    if complete {
970        sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
971        poll_shell(shell_arc).await;
972    }
973    output = snapshot_shell(shell_arc).await;
974
975    // If not complete and this is a status check, use WCGW-style patience waiting.
976    //
977    // Treat `timeout_s` (== caller's `wait_for_seconds`, capped at
978    // `TIMEOUT_WHILE_OUTPUT`) as the hard upper bound on the TOTAL wall-clock
979    // spent inside this call. wcgw computes `remaining = TIMEOUT_WHILE_OUTPUT
980    // - wait`, which makes a 2-second `wait_for_seconds` block for almost 20s
981    // on a TUI that keeps emitting spinner frames. We diverge from wcgw here
982    // because driving agents expect their wait budget to be respected.
983    if !complete && is_status_check {
984        let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
985        let iter_wait_secs = 0.5_f64;
986        let mut patience = OUTPUT_WAIT_PATIENCE;
987
988        let incremental = wcgw_incremental_text(&output, &last_pending_output);
989        if incremental.is_empty() {
990            patience -= 1;
991        }
992
993        let mut last_incremental = incremental;
994
995        while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
996            let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
997            if remaining < 0.1 {
998                break;
999            }
1000            sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
1001
1002            // The patience `sleep(iter_wait_secs)` above already elapsed, so the
1003            // last half-second of output is queued — drain it non-blocking (was
1004            // read_output(0.5) holding the lock across its own internal sleep).
1005            let done = poll_shell(shell_arc).await;
1006            let new_output = snapshot_shell(shell_arc).await;
1007
1008            if done {
1009                complete = true;
1010                output = new_output;
1011                break;
1012            }
1013
1014            // Check if output changed - matches WCGW Python patience logic
1015            let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
1016            if new_incremental == last_incremental {
1017                patience -= 1;
1018            } else {
1019                patience = OUTPUT_WAIT_PATIENCE; // Reset patience on new output
1020            }
1021            last_incremental = new_incremental;
1022
1023            output = new_output;
1024        }
1025
1026        if !complete {
1027            // Update pending output - matches WCGW Python bash_state.set_pending(text)
1028            last_pending_output = output.clone();
1029        }
1030    }
1031
1032    if complete {
1033        if let Some(cwd) = extract_prompt_cwd(&output) {
1034            bash_state.cwd = cwd;
1035        }
1036    }
1037
1038    // Process output through terminal emulation - matches WCGW Python _incremental_text
1039    let rendered = wcgw_incremental_text(&output, &last_pending_output);
1040
1041    // Conscious compression: collapse mechanical repetition (identical line runs,
1042    // blank-line blocks) before truncating, to save tokens without dropping any
1043    // unique context. Falls back to the raw text when nothing is safe to collapse.
1044    let rendered = {
1045        let before_lines = rendered.lines().count();
1046        match crate::utils::output_compress::compress_output(&rendered) {
1047            Some(compressed) => {
1048                debug!(
1049                    from_lines = before_lines,
1050                    to_lines = compressed.lines().count(),
1051                    "winx collapsed mechanical repetition in shell output"
1052                );
1053                compressed
1054            }
1055            None => rendered,
1056        }
1057    };
1058
1059    // Truncate if needed - matches WCGW Python token truncation
1060    let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1061
1062    // Calculate running duration for status
1063    let running_for =
1064        if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
1065
1066    // Surface the just-finished command's exit code (parsed from the prompt
1067    // marker) so the agent sees failure without grepping stderr.
1068    // Exit code (parsed from the prompt) plus a pointer to any offloaded output
1069    // scratch file, read in one lock acquisition.
1070    let (exit_code, scratch_pointer) = read_completion_extras(shell_arc, complete).await;
1071
1072    // Add status - matches WCGW Python get_status
1073    let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref(), exit_code);
1074    Ok(format!("{rendered}{status}{scratch_pointer}"))
1075}
1076
1077/// Pull the just-finished command's exit code and, if its output overflowed and
1078/// the dropped head was offloaded, a pointer message to the scratch file - in a
1079/// single lock acquisition so callers don't lock the shell twice.
1080async fn read_completion_extras(
1081    shell_arc: &SharedPtyShell,
1082    complete: bool,
1083) -> (Option<i32>, String) {
1084    let guard = shell_arc.lock().await;
1085    let Some(shell) = guard.as_ref() else {
1086        return (None, String::new());
1087    };
1088    let exit_code = if complete { shell.last_exit_code } else { None };
1089    let pointer = match (shell.output_truncated, shell.scratch_path()) {
1090        (true, Some(path)) => format!(
1091            "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1092             saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1093            path.display()
1094        ),
1095        _ => String::new(),
1096    };
1097    (exit_code, pointer)
1098}
1099
1100/// Render the final cached output of an exited background shell.
1101///
1102/// `status_check` is allowed to "consume" the tombstone and return the trailing
1103/// output exactly once. Send-style actions (`send_text`, `send_specials`,
1104/// `send_ascii`) cannot interact with a dead shell, so we return an explicit
1105/// error that still includes the captured output so the agent can recover state.
1106fn finalize_tombstone(
1107    cwd: &Path,
1108    id: &str,
1109    tombstone: ExitedShellInfo,
1110    action: &BashCommandAction,
1111) -> Result<String> {
1112    let ExitedShellInfo { last_command, final_output, .. } = tombstone;
1113    match action {
1114        // A dead shell's turn is over: Screen/WaitForTurn hand back the final
1115        // captured output as the snapshot, exactly like a status check.
1116        BashCommandAction::StatusCheck { .. }
1117        | BashCommandAction::Screen { .. }
1118        | BashCommandAction::WaitForTurn { .. } => {
1119            let rendered = wcgw_incremental_text(&final_output, "");
1120            let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1121            // Build a compact status block matching `get_status` for a finished bg shell.
1122            let mut status = "\n\n---\n\n".to_string();
1123            let _ = writeln!(status, "bg_command_id = {id}");
1124            status.push_str("status = process exited\n");
1125            let _ = writeln!(status, "cwd = {}", cwd.display());
1126            Ok(format!("{rendered}{}", status.trim_end()))
1127        }
1128        BashCommandAction::SendText { .. }
1129        | BashCommandAction::SendSpecials { .. }
1130        | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1131            "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1132        ))),
1133        BashCommandAction::Command { .. } => {
1134            // We only enter `finalize_tombstone` from the bg routing path, which
1135            // never matches Command. Treat this as a programmer error.
1136            unreachable!("finalize_tombstone called for non-bg action")
1137        }
1138    }
1139}
1140
1141/// Execute a status check - matches WCGW Python's `StatusCheck` handling.
1142///
1143/// New behavior (v0.2.308):
1144/// - Deduplicates against the last response by fingerprint; when nothing
1145///   changed and `verbose=false`, returns a compact "no new output" payload
1146///   instead of resending the same screen.
1147/// - Optional `scrollback_lines` pulls bounded history from the `PtyShell`
1148///   ringbuffer so agents can reorient after a long pause.
1149async fn execute_status_check(
1150    bash_state: &mut BashState,
1151    bg_shell: Option<SharedPtyShell>,
1152    is_bg: bool,
1153    bg_id: Option<&str>,
1154    timeout_s: f64,
1155    scrollback_lines: Option<usize>,
1156    verbose: bool,
1157) -> Result<String> {
1158    debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1159
1160    // Pick the shell we're going to inspect: bg shell when bg_command_id was provided,
1161    // otherwise fall back to the main interactive shell.
1162    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1163
1164    // Check if there's a running command - matches WCGW Python state check
1165    let is_running = {
1166        let guard = shell_arc.lock().await;
1167        if let Some(ref bash) = *guard {
1168            bash.command_running
1169        } else {
1170            false
1171        }
1172    };
1173
1174    // If no command running and not background, return error - matches WCGW Python
1175    if !is_running && !is_bg {
1176        let mut manager = lock_bg_manager();
1177        let error = format!(
1178            "No command is currently running, so there's nothing to check. The previous \
1179             command already finished and its output was returned when it completed. Start a \
1180             new command, or pass a bg_command_id if you launched one in the background.\n{}",
1181            manager.get_running_info()
1182        );
1183        return Err(WinxError::CommandExecutionError(error));
1184    }
1185
1186    // Read output with patience handling - this IS a status check
1187    let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1188
1189    // Inter-call dedup: hash only the response *body* (the chunk before the
1190    // `\n\n---\n` status footer). The footer contains a live "running for"
1191    // counter that would otherwise defeat the comparison.
1192    let body = response.split("\n\n---\n").next().unwrap_or(&response);
1193    if !verbose && scrollback_lines.is_none() {
1194        let mut guard = shell_arc.lock().await;
1195        if let Some(bash) = guard.as_mut() {
1196            let fingerprint = PtyShell::fingerprint(body);
1197            if Some(fingerprint) == bash.last_returned_hash {
1198                let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1199                return Ok(format!("no new output since last check{status}"));
1200            }
1201            bash.last_returned_hash = Some(fingerprint);
1202        }
1203    } else if !verbose {
1204        // Still record the hash so subsequent non-scrollback calls can dedup.
1205        let mut guard = shell_arc.lock().await;
1206        if let Some(bash) = guard.as_mut() {
1207            bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1208        }
1209    }
1210
1211    // Optional scrollback prefix — only ever pulled when the caller asks for it.
1212    if let Some(lines) = scrollback_lines {
1213        if lines > 0 {
1214            let scrollback = {
1215                let guard = shell_arc.lock().await;
1216                guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1217            };
1218            if !scrollback.is_empty() {
1219                let count = scrollback.lines().count();
1220                return Ok(format!(
1221                    "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1222                ));
1223            }
1224        }
1225    }
1226
1227    Ok(response)
1228}
1229
1230/// Execute `Screen` — a stable, point-in-time snapshot of a shell's live
1231/// terminal screen (consolidated grid, ANSI stripped). No waiting, no dedup;
1232/// the foundation for reading an interactive TUI's current frame.
1233async fn execute_screen(
1234    bash_state: &BashState,
1235    bg_shell: Option<SharedPtyShell>,
1236    is_bg: bool,
1237    bg_id: Option<&str>,
1238    lines: Option<usize>,
1239    diff: bool,
1240) -> Result<String> {
1241    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1242    let max_lines = lines.unwrap_or(0);
1243
1244    if diff {
1245        let (update, is_running, in_alt, cursor) = {
1246            let guard = shell_arc.lock().await;
1247            match guard.as_ref() {
1248                Some(bash) => (
1249                    bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1250                    bash.command_running,
1251                    bash.live_in_alt_screen(),
1252                    bash.live_cursor_position(),
1253                ),
1254                None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1255            }
1256        };
1257        let (crow, ccol) = cursor;
1258        let alt = if in_alt { " [alt-screen]" } else { "" };
1259        let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1260        let body = match update {
1261            ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1262            ScreenUpdate::Diff(changed) => {
1263                let mut out = String::from("(changed lines only)\n");
1264                for (row, content) in changed {
1265                    let _ = writeln!(out, "{row:>4}: {content}");
1266                }
1267                out
1268            }
1269            ScreenUpdate::Full(snap) => {
1270                let joined = snap.join("\n");
1271                if joined.trim().is_empty() {
1272                    "(screen is empty)".to_string()
1273                } else {
1274                    truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1275                }
1276            }
1277        };
1278        return Ok(format!(
1279            "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1280        ));
1281    }
1282
1283    let (snapshot, is_running, in_alt, cursor) = {
1284        let guard = shell_arc.lock().await;
1285        match guard.as_ref() {
1286            Some(bash) => (
1287                bash.live_snapshot(max_lines),
1288                bash.command_running,
1289                bash.live_in_alt_screen(),
1290                bash.live_cursor_position(),
1291            ),
1292            None => (Vec::new(), false, false, (0, 0)),
1293        }
1294    };
1295
1296    let joined = snapshot.join("\n");
1297    let body = if joined.trim().is_empty() {
1298        "(screen is empty)".to_string()
1299    } else {
1300        truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1301    };
1302    let alt = if in_alt { " [alt-screen]" } else { "" };
1303    let (crow, ccol) = cursor;
1304    let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1305    Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1306}
1307
1308/// Why [`execute_wait_for_turn`] should stop polling, or `None` to keep waiting.
1309///
1310/// Extracted as a pure function so the (subtle) exit logic is unit-testable
1311/// without driving a real PTY. `busy_for` is how long we've read `Busy`
1312/// *continuously*: a TUI that is actively working never holds a stable screen
1313/// (its spinner repaints every frame), so we confirm `Busy` by elapsed duration,
1314/// not by `stable_for` — that was exactly why a busy child used to pin the caller
1315/// for the whole `hard_cap` (up to 600s).
1316#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1317fn wait_turn_outcome(
1318    state: crate::state::turn::TurnState,
1319    alive: bool,
1320    activity: bool,
1321    seen_busy: bool,
1322    stable_for: Duration,
1323    busy_for: Duration,
1324    settle: Duration,
1325    quiet: Duration,
1326    timed_out: bool,
1327    wait_through_busy: bool,
1328) -> Option<&'static str> {
1329    use crate::state::turn::TurnState;
1330    if !alive {
1331        return Some("exited");
1332    }
1333    let ready = match state {
1334        TurnState::Busy => false,
1335        // A short settle is enough once we saw it go Busy; otherwise wait the
1336        // full quiet window so a slow first token isn't read as "done".
1337        TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1338            activity && stable_for >= if seen_busy { settle } else { quiet }
1339        }
1340        TurnState::Unknown => activity && stable_for >= quiet,
1341    };
1342    if ready {
1343        return Some("ready");
1344    }
1345    // Early-out on a confirmed-busy turn instead of blocking until the hard cap.
1346    // A settled `busy` reading is a valid answer on its own (the tool documents
1347    // `busy` as a return state); the caller can poll again to keep watching. This
1348    // is the fix for a parent that "waits forever" on a long-running child.
1349    if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
1350        return Some("busy");
1351    }
1352    if timed_out {
1353        return Some("timeout");
1354    }
1355    None
1356}
1357
1358/// Execute `WaitForTurn` — wait for an interactive TUI's turn.
1359///
1360/// Polls the live screen, combining a per-app recognizer (claude/codex/auto)
1361/// with a generic quiescence window: the turn is "ready" when the recognizer
1362/// reports awaiting-input/approval (after a short settle) or, for an unknown
1363/// TUI, when the screen simply stops changing for `quiet`. By default it also
1364/// returns as soon as `Busy` is confirmed (see [`wait_turn_outcome`]); pass
1365/// `wait_through_busy` to block through busy until ready. Returns the stable
1366/// snapshot plus the detected state.
1367#[allow(clippy::too_many_arguments)]
1368async fn execute_wait_for_turn(
1369    bash_state: &BashState,
1370    bg_shell: Option<SharedPtyShell>,
1371    is_bg: bool,
1372    bg_id: Option<&str>,
1373    recognizer_hint: Option<&str>,
1374    quiet_ms: Option<u64>,
1375    timeout_seconds: Option<f32>,
1376    lines: Option<usize>,
1377    wait_through_busy: bool,
1378) -> Result<String> {
1379    use crate::state::turn::{recognizer_for, TurnState};
1380
1381    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1382    let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1383    let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1384    let settle = quiet.min(Duration::from_millis(300));
1385    let hard_cap =
1386        Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1387    let max_lines = lines.unwrap_or(0);
1388    let poll = Duration::from_millis(120);
1389    // Grace period so a freshly-prompted app has time to react before we'd
1390    // otherwise mistake the *pre-input* idle screen for a finished turn.
1391    let warmup = Duration::from_millis(2500);
1392
1393    let start = Instant::now();
1394    let mut last_hash: Option<u64> = None;
1395    let mut initial_hash: Option<u64> = None;
1396    let mut stable_since = Instant::now();
1397    let mut seen_busy = false;
1398    let mut busy_since: Option<Instant> = None;
1399
1400    loop {
1401        let (snapshot, in_alt, alive, running) = {
1402            let mut guard = shell_arc.lock().await;
1403            match guard.as_mut() {
1404                Some(bash) => (
1405                    bash.live_snapshot(max_lines),
1406                    bash.live_in_alt_screen(),
1407                    bash.is_alive(),
1408                    bash.command_running,
1409                ),
1410                None => (Vec::new(), false, false, false),
1411            }
1412        };
1413
1414        let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1415        if initial_hash.is_none() {
1416            initial_hash = Some(hash);
1417        }
1418        if Some(hash) != last_hash {
1419            last_hash = Some(hash);
1420            stable_since = Instant::now();
1421        }
1422
1423        let state = recognizer.detect(&snapshot);
1424        if state == TurnState::Busy {
1425            seen_busy = true;
1426            if busy_since.is_none() {
1427                busy_since = Some(Instant::now());
1428            }
1429        } else {
1430            busy_since = None;
1431        }
1432        let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
1433        let stable_for = stable_since.elapsed();
1434        // Don't call it "done" until something actually happened since we began
1435        // waiting: the app went Busy, the screen changed from the first frame we
1436        // saw, or the warmup elapsed (instant reply / nothing to do).
1437        let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1438        let timed_out = start.elapsed() >= hard_cap;
1439        if let Some(reason) = wait_turn_outcome(
1440            state,
1441            alive,
1442            activity,
1443            seen_busy,
1444            stable_for,
1445            busy_for,
1446            settle,
1447            quiet,
1448            timed_out,
1449            wait_through_busy,
1450        ) {
1451            let joined = snapshot.join("\n");
1452            let body = if joined.trim().is_empty() {
1453                "(screen is empty)".to_string()
1454            } else {
1455                truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1456            };
1457            let alt = if in_alt { " [alt-screen]" } else { "" };
1458            let header = format!(
1459                "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1460                state.as_str(),
1461                reason,
1462                recognizer.name(),
1463                start.elapsed().as_secs_f64(),
1464                alt
1465            );
1466            let status = get_status(bash_state, is_bg, bg_id, running, None, None);
1467            return Ok(format!("{header}\n{body}{status}"));
1468        }
1469
1470        tokio::time::sleep(poll).await;
1471    }
1472}
1473
1474/// Execute `send_text` - matches WCGW Python's `SendText` handling
1475async fn execute_send_text(
1476    bash_state: &mut BashState,
1477    text: &str,
1478    submit: bool,
1479    bg_shell: Option<SharedPtyShell>,
1480    is_bg: bool,
1481    bg_id: Option<&str>,
1482    timeout_s: f64,
1483) -> Result<String> {
1484    debug!("Processing SendText action: {text:?} (submit={submit})");
1485
1486    // Validate - matches WCGW Python
1487    if text.is_empty() {
1488        return Err(WinxError::CommandExecutionError(
1489            "Failure: send_text cannot be empty".to_string(),
1490        ));
1491    }
1492
1493    // Get the target shell
1494    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1495
1496    // Send text in chunks of 128 characters - matches WCGW Python exactly
1497    {
1498        let mut guard = shell_arc.lock().await;
1499
1500        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1501
1502        // Send in chunks - matches WCGW Python: for i in range(0, len(command_data.send_text), 128)
1503        send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1504    }
1505
1506    // Only submit when explicitly asked. The Enter goes as a separate, delayed
1507    // write so Ink TUIs (Claude Code) don't swallow a CR glued to the text — a
1508    // bare CR there is otherwise treated as a soft newline and never submits.
1509    if submit {
1510        submit_enter(&shell_arc).await?;
1511    }
1512
1513    // Wait for output
1514    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1515}
1516
1517/// Execute `send_specials` - matches WCGW Python's `SendSpecials` handling exactly
1518async fn execute_send_specials(
1519    bash_state: &mut BashState,
1520    keys: &[SpecialKey],
1521    submit: bool,
1522    bg_shell: Option<SharedPtyShell>,
1523    is_bg: bool,
1524    bg_id: Option<&str>,
1525    timeout_s: f64,
1526) -> Result<String> {
1527    debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1528
1529    // Validate - matches WCGW Python
1530    if keys.is_empty() {
1531        return Err(WinxError::CommandExecutionError(
1532            "Failure: send_specials cannot be empty".to_string(),
1533        ));
1534    }
1535
1536    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1537    let mut is_interrupt = false;
1538
1539    {
1540        let mut guard = shell_arc.lock().await;
1541
1542        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1543
1544        // Send each special key - matches WCGW Python exactly
1545        for key in keys {
1546            match key {
1547                SpecialKey::KeyUp => {
1548                    // matches WCGW Python: bash_state.send("\033[A", ...)
1549                    bash.send_special_key("KeyUp").map_err(|e| {
1550                        WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1551                    })?;
1552                }
1553                SpecialKey::KeyDown => {
1554                    // matches WCGW Python: bash_state.send("\033[B", ...)
1555                    bash.send_special_key("KeyDown").map_err(|e| {
1556                        WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1557                    })?;
1558                }
1559                SpecialKey::KeyLeft => {
1560                    // matches WCGW Python: bash_state.send("\033[D", ...)
1561                    bash.send_special_key("KeyLeft").map_err(|e| {
1562                        WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1563                    })?;
1564                }
1565                SpecialKey::KeyRight => {
1566                    // matches WCGW Python: bash_state.send("\033[C", ...)
1567                    bash.send_special_key("KeyRight").map_err(|e| {
1568                        WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1569                    })?;
1570                }
1571                SpecialKey::Enter => {
1572                    // matches WCGW Python: bash_state.send("\x0d", ...) - carriage return
1573                    bash.send_special_key("Enter").map_err(|e| {
1574                        WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1575                    })?;
1576                }
1577                SpecialKey::CtrlC => {
1578                    // matches WCGW Python: bash_state.sendintr()
1579                    bash.send_interrupt().map_err(|e| {
1580                        WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1581                    })?;
1582                    is_interrupt = true;
1583                }
1584                SpecialKey::CtrlD => {
1585                    // matches WCGW Python: bash_state.sendintr() - same as Ctrl+C in WCGW
1586                    bash.send_eof().map_err(|e| {
1587                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1588                    })?;
1589                    is_interrupt = true;
1590                }
1591                SpecialKey::CtrlZ => {
1592                    // Ctrl+Z = SIGTSTP (suspend) - ASCII 0x1a
1593                    bash.send_suspend().map_err(|e| {
1594                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1595                    })?;
1596                }
1597            }
1598        }
1599    }
1600
1601    // Submit as a separate, slightly-delayed write (see `submit_enter`) so an
1602    // Ink TUI doesn't drop the CR.
1603    if submit {
1604        submit_enter(&shell_arc).await?;
1605    }
1606
1607    // NOTE: wcgw treats a bare Enter as a status check and applies its
1608    // patience loop. We deliberately diverge: for a driving agent (e.g.,
1609    // pushing Enter to submit text in a TUI) the patience loop swallows the
1610    // immediate response. Callers that want patience semantics should use the
1611    // explicit `status_check` action instead.
1612
1613    // Wait for output
1614    let mut output =
1615        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1616
1617    // Add interrupt failure message if still running - matches WCGW Python exactly
1618    if is_interrupt && output.contains("status = still running") {
1619        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1620    }
1621
1622    Ok(output)
1623}
1624
1625/// Execute `send_ascii` - matches WCGW Python's `SendAscii` handling
1626async fn execute_send_ascii(
1627    bash_state: &mut BashState,
1628    ascii_codes: &[u8],
1629    submit: bool,
1630    bg_shell: Option<SharedPtyShell>,
1631    is_bg: bool,
1632    bg_id: Option<&str>,
1633    timeout_s: f64,
1634) -> Result<String> {
1635    debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1636
1637    // Validate - matches WCGW Python
1638    if ascii_codes.is_empty() {
1639        return Err(WinxError::CommandExecutionError(
1640            "Failure: send_ascii cannot be empty".to_string(),
1641        ));
1642    }
1643
1644    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1645    let mut is_interrupt = false;
1646
1647    {
1648        let mut guard = shell_arc.lock().await;
1649
1650        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1651
1652        // Send each ASCII code - matches WCGW Python
1653        for &code in ascii_codes {
1654            // matches WCGW Python: bash_state.send(chr(ascii_char), ...)
1655            bash.send_bytes(&[code]).map_err(|e| {
1656                WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1657            })?;
1658
1659            // Check for interrupt - matches WCGW Python: if ascii_char == 3: is_interrupt = True
1660            if code == 3 {
1661                is_interrupt = true;
1662            }
1663        }
1664    }
1665
1666    // Submit as a separate, slightly-delayed write (see `submit_enter`) so an
1667    // Ink TUI doesn't drop the CR.
1668    if submit {
1669        submit_enter(&shell_arc).await?;
1670    }
1671
1672    // Same divergence from wcgw as in `execute_send_specials`: send_ascii [10]
1673    // or [13] is treated as a direct write, not a status check. Callers that
1674    // need patience-aware reads should use `status_check`.
1675
1676    // Wait for output
1677    let mut output =
1678        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1679
1680    // Add interrupt failure message if still running - matches WCGW Python
1681    if is_interrupt && output.contains("status = still running") {
1682        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1683    }
1684
1685    Ok(output)
1686}
1687
1688/// Execute command in background - matches WCGW Python's `is_background` handling
1689async fn execute_in_background(
1690    bash_state: &mut BashState,
1691    command: &str,
1692    timeout_s: f64,
1693) -> Result<String> {
1694    debug!("Executing command in background: {}", command);
1695
1696    // Start a new background shell - matches WCGW Python bash_state.start_new_bg_shell
1697    let restricted_mode =
1698        matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1699
1700    // Build the shell OFF the tokio worker AND outside the manager lock:
1701    // PtyShell::new forks+execs and blocks ~300ms on prompt init. The old
1702    // start_new_shell did all of that under the std::Mutex on the executor thread.
1703    let bg_id = {
1704        let cwd = bash_state.cwd.clone();
1705        let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1706            .await
1707            .map_err(|e| {
1708                WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1709            })?
1710            .map_err(|e| {
1711                WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1712            })?;
1713        lock_bg_manager().register_shell(shell)
1714    };
1715
1716    // Get the shell
1717    let shell_arc = {
1718        let manager = lock_bg_manager();
1719        manager.get_shell(&bg_id).ok_or_else(|| {
1720            WinxError::CommandExecutionError("Failed to get background shell".to_string())
1721        })?
1722    };
1723
1724    // Send command via the same PTY path used by foreground execute_command.
1725    let scratch_root = bash_state.workspace_root.clone();
1726    {
1727        let mut guard = shell_arc.lock().await;
1728        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1729        // Offload an over-long bg output's dropped head too (send_command resets
1730        // the per-command scratch state; this points it at the workspace).
1731        bash.set_scratch_root(&scratch_root);
1732        bash.send_command(command).map_err(|e| {
1733            WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1734        })?;
1735    }
1736    debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1737
1738    let _ = timeout_s;
1739    let _ = shell_arc;
1740    Ok(get_status(bash_state, true, Some(&bg_id), true, None, None))
1741}
1742
1743#[cfg(test)]
1744mod wait_turn_tests {
1745    use super::wait_turn_outcome;
1746    use crate::state::turn::TurnState;
1747    use std::time::Duration;
1748
1749    const SETTLE: Duration = Duration::from_millis(300);
1750    const QUIET: Duration = Duration::from_millis(600);
1751
1752    fn call(
1753        state: TurnState,
1754        busy_for: Duration,
1755        stable_for: Duration,
1756        seen_busy: bool,
1757        timed_out: bool,
1758        wait_through_busy: bool,
1759    ) -> Option<&'static str> {
1760        // `activity` is true in these cases (something happened) unless noted.
1761        wait_turn_outcome(
1762            state,
1763            true,
1764            true,
1765            seen_busy,
1766            stable_for,
1767            busy_for,
1768            SETTLE,
1769            QUIET,
1770            timed_out,
1771            wait_through_busy,
1772        )
1773    }
1774
1775    #[test]
1776    fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
1777        // The bug: a busy child used to pin the caller until hard_cap. Now a
1778        // busy reading held for >= settle returns "busy" with NO timeout.
1779        let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
1780        assert_eq!(out, Some("busy"));
1781    }
1782
1783    #[test]
1784    fn busy_not_yet_confirmed_keeps_waiting() {
1785        // Seen busy for less than settle (one-frame flicker): keep polling.
1786        let out =
1787            call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
1788        assert_eq!(out, None);
1789    }
1790
1791    #[test]
1792    fn wait_through_busy_blocks_through_busy_until_timeout() {
1793        // Opt-out preserves the old contract: busy never returns early...
1794        let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
1795        assert_eq!(out, None);
1796        // ...only the hard cap ends it.
1797        let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
1798        assert_eq!(out, Some("timeout"));
1799    }
1800
1801    #[test]
1802    fn awaiting_input_after_busy_is_ready_on_short_settle() {
1803        let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
1804        assert_eq!(out, Some("ready"));
1805    }
1806
1807    #[test]
1808    fn awaiting_input_without_prior_busy_needs_full_quiet() {
1809        // No prior busy: a short settle is not enough, must wait the quiet window.
1810        let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
1811        assert_eq!(short, None);
1812        let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
1813        assert_eq!(full, Some("ready"));
1814    }
1815
1816    #[test]
1817    fn dead_shell_reports_exited_even_if_busy() {
1818        let out = wait_turn_outcome(
1819            TurnState::Busy,
1820            false,
1821            true,
1822            true,
1823            Duration::ZERO,
1824            SETTLE,
1825            SETTLE,
1826            QUIET,
1827            false,
1828            false,
1829        );
1830        assert_eq!(out, Some("exited"));
1831    }
1832
1833    #[test]
1834    fn nothing_happening_keeps_waiting() {
1835        let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
1836        assert_eq!(out, None);
1837    }
1838}
1839
1840#[cfg(test)]
1841mod tests {
1842    use super::strip_tail_pipe_impl;
1843
1844    #[test]
1845    fn strips_trailing_tail_by_default() {
1846        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1847        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1848        assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1849        assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1850    }
1851
1852    #[test]
1853    fn keeps_command_without_trailing_tail() {
1854        // tail not at the end, or piped further, must be left alone.
1855        assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1856        assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1857        assert_eq!(
1858            strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1859            "cat a | tail -5 | wc -l"
1860        );
1861    }
1862
1863    #[test]
1864    fn keep_mode_preserves_tail_pipe() {
1865        // WINX_KEEP_TAIL_PIPE behavior: command passes through untouched.
1866        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1867        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1868    }
1869}