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