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