use anyhow::Context as AnyhowContext;
use rand::RngExt;
use regex::Regex;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::{debug, error, info, warn};
use crate::errors::{Result, WinxError};
use crate::state::bash_state::BashState;
use crate::state::pty::PtyShell;
use crate::state::terminal::{render_terminal_output, strip_ansi_codes};
use crate::types::{normalize_thread_id, BashCommand, BashCommandAction, SpecialKey};
type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
const DEFAULT_TIMEOUT: f64 = 5.0;
const TIMEOUT_WHILE_OUTPUT: f64 = 20.0;
const OUTPUT_WAIT_PATIENCE: i32 = 3;
const COMMAND_CHUNK_SIZE: usize = 64;
const TEXT_CHUNK_SIZE: usize = 128;
const MAX_OUTPUT_LENGTH: usize = 100_000;
const MAX_OUTPUT_TOKENS: usize = 25_000;
fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
if text.len() <= MAX_OUTPUT_LENGTH {
return std::borrow::Cow::Borrowed(text);
}
let Ok(bpe) = tiktoken_rs::cl100k_base() else {
return std::borrow::Cow::Owned(format!(
"(...truncated)\n{}",
&text[text.len() - MAX_OUTPUT_LENGTH..]
));
};
let tokens = bpe.encode_with_special_tokens(text);
if tokens.len() <= max_tokens {
return std::borrow::Cow::Borrowed(text);
}
let keep = max_tokens.saturating_sub(1);
let tail = &tokens[tokens.len() - keep..];
let decoded = bpe.decode(tail).unwrap_or_default();
std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
}
const 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.
1. Get its output using status check.
2. Use `send_ascii` or `send_specials` to give inputs to the running program OR
3. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
4. Interrupt and run the process in background
";
#[derive(Debug, Clone)]
pub struct ExitedShellInfo {
pub last_command: String,
pub final_output: String,
pub exited_at: Instant,
}
#[derive(Debug, Default)]
pub struct BackgroundShellManager {
shells: HashMap<String, SharedPtyShell>,
tombstones: HashMap<String, ExitedShellInfo>,
}
impl BackgroundShellManager {
const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
pub fn new() -> Self {
Self { shells: HashMap::new(), tombstones: HashMap::new() }
}
pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
let cid = format!("{:010x}", rand::rng().random::<u32>());
let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
})?;
self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
info!("Started background shell with id: {}", cid);
Ok(cid)
}
pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
self.shells.get(bg_command_id).cloned()
}
pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
if let Some(shell_arc) = self.shells.remove(bg_command_id) {
if let Ok(mut guard) = shell_arc.try_lock() {
*guard = None;
}
info!("Removed background shell: {}", bg_command_id);
true
} else {
false
}
}
fn prune_finished_shells(&mut self) {
let now = Instant::now();
self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
for (id, shell_arc) in &self.shells {
let Ok(mut guard) = shell_arc.try_lock() else {
continue;
};
let Some(shell) = guard.as_mut() else {
finished.push((id.clone(), None));
continue;
};
if !shell.is_alive() {
let tombstone = ExitedShellInfo {
last_command: shell.last_command.clone(),
final_output: shell.output_buffer.clone(),
exited_at: now,
};
finished.push((id.clone(), Some(tombstone)));
continue;
}
if shell.last_command.is_empty() {
continue;
}
if shell.command_running {
let _ = shell.read_output(0.1);
}
if !shell.command_running {
let tombstone = ExitedShellInfo {
last_command: shell.last_command.clone(),
final_output: shell.output_buffer.clone(),
exited_at: now,
};
finished.push((id.clone(), Some(tombstone)));
}
}
for (id, tombstone) in finished {
self.remove_shell(&id);
if let Some(info) = tombstone {
self.tombstones.insert(id, info);
}
}
}
pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
self.tombstones.get(bg_command_id).cloned()
}
pub fn get_running_info(&mut self) -> String {
self.prune_finished_shells();
if self.shells.is_empty() {
return "No command running in background.\n".to_string();
}
let mut running = Vec::new();
for (id, shell_arc) in &self.shells {
if let Ok(guard) = shell_arc.try_lock() {
if let Some(bash) = guard.as_ref() {
if bash.command_running {
running
.push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
}
}
} else {
running.push(format!("Command: <busy>, bg_command_id: {id}"));
}
}
if running.is_empty() {
"No command running in background.\n".to_string()
} else {
format!("Following background commands are attached:\n{}\n", running.join("\n"))
}
}
}
lazy_static::lazy_static! {
static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
}
fn get_status(
bash_state: &BashState,
is_bg: bool,
bg_id: Option<&str>,
is_running: bool,
running_for: Option<&str>,
) -> String {
let mut status = "\n\n---\n\n".to_string();
if is_bg {
if let Some(id) = bg_id {
let _ = writeln!(status, "bg_command_id = {id}");
}
}
if is_running {
status.push_str("status = still running\n");
if let Some(duration) = running_for {
let _ = writeln!(status, "running for = {duration}");
}
} else {
status.push_str("status = process exited\n");
}
let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
if !is_bg {
if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
status.push_str("This is the main shell. ");
status.push_str(&manager.get_running_info());
}
}
status.trim_end().to_string()
}
fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
let text = truncated.as_ref();
if last_pending_output.is_empty() {
let rendered = render_terminal_output(text);
return rstrip_lines(&rendered).trim_start().to_string();
}
let last_rendered = render_terminal_output(last_pending_output);
if last_rendered.is_empty() {
return rstrip_lines(&render_terminal_output(text));
}
let text_after_last = if text.len() > last_pending_output.len() {
&text[last_pending_output.len()..]
} else {
text
};
let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
let new_rendered = render_terminal_output(&combined);
let incremental = get_incremental_output(&last_rendered, &new_rendered);
rstrip_lines(&incremental)
}
fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
let stripped = strip_ansi_codes(output);
let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
prompt_regex
.captures_iter(&stripped)
.filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
.filter(|cwd| !cwd.is_empty())
.last()
.map(PathBuf::from)
}
fn rstrip_lines(lines: &[String]) -> String {
lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
}
fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
if old_output.is_empty() {
return new_output.to_vec();
}
let nold = old_output.len();
let nnew = new_output.len();
for i in (0..nnew).rev() {
if new_output[i] != old_output[nold - 1] {
continue;
}
let mut matched = true;
for j in (0..i).rev() {
let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
if old_idx < 0 {
break;
}
if new_output[j] != old_output[old_idx as usize] {
matched = false;
break;
}
}
if matched {
return new_output[i + 1..].to_vec();
}
}
new_output.to_vec()
}
fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
let mut start = 0;
while start < text.len() {
let mut end = (start + chunk_size).min(text.len());
while !text.is_char_boundary(end) {
end -= 1;
}
if end == start {
end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
}
shell.send_text(&text[start..end]).map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
})?;
start = end;
}
Ok(())
}
#[allow(dead_code)]
fn is_status_check_action(action: &BashCommandAction) -> bool {
match action {
BashCommandAction::StatusCheck { .. } => true,
BashCommandAction::SendSpecials { send_specials, .. } => {
send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
}
BashCommandAction::SendAscii { send_ascii, .. } => {
send_ascii.len() == 1 && send_ascii[0] == 10 }
_ => false,
}
}
#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
pub async fn handle_tool_call(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
bash_command: BashCommand,
) -> Result<String> {
info!("BashCommand tool called with: {:?}", bash_command);
let thread_id = normalize_thread_id(&bash_command.thread_id);
if thread_id.is_empty() {
error!("Empty thread_id provided in BashCommand");
return Err(WinxError::ThreadIdMismatch(
"Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
));
}
let mut bash_state: BashState;
{
let bash_state_guard = bash_state_arc.lock().await;
let Some(state) = &*bash_state_guard else {
error!("BashState not initialized");
return Err(WinxError::BashStateNotInitialized);
};
bash_state = state.clone();
}
if thread_id != bash_state.current_thread_id {
if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
return Err(WinxError::ThreadIdMismatch(format!(
"Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
)));
}
}
let timeout_s = bash_command
.wait_for_seconds
.map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
.min(TIMEOUT_WHILE_OUTPUT);
let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
{
let mut bash_state_guard = bash_state_arc.lock().await;
if let Some(state) = bash_state_guard.as_mut() {
state.cwd.clone_from(&bash_state.cwd);
}
}
match result {
Ok(mut output) => {
if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
let cmd_trimmed = command.trim();
if output.starts_with(cmd_trimmed) {
output = output[cmd_trimmed.len()..].to_string();
}
}
Ok(output)
}
Err(e) => Err(e),
}
}
async fn execute_bash_action(
bash_state: &mut BashState,
action: &BashCommandAction,
timeout_s: f64,
) -> Result<String> {
let mut is_bg = false;
let mut bg_id: Option<String> = None;
let bg_shell: Option<SharedPtyShell> = match action {
BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
| BashCommandAction::SendText { bg_command_id, .. }
| BashCommandAction::SendSpecials { bg_command_id, .. }
| BashCommandAction::SendAscii { bg_command_id, .. } => {
if let Some(id) = bg_command_id {
let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
})?;
manager.prune_finished_shells();
if let Some(shell) = manager.get_shell(id) {
is_bg = true;
bg_id = Some(id.clone());
Some(shell)
} else if let Some(tombstone) = manager.peek_tombstone(id) {
drop(manager);
return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
} else {
let error = format!(
"No shell found running with command id {}.\n{}",
id,
manager.get_running_info()
);
return Err(WinxError::CommandExecutionError(error));
}
} else {
None
}
}
};
match action {
BashCommandAction::Command { command, is_background, allow_multi } => {
execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
}
BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
execute_status_check(
bash_state,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
*scrollback_lines,
*verbose,
)
.await
}
BashCommandAction::SendText { send_text, submit, .. } => {
execute_send_text(
bash_state,
send_text,
*submit,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
)
.await
}
BashCommandAction::SendSpecials { send_specials, submit, .. } => {
execute_send_specials(
bash_state,
send_specials,
*submit,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
)
.await
}
BashCommandAction::SendAscii { send_ascii, submit, .. } => {
execute_send_ascii(
bash_state,
send_ascii,
*submit,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
)
.await
}
}
}
async fn execute_command(
bash_state: &mut BashState,
command: &str,
is_background: bool,
allow_multi: bool,
timeout_s: f64,
) -> Result<String> {
debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
if !bash_state.is_command_allowed(command) {
error!("Command '{}' not allowed in current mode", command);
return Err(WinxError::CommandNotAllowed(
"Error: BashCommand not allowed in current mode".to_string(),
));
}
let command = command.trim();
if !allow_multi {
crate::utils::bash_parser::assert_single_statement(command)?;
}
if is_background {
return execute_in_background(bash_state, command, timeout_s).await;
}
{
let bash_guard = bash_state.pty_shell.lock().await;
if let Some(ref bash) = *bash_guard {
if bash.command_running {
return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
}
}
}
if bash_state.pty_shell.lock().await.is_none() {
bash_state
.init_pty_shell()
.await
.map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
}
{
let mut bash_guard = bash_state.pty_shell.lock().await;
if let Some(bash) = bash_guard.as_mut() {
if let Err(e) = bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
warn!("clear_to_run failed before send: {e}");
}
}
}
{
let mut bash_guard = bash_state.pty_shell.lock().await;
let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
bash.output_buffer.clear();
bash.output_truncated = false;
send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
bash.send_special_key("Enter").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
})?;
bash.last_command = command.to_string();
bash.command_running = true;
}
let shell_arc = bash_state.pty_shell.clone();
wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
}
async fn wait_for_output(
bash_state: &mut BashState,
shell_arc: &SharedPtyShell,
timeout_s: f64,
is_bg: bool,
bg_id: Option<&str>,
is_status_check: bool,
) -> Result<String> {
let start = Instant::now();
let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
let mut last_pending_output = String::new();
let mut complete = false;
sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
let mut output = {
let mut bash_guard = shell_arc.lock().await;
if let Some(bash) = bash_guard.as_mut() {
let (out, done) = bash.read_output(0.5).map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
})?;
complete = done;
out
} else {
String::new()
}
};
if !complete && is_status_check {
let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
let iter_wait_secs = 0.5_f64;
let mut patience = OUTPUT_WAIT_PATIENCE;
let incremental = wcgw_incremental_text(&output, &last_pending_output);
if incremental.is_empty() {
patience -= 1;
}
let mut last_incremental = incremental;
while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
if remaining < 0.1 {
break;
}
sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
let (new_output, done) = {
let mut bash_guard = shell_arc.lock().await;
if let Some(bash) = bash_guard.as_mut() {
bash.read_output(0.5).map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
})?
} else {
(String::new(), true)
}
};
if done {
complete = true;
output = new_output;
break;
}
let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
if new_incremental == last_incremental {
patience -= 1;
} else {
patience = OUTPUT_WAIT_PATIENCE; }
last_incremental = new_incremental;
output = new_output;
}
if !complete {
last_pending_output = output.clone();
}
}
if complete {
if let Some(cwd) = extract_prompt_cwd(&output) {
bash_state.cwd = cwd;
}
}
let rendered = wcgw_incremental_text(&output, &last_pending_output);
let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
let running_for = if complete {
None
} else {
Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
};
let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
Ok(format!("{rendered}{status}"))
}
fn finalize_tombstone(
cwd: &Path,
id: &str,
tombstone: ExitedShellInfo,
action: &BashCommandAction,
) -> Result<String> {
let ExitedShellInfo { last_command, final_output, .. } = tombstone;
match action {
BashCommandAction::StatusCheck { .. } => {
let rendered = wcgw_incremental_text(&final_output, "");
let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
let mut status = "\n\n---\n\n".to_string();
let _ = writeln!(status, "bg_command_id = {id}");
status.push_str("status = process exited\n");
let _ = writeln!(status, "cwd = {}", cwd.display());
Ok(format!("{rendered}{}", status.trim_end()))
}
BashCommandAction::SendText { .. }
| BashCommandAction::SendSpecials { .. }
| BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
"Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
))),
BashCommandAction::Command { .. } => {
unreachable!("finalize_tombstone called for non-bg action")
}
}
}
async fn execute_status_check(
bash_state: &mut BashState,
bg_shell: Option<SharedPtyShell>,
is_bg: bool,
bg_id: Option<&str>,
timeout_s: f64,
scrollback_lines: Option<usize>,
verbose: bool,
) -> Result<String> {
debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
let is_running = {
let guard = shell_arc.lock().await;
if let Some(ref bash) = *guard {
bash.command_running
} else {
false
}
};
if !is_running && !is_bg {
let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
})?;
let error =
format!("No running command to check status of.\n{}", manager.get_running_info());
return Err(WinxError::CommandExecutionError(error));
}
let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
let body = response.split("\n\n---\n").next().unwrap_or(&response);
if !verbose && scrollback_lines.is_none() {
let mut guard = shell_arc.lock().await;
if let Some(bash) = guard.as_mut() {
let fingerprint = PtyShell::fingerprint(body);
if Some(fingerprint) == bash.last_returned_hash {
let status = get_status(bash_state, is_bg, bg_id, is_running, None);
return Ok(format!("no new output since last check{status}"));
}
bash.last_returned_hash = Some(fingerprint);
}
} else if !verbose {
let mut guard = shell_arc.lock().await;
if let Some(bash) = guard.as_mut() {
bash.last_returned_hash = Some(PtyShell::fingerprint(body));
}
}
if let Some(lines) = scrollback_lines {
if lines > 0 {
let scrollback = {
let guard = shell_arc.lock().await;
guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
};
if !scrollback.is_empty() {
let count = scrollback.lines().count();
return Ok(format!(
"--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
));
}
}
}
Ok(response)
}
async fn execute_send_text(
bash_state: &mut BashState,
text: &str,
submit: bool,
bg_shell: Option<SharedPtyShell>,
is_bg: bool,
bg_id: Option<&str>,
timeout_s: f64,
) -> Result<String> {
debug!("Processing SendText action: {text:?} (submit={submit})");
if text.is_empty() {
return Err(WinxError::CommandExecutionError(
"Failure: send_text cannot be empty".to_string(),
));
}
let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
if submit {
bash.send_special_key("Enter").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
})?;
}
}
wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
}
async fn execute_send_specials(
bash_state: &mut BashState,
keys: &[SpecialKey],
submit: bool,
bg_shell: Option<SharedPtyShell>,
is_bg: bool,
bg_id: Option<&str>,
timeout_s: f64,
) -> Result<String> {
debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
if keys.is_empty() {
return Err(WinxError::CommandExecutionError(
"Failure: send_specials cannot be empty".to_string(),
));
}
let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
let mut is_interrupt = false;
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
for key in keys {
match key {
SpecialKey::KeyUp => {
bash.send_special_key("KeyUp").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
})?;
}
SpecialKey::KeyDown => {
bash.send_special_key("KeyDown").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
})?;
}
SpecialKey::KeyLeft => {
bash.send_special_key("KeyLeft").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
})?;
}
SpecialKey::KeyRight => {
bash.send_special_key("KeyRight").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
})?;
}
SpecialKey::Enter => {
bash.send_special_key("Enter").map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
})?;
}
SpecialKey::CtrlC => {
bash.send_interrupt().map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
})?;
is_interrupt = true;
}
SpecialKey::CtrlD => {
bash.send_eof().map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
})?;
is_interrupt = true;
}
SpecialKey::CtrlZ => {
bash.send_suspend().map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
})?;
}
}
}
if submit {
bash.send_special_key("Enter")
.map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
}
}
let mut output =
wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
if is_interrupt && output.contains("status = still running") {
output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
}
Ok(output)
}
async fn execute_send_ascii(
bash_state: &mut BashState,
ascii_codes: &[u8],
submit: bool,
bg_shell: Option<SharedPtyShell>,
is_bg: bool,
bg_id: Option<&str>,
timeout_s: f64,
) -> Result<String> {
debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
if ascii_codes.is_empty() {
return Err(WinxError::CommandExecutionError(
"Failure: send_ascii cannot be empty".to_string(),
));
}
let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
let mut is_interrupt = false;
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
for &code in ascii_codes {
bash.send_bytes(&[code]).map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
})?;
if code == 3 {
is_interrupt = true;
}
}
if submit {
bash.send_special_key("Enter")
.map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
}
}
let mut output =
wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
if is_interrupt && output.contains("status = still running") {
output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
}
Ok(output)
}
async fn execute_in_background(
bash_state: &mut BashState,
command: &str,
timeout_s: f64,
) -> Result<String> {
debug!("Executing command in background: {}", command);
let restricted_mode =
matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
let bg_id = {
let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
})?;
manager.start_new_shell(&bash_state.cwd, restricted_mode)?
};
let shell_arc = {
let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
})?;
manager.get_shell(&bg_id).ok_or_else(|| {
WinxError::CommandExecutionError("Failed to get background shell".to_string())
})?
};
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
bash.send_command(command).map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
})?;
}
debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
let _ = timeout_s;
let _ = shell_arc;
Ok(get_status(bash_state, true, Some(&bg_id), true, None))
}
#[allow(dead_code)]
#[tracing::instrument(level = "debug", skip(command, cwd))]
async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
debug!("Executing command: {}", command);
let start_time = Instant::now();
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd.output().context("Failed to execute command")?;
let elapsed = start_time.elapsed();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let raw_result = format!("{stdout}{stderr}");
let mut result = raw_result.clone();
if !raw_result.is_empty() {
let rendered_lines = render_terminal_output(&raw_result);
if rendered_lines.is_empty() {
result = strip_ansi_codes(&raw_result);
} else {
result = rendered_lines.join("\n");
}
}
result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
let exit_status = if output.status.success() {
"Command completed successfully".to_string()
} else {
format!("Command failed with status: {}", output.status)
};
let current_dir = std::env::current_dir()
.map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
debug!("Command executed in {:.2?}", elapsed);
Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
}
#[allow(dead_code)]
#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
debug!("Executing command in screen session '{}': {}", screen_name, command);
let screen_check = Command::new("which")
.arg("screen")
.output()
.context("Failed to check for screen command")?;
if !screen_check.status.success() {
warn!("Screen command not found, falling back to direct execution");
return execute_simple_command(command, cwd).await;
}
let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
let screen_cmd = format!(
"screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
screen_name,
command.replace('\'', "'\\''")
);
let screen_start = Command::new("sh")
.arg("-c")
.arg(&screen_cmd)
.current_dir(cwd)
.output()
.context("Failed to start screen session")?;
if !screen_start.status.success() {
let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
error!("Failed to start screen session: {}", stderr);
return Err(WinxError::CommandExecutionError(format!(
"Failed to start screen session: {stderr}"
)));
}
sleep(Duration::from_millis(300)).await;
let screen_check =
Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
let current_dir = std::env::current_dir()
.map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
Ok(format!(
"Started command in background screen session '{screen_name}'.\n\
Use status_check to get output.\n\n\
Screen sessions:\n{screen_list}\n\
---\n\n\
status = running in background\n\
cwd = {current_dir}\n"
))
}
#[allow(dead_code)]
fn special_key_to_screen_input(key: SpecialKey) -> String {
match key {
SpecialKey::Enter => String::from("\r"),
SpecialKey::KeyUp => String::from("\x1b[A"),
SpecialKey::KeyDown => String::from("\x1b[B"),
SpecialKey::KeyLeft => String::from("\x1b[D"),
SpecialKey::KeyRight => String::from("\x1b[C"),
SpecialKey::CtrlC => String::from("\x03"),
SpecialKey::CtrlD => String::from("\x04"),
SpecialKey::CtrlZ => String::from("\x1a"),
}
}