use anyhow::Context as AnyhowContext;
use regex::Regex;
use std::fmt::Write as FmtWrite;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::{debug, error, info, warn};
pub use super::background_shell::{BackgroundShellManager, ExitedShellInfo};
use crate::errors::{Result, WinxError};
use crate::runtime::{lock_session_store, EmbeddedShellRuntime, ShellRuntime, ShellTarget};
use crate::state::bash_state::BashState;
use crate::state::live_terminal::ScreenUpdate;
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>>>;
#[derive(Debug, Default)]
pub(crate) struct ShellDeliveryCursor {
generation: Option<u64>,
delivered_output: String,
last_returned_hash: Option<u64>,
screen_snapshot: Option<Vec<String>>,
}
impl ShellDeliveryCursor {
fn sync_generation(&mut self, generation: u64) {
if self.generation != Some(generation) {
self.generation = Some(generation);
self.delivered_output.clear();
self.last_returned_hash = None;
self.screen_snapshot = None;
}
}
fn screen_update(&mut self, current: Vec<String>, threshold: usize) -> ScreenUpdate {
let update = match self.screen_snapshot.as_ref() {
Some(previous) => {
let rows = previous.len().max(current.len());
let changed = (0..rows)
.filter_map(|index| {
let before = previous.get(index).map_or("", String::as_str);
let after = current.get(index).map_or("", String::as_str);
(before != after).then(|| (index + 1, after.to_string()))
})
.collect::<Vec<_>>();
if changed.is_empty() {
ScreenUpdate::Unchanged
} else if changed.len() <= threshold {
ScreenUpdate::Diff(changed)
} else {
ScreenUpdate::Full(current.clone())
}
}
None => ScreenUpdate::Full(current.clone()),
};
self.screen_snapshot = Some(current);
update
}
}
fn main_shell(bash_state: &BashState) -> SharedPtyShell {
let mut store = lock_session_store();
store.bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
store
.resolve(&bash_state.current_thread_id, &ShellTarget::Main)
.unwrap_or_else(|| bash_state.pty_shell.clone())
}
const DEFAULT_TIMEOUT: f64 = 5.0;
const OUTPUT_WAIT_PATIENCE: i32 = 3;
fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
}
const POLL_INTERVAL_MS: u64 = 20;
const POST_PROMPT_DRAIN_MS: u64 = 100;
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;
const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
const SCREEN_DIFF_THRESHOLD: usize = 10;
fn char_safe_tail(text: &str, max_len: usize) -> &str {
if text.len() <= max_len {
return text;
}
let mut start = text.len() - max_len;
while start < text.len() && !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
if crate::utils::encoder::definitely_fits_token_budget(text, max_tokens) {
return std::borrow::Cow::Borrowed(text);
}
let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
return std::borrow::Cow::Owned(format!(
"(...truncated)\n{}",
char_safe_tail(text, MAX_OUTPUT_LENGTH)
));
};
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 = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
});
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
";
fn spawn_background_reaper(owner_thread_id: String, bg_command_id: String) {
tokio::spawn(async move {
loop {
sleep(Duration::from_millis(100)).await;
let shell_arc = {
let manager = lock_session_store();
manager.get_shell(&owner_thread_id, &bg_command_id)
};
let Some(shell_arc) = shell_arc else {
return;
};
let finished = {
let mut guard = shell_arc.lock().await;
match guard.as_mut() {
Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
None => true,
}
};
if finished {
drop(shell_arc);
let removed = {
let mut manager = lock_session_store();
manager.prune_finished_shells();
manager.get_shell(&owner_thread_id, &bg_command_id).is_none()
};
if removed {
return;
}
}
}
});
}
fn get_status(
bash_state: &BashState,
is_bg: bool,
bg_id: Option<&str>,
is_running: bool,
running_for: Option<&str>,
exit_code: Option<i32>,
reported_cwd: Option<&Path>,
) -> 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");
if let Some(code) = exit_code {
let _ = writeln!(status, "exit code = {code}");
}
}
let cwd = reported_cwd.unwrap_or(&bash_state.cwd);
let _ = writeln!(status, "cwd = {}", cwd.display());
if !is_bg {
{
let mut manager = lock_session_store();
status.push_str("This is the main shell. ");
status.push_str(&manager.get_running_info(&bash_state.current_thread_id));
}
}
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() {
let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
&text[cut..]
} 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> {
static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
#[allow(clippy::expect_used)]
let prompt_regex = PROMPT_RE
.get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
let stripped = strip_ansi_codes(output);
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(())
}
async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
ensure_interactive_target(bash)?;
bash.send_special_key("Enter")
.map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
Ok(())
}
fn ensure_interactive_target(shell: &mut PtyShell) -> Result<()> {
shell.poll_output_nonblocking();
if shell.command_running {
Ok(())
} else {
Err(WinxError::CommandExecutionError(
"No interactive command is running. Start an allowed command first; send_text/send_ascii/send_specials cannot execute commands in an idle shell."
.to_string(),
))
}
}
pub async fn handle_tool_call(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
bash_command: BashCommand,
) -> Result<String> {
handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state_arc, bash_command).await
}
pub async fn handle_tool_call_with_runtime(
runtime: &dyn ShellRuntime,
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
bash_command: BashCommand,
) -> Result<String> {
runtime.run_action(bash_state_arc, bash_command).await
}
pub(crate) async fn handle_embedded_tool_call(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
bash_command: BashCommand,
) -> Result<String> {
handle_embedded_tool_call_inner(bash_state_arc, bash_command, None).await
}
pub(crate) async fn handle_embedded_tool_call_with_cursor(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
bash_command: BashCommand,
delivery_cursor: &Arc<Mutex<ShellDeliveryCursor>>,
) -> Result<String> {
let mut delivery_cursor = delivery_cursor.lock().await;
handle_embedded_tool_call_inner(bash_state_arc, bash_command, Some(&mut delivery_cursor)).await
}
#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command, delivery_cursor))]
async fn handle_embedded_tool_call_inner(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
bash_command: BashCommand,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
let action_kind = match &bash_command.action_json {
BashCommandAction::Command { .. } => "command",
BashCommandAction::StatusCheck { .. } => "status_check",
BashCommandAction::SendText { .. } => "send_text",
BashCommandAction::SendSpecials { .. } => "send_specials",
BashCommandAction::SendAscii { .. } => "send_ascii",
BashCommandAction::Screen { .. } => "screen",
BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
};
info!(thread_id = %bash_command.thread_id, action = action_kind, "BashCommand tool called");
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 {
let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
WinxError::CommandExecutionError(format!(
"Failed to load saved bash state for thread_id `{thread_id}`: {e}"
))
})?;
if !loaded {
return Err(WinxError::ThreadIdMismatch(format!(
"Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
)));
}
if let Some(state) = bash_state_arc.lock().await.as_mut() {
*state = bash_state.clone();
}
}
lock_session_store().bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
let timeout_s = effective_wait_for_seconds(bash_command.wait_for_seconds);
let result =
execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s, delivery_cursor)
.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),
}
}
#[allow(clippy::too_many_lines)]
async fn execute_bash_action(
bash_state: &mut BashState,
action: &BashCommandAction,
timeout_s: f64,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> 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, .. }
| BashCommandAction::Screen { bg_command_id, .. }
| BashCommandAction::WaitForTurn { bg_command_id, .. } => {
if let Some(id) = bg_command_id {
let mut manager = lock_session_store();
manager.prune_finished_shells();
if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
is_bg = true;
bg_id = Some(id.clone());
Some(shell)
} else if let Some(tombstone) =
manager.peek_tombstone(&bash_state.current_thread_id, id)
{
drop(manager);
return finalize_tombstone(id, tombstone, action);
} else {
let error = format!(
"No shell found running with command id {}.\n{}",
id,
manager.get_running_info(&bash_state.current_thread_id)
);
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,
delivery_cursor,
)
.await
}
BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
execute_status_check(
bash_state,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
*scrollback_lines,
*verbose,
delivery_cursor,
)
.await
}
BashCommandAction::SendText { send_text, submit, .. } => {
execute_send_text(
bash_state,
send_text,
*submit,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
delivery_cursor,
)
.await
}
BashCommandAction::SendSpecials { send_specials, submit, .. } => {
execute_send_specials(
bash_state,
send_specials,
*submit,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
delivery_cursor,
)
.await
}
BashCommandAction::SendAscii { send_ascii, submit, .. } => {
execute_send_ascii(
bash_state,
send_ascii,
*submit,
bg_shell,
is_bg,
bg_id.as_deref(),
timeout_s,
delivery_cursor,
)
.await
}
BashCommandAction::Screen { lines, diff, .. } => {
execute_screen(
bash_state,
bg_shell,
is_bg,
bg_id.as_deref(),
*lines,
*diff,
delivery_cursor,
)
.await
}
BashCommandAction::WaitForTurn {
recognizer,
quiet_ms,
timeout_seconds,
lines,
wait_through_busy,
..
} => {
execute_wait_for_turn(
bash_state,
bg_shell,
is_bg,
bg_id.as_deref(),
recognizer.as_deref(),
*quiet_ms,
*timeout_seconds,
*lines,
*wait_through_busy,
)
.await
}
}
}
fn strip_tail_pipe(command: &str) -> String {
strip_tail_pipe_impl(command, keep_tail_pipe())
}
fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
if keep {
return command.to_string();
}
#[allow(clippy::expect_used)]
let re = RE.get_or_init(|| {
regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
.expect("tail-pipe regex must compile")
});
match re.find(command) {
Some(matched) => command[..matched.start()].trim_end().to_string(),
None => command.to_string(),
}
}
fn keep_tail_pipe() -> bool {
std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
let value = value.trim();
!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
})
}
async fn execute_command(
bash_state: &mut BashState,
command: &str,
is_background: bool,
allow_multi: bool,
timeout_s: f64,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
let stripped_command = strip_tail_pipe(command);
let command = stripped_command.as_str();
debug!(bytes = command.len(), allow_multi, "Processing Command action");
if !bash_state.is_command_allowed(command) {
error!(bytes = command.len(), "Command not allowed in current mode");
return Err(WinxError::CommandNotAllowed(
"Error: BashCommand not allowed in current mode".to_string(),
));
}
let command = command.trim();
if !allow_multi {
let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
}
if is_background {
return execute_in_background(bash_state, command, timeout_s, delivery_cursor).await;
}
let foreground_gate = bash_state.foreground_command_gate.clone();
let _foreground_guard = foreground_gate.lock_owned().await;
let shell_arc = main_shell(bash_state);
{
let bash_guard = shell_arc.lock().await;
if let Some(ref bash) = *bash_guard {
if bash.command_running {
return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
}
}
}
if shell_arc.lock().await.is_none() {
bash_state
.init_pty_shell()
.await
.map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
}
{
let needs_reset = {
if shell_arc.lock().await.is_some() {
if clear_to_run_async(&shell_arc, DEFAULT_TIMEOUT).await {
false
} else {
warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
true
}
} else {
false
}
};
if needs_reset {
if let Err(e) = bash_state.init_pty_shell().await {
warn!("Failed to reset shell after clear_to_run: {e}");
}
}
}
let scratch_root = bash_state.workspace_root.clone();
{
let mut bash_guard = shell_arc.lock().await;
let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
bash.output_buffer.clear();
bash.output_truncated = false;
bash.reset_scratch();
bash.set_scratch_root(&scratch_root);
bash.last_exit_code = None;
bash.last_returned_hash = None;
bash.mark_output_delivered("");
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;
bash.mark_command_started();
}
wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false, delivery_cursor).await
}
async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
let mut guard = shell_arc.lock().await;
match guard.as_mut() {
Some(bash) => bash.poll_output_nonblocking(),
None => true,
}
}
async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
let mut guard = shell_arc.lock().await;
guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
}
async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
let start = Instant::now();
loop {
if poll_shell(shell_arc).await {
return true;
}
if start.elapsed().as_secs_f64() >= budget_secs {
return false;
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
}
async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
return true;
}
{
let mut guard = shell_arc.lock().await;
if let Some(bash) = guard.as_mut() {
if let Err(e) = bash.send_interrupt() {
warn!("clear_to_run: failed to send Ctrl-C: {e}");
}
}
}
drain_until_prompt(shell_arc, max_wait_secs).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,
mut delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
let start = Instant::now();
let wait = timeout_s;
let (generation, legacy_delivered) = {
let guard = shell_arc.lock().await;
guard.as_ref().map_or((0, String::new()), |shell| {
(shell.command_generation(), shell.delivered_output())
})
};
let mut previously_delivered = match delivery_cursor.as_deref_mut() {
Some(cursor) => {
cursor.sync_generation(generation);
cursor.delivered_output.clone()
}
None => legacy_delivered,
};
let mut complete = false;
let mut output = String::new();
loop {
if start.elapsed().as_secs_f64() >= wait {
break;
}
complete = poll_shell(shell_arc).await;
if complete {
break;
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
if complete {
sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
poll_shell(shell_arc).await;
}
output = snapshot_shell(shell_arc).await;
if let Some(cursor) = delivery_cursor.as_deref_mut() {
let generation = {
let guard = shell_arc.lock().await;
guard.as_ref().map_or(0, PtyShell::command_generation)
};
if cursor.generation != Some(generation) {
cursor.sync_generation(generation);
previously_delivered.clear();
}
}
if !complete && is_status_check {
let budget_secs = timeout_s;
let iter_wait_secs = 0.5_f64;
let mut patience = OUTPUT_WAIT_PATIENCE;
let incremental = wcgw_incremental_text(&output, &previously_delivered);
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 done = poll_shell(shell_arc).await;
let new_output = snapshot_shell(shell_arc).await;
if done {
complete = true;
output = new_output;
break;
}
let new_incremental = wcgw_incremental_text(&new_output, &previously_delivered);
if new_incremental == last_incremental {
patience -= 1;
} else {
patience = OUTPUT_WAIT_PATIENCE; }
last_incremental = new_incremental;
output = new_output;
}
}
if complete && !is_bg {
if let Some(cwd) = extract_prompt_cwd(&output) {
bash_state.cwd = cwd;
}
}
let rendered = wcgw_incremental_text(&output, &previously_delivered);
if let Some(cursor) = delivery_cursor.as_deref_mut() {
cursor.delivered_output.clone_from(&output);
} else {
let mut guard = shell_arc.lock().await;
if let Some(shell) = guard.as_mut() {
shell.mark_output_delivered(&output);
}
}
let rendered = {
let before_lines = rendered.lines().count();
match crate::utils::output_compress::compress_output(&rendered) {
Some(compressed) => {
debug!(
from_lines = before_lines,
to_lines = compressed.lines().count(),
"winx collapsed mechanical repetition in shell output"
);
compressed
}
None => rendered,
}
};
let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
let (running_for, exit_code, shell_cwd, scratch_pointer) =
read_status_extras(shell_arc, complete).await;
let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
let status = get_status(
bash_state,
is_bg,
bg_id,
!complete,
running_for.as_deref(),
exit_code,
shell_cwd.as_deref(),
);
Ok(format!("{rendered}{status}{scratch_pointer}"))
}
async fn read_status_extras(
shell_arc: &SharedPtyShell,
complete: bool,
) -> (Option<Duration>, Option<i32>, Option<PathBuf>, String) {
let guard = shell_arc.lock().await;
let Some(shell) = guard.as_ref() else {
return (None, None, None, String::new());
};
let running_for = if complete { None } else { shell.command_elapsed() };
let exit_code = if complete { shell.last_exit_code } else { None };
let cwd = Some(shell.current_cwd().to_path_buf());
let pointer = scratch_pointer(shell.output_truncated, shell.scratch_path());
(running_for, exit_code, cwd, pointer)
}
fn scratch_pointer(output_truncated: bool, scratch_path: Option<&Path>) -> String {
match (output_truncated, scratch_path) {
(true, Some(path)) => format!(
"\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
path.display()
),
_ => String::new(),
}
}
fn finalize_tombstone(
id: &str,
tombstone: ExitedShellInfo,
action: &BashCommandAction,
) -> Result<String> {
let ExitedShellInfo {
last_command,
final_output,
exit_code,
cwd,
output_truncated,
scratch_path,
..
} = tombstone;
match action {
BashCommandAction::StatusCheck { .. }
| BashCommandAction::Screen { .. }
| BashCommandAction::WaitForTurn { .. } => {
let rendered = wcgw_incremental_text(final_output.as_ref(), "");
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");
if let Some(code) = exit_code {
let _ = writeln!(status, "exit code = {code}");
}
let _ = writeln!(status, "cwd = {}", cwd.display());
let pointer = scratch_pointer(output_truncated, scratch_path.as_deref());
Ok(format!("{rendered}{}{pointer}", 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,
mut delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
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 = lock_session_store();
let error = format!(
"No command is currently running, so there's nothing to check. The previous \
command already finished and its output was returned when it completed. Start a \
new command, or pass a bg_command_id if you launched one in the background.\n{}",
manager.get_running_info(&bash_state.current_thread_id)
);
return Err(WinxError::CommandExecutionError(error));
}
let response = wait_for_output(
bash_state,
&shell_arc,
timeout_s,
is_bg,
bg_id,
true,
delivery_cursor.as_deref_mut(),
)
.await?;
if !verbose && scrollback_lines.is_none() {
let (fingerprint, running_for, running, exit_code, cwd) = {
let guard = shell_arc.lock().await;
let Some(bash) = guard.as_ref() else {
return Err(WinxError::BashStateNotInitialized);
};
(
PtyShell::fingerprint(&bash.output_snapshot()),
bash.command_elapsed().map(|elapsed| format!("{} seconds", elapsed.as_secs())),
bash.command_running,
(!bash.command_running).then_some(bash.last_exit_code).flatten(),
bash.current_cwd().to_path_buf(),
)
};
let previous_hash = match delivery_cursor.as_deref_mut() {
Some(cursor) => cursor.last_returned_hash.replace(fingerprint),
None => {
let mut guard = shell_arc.lock().await;
guard.as_mut().and_then(|bash| bash.last_returned_hash.replace(fingerprint))
}
};
if previous_hash == Some(fingerprint) {
let status = get_status(
bash_state,
is_bg,
bg_id,
running,
running_for.as_deref(),
exit_code,
Some(&cwd),
);
return Ok(format!("no new output since last check{status}"));
}
} else if !verbose {
let fingerprint = {
let guard = shell_arc.lock().await;
guard.as_ref().map(|bash| PtyShell::fingerprint(&bash.output_snapshot()))
};
if let Some(fingerprint) = fingerprint {
if let Some(cursor) = delivery_cursor.as_deref_mut() {
cursor.last_returned_hash = Some(fingerprint);
} else {
let mut guard = shell_arc.lock().await;
if let Some(bash) = guard.as_mut() {
bash.last_returned_hash = Some(fingerprint);
}
}
}
}
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_screen(
bash_state: &BashState,
bg_shell: Option<SharedPtyShell>,
is_bg: bool,
bg_id: Option<&str>,
lines: Option<usize>,
diff: bool,
mut delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
let max_lines = lines.unwrap_or(0);
if diff {
let (update, is_running, in_alt, cursor, running_for, exit_code, cwd) = {
let mut guard = shell_arc.lock().await;
match guard.as_mut() {
Some(bash) => {
bash.poll_output_nonblocking();
let running = bash.command_running;
let update = if let Some(cursor) = delivery_cursor.as_deref_mut() {
cursor.sync_generation(bash.command_generation());
cursor.screen_update(bash.live_snapshot(max_lines), SCREEN_DIFF_THRESHOLD)
} else {
bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD)
};
(
update,
running,
bash.live_in_alt_screen(),
bash.live_cursor_position(),
running.then(|| bash.command_elapsed()).flatten(),
(!running).then_some(bash.last_exit_code).flatten(),
Some(bash.current_cwd().to_path_buf()),
)
}
None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0), None, None, None),
}
};
let (crow, ccol) = cursor;
let alt = if in_alt { " [alt-screen]" } else { "" };
let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
let status = get_status(
bash_state,
is_bg,
bg_id,
is_running,
running_for.as_deref(),
exit_code,
cwd.as_deref(),
);
let body = match update {
ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
ScreenUpdate::Diff(changed) => {
let mut out = String::from("(changed lines only)\n");
for (row, content) in changed {
let _ = writeln!(out, "{row:>4}: {content}");
}
out
}
ScreenUpdate::Full(snap) => {
let joined = snap.join("\n");
if joined.trim().is_empty() {
"(screen is empty)".to_string()
} else {
truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
}
}
};
return Ok(format!(
"--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
));
}
let (snapshot, is_running, in_alt, cursor, running_for, exit_code, cwd) = {
let mut guard = shell_arc.lock().await;
match guard.as_mut() {
Some(bash) => {
bash.poll_output_nonblocking();
let running = bash.command_running;
(
bash.live_snapshot(max_lines),
running,
bash.live_in_alt_screen(),
bash.live_cursor_position(),
running.then(|| bash.command_elapsed()).flatten(),
(!running).then_some(bash.last_exit_code).flatten(),
Some(bash.current_cwd().to_path_buf()),
)
}
None => (Vec::new(), false, false, (0, 0), None, None, None),
}
};
let joined = snapshot.join("\n");
let body = if joined.trim().is_empty() {
"(screen is empty)".to_string()
} else {
truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
};
let alt = if in_alt { " [alt-screen]" } else { "" };
let (crow, ccol) = cursor;
let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
let status = get_status(
bash_state,
is_bg,
bg_id,
is_running,
running_for.as_deref(),
exit_code,
cwd.as_deref(),
);
Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
fn wait_turn_outcome(
state: crate::state::turn::TurnState,
alive: bool,
activity: bool,
seen_busy: bool,
stable_for: Duration,
busy_for: Duration,
settle: Duration,
quiet: Duration,
timed_out: bool,
wait_through_busy: bool,
) -> Option<&'static str> {
use crate::state::turn::TurnState;
if !alive {
return Some("exited");
}
let ready = match state {
TurnState::Busy => false,
TurnState::AwaitingInput | TurnState::AwaitingApproval => {
activity && stable_for >= if seen_busy { settle } else { quiet }
}
TurnState::Unknown => activity && stable_for >= quiet,
};
if ready {
return Some("ready");
}
if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
return Some("busy");
}
if timed_out {
return Some("timeout");
}
None
}
#[allow(clippy::too_many_arguments)]
async fn execute_wait_for_turn(
bash_state: &BashState,
bg_shell: Option<SharedPtyShell>,
is_bg: bool,
bg_id: Option<&str>,
recognizer_hint: Option<&str>,
quiet_ms: Option<u64>,
timeout_seconds: Option<f32>,
lines: Option<usize>,
wait_through_busy: bool,
) -> Result<String> {
use crate::state::turn::{recognizer_for, TurnState};
let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
let settle = quiet.min(Duration::from_millis(300));
let hard_cap =
Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
let max_lines = lines.unwrap_or(0);
let poll = Duration::from_millis(120);
let warmup = Duration::from_millis(2500);
let start = Instant::now();
let mut last_hash: Option<u64> = None;
let mut initial_hash: Option<u64> = None;
let mut stable_since = Instant::now();
let mut seen_busy = false;
let mut busy_since: Option<Instant> = None;
loop {
let (snapshot, in_alt, alive, running, running_for, exit_code, cwd) = {
let mut guard = shell_arc.lock().await;
match guard.as_mut() {
Some(bash) => {
bash.poll_output_nonblocking();
let running = bash.command_running;
(
bash.live_snapshot(max_lines),
bash.live_in_alt_screen(),
bash.is_alive(),
running,
running.then(|| bash.command_elapsed()).flatten(),
(!running).then_some(bash.last_exit_code).flatten(),
Some(bash.current_cwd().to_path_buf()),
)
}
None => (Vec::new(), false, false, false, None, None, None),
}
};
let hash = PtyShell::fingerprint(&snapshot.join("\n"));
if initial_hash.is_none() {
initial_hash = Some(hash);
}
if Some(hash) != last_hash {
last_hash = Some(hash);
stable_since = Instant::now();
}
let state = recognizer.detect(&snapshot);
if state == TurnState::Busy {
seen_busy = true;
if busy_since.is_none() {
busy_since = Some(Instant::now());
}
} else {
busy_since = None;
}
let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
let stable_for = stable_since.elapsed();
let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
let timed_out = start.elapsed() >= hard_cap;
if let Some(reason) = wait_turn_outcome(
state,
alive,
activity,
seen_busy,
stable_for,
busy_for,
settle,
quiet,
timed_out,
wait_through_busy,
) {
let joined = snapshot.join("\n");
let body = if joined.trim().is_empty() {
"(screen is empty)".to_string()
} else {
truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
};
let alt = if in_alt { " [alt-screen]" } else { "" };
let header = format!(
"--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
state.as_str(),
reason,
recognizer.name(),
start.elapsed().as_secs_f64(),
alt
);
let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
let status = get_status(
bash_state,
is_bg,
bg_id,
running,
running_for.as_deref(),
exit_code,
cwd.as_deref(),
);
return Ok(format!("{header}\n{body}{status}"));
}
tokio::time::sleep(poll).await;
}
}
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,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
debug!(bytes = text.len(), submit, "Processing SendText action");
if text.is_empty() {
return Err(WinxError::CommandExecutionError(
"Failure: send_text cannot be empty".to_string(),
));
}
let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
ensure_interactive_target(bash)?;
send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
}
if submit {
submit_enter(&shell_arc).await?;
}
wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false, delivery_cursor).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,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> 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(|| main_shell(bash_state));
let mut is_interrupt = false;
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
ensure_interactive_target(bash)?;
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}"))
})?;
}
SpecialKey::CtrlZ => {
bash.send_suspend().map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
})?;
}
}
}
}
if submit {
submit_enter(&shell_arc).await?;
}
let mut output =
wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false, delivery_cursor)
.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,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
debug!(bytes = ascii_codes.len(), submit, "Processing SendAscii action");
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(|| main_shell(bash_state));
let mut is_interrupt = false;
{
let mut guard = shell_arc.lock().await;
let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
ensure_interactive_target(bash)?;
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 {
submit_enter(&shell_arc).await?;
}
let mut output =
wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false, delivery_cursor)
.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,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
debug!(bytes = command.len(), "Executing command in background");
let restricted_mode =
matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
let bg_id = {
let cwd = bash_state.cwd.clone();
let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
.await
.map_err(|e| {
WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
})?
.map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
})?;
lock_session_store().register_shell(&bash_state.current_thread_id, shell)?
};
let shell_arc = {
let manager = lock_session_store();
manager.get_shell(&bash_state.current_thread_id, &bg_id).ok_or_else(|| {
WinxError::CommandExecutionError("Failed to get background shell".to_string())
})?
};
let scratch_root = bash_state.workspace_root.clone();
let send_result = {
let mut guard = shell_arc.lock().await;
guard.as_mut().map(|bash| {
bash.set_scratch_root(&scratch_root);
bash.send_command(command)
})
};
let Some(send_result) = send_result else {
lock_session_store().remove_shell(&bg_id);
return Err(WinxError::BashStateNotInitialized);
};
if let Err(error) = send_result {
lock_session_store().remove_shell(&bg_id);
return Err(WinxError::CommandExecutionError(format!(
"Failed to send bg command: {error}"
)));
}
debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
spawn_background_reaper(bash_state.current_thread_id.clone(), bg_id.clone());
let _ = (timeout_s, delivery_cursor);
let _ = shell_arc;
Ok(get_status(bash_state, true, Some(&bg_id), true, None, None, Some(&bash_state.cwd)))
}
#[cfg(test)]
mod wait_turn_tests {
use super::wait_turn_outcome;
use crate::state::turn::TurnState;
use std::time::Duration;
const SETTLE: Duration = Duration::from_millis(300);
const QUIET: Duration = Duration::from_millis(600);
fn call(
state: TurnState,
busy_for: Duration,
stable_for: Duration,
seen_busy: bool,
timed_out: bool,
wait_through_busy: bool,
) -> Option<&'static str> {
wait_turn_outcome(
state,
true,
true,
seen_busy,
stable_for,
busy_for,
SETTLE,
QUIET,
timed_out,
wait_through_busy,
)
}
#[test]
fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
assert_eq!(out, Some("busy"));
}
#[test]
fn busy_not_yet_confirmed_keeps_waiting() {
let out =
call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
assert_eq!(out, None);
}
#[test]
fn wait_through_busy_blocks_through_busy_until_timeout() {
let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
assert_eq!(out, None);
let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
assert_eq!(out, Some("timeout"));
}
#[test]
fn awaiting_input_after_busy_is_ready_on_short_settle() {
let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
assert_eq!(out, Some("ready"));
}
#[test]
fn awaiting_input_without_prior_busy_needs_full_quiet() {
let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
assert_eq!(short, None);
let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
assert_eq!(full, Some("ready"));
}
#[test]
fn dead_shell_reports_exited_even_if_busy() {
let out = wait_turn_outcome(
TurnState::Busy,
false,
true,
true,
Duration::ZERO,
SETTLE,
SETTLE,
QUIET,
false,
false,
);
assert_eq!(out, Some("exited"));
}
#[test]
fn nothing_happening_keeps_waiting() {
let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
assert_eq!(out, None);
}
}
#[cfg(test)]
mod tests {
use super::{effective_wait_for_seconds, strip_tail_pipe_impl, truncate_to_token_budget};
#[test]
fn strips_trailing_tail_by_default() {
assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
}
#[test]
fn keeps_command_without_trailing_tail() {
assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
assert_eq!(
strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
"cat a | tail -5 | wc -l"
);
}
#[test]
fn keep_mode_preserves_tail_pipe() {
assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
}
#[test]
fn requested_wait_is_not_silently_capped() {
assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
}
#[test]
fn token_budget_applies_below_the_byte_fallback_cap() {
let input = "alpha beta gamma delta ".repeat(100);
let rendered = truncate_to_token_budget(&input, 20);
assert!(rendered.starts_with("(...truncated)\n"));
assert_ne!(rendered.as_ref(), input);
let tail = rendered.strip_prefix("(...truncated)\n").unwrap_or_default();
let tail_tokens = crate::utils::encoder::encode_ids(tail);
assert!(tail_tokens.as_ref().is_some_and(|tokens| tokens.len() <= 19));
}
}