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