use std::fmt::Write as FmtWrite;
use std::time::{Duration, Instant};
use super::output::{get_status, truncate_to_token_budget, MAX_OUTPUT_TOKENS};
use super::{main_shell, SharedPtyShell, ShellDeliveryCursor};
use crate::errors::Result;
use crate::state::bash_state::BashState;
use crate::state::live_terminal::ScreenUpdate;
use crate::state::pty::PtyShell;
const SCREEN_DIFF_THRESHOLD: usize = 10;
#[allow(clippy::too_many_lines)]
pub(super) async fn execute_screen(
bash_state: &BashState,
background_shell: Option<SharedPtyShell>,
is_background: bool,
background_id: Option<&str>,
lines: Option<usize>,
diff: bool,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
let shell = background_shell.unwrap_or_else(|| main_shell(bash_state));
let max_lines = lines.unwrap_or(0);
if diff {
let (update, running, in_alt, cursor, running_for, exit_code, cwd) = {
let mut guard = shell.lock().await;
match guard.as_mut() {
Some(shell) => {
shell.poll_output_nonblocking();
let running = shell.command_running;
let update = if let Some(cursor) = delivery_cursor {
cursor.sync_generation(shell.command_generation());
cursor.screen_update(shell.live_snapshot(max_lines), SCREEN_DIFF_THRESHOLD)
} else {
shell.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD)
};
(
update,
running,
shell.live_in_alt_screen(),
shell.live_cursor_position(),
running.then(|| shell.command_elapsed()).flatten(),
(!running).then_some(shell.last_exit_code).flatten(),
Some(shell.current_cwd().to_path_buf()),
)
}
None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0), None, None, None),
}
};
let (cursor_row, cursor_column) = 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_background,
background_id,
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 output = String::from("(changed lines only)\n");
for (row, content) in changed {
let _ = writeln!(output, "{row:>4}: {content}");
}
output
}
ScreenUpdate::Full(snapshot) => render_snapshot(&snapshot),
};
return Ok(format!(
"--- live screen{alt} [cursor row={cursor_row} col={cursor_column}] (diff) ---\n{body}{status}"
));
}
let (snapshot, running, in_alt, cursor, running_for, exit_code, cwd) = {
let mut guard = shell.lock().await;
match guard.as_mut() {
Some(shell) => {
shell.poll_output_nonblocking();
let running = shell.command_running;
(
shell.live_snapshot(max_lines),
running,
shell.live_in_alt_screen(),
shell.live_cursor_position(),
running.then(|| shell.command_elapsed()).flatten(),
(!running).then_some(shell.last_exit_code).flatten(),
Some(shell.current_cwd().to_path_buf()),
)
}
None => (Vec::new(), false, false, (0, 0), None, None, None),
}
};
let body = render_snapshot(&snapshot);
let alt = if in_alt { " [alt-screen]" } else { "" };
let (cursor_row, cursor_column) = cursor;
let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
let status = get_status(
bash_state,
is_background,
background_id,
running,
running_for.as_deref(),
exit_code,
cwd.as_deref(),
);
Ok(format!(
"--- live screen{alt} [cursor row={cursor_row} col={cursor_column}] ---\n{body}{status}"
))
}
fn render_snapshot(snapshot: &[String]) -> String {
let joined = snapshot.join("\n");
if joined.trim().is_empty() {
"(screen is empty)".to_string()
} else {
truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
}
}
#[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)]
pub(super) async fn execute_wait_for_turn(
bash_state: &BashState,
background_shell: Option<SharedPtyShell>,
is_background: bool,
background_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 = background_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.lock().await;
match guard.as_mut() {
Some(shell) => {
shell.poll_output_nonblocking();
let running = shell.command_running;
(
shell.live_snapshot(max_lines),
shell.live_in_alt_screen(),
shell.is_alive(),
running,
running.then(|| shell.command_elapsed()).flatten(),
(!running).then_some(shell.last_exit_code).flatten(),
Some(shell.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 body = render_snapshot(&snapshot);
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_background,
background_id,
running,
running_for.as_deref(),
exit_code,
cwd.as_deref(),
);
return Ok(format!("{header}\n{body}{status}"));
}
tokio::time::sleep(poll).await;
}
}
#[cfg(test)]
mod 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() {
assert_eq!(call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false), Some("busy"));
}
#[test]
fn busy_not_yet_confirmed_keeps_waiting() {
assert_eq!(
call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false,),
None
);
}
#[test]
fn wait_through_busy_blocks_through_busy_until_timeout() {
assert_eq!(call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true), None);
assert_eq!(
call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true),
Some("timeout")
);
}
#[test]
fn awaiting_input_after_busy_is_ready_on_short_settle() {
assert_eq!(
call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false),
Some("ready")
);
}
#[test]
fn awaiting_input_without_prior_busy_needs_full_quiet() {
assert_eq!(
call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false),
None
);
assert_eq!(
call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false),
Some("ready")
);
}
#[test]
fn dead_shell_reports_exited_even_if_busy() {
assert_eq!(
wait_turn_outcome(
TurnState::Busy,
false,
true,
true,
Duration::ZERO,
SETTLE,
SETTLE,
QUIET,
false,
false,
),
Some("exited")
);
}
#[test]
fn nothing_happening_keeps_waiting() {
assert_eq!(
call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false,),
None
);
}
}