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::process::{Command, Stdio};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::{Duration, Instant};
16use tokio::sync::Mutex;
17use tokio::time::sleep;
18use tracing::{debug, error, info, warn};
19
20use crate::errors::{Result, WinxError};
21use crate::state::bash_state::BashState;
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/// Polling slice for adaptive output reads. We read in chunks this long and
40/// return as soon as the prompt returns, instead of sleeping the full budget.
41const POLL_SLICE_SECS: f64 = 0.5;
42
43/// Chunk size for sending commands (characters) - matches WCGW Python (64 chars)
44const COMMAND_CHUNK_SIZE: usize = 64;
45
46/// Chunk size for sending text input (characters) - matches WCGW Python (128 chars)
47const TEXT_CHUNK_SIZE: usize = 128;
48
49/// Cheap byte-level safety net. We never even consider token counting if the
50/// raw payload is smaller than this — tokenizing is fast but not free, and
51/// the vast majority of responses are tiny status updates.
52const MAX_OUTPUT_LENGTH: usize = 100_000;
53
54/// Token budget reserved for a single PTY response when token-aware truncation
55/// kicks in. Picked to leave plenty of room for the surrounding context — most
56/// frontier models have 128k+ windows, so 25k for one shell payload is generous
57/// without monopolizing the conversation.
58const MAX_OUTPUT_TOKENS: usize = 25_000;
59
60/// Tail of `text` at most `max_len` bytes long, snapped up to a char boundary so
61/// we never slice through a multibyte UTF-8 sequence (which would panic).
62fn char_safe_tail(text: &str, max_len: usize) -> &str {
63    if text.len() <= max_len {
64        return text;
65    }
66    let mut start = text.len() - max_len;
67    while start < text.len() && !text.is_char_boundary(start) {
68        start += 1;
69    }
70    &text[start..]
71}
72
73/// Truncate `text` so its Claude token count stays under `max_tokens`.
74///
75/// We tokenize the tail of the string only when the raw byte length already
76/// exceeds the byte cap; otherwise we trust the byte budget and return as-is.
77/// When the tail still overshoots, we keep the last `max_tokens - reserve`
78/// tokens and prepend a "(...truncated)" marker — exactly what wcgw does in
79/// `_incremental_text`.
80fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
81    if text.len() <= MAX_OUTPUT_LENGTH {
82        return std::borrow::Cow::Borrowed(text);
83    }
84
85    let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
86        // Fallback to the byte-based truncation we used before the tokenizer.
87        return std::borrow::Cow::Owned(format!(
88            "(...truncated)\n{}",
89            char_safe_tail(text, MAX_OUTPUT_LENGTH)
90        ));
91    };
92
93    if tokens.len() <= max_tokens {
94        return std::borrow::Cow::Borrowed(text);
95    }
96
97    // Reserve one token slot for the marker overhead.
98    let keep = max_tokens.saturating_sub(1);
99    let tail = &tokens[tokens.len() - keep..];
100    let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
101        // Tokenizer present but decode failed: fall back to a byte tail.
102        char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
103    });
104    std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
105}
106
107/// Message when a command is already running - matches WCGW Python `WAITING_INPUT_MESSAGE`
108const 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.
1091. Get its output using status check.
1102. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1113. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1124. Interrupt and run the process in background
113";
114
115// ==================== Background Shell Manager ====================
116
117/// Snapshot of a background shell that has exited but whose final output has not
118/// yet been consumed by the caller. We keep it around so the next call (typically
119/// a `status_check`) can return the trailing output before the entry is gone.
120#[derive(Debug, Clone)]
121pub struct ExitedShellInfo {
122    pub last_command: String,
123    pub final_output: String,
124    pub exited_at: Instant,
125}
126
127/// Manages background shell sessions - matches WCGW Python's `background_shells` dict
128#[derive(Debug, Default)]
129pub struct BackgroundShellManager {
130    shells: HashMap<String, SharedPtyShell>,
131    /// Recently exited shells that still owe their final output to the caller.
132    /// Entries are consumed the first time the caller queries the id, then dropped.
133    tombstones: HashMap<String, ExitedShellInfo>,
134}
135
136impl BackgroundShellManager {
137    /// Tombstones older than this are garbage-collected on the next prune pass.
138    const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
139
140    /// Create a new background shell manager
141    pub fn new() -> Self {
142        Self { shells: HashMap::new(), tombstones: HashMap::new() }
143    }
144
145    /// Start a new background shell and return its command ID
146    pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
147        let cid = format!("{:010x}", rand::rng().random::<u32>());
148
149        let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
150            WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
151        })?;
152
153        self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
154
155        info!("Started background shell with id: {}", cid);
156        Ok(cid)
157    }
158
159    /// Get a background shell by its command ID
160    pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
161        self.shells.get(bg_command_id).cloned()
162    }
163
164    /// Remove and cleanup a background shell
165    pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
166        if let Some(shell_arc) = self.shells.remove(bg_command_id) {
167            if let Ok(mut guard) = shell_arc.try_lock() {
168                *guard = None;
169            }
170            info!("Removed background shell: {}", bg_command_id);
171            true
172        } else {
173            false
174        }
175    }
176
177    fn prune_finished_shells(&mut self) {
178        // GC old tombstones first.
179        let now = Instant::now();
180        self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
181
182        let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
183
184        for (id, shell_arc) in &self.shells {
185            let Ok(mut guard) = shell_arc.try_lock() else {
186                continue;
187            };
188
189            let Some(shell) = guard.as_mut() else {
190                finished.push((id.clone(), None));
191                continue;
192            };
193
194            if !shell.is_alive() {
195                let tombstone = ExitedShellInfo {
196                    last_command: shell.last_command.clone(),
197                    final_output: shell.output_buffer.clone(),
198                    exited_at: now,
199                };
200                finished.push((id.clone(), Some(tombstone)));
201                continue;
202            }
203
204            // Never prune shells that haven't received a command yet.
205            // The global BG_SHELL_MANAGER is shared across parallel tests; a freshly
206            // spawned shell would otherwise be evicted between start_new_shell and
207            // the first send_command, leading to "Failed to get background shell".
208            if shell.last_command.is_empty() {
209                continue;
210            }
211
212            if shell.command_running {
213                let _ = shell.read_output(0.1);
214            }
215
216            if !shell.command_running {
217                let tombstone = ExitedShellInfo {
218                    last_command: shell.last_command.clone(),
219                    final_output: shell.output_buffer.clone(),
220                    exited_at: now,
221                };
222                finished.push((id.clone(), Some(tombstone)));
223            }
224        }
225
226        for (id, tombstone) in finished {
227            self.remove_shell(&id);
228            if let Some(info) = tombstone {
229                self.tombstones.insert(id, info);
230            }
231        }
232    }
233
234    /// Look up the tombstone for a recently-exited shell, if any.
235    ///
236    /// The entry stays in the map until the TTL expires (see
237    /// `prune_finished_shells`), so repeated `status_check` calls on the same
238    /// `bg_command_id` keep returning the cached final output instead of
239    /// flipping to "shell not found" after the first read.
240    pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
241        self.tombstones.get(bg_command_id).cloned()
242    }
243
244    /// Get info about all running background shells - matches WCGW Python `get_bg_running_commandsinfo`
245    pub fn get_running_info(&mut self) -> String {
246        self.prune_finished_shells();
247
248        if self.shells.is_empty() {
249            return "No command running in background.\n".to_string();
250        }
251
252        let mut running = Vec::new();
253        for (id, shell_arc) in &self.shells {
254            if let Ok(guard) = shell_arc.try_lock() {
255                if let Some(bash) = guard.as_ref() {
256                    if bash.command_running {
257                        running
258                            .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
259                    }
260                }
261            } else {
262                running.push(format!("Command: <busy>, bg_command_id: {id}"));
263            }
264        }
265
266        if running.is_empty() {
267            "No command running in background.\n".to_string()
268        } else {
269            format!("Following background commands are attached:\n{}\n", running.join("\n"))
270        }
271    }
272}
273
274// Global background shell manager (thread-safe) - matches WCGW Python's BashState.background_shells
275lazy_static::lazy_static! {
276    static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
277}
278
279/// Lock the global background-shell manager, recovering from poisoning.
280///
281/// A panic while holding this lock (e.g. in the rendering path during a prune)
282/// must NOT permanently brick all background-shell functionality for the rest of
283/// the server's lifetime. The manager's data stays consistent across a panic, so
284/// recovering the inner guard is safe.
285fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
286    BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
287}
288
289// ==================== WCGW-Style Helper Functions ====================
290
291/// Get WCGW-style status string - matches WCGW Python's `get_status()`
292fn get_status(
293    bash_state: &BashState,
294    is_bg: bool,
295    bg_id: Option<&str>,
296    is_running: bool,
297    running_for: Option<&str>,
298) -> String {
299    let mut status = "\n\n---\n\n".to_string();
300
301    if is_bg {
302        if let Some(id) = bg_id {
303            let _ = writeln!(status, "bg_command_id = {id}");
304        }
305    }
306
307    if is_running {
308        status.push_str("status = still running\n");
309        if let Some(duration) = running_for {
310            let _ = writeln!(status, "running for = {duration}");
311        }
312    } else {
313        status.push_str("status = process exited\n");
314    }
315
316    let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
317
318    if !is_bg {
319        // Add background shell info for main shell - matches WCGW Python
320        {
321            let mut manager = lock_bg_manager();
322            status.push_str("This is the main shell. ");
323            status.push_str(&manager.get_running_info());
324        }
325    }
326
327    status.trim_end().to_string()
328}
329
330/// Process output with WCGW-style incremental text handling - matches WCGW Python _`incremental_text`
331fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
332    let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
333    let text = truncated.as_ref();
334
335    if last_pending_output.is_empty() {
336        let rendered = render_terminal_output(text);
337        return rstrip_lines(&rendered).trim_start().to_string();
338    }
339
340    let last_rendered = render_terminal_output(last_pending_output);
341    if last_rendered.is_empty() {
342        return rstrip_lines(&render_terminal_output(text));
343    }
344
345    // Get text after last pending output. Snap the offset down to a char
346    // boundary: `last_pending_output.len()` is a byte count and may land inside
347    // a multibyte code point of `text`, which would panic on the slice.
348    let text_after_last = if text.len() > last_pending_output.len() {
349        let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
350        &text[cut..]
351    } else {
352        text
353    };
354
355    let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
356    let new_rendered = render_terminal_output(&combined);
357
358    // Get incremental part - matches WCGW Python get_incremental_output
359    let incremental = get_incremental_output(&last_rendered, &new_rendered);
360    rstrip_lines(&incremental)
361}
362
363fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
364    static PROMPT_RE: std::sync::OnceLock<Option<Regex>> = std::sync::OnceLock::new();
365    let prompt_regex =
366        PROMPT_RE.get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()).as_ref()?;
367    let stripped = strip_ansi_codes(output);
368
369    prompt_regex
370        .captures_iter(&stripped)
371        .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
372        .filter(|cwd| !cwd.is_empty())
373        .last()
374        .map(PathBuf::from)
375}
376
377/// Right-strip each line and join - matches WCGW Python rstrip
378fn rstrip_lines(lines: &[String]) -> String {
379    lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
380}
381
382/// Get incremental output between old and new - matches WCGW Python `get_incremental_output`
383fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
384    if old_output.is_empty() {
385        return new_output.to_vec();
386    }
387
388    let nold = old_output.len();
389    let nnew = new_output.len();
390
391    // Find where old output ends in new output
392    for i in (0..nnew).rev() {
393        if new_output[i] != old_output[nold - 1] {
394            continue;
395        }
396
397        let mut matched = true;
398        for j in (0..i).rev() {
399            let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
400            if old_idx < 0 {
401                break;
402            }
403            if new_output[j] != old_output[old_idx as usize] {
404                matched = false;
405                break;
406            }
407        }
408
409        if matched {
410            return new_output[i + 1..].to_vec();
411        }
412    }
413
414    new_output.to_vec()
415}
416
417fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
418    let mut start = 0;
419
420    while start < text.len() {
421        let mut end = (start + chunk_size).min(text.len());
422        while !text.is_char_boundary(end) {
423            end -= 1;
424        }
425        if end == start {
426            end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
427        }
428
429        shell.send_text(&text[start..end]).map_err(|e| {
430            WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
431        })?;
432        start = end;
433    }
434
435    Ok(())
436}
437
438/// Check if action is effectively a status check - matches WCGW Python `is_status_check`
439#[allow(dead_code)]
440fn is_status_check_action(action: &BashCommandAction) -> bool {
441    match action {
442        BashCommandAction::StatusCheck { .. } => true,
443        BashCommandAction::SendSpecials { send_specials, .. } => {
444            send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
445        }
446        BashCommandAction::SendAscii { send_ascii, .. } => {
447            send_ascii.len() == 1 && send_ascii[0] == 10 // newline
448        }
449        _ => false,
450    }
451}
452
453// ==================== Main Tool Handler ====================
454
455/// Handles the `BashCommand` tool call with WCGW parity
456///
457/// This function processes the `BashCommand` tool call following WCGW Python's
458/// `execute_bash()` function behavior exactly.
459#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
460pub async fn handle_tool_call(
461    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
462    bash_command: BashCommand,
463) -> Result<String> {
464    info!("BashCommand tool called with: {:?}", bash_command);
465
466    let thread_id = normalize_thread_id(&bash_command.thread_id);
467
468    // Check if thread_id is empty
469    if thread_id.is_empty() {
470        error!("Empty thread_id provided in BashCommand");
471        return Err(WinxError::ThreadIdMismatch(
472            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
473        ));
474    }
475
476    // Extract bash_state data
477    let mut bash_state: BashState;
478    {
479        let bash_state_guard = bash_state_arc.lock().await;
480
481        let Some(state) = &*bash_state_guard else {
482            error!("BashState not initialized");
483            return Err(WinxError::BashStateNotInitialized);
484        };
485
486        bash_state = state.clone();
487    }
488
489    // Verify thread ID matches - matches WCGW Python thread_id check
490    if thread_id != bash_state.current_thread_id {
491        // Try to load state from thread_id - matches WCGW Python load_state_from_thread_id
492        if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
493            return Err(WinxError::ThreadIdMismatch(format!(
494                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
495            )));
496        }
497    }
498
499    // Calculate effective timeout - matches WCGW Python
500    // SECURITY: Ensure timeout is not negative to prevent unexpected behavior
501    let timeout_s = bash_command
502        .wait_for_seconds
503        .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
504        .min(TIMEOUT_WHILE_OUTPUT);
505
506    // Execute the action based on type - matches WCGW Python's _execute_bash()
507    let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
508
509    {
510        let mut bash_state_guard = bash_state_arc.lock().await;
511        if let Some(state) = bash_state_guard.as_mut() {
512            state.cwd.clone_from(&bash_state.cwd);
513        }
514    }
515
516    // Remove echo if it's a command - matches WCGW Python
517    match result {
518        Ok(mut output) => {
519            if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
520                let cmd_trimmed = command.trim();
521                if output.starts_with(cmd_trimmed) {
522                    output = output[cmd_trimmed.len()..].to_string();
523                }
524            }
525            Ok(output)
526        }
527        Err(e) => Err(e),
528    }
529}
530
531/// Execute a bash action - matches WCGW Python's _`execute_bash()` function
532async fn execute_bash_action(
533    bash_state: &mut BashState,
534    action: &BashCommandAction,
535    timeout_s: f64,
536) -> Result<String> {
537    let mut is_bg = false;
538    let mut bg_id: Option<String> = None;
539
540    // Handle bg_command_id routing - matches WCGW Python
541    let bg_shell: Option<SharedPtyShell> = match action {
542        BashCommandAction::Command { .. } => None, // Commands don't use bg_command_id for routing
543        BashCommandAction::StatusCheck { bg_command_id, .. }
544        | BashCommandAction::SendText { bg_command_id, .. }
545        | BashCommandAction::SendSpecials { bg_command_id, .. }
546        | BashCommandAction::SendAscii { bg_command_id, .. } => {
547            if let Some(id) = bg_command_id {
548                let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
549                    WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
550                })?;
551                manager.prune_finished_shells();
552
553                if let Some(shell) = manager.get_shell(id) {
554                    is_bg = true;
555                    bg_id = Some(id.clone());
556                    Some(shell)
557                } else if let Some(tombstone) = manager.peek_tombstone(id) {
558                    // Shell already exited. For a status check we can hand back the
559                    // final cached output exactly once. For anything else (send_text,
560                    // send_specials, send_ascii) tell the caller the shell is gone
561                    // and include the captured output so they can recover state.
562                    drop(manager);
563                    return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
564                } else {
565                    // Error message matches WCGW Python
566                    let error = format!(
567                        "No shell found running with command id {}.\n{}",
568                        id,
569                        manager.get_running_info()
570                    );
571                    return Err(WinxError::CommandExecutionError(error));
572                }
573            } else {
574                None
575            }
576        }
577    };
578
579    // Process based on action type - matches WCGW Python _execute_bash dispatch
580    match action {
581        BashCommandAction::Command { command, is_background, allow_multi } => {
582            execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
583        }
584        BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
585            execute_status_check(
586                bash_state,
587                bg_shell,
588                is_bg,
589                bg_id.as_deref(),
590                timeout_s,
591                *scrollback_lines,
592                *verbose,
593            )
594            .await
595        }
596        BashCommandAction::SendText { send_text, submit, .. } => {
597            execute_send_text(
598                bash_state,
599                send_text,
600                *submit,
601                bg_shell,
602                is_bg,
603                bg_id.as_deref(),
604                timeout_s,
605            )
606            .await
607        }
608        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
609            execute_send_specials(
610                bash_state,
611                send_specials,
612                *submit,
613                bg_shell,
614                is_bg,
615                bg_id.as_deref(),
616                timeout_s,
617            )
618            .await
619        }
620        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
621            execute_send_ascii(
622                bash_state,
623                send_ascii,
624                *submit,
625                bg_shell,
626                is_bg,
627                bg_id.as_deref(),
628                timeout_s,
629            )
630            .await
631        }
632    }
633}
634
635/// Strip a trailing `| tail ...` from a command (wcgw parity, `strip_tail_pipe`).
636///
637/// LLMs habitually pipe output through `tail`, but we already truncate output
638/// server-side — stripping the pipe avoids hiding the earlier output the model
639/// usually wants. Only a `tail` at the very end of the command is removed.
640///
641/// This matches wcgw by default. Set `WINX_KEEP_TAIL_PIPE=1` to preserve the
642/// pipe instead (winx's original behavior), e.g. when you deliberately want only
643/// the tail of a huge log rather than the server-side truncation.
644fn strip_tail_pipe(command: &str) -> String {
645    strip_tail_pipe_impl(command, keep_tail_pipe())
646}
647
648/// Pure core of [`strip_tail_pipe`], split out so both modes are unit-testable
649/// without touching process-wide env vars (tests run concurrently).
650fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
651    static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
652    if keep {
653        return command.to_string();
654    }
655    let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
656    match re.as_ref().and_then(|re| re.find(command)) {
657        Some(matched) => command[..matched.start()].trim_end().to_string(),
658        None => command.to_string(),
659    }
660}
661
662/// Whether the user opted out of `| tail` stripping via `WINX_KEEP_TAIL_PIPE`.
663fn keep_tail_pipe() -> bool {
664    std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
665        let value = value.trim();
666        !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
667    })
668}
669
670/// Execute a command - matches WCGW Python's Command handling in _`execute_bash`
671async fn execute_command(
672    bash_state: &mut BashState,
673    command: &str,
674    is_background: bool,
675    allow_multi: bool,
676    timeout_s: f64,
677) -> Result<String> {
678    // wcgw strips a trailing `| tail` before anything else (model_validator).
679    let stripped_command = strip_tail_pipe(command);
680    let command = stripped_command.as_str();
681    debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
682
683    // Check mode permissions - matches WCGW Python bash_command_mode check
684    if !bash_state.is_command_allowed(command) {
685        error!("Command '{}' not allowed in current mode", command);
686        return Err(WinxError::CommandNotAllowed(
687            "Error: BashCommand not allowed in current mode".to_string(),
688        ));
689    }
690
691    // Single-statement guard (wcgw parity). Callers can opt out via
692    // `allow_multi: true` when they knowingly want to chain commands
693    // without wrapping in `bash -lc '...'`.
694    let command = command.trim();
695    if !allow_multi {
696        crate::utils::bash_parser::assert_single_statement(command)?;
697    }
698
699    // If background execution requested, start new shell - matches WCGW Python is_background handling
700    if is_background {
701        return execute_in_background(bash_state, command, timeout_s).await;
702    }
703
704    // Check if a command is already running - matches WCGW Python state check
705    {
706        let bash_guard = bash_state.pty_shell.lock().await;
707
708        if let Some(ref bash) = *bash_guard {
709            if bash.command_running {
710                return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
711            }
712        }
713    }
714
715    // Initialize bash if needed
716    if bash_state.pty_shell.lock().await.is_none() {
717        bash_state
718            .init_pty_shell()
719            .await
720            .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
721    }
722
723    // Clear prompt before sending - matches WCGW Python clear_to_run.
724    // Drain any leftover output and, if the shell still looks busy, send
725    // Ctrl-C so the new command lands on a fresh prompt instead of being
726    // appended to whatever was hanging on stdin.
727    {
728        let needs_reset = {
729            let mut bash_guard = bash_state.pty_shell.lock().await;
730            match bash_guard.as_mut() {
731                Some(bash) => match bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
732                    Ok(true) => false,
733                    Ok(false) => {
734                        warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
735                        true
736                    }
737                    Err(e) => {
738                        warn!("clear_to_run failed ({e}), resetting shell");
739                        true
740                    }
741                },
742                None => false,
743            }
744        };
745        // wcgw parity: a shell that won't return to a prompt even after Ctrl-C is
746        // recreated, so the new command lands on a fresh prompt instead of being
747        // appended to a hung shell. init_pty_shell rebuilds at the same cwd/mode.
748        if needs_reset {
749            if let Err(e) = bash_state.init_pty_shell().await {
750                warn!("Failed to reset shell after clear_to_run: {e}");
751            }
752        }
753    }
754
755    // Send command in chunks of 64 characters - matches WCGW Python exactly
756    {
757        let mut bash_guard = bash_state.pty_shell.lock().await;
758
759        let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
760
761        bash.output_buffer.clear();
762        bash.output_truncated = false;
763        // Send in chunks - matches WCGW Python: for i in range(0, len(command), 64)
764        send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
765
766        // Send linesep to execute - matches WCGW Python bash_state.send(bash_state.linesep, ...)
767        bash.send_special_key("Enter").map_err(|e| {
768            WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
769        })?;
770
771        bash.last_command = command.to_string();
772        bash.command_running = true;
773    }
774
775    // Wait for output with WCGW-style patience handling
776    let shell_arc = bash_state.pty_shell.clone();
777    wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
778}
779
780/// Wait for command output with WCGW-style patience handling - matches WCGW Python expect/wait logic.
781///
782/// `shell_arc` selects which shell to read from (main shell or a bg shell handle).
783async fn wait_for_output(
784    bash_state: &mut BashState,
785    shell_arc: &SharedPtyShell,
786    timeout_s: f64,
787    is_bg: bool,
788    bg_id: Option<&str>,
789    is_status_check: bool,
790) -> Result<String> {
791    let start = Instant::now();
792    let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
793    let mut last_pending_output = String::new();
794    let mut complete = false;
795
796    // Adaptive polling instead of a blind sleep. wcgw sleeps the full `wait`
797    // budget before reading even once, so a `pwd` that finishes in 10ms still
798    // costs ~5s. Instead we read in short slices and return the moment the
799    // prompt comes back (`read_output` already exits early on prompt + drain),
800    // dropping fast-command latency from seconds to ~100ms. Long-running
801    // commands still consume the whole budget, since we loop until `complete`
802    // or `wait` elapses — identical upper-bound behavior, far snappier floor.
803    let mut output = String::new();
804    loop {
805        let elapsed = start.elapsed().as_secs_f64();
806        if elapsed >= wait {
807            break;
808        }
809        let slice = (wait - elapsed).clamp(0.1, POLL_SLICE_SECS);
810        let (out, done) = {
811            let mut bash_guard = shell_arc.lock().await;
812            match bash_guard.as_mut() {
813                Some(bash) => bash.read_output(slice as f32).map_err(|e| {
814                    WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
815                })?,
816                None => (String::new(), true),
817            }
818        };
819        output = out;
820        complete = done;
821        if complete {
822            break;
823        }
824    }
825
826    // If not complete and this is a status check, use WCGW-style patience waiting.
827    //
828    // Treat `timeout_s` (== caller's `wait_for_seconds`, capped at
829    // `TIMEOUT_WHILE_OUTPUT`) as the hard upper bound on the TOTAL wall-clock
830    // spent inside this call. wcgw computes `remaining = TIMEOUT_WHILE_OUTPUT
831    // - wait`, which makes a 2-second `wait_for_seconds` block for almost 20s
832    // on a TUI that keeps emitting spinner frames. We diverge from wcgw here
833    // because driving agents expect their wait budget to be respected.
834    if !complete && is_status_check {
835        let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
836        let iter_wait_secs = 0.5_f64;
837        let mut patience = OUTPUT_WAIT_PATIENCE;
838
839        let incremental = wcgw_incremental_text(&output, &last_pending_output);
840        if incremental.is_empty() {
841            patience -= 1;
842        }
843
844        let mut last_incremental = incremental;
845
846        while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
847            let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
848            if remaining < 0.1 {
849                break;
850            }
851            sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
852
853            let (new_output, done) = {
854                let mut bash_guard = shell_arc.lock().await;
855
856                if let Some(bash) = bash_guard.as_mut() {
857                    bash.read_output(0.5).map_err(|e| {
858                        WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
859                    })?
860                } else {
861                    (String::new(), true)
862                }
863            };
864
865            if done {
866                complete = true;
867                output = new_output;
868                break;
869            }
870
871            // Check if output changed - matches WCGW Python patience logic
872            let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
873            if new_incremental == last_incremental {
874                patience -= 1;
875            } else {
876                patience = OUTPUT_WAIT_PATIENCE; // Reset patience on new output
877            }
878            last_incremental = new_incremental;
879
880            output = new_output;
881        }
882
883        if !complete {
884            // Update pending output - matches WCGW Python bash_state.set_pending(text)
885            last_pending_output = output.clone();
886        }
887    }
888
889    if complete {
890        if let Some(cwd) = extract_prompt_cwd(&output) {
891            bash_state.cwd = cwd;
892        }
893    }
894
895    // Process output through terminal emulation - matches WCGW Python _incremental_text
896    let rendered = wcgw_incremental_text(&output, &last_pending_output);
897
898    // Conscious compression: collapse mechanical repetition (identical line runs,
899    // blank-line blocks) before truncating, to save tokens without dropping any
900    // unique context. Falls back to the raw text when nothing is safe to collapse.
901    let rendered = crate::utils::output_compress::compress_output(&rendered).unwrap_or(rendered);
902
903    // Truncate if needed - matches WCGW Python token truncation
904    let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
905
906    // Calculate running duration for status
907    let running_for =
908        if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
909
910    // Add status - matches WCGW Python get_status
911    let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
912    Ok(format!("{rendered}{status}"))
913}
914
915/// Render the final cached output of an exited background shell.
916///
917/// `status_check` is allowed to "consume" the tombstone and return the trailing
918/// output exactly once. Send-style actions (`send_text`, `send_specials`,
919/// `send_ascii`) cannot interact with a dead shell, so we return an explicit
920/// error that still includes the captured output so the agent can recover state.
921fn finalize_tombstone(
922    cwd: &Path,
923    id: &str,
924    tombstone: ExitedShellInfo,
925    action: &BashCommandAction,
926) -> Result<String> {
927    let ExitedShellInfo { last_command, final_output, .. } = tombstone;
928    match action {
929        BashCommandAction::StatusCheck { .. } => {
930            let rendered = wcgw_incremental_text(&final_output, "");
931            let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
932            // Build a compact status block matching `get_status` for a finished bg shell.
933            let mut status = "\n\n---\n\n".to_string();
934            let _ = writeln!(status, "bg_command_id = {id}");
935            status.push_str("status = process exited\n");
936            let _ = writeln!(status, "cwd = {}", cwd.display());
937            Ok(format!("{rendered}{}", status.trim_end()))
938        }
939        BashCommandAction::SendText { .. }
940        | BashCommandAction::SendSpecials { .. }
941        | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
942            "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
943        ))),
944        BashCommandAction::Command { .. } => {
945            // We only enter `finalize_tombstone` from the bg routing path, which
946            // never matches Command. Treat this as a programmer error.
947            unreachable!("finalize_tombstone called for non-bg action")
948        }
949    }
950}
951
952/// Execute a status check - matches WCGW Python's `StatusCheck` handling.
953///
954/// New behavior (v0.2.308):
955/// - Deduplicates against the last response by fingerprint; when nothing
956///   changed and `verbose=false`, returns a compact "no new output" payload
957///   instead of resending the same screen.
958/// - Optional `scrollback_lines` pulls bounded history from the `PtyShell`
959///   ringbuffer so agents can reorient after a long pause.
960async fn execute_status_check(
961    bash_state: &mut BashState,
962    bg_shell: Option<SharedPtyShell>,
963    is_bg: bool,
964    bg_id: Option<&str>,
965    timeout_s: f64,
966    scrollback_lines: Option<usize>,
967    verbose: bool,
968) -> Result<String> {
969    debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
970
971    // Pick the shell we're going to inspect: bg shell when bg_command_id was provided,
972    // otherwise fall back to the main interactive shell.
973    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
974
975    // Check if there's a running command - matches WCGW Python state check
976    let is_running = {
977        let guard = shell_arc.lock().await;
978        if let Some(ref bash) = *guard {
979            bash.command_running
980        } else {
981            false
982        }
983    };
984
985    // If no command running and not background, return error - matches WCGW Python
986    if !is_running && !is_bg {
987        let mut manager = lock_bg_manager();
988        let error = format!(
989            "No command is currently running, so there's nothing to check. The previous \
990             command already finished and its output was returned when it completed. Start a \
991             new command, or pass a bg_command_id if you launched one in the background.\n{}",
992            manager.get_running_info()
993        );
994        return Err(WinxError::CommandExecutionError(error));
995    }
996
997    // Read output with patience handling - this IS a status check
998    let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
999
1000    // Inter-call dedup: hash only the response *body* (the chunk before the
1001    // `\n\n---\n` status footer). The footer contains a live "running for"
1002    // counter that would otherwise defeat the comparison.
1003    let body = response.split("\n\n---\n").next().unwrap_or(&response);
1004    if !verbose && scrollback_lines.is_none() {
1005        let mut guard = shell_arc.lock().await;
1006        if let Some(bash) = guard.as_mut() {
1007            let fingerprint = PtyShell::fingerprint(body);
1008            if Some(fingerprint) == bash.last_returned_hash {
1009                let status = get_status(bash_state, is_bg, bg_id, is_running, None);
1010                return Ok(format!("no new output since last check{status}"));
1011            }
1012            bash.last_returned_hash = Some(fingerprint);
1013        }
1014    } else if !verbose {
1015        // Still record the hash so subsequent non-scrollback calls can dedup.
1016        let mut guard = shell_arc.lock().await;
1017        if let Some(bash) = guard.as_mut() {
1018            bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1019        }
1020    }
1021
1022    // Optional scrollback prefix — only ever pulled when the caller asks for it.
1023    if let Some(lines) = scrollback_lines {
1024        if lines > 0 {
1025            let scrollback = {
1026                let guard = shell_arc.lock().await;
1027                guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1028            };
1029            if !scrollback.is_empty() {
1030                let count = scrollback.lines().count();
1031                return Ok(format!(
1032                    "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1033                ));
1034            }
1035        }
1036    }
1037
1038    Ok(response)
1039}
1040
1041/// Execute `send_text` - matches WCGW Python's `SendText` handling
1042async fn execute_send_text(
1043    bash_state: &mut BashState,
1044    text: &str,
1045    submit: bool,
1046    bg_shell: Option<SharedPtyShell>,
1047    is_bg: bool,
1048    bg_id: Option<&str>,
1049    timeout_s: f64,
1050) -> Result<String> {
1051    debug!("Processing SendText action: {text:?} (submit={submit})");
1052
1053    // Validate - matches WCGW Python
1054    if text.is_empty() {
1055        return Err(WinxError::CommandExecutionError(
1056            "Failure: send_text cannot be empty".to_string(),
1057        ));
1058    }
1059
1060    // Get the target shell
1061    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1062
1063    // Send text in chunks of 128 characters - matches WCGW Python exactly
1064    {
1065        let mut guard = shell_arc.lock().await;
1066
1067        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1068
1069        // Send in chunks - matches WCGW Python: for i in range(0, len(command_data.send_text), 128)
1070        send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1071
1072        // Only append Enter when the caller explicitly asks to submit. Many TUIs
1073        // (e.g., Claude Code) treat a bare CR as a soft newline inside the input
1074        // box, so blindly auto-Entering interferes with multi-step interaction.
1075        if submit {
1076            bash.send_special_key("Enter").map_err(|e| {
1077                WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
1078            })?;
1079        }
1080    }
1081
1082    // Wait for output
1083    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1084}
1085
1086/// Execute `send_specials` - matches WCGW Python's `SendSpecials` handling exactly
1087async fn execute_send_specials(
1088    bash_state: &mut BashState,
1089    keys: &[SpecialKey],
1090    submit: bool,
1091    bg_shell: Option<SharedPtyShell>,
1092    is_bg: bool,
1093    bg_id: Option<&str>,
1094    timeout_s: f64,
1095) -> Result<String> {
1096    debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1097
1098    // Validate - matches WCGW Python
1099    if keys.is_empty() {
1100        return Err(WinxError::CommandExecutionError(
1101            "Failure: send_specials cannot be empty".to_string(),
1102        ));
1103    }
1104
1105    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1106    let mut is_interrupt = false;
1107
1108    {
1109        let mut guard = shell_arc.lock().await;
1110
1111        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1112
1113        // Send each special key - matches WCGW Python exactly
1114        for key in keys {
1115            match key {
1116                SpecialKey::KeyUp => {
1117                    // matches WCGW Python: bash_state.send("\033[A", ...)
1118                    bash.send_special_key("KeyUp").map_err(|e| {
1119                        WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1120                    })?;
1121                }
1122                SpecialKey::KeyDown => {
1123                    // matches WCGW Python: bash_state.send("\033[B", ...)
1124                    bash.send_special_key("KeyDown").map_err(|e| {
1125                        WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1126                    })?;
1127                }
1128                SpecialKey::KeyLeft => {
1129                    // matches WCGW Python: bash_state.send("\033[D", ...)
1130                    bash.send_special_key("KeyLeft").map_err(|e| {
1131                        WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1132                    })?;
1133                }
1134                SpecialKey::KeyRight => {
1135                    // matches WCGW Python: bash_state.send("\033[C", ...)
1136                    bash.send_special_key("KeyRight").map_err(|e| {
1137                        WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1138                    })?;
1139                }
1140                SpecialKey::Enter => {
1141                    // matches WCGW Python: bash_state.send("\x0d", ...) - carriage return
1142                    bash.send_special_key("Enter").map_err(|e| {
1143                        WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1144                    })?;
1145                }
1146                SpecialKey::CtrlC => {
1147                    // matches WCGW Python: bash_state.sendintr()
1148                    bash.send_interrupt().map_err(|e| {
1149                        WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1150                    })?;
1151                    is_interrupt = true;
1152                }
1153                SpecialKey::CtrlD => {
1154                    // matches WCGW Python: bash_state.sendintr() - same as Ctrl+C in WCGW
1155                    bash.send_eof().map_err(|e| {
1156                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1157                    })?;
1158                    is_interrupt = true;
1159                }
1160                SpecialKey::CtrlZ => {
1161                    // Ctrl+Z = SIGTSTP (suspend) - ASCII 0x1a
1162                    bash.send_suspend().map_err(|e| {
1163                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1164                    })?;
1165                }
1166            }
1167        }
1168        // Submit (append Enter) only when explicitly requested by the caller.
1169        if submit {
1170            bash.send_special_key("Enter")
1171                .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1172        }
1173    }
1174
1175    // NOTE: wcgw treats a bare Enter as a status check and applies its
1176    // patience loop. We deliberately diverge: for a driving agent (e.g.,
1177    // pushing Enter to submit text in a TUI) the patience loop swallows the
1178    // immediate response. Callers that want patience semantics should use the
1179    // explicit `status_check` action instead.
1180
1181    // Wait for output
1182    let mut output =
1183        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1184
1185    // Add interrupt failure message if still running - matches WCGW Python exactly
1186    if is_interrupt && output.contains("status = still running") {
1187        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1188    }
1189
1190    Ok(output)
1191}
1192
1193/// Execute `send_ascii` - matches WCGW Python's `SendAscii` handling
1194async fn execute_send_ascii(
1195    bash_state: &mut BashState,
1196    ascii_codes: &[u8],
1197    submit: bool,
1198    bg_shell: Option<SharedPtyShell>,
1199    is_bg: bool,
1200    bg_id: Option<&str>,
1201    timeout_s: f64,
1202) -> Result<String> {
1203    debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1204
1205    // Validate - matches WCGW Python
1206    if ascii_codes.is_empty() {
1207        return Err(WinxError::CommandExecutionError(
1208            "Failure: send_ascii cannot be empty".to_string(),
1209        ));
1210    }
1211
1212    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1213    let mut is_interrupt = false;
1214
1215    {
1216        let mut guard = shell_arc.lock().await;
1217
1218        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1219
1220        // Send each ASCII code - matches WCGW Python
1221        for &code in ascii_codes {
1222            // matches WCGW Python: bash_state.send(chr(ascii_char), ...)
1223            bash.send_bytes(&[code]).map_err(|e| {
1224                WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1225            })?;
1226
1227            // Check for interrupt - matches WCGW Python: if ascii_char == 3: is_interrupt = True
1228            if code == 3 {
1229                is_interrupt = true;
1230            }
1231        }
1232        // Submit (append Enter) only when explicitly requested by the caller.
1233        if submit {
1234            bash.send_special_key("Enter")
1235                .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1236        }
1237    }
1238
1239    // Same divergence from wcgw as in `execute_send_specials`: send_ascii [10]
1240    // or [13] is treated as a direct write, not a status check. Callers that
1241    // need patience-aware reads should use `status_check`.
1242
1243    // Wait for output
1244    let mut output =
1245        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1246
1247    // Add interrupt failure message if still running - matches WCGW Python
1248    if is_interrupt && output.contains("status = still running") {
1249        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1250    }
1251
1252    Ok(output)
1253}
1254
1255/// Execute command in background - matches WCGW Python's `is_background` handling
1256async fn execute_in_background(
1257    bash_state: &mut BashState,
1258    command: &str,
1259    timeout_s: f64,
1260) -> Result<String> {
1261    debug!("Executing command in background: {}", command);
1262
1263    // Start a new background shell - matches WCGW Python bash_state.start_new_bg_shell
1264    let restricted_mode =
1265        matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1266
1267    let bg_id = {
1268        let mut manager = lock_bg_manager();
1269        manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1270    };
1271
1272    // Get the shell
1273    let shell_arc = {
1274        let manager = lock_bg_manager();
1275        manager.get_shell(&bg_id).ok_or_else(|| {
1276            WinxError::CommandExecutionError("Failed to get background shell".to_string())
1277        })?
1278    };
1279
1280    // Send command via the same PTY path used by foreground execute_command.
1281    {
1282        let mut guard = shell_arc.lock().await;
1283        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1284        bash.send_command(command).map_err(|e| {
1285            WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1286        })?;
1287    }
1288    debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1289
1290    let _ = timeout_s;
1291    let _ = shell_arc;
1292    Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1293}
1294
1295// ==================== Legacy Screen-based Functions (kept for backward compatibility) ====================
1296
1297/// Process simple command execution for a bash command (legacy)
1298#[allow(dead_code)]
1299#[tracing::instrument(level = "debug", skip(command, cwd))]
1300async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1301    debug!("Executing command: {}", command);
1302
1303    let start_time = Instant::now();
1304    let mut cmd = Command::new("sh");
1305    cmd.arg("-c")
1306        .arg(command)
1307        .current_dir(cwd)
1308        .stdin(Stdio::null())
1309        .stdout(Stdio::piped())
1310        .stderr(Stdio::piped());
1311
1312    let output = cmd.output().context("Failed to execute command")?;
1313    let elapsed = start_time.elapsed();
1314
1315    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1316    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1317
1318    let raw_result = format!("{stdout}{stderr}");
1319    let mut result = raw_result.clone();
1320    if !raw_result.is_empty() {
1321        let rendered_lines = render_terminal_output(&raw_result);
1322        if rendered_lines.is_empty() {
1323            // Fallback: just strip ANSI codes if rendering failed or wasn't needed
1324            result = strip_ansi_codes(&raw_result);
1325        } else {
1326            result = rendered_lines.join("\n");
1327        }
1328    }
1329
1330    result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
1331
1332    let exit_status = if output.status.success() {
1333        "Command completed successfully".to_string()
1334    } else {
1335        format!("Command failed with status: {}", output.status)
1336    };
1337
1338    let current_dir = std::env::current_dir()
1339        .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1340
1341    debug!("Command executed in {:.2?}", elapsed);
1342    Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1343}
1344
1345/// Execute command in screen (legacy)
1346#[allow(dead_code)]
1347#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1348async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1349    debug!("Executing command in screen session '{}': {}", screen_name, command);
1350
1351    let screen_check = Command::new("which")
1352        .arg("screen")
1353        .output()
1354        .context("Failed to check for screen command")?;
1355
1356    if !screen_check.status.success() {
1357        warn!("Screen command not found, falling back to direct execution");
1358        return execute_simple_command(command, cwd).await;
1359    }
1360
1361    let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1362
1363    let screen_cmd = format!(
1364        "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1365        screen_name,
1366        command.replace('\'', "'\\''")
1367    );
1368
1369    let screen_start = Command::new("sh")
1370        .arg("-c")
1371        .arg(&screen_cmd)
1372        .current_dir(cwd)
1373        .output()
1374        .context("Failed to start screen session")?;
1375
1376    if !screen_start.status.success() {
1377        let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1378        error!("Failed to start screen session: {}", stderr);
1379        return Err(WinxError::CommandExecutionError(format!(
1380            "Failed to start screen session: {stderr}"
1381        )));
1382    }
1383
1384    sleep(Duration::from_millis(300)).await;
1385
1386    let screen_check =
1387        Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1388
1389    let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1390
1391    let current_dir = std::env::current_dir()
1392        .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1393
1394    Ok(format!(
1395        "Started command in background screen session '{screen_name}'.\n\
1396        Use status_check to get output.\n\n\
1397        Screen sessions:\n{screen_list}\n\
1398        ---\n\n\
1399        status = running in background\n\
1400        cwd = {current_dir}\n"
1401    ))
1402}
1403
1404/// Converts a `SpecialKey` to its screen stuff input representation (legacy)
1405#[allow(dead_code)]
1406fn special_key_to_screen_input(key: SpecialKey) -> String {
1407    match key {
1408        SpecialKey::Enter => String::from("\r"),
1409        SpecialKey::KeyUp => String::from("\x1b[A"),
1410        SpecialKey::KeyDown => String::from("\x1b[B"),
1411        SpecialKey::KeyLeft => String::from("\x1b[D"),
1412        SpecialKey::KeyRight => String::from("\x1b[C"),
1413        SpecialKey::CtrlC => String::from("\x03"),
1414        SpecialKey::CtrlD => String::from("\x04"),
1415        SpecialKey::CtrlZ => String::from("\x1a"),
1416    }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421    use super::strip_tail_pipe_impl;
1422
1423    #[test]
1424    fn strips_trailing_tail_by_default() {
1425        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1426        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1427        assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1428        assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1429    }
1430
1431    #[test]
1432    fn keeps_command_without_trailing_tail() {
1433        // tail not at the end, or piped further, must be left alone.
1434        assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1435        assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1436        assert_eq!(
1437            strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1438            "cat a | tail -5 | wc -l"
1439        );
1440    }
1441
1442    #[test]
1443    fn keep_mode_preserves_tail_pipe() {
1444        // WINX_KEEP_TAIL_PIPE behavior: command passes through untouched.
1445        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1446        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1447    }
1448}