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/// Chunk size for sending commands (characters) - matches WCGW Python (64 chars)
40const COMMAND_CHUNK_SIZE: usize = 64;
41
42/// Chunk size for sending text input (characters) - matches WCGW Python (128 chars)
43const TEXT_CHUNK_SIZE: usize = 128;
44
45/// Maximum output length to prevent excessive responses
46const MAX_OUTPUT_LENGTH: usize = 100_000;
47
48/// Message when a command is already running - matches WCGW Python `WAITING_INPUT_MESSAGE`
49const 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.
501. Get its output using status check.
512. Use `send_ascii` or `send_specials` to give inputs to the running program OR
523. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
534. Interrupt and run the process in background
54";
55
56// ==================== Background Shell Manager ====================
57
58/// Manages background shell sessions - matches WCGW Python's `background_shells` dict
59#[derive(Debug, Default)]
60pub struct BackgroundShellManager {
61    shells: HashMap<String, SharedPtyShell>,
62}
63
64impl BackgroundShellManager {
65    /// Create a new background shell manager
66    pub fn new() -> Self {
67        Self { shells: HashMap::new() }
68    }
69
70    /// Start a new background shell and return its command ID
71    pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
72        let cid = format!("{:010x}", rand::rng().random::<u32>());
73
74        let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
75            WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
76        })?;
77
78        self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
79
80        info!("Started background shell with id: {}", cid);
81        Ok(cid)
82    }
83
84    /// Get a background shell by its command ID
85    pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
86        self.shells.get(bg_command_id).cloned()
87    }
88
89    /// Remove and cleanup a background shell
90    pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
91        if let Some(shell_arc) = self.shells.remove(bg_command_id) {
92            if let Ok(mut guard) = shell_arc.try_lock() {
93                *guard = None;
94            }
95            info!("Removed background shell: {}", bg_command_id);
96            true
97        } else {
98            false
99        }
100    }
101
102    fn prune_finished_shells(&mut self) {
103        let mut finished = Vec::new();
104
105        for (id, shell_arc) in &self.shells {
106            let Ok(mut guard) = shell_arc.try_lock() else {
107                continue;
108            };
109
110            let Some(shell) = guard.as_mut() else {
111                finished.push(id.clone());
112                continue;
113            };
114
115            if !shell.is_alive() {
116                finished.push(id.clone());
117                continue;
118            }
119
120            // Never prune shells that haven't received a command yet.
121            // The global BG_SHELL_MANAGER is shared across parallel tests; a freshly
122            // spawned shell would otherwise be evicted between start_new_shell and
123            // the first send_command, leading to "Failed to get background shell".
124            if shell.last_command.is_empty() {
125                continue;
126            }
127
128            if shell.command_running {
129                let _ = shell.read_output(0.1);
130            }
131
132            if !shell.command_running {
133                finished.push(id.clone());
134            }
135        }
136
137        for id in finished {
138            self.remove_shell(&id);
139        }
140    }
141
142    /// Get info about all running background shells - matches WCGW Python `get_bg_running_commandsinfo`
143    pub fn get_running_info(&mut self) -> String {
144        self.prune_finished_shells();
145
146        if self.shells.is_empty() {
147            return "No command running in background.\n".to_string();
148        }
149
150        let mut running = Vec::new();
151        for (id, shell_arc) in &self.shells {
152            if let Ok(guard) = shell_arc.try_lock() {
153                if let Some(bash) = guard.as_ref() {
154                    if bash.command_running {
155                        running
156                            .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
157                    }
158                }
159            } else {
160                running.push(format!("Command: <busy>, bg_command_id: {id}"));
161            }
162        }
163
164        if running.is_empty() {
165            "No command running in background.\n".to_string()
166        } else {
167            format!("Following background commands are attached:\n{}\n", running.join("\n"))
168        }
169    }
170}
171
172// Global background shell manager (thread-safe) - matches WCGW Python's BashState.background_shells
173lazy_static::lazy_static! {
174    static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
175}
176
177// ==================== WCGW-Style Helper Functions ====================
178
179/// Get WCGW-style status string - matches WCGW Python's `get_status()`
180fn get_status(
181    bash_state: &BashState,
182    is_bg: bool,
183    bg_id: Option<&str>,
184    is_running: bool,
185    running_for: Option<&str>,
186) -> String {
187    let mut status = "\n\n---\n\n".to_string();
188
189    if is_bg {
190        if let Some(id) = bg_id {
191            let _ = writeln!(status, "bg_command_id = {id}");
192        }
193    }
194
195    if is_running {
196        status.push_str("status = still running\n");
197        if let Some(duration) = running_for {
198            let _ = writeln!(status, "running for = {duration}");
199        }
200    } else {
201        status.push_str("status = process exited\n");
202    }
203
204    let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
205
206    if !is_bg {
207        // Add background shell info for main shell - matches WCGW Python
208        if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
209            status.push_str("This is the main shell. ");
210            status.push_str(&manager.get_running_info());
211        }
212    }
213
214    status.trim_end().to_string()
215}
216
217/// Process output with WCGW-style incremental text handling - matches WCGW Python _`incremental_text`
218fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
219    let text =
220        if text.len() > MAX_OUTPUT_LENGTH { &text[text.len() - MAX_OUTPUT_LENGTH..] } else { text };
221
222    if last_pending_output.is_empty() {
223        let rendered = render_terminal_output(text);
224        return rstrip_lines(&rendered).trim_start().to_string();
225    }
226
227    let last_rendered = render_terminal_output(last_pending_output);
228    if last_rendered.is_empty() {
229        return rstrip_lines(&render_terminal_output(text));
230    }
231
232    // Get text after last pending output
233    let text_after_last = if text.len() > last_pending_output.len() {
234        &text[last_pending_output.len()..]
235    } else {
236        text
237    };
238
239    let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
240    let new_rendered = render_terminal_output(&combined);
241
242    // Get incremental part - matches WCGW Python get_incremental_output
243    let incremental = get_incremental_output(&last_rendered, &new_rendered);
244    rstrip_lines(&incremental)
245}
246
247fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
248    let stripped = strip_ansi_codes(output);
249    let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
250
251    prompt_regex
252        .captures_iter(&stripped)
253        .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
254        .filter(|cwd| !cwd.is_empty())
255        .last()
256        .map(PathBuf::from)
257}
258
259/// Right-strip each line and join - matches WCGW Python rstrip
260fn rstrip_lines(lines: &[String]) -> String {
261    lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
262}
263
264/// Get incremental output between old and new - matches WCGW Python `get_incremental_output`
265fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
266    if old_output.is_empty() {
267        return new_output.to_vec();
268    }
269
270    let nold = old_output.len();
271    let nnew = new_output.len();
272
273    // Find where old output ends in new output
274    for i in (0..nnew).rev() {
275        if new_output[i] != old_output[nold - 1] {
276            continue;
277        }
278
279        let mut matched = true;
280        for j in (0..i).rev() {
281            let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
282            if old_idx < 0 {
283                break;
284            }
285            if new_output[j] != old_output[old_idx as usize] {
286                matched = false;
287                break;
288            }
289        }
290
291        if matched {
292            return new_output[i + 1..].to_vec();
293        }
294    }
295
296    new_output.to_vec()
297}
298
299fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
300    let mut start = 0;
301
302    while start < text.len() {
303        let mut end = (start + chunk_size).min(text.len());
304        while !text.is_char_boundary(end) {
305            end -= 1;
306        }
307        if end == start {
308            end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
309        }
310
311        shell.send_text(&text[start..end]).map_err(|e| {
312            WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
313        })?;
314        start = end;
315    }
316
317    Ok(())
318}
319
320/// Check if action is effectively a status check - matches WCGW Python `is_status_check`
321#[allow(dead_code)]
322fn is_status_check_action(action: &BashCommandAction) -> bool {
323    match action {
324        BashCommandAction::StatusCheck { .. } => true,
325        BashCommandAction::SendSpecials { send_specials, .. } => {
326            send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
327        }
328        BashCommandAction::SendAscii { send_ascii, .. } => {
329            send_ascii.len() == 1 && send_ascii[0] == 10 // newline
330        }
331        _ => false,
332    }
333}
334
335// ==================== Main Tool Handler ====================
336
337/// Handles the `BashCommand` tool call with WCGW parity
338///
339/// This function processes the `BashCommand` tool call following WCGW Python's
340/// `execute_bash()` function behavior exactly.
341#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
342pub async fn handle_tool_call(
343    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
344    bash_command: BashCommand,
345) -> Result<String> {
346    info!("BashCommand tool called with: {:?}", bash_command);
347
348    let thread_id = normalize_thread_id(&bash_command.thread_id);
349
350    // Check if thread_id is empty
351    if thread_id.is_empty() {
352        error!("Empty thread_id provided in BashCommand");
353        return Err(WinxError::ThreadIdMismatch(
354            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
355        ));
356    }
357
358    // Extract bash_state data
359    let mut bash_state: BashState;
360    {
361        let bash_state_guard = bash_state_arc.lock().await;
362
363        let Some(state) = &*bash_state_guard else {
364            error!("BashState not initialized");
365            return Err(WinxError::BashStateNotInitialized);
366        };
367
368        bash_state = state.clone();
369    }
370
371    // Verify thread ID matches - matches WCGW Python thread_id check
372    if thread_id != bash_state.current_thread_id {
373        // Try to load state from thread_id - matches WCGW Python load_state_from_thread_id
374        if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
375            return Err(WinxError::ThreadIdMismatch(format!(
376                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
377            )));
378        }
379    }
380
381    // Calculate effective timeout - matches WCGW Python
382    // SECURITY: Ensure timeout is not negative to prevent unexpected behavior
383    let timeout_s = bash_command
384        .wait_for_seconds
385        .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
386        .min(TIMEOUT_WHILE_OUTPUT);
387
388    // Execute the action based on type - matches WCGW Python's _execute_bash()
389    let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
390
391    {
392        let mut bash_state_guard = bash_state_arc.lock().await;
393        if let Some(state) = bash_state_guard.as_mut() {
394            state.cwd.clone_from(&bash_state.cwd);
395        }
396    }
397
398    // Remove echo if it's a command - matches WCGW Python
399    match result {
400        Ok(mut output) => {
401            if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
402                let cmd_trimmed = command.trim();
403                if output.starts_with(cmd_trimmed) {
404                    output = output[cmd_trimmed.len()..].to_string();
405                }
406            }
407            Ok(output)
408        }
409        Err(e) => Err(e),
410    }
411}
412
413/// Execute a bash action - matches WCGW Python's _`execute_bash()` function
414async fn execute_bash_action(
415    bash_state: &mut BashState,
416    action: &BashCommandAction,
417    timeout_s: f64,
418) -> Result<String> {
419    let mut is_bg = false;
420    let mut bg_id: Option<String> = None;
421
422    // Handle bg_command_id routing - matches WCGW Python
423    let bg_shell: Option<SharedPtyShell> = match action {
424        BashCommandAction::Command { .. } => None, // Commands don't use bg_command_id for routing
425        BashCommandAction::StatusCheck { bg_command_id, .. }
426        | BashCommandAction::SendText { bg_command_id, .. }
427        | BashCommandAction::SendSpecials { bg_command_id, .. }
428        | BashCommandAction::SendAscii { bg_command_id, .. } => {
429            if let Some(id) = bg_command_id {
430                let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
431                    WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
432                })?;
433                manager.prune_finished_shells();
434
435                if let Some(shell) = manager.get_shell(id) {
436                    is_bg = true;
437                    bg_id = Some(id.clone());
438                    Some(shell)
439                } else {
440                    // Error message matches WCGW Python
441                    let error = format!(
442                        "No shell found running with command id {}.\n{}",
443                        id,
444                        manager.get_running_info()
445                    );
446                    return Err(WinxError::CommandExecutionError(error));
447                }
448            } else {
449                None
450            }
451        }
452    };
453
454    // Process based on action type - matches WCGW Python _execute_bash dispatch
455    match action {
456        BashCommandAction::Command { command, is_background } => {
457            execute_command(bash_state, command, *is_background, timeout_s).await
458        }
459        BashCommandAction::StatusCheck { .. } => {
460            execute_status_check(bash_state, bg_shell, is_bg, bg_id.as_deref(), timeout_s).await
461        }
462        BashCommandAction::SendText { send_text, .. } => {
463            execute_send_text(bash_state, send_text, bg_shell, is_bg, bg_id.as_deref(), timeout_s)
464                .await
465        }
466        BashCommandAction::SendSpecials { send_specials, .. } => {
467            execute_send_specials(
468                bash_state,
469                send_specials,
470                bg_shell,
471                is_bg,
472                bg_id.as_deref(),
473                timeout_s,
474            )
475            .await
476        }
477        BashCommandAction::SendAscii { send_ascii, .. } => {
478            execute_send_ascii(bash_state, send_ascii, bg_shell, is_bg, bg_id.as_deref(), timeout_s)
479                .await
480        }
481    }
482}
483
484/// Execute a command - matches WCGW Python's Command handling in _`execute_bash`
485async fn execute_command(
486    bash_state: &mut BashState,
487    command: &str,
488    is_background: bool,
489    timeout_s: f64,
490) -> Result<String> {
491    debug!("Processing Command action: {}", command);
492
493    // Check mode permissions - matches WCGW Python bash_command_mode check
494    if !bash_state.is_command_allowed(command) {
495        error!("Command '{}' not allowed in current mode", command);
496        return Err(WinxError::CommandNotAllowed(
497            "Error: BashCommand not allowed in current mode".to_string(),
498        ));
499    }
500
501    // Validate single statement - matches WCGW Python assert_single_statement
502    let command = command.trim();
503    crate::utils::bash_parser::assert_single_statement(command)?;
504
505    // If background execution requested, start new shell - matches WCGW Python is_background handling
506    if is_background {
507        return execute_in_background(bash_state, command, timeout_s).await;
508    }
509
510    // Check if a command is already running - matches WCGW Python state check
511    {
512        let bash_guard = bash_state.pty_shell.lock().await;
513
514        if let Some(ref bash) = *bash_guard {
515            if bash.command_running {
516                return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
517            }
518        }
519    }
520
521    // Initialize bash if needed
522    if bash_state.pty_shell.lock().await.is_none() {
523        bash_state
524            .init_pty_shell()
525            .await
526            .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
527    }
528
529    // Clear prompt before sending - matches WCGW Python clear_to_run
530    // (simplified version - WCGW does more complex clearing)
531
532    // Send command in chunks of 64 characters - matches WCGW Python exactly
533    {
534        let mut bash_guard = bash_state.pty_shell.lock().await;
535
536        let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
537
538        bash.output_buffer.clear();
539        bash.output_truncated = false;
540        // Send in chunks - matches WCGW Python: for i in range(0, len(command), 64)
541        send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
542
543        // Send linesep to execute - matches WCGW Python bash_state.send(bash_state.linesep, ...)
544        bash.send_special_key("Enter").map_err(|e| {
545            WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
546        })?;
547
548        bash.last_command = command.to_string();
549        bash.command_running = true;
550    }
551
552    // Wait for output with WCGW-style patience handling
553    let shell_arc = bash_state.pty_shell.clone();
554    wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
555}
556
557/// Wait for command output with WCGW-style patience handling - matches WCGW Python expect/wait logic.
558///
559/// `shell_arc` selects which shell to read from (main shell or a bg shell handle).
560async fn wait_for_output(
561    bash_state: &mut BashState,
562    shell_arc: &SharedPtyShell,
563    timeout_s: f64,
564    is_bg: bool,
565    bg_id: Option<&str>,
566    is_status_check: bool,
567) -> Result<String> {
568    let start = Instant::now();
569    let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
570    let mut last_pending_output = String::new();
571    let mut complete = false;
572
573    // Initial wait - matches WCGW Python wait = min(timeout_s or CONFIG.timeout, CONFIG.timeout_while_output)
574    sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
575
576    // Read initial output
577    let mut output = {
578        let mut bash_guard = shell_arc.lock().await;
579
580        if let Some(bash) = bash_guard.as_mut() {
581            let (out, done) = bash.read_output(0.5).map_err(|e| {
582                WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
583            })?;
584            complete = done;
585            out
586        } else {
587            String::new()
588        }
589    };
590
591    // If not complete and this is a status check, use WCGW-style patience waiting
592    // Matches WCGW Python: if is_status_check(bash_arg) block
593    if !complete && is_status_check {
594        let mut remaining = TIMEOUT_WHILE_OUTPUT - wait;
595        let mut patience = OUTPUT_WAIT_PATIENCE;
596
597        let incremental = wcgw_incremental_text(&output, &last_pending_output);
598        if incremental.is_empty() {
599            patience -= 1;
600        }
601
602        let mut last_incremental = incremental;
603
604        while remaining > 0.0 && patience > 0 {
605            sleep(Duration::from_secs_f64(wait.min(remaining))).await;
606
607            let (new_output, done) = {
608                let mut bash_guard = shell_arc.lock().await;
609
610                if let Some(bash) = bash_guard.as_mut() {
611                    bash.read_output(0.5).map_err(|e| {
612                        WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
613                    })?
614                } else {
615                    (String::new(), true)
616                }
617            };
618
619            if done {
620                complete = true;
621                output = new_output;
622                break;
623            }
624
625            // Check if output changed - matches WCGW Python patience logic
626            let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
627            if new_incremental == last_incremental {
628                patience -= 1;
629            } else {
630                patience = OUTPUT_WAIT_PATIENCE; // Reset patience on new output
631            }
632            last_incremental = new_incremental;
633
634            output = new_output;
635            remaining -= wait;
636        }
637
638        if !complete {
639            // Update pending output - matches WCGW Python bash_state.set_pending(text)
640            last_pending_output = output.clone();
641        }
642    }
643
644    if complete {
645        if let Some(cwd) = extract_prompt_cwd(&output) {
646            bash_state.cwd = cwd;
647        }
648    }
649
650    // Process output through terminal emulation - matches WCGW Python _incremental_text
651    let rendered = wcgw_incremental_text(&output, &last_pending_output);
652
653    // Truncate if needed - matches WCGW Python token truncation
654    let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
655        format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
656    } else {
657        rendered
658    };
659
660    // Calculate running duration for status
661    let running_for = if complete {
662        None
663    } else {
664        Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
665    };
666
667    // Add status - matches WCGW Python get_status
668    let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
669    Ok(format!("{rendered}{status}"))
670}
671
672/// Execute a status check - matches WCGW Python's `StatusCheck` handling
673async fn execute_status_check(
674    bash_state: &mut BashState,
675    bg_shell: Option<SharedPtyShell>,
676    is_bg: bool,
677    bg_id: Option<&str>,
678    timeout_s: f64,
679) -> Result<String> {
680    debug!("Processing StatusCheck action");
681
682    // Pick the shell we're going to inspect: bg shell when bg_command_id was provided,
683    // otherwise fall back to the main interactive shell.
684    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
685
686    // Check if there's a running command - matches WCGW Python state check
687    let is_running = {
688        let guard = shell_arc.lock().await;
689        if let Some(ref bash) = *guard {
690            bash.command_running
691        } else {
692            false
693        }
694    };
695
696    // If no command running and not background, return error - matches WCGW Python
697    if !is_running && !is_bg {
698        let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
699            WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
700        })?;
701        let error =
702            format!("No running command to check status of.\n{}", manager.get_running_info());
703        return Err(WinxError::CommandExecutionError(error));
704    }
705
706    // Read output with patience handling - this IS a status check
707    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await
708}
709
710/// Execute `send_text` - matches WCGW Python's `SendText` handling
711async fn execute_send_text(
712    bash_state: &mut BashState,
713    text: &str,
714    bg_shell: Option<SharedPtyShell>,
715    is_bg: bool,
716    bg_id: Option<&str>,
717    timeout_s: f64,
718) -> Result<String> {
719    debug!("Processing SendText action: {}", text);
720
721    // Validate - matches WCGW Python
722    if text.is_empty() {
723        return Err(WinxError::CommandExecutionError(
724            "Failure: send_text cannot be empty".to_string(),
725        ));
726    }
727
728    // Get the target shell
729    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
730
731    // Send text in chunks of 128 characters - matches WCGW Python exactly
732    {
733        let mut guard = shell_arc.lock().await;
734
735        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
736
737        // Send in chunks - matches WCGW Python: for i in range(0, len(command_data.send_text), 128)
738        send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
739
740        // Send linesep - matches WCGW Python bash_state.send(bash_state.linesep, ...)
741        bash.send_special_key("Enter").map_err(|e| {
742            WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
743        })?;
744    }
745
746    // Wait for output
747    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
748}
749
750/// Execute `send_specials` - matches WCGW Python's `SendSpecials` handling exactly
751async fn execute_send_specials(
752    bash_state: &mut BashState,
753    keys: &[SpecialKey],
754    bg_shell: Option<SharedPtyShell>,
755    is_bg: bool,
756    bg_id: Option<&str>,
757    timeout_s: f64,
758) -> Result<String> {
759    debug!("Processing SendSpecials action: {:?}", keys);
760
761    // Validate - matches WCGW Python
762    if keys.is_empty() {
763        return Err(WinxError::CommandExecutionError(
764            "Failure: send_specials cannot be empty".to_string(),
765        ));
766    }
767
768    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
769    let mut is_interrupt = false;
770
771    {
772        let mut guard = shell_arc.lock().await;
773
774        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
775
776        // Send each special key - matches WCGW Python exactly
777        for key in keys {
778            match key {
779                SpecialKey::KeyUp => {
780                    // matches WCGW Python: bash_state.send("\033[A", ...)
781                    bash.send_special_key("KeyUp").map_err(|e| {
782                        WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
783                    })?;
784                }
785                SpecialKey::KeyDown => {
786                    // matches WCGW Python: bash_state.send("\033[B", ...)
787                    bash.send_special_key("KeyDown").map_err(|e| {
788                        WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
789                    })?;
790                }
791                SpecialKey::KeyLeft => {
792                    // matches WCGW Python: bash_state.send("\033[D", ...)
793                    bash.send_special_key("KeyLeft").map_err(|e| {
794                        WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
795                    })?;
796                }
797                SpecialKey::KeyRight => {
798                    // matches WCGW Python: bash_state.send("\033[C", ...)
799                    bash.send_special_key("KeyRight").map_err(|e| {
800                        WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
801                    })?;
802                }
803                SpecialKey::Enter => {
804                    // matches WCGW Python: bash_state.send("\x0d", ...) - carriage return
805                    bash.send_special_key("Enter").map_err(|e| {
806                        WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
807                    })?;
808                }
809                SpecialKey::CtrlC => {
810                    // matches WCGW Python: bash_state.sendintr()
811                    bash.send_interrupt().map_err(|e| {
812                        WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
813                    })?;
814                    is_interrupt = true;
815                }
816                SpecialKey::CtrlD => {
817                    // matches WCGW Python: bash_state.sendintr() - same as Ctrl+C in WCGW
818                    bash.send_eof().map_err(|e| {
819                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
820                    })?;
821                    is_interrupt = true;
822                }
823                SpecialKey::CtrlZ => {
824                    // Ctrl+Z = SIGTSTP (suspend) - ASCII 0x1a
825                    bash.send_suspend().map_err(|e| {
826                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
827                    })?;
828                }
829            }
830        }
831    }
832
833    // Wait for output
834    let mut output =
835        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
836
837    // Add interrupt failure message if still running - matches WCGW Python exactly
838    if is_interrupt && output.contains("status = still running") {
839        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
840    }
841
842    Ok(output)
843}
844
845/// Execute `send_ascii` - matches WCGW Python's `SendAscii` handling
846async fn execute_send_ascii(
847    bash_state: &mut BashState,
848    ascii_codes: &[u8],
849    bg_shell: Option<SharedPtyShell>,
850    is_bg: bool,
851    bg_id: Option<&str>,
852    timeout_s: f64,
853) -> Result<String> {
854    debug!("Processing SendAscii action: {:?}", ascii_codes);
855
856    // Validate - matches WCGW Python
857    if ascii_codes.is_empty() {
858        return Err(WinxError::CommandExecutionError(
859            "Failure: send_ascii cannot be empty".to_string(),
860        ));
861    }
862
863    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
864    let mut is_interrupt = false;
865
866    {
867        let mut guard = shell_arc.lock().await;
868
869        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
870
871        // Send each ASCII code - matches WCGW Python
872        for &code in ascii_codes {
873            // matches WCGW Python: bash_state.send(chr(ascii_char), ...)
874            bash.send_bytes(&[code]).map_err(|e| {
875                WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
876            })?;
877
878            // Check for interrupt - matches WCGW Python: if ascii_char == 3: is_interrupt = True
879            if code == 3 {
880                is_interrupt = true;
881            }
882        }
883    }
884
885    // Wait for output
886    let mut output =
887        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
888
889    // Add interrupt failure message if still running - matches WCGW Python
890    if is_interrupt && output.contains("status = still running") {
891        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
892    }
893
894    Ok(output)
895}
896
897/// Execute command in background - matches WCGW Python's `is_background` handling
898async fn execute_in_background(
899    bash_state: &mut BashState,
900    command: &str,
901    timeout_s: f64,
902) -> Result<String> {
903    debug!("Executing command in background: {}", command);
904
905    // Start a new background shell - matches WCGW Python bash_state.start_new_bg_shell
906    let restricted_mode =
907        matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
908
909    let bg_id = {
910        let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
911            WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
912        })?;
913        manager.start_new_shell(&bash_state.cwd, restricted_mode)?
914    };
915
916    // Get the shell
917    let shell_arc = {
918        let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
919            WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
920        })?;
921        manager.get_shell(&bg_id).ok_or_else(|| {
922            WinxError::CommandExecutionError("Failed to get background shell".to_string())
923        })?
924    };
925
926    // Send command via the same PTY path used by foreground execute_command.
927    {
928        let mut guard = shell_arc.lock().await;
929        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
930        bash.send_command(command).map_err(|e| {
931            WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
932        })?;
933    }
934    debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
935
936    let _ = timeout_s;
937    let _ = shell_arc;
938    Ok(get_status(bash_state, true, Some(&bg_id), true, None))
939}
940
941// ==================== Legacy Screen-based Functions (kept for backward compatibility) ====================
942
943/// Process simple command execution for a bash command (legacy)
944#[allow(dead_code)]
945#[tracing::instrument(level = "debug", skip(command, cwd))]
946async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
947    debug!("Executing command: {}", command);
948
949    let start_time = Instant::now();
950    let mut cmd = Command::new("sh");
951    cmd.arg("-c")
952        .arg(command)
953        .current_dir(cwd)
954        .stdin(Stdio::null())
955        .stdout(Stdio::piped())
956        .stderr(Stdio::piped());
957
958    let output = cmd.output().context("Failed to execute command")?;
959    let elapsed = start_time.elapsed();
960
961    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
962    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
963
964    let raw_result = format!("{stdout}{stderr}");
965    let mut result = raw_result.clone();
966    if !raw_result.is_empty() {
967        let rendered_lines = render_terminal_output(&raw_result);
968        if rendered_lines.is_empty() {
969            // Fallback: just strip ANSI codes if rendering failed or wasn't needed
970            result = strip_ansi_codes(&raw_result);
971        } else {
972            result = rendered_lines.join("\n");
973        }
974    }
975
976    if result.len() > MAX_OUTPUT_LENGTH {
977        result = format!("(...truncated)\n{}", &result[result.len() - MAX_OUTPUT_LENGTH..]);
978    }
979
980    let exit_status = if output.status.success() {
981        "Command completed successfully".to_string()
982    } else {
983        format!("Command failed with status: {}", output.status)
984    };
985
986    let current_dir = std::env::current_dir()
987        .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
988
989    debug!("Command executed in {:.2?}", elapsed);
990    Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
991}
992
993/// Execute command in screen (legacy)
994#[allow(dead_code)]
995#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
996async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
997    debug!("Executing command in screen session '{}': {}", screen_name, command);
998
999    let screen_check = Command::new("which")
1000        .arg("screen")
1001        .output()
1002        .context("Failed to check for screen command")?;
1003
1004    if !screen_check.status.success() {
1005        warn!("Screen command not found, falling back to direct execution");
1006        return execute_simple_command(command, cwd).await;
1007    }
1008
1009    let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1010
1011    let screen_cmd = format!(
1012        "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1013        screen_name,
1014        command.replace('\'', "'\\''")
1015    );
1016
1017    let screen_start = Command::new("sh")
1018        .arg("-c")
1019        .arg(&screen_cmd)
1020        .current_dir(cwd)
1021        .output()
1022        .context("Failed to start screen session")?;
1023
1024    if !screen_start.status.success() {
1025        let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1026        error!("Failed to start screen session: {}", stderr);
1027        return Err(WinxError::CommandExecutionError(format!(
1028            "Failed to start screen session: {stderr}"
1029        )));
1030    }
1031
1032    sleep(Duration::from_millis(300)).await;
1033
1034    let screen_check =
1035        Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1036
1037    let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1038
1039    let current_dir = std::env::current_dir()
1040        .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1041
1042    Ok(format!(
1043        "Started command in background screen session '{screen_name}'.\n\
1044        Use status_check to get output.\n\n\
1045        Screen sessions:\n{screen_list}\n\
1046        ---\n\n\
1047        status = running in background\n\
1048        cwd = {current_dir}\n"
1049    ))
1050}
1051
1052/// Converts a `SpecialKey` to its screen stuff input representation (legacy)
1053#[allow(dead_code)]
1054fn special_key_to_screen_input(key: SpecialKey) -> String {
1055    match key {
1056        SpecialKey::Enter => String::from("\r"),
1057        SpecialKey::KeyUp => String::from("\x1b[A"),
1058        SpecialKey::KeyDown => String::from("\x1b[B"),
1059        SpecialKey::KeyLeft => String::from("\x1b[D"),
1060        SpecialKey::KeyRight => String::from("\x1b[C"),
1061        SpecialKey::CtrlC => String::from("\x03"),
1062        SpecialKey::CtrlD => String::from("\x04"),
1063        SpecialKey::CtrlZ => String::from("\x1a"),
1064    }
1065}