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 { recognizer, quiet_ms, timeout_seconds, lines, .. } => {
684 execute_wait_for_turn(
685 bash_state,
686 bg_shell,
687 is_bg,
688 bg_id.as_deref(),
689 recognizer.as_deref(),
690 *quiet_ms,
691 *timeout_seconds,
692 *lines,
693 )
694 .await
695 }
696 }
697}
698
699fn strip_tail_pipe(command: &str) -> String {
709 strip_tail_pipe_impl(command, keep_tail_pipe())
710}
711
712fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
715 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
716 if keep {
717 return command.to_string();
718 }
719 #[allow(clippy::expect_used)]
723 let re = RE.get_or_init(|| {
724 regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
725 .expect("tail-pipe regex must compile")
726 });
727 match re.find(command) {
728 Some(matched) => command[..matched.start()].trim_end().to_string(),
729 None => command.to_string(),
730 }
731}
732
733fn keep_tail_pipe() -> bool {
735 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
736 let value = value.trim();
737 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
738 })
739}
740
741async fn execute_command(
743 bash_state: &mut BashState,
744 command: &str,
745 is_background: bool,
746 allow_multi: bool,
747 timeout_s: f64,
748) -> Result<String> {
749 let stripped_command = strip_tail_pipe(command);
751 let command = stripped_command.as_str();
752 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
753
754 if !bash_state.is_command_allowed(command) {
756 error!("Command '{}' not allowed in current mode", command);
757 return Err(WinxError::CommandNotAllowed(
758 "Error: BashCommand not allowed in current mode".to_string(),
759 ));
760 }
761
762 let command = command.trim();
766 if !allow_multi {
767 let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
771 crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
772 }
773
774 if is_background {
776 return execute_in_background(bash_state, command, timeout_s).await;
777 }
778
779 {
781 let bash_guard = bash_state.pty_shell.lock().await;
782
783 if let Some(ref bash) = *bash_guard {
784 if bash.command_running {
785 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
786 }
787 }
788 }
789
790 if bash_state.pty_shell.lock().await.is_none() {
792 bash_state
793 .init_pty_shell()
794 .await
795 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
796 }
797
798 {
803 let needs_reset = {
804 if bash_state.pty_shell.lock().await.is_some() {
808 if clear_to_run_async(&bash_state.pty_shell, DEFAULT_TIMEOUT).await {
809 false
810 } else {
811 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
812 true
813 }
814 } else {
815 false
816 }
817 };
818 if needs_reset {
822 if let Err(e) = bash_state.init_pty_shell().await {
823 warn!("Failed to reset shell after clear_to_run: {e}");
824 }
825 }
826 }
827
828 let scratch_root = bash_state.workspace_root.clone();
830 {
831 let mut bash_guard = bash_state.pty_shell.lock().await;
832
833 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
834
835 bash.output_buffer.clear();
836 bash.output_truncated = false;
837 bash.reset_scratch();
840 bash.set_scratch_root(&scratch_root);
841 bash.last_exit_code = None;
846 bash.last_returned_hash = None;
847 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
849
850 bash.send_special_key("Enter").map_err(|e| {
852 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
853 })?;
854
855 bash.last_command = command.to_string();
856 bash.command_running = true;
857 }
858
859 let shell_arc = bash_state.pty_shell.clone();
861 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
862}
863
864async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
871 let mut guard = shell_arc.lock().await;
872 match guard.as_mut() {
873 Some(bash) => bash.poll_output_nonblocking(),
874 None => true,
875 }
876}
877
878async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
880 let mut guard = shell_arc.lock().await;
881 guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
882}
883
884async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
888 let start = Instant::now();
889 loop {
890 if poll_shell(shell_arc).await {
891 return true;
892 }
893 if start.elapsed().as_secs_f64() >= budget_secs {
894 return false;
895 }
896 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
897 }
898}
899
900async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
907 if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
909 return true;
910 }
911 {
913 let mut guard = shell_arc.lock().await;
914 if let Some(bash) = guard.as_mut() {
915 if let Err(e) = bash.send_interrupt() {
916 warn!("clear_to_run: failed to send Ctrl-C: {e}");
917 }
918 }
919 }
920 drain_until_prompt(shell_arc, max_wait_secs).await
921}
922
923async fn wait_for_output(
925 bash_state: &mut BashState,
926 shell_arc: &SharedPtyShell,
927 timeout_s: f64,
928 is_bg: bool,
929 bg_id: Option<&str>,
930 is_status_check: bool,
931) -> Result<String> {
932 let start = Instant::now();
933 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
934 let mut last_pending_output = String::new();
935 let mut complete = false;
936
937 let mut output = String::new();
949 loop {
950 if start.elapsed().as_secs_f64() >= wait {
951 break;
952 }
953 complete = poll_shell(shell_arc).await;
954 if complete {
955 break;
956 }
957 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
958 }
959 if complete {
962 sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
963 poll_shell(shell_arc).await;
964 }
965 output = snapshot_shell(shell_arc).await;
966
967 if !complete && is_status_check {
976 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
977 let iter_wait_secs = 0.5_f64;
978 let mut patience = OUTPUT_WAIT_PATIENCE;
979
980 let incremental = wcgw_incremental_text(&output, &last_pending_output);
981 if incremental.is_empty() {
982 patience -= 1;
983 }
984
985 let mut last_incremental = incremental;
986
987 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
988 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
989 if remaining < 0.1 {
990 break;
991 }
992 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
993
994 let done = poll_shell(shell_arc).await;
998 let new_output = snapshot_shell(shell_arc).await;
999
1000 if done {
1001 complete = true;
1002 output = new_output;
1003 break;
1004 }
1005
1006 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
1008 if new_incremental == last_incremental {
1009 patience -= 1;
1010 } else {
1011 patience = OUTPUT_WAIT_PATIENCE; }
1013 last_incremental = new_incremental;
1014
1015 output = new_output;
1016 }
1017
1018 if !complete {
1019 last_pending_output = output.clone();
1021 }
1022 }
1023
1024 if complete {
1025 if let Some(cwd) = extract_prompt_cwd(&output) {
1026 bash_state.cwd = cwd;
1027 }
1028 }
1029
1030 let rendered = wcgw_incremental_text(&output, &last_pending_output);
1032
1033 let rendered = {
1037 let before_lines = rendered.lines().count();
1038 match crate::utils::output_compress::compress_output(&rendered) {
1039 Some(compressed) => {
1040 debug!(
1041 from_lines = before_lines,
1042 to_lines = compressed.lines().count(),
1043 "winx collapsed mechanical repetition in shell output"
1044 );
1045 compressed
1046 }
1047 None => rendered,
1048 }
1049 };
1050
1051 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1053
1054 let running_for =
1056 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
1057
1058 let (exit_code, scratch_pointer) = read_completion_extras(shell_arc, complete).await;
1063
1064 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref(), exit_code);
1066 Ok(format!("{rendered}{status}{scratch_pointer}"))
1067}
1068
1069async fn read_completion_extras(
1073 shell_arc: &SharedPtyShell,
1074 complete: bool,
1075) -> (Option<i32>, String) {
1076 let guard = shell_arc.lock().await;
1077 let Some(shell) = guard.as_ref() else {
1078 return (None, String::new());
1079 };
1080 let exit_code = if complete { shell.last_exit_code } else { None };
1081 let pointer = match (shell.output_truncated, shell.scratch_path()) {
1082 (true, Some(path)) => format!(
1083 "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1084 saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1085 path.display()
1086 ),
1087 _ => String::new(),
1088 };
1089 (exit_code, pointer)
1090}
1091
1092fn finalize_tombstone(
1099 cwd: &Path,
1100 id: &str,
1101 tombstone: ExitedShellInfo,
1102 action: &BashCommandAction,
1103) -> Result<String> {
1104 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
1105 match action {
1106 BashCommandAction::StatusCheck { .. }
1109 | BashCommandAction::Screen { .. }
1110 | BashCommandAction::WaitForTurn { .. } => {
1111 let rendered = wcgw_incremental_text(&final_output, "");
1112 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1113 let mut status = "\n\n---\n\n".to_string();
1115 let _ = writeln!(status, "bg_command_id = {id}");
1116 status.push_str("status = process exited\n");
1117 let _ = writeln!(status, "cwd = {}", cwd.display());
1118 Ok(format!("{rendered}{}", status.trim_end()))
1119 }
1120 BashCommandAction::SendText { .. }
1121 | BashCommandAction::SendSpecials { .. }
1122 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1123 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1124 ))),
1125 BashCommandAction::Command { .. } => {
1126 unreachable!("finalize_tombstone called for non-bg action")
1129 }
1130 }
1131}
1132
1133async fn execute_status_check(
1142 bash_state: &mut BashState,
1143 bg_shell: Option<SharedPtyShell>,
1144 is_bg: bool,
1145 bg_id: Option<&str>,
1146 timeout_s: f64,
1147 scrollback_lines: Option<usize>,
1148 verbose: bool,
1149) -> Result<String> {
1150 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1151
1152 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1155
1156 let is_running = {
1158 let guard = shell_arc.lock().await;
1159 if let Some(ref bash) = *guard {
1160 bash.command_running
1161 } else {
1162 false
1163 }
1164 };
1165
1166 if !is_running && !is_bg {
1168 let mut manager = lock_bg_manager();
1169 let error = format!(
1170 "No command is currently running, so there's nothing to check. The previous \
1171 command already finished and its output was returned when it completed. Start a \
1172 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1173 manager.get_running_info()
1174 );
1175 return Err(WinxError::CommandExecutionError(error));
1176 }
1177
1178 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1180
1181 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1185 if !verbose && scrollback_lines.is_none() {
1186 let mut guard = shell_arc.lock().await;
1187 if let Some(bash) = guard.as_mut() {
1188 let fingerprint = PtyShell::fingerprint(body);
1189 if Some(fingerprint) == bash.last_returned_hash {
1190 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1191 return Ok(format!("no new output since last check{status}"));
1192 }
1193 bash.last_returned_hash = Some(fingerprint);
1194 }
1195 } else if !verbose {
1196 let mut guard = shell_arc.lock().await;
1198 if let Some(bash) = guard.as_mut() {
1199 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1200 }
1201 }
1202
1203 if let Some(lines) = scrollback_lines {
1205 if lines > 0 {
1206 let scrollback = {
1207 let guard = shell_arc.lock().await;
1208 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1209 };
1210 if !scrollback.is_empty() {
1211 let count = scrollback.lines().count();
1212 return Ok(format!(
1213 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1214 ));
1215 }
1216 }
1217 }
1218
1219 Ok(response)
1220}
1221
1222async fn execute_screen(
1226 bash_state: &BashState,
1227 bg_shell: Option<SharedPtyShell>,
1228 is_bg: bool,
1229 bg_id: Option<&str>,
1230 lines: Option<usize>,
1231 diff: bool,
1232) -> Result<String> {
1233 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1234 let max_lines = lines.unwrap_or(0);
1235
1236 if diff {
1237 let (update, is_running, in_alt, cursor) = {
1238 let guard = shell_arc.lock().await;
1239 match guard.as_ref() {
1240 Some(bash) => (
1241 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1242 bash.command_running,
1243 bash.live_in_alt_screen(),
1244 bash.live_cursor_position(),
1245 ),
1246 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1247 }
1248 };
1249 let (crow, ccol) = cursor;
1250 let alt = if in_alt { " [alt-screen]" } else { "" };
1251 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1252 let body = match update {
1253 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1254 ScreenUpdate::Diff(changed) => {
1255 let mut out = String::from("(changed lines only)\n");
1256 for (row, content) in changed {
1257 let _ = writeln!(out, "{row:>4}: {content}");
1258 }
1259 out
1260 }
1261 ScreenUpdate::Full(snap) => {
1262 let joined = snap.join("\n");
1263 if joined.trim().is_empty() {
1264 "(screen is empty)".to_string()
1265 } else {
1266 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1267 }
1268 }
1269 };
1270 return Ok(format!(
1271 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1272 ));
1273 }
1274
1275 let (snapshot, is_running, in_alt, cursor) = {
1276 let guard = shell_arc.lock().await;
1277 match guard.as_ref() {
1278 Some(bash) => (
1279 bash.live_snapshot(max_lines),
1280 bash.command_running,
1281 bash.live_in_alt_screen(),
1282 bash.live_cursor_position(),
1283 ),
1284 None => (Vec::new(), false, false, (0, 0)),
1285 }
1286 };
1287
1288 let joined = snapshot.join("\n");
1289 let body = if joined.trim().is_empty() {
1290 "(screen is empty)".to_string()
1291 } else {
1292 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1293 };
1294 let alt = if in_alt { " [alt-screen]" } else { "" };
1295 let (crow, ccol) = cursor;
1296 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1297 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1298}
1299
1300#[allow(clippy::too_many_arguments)]
1308async fn execute_wait_for_turn(
1309 bash_state: &BashState,
1310 bg_shell: Option<SharedPtyShell>,
1311 is_bg: bool,
1312 bg_id: Option<&str>,
1313 recognizer_hint: Option<&str>,
1314 quiet_ms: Option<u64>,
1315 timeout_seconds: Option<f32>,
1316 lines: Option<usize>,
1317) -> Result<String> {
1318 use crate::state::turn::{recognizer_for, TurnState};
1319
1320 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1321 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1322 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1323 let settle = quiet.min(Duration::from_millis(300));
1324 let hard_cap =
1325 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1326 let max_lines = lines.unwrap_or(0);
1327 let poll = Duration::from_millis(120);
1328 let warmup = Duration::from_millis(2500);
1331
1332 let start = Instant::now();
1333 let mut last_hash: Option<u64> = None;
1334 let mut initial_hash: Option<u64> = None;
1335 let mut stable_since = Instant::now();
1336 let mut seen_busy = false;
1337
1338 loop {
1339 let (snapshot, in_alt, alive, running) = {
1340 let mut guard = shell_arc.lock().await;
1341 match guard.as_mut() {
1342 Some(bash) => (
1343 bash.live_snapshot(max_lines),
1344 bash.live_in_alt_screen(),
1345 bash.is_alive(),
1346 bash.command_running,
1347 ),
1348 None => (Vec::new(), false, false, false),
1349 }
1350 };
1351
1352 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1353 if initial_hash.is_none() {
1354 initial_hash = Some(hash);
1355 }
1356 if Some(hash) != last_hash {
1357 last_hash = Some(hash);
1358 stable_since = Instant::now();
1359 }
1360
1361 let state = recognizer.detect(&snapshot);
1362 if state == TurnState::Busy {
1363 seen_busy = true;
1364 }
1365 let stable_for = stable_since.elapsed();
1366 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1370 let ready = match state {
1371 TurnState::Busy => false,
1372 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1375 activity && stable_for >= if seen_busy { settle } else { quiet }
1376 }
1377 TurnState::Unknown => activity && stable_for >= quiet,
1378 };
1379
1380 let timed_out = start.elapsed() >= hard_cap;
1381 if ready || !alive || timed_out {
1382 let reason = if !alive {
1383 "exited"
1384 } else if ready {
1385 "ready"
1386 } else {
1387 "timeout"
1388 };
1389 let joined = snapshot.join("\n");
1390 let body = if joined.trim().is_empty() {
1391 "(screen is empty)".to_string()
1392 } else {
1393 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1394 };
1395 let alt = if in_alt { " [alt-screen]" } else { "" };
1396 let header = format!(
1397 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1398 state.as_str(),
1399 reason,
1400 recognizer.name(),
1401 start.elapsed().as_secs_f64(),
1402 alt
1403 );
1404 let status = get_status(bash_state, is_bg, bg_id, running, None, None);
1405 return Ok(format!("{header}\n{body}{status}"));
1406 }
1407
1408 tokio::time::sleep(poll).await;
1409 }
1410}
1411
1412async fn execute_send_text(
1414 bash_state: &mut BashState,
1415 text: &str,
1416 submit: bool,
1417 bg_shell: Option<SharedPtyShell>,
1418 is_bg: bool,
1419 bg_id: Option<&str>,
1420 timeout_s: f64,
1421) -> Result<String> {
1422 debug!("Processing SendText action: {text:?} (submit={submit})");
1423
1424 if text.is_empty() {
1426 return Err(WinxError::CommandExecutionError(
1427 "Failure: send_text cannot be empty".to_string(),
1428 ));
1429 }
1430
1431 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1433
1434 {
1436 let mut guard = shell_arc.lock().await;
1437
1438 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1439
1440 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1442 }
1443
1444 if submit {
1448 submit_enter(&shell_arc).await?;
1449 }
1450
1451 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1453}
1454
1455async fn execute_send_specials(
1457 bash_state: &mut BashState,
1458 keys: &[SpecialKey],
1459 submit: bool,
1460 bg_shell: Option<SharedPtyShell>,
1461 is_bg: bool,
1462 bg_id: Option<&str>,
1463 timeout_s: f64,
1464) -> Result<String> {
1465 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1466
1467 if keys.is_empty() {
1469 return Err(WinxError::CommandExecutionError(
1470 "Failure: send_specials cannot be empty".to_string(),
1471 ));
1472 }
1473
1474 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1475 let mut is_interrupt = false;
1476
1477 {
1478 let mut guard = shell_arc.lock().await;
1479
1480 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1481
1482 for key in keys {
1484 match key {
1485 SpecialKey::KeyUp => {
1486 bash.send_special_key("KeyUp").map_err(|e| {
1488 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1489 })?;
1490 }
1491 SpecialKey::KeyDown => {
1492 bash.send_special_key("KeyDown").map_err(|e| {
1494 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1495 })?;
1496 }
1497 SpecialKey::KeyLeft => {
1498 bash.send_special_key("KeyLeft").map_err(|e| {
1500 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1501 })?;
1502 }
1503 SpecialKey::KeyRight => {
1504 bash.send_special_key("KeyRight").map_err(|e| {
1506 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1507 })?;
1508 }
1509 SpecialKey::Enter => {
1510 bash.send_special_key("Enter").map_err(|e| {
1512 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1513 })?;
1514 }
1515 SpecialKey::CtrlC => {
1516 bash.send_interrupt().map_err(|e| {
1518 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1519 })?;
1520 is_interrupt = true;
1521 }
1522 SpecialKey::CtrlD => {
1523 bash.send_eof().map_err(|e| {
1525 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1526 })?;
1527 is_interrupt = true;
1528 }
1529 SpecialKey::CtrlZ => {
1530 bash.send_suspend().map_err(|e| {
1532 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1533 })?;
1534 }
1535 }
1536 }
1537 }
1538
1539 if submit {
1542 submit_enter(&shell_arc).await?;
1543 }
1544
1545 let mut output =
1553 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1554
1555 if is_interrupt && output.contains("status = still running") {
1557 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1558 }
1559
1560 Ok(output)
1561}
1562
1563async fn execute_send_ascii(
1565 bash_state: &mut BashState,
1566 ascii_codes: &[u8],
1567 submit: bool,
1568 bg_shell: Option<SharedPtyShell>,
1569 is_bg: bool,
1570 bg_id: Option<&str>,
1571 timeout_s: f64,
1572) -> Result<String> {
1573 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1574
1575 if ascii_codes.is_empty() {
1577 return Err(WinxError::CommandExecutionError(
1578 "Failure: send_ascii cannot be empty".to_string(),
1579 ));
1580 }
1581
1582 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1583 let mut is_interrupt = false;
1584
1585 {
1586 let mut guard = shell_arc.lock().await;
1587
1588 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1589
1590 for &code in ascii_codes {
1592 bash.send_bytes(&[code]).map_err(|e| {
1594 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1595 })?;
1596
1597 if code == 3 {
1599 is_interrupt = true;
1600 }
1601 }
1602 }
1603
1604 if submit {
1607 submit_enter(&shell_arc).await?;
1608 }
1609
1610 let mut output =
1616 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1617
1618 if is_interrupt && output.contains("status = still running") {
1620 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1621 }
1622
1623 Ok(output)
1624}
1625
1626async fn execute_in_background(
1628 bash_state: &mut BashState,
1629 command: &str,
1630 timeout_s: f64,
1631) -> Result<String> {
1632 debug!("Executing command in background: {}", command);
1633
1634 let restricted_mode =
1636 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1637
1638 let bg_id = {
1642 let cwd = bash_state.cwd.clone();
1643 let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1644 .await
1645 .map_err(|e| {
1646 WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1647 })?
1648 .map_err(|e| {
1649 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1650 })?;
1651 lock_bg_manager().register_shell(shell)
1652 };
1653
1654 let shell_arc = {
1656 let manager = lock_bg_manager();
1657 manager.get_shell(&bg_id).ok_or_else(|| {
1658 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1659 })?
1660 };
1661
1662 let scratch_root = bash_state.workspace_root.clone();
1664 {
1665 let mut guard = shell_arc.lock().await;
1666 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1667 bash.set_scratch_root(&scratch_root);
1670 bash.send_command(command).map_err(|e| {
1671 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1672 })?;
1673 }
1674 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1675
1676 let _ = timeout_s;
1677 let _ = shell_arc;
1678 Ok(get_status(bash_state, true, Some(&bg_id), true, None, None))
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683 use super::strip_tail_pipe_impl;
1684
1685 #[test]
1686 fn strips_trailing_tail_by_default() {
1687 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1688 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1689 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1690 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1691 }
1692
1693 #[test]
1694 fn keeps_command_without_trailing_tail() {
1695 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1697 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1698 assert_eq!(
1699 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1700 "cat a | tail -5 | wc -l"
1701 );
1702 }
1703
1704 #[test]
1705 fn keep_mode_preserves_tail_pipe() {
1706 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1708 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1709 }
1710}