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