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 OUTPUT_WAIT_PATIENCE: i32 = 3;
35
36fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
37 wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
38}
39
40const POLL_INTERVAL_MS: u64 = 20;
44
45const POST_PROMPT_DRAIN_MS: u64 = 100;
48
49const COMMAND_CHUNK_SIZE: usize = 64;
51
52const TEXT_CHUNK_SIZE: usize = 128;
54
55const MAX_OUTPUT_LENGTH: usize = 100_000;
59
60const MAX_OUTPUT_TOKENS: usize = 25_000;
65
66const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
73
74const SCREEN_DIFF_THRESHOLD: usize = 10;
77
78fn char_safe_tail(text: &str, max_len: usize) -> &str {
81 if text.len() <= max_len {
82 return text;
83 }
84 let mut start = text.len() - max_len;
85 while start < text.len() && !text.is_char_boundary(start) {
86 start += 1;
87 }
88 &text[start..]
89}
90
91fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
99 if text.len() <= MAX_OUTPUT_LENGTH {
100 return std::borrow::Cow::Borrowed(text);
101 }
102
103 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
104 return std::borrow::Cow::Owned(format!(
106 "(...truncated)\n{}",
107 char_safe_tail(text, MAX_OUTPUT_LENGTH)
108 ));
109 };
110
111 if tokens.len() <= max_tokens {
112 return std::borrow::Cow::Borrowed(text);
113 }
114
115 let keep = max_tokens.saturating_sub(1);
117 let tail = &tokens[tokens.len() - keep..];
118 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
119 char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
121 });
122 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
123}
124
125const 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.
1271. Get its output using status check.
1282. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1293. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1304. Interrupt and run the process in background
131";
132
133#[derive(Debug, Clone)]
139pub struct ExitedShellInfo {
140 pub owner_thread_id: String,
141 pub last_command: String,
142 pub final_output: Arc<str>,
143 pub exit_code: Option<i32>,
144 pub cwd: PathBuf,
145 pub output_truncated: bool,
146 pub scratch_path: Option<PathBuf>,
147 pub exited_at: Instant,
148}
149
150#[derive(Debug)]
151struct BackgroundShellEntry {
152 owner_thread_id: String,
153 shell: SharedPtyShell,
154}
155
156#[derive(Debug, Default)]
158pub struct BackgroundShellManager {
159 shells: HashMap<String, BackgroundShellEntry>,
160 tombstones: HashMap<String, ExitedShellInfo>,
163}
164
165impl BackgroundShellManager {
166 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
168 const MAX_SHELLS: usize = 32;
169 const MAX_SHELLS_PER_THREAD: usize = 8;
170 const MAX_TOMBSTONES: usize = 32;
171 const MAX_TOMBSTONES_PER_THREAD: usize = 8;
172
173 pub fn new() -> Self {
175 Self { shells: HashMap::new(), tombstones: HashMap::new() }
176 }
177
178 pub fn register_shell(&mut self, owner_thread_id: &str, shell: PtyShell) -> Result<String> {
186 self.prune_finished_shells();
187 self.ensure_capacity(owner_thread_id)?;
188
189 let cid = format!("{:016x}", rand::rng().random::<u64>());
190 self.shells.insert(
191 cid.clone(),
192 BackgroundShellEntry {
193 owner_thread_id: owner_thread_id.to_string(),
194 shell: Arc::new(Mutex::new(Some(shell))),
195 },
196 );
197 info!("Started background shell with id: {}", cid);
198 Ok(cid)
199 }
200
201 fn ensure_capacity(&self, owner_thread_id: &str) -> Result<()> {
202 let owned =
203 self.shells.values().filter(|entry| entry.owner_thread_id == owner_thread_id).count();
204 if owned >= Self::MAX_SHELLS_PER_THREAD {
205 return Err(WinxError::CommandExecutionError(format!(
206 "Background shell limit reached for this thread ({}). Reuse or stop an existing shell.",
207 Self::MAX_SHELLS_PER_THREAD
208 )));
209 }
210 if self.shells.len() >= Self::MAX_SHELLS {
211 return Err(WinxError::CommandExecutionError(format!(
212 "Global background shell limit reached ({}). Wait for an existing shell to exit.",
213 Self::MAX_SHELLS
214 )));
215 }
216 Ok(())
217 }
218
219 pub fn get_shell(&self, owner_thread_id: &str, bg_command_id: &str) -> Option<SharedPtyShell> {
221 self.shells
222 .get(bg_command_id)
223 .filter(|entry| entry.owner_thread_id == owner_thread_id)
224 .map(|entry| entry.shell.clone())
225 }
226
227 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
229 if let Some(entry) = self.shells.remove(bg_command_id) {
230 if let Ok(mut guard) = entry.shell.try_lock() {
231 *guard = None;
232 }
233 info!("Removed background shell: {}", bg_command_id);
234 true
235 } else {
236 false
237 }
238 }
239
240 fn prune_finished_shells(&mut self) {
241 let now = Instant::now();
243 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
244
245 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
246
247 for (id, entry) in &self.shells {
248 if Arc::strong_count(&entry.shell) > 1 {
253 continue;
254 }
255 let Ok(mut guard) = entry.shell.try_lock() else {
256 continue;
257 };
258
259 let Some(shell) = guard.as_mut() else {
260 finished.push((id.clone(), None));
261 continue;
262 };
263
264 if !shell.is_alive() {
265 let tombstone = Self::exited_shell_info(&entry.owner_thread_id, shell, now);
266 finished.push((id.clone(), Some(tombstone)));
267 continue;
268 }
269
270 if shell.last_command.is_empty() {
275 continue;
276 }
277
278 if shell.command_running {
279 shell.poll_output_nonblocking();
283 }
284
285 if !shell.command_running {
286 let tombstone = Self::exited_shell_info(&entry.owner_thread_id, shell, now);
287 finished.push((id.clone(), Some(tombstone)));
288 }
289 }
290
291 for (id, tombstone) in finished {
292 self.remove_shell(&id);
293 if let Some(info) = tombstone {
294 self.insert_tombstone(id, info);
295 }
296 }
297 }
298
299 fn exited_shell_info(
300 owner_thread_id: &str,
301 shell: &mut PtyShell,
302 now: Instant,
303 ) -> ExitedShellInfo {
304 ExitedShellInfo {
305 owner_thread_id: owner_thread_id.to_string(),
306 last_command: shell.last_command.clone(),
307 final_output: Arc::from(std::mem::take(&mut shell.output_buffer)),
308 exit_code: shell.last_exit_code,
309 cwd: shell.current_cwd().to_path_buf(),
310 output_truncated: shell.output_truncated,
311 scratch_path: shell.scratch_path().map(Path::to_path_buf),
312 exited_at: now,
313 }
314 }
315
316 fn insert_tombstone(&mut self, id: String, info: ExitedShellInfo) {
317 let owner = info.owner_thread_id.clone();
318 self.tombstones.insert(id, info);
319
320 while self.tombstones.values().filter(|entry| entry.owner_thread_id == owner).count()
321 > Self::MAX_TOMBSTONES_PER_THREAD
322 {
323 let oldest = self
324 .tombstones
325 .iter()
326 .filter(|(_, entry)| entry.owner_thread_id == owner)
327 .min_by_key(|(_, entry)| entry.exited_at)
328 .map(|(id, _)| id.clone());
329 if let Some(id) = oldest {
330 self.tombstones.remove(&id);
331 }
332 }
333
334 while self.tombstones.len() > Self::MAX_TOMBSTONES {
335 let oldest = self
336 .tombstones
337 .iter()
338 .min_by_key(|(_, entry)| entry.exited_at)
339 .map(|(id, _)| id.clone());
340 if let Some(id) = oldest {
341 self.tombstones.remove(&id);
342 }
343 }
344 }
345
346 pub fn peek_tombstone(
353 &self,
354 owner_thread_id: &str,
355 bg_command_id: &str,
356 ) -> Option<ExitedShellInfo> {
357 self.tombstones
358 .get(bg_command_id)
359 .filter(|entry| entry.owner_thread_id == owner_thread_id)
360 .cloned()
361 }
362
363 pub fn get_running_info(&mut self, owner_thread_id: &str) -> String {
365 self.prune_finished_shells();
366
367 if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
368 return "No command running in background.\n".to_string();
369 }
370
371 let mut running = Vec::new();
372 for (id, entry) in &self.shells {
373 if entry.owner_thread_id != owner_thread_id {
374 continue;
375 }
376 if let Ok(guard) = entry.shell.try_lock() {
377 if let Some(bash) = guard.as_ref() {
378 if bash.command_running {
379 running
380 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
381 }
382 }
383 } else {
384 running.push(format!("Command: <busy>, bg_command_id: {id}"));
385 }
386 }
387
388 if running.is_empty() {
389 "No command running in background.\n".to_string()
390 } else {
391 format!("Following background commands are attached:\n{}\n", running.join("\n"))
392 }
393 }
394}
395
396lazy_static::lazy_static! {
398 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
399}
400
401fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
408 BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
409}
410
411fn spawn_background_reaper(owner_thread_id: String, bg_command_id: String) {
416 tokio::spawn(async move {
417 loop {
418 sleep(Duration::from_millis(100)).await;
419 let shell_arc = {
420 let manager = lock_bg_manager();
421 manager.get_shell(&owner_thread_id, &bg_command_id)
422 };
423 let Some(shell_arc) = shell_arc else {
424 return;
425 };
426
427 let finished = {
428 let mut guard = shell_arc.lock().await;
429 match guard.as_mut() {
430 Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
431 None => true,
432 }
433 };
434 if finished {
435 drop(shell_arc);
439 let removed = {
440 let mut manager = lock_bg_manager();
441 manager.prune_finished_shells();
442 manager.get_shell(&owner_thread_id, &bg_command_id).is_none()
443 };
444 if removed {
445 return;
446 }
447 }
448 }
449 });
450}
451
452fn get_status(
456 bash_state: &BashState,
457 is_bg: bool,
458 bg_id: Option<&str>,
459 is_running: bool,
460 running_for: Option<&str>,
461 exit_code: Option<i32>,
462 reported_cwd: Option<&Path>,
463) -> String {
464 let mut status = "\n\n---\n\n".to_string();
465
466 if is_bg {
467 if let Some(id) = bg_id {
468 let _ = writeln!(status, "bg_command_id = {id}");
469 }
470 }
471
472 if is_running {
473 status.push_str("status = still running\n");
474 if let Some(duration) = running_for {
475 let _ = writeln!(status, "running for = {duration}");
476 }
477 } else {
478 status.push_str("status = process exited\n");
479 if let Some(code) = exit_code {
482 let _ = writeln!(status, "exit code = {code}");
483 }
484 }
485
486 let cwd = reported_cwd.unwrap_or(&bash_state.cwd);
487 let _ = writeln!(status, "cwd = {}", cwd.display());
488
489 if !is_bg {
490 {
492 let mut manager = lock_bg_manager();
493 status.push_str("This is the main shell. ");
494 status.push_str(&manager.get_running_info(&bash_state.current_thread_id));
495 }
496 }
497
498 status.trim_end().to_string()
499}
500
501fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
503 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
504 let text = truncated.as_ref();
505
506 if last_pending_output.is_empty() {
507 let rendered = render_terminal_output(text);
508 return rstrip_lines(&rendered).trim_start().to_string();
509 }
510
511 let last_rendered = render_terminal_output(last_pending_output);
512 if last_rendered.is_empty() {
513 return rstrip_lines(&render_terminal_output(text));
514 }
515
516 let text_after_last = if text.len() > last_pending_output.len() {
520 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
521 &text[cut..]
522 } else {
523 text
524 };
525
526 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
527 let new_rendered = render_terminal_output(&combined);
528
529 let incremental = get_incremental_output(&last_rendered, &new_rendered);
531 rstrip_lines(&incremental)
532}
533
534fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
535 static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
536 #[allow(clippy::expect_used)]
541 let prompt_regex = PROMPT_RE
542 .get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
543 let stripped = strip_ansi_codes(output);
544
545 prompt_regex
546 .captures_iter(&stripped)
547 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
548 .filter(|cwd| !cwd.is_empty())
549 .last()
550 .map(PathBuf::from)
551}
552
553fn rstrip_lines(lines: &[String]) -> String {
555 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
556}
557
558fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
560 if old_output.is_empty() {
561 return new_output.to_vec();
562 }
563
564 let nold = old_output.len();
565 let nnew = new_output.len();
566
567 for i in (0..nnew).rev() {
569 if new_output[i] != old_output[nold - 1] {
570 continue;
571 }
572
573 let mut matched = true;
574 for j in (0..i).rev() {
575 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
576 if old_idx < 0 {
577 break;
578 }
579 if new_output[j] != old_output[old_idx as usize] {
580 matched = false;
581 break;
582 }
583 }
584
585 if matched {
586 return new_output[i + 1..].to_vec();
587 }
588 }
589
590 new_output.to_vec()
591}
592
593fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
594 let mut start = 0;
595
596 while start < text.len() {
597 let mut end = (start + chunk_size).min(text.len());
598 while !text.is_char_boundary(end) {
599 end -= 1;
600 }
601 if end == start {
602 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
603 }
604
605 shell.send_text(&text[start..end]).map_err(|e| {
606 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
607 })?;
608 start = end;
609 }
610
611 Ok(())
612}
613
614async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
619 tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
620 let mut guard = shell_arc.lock().await;
621 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
622 ensure_interactive_target(bash)?;
623 bash.send_special_key("Enter")
624 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
625 Ok(())
626}
627
628fn ensure_interactive_target(shell: &mut PtyShell) -> Result<()> {
633 shell.poll_output_nonblocking();
634 if shell.command_running {
635 Ok(())
636 } else {
637 Err(WinxError::CommandExecutionError(
638 "No interactive command is running. Start an allowed command first; send_text/send_ascii/send_specials cannot execute commands in an idle shell."
639 .to_string(),
640 ))
641 }
642}
643
644#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
651pub async fn handle_tool_call(
652 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
653 bash_command: BashCommand,
654) -> Result<String> {
655 let action_kind = match &bash_command.action_json {
656 BashCommandAction::Command { .. } => "command",
657 BashCommandAction::StatusCheck { .. } => "status_check",
658 BashCommandAction::SendText { .. } => "send_text",
659 BashCommandAction::SendSpecials { .. } => "send_specials",
660 BashCommandAction::SendAscii { .. } => "send_ascii",
661 BashCommandAction::Screen { .. } => "screen",
662 BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
663 };
664 info!(thread_id = %bash_command.thread_id, action = action_kind, "BashCommand tool called");
665
666 let thread_id = normalize_thread_id(&bash_command.thread_id);
667
668 if thread_id.is_empty() {
670 error!("Empty thread_id provided in BashCommand");
671 return Err(WinxError::ThreadIdMismatch(
672 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
673 ));
674 }
675
676 let mut bash_state: BashState;
678 {
679 let bash_state_guard = bash_state_arc.lock().await;
680
681 let Some(state) = &*bash_state_guard else {
682 error!("BashState not initialized");
683 return Err(WinxError::BashStateNotInitialized);
684 };
685
686 bash_state = state.clone();
687 }
688
689 if thread_id != bash_state.current_thread_id {
691 let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
696 WinxError::CommandExecutionError(format!(
697 "Failed to load saved bash state for thread_id `{thread_id}`: {e}"
698 ))
699 })?;
700 if !loaded {
701 return Err(WinxError::ThreadIdMismatch(format!(
702 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
703 )));
704 }
705 if let Some(state) = bash_state_arc.lock().await.as_mut() {
711 *state = bash_state.clone();
712 }
713 }
714
715 let timeout_s = effective_wait_for_seconds(bash_command.wait_for_seconds);
717
718 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
720
721 {
722 let mut bash_state_guard = bash_state_arc.lock().await;
723 if let Some(state) = bash_state_guard.as_mut() {
724 state.cwd.clone_from(&bash_state.cwd);
725 }
726 }
727
728 match result {
730 Ok(mut output) => {
731 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
732 let cmd_trimmed = command.trim();
733 if output.starts_with(cmd_trimmed) {
734 output = output[cmd_trimmed.len()..].to_string();
735 }
736 }
737 Ok(output)
738 }
739 Err(e) => Err(e),
740 }
741}
742
743#[allow(clippy::too_many_lines)]
745async fn execute_bash_action(
746 bash_state: &mut BashState,
747 action: &BashCommandAction,
748 timeout_s: f64,
749) -> Result<String> {
750 let mut is_bg = false;
751 let mut bg_id: Option<String> = None;
752
753 let bg_shell: Option<SharedPtyShell> = match action {
755 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
757 | BashCommandAction::SendText { bg_command_id, .. }
758 | BashCommandAction::SendSpecials { bg_command_id, .. }
759 | BashCommandAction::SendAscii { bg_command_id, .. }
760 | BashCommandAction::Screen { bg_command_id, .. }
761 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
762 if let Some(id) = bg_command_id {
763 let mut manager = lock_bg_manager();
766 manager.prune_finished_shells();
767
768 if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
769 is_bg = true;
770 bg_id = Some(id.clone());
771 Some(shell)
772 } else if let Some(tombstone) =
773 manager.peek_tombstone(&bash_state.current_thread_id, id)
774 {
775 drop(manager);
780 return finalize_tombstone(id, tombstone, action);
781 } else {
782 let error = format!(
784 "No shell found running with command id {}.\n{}",
785 id,
786 manager.get_running_info(&bash_state.current_thread_id)
787 );
788 return Err(WinxError::CommandExecutionError(error));
789 }
790 } else {
791 None
792 }
793 }
794 };
795
796 match action {
798 BashCommandAction::Command { command, is_background, allow_multi } => {
799 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
800 }
801 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
802 execute_status_check(
803 bash_state,
804 bg_shell,
805 is_bg,
806 bg_id.as_deref(),
807 timeout_s,
808 *scrollback_lines,
809 *verbose,
810 )
811 .await
812 }
813 BashCommandAction::SendText { send_text, submit, .. } => {
814 execute_send_text(
815 bash_state,
816 send_text,
817 *submit,
818 bg_shell,
819 is_bg,
820 bg_id.as_deref(),
821 timeout_s,
822 )
823 .await
824 }
825 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
826 execute_send_specials(
827 bash_state,
828 send_specials,
829 *submit,
830 bg_shell,
831 is_bg,
832 bg_id.as_deref(),
833 timeout_s,
834 )
835 .await
836 }
837 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
838 execute_send_ascii(
839 bash_state,
840 send_ascii,
841 *submit,
842 bg_shell,
843 is_bg,
844 bg_id.as_deref(),
845 timeout_s,
846 )
847 .await
848 }
849 BashCommandAction::Screen { lines, diff, .. } => {
850 execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
851 }
852 BashCommandAction::WaitForTurn {
853 recognizer,
854 quiet_ms,
855 timeout_seconds,
856 lines,
857 wait_through_busy,
858 ..
859 } => {
860 execute_wait_for_turn(
861 bash_state,
862 bg_shell,
863 is_bg,
864 bg_id.as_deref(),
865 recognizer.as_deref(),
866 *quiet_ms,
867 *timeout_seconds,
868 *lines,
869 *wait_through_busy,
870 )
871 .await
872 }
873 }
874}
875
876fn strip_tail_pipe(command: &str) -> String {
886 strip_tail_pipe_impl(command, keep_tail_pipe())
887}
888
889fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
892 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
893 if keep {
894 return command.to_string();
895 }
896 #[allow(clippy::expect_used)]
900 let re = RE.get_or_init(|| {
901 regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
902 .expect("tail-pipe regex must compile")
903 });
904 match re.find(command) {
905 Some(matched) => command[..matched.start()].trim_end().to_string(),
906 None => command.to_string(),
907 }
908}
909
910fn keep_tail_pipe() -> bool {
912 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
913 let value = value.trim();
914 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
915 })
916}
917
918async fn execute_command(
920 bash_state: &mut BashState,
921 command: &str,
922 is_background: bool,
923 allow_multi: bool,
924 timeout_s: f64,
925) -> Result<String> {
926 let stripped_command = strip_tail_pipe(command);
928 let command = stripped_command.as_str();
929 debug!(bytes = command.len(), allow_multi, "Processing Command action");
930
931 if !bash_state.is_command_allowed(command) {
933 error!(bytes = command.len(), "Command not allowed in current mode");
934 return Err(WinxError::CommandNotAllowed(
935 "Error: BashCommand not allowed in current mode".to_string(),
936 ));
937 }
938
939 let command = command.trim();
943 if !allow_multi {
944 let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
948 crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
949 }
950
951 if is_background {
953 return execute_in_background(bash_state, command, timeout_s).await;
954 }
955
956 let foreground_gate = bash_state.foreground_command_gate.clone();
961 let _foreground_guard = foreground_gate.lock_owned().await;
962
963 {
965 let bash_guard = bash_state.pty_shell.lock().await;
966
967 if let Some(ref bash) = *bash_guard {
968 if bash.command_running {
969 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
970 }
971 }
972 }
973
974 if bash_state.pty_shell.lock().await.is_none() {
976 bash_state
977 .init_pty_shell()
978 .await
979 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
980 }
981
982 {
987 let needs_reset = {
988 if bash_state.pty_shell.lock().await.is_some() {
992 if clear_to_run_async(&bash_state.pty_shell, DEFAULT_TIMEOUT).await {
993 false
994 } else {
995 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
996 true
997 }
998 } else {
999 false
1000 }
1001 };
1002 if needs_reset {
1006 if let Err(e) = bash_state.init_pty_shell().await {
1007 warn!("Failed to reset shell after clear_to_run: {e}");
1008 }
1009 }
1010 }
1011
1012 let scratch_root = bash_state.workspace_root.clone();
1014 {
1015 let mut bash_guard = bash_state.pty_shell.lock().await;
1016
1017 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1018
1019 bash.output_buffer.clear();
1020 bash.output_truncated = false;
1021 bash.reset_scratch();
1024 bash.set_scratch_root(&scratch_root);
1025 bash.last_exit_code = None;
1030 bash.last_returned_hash = None;
1031 bash.mark_output_delivered("");
1032 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
1034
1035 bash.send_special_key("Enter").map_err(|e| {
1037 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
1038 })?;
1039
1040 bash.last_command = command.to_string();
1041 bash.command_running = true;
1042 bash.mark_command_started();
1043 }
1044
1045 let shell_arc = bash_state.pty_shell.clone();
1047 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
1048}
1049
1050async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
1057 let mut guard = shell_arc.lock().await;
1058 match guard.as_mut() {
1059 Some(bash) => bash.poll_output_nonblocking(),
1060 None => true,
1061 }
1062}
1063
1064async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
1066 let mut guard = shell_arc.lock().await;
1067 guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
1068}
1069
1070async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
1074 let start = Instant::now();
1075 loop {
1076 if poll_shell(shell_arc).await {
1077 return true;
1078 }
1079 if start.elapsed().as_secs_f64() >= budget_secs {
1080 return false;
1081 }
1082 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1083 }
1084}
1085
1086async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
1093 if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
1095 return true;
1096 }
1097 {
1099 let mut guard = shell_arc.lock().await;
1100 if let Some(bash) = guard.as_mut() {
1101 if let Err(e) = bash.send_interrupt() {
1102 warn!("clear_to_run: failed to send Ctrl-C: {e}");
1103 }
1104 }
1105 }
1106 drain_until_prompt(shell_arc, max_wait_secs).await
1107}
1108
1109async fn wait_for_output(
1111 bash_state: &mut BashState,
1112 shell_arc: &SharedPtyShell,
1113 timeout_s: f64,
1114 is_bg: bool,
1115 bg_id: Option<&str>,
1116 is_status_check: bool,
1117) -> Result<String> {
1118 let start = Instant::now();
1119 let wait = timeout_s;
1120 let previously_delivered = {
1121 let guard = shell_arc.lock().await;
1122 guard.as_ref().map(PtyShell::delivered_output).unwrap_or_default()
1123 };
1124 let mut complete = false;
1125
1126 let mut output = String::new();
1138 loop {
1139 if start.elapsed().as_secs_f64() >= wait {
1140 break;
1141 }
1142 complete = poll_shell(shell_arc).await;
1143 if complete {
1144 break;
1145 }
1146 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1147 }
1148 if complete {
1151 sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
1152 poll_shell(shell_arc).await;
1153 }
1154 output = snapshot_shell(shell_arc).await;
1155
1156 if !complete && is_status_check {
1162 let budget_secs = timeout_s;
1163 let iter_wait_secs = 0.5_f64;
1164 let mut patience = OUTPUT_WAIT_PATIENCE;
1165
1166 let incremental = wcgw_incremental_text(&output, &previously_delivered);
1167 if incremental.is_empty() {
1168 patience -= 1;
1169 }
1170
1171 let mut last_incremental = incremental;
1172
1173 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
1174 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
1175 if remaining < 0.1 {
1176 break;
1177 }
1178 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
1179
1180 let done = poll_shell(shell_arc).await;
1184 let new_output = snapshot_shell(shell_arc).await;
1185
1186 if done {
1187 complete = true;
1188 output = new_output;
1189 break;
1190 }
1191
1192 let new_incremental = wcgw_incremental_text(&new_output, &previously_delivered);
1194 if new_incremental == last_incremental {
1195 patience -= 1;
1196 } else {
1197 patience = OUTPUT_WAIT_PATIENCE; }
1199 last_incremental = new_incremental;
1200
1201 output = new_output;
1202 }
1203 }
1204
1205 if complete && !is_bg {
1206 if let Some(cwd) = extract_prompt_cwd(&output) {
1207 bash_state.cwd = cwd;
1208 }
1209 }
1210
1211 let rendered = wcgw_incremental_text(&output, &previously_delivered);
1213 {
1217 let mut guard = shell_arc.lock().await;
1218 if let Some(shell) = guard.as_mut() {
1219 shell.mark_output_delivered(&output);
1220 }
1221 }
1222
1223 let rendered = {
1227 let before_lines = rendered.lines().count();
1228 match crate::utils::output_compress::compress_output(&rendered) {
1229 Some(compressed) => {
1230 debug!(
1231 from_lines = before_lines,
1232 to_lines = compressed.lines().count(),
1233 "winx collapsed mechanical repetition in shell output"
1234 );
1235 compressed
1236 }
1237 None => rendered,
1238 }
1239 };
1240
1241 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1243
1244 let (running_for, exit_code, shell_cwd, scratch_pointer) =
1246 read_status_extras(shell_arc, complete).await;
1247 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1248
1249 let status = get_status(
1251 bash_state,
1252 is_bg,
1253 bg_id,
1254 !complete,
1255 running_for.as_deref(),
1256 exit_code,
1257 shell_cwd.as_deref(),
1258 );
1259 Ok(format!("{rendered}{status}{scratch_pointer}"))
1260}
1261
1262async fn read_status_extras(
1264 shell_arc: &SharedPtyShell,
1265 complete: bool,
1266) -> (Option<Duration>, Option<i32>, Option<PathBuf>, String) {
1267 let guard = shell_arc.lock().await;
1268 let Some(shell) = guard.as_ref() else {
1269 return (None, None, None, String::new());
1270 };
1271 let running_for = if complete { None } else { shell.command_elapsed() };
1272 let exit_code = if complete { shell.last_exit_code } else { None };
1273 let cwd = Some(shell.current_cwd().to_path_buf());
1274 let pointer = scratch_pointer(shell.output_truncated, shell.scratch_path());
1275 (running_for, exit_code, cwd, pointer)
1276}
1277
1278fn scratch_pointer(output_truncated: bool, scratch_path: Option<&Path>) -> String {
1279 match (output_truncated, scratch_path) {
1280 (true, Some(path)) => format!(
1281 "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1282 saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1283 path.display()
1284 ),
1285 _ => String::new(),
1286 }
1287}
1288
1289fn finalize_tombstone(
1296 id: &str,
1297 tombstone: ExitedShellInfo,
1298 action: &BashCommandAction,
1299) -> Result<String> {
1300 let ExitedShellInfo {
1301 last_command,
1302 final_output,
1303 exit_code,
1304 cwd,
1305 output_truncated,
1306 scratch_path,
1307 ..
1308 } = tombstone;
1309 match action {
1310 BashCommandAction::StatusCheck { .. }
1313 | BashCommandAction::Screen { .. }
1314 | BashCommandAction::WaitForTurn { .. } => {
1315 let rendered = wcgw_incremental_text(final_output.as_ref(), "");
1316 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1317 let mut status = "\n\n---\n\n".to_string();
1319 let _ = writeln!(status, "bg_command_id = {id}");
1320 status.push_str("status = process exited\n");
1321 if let Some(code) = exit_code {
1322 let _ = writeln!(status, "exit code = {code}");
1323 }
1324 let _ = writeln!(status, "cwd = {}", cwd.display());
1325 let pointer = scratch_pointer(output_truncated, scratch_path.as_deref());
1326 Ok(format!("{rendered}{}{pointer}", status.trim_end()))
1327 }
1328 BashCommandAction::SendText { .. }
1329 | BashCommandAction::SendSpecials { .. }
1330 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1331 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1332 ))),
1333 BashCommandAction::Command { .. } => {
1334 unreachable!("finalize_tombstone called for non-bg action")
1337 }
1338 }
1339}
1340
1341async fn execute_status_check(
1350 bash_state: &mut BashState,
1351 bg_shell: Option<SharedPtyShell>,
1352 is_bg: bool,
1353 bg_id: Option<&str>,
1354 timeout_s: f64,
1355 scrollback_lines: Option<usize>,
1356 verbose: bool,
1357) -> Result<String> {
1358 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1359
1360 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1363
1364 let is_running = {
1366 let guard = shell_arc.lock().await;
1367 if let Some(ref bash) = *guard {
1368 bash.command_running
1369 } else {
1370 false
1371 }
1372 };
1373
1374 if !is_running && !is_bg {
1376 let mut manager = lock_bg_manager();
1377 let error = format!(
1378 "No command is currently running, so there's nothing to check. The previous \
1379 command already finished and its output was returned when it completed. Start a \
1380 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1381 manager.get_running_info(&bash_state.current_thread_id)
1382 );
1383 return Err(WinxError::CommandExecutionError(error));
1384 }
1385
1386 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1388
1389 if !verbose && scrollback_lines.is_none() {
1393 let mut guard = shell_arc.lock().await;
1394 if let Some(bash) = guard.as_mut() {
1395 let fingerprint = PtyShell::fingerprint(&bash.output_snapshot());
1396 if Some(fingerprint) == bash.last_returned_hash {
1397 let running_for =
1398 bash.command_elapsed().map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1399 let running = bash.command_running;
1400 let exit_code = (!running).then_some(bash.last_exit_code).flatten();
1401 let status = get_status(
1402 bash_state,
1403 is_bg,
1404 bg_id,
1405 running,
1406 running_for.as_deref(),
1407 exit_code,
1408 Some(bash.current_cwd()),
1409 );
1410 return Ok(format!("no new output since last check{status}"));
1411 }
1412 bash.last_returned_hash = Some(fingerprint);
1413 }
1414 } else if !verbose {
1415 let mut guard = shell_arc.lock().await;
1417 if let Some(bash) = guard.as_mut() {
1418 bash.last_returned_hash = Some(PtyShell::fingerprint(&bash.output_snapshot()));
1419 }
1420 }
1421
1422 if let Some(lines) = scrollback_lines {
1424 if lines > 0 {
1425 let scrollback = {
1426 let guard = shell_arc.lock().await;
1427 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1428 };
1429 if !scrollback.is_empty() {
1430 let count = scrollback.lines().count();
1431 return Ok(format!(
1432 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1433 ));
1434 }
1435 }
1436 }
1437
1438 Ok(response)
1439}
1440
1441async fn execute_screen(
1445 bash_state: &BashState,
1446 bg_shell: Option<SharedPtyShell>,
1447 is_bg: bool,
1448 bg_id: Option<&str>,
1449 lines: Option<usize>,
1450 diff: bool,
1451) -> Result<String> {
1452 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1453 let max_lines = lines.unwrap_or(0);
1454
1455 if diff {
1456 let (update, is_running, in_alt, cursor, running_for, exit_code, cwd) = {
1457 let mut guard = shell_arc.lock().await;
1458 match guard.as_mut() {
1459 Some(bash) => {
1460 bash.poll_output_nonblocking();
1461 let running = bash.command_running;
1462 (
1463 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1464 running,
1465 bash.live_in_alt_screen(),
1466 bash.live_cursor_position(),
1467 running.then(|| bash.command_elapsed()).flatten(),
1468 (!running).then_some(bash.last_exit_code).flatten(),
1469 Some(bash.current_cwd().to_path_buf()),
1470 )
1471 }
1472 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0), None, None, None),
1473 }
1474 };
1475 let (crow, ccol) = cursor;
1476 let alt = if in_alt { " [alt-screen]" } else { "" };
1477 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1478 let status = get_status(
1479 bash_state,
1480 is_bg,
1481 bg_id,
1482 is_running,
1483 running_for.as_deref(),
1484 exit_code,
1485 cwd.as_deref(),
1486 );
1487 let body = match update {
1488 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1489 ScreenUpdate::Diff(changed) => {
1490 let mut out = String::from("(changed lines only)\n");
1491 for (row, content) in changed {
1492 let _ = writeln!(out, "{row:>4}: {content}");
1493 }
1494 out
1495 }
1496 ScreenUpdate::Full(snap) => {
1497 let joined = snap.join("\n");
1498 if joined.trim().is_empty() {
1499 "(screen is empty)".to_string()
1500 } else {
1501 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1502 }
1503 }
1504 };
1505 return Ok(format!(
1506 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1507 ));
1508 }
1509
1510 let (snapshot, is_running, in_alt, cursor, running_for, exit_code, cwd) = {
1511 let mut guard = shell_arc.lock().await;
1512 match guard.as_mut() {
1513 Some(bash) => {
1514 bash.poll_output_nonblocking();
1515 let running = bash.command_running;
1516 (
1517 bash.live_snapshot(max_lines),
1518 running,
1519 bash.live_in_alt_screen(),
1520 bash.live_cursor_position(),
1521 running.then(|| bash.command_elapsed()).flatten(),
1522 (!running).then_some(bash.last_exit_code).flatten(),
1523 Some(bash.current_cwd().to_path_buf()),
1524 )
1525 }
1526 None => (Vec::new(), false, false, (0, 0), None, None, None),
1527 }
1528 };
1529
1530 let joined = snapshot.join("\n");
1531 let body = if joined.trim().is_empty() {
1532 "(screen is empty)".to_string()
1533 } else {
1534 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1535 };
1536 let alt = if in_alt { " [alt-screen]" } else { "" };
1537 let (crow, ccol) = cursor;
1538 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1539 let status = get_status(
1540 bash_state,
1541 is_bg,
1542 bg_id,
1543 is_running,
1544 running_for.as_deref(),
1545 exit_code,
1546 cwd.as_deref(),
1547 );
1548 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1549}
1550
1551#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1560fn wait_turn_outcome(
1561 state: crate::state::turn::TurnState,
1562 alive: bool,
1563 activity: bool,
1564 seen_busy: bool,
1565 stable_for: Duration,
1566 busy_for: Duration,
1567 settle: Duration,
1568 quiet: Duration,
1569 timed_out: bool,
1570 wait_through_busy: bool,
1571) -> Option<&'static str> {
1572 use crate::state::turn::TurnState;
1573 if !alive {
1574 return Some("exited");
1575 }
1576 let ready = match state {
1577 TurnState::Busy => false,
1578 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1581 activity && stable_for >= if seen_busy { settle } else { quiet }
1582 }
1583 TurnState::Unknown => activity && stable_for >= quiet,
1584 };
1585 if ready {
1586 return Some("ready");
1587 }
1588 if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
1593 return Some("busy");
1594 }
1595 if timed_out {
1596 return Some("timeout");
1597 }
1598 None
1599}
1600
1601#[allow(clippy::too_many_arguments)]
1611async fn execute_wait_for_turn(
1612 bash_state: &BashState,
1613 bg_shell: Option<SharedPtyShell>,
1614 is_bg: bool,
1615 bg_id: Option<&str>,
1616 recognizer_hint: Option<&str>,
1617 quiet_ms: Option<u64>,
1618 timeout_seconds: Option<f32>,
1619 lines: Option<usize>,
1620 wait_through_busy: bool,
1621) -> Result<String> {
1622 use crate::state::turn::{recognizer_for, TurnState};
1623
1624 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1625 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1626 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1627 let settle = quiet.min(Duration::from_millis(300));
1628 let hard_cap =
1629 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1630 let max_lines = lines.unwrap_or(0);
1631 let poll = Duration::from_millis(120);
1632 let warmup = Duration::from_millis(2500);
1635
1636 let start = Instant::now();
1637 let mut last_hash: Option<u64> = None;
1638 let mut initial_hash: Option<u64> = None;
1639 let mut stable_since = Instant::now();
1640 let mut seen_busy = false;
1641 let mut busy_since: Option<Instant> = None;
1642
1643 loop {
1644 let (snapshot, in_alt, alive, running, running_for, exit_code, cwd) = {
1645 let mut guard = shell_arc.lock().await;
1646 match guard.as_mut() {
1647 Some(bash) => {
1648 bash.poll_output_nonblocking();
1649 let running = bash.command_running;
1650 (
1651 bash.live_snapshot(max_lines),
1652 bash.live_in_alt_screen(),
1653 bash.is_alive(),
1654 running,
1655 running.then(|| bash.command_elapsed()).flatten(),
1656 (!running).then_some(bash.last_exit_code).flatten(),
1657 Some(bash.current_cwd().to_path_buf()),
1658 )
1659 }
1660 None => (Vec::new(), false, false, false, None, None, None),
1661 }
1662 };
1663
1664 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1665 if initial_hash.is_none() {
1666 initial_hash = Some(hash);
1667 }
1668 if Some(hash) != last_hash {
1669 last_hash = Some(hash);
1670 stable_since = Instant::now();
1671 }
1672
1673 let state = recognizer.detect(&snapshot);
1674 if state == TurnState::Busy {
1675 seen_busy = true;
1676 if busy_since.is_none() {
1677 busy_since = Some(Instant::now());
1678 }
1679 } else {
1680 busy_since = None;
1681 }
1682 let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
1683 let stable_for = stable_since.elapsed();
1684 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1688 let timed_out = start.elapsed() >= hard_cap;
1689 if let Some(reason) = wait_turn_outcome(
1690 state,
1691 alive,
1692 activity,
1693 seen_busy,
1694 stable_for,
1695 busy_for,
1696 settle,
1697 quiet,
1698 timed_out,
1699 wait_through_busy,
1700 ) {
1701 let joined = snapshot.join("\n");
1702 let body = if joined.trim().is_empty() {
1703 "(screen is empty)".to_string()
1704 } else {
1705 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1706 };
1707 let alt = if in_alt { " [alt-screen]" } else { "" };
1708 let header = format!(
1709 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1710 state.as_str(),
1711 reason,
1712 recognizer.name(),
1713 start.elapsed().as_secs_f64(),
1714 alt
1715 );
1716 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1717 let status = get_status(
1718 bash_state,
1719 is_bg,
1720 bg_id,
1721 running,
1722 running_for.as_deref(),
1723 exit_code,
1724 cwd.as_deref(),
1725 );
1726 return Ok(format!("{header}\n{body}{status}"));
1727 }
1728
1729 tokio::time::sleep(poll).await;
1730 }
1731}
1732
1733async fn execute_send_text(
1735 bash_state: &mut BashState,
1736 text: &str,
1737 submit: bool,
1738 bg_shell: Option<SharedPtyShell>,
1739 is_bg: bool,
1740 bg_id: Option<&str>,
1741 timeout_s: f64,
1742) -> Result<String> {
1743 debug!(bytes = text.len(), submit, "Processing SendText action");
1744
1745 if text.is_empty() {
1747 return Err(WinxError::CommandExecutionError(
1748 "Failure: send_text cannot be empty".to_string(),
1749 ));
1750 }
1751
1752 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1754
1755 {
1757 let mut guard = shell_arc.lock().await;
1758
1759 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1760 ensure_interactive_target(bash)?;
1761
1762 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1764 }
1765
1766 if submit {
1770 submit_enter(&shell_arc).await?;
1771 }
1772
1773 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1775}
1776
1777async fn execute_send_specials(
1779 bash_state: &mut BashState,
1780 keys: &[SpecialKey],
1781 submit: bool,
1782 bg_shell: Option<SharedPtyShell>,
1783 is_bg: bool,
1784 bg_id: Option<&str>,
1785 timeout_s: f64,
1786) -> Result<String> {
1787 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1788
1789 if keys.is_empty() {
1791 return Err(WinxError::CommandExecutionError(
1792 "Failure: send_specials cannot be empty".to_string(),
1793 ));
1794 }
1795
1796 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1797 let mut is_interrupt = false;
1798
1799 {
1800 let mut guard = shell_arc.lock().await;
1801
1802 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1803 ensure_interactive_target(bash)?;
1804
1805 for key in keys {
1807 match key {
1808 SpecialKey::KeyUp => {
1809 bash.send_special_key("KeyUp").map_err(|e| {
1811 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1812 })?;
1813 }
1814 SpecialKey::KeyDown => {
1815 bash.send_special_key("KeyDown").map_err(|e| {
1817 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1818 })?;
1819 }
1820 SpecialKey::KeyLeft => {
1821 bash.send_special_key("KeyLeft").map_err(|e| {
1823 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1824 })?;
1825 }
1826 SpecialKey::KeyRight => {
1827 bash.send_special_key("KeyRight").map_err(|e| {
1829 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1830 })?;
1831 }
1832 SpecialKey::Enter => {
1833 bash.send_special_key("Enter").map_err(|e| {
1835 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1836 })?;
1837 }
1838 SpecialKey::CtrlC => {
1839 bash.send_interrupt().map_err(|e| {
1841 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1842 })?;
1843 is_interrupt = true;
1844 }
1845 SpecialKey::CtrlD => {
1846 bash.send_eof().map_err(|e| {
1849 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1850 })?;
1851 }
1852 SpecialKey::CtrlZ => {
1853 bash.send_suspend().map_err(|e| {
1855 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1856 })?;
1857 }
1858 }
1859 }
1860 }
1861
1862 if submit {
1865 submit_enter(&shell_arc).await?;
1866 }
1867
1868 let mut output =
1876 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1877
1878 if is_interrupt && output.contains("status = still running") {
1880 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1881 }
1882
1883 Ok(output)
1884}
1885
1886async fn execute_send_ascii(
1888 bash_state: &mut BashState,
1889 ascii_codes: &[u8],
1890 submit: bool,
1891 bg_shell: Option<SharedPtyShell>,
1892 is_bg: bool,
1893 bg_id: Option<&str>,
1894 timeout_s: f64,
1895) -> Result<String> {
1896 debug!(bytes = ascii_codes.len(), submit, "Processing SendAscii action");
1897
1898 if ascii_codes.is_empty() {
1900 return Err(WinxError::CommandExecutionError(
1901 "Failure: send_ascii cannot be empty".to_string(),
1902 ));
1903 }
1904
1905 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1906 let mut is_interrupt = false;
1907
1908 {
1909 let mut guard = shell_arc.lock().await;
1910
1911 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1912 ensure_interactive_target(bash)?;
1913
1914 for &code in ascii_codes {
1916 bash.send_bytes(&[code]).map_err(|e| {
1918 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1919 })?;
1920
1921 if code == 3 {
1923 is_interrupt = true;
1924 }
1925 }
1926 }
1927
1928 if submit {
1931 submit_enter(&shell_arc).await?;
1932 }
1933
1934 let mut output =
1940 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1941
1942 if is_interrupt && output.contains("status = still running") {
1944 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1945 }
1946
1947 Ok(output)
1948}
1949
1950async fn execute_in_background(
1952 bash_state: &mut BashState,
1953 command: &str,
1954 timeout_s: f64,
1955) -> Result<String> {
1956 debug!(bytes = command.len(), "Executing command in background");
1957
1958 let restricted_mode =
1960 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1961
1962 let bg_id = {
1966 let cwd = bash_state.cwd.clone();
1967 let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1968 .await
1969 .map_err(|e| {
1970 WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1971 })?
1972 .map_err(|e| {
1973 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1974 })?;
1975 lock_bg_manager().register_shell(&bash_state.current_thread_id, shell)?
1976 };
1977
1978 let shell_arc = {
1980 let manager = lock_bg_manager();
1981 manager.get_shell(&bash_state.current_thread_id, &bg_id).ok_or_else(|| {
1982 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1983 })?
1984 };
1985
1986 let scratch_root = bash_state.workspace_root.clone();
1988 let send_result = {
1989 let mut guard = shell_arc.lock().await;
1990 guard.as_mut().map(|bash| {
1991 bash.set_scratch_root(&scratch_root);
1994 bash.send_command(command)
1995 })
1996 };
1997 let Some(send_result) = send_result else {
1998 lock_bg_manager().remove_shell(&bg_id);
1999 return Err(WinxError::BashStateNotInitialized);
2000 };
2001 if let Err(error) = send_result {
2002 lock_bg_manager().remove_shell(&bg_id);
2005 return Err(WinxError::CommandExecutionError(format!(
2006 "Failed to send bg command: {error}"
2007 )));
2008 }
2009 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
2010
2011 spawn_background_reaper(bash_state.current_thread_id.clone(), bg_id.clone());
2012
2013 let _ = timeout_s;
2014 let _ = shell_arc;
2015 Ok(get_status(bash_state, true, Some(&bg_id), true, None, None, Some(&bash_state.cwd)))
2016}
2017
2018#[cfg(test)]
2019mod wait_turn_tests {
2020 use super::wait_turn_outcome;
2021 use crate::state::turn::TurnState;
2022 use std::time::Duration;
2023
2024 const SETTLE: Duration = Duration::from_millis(300);
2025 const QUIET: Duration = Duration::from_millis(600);
2026
2027 fn call(
2028 state: TurnState,
2029 busy_for: Duration,
2030 stable_for: Duration,
2031 seen_busy: bool,
2032 timed_out: bool,
2033 wait_through_busy: bool,
2034 ) -> Option<&'static str> {
2035 wait_turn_outcome(
2037 state,
2038 true,
2039 true,
2040 seen_busy,
2041 stable_for,
2042 busy_for,
2043 SETTLE,
2044 QUIET,
2045 timed_out,
2046 wait_through_busy,
2047 )
2048 }
2049
2050 #[test]
2051 fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
2052 let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
2055 assert_eq!(out, Some("busy"));
2056 }
2057
2058 #[test]
2059 fn busy_not_yet_confirmed_keeps_waiting() {
2060 let out =
2062 call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
2063 assert_eq!(out, None);
2064 }
2065
2066 #[test]
2067 fn wait_through_busy_blocks_through_busy_until_timeout() {
2068 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
2070 assert_eq!(out, None);
2071 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
2073 assert_eq!(out, Some("timeout"));
2074 }
2075
2076 #[test]
2077 fn awaiting_input_after_busy_is_ready_on_short_settle() {
2078 let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
2079 assert_eq!(out, Some("ready"));
2080 }
2081
2082 #[test]
2083 fn awaiting_input_without_prior_busy_needs_full_quiet() {
2084 let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
2086 assert_eq!(short, None);
2087 let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
2088 assert_eq!(full, Some("ready"));
2089 }
2090
2091 #[test]
2092 fn dead_shell_reports_exited_even_if_busy() {
2093 let out = wait_turn_outcome(
2094 TurnState::Busy,
2095 false,
2096 true,
2097 true,
2098 Duration::ZERO,
2099 SETTLE,
2100 SETTLE,
2101 QUIET,
2102 false,
2103 false,
2104 );
2105 assert_eq!(out, Some("exited"));
2106 }
2107
2108 #[test]
2109 fn nothing_happening_keeps_waiting() {
2110 let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
2111 assert_eq!(out, None);
2112 }
2113}
2114
2115#[cfg(test)]
2116mod tests {
2117 use super::{
2118 effective_wait_for_seconds, strip_tail_pipe_impl, BackgroundShellEntry,
2119 BackgroundShellManager, ExitedShellInfo,
2120 };
2121 use crate::errors::WinxError;
2122 use std::path::PathBuf;
2123 use std::sync::Arc;
2124 use std::time::Instant;
2125 use tokio::sync::Mutex;
2126
2127 fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
2128 BackgroundShellEntry {
2129 owner_thread_id: owner_thread_id.to_string(),
2130 shell: Arc::new(Mutex::new(None)),
2131 }
2132 }
2133
2134 fn tombstone(owner_thread_id: &str) -> ExitedShellInfo {
2135 ExitedShellInfo {
2136 owner_thread_id: owner_thread_id.to_string(),
2137 last_command: "true".to_string(),
2138 final_output: Arc::from("done"),
2139 exit_code: Some(0),
2140 cwd: PathBuf::from("/tmp"),
2141 output_truncated: false,
2142 scratch_path: None,
2143 exited_at: Instant::now(),
2144 }
2145 }
2146
2147 #[test]
2148 fn background_capacity_is_bounded_per_thread_and_globally() {
2149 let mut manager = BackgroundShellManager::new();
2150 for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
2151 manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
2152 }
2153 assert!(matches!(
2154 manager.ensure_capacity("owner"),
2155 Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
2156 ));
2157 assert!(manager.ensure_capacity("another-owner").is_ok());
2158
2159 for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
2160 manager
2161 .shells
2162 .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
2163 }
2164 assert!(matches!(
2165 manager.ensure_capacity("new-owner"),
2166 Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
2167 ));
2168 }
2169
2170 #[test]
2171 fn tombstone_capacity_is_bounded_per_thread_and_globally() {
2172 let mut manager = BackgroundShellManager::new();
2173 for index in 0..BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD + 3 {
2174 manager.insert_tombstone(format!("owned-{index}"), tombstone("owner"));
2175 }
2176 assert_eq!(
2177 manager.tombstones.values().filter(|entry| entry.owner_thread_id == "owner").count(),
2178 BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD
2179 );
2180
2181 for index in 0..BackgroundShellManager::MAX_TOMBSTONES + 5 {
2182 manager.insert_tombstone(format!("global-{index}"), tombstone(&format!("o-{index}")));
2183 }
2184 assert_eq!(manager.tombstones.len(), BackgroundShellManager::MAX_TOMBSTONES);
2185 }
2186
2187 #[test]
2188 fn strips_trailing_tail_by_default() {
2189 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
2190 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
2191 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
2192 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
2193 }
2194
2195 #[test]
2196 fn keeps_command_without_trailing_tail() {
2197 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
2199 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
2200 assert_eq!(
2201 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
2202 "cat a | tail -5 | wc -l"
2203 );
2204 }
2205
2206 #[test]
2207 fn keep_mode_preserves_tail_pipe() {
2208 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
2210 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
2211 }
2212
2213 #[test]
2214 fn requested_wait_is_not_silently_capped() {
2215 assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
2216 assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
2217 }
2218}