1use 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::sync::{Arc, Mutex as StdMutex};
14use std::time::{Duration, Instant};
15use tokio::sync::Mutex;
16use tokio::time::sleep;
17use tracing::{debug, error, info, warn};
18
19use crate::errors::{Result, WinxError};
20use crate::state::bash_state::BashState;
21use crate::state::live_terminal::ScreenUpdate;
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
28const DEFAULT_TIMEOUT: f64 = 5.0;
32
33const TIMEOUT_WHILE_OUTPUT: f64 = 20.0;
35
36const OUTPUT_WAIT_PATIENCE: i32 = 3;
38
39const POLL_INTERVAL_MS: u64 = 20;
43
44const POST_PROMPT_DRAIN_MS: u64 = 100;
47
48const COMMAND_CHUNK_SIZE: usize = 64;
50
51const TEXT_CHUNK_SIZE: usize = 128;
53
54const MAX_OUTPUT_LENGTH: usize = 100_000;
58
59const MAX_OUTPUT_TOKENS: usize = 25_000;
64
65const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
72
73const SCREEN_DIFF_THRESHOLD: usize = 10;
76
77fn char_safe_tail(text: &str, max_len: usize) -> &str {
80 if text.len() <= max_len {
81 return text;
82 }
83 let mut start = text.len() - max_len;
84 while start < text.len() && !text.is_char_boundary(start) {
85 start += 1;
86 }
87 &text[start..]
88}
89
90fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
98 if text.len() <= MAX_OUTPUT_LENGTH {
99 return std::borrow::Cow::Borrowed(text);
100 }
101
102 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
103 return std::borrow::Cow::Owned(format!(
105 "(...truncated)\n{}",
106 char_safe_tail(text, MAX_OUTPUT_LENGTH)
107 ));
108 };
109
110 if tokens.len() <= max_tokens {
111 return std::borrow::Cow::Borrowed(text);
112 }
113
114 let keep = max_tokens.saturating_sub(1);
116 let tail = &tokens[tokens.len() - keep..];
117 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
118 char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
120 });
121 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
122}
123
124const 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.
1261. Get its output using status check.
1272. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1283. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1294. Interrupt and run the process in background
130";
131
132#[derive(Debug, Clone)]
138pub struct ExitedShellInfo {
139 pub last_command: String,
140 pub final_output: String,
141 pub exited_at: Instant,
142}
143
144#[derive(Debug, Default)]
146pub struct BackgroundShellManager {
147 shells: HashMap<String, SharedPtyShell>,
148 tombstones: HashMap<String, ExitedShellInfo>,
151}
152
153impl BackgroundShellManager {
154 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
156
157 pub fn new() -> Self {
159 Self { shells: HashMap::new(), tombstones: HashMap::new() }
160 }
161
162 pub fn register_shell(&mut self, shell: PtyShell) -> String {
170 let cid = format!("{:010x}", rand::rng().random::<u32>());
171 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
172 info!("Started background shell with id: {}", cid);
173 cid
174 }
175
176 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
178 self.shells.get(bg_command_id).cloned()
179 }
180
181 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
183 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
184 if let Ok(mut guard) = shell_arc.try_lock() {
185 *guard = None;
186 }
187 info!("Removed background shell: {}", bg_command_id);
188 true
189 } else {
190 false
191 }
192 }
193
194 fn prune_finished_shells(&mut self) {
195 let now = Instant::now();
197 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
198
199 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
200
201 for (id, shell_arc) in &self.shells {
202 let Ok(mut guard) = shell_arc.try_lock() else {
203 continue;
204 };
205
206 let Some(shell) = guard.as_mut() else {
207 finished.push((id.clone(), None));
208 continue;
209 };
210
211 if !shell.is_alive() {
212 let tombstone = ExitedShellInfo {
213 last_command: shell.last_command.clone(),
214 final_output: std::mem::take(&mut shell.output_buffer),
215 exited_at: now,
216 };
217 finished.push((id.clone(), Some(tombstone)));
218 continue;
219 }
220
221 if shell.last_command.is_empty() {
226 continue;
227 }
228
229 if shell.command_running {
230 shell.poll_output_nonblocking();
234 }
235
236 if !shell.command_running {
237 let tombstone = ExitedShellInfo {
238 last_command: shell.last_command.clone(),
239 final_output: std::mem::take(&mut shell.output_buffer),
240 exited_at: now,
241 };
242 finished.push((id.clone(), Some(tombstone)));
243 }
244 }
245
246 for (id, tombstone) in finished {
247 self.remove_shell(&id);
248 if let Some(info) = tombstone {
249 self.tombstones.insert(id, info);
250 }
251 }
252 }
253
254 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
261 self.tombstones.get(bg_command_id).cloned()
262 }
263
264 pub fn get_running_info(&mut self) -> String {
266 self.prune_finished_shells();
267
268 if self.shells.is_empty() {
269 return "No command running in background.\n".to_string();
270 }
271
272 let mut running = Vec::new();
273 for (id, shell_arc) in &self.shells {
274 if let Ok(guard) = shell_arc.try_lock() {
275 if let Some(bash) = guard.as_ref() {
276 if bash.command_running {
277 running
278 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
279 }
280 }
281 } else {
282 running.push(format!("Command: <busy>, bg_command_id: {id}"));
283 }
284 }
285
286 if running.is_empty() {
287 "No command running in background.\n".to_string()
288 } else {
289 format!("Following background commands are attached:\n{}\n", running.join("\n"))
290 }
291 }
292}
293
294lazy_static::lazy_static! {
296 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
297}
298
299fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
306 BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
307}
308
309fn get_status(
313 bash_state: &BashState,
314 is_bg: bool,
315 bg_id: Option<&str>,
316 is_running: bool,
317 running_for: Option<&str>,
318 exit_code: Option<i32>,
319) -> String {
320 let mut status = "\n\n---\n\n".to_string();
321
322 if is_bg {
323 if let Some(id) = bg_id {
324 let _ = writeln!(status, "bg_command_id = {id}");
325 }
326 }
327
328 if is_running {
329 status.push_str("status = still running\n");
330 if let Some(duration) = running_for {
331 let _ = writeln!(status, "running for = {duration}");
332 }
333 } else {
334 status.push_str("status = process exited\n");
335 if let Some(code) = exit_code {
338 let _ = writeln!(status, "exit code = {code}");
339 }
340 }
341
342 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
343
344 if !is_bg {
345 {
347 let mut manager = lock_bg_manager();
348 status.push_str("This is the main shell. ");
349 status.push_str(&manager.get_running_info());
350 }
351 }
352
353 status.trim_end().to_string()
354}
355
356fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
358 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
359 let text = truncated.as_ref();
360
361 if last_pending_output.is_empty() {
362 let rendered = render_terminal_output(text);
363 return rstrip_lines(&rendered).trim_start().to_string();
364 }
365
366 let last_rendered = render_terminal_output(last_pending_output);
367 if last_rendered.is_empty() {
368 return rstrip_lines(&render_terminal_output(text));
369 }
370
371 let text_after_last = if text.len() > last_pending_output.len() {
375 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
376 &text[cut..]
377 } else {
378 text
379 };
380
381 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
382 let new_rendered = render_terminal_output(&combined);
383
384 let incremental = get_incremental_output(&last_rendered, &new_rendered);
386 rstrip_lines(&incremental)
387}
388
389fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
390 static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
391 #[allow(clippy::expect_used)]
396 let prompt_regex = PROMPT_RE
397 .get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
398 let stripped = strip_ansi_codes(output);
399
400 prompt_regex
401 .captures_iter(&stripped)
402 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
403 .filter(|cwd| !cwd.is_empty())
404 .last()
405 .map(PathBuf::from)
406}
407
408fn rstrip_lines(lines: &[String]) -> String {
410 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
411}
412
413fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
415 if old_output.is_empty() {
416 return new_output.to_vec();
417 }
418
419 let nold = old_output.len();
420 let nnew = new_output.len();
421
422 for i in (0..nnew).rev() {
424 if new_output[i] != old_output[nold - 1] {
425 continue;
426 }
427
428 let mut matched = true;
429 for j in (0..i).rev() {
430 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
431 if old_idx < 0 {
432 break;
433 }
434 if new_output[j] != old_output[old_idx as usize] {
435 matched = false;
436 break;
437 }
438 }
439
440 if matched {
441 return new_output[i + 1..].to_vec();
442 }
443 }
444
445 new_output.to_vec()
446}
447
448fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
449 let mut start = 0;
450
451 while start < text.len() {
452 let mut end = (start + chunk_size).min(text.len());
453 while !text.is_char_boundary(end) {
454 end -= 1;
455 }
456 if end == start {
457 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
458 }
459
460 shell.send_text(&text[start..end]).map_err(|e| {
461 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
462 })?;
463 start = end;
464 }
465
466 Ok(())
467}
468
469async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
474 tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
475 let mut guard = shell_arc.lock().await;
476 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
477 bash.send_special_key("Enter")
478 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
479 Ok(())
480}
481
482#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
489pub async fn handle_tool_call(
490 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
491 bash_command: BashCommand,
492) -> Result<String> {
493 info!("BashCommand tool called with: {:?}", bash_command);
494
495 let thread_id = normalize_thread_id(&bash_command.thread_id);
496
497 if thread_id.is_empty() {
499 error!("Empty thread_id provided in BashCommand");
500 return Err(WinxError::ThreadIdMismatch(
501 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
502 ));
503 }
504
505 let mut bash_state: BashState;
507 {
508 let bash_state_guard = bash_state_arc.lock().await;
509
510 let Some(state) = &*bash_state_guard else {
511 error!("BashState not initialized");
512 return Err(WinxError::BashStateNotInitialized);
513 };
514
515 bash_state = state.clone();
516 }
517
518 if thread_id != bash_state.current_thread_id {
520 let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
525 WinxError::CommandExecutionError(format!(
526 "Failed to load saved bash state for thread_id `{thread_id}`: {e}"
527 ))
528 })?;
529 if !loaded {
530 return Err(WinxError::ThreadIdMismatch(format!(
531 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
532 )));
533 }
534 if let Some(state) = bash_state_arc.lock().await.as_mut() {
540 *state = bash_state.clone();
541 }
542 }
543
544 let timeout_s = bash_command
547 .wait_for_seconds
548 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
549 .min(TIMEOUT_WHILE_OUTPUT);
550
551 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
553
554 {
555 let mut bash_state_guard = bash_state_arc.lock().await;
556 if let Some(state) = bash_state_guard.as_mut() {
557 state.cwd.clone_from(&bash_state.cwd);
558 }
559 }
560
561 match result {
563 Ok(mut output) => {
564 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
565 let cmd_trimmed = command.trim();
566 if output.starts_with(cmd_trimmed) {
567 output = output[cmd_trimmed.len()..].to_string();
568 }
569 }
570 Ok(output)
571 }
572 Err(e) => Err(e),
573 }
574}
575
576#[allow(clippy::too_many_lines)]
578async fn execute_bash_action(
579 bash_state: &mut BashState,
580 action: &BashCommandAction,
581 timeout_s: f64,
582) -> Result<String> {
583 let mut is_bg = false;
584 let mut bg_id: Option<String> = None;
585
586 let bg_shell: Option<SharedPtyShell> = match action {
588 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
590 | BashCommandAction::SendText { bg_command_id, .. }
591 | BashCommandAction::SendSpecials { bg_command_id, .. }
592 | BashCommandAction::SendAscii { bg_command_id, .. }
593 | BashCommandAction::Screen { bg_command_id, .. }
594 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
595 if let Some(id) = bg_command_id {
596 let mut manager = lock_bg_manager();
599 manager.prune_finished_shells();
600
601 if let Some(shell) = manager.get_shell(id) {
602 is_bg = true;
603 bg_id = Some(id.clone());
604 Some(shell)
605 } else if let Some(tombstone) = manager.peek_tombstone(id) {
606 drop(manager);
611 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
612 } else {
613 let error = format!(
615 "No shell found running with command id {}.\n{}",
616 id,
617 manager.get_running_info()
618 );
619 return Err(WinxError::CommandExecutionError(error));
620 }
621 } else {
622 None
623 }
624 }
625 };
626
627 match action {
629 BashCommandAction::Command { command, is_background, allow_multi } => {
630 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
631 }
632 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
633 execute_status_check(
634 bash_state,
635 bg_shell,
636 is_bg,
637 bg_id.as_deref(),
638 timeout_s,
639 *scrollback_lines,
640 *verbose,
641 )
642 .await
643 }
644 BashCommandAction::SendText { send_text, submit, .. } => {
645 execute_send_text(
646 bash_state,
647 send_text,
648 *submit,
649 bg_shell,
650 is_bg,
651 bg_id.as_deref(),
652 timeout_s,
653 )
654 .await
655 }
656 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
657 execute_send_specials(
658 bash_state,
659 send_specials,
660 *submit,
661 bg_shell,
662 is_bg,
663 bg_id.as_deref(),
664 timeout_s,
665 )
666 .await
667 }
668 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
669 execute_send_ascii(
670 bash_state,
671 send_ascii,
672 *submit,
673 bg_shell,
674 is_bg,
675 bg_id.as_deref(),
676 timeout_s,
677 )
678 .await
679 }
680 BashCommandAction::Screen { lines, diff, .. } => {
681 execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
682 }
683 BashCommandAction::WaitForTurn {
684 recognizer,
685 quiet_ms,
686 timeout_seconds,
687 lines,
688 wait_through_busy,
689 ..
690 } => {
691 execute_wait_for_turn(
692 bash_state,
693 bg_shell,
694 is_bg,
695 bg_id.as_deref(),
696 recognizer.as_deref(),
697 *quiet_ms,
698 *timeout_seconds,
699 *lines,
700 *wait_through_busy,
701 )
702 .await
703 }
704 }
705}
706
707fn strip_tail_pipe(command: &str) -> String {
717 strip_tail_pipe_impl(command, keep_tail_pipe())
718}
719
720fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
723 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
724 if keep {
725 return command.to_string();
726 }
727 #[allow(clippy::expect_used)]
731 let re = RE.get_or_init(|| {
732 regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
733 .expect("tail-pipe regex must compile")
734 });
735 match re.find(command) {
736 Some(matched) => command[..matched.start()].trim_end().to_string(),
737 None => command.to_string(),
738 }
739}
740
741fn keep_tail_pipe() -> bool {
743 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
744 let value = value.trim();
745 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
746 })
747}
748
749async fn execute_command(
751 bash_state: &mut BashState,
752 command: &str,
753 is_background: bool,
754 allow_multi: bool,
755 timeout_s: f64,
756) -> Result<String> {
757 let stripped_command = strip_tail_pipe(command);
759 let command = stripped_command.as_str();
760 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
761
762 if !bash_state.is_command_allowed(command) {
764 error!("Command '{}' not allowed in current mode", command);
765 return Err(WinxError::CommandNotAllowed(
766 "Error: BashCommand not allowed in current mode".to_string(),
767 ));
768 }
769
770 let command = command.trim();
774 if !allow_multi {
775 let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
779 crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
780 }
781
782 if is_background {
784 return execute_in_background(bash_state, command, timeout_s).await;
785 }
786
787 {
789 let bash_guard = bash_state.pty_shell.lock().await;
790
791 if let Some(ref bash) = *bash_guard {
792 if bash.command_running {
793 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
794 }
795 }
796 }
797
798 if bash_state.pty_shell.lock().await.is_none() {
800 bash_state
801 .init_pty_shell()
802 .await
803 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
804 }
805
806 {
811 let needs_reset = {
812 if bash_state.pty_shell.lock().await.is_some() {
816 if clear_to_run_async(&bash_state.pty_shell, DEFAULT_TIMEOUT).await {
817 false
818 } else {
819 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
820 true
821 }
822 } else {
823 false
824 }
825 };
826 if needs_reset {
830 if let Err(e) = bash_state.init_pty_shell().await {
831 warn!("Failed to reset shell after clear_to_run: {e}");
832 }
833 }
834 }
835
836 let scratch_root = bash_state.workspace_root.clone();
838 {
839 let mut bash_guard = bash_state.pty_shell.lock().await;
840
841 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
842
843 bash.output_buffer.clear();
844 bash.output_truncated = false;
845 bash.reset_scratch();
848 bash.set_scratch_root(&scratch_root);
849 bash.last_exit_code = None;
854 bash.last_returned_hash = None;
855 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
857
858 bash.send_special_key("Enter").map_err(|e| {
860 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
861 })?;
862
863 bash.last_command = command.to_string();
864 bash.command_running = true;
865 }
866
867 let shell_arc = bash_state.pty_shell.clone();
869 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
870}
871
872async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
879 let mut guard = shell_arc.lock().await;
880 match guard.as_mut() {
881 Some(bash) => bash.poll_output_nonblocking(),
882 None => true,
883 }
884}
885
886async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
888 let mut guard = shell_arc.lock().await;
889 guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
890}
891
892async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
896 let start = Instant::now();
897 loop {
898 if poll_shell(shell_arc).await {
899 return true;
900 }
901 if start.elapsed().as_secs_f64() >= budget_secs {
902 return false;
903 }
904 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
905 }
906}
907
908async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
915 if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
917 return true;
918 }
919 {
921 let mut guard = shell_arc.lock().await;
922 if let Some(bash) = guard.as_mut() {
923 if let Err(e) = bash.send_interrupt() {
924 warn!("clear_to_run: failed to send Ctrl-C: {e}");
925 }
926 }
927 }
928 drain_until_prompt(shell_arc, max_wait_secs).await
929}
930
931async fn wait_for_output(
933 bash_state: &mut BashState,
934 shell_arc: &SharedPtyShell,
935 timeout_s: f64,
936 is_bg: bool,
937 bg_id: Option<&str>,
938 is_status_check: bool,
939) -> Result<String> {
940 let start = Instant::now();
941 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
942 let mut last_pending_output = String::new();
943 let mut complete = false;
944
945 let mut output = String::new();
957 loop {
958 if start.elapsed().as_secs_f64() >= wait {
959 break;
960 }
961 complete = poll_shell(shell_arc).await;
962 if complete {
963 break;
964 }
965 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
966 }
967 if complete {
970 sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
971 poll_shell(shell_arc).await;
972 }
973 output = snapshot_shell(shell_arc).await;
974
975 if !complete && is_status_check {
984 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
985 let iter_wait_secs = 0.5_f64;
986 let mut patience = OUTPUT_WAIT_PATIENCE;
987
988 let incremental = wcgw_incremental_text(&output, &last_pending_output);
989 if incremental.is_empty() {
990 patience -= 1;
991 }
992
993 let mut last_incremental = incremental;
994
995 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
996 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
997 if remaining < 0.1 {
998 break;
999 }
1000 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
1001
1002 let done = poll_shell(shell_arc).await;
1006 let new_output = snapshot_shell(shell_arc).await;
1007
1008 if done {
1009 complete = true;
1010 output = new_output;
1011 break;
1012 }
1013
1014 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
1016 if new_incremental == last_incremental {
1017 patience -= 1;
1018 } else {
1019 patience = OUTPUT_WAIT_PATIENCE; }
1021 last_incremental = new_incremental;
1022
1023 output = new_output;
1024 }
1025
1026 if !complete {
1027 last_pending_output = output.clone();
1029 }
1030 }
1031
1032 if complete {
1033 if let Some(cwd) = extract_prompt_cwd(&output) {
1034 bash_state.cwd = cwd;
1035 }
1036 }
1037
1038 let rendered = wcgw_incremental_text(&output, &last_pending_output);
1040
1041 let rendered = {
1045 let before_lines = rendered.lines().count();
1046 match crate::utils::output_compress::compress_output(&rendered) {
1047 Some(compressed) => {
1048 debug!(
1049 from_lines = before_lines,
1050 to_lines = compressed.lines().count(),
1051 "winx collapsed mechanical repetition in shell output"
1052 );
1053 compressed
1054 }
1055 None => rendered,
1056 }
1057 };
1058
1059 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1061
1062 let running_for =
1064 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
1065
1066 let (exit_code, scratch_pointer) = read_completion_extras(shell_arc, complete).await;
1071
1072 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref(), exit_code);
1074 Ok(format!("{rendered}{status}{scratch_pointer}"))
1075}
1076
1077async fn read_completion_extras(
1081 shell_arc: &SharedPtyShell,
1082 complete: bool,
1083) -> (Option<i32>, String) {
1084 let guard = shell_arc.lock().await;
1085 let Some(shell) = guard.as_ref() else {
1086 return (None, String::new());
1087 };
1088 let exit_code = if complete { shell.last_exit_code } else { None };
1089 let pointer = match (shell.output_truncated, shell.scratch_path()) {
1090 (true, Some(path)) => format!(
1091 "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1092 saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1093 path.display()
1094 ),
1095 _ => String::new(),
1096 };
1097 (exit_code, pointer)
1098}
1099
1100fn finalize_tombstone(
1107 cwd: &Path,
1108 id: &str,
1109 tombstone: ExitedShellInfo,
1110 action: &BashCommandAction,
1111) -> Result<String> {
1112 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
1113 match action {
1114 BashCommandAction::StatusCheck { .. }
1117 | BashCommandAction::Screen { .. }
1118 | BashCommandAction::WaitForTurn { .. } => {
1119 let rendered = wcgw_incremental_text(&final_output, "");
1120 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1121 let mut status = "\n\n---\n\n".to_string();
1123 let _ = writeln!(status, "bg_command_id = {id}");
1124 status.push_str("status = process exited\n");
1125 let _ = writeln!(status, "cwd = {}", cwd.display());
1126 Ok(format!("{rendered}{}", status.trim_end()))
1127 }
1128 BashCommandAction::SendText { .. }
1129 | BashCommandAction::SendSpecials { .. }
1130 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1131 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1132 ))),
1133 BashCommandAction::Command { .. } => {
1134 unreachable!("finalize_tombstone called for non-bg action")
1137 }
1138 }
1139}
1140
1141async fn execute_status_check(
1150 bash_state: &mut BashState,
1151 bg_shell: Option<SharedPtyShell>,
1152 is_bg: bool,
1153 bg_id: Option<&str>,
1154 timeout_s: f64,
1155 scrollback_lines: Option<usize>,
1156 verbose: bool,
1157) -> Result<String> {
1158 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1159
1160 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1163
1164 let is_running = {
1166 let guard = shell_arc.lock().await;
1167 if let Some(ref bash) = *guard {
1168 bash.command_running
1169 } else {
1170 false
1171 }
1172 };
1173
1174 if !is_running && !is_bg {
1176 let mut manager = lock_bg_manager();
1177 let error = format!(
1178 "No command is currently running, so there's nothing to check. The previous \
1179 command already finished and its output was returned when it completed. Start a \
1180 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1181 manager.get_running_info()
1182 );
1183 return Err(WinxError::CommandExecutionError(error));
1184 }
1185
1186 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1188
1189 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1193 if !verbose && scrollback_lines.is_none() {
1194 let mut guard = shell_arc.lock().await;
1195 if let Some(bash) = guard.as_mut() {
1196 let fingerprint = PtyShell::fingerprint(body);
1197 if Some(fingerprint) == bash.last_returned_hash {
1198 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1199 return Ok(format!("no new output since last check{status}"));
1200 }
1201 bash.last_returned_hash = Some(fingerprint);
1202 }
1203 } else if !verbose {
1204 let mut guard = shell_arc.lock().await;
1206 if let Some(bash) = guard.as_mut() {
1207 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1208 }
1209 }
1210
1211 if let Some(lines) = scrollback_lines {
1213 if lines > 0 {
1214 let scrollback = {
1215 let guard = shell_arc.lock().await;
1216 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1217 };
1218 if !scrollback.is_empty() {
1219 let count = scrollback.lines().count();
1220 return Ok(format!(
1221 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1222 ));
1223 }
1224 }
1225 }
1226
1227 Ok(response)
1228}
1229
1230async fn execute_screen(
1234 bash_state: &BashState,
1235 bg_shell: Option<SharedPtyShell>,
1236 is_bg: bool,
1237 bg_id: Option<&str>,
1238 lines: Option<usize>,
1239 diff: bool,
1240) -> Result<String> {
1241 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1242 let max_lines = lines.unwrap_or(0);
1243
1244 if diff {
1245 let (update, is_running, in_alt, cursor) = {
1246 let guard = shell_arc.lock().await;
1247 match guard.as_ref() {
1248 Some(bash) => (
1249 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1250 bash.command_running,
1251 bash.live_in_alt_screen(),
1252 bash.live_cursor_position(),
1253 ),
1254 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1255 }
1256 };
1257 let (crow, ccol) = cursor;
1258 let alt = if in_alt { " [alt-screen]" } else { "" };
1259 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1260 let body = match update {
1261 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1262 ScreenUpdate::Diff(changed) => {
1263 let mut out = String::from("(changed lines only)\n");
1264 for (row, content) in changed {
1265 let _ = writeln!(out, "{row:>4}: {content}");
1266 }
1267 out
1268 }
1269 ScreenUpdate::Full(snap) => {
1270 let joined = snap.join("\n");
1271 if joined.trim().is_empty() {
1272 "(screen is empty)".to_string()
1273 } else {
1274 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1275 }
1276 }
1277 };
1278 return Ok(format!(
1279 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1280 ));
1281 }
1282
1283 let (snapshot, is_running, in_alt, cursor) = {
1284 let guard = shell_arc.lock().await;
1285 match guard.as_ref() {
1286 Some(bash) => (
1287 bash.live_snapshot(max_lines),
1288 bash.command_running,
1289 bash.live_in_alt_screen(),
1290 bash.live_cursor_position(),
1291 ),
1292 None => (Vec::new(), false, false, (0, 0)),
1293 }
1294 };
1295
1296 let joined = snapshot.join("\n");
1297 let body = if joined.trim().is_empty() {
1298 "(screen is empty)".to_string()
1299 } else {
1300 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1301 };
1302 let alt = if in_alt { " [alt-screen]" } else { "" };
1303 let (crow, ccol) = cursor;
1304 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1305 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1306}
1307
1308#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1317fn wait_turn_outcome(
1318 state: crate::state::turn::TurnState,
1319 alive: bool,
1320 activity: bool,
1321 seen_busy: bool,
1322 stable_for: Duration,
1323 busy_for: Duration,
1324 settle: Duration,
1325 quiet: Duration,
1326 timed_out: bool,
1327 wait_through_busy: bool,
1328) -> Option<&'static str> {
1329 use crate::state::turn::TurnState;
1330 if !alive {
1331 return Some("exited");
1332 }
1333 let ready = match state {
1334 TurnState::Busy => false,
1335 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1338 activity && stable_for >= if seen_busy { settle } else { quiet }
1339 }
1340 TurnState::Unknown => activity && stable_for >= quiet,
1341 };
1342 if ready {
1343 return Some("ready");
1344 }
1345 if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
1350 return Some("busy");
1351 }
1352 if timed_out {
1353 return Some("timeout");
1354 }
1355 None
1356}
1357
1358#[allow(clippy::too_many_arguments)]
1368async fn execute_wait_for_turn(
1369 bash_state: &BashState,
1370 bg_shell: Option<SharedPtyShell>,
1371 is_bg: bool,
1372 bg_id: Option<&str>,
1373 recognizer_hint: Option<&str>,
1374 quiet_ms: Option<u64>,
1375 timeout_seconds: Option<f32>,
1376 lines: Option<usize>,
1377 wait_through_busy: bool,
1378) -> Result<String> {
1379 use crate::state::turn::{recognizer_for, TurnState};
1380
1381 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1382 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1383 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1384 let settle = quiet.min(Duration::from_millis(300));
1385 let hard_cap =
1386 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1387 let max_lines = lines.unwrap_or(0);
1388 let poll = Duration::from_millis(120);
1389 let warmup = Duration::from_millis(2500);
1392
1393 let start = Instant::now();
1394 let mut last_hash: Option<u64> = None;
1395 let mut initial_hash: Option<u64> = None;
1396 let mut stable_since = Instant::now();
1397 let mut seen_busy = false;
1398 let mut busy_since: Option<Instant> = None;
1399
1400 loop {
1401 let (snapshot, in_alt, alive, running) = {
1402 let mut guard = shell_arc.lock().await;
1403 match guard.as_mut() {
1404 Some(bash) => (
1405 bash.live_snapshot(max_lines),
1406 bash.live_in_alt_screen(),
1407 bash.is_alive(),
1408 bash.command_running,
1409 ),
1410 None => (Vec::new(), false, false, false),
1411 }
1412 };
1413
1414 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1415 if initial_hash.is_none() {
1416 initial_hash = Some(hash);
1417 }
1418 if Some(hash) != last_hash {
1419 last_hash = Some(hash);
1420 stable_since = Instant::now();
1421 }
1422
1423 let state = recognizer.detect(&snapshot);
1424 if state == TurnState::Busy {
1425 seen_busy = true;
1426 if busy_since.is_none() {
1427 busy_since = Some(Instant::now());
1428 }
1429 } else {
1430 busy_since = None;
1431 }
1432 let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
1433 let stable_for = stable_since.elapsed();
1434 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1438 let timed_out = start.elapsed() >= hard_cap;
1439 if let Some(reason) = wait_turn_outcome(
1440 state,
1441 alive,
1442 activity,
1443 seen_busy,
1444 stable_for,
1445 busy_for,
1446 settle,
1447 quiet,
1448 timed_out,
1449 wait_through_busy,
1450 ) {
1451 let joined = snapshot.join("\n");
1452 let body = if joined.trim().is_empty() {
1453 "(screen is empty)".to_string()
1454 } else {
1455 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1456 };
1457 let alt = if in_alt { " [alt-screen]" } else { "" };
1458 let header = format!(
1459 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1460 state.as_str(),
1461 reason,
1462 recognizer.name(),
1463 start.elapsed().as_secs_f64(),
1464 alt
1465 );
1466 let status = get_status(bash_state, is_bg, bg_id, running, None, None);
1467 return Ok(format!("{header}\n{body}{status}"));
1468 }
1469
1470 tokio::time::sleep(poll).await;
1471 }
1472}
1473
1474async fn execute_send_text(
1476 bash_state: &mut BashState,
1477 text: &str,
1478 submit: bool,
1479 bg_shell: Option<SharedPtyShell>,
1480 is_bg: bool,
1481 bg_id: Option<&str>,
1482 timeout_s: f64,
1483) -> Result<String> {
1484 debug!("Processing SendText action: {text:?} (submit={submit})");
1485
1486 if text.is_empty() {
1488 return Err(WinxError::CommandExecutionError(
1489 "Failure: send_text cannot be empty".to_string(),
1490 ));
1491 }
1492
1493 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1495
1496 {
1498 let mut guard = shell_arc.lock().await;
1499
1500 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1501
1502 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1504 }
1505
1506 if submit {
1510 submit_enter(&shell_arc).await?;
1511 }
1512
1513 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1515}
1516
1517async fn execute_send_specials(
1519 bash_state: &mut BashState,
1520 keys: &[SpecialKey],
1521 submit: bool,
1522 bg_shell: Option<SharedPtyShell>,
1523 is_bg: bool,
1524 bg_id: Option<&str>,
1525 timeout_s: f64,
1526) -> Result<String> {
1527 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1528
1529 if keys.is_empty() {
1531 return Err(WinxError::CommandExecutionError(
1532 "Failure: send_specials cannot be empty".to_string(),
1533 ));
1534 }
1535
1536 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1537 let mut is_interrupt = false;
1538
1539 {
1540 let mut guard = shell_arc.lock().await;
1541
1542 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1543
1544 for key in keys {
1546 match key {
1547 SpecialKey::KeyUp => {
1548 bash.send_special_key("KeyUp").map_err(|e| {
1550 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1551 })?;
1552 }
1553 SpecialKey::KeyDown => {
1554 bash.send_special_key("KeyDown").map_err(|e| {
1556 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1557 })?;
1558 }
1559 SpecialKey::KeyLeft => {
1560 bash.send_special_key("KeyLeft").map_err(|e| {
1562 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1563 })?;
1564 }
1565 SpecialKey::KeyRight => {
1566 bash.send_special_key("KeyRight").map_err(|e| {
1568 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1569 })?;
1570 }
1571 SpecialKey::Enter => {
1572 bash.send_special_key("Enter").map_err(|e| {
1574 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1575 })?;
1576 }
1577 SpecialKey::CtrlC => {
1578 bash.send_interrupt().map_err(|e| {
1580 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1581 })?;
1582 is_interrupt = true;
1583 }
1584 SpecialKey::CtrlD => {
1585 bash.send_eof().map_err(|e| {
1587 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1588 })?;
1589 is_interrupt = true;
1590 }
1591 SpecialKey::CtrlZ => {
1592 bash.send_suspend().map_err(|e| {
1594 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1595 })?;
1596 }
1597 }
1598 }
1599 }
1600
1601 if submit {
1604 submit_enter(&shell_arc).await?;
1605 }
1606
1607 let mut output =
1615 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1616
1617 if is_interrupt && output.contains("status = still running") {
1619 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1620 }
1621
1622 Ok(output)
1623}
1624
1625async fn execute_send_ascii(
1627 bash_state: &mut BashState,
1628 ascii_codes: &[u8],
1629 submit: bool,
1630 bg_shell: Option<SharedPtyShell>,
1631 is_bg: bool,
1632 bg_id: Option<&str>,
1633 timeout_s: f64,
1634) -> Result<String> {
1635 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1636
1637 if ascii_codes.is_empty() {
1639 return Err(WinxError::CommandExecutionError(
1640 "Failure: send_ascii cannot be empty".to_string(),
1641 ));
1642 }
1643
1644 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1645 let mut is_interrupt = false;
1646
1647 {
1648 let mut guard = shell_arc.lock().await;
1649
1650 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1651
1652 for &code in ascii_codes {
1654 bash.send_bytes(&[code]).map_err(|e| {
1656 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1657 })?;
1658
1659 if code == 3 {
1661 is_interrupt = true;
1662 }
1663 }
1664 }
1665
1666 if submit {
1669 submit_enter(&shell_arc).await?;
1670 }
1671
1672 let mut output =
1678 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1679
1680 if is_interrupt && output.contains("status = still running") {
1682 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1683 }
1684
1685 Ok(output)
1686}
1687
1688async fn execute_in_background(
1690 bash_state: &mut BashState,
1691 command: &str,
1692 timeout_s: f64,
1693) -> Result<String> {
1694 debug!("Executing command in background: {}", command);
1695
1696 let restricted_mode =
1698 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1699
1700 let bg_id = {
1704 let cwd = bash_state.cwd.clone();
1705 let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1706 .await
1707 .map_err(|e| {
1708 WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1709 })?
1710 .map_err(|e| {
1711 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1712 })?;
1713 lock_bg_manager().register_shell(shell)
1714 };
1715
1716 let shell_arc = {
1718 let manager = lock_bg_manager();
1719 manager.get_shell(&bg_id).ok_or_else(|| {
1720 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1721 })?
1722 };
1723
1724 let scratch_root = bash_state.workspace_root.clone();
1726 {
1727 let mut guard = shell_arc.lock().await;
1728 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1729 bash.set_scratch_root(&scratch_root);
1732 bash.send_command(command).map_err(|e| {
1733 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1734 })?;
1735 }
1736 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1737
1738 let _ = timeout_s;
1739 let _ = shell_arc;
1740 Ok(get_status(bash_state, true, Some(&bg_id), true, None, None))
1741}
1742
1743#[cfg(test)]
1744mod wait_turn_tests {
1745 use super::wait_turn_outcome;
1746 use crate::state::turn::TurnState;
1747 use std::time::Duration;
1748
1749 const SETTLE: Duration = Duration::from_millis(300);
1750 const QUIET: Duration = Duration::from_millis(600);
1751
1752 fn call(
1753 state: TurnState,
1754 busy_for: Duration,
1755 stable_for: Duration,
1756 seen_busy: bool,
1757 timed_out: bool,
1758 wait_through_busy: bool,
1759 ) -> Option<&'static str> {
1760 wait_turn_outcome(
1762 state,
1763 true,
1764 true,
1765 seen_busy,
1766 stable_for,
1767 busy_for,
1768 SETTLE,
1769 QUIET,
1770 timed_out,
1771 wait_through_busy,
1772 )
1773 }
1774
1775 #[test]
1776 fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
1777 let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
1780 assert_eq!(out, Some("busy"));
1781 }
1782
1783 #[test]
1784 fn busy_not_yet_confirmed_keeps_waiting() {
1785 let out =
1787 call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
1788 assert_eq!(out, None);
1789 }
1790
1791 #[test]
1792 fn wait_through_busy_blocks_through_busy_until_timeout() {
1793 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
1795 assert_eq!(out, None);
1796 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
1798 assert_eq!(out, Some("timeout"));
1799 }
1800
1801 #[test]
1802 fn awaiting_input_after_busy_is_ready_on_short_settle() {
1803 let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
1804 assert_eq!(out, Some("ready"));
1805 }
1806
1807 #[test]
1808 fn awaiting_input_without_prior_busy_needs_full_quiet() {
1809 let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
1811 assert_eq!(short, None);
1812 let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
1813 assert_eq!(full, Some("ready"));
1814 }
1815
1816 #[test]
1817 fn dead_shell_reports_exited_even_if_busy() {
1818 let out = wait_turn_outcome(
1819 TurnState::Busy,
1820 false,
1821 true,
1822 true,
1823 Duration::ZERO,
1824 SETTLE,
1825 SETTLE,
1826 QUIET,
1827 false,
1828 false,
1829 );
1830 assert_eq!(out, Some("exited"));
1831 }
1832
1833 #[test]
1834 fn nothing_happening_keeps_waiting() {
1835 let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
1836 assert_eq!(out, None);
1837 }
1838}
1839
1840#[cfg(test)]
1841mod tests {
1842 use super::strip_tail_pipe_impl;
1843
1844 #[test]
1845 fn strips_trailing_tail_by_default() {
1846 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1847 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1848 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1849 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1850 }
1851
1852 #[test]
1853 fn keeps_command_without_trailing_tail() {
1854 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1856 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1857 assert_eq!(
1858 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1859 "cat a | tail -5 | wc -l"
1860 );
1861 }
1862
1863 #[test]
1864 fn keep_mode_preserves_tail_pipe() {
1865 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1867 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1868 }
1869}