Skip to main content

winx_code_agent/tools/
bash_command.rs

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