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 owner_thread_id: String,
140 pub last_command: String,
141 pub final_output: String,
142 pub exited_at: Instant,
143}
144
145#[derive(Debug)]
146struct BackgroundShellEntry {
147 owner_thread_id: String,
148 shell: SharedPtyShell,
149}
150
151#[derive(Debug, Default)]
153pub struct BackgroundShellManager {
154 shells: HashMap<String, BackgroundShellEntry>,
155 tombstones: HashMap<String, ExitedShellInfo>,
158}
159
160impl BackgroundShellManager {
161 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
163 const MAX_SHELLS: usize = 32;
164 const MAX_SHELLS_PER_THREAD: usize = 8;
165
166 pub fn new() -> Self {
168 Self { shells: HashMap::new(), tombstones: HashMap::new() }
169 }
170
171 pub fn register_shell(&mut self, owner_thread_id: &str, shell: PtyShell) -> Result<String> {
179 self.prune_finished_shells();
180 self.ensure_capacity(owner_thread_id)?;
181
182 let cid = format!("{:016x}", rand::rng().random::<u64>());
183 self.shells.insert(
184 cid.clone(),
185 BackgroundShellEntry {
186 owner_thread_id: owner_thread_id.to_string(),
187 shell: Arc::new(Mutex::new(Some(shell))),
188 },
189 );
190 info!("Started background shell with id: {}", cid);
191 Ok(cid)
192 }
193
194 fn ensure_capacity(&self, owner_thread_id: &str) -> Result<()> {
195 let owned =
196 self.shells.values().filter(|entry| entry.owner_thread_id == owner_thread_id).count();
197 if owned >= Self::MAX_SHELLS_PER_THREAD {
198 return Err(WinxError::CommandExecutionError(format!(
199 "Background shell limit reached for this thread ({}). Reuse or stop an existing shell.",
200 Self::MAX_SHELLS_PER_THREAD
201 )));
202 }
203 if self.shells.len() >= Self::MAX_SHELLS {
204 return Err(WinxError::CommandExecutionError(format!(
205 "Global background shell limit reached ({}). Wait for an existing shell to exit.",
206 Self::MAX_SHELLS
207 )));
208 }
209 Ok(())
210 }
211
212 pub fn get_shell(&self, owner_thread_id: &str, bg_command_id: &str) -> Option<SharedPtyShell> {
214 self.shells
215 .get(bg_command_id)
216 .filter(|entry| entry.owner_thread_id == owner_thread_id)
217 .map(|entry| entry.shell.clone())
218 }
219
220 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
222 if let Some(entry) = self.shells.remove(bg_command_id) {
223 if let Ok(mut guard) = entry.shell.try_lock() {
224 *guard = None;
225 }
226 info!("Removed background shell: {}", bg_command_id);
227 true
228 } else {
229 false
230 }
231 }
232
233 fn prune_finished_shells(&mut self) {
234 let now = Instant::now();
236 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
237
238 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
239
240 for (id, entry) in &self.shells {
241 let Ok(mut guard) = entry.shell.try_lock() else {
242 continue;
243 };
244
245 let Some(shell) = guard.as_mut() else {
246 finished.push((id.clone(), None));
247 continue;
248 };
249
250 if !shell.is_alive() {
251 let tombstone = ExitedShellInfo {
252 owner_thread_id: entry.owner_thread_id.clone(),
253 last_command: shell.last_command.clone(),
254 final_output: std::mem::take(&mut shell.output_buffer),
255 exited_at: now,
256 };
257 finished.push((id.clone(), Some(tombstone)));
258 continue;
259 }
260
261 if shell.last_command.is_empty() {
266 continue;
267 }
268
269 if shell.command_running {
270 shell.poll_output_nonblocking();
274 }
275
276 if !shell.command_running {
277 let tombstone = ExitedShellInfo {
278 owner_thread_id: entry.owner_thread_id.clone(),
279 last_command: shell.last_command.clone(),
280 final_output: std::mem::take(&mut shell.output_buffer),
281 exited_at: now,
282 };
283 finished.push((id.clone(), Some(tombstone)));
284 }
285 }
286
287 for (id, tombstone) in finished {
288 self.remove_shell(&id);
289 if let Some(info) = tombstone {
290 self.tombstones.insert(id, info);
291 }
292 }
293 }
294
295 pub fn peek_tombstone(
302 &self,
303 owner_thread_id: &str,
304 bg_command_id: &str,
305 ) -> Option<ExitedShellInfo> {
306 self.tombstones
307 .get(bg_command_id)
308 .filter(|entry| entry.owner_thread_id == owner_thread_id)
309 .cloned()
310 }
311
312 pub fn get_running_info(&mut self, owner_thread_id: &str) -> String {
314 self.prune_finished_shells();
315
316 if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
317 return "No command running in background.\n".to_string();
318 }
319
320 let mut running = Vec::new();
321 for (id, entry) in &self.shells {
322 if entry.owner_thread_id != owner_thread_id {
323 continue;
324 }
325 if let Ok(guard) = entry.shell.try_lock() {
326 if let Some(bash) = guard.as_ref() {
327 if bash.command_running {
328 running
329 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
330 }
331 }
332 } else {
333 running.push(format!("Command: <busy>, bg_command_id: {id}"));
334 }
335 }
336
337 if running.is_empty() {
338 "No command running in background.\n".to_string()
339 } else {
340 format!("Following background commands are attached:\n{}\n", running.join("\n"))
341 }
342 }
343}
344
345lazy_static::lazy_static! {
347 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
348}
349
350fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
357 BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
358}
359
360fn spawn_background_reaper(owner_thread_id: String, bg_command_id: String) {
365 tokio::spawn(async move {
366 loop {
367 sleep(Duration::from_millis(100)).await;
368 let shell_arc = {
369 let manager = lock_bg_manager();
370 manager.get_shell(&owner_thread_id, &bg_command_id)
371 };
372 let Some(shell_arc) = shell_arc else {
373 return;
374 };
375
376 let finished = {
377 let mut guard = shell_arc.lock().await;
378 match guard.as_mut() {
379 Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
380 None => true,
381 }
382 };
383 if finished {
384 lock_bg_manager().prune_finished_shells();
385 return;
386 }
387 }
388 });
389}
390
391fn get_status(
395 bash_state: &BashState,
396 is_bg: bool,
397 bg_id: Option<&str>,
398 is_running: bool,
399 running_for: Option<&str>,
400 exit_code: Option<i32>,
401) -> String {
402 let mut status = "\n\n---\n\n".to_string();
403
404 if is_bg {
405 if let Some(id) = bg_id {
406 let _ = writeln!(status, "bg_command_id = {id}");
407 }
408 }
409
410 if is_running {
411 status.push_str("status = still running\n");
412 if let Some(duration) = running_for {
413 let _ = writeln!(status, "running for = {duration}");
414 }
415 } else {
416 status.push_str("status = process exited\n");
417 if let Some(code) = exit_code {
420 let _ = writeln!(status, "exit code = {code}");
421 }
422 }
423
424 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
425
426 if !is_bg {
427 {
429 let mut manager = lock_bg_manager();
430 status.push_str("This is the main shell. ");
431 status.push_str(&manager.get_running_info(&bash_state.current_thread_id));
432 }
433 }
434
435 status.trim_end().to_string()
436}
437
438fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
440 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
441 let text = truncated.as_ref();
442
443 if last_pending_output.is_empty() {
444 let rendered = render_terminal_output(text);
445 return rstrip_lines(&rendered).trim_start().to_string();
446 }
447
448 let last_rendered = render_terminal_output(last_pending_output);
449 if last_rendered.is_empty() {
450 return rstrip_lines(&render_terminal_output(text));
451 }
452
453 let text_after_last = if text.len() > last_pending_output.len() {
457 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
458 &text[cut..]
459 } else {
460 text
461 };
462
463 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
464 let new_rendered = render_terminal_output(&combined);
465
466 let incremental = get_incremental_output(&last_rendered, &new_rendered);
468 rstrip_lines(&incremental)
469}
470
471fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
472 static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
473 #[allow(clippy::expect_used)]
478 let prompt_regex = PROMPT_RE
479 .get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
480 let stripped = strip_ansi_codes(output);
481
482 prompt_regex
483 .captures_iter(&stripped)
484 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
485 .filter(|cwd| !cwd.is_empty())
486 .last()
487 .map(PathBuf::from)
488}
489
490fn rstrip_lines(lines: &[String]) -> String {
492 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
493}
494
495fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
497 if old_output.is_empty() {
498 return new_output.to_vec();
499 }
500
501 let nold = old_output.len();
502 let nnew = new_output.len();
503
504 for i in (0..nnew).rev() {
506 if new_output[i] != old_output[nold - 1] {
507 continue;
508 }
509
510 let mut matched = true;
511 for j in (0..i).rev() {
512 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
513 if old_idx < 0 {
514 break;
515 }
516 if new_output[j] != old_output[old_idx as usize] {
517 matched = false;
518 break;
519 }
520 }
521
522 if matched {
523 return new_output[i + 1..].to_vec();
524 }
525 }
526
527 new_output.to_vec()
528}
529
530fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
531 let mut start = 0;
532
533 while start < text.len() {
534 let mut end = (start + chunk_size).min(text.len());
535 while !text.is_char_boundary(end) {
536 end -= 1;
537 }
538 if end == start {
539 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
540 }
541
542 shell.send_text(&text[start..end]).map_err(|e| {
543 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
544 })?;
545 start = end;
546 }
547
548 Ok(())
549}
550
551async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
556 tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
557 let mut guard = shell_arc.lock().await;
558 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
559 ensure_interactive_target(bash)?;
560 bash.send_special_key("Enter")
561 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
562 Ok(())
563}
564
565fn ensure_interactive_target(shell: &mut PtyShell) -> Result<()> {
570 shell.poll_output_nonblocking();
571 if shell.command_running {
572 Ok(())
573 } else {
574 Err(WinxError::CommandExecutionError(
575 "No interactive command is running. Start an allowed command first; send_text/send_ascii/send_specials cannot execute commands in an idle shell."
576 .to_string(),
577 ))
578 }
579}
580
581#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
588pub async fn handle_tool_call(
589 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
590 bash_command: BashCommand,
591) -> Result<String> {
592 let action_kind = match &bash_command.action_json {
593 BashCommandAction::Command { .. } => "command",
594 BashCommandAction::StatusCheck { .. } => "status_check",
595 BashCommandAction::SendText { .. } => "send_text",
596 BashCommandAction::SendSpecials { .. } => "send_specials",
597 BashCommandAction::SendAscii { .. } => "send_ascii",
598 BashCommandAction::Screen { .. } => "screen",
599 BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
600 };
601 info!(thread_id = %bash_command.thread_id, action = action_kind, "BashCommand tool called");
602
603 let thread_id = normalize_thread_id(&bash_command.thread_id);
604
605 if thread_id.is_empty() {
607 error!("Empty thread_id provided in BashCommand");
608 return Err(WinxError::ThreadIdMismatch(
609 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
610 ));
611 }
612
613 let mut bash_state: BashState;
615 {
616 let bash_state_guard = bash_state_arc.lock().await;
617
618 let Some(state) = &*bash_state_guard else {
619 error!("BashState not initialized");
620 return Err(WinxError::BashStateNotInitialized);
621 };
622
623 bash_state = state.clone();
624 }
625
626 if thread_id != bash_state.current_thread_id {
628 let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
633 WinxError::CommandExecutionError(format!(
634 "Failed to load saved bash state for thread_id `{thread_id}`: {e}"
635 ))
636 })?;
637 if !loaded {
638 return Err(WinxError::ThreadIdMismatch(format!(
639 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
640 )));
641 }
642 if let Some(state) = bash_state_arc.lock().await.as_mut() {
648 *state = bash_state.clone();
649 }
650 }
651
652 let timeout_s = bash_command
655 .wait_for_seconds
656 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
657 .min(TIMEOUT_WHILE_OUTPUT);
658
659 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
661
662 {
663 let mut bash_state_guard = bash_state_arc.lock().await;
664 if let Some(state) = bash_state_guard.as_mut() {
665 state.cwd.clone_from(&bash_state.cwd);
666 }
667 }
668
669 match result {
671 Ok(mut output) => {
672 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
673 let cmd_trimmed = command.trim();
674 if output.starts_with(cmd_trimmed) {
675 output = output[cmd_trimmed.len()..].to_string();
676 }
677 }
678 Ok(output)
679 }
680 Err(e) => Err(e),
681 }
682}
683
684#[allow(clippy::too_many_lines)]
686async fn execute_bash_action(
687 bash_state: &mut BashState,
688 action: &BashCommandAction,
689 timeout_s: f64,
690) -> Result<String> {
691 let mut is_bg = false;
692 let mut bg_id: Option<String> = None;
693
694 let bg_shell: Option<SharedPtyShell> = match action {
696 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
698 | BashCommandAction::SendText { bg_command_id, .. }
699 | BashCommandAction::SendSpecials { bg_command_id, .. }
700 | BashCommandAction::SendAscii { bg_command_id, .. }
701 | BashCommandAction::Screen { bg_command_id, .. }
702 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
703 if let Some(id) = bg_command_id {
704 let mut manager = lock_bg_manager();
707 manager.prune_finished_shells();
708
709 if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
710 is_bg = true;
711 bg_id = Some(id.clone());
712 Some(shell)
713 } else if let Some(tombstone) =
714 manager.peek_tombstone(&bash_state.current_thread_id, id)
715 {
716 drop(manager);
721 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
722 } else {
723 let error = format!(
725 "No shell found running with command id {}.\n{}",
726 id,
727 manager.get_running_info(&bash_state.current_thread_id)
728 );
729 return Err(WinxError::CommandExecutionError(error));
730 }
731 } else {
732 None
733 }
734 }
735 };
736
737 match action {
739 BashCommandAction::Command { command, is_background, allow_multi } => {
740 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
741 }
742 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
743 execute_status_check(
744 bash_state,
745 bg_shell,
746 is_bg,
747 bg_id.as_deref(),
748 timeout_s,
749 *scrollback_lines,
750 *verbose,
751 )
752 .await
753 }
754 BashCommandAction::SendText { send_text, submit, .. } => {
755 execute_send_text(
756 bash_state,
757 send_text,
758 *submit,
759 bg_shell,
760 is_bg,
761 bg_id.as_deref(),
762 timeout_s,
763 )
764 .await
765 }
766 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
767 execute_send_specials(
768 bash_state,
769 send_specials,
770 *submit,
771 bg_shell,
772 is_bg,
773 bg_id.as_deref(),
774 timeout_s,
775 )
776 .await
777 }
778 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
779 execute_send_ascii(
780 bash_state,
781 send_ascii,
782 *submit,
783 bg_shell,
784 is_bg,
785 bg_id.as_deref(),
786 timeout_s,
787 )
788 .await
789 }
790 BashCommandAction::Screen { lines, diff, .. } => {
791 execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
792 }
793 BashCommandAction::WaitForTurn {
794 recognizer,
795 quiet_ms,
796 timeout_seconds,
797 lines,
798 wait_through_busy,
799 ..
800 } => {
801 execute_wait_for_turn(
802 bash_state,
803 bg_shell,
804 is_bg,
805 bg_id.as_deref(),
806 recognizer.as_deref(),
807 *quiet_ms,
808 *timeout_seconds,
809 *lines,
810 *wait_through_busy,
811 )
812 .await
813 }
814 }
815}
816
817fn strip_tail_pipe(command: &str) -> String {
827 strip_tail_pipe_impl(command, keep_tail_pipe())
828}
829
830fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
833 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
834 if keep {
835 return command.to_string();
836 }
837 #[allow(clippy::expect_used)]
841 let re = RE.get_or_init(|| {
842 regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
843 .expect("tail-pipe regex must compile")
844 });
845 match re.find(command) {
846 Some(matched) => command[..matched.start()].trim_end().to_string(),
847 None => command.to_string(),
848 }
849}
850
851fn keep_tail_pipe() -> bool {
853 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
854 let value = value.trim();
855 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
856 })
857}
858
859async fn execute_command(
861 bash_state: &mut BashState,
862 command: &str,
863 is_background: bool,
864 allow_multi: bool,
865 timeout_s: f64,
866) -> Result<String> {
867 let stripped_command = strip_tail_pipe(command);
869 let command = stripped_command.as_str();
870 debug!(bytes = command.len(), allow_multi, "Processing Command action");
871
872 if !bash_state.is_command_allowed(command) {
874 error!(bytes = command.len(), "Command not allowed in current mode");
875 return Err(WinxError::CommandNotAllowed(
876 "Error: BashCommand not allowed in current mode".to_string(),
877 ));
878 }
879
880 let command = command.trim();
884 if !allow_multi {
885 let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
889 crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
890 }
891
892 if is_background {
894 return execute_in_background(bash_state, command, timeout_s).await;
895 }
896
897 {
899 let bash_guard = bash_state.pty_shell.lock().await;
900
901 if let Some(ref bash) = *bash_guard {
902 if bash.command_running {
903 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
904 }
905 }
906 }
907
908 if bash_state.pty_shell.lock().await.is_none() {
910 bash_state
911 .init_pty_shell()
912 .await
913 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
914 }
915
916 {
921 let needs_reset = {
922 if bash_state.pty_shell.lock().await.is_some() {
926 if clear_to_run_async(&bash_state.pty_shell, DEFAULT_TIMEOUT).await {
927 false
928 } else {
929 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
930 true
931 }
932 } else {
933 false
934 }
935 };
936 if needs_reset {
940 if let Err(e) = bash_state.init_pty_shell().await {
941 warn!("Failed to reset shell after clear_to_run: {e}");
942 }
943 }
944 }
945
946 let scratch_root = bash_state.workspace_root.clone();
948 {
949 let mut bash_guard = bash_state.pty_shell.lock().await;
950
951 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
952
953 bash.output_buffer.clear();
954 bash.output_truncated = false;
955 bash.reset_scratch();
958 bash.set_scratch_root(&scratch_root);
959 bash.last_exit_code = None;
964 bash.last_returned_hash = None;
965 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
967
968 bash.send_special_key("Enter").map_err(|e| {
970 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
971 })?;
972
973 bash.last_command = command.to_string();
974 bash.command_running = true;
975 }
976
977 let shell_arc = bash_state.pty_shell.clone();
979 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
980}
981
982async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
989 let mut guard = shell_arc.lock().await;
990 match guard.as_mut() {
991 Some(bash) => bash.poll_output_nonblocking(),
992 None => true,
993 }
994}
995
996async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
998 let mut guard = shell_arc.lock().await;
999 guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
1000}
1001
1002async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
1006 let start = Instant::now();
1007 loop {
1008 if poll_shell(shell_arc).await {
1009 return true;
1010 }
1011 if start.elapsed().as_secs_f64() >= budget_secs {
1012 return false;
1013 }
1014 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1015 }
1016}
1017
1018async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
1025 if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
1027 return true;
1028 }
1029 {
1031 let mut guard = shell_arc.lock().await;
1032 if let Some(bash) = guard.as_mut() {
1033 if let Err(e) = bash.send_interrupt() {
1034 warn!("clear_to_run: failed to send Ctrl-C: {e}");
1035 }
1036 }
1037 }
1038 drain_until_prompt(shell_arc, max_wait_secs).await
1039}
1040
1041async fn wait_for_output(
1043 bash_state: &mut BashState,
1044 shell_arc: &SharedPtyShell,
1045 timeout_s: f64,
1046 is_bg: bool,
1047 bg_id: Option<&str>,
1048 is_status_check: bool,
1049) -> Result<String> {
1050 let start = Instant::now();
1051 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
1052 let mut last_pending_output = String::new();
1053 let mut complete = false;
1054
1055 let mut output = String::new();
1067 loop {
1068 if start.elapsed().as_secs_f64() >= wait {
1069 break;
1070 }
1071 complete = poll_shell(shell_arc).await;
1072 if complete {
1073 break;
1074 }
1075 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1076 }
1077 if complete {
1080 sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
1081 poll_shell(shell_arc).await;
1082 }
1083 output = snapshot_shell(shell_arc).await;
1084
1085 if !complete && is_status_check {
1094 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
1095 let iter_wait_secs = 0.5_f64;
1096 let mut patience = OUTPUT_WAIT_PATIENCE;
1097
1098 let incremental = wcgw_incremental_text(&output, &last_pending_output);
1099 if incremental.is_empty() {
1100 patience -= 1;
1101 }
1102
1103 let mut last_incremental = incremental;
1104
1105 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
1106 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
1107 if remaining < 0.1 {
1108 break;
1109 }
1110 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
1111
1112 let done = poll_shell(shell_arc).await;
1116 let new_output = snapshot_shell(shell_arc).await;
1117
1118 if done {
1119 complete = true;
1120 output = new_output;
1121 break;
1122 }
1123
1124 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
1126 if new_incremental == last_incremental {
1127 patience -= 1;
1128 } else {
1129 patience = OUTPUT_WAIT_PATIENCE; }
1131 last_incremental = new_incremental;
1132
1133 output = new_output;
1134 }
1135
1136 if !complete {
1137 last_pending_output = output.clone();
1139 }
1140 }
1141
1142 if complete {
1143 if let Some(cwd) = extract_prompt_cwd(&output) {
1144 bash_state.cwd = cwd;
1145 }
1146 }
1147
1148 let rendered = wcgw_incremental_text(&output, &last_pending_output);
1150
1151 let rendered = {
1155 let before_lines = rendered.lines().count();
1156 match crate::utils::output_compress::compress_output(&rendered) {
1157 Some(compressed) => {
1158 debug!(
1159 from_lines = before_lines,
1160 to_lines = compressed.lines().count(),
1161 "winx collapsed mechanical repetition in shell output"
1162 );
1163 compressed
1164 }
1165 None => rendered,
1166 }
1167 };
1168
1169 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1171
1172 let running_for =
1174 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
1175
1176 let (exit_code, scratch_pointer) = read_completion_extras(shell_arc, complete).await;
1181
1182 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref(), exit_code);
1184 Ok(format!("{rendered}{status}{scratch_pointer}"))
1185}
1186
1187async fn read_completion_extras(
1191 shell_arc: &SharedPtyShell,
1192 complete: bool,
1193) -> (Option<i32>, String) {
1194 let guard = shell_arc.lock().await;
1195 let Some(shell) = guard.as_ref() else {
1196 return (None, String::new());
1197 };
1198 let exit_code = if complete { shell.last_exit_code } else { None };
1199 let pointer = match (shell.output_truncated, shell.scratch_path()) {
1200 (true, Some(path)) => format!(
1201 "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1202 saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1203 path.display()
1204 ),
1205 _ => String::new(),
1206 };
1207 (exit_code, pointer)
1208}
1209
1210fn finalize_tombstone(
1217 cwd: &Path,
1218 id: &str,
1219 tombstone: ExitedShellInfo,
1220 action: &BashCommandAction,
1221) -> Result<String> {
1222 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
1223 match action {
1224 BashCommandAction::StatusCheck { .. }
1227 | BashCommandAction::Screen { .. }
1228 | BashCommandAction::WaitForTurn { .. } => {
1229 let rendered = wcgw_incremental_text(&final_output, "");
1230 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1231 let mut status = "\n\n---\n\n".to_string();
1233 let _ = writeln!(status, "bg_command_id = {id}");
1234 status.push_str("status = process exited\n");
1235 let _ = writeln!(status, "cwd = {}", cwd.display());
1236 Ok(format!("{rendered}{}", status.trim_end()))
1237 }
1238 BashCommandAction::SendText { .. }
1239 | BashCommandAction::SendSpecials { .. }
1240 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1241 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1242 ))),
1243 BashCommandAction::Command { .. } => {
1244 unreachable!("finalize_tombstone called for non-bg action")
1247 }
1248 }
1249}
1250
1251async fn execute_status_check(
1260 bash_state: &mut BashState,
1261 bg_shell: Option<SharedPtyShell>,
1262 is_bg: bool,
1263 bg_id: Option<&str>,
1264 timeout_s: f64,
1265 scrollback_lines: Option<usize>,
1266 verbose: bool,
1267) -> Result<String> {
1268 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1269
1270 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1273
1274 let is_running = {
1276 let guard = shell_arc.lock().await;
1277 if let Some(ref bash) = *guard {
1278 bash.command_running
1279 } else {
1280 false
1281 }
1282 };
1283
1284 if !is_running && !is_bg {
1286 let mut manager = lock_bg_manager();
1287 let error = format!(
1288 "No command is currently running, so there's nothing to check. The previous \
1289 command already finished and its output was returned when it completed. Start a \
1290 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1291 manager.get_running_info(&bash_state.current_thread_id)
1292 );
1293 return Err(WinxError::CommandExecutionError(error));
1294 }
1295
1296 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1298
1299 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1303 if !verbose && scrollback_lines.is_none() {
1304 let mut guard = shell_arc.lock().await;
1305 if let Some(bash) = guard.as_mut() {
1306 let fingerprint = PtyShell::fingerprint(body);
1307 if Some(fingerprint) == bash.last_returned_hash {
1308 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1309 return Ok(format!("no new output since last check{status}"));
1310 }
1311 bash.last_returned_hash = Some(fingerprint);
1312 }
1313 } else if !verbose {
1314 let mut guard = shell_arc.lock().await;
1316 if let Some(bash) = guard.as_mut() {
1317 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1318 }
1319 }
1320
1321 if let Some(lines) = scrollback_lines {
1323 if lines > 0 {
1324 let scrollback = {
1325 let guard = shell_arc.lock().await;
1326 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1327 };
1328 if !scrollback.is_empty() {
1329 let count = scrollback.lines().count();
1330 return Ok(format!(
1331 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1332 ));
1333 }
1334 }
1335 }
1336
1337 Ok(response)
1338}
1339
1340async fn execute_screen(
1344 bash_state: &BashState,
1345 bg_shell: Option<SharedPtyShell>,
1346 is_bg: bool,
1347 bg_id: Option<&str>,
1348 lines: Option<usize>,
1349 diff: bool,
1350) -> Result<String> {
1351 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1352 let max_lines = lines.unwrap_or(0);
1353
1354 if diff {
1355 let (update, is_running, in_alt, cursor) = {
1356 let mut guard = shell_arc.lock().await;
1357 match guard.as_mut() {
1358 Some(bash) => {
1359 bash.poll_output_nonblocking();
1360 (
1361 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1362 bash.command_running,
1363 bash.live_in_alt_screen(),
1364 bash.live_cursor_position(),
1365 )
1366 }
1367 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1368 }
1369 };
1370 let (crow, ccol) = cursor;
1371 let alt = if in_alt { " [alt-screen]" } else { "" };
1372 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1373 let body = match update {
1374 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1375 ScreenUpdate::Diff(changed) => {
1376 let mut out = String::from("(changed lines only)\n");
1377 for (row, content) in changed {
1378 let _ = writeln!(out, "{row:>4}: {content}");
1379 }
1380 out
1381 }
1382 ScreenUpdate::Full(snap) => {
1383 let joined = snap.join("\n");
1384 if joined.trim().is_empty() {
1385 "(screen is empty)".to_string()
1386 } else {
1387 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1388 }
1389 }
1390 };
1391 return Ok(format!(
1392 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1393 ));
1394 }
1395
1396 let (snapshot, is_running, in_alt, cursor) = {
1397 let mut guard = shell_arc.lock().await;
1398 match guard.as_mut() {
1399 Some(bash) => {
1400 bash.poll_output_nonblocking();
1401 (
1402 bash.live_snapshot(max_lines),
1403 bash.command_running,
1404 bash.live_in_alt_screen(),
1405 bash.live_cursor_position(),
1406 )
1407 }
1408 None => (Vec::new(), false, false, (0, 0)),
1409 }
1410 };
1411
1412 let joined = snapshot.join("\n");
1413 let body = if joined.trim().is_empty() {
1414 "(screen is empty)".to_string()
1415 } else {
1416 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1417 };
1418 let alt = if in_alt { " [alt-screen]" } else { "" };
1419 let (crow, ccol) = cursor;
1420 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1421 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1422}
1423
1424#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1433fn wait_turn_outcome(
1434 state: crate::state::turn::TurnState,
1435 alive: bool,
1436 activity: bool,
1437 seen_busy: bool,
1438 stable_for: Duration,
1439 busy_for: Duration,
1440 settle: Duration,
1441 quiet: Duration,
1442 timed_out: bool,
1443 wait_through_busy: bool,
1444) -> Option<&'static str> {
1445 use crate::state::turn::TurnState;
1446 if !alive {
1447 return Some("exited");
1448 }
1449 let ready = match state {
1450 TurnState::Busy => false,
1451 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1454 activity && stable_for >= if seen_busy { settle } else { quiet }
1455 }
1456 TurnState::Unknown => activity && stable_for >= quiet,
1457 };
1458 if ready {
1459 return Some("ready");
1460 }
1461 if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
1466 return Some("busy");
1467 }
1468 if timed_out {
1469 return Some("timeout");
1470 }
1471 None
1472}
1473
1474#[allow(clippy::too_many_arguments)]
1484async fn execute_wait_for_turn(
1485 bash_state: &BashState,
1486 bg_shell: Option<SharedPtyShell>,
1487 is_bg: bool,
1488 bg_id: Option<&str>,
1489 recognizer_hint: Option<&str>,
1490 quiet_ms: Option<u64>,
1491 timeout_seconds: Option<f32>,
1492 lines: Option<usize>,
1493 wait_through_busy: bool,
1494) -> Result<String> {
1495 use crate::state::turn::{recognizer_for, TurnState};
1496
1497 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1498 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1499 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1500 let settle = quiet.min(Duration::from_millis(300));
1501 let hard_cap =
1502 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1503 let max_lines = lines.unwrap_or(0);
1504 let poll = Duration::from_millis(120);
1505 let warmup = Duration::from_millis(2500);
1508
1509 let start = Instant::now();
1510 let mut last_hash: Option<u64> = None;
1511 let mut initial_hash: Option<u64> = None;
1512 let mut stable_since = Instant::now();
1513 let mut seen_busy = false;
1514 let mut busy_since: Option<Instant> = None;
1515
1516 loop {
1517 let (snapshot, in_alt, alive, running) = {
1518 let mut guard = shell_arc.lock().await;
1519 match guard.as_mut() {
1520 Some(bash) => (
1521 {
1522 bash.poll_output_nonblocking();
1523 bash.live_snapshot(max_lines)
1524 },
1525 bash.live_in_alt_screen(),
1526 bash.is_alive(),
1527 bash.command_running,
1528 ),
1529 None => (Vec::new(), false, false, false),
1530 }
1531 };
1532
1533 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1534 if initial_hash.is_none() {
1535 initial_hash = Some(hash);
1536 }
1537 if Some(hash) != last_hash {
1538 last_hash = Some(hash);
1539 stable_since = Instant::now();
1540 }
1541
1542 let state = recognizer.detect(&snapshot);
1543 if state == TurnState::Busy {
1544 seen_busy = true;
1545 if busy_since.is_none() {
1546 busy_since = Some(Instant::now());
1547 }
1548 } else {
1549 busy_since = None;
1550 }
1551 let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
1552 let stable_for = stable_since.elapsed();
1553 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1557 let timed_out = start.elapsed() >= hard_cap;
1558 if let Some(reason) = wait_turn_outcome(
1559 state,
1560 alive,
1561 activity,
1562 seen_busy,
1563 stable_for,
1564 busy_for,
1565 settle,
1566 quiet,
1567 timed_out,
1568 wait_through_busy,
1569 ) {
1570 let joined = snapshot.join("\n");
1571 let body = if joined.trim().is_empty() {
1572 "(screen is empty)".to_string()
1573 } else {
1574 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1575 };
1576 let alt = if in_alt { " [alt-screen]" } else { "" };
1577 let header = format!(
1578 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1579 state.as_str(),
1580 reason,
1581 recognizer.name(),
1582 start.elapsed().as_secs_f64(),
1583 alt
1584 );
1585 let status = get_status(bash_state, is_bg, bg_id, running, None, None);
1586 return Ok(format!("{header}\n{body}{status}"));
1587 }
1588
1589 tokio::time::sleep(poll).await;
1590 }
1591}
1592
1593async fn execute_send_text(
1595 bash_state: &mut BashState,
1596 text: &str,
1597 submit: bool,
1598 bg_shell: Option<SharedPtyShell>,
1599 is_bg: bool,
1600 bg_id: Option<&str>,
1601 timeout_s: f64,
1602) -> Result<String> {
1603 debug!(bytes = text.len(), submit, "Processing SendText action");
1604
1605 if text.is_empty() {
1607 return Err(WinxError::CommandExecutionError(
1608 "Failure: send_text cannot be empty".to_string(),
1609 ));
1610 }
1611
1612 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1614
1615 {
1617 let mut guard = shell_arc.lock().await;
1618
1619 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1620 ensure_interactive_target(bash)?;
1621
1622 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1624 }
1625
1626 if submit {
1630 submit_enter(&shell_arc).await?;
1631 }
1632
1633 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1635}
1636
1637async fn execute_send_specials(
1639 bash_state: &mut BashState,
1640 keys: &[SpecialKey],
1641 submit: bool,
1642 bg_shell: Option<SharedPtyShell>,
1643 is_bg: bool,
1644 bg_id: Option<&str>,
1645 timeout_s: f64,
1646) -> Result<String> {
1647 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1648
1649 if keys.is_empty() {
1651 return Err(WinxError::CommandExecutionError(
1652 "Failure: send_specials cannot be empty".to_string(),
1653 ));
1654 }
1655
1656 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1657 let mut is_interrupt = false;
1658
1659 {
1660 let mut guard = shell_arc.lock().await;
1661
1662 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1663 ensure_interactive_target(bash)?;
1664
1665 for key in keys {
1667 match key {
1668 SpecialKey::KeyUp => {
1669 bash.send_special_key("KeyUp").map_err(|e| {
1671 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1672 })?;
1673 }
1674 SpecialKey::KeyDown => {
1675 bash.send_special_key("KeyDown").map_err(|e| {
1677 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1678 })?;
1679 }
1680 SpecialKey::KeyLeft => {
1681 bash.send_special_key("KeyLeft").map_err(|e| {
1683 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1684 })?;
1685 }
1686 SpecialKey::KeyRight => {
1687 bash.send_special_key("KeyRight").map_err(|e| {
1689 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1690 })?;
1691 }
1692 SpecialKey::Enter => {
1693 bash.send_special_key("Enter").map_err(|e| {
1695 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1696 })?;
1697 }
1698 SpecialKey::CtrlC => {
1699 bash.send_interrupt().map_err(|e| {
1701 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1702 })?;
1703 is_interrupt = true;
1704 }
1705 SpecialKey::CtrlD => {
1706 bash.send_eof().map_err(|e| {
1708 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1709 })?;
1710 is_interrupt = true;
1711 }
1712 SpecialKey::CtrlZ => {
1713 bash.send_suspend().map_err(|e| {
1715 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1716 })?;
1717 }
1718 }
1719 }
1720 }
1721
1722 if submit {
1725 submit_enter(&shell_arc).await?;
1726 }
1727
1728 let mut output =
1736 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1737
1738 if is_interrupt && output.contains("status = still running") {
1740 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1741 }
1742
1743 Ok(output)
1744}
1745
1746async fn execute_send_ascii(
1748 bash_state: &mut BashState,
1749 ascii_codes: &[u8],
1750 submit: bool,
1751 bg_shell: Option<SharedPtyShell>,
1752 is_bg: bool,
1753 bg_id: Option<&str>,
1754 timeout_s: f64,
1755) -> Result<String> {
1756 debug!(bytes = ascii_codes.len(), submit, "Processing SendAscii action");
1757
1758 if ascii_codes.is_empty() {
1760 return Err(WinxError::CommandExecutionError(
1761 "Failure: send_ascii cannot be empty".to_string(),
1762 ));
1763 }
1764
1765 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1766 let mut is_interrupt = false;
1767
1768 {
1769 let mut guard = shell_arc.lock().await;
1770
1771 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1772 ensure_interactive_target(bash)?;
1773
1774 for &code in ascii_codes {
1776 bash.send_bytes(&[code]).map_err(|e| {
1778 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1779 })?;
1780
1781 if code == 3 {
1783 is_interrupt = true;
1784 }
1785 }
1786 }
1787
1788 if submit {
1791 submit_enter(&shell_arc).await?;
1792 }
1793
1794 let mut output =
1800 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1801
1802 if is_interrupt && output.contains("status = still running") {
1804 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1805 }
1806
1807 Ok(output)
1808}
1809
1810async fn execute_in_background(
1812 bash_state: &mut BashState,
1813 command: &str,
1814 timeout_s: f64,
1815) -> Result<String> {
1816 debug!(bytes = command.len(), "Executing command in background");
1817
1818 let restricted_mode =
1820 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1821
1822 let bg_id = {
1826 let cwd = bash_state.cwd.clone();
1827 let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1828 .await
1829 .map_err(|e| {
1830 WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1831 })?
1832 .map_err(|e| {
1833 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1834 })?;
1835 lock_bg_manager().register_shell(&bash_state.current_thread_id, shell)?
1836 };
1837
1838 let shell_arc = {
1840 let manager = lock_bg_manager();
1841 manager.get_shell(&bash_state.current_thread_id, &bg_id).ok_or_else(|| {
1842 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1843 })?
1844 };
1845
1846 let scratch_root = bash_state.workspace_root.clone();
1848 {
1849 let mut guard = shell_arc.lock().await;
1850 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1851 bash.set_scratch_root(&scratch_root);
1854 bash.send_command(command).map_err(|e| {
1855 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1856 })?;
1857 }
1858 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1859
1860 spawn_background_reaper(bash_state.current_thread_id.clone(), bg_id.clone());
1861
1862 let _ = timeout_s;
1863 let _ = shell_arc;
1864 Ok(get_status(bash_state, true, Some(&bg_id), true, None, None))
1865}
1866
1867#[cfg(test)]
1868mod wait_turn_tests {
1869 use super::wait_turn_outcome;
1870 use crate::state::turn::TurnState;
1871 use std::time::Duration;
1872
1873 const SETTLE: Duration = Duration::from_millis(300);
1874 const QUIET: Duration = Duration::from_millis(600);
1875
1876 fn call(
1877 state: TurnState,
1878 busy_for: Duration,
1879 stable_for: Duration,
1880 seen_busy: bool,
1881 timed_out: bool,
1882 wait_through_busy: bool,
1883 ) -> Option<&'static str> {
1884 wait_turn_outcome(
1886 state,
1887 true,
1888 true,
1889 seen_busy,
1890 stable_for,
1891 busy_for,
1892 SETTLE,
1893 QUIET,
1894 timed_out,
1895 wait_through_busy,
1896 )
1897 }
1898
1899 #[test]
1900 fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
1901 let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
1904 assert_eq!(out, Some("busy"));
1905 }
1906
1907 #[test]
1908 fn busy_not_yet_confirmed_keeps_waiting() {
1909 let out =
1911 call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
1912 assert_eq!(out, None);
1913 }
1914
1915 #[test]
1916 fn wait_through_busy_blocks_through_busy_until_timeout() {
1917 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
1919 assert_eq!(out, None);
1920 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
1922 assert_eq!(out, Some("timeout"));
1923 }
1924
1925 #[test]
1926 fn awaiting_input_after_busy_is_ready_on_short_settle() {
1927 let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
1928 assert_eq!(out, Some("ready"));
1929 }
1930
1931 #[test]
1932 fn awaiting_input_without_prior_busy_needs_full_quiet() {
1933 let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
1935 assert_eq!(short, None);
1936 let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
1937 assert_eq!(full, Some("ready"));
1938 }
1939
1940 #[test]
1941 fn dead_shell_reports_exited_even_if_busy() {
1942 let out = wait_turn_outcome(
1943 TurnState::Busy,
1944 false,
1945 true,
1946 true,
1947 Duration::ZERO,
1948 SETTLE,
1949 SETTLE,
1950 QUIET,
1951 false,
1952 false,
1953 );
1954 assert_eq!(out, Some("exited"));
1955 }
1956
1957 #[test]
1958 fn nothing_happening_keeps_waiting() {
1959 let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
1960 assert_eq!(out, None);
1961 }
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966 use super::{strip_tail_pipe_impl, BackgroundShellEntry, BackgroundShellManager};
1967 use crate::errors::WinxError;
1968 use std::sync::Arc;
1969 use tokio::sync::Mutex;
1970
1971 fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
1972 BackgroundShellEntry {
1973 owner_thread_id: owner_thread_id.to_string(),
1974 shell: Arc::new(Mutex::new(None)),
1975 }
1976 }
1977
1978 #[test]
1979 fn background_capacity_is_bounded_per_thread_and_globally() {
1980 let mut manager = BackgroundShellManager::new();
1981 for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
1982 manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
1983 }
1984 assert!(matches!(
1985 manager.ensure_capacity("owner"),
1986 Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
1987 ));
1988 assert!(manager.ensure_capacity("another-owner").is_ok());
1989
1990 for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
1991 manager
1992 .shells
1993 .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
1994 }
1995 assert!(matches!(
1996 manager.ensure_capacity("new-owner"),
1997 Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
1998 ));
1999 }
2000
2001 #[test]
2002 fn strips_trailing_tail_by_default() {
2003 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
2004 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
2005 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
2006 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
2007 }
2008
2009 #[test]
2010 fn keeps_command_without_trailing_tail() {
2011 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
2013 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
2014 assert_eq!(
2015 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
2016 "cat a | tail -5 | wc -l"
2017 );
2018 }
2019
2020 #[test]
2021 fn keep_mode_preserves_tail_pipe() {
2022 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
2024 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
2025 }
2026}