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_SLICE_SECS: f64 = 0.5;
42
43const COMMAND_CHUNK_SIZE: usize = 64;
45
46const TEXT_CHUNK_SIZE: usize = 128;
48
49const MAX_OUTPUT_LENGTH: usize = 100_000;
53
54const MAX_OUTPUT_TOKENS: usize = 25_000;
59
60const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
67
68const SCREEN_DIFF_THRESHOLD: usize = 10;
71
72fn char_safe_tail(text: &str, max_len: usize) -> &str {
75 if text.len() <= max_len {
76 return text;
77 }
78 let mut start = text.len() - max_len;
79 while start < text.len() && !text.is_char_boundary(start) {
80 start += 1;
81 }
82 &text[start..]
83}
84
85fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
93 if text.len() <= MAX_OUTPUT_LENGTH {
94 return std::borrow::Cow::Borrowed(text);
95 }
96
97 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
98 return std::borrow::Cow::Owned(format!(
100 "(...truncated)\n{}",
101 char_safe_tail(text, MAX_OUTPUT_LENGTH)
102 ));
103 };
104
105 if tokens.len() <= max_tokens {
106 return std::borrow::Cow::Borrowed(text);
107 }
108
109 let keep = max_tokens.saturating_sub(1);
111 let tail = &tokens[tokens.len() - keep..];
112 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
113 char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
115 });
116 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
117}
118
119const 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.
1211. Get its output using status check.
1222. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1233. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1244. Interrupt and run the process in background
125";
126
127#[derive(Debug, Clone)]
133pub struct ExitedShellInfo {
134 pub last_command: String,
135 pub final_output: String,
136 pub exited_at: Instant,
137}
138
139#[derive(Debug, Default)]
141pub struct BackgroundShellManager {
142 shells: HashMap<String, SharedPtyShell>,
143 tombstones: HashMap<String, ExitedShellInfo>,
146}
147
148impl BackgroundShellManager {
149 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
151
152 pub fn new() -> Self {
154 Self { shells: HashMap::new(), tombstones: HashMap::new() }
155 }
156
157 pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
159 let cid = format!("{:010x}", rand::rng().random::<u32>());
160
161 let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
162 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
163 })?;
164
165 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
166
167 info!("Started background shell with id: {}", cid);
168 Ok(cid)
169 }
170
171 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
173 self.shells.get(bg_command_id).cloned()
174 }
175
176 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
178 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
179 if let Ok(mut guard) = shell_arc.try_lock() {
180 *guard = None;
181 }
182 info!("Removed background shell: {}", bg_command_id);
183 true
184 } else {
185 false
186 }
187 }
188
189 fn prune_finished_shells(&mut self) {
190 let now = Instant::now();
192 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
193
194 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
195
196 for (id, shell_arc) in &self.shells {
197 let Ok(mut guard) = shell_arc.try_lock() else {
198 continue;
199 };
200
201 let Some(shell) = guard.as_mut() else {
202 finished.push((id.clone(), None));
203 continue;
204 };
205
206 if !shell.is_alive() {
207 let tombstone = ExitedShellInfo {
208 last_command: shell.last_command.clone(),
209 final_output: std::mem::take(&mut shell.output_buffer),
210 exited_at: now,
211 };
212 finished.push((id.clone(), Some(tombstone)));
213 continue;
214 }
215
216 if shell.last_command.is_empty() {
221 continue;
222 }
223
224 if shell.command_running {
225 shell.poll_output_nonblocking();
229 }
230
231 if !shell.command_running {
232 let tombstone = ExitedShellInfo {
233 last_command: shell.last_command.clone(),
234 final_output: std::mem::take(&mut shell.output_buffer),
235 exited_at: now,
236 };
237 finished.push((id.clone(), Some(tombstone)));
238 }
239 }
240
241 for (id, tombstone) in finished {
242 self.remove_shell(&id);
243 if let Some(info) = tombstone {
244 self.tombstones.insert(id, info);
245 }
246 }
247 }
248
249 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
256 self.tombstones.get(bg_command_id).cloned()
257 }
258
259 pub fn get_running_info(&mut self) -> String {
261 self.prune_finished_shells();
262
263 if self.shells.is_empty() {
264 return "No command running in background.\n".to_string();
265 }
266
267 let mut running = Vec::new();
268 for (id, shell_arc) in &self.shells {
269 if let Ok(guard) = shell_arc.try_lock() {
270 if let Some(bash) = guard.as_ref() {
271 if bash.command_running {
272 running
273 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
274 }
275 }
276 } else {
277 running.push(format!("Command: <busy>, bg_command_id: {id}"));
278 }
279 }
280
281 if running.is_empty() {
282 "No command running in background.\n".to_string()
283 } else {
284 format!("Following background commands are attached:\n{}\n", running.join("\n"))
285 }
286 }
287}
288
289lazy_static::lazy_static! {
291 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
292}
293
294fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
301 BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
302}
303
304fn get_status(
308 bash_state: &BashState,
309 is_bg: bool,
310 bg_id: Option<&str>,
311 is_running: bool,
312 running_for: Option<&str>,
313) -> String {
314 let mut status = "\n\n---\n\n".to_string();
315
316 if is_bg {
317 if let Some(id) = bg_id {
318 let _ = writeln!(status, "bg_command_id = {id}");
319 }
320 }
321
322 if is_running {
323 status.push_str("status = still running\n");
324 if let Some(duration) = running_for {
325 let _ = writeln!(status, "running for = {duration}");
326 }
327 } else {
328 status.push_str("status = process exited\n");
329 }
330
331 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
332
333 if !is_bg {
334 {
336 let mut manager = lock_bg_manager();
337 status.push_str("This is the main shell. ");
338 status.push_str(&manager.get_running_info());
339 }
340 }
341
342 status.trim_end().to_string()
343}
344
345fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
347 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
348 let text = truncated.as_ref();
349
350 if last_pending_output.is_empty() {
351 let rendered = render_terminal_output(text);
352 return rstrip_lines(&rendered).trim_start().to_string();
353 }
354
355 let last_rendered = render_terminal_output(last_pending_output);
356 if last_rendered.is_empty() {
357 return rstrip_lines(&render_terminal_output(text));
358 }
359
360 let text_after_last = if text.len() > last_pending_output.len() {
364 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
365 &text[cut..]
366 } else {
367 text
368 };
369
370 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
371 let new_rendered = render_terminal_output(&combined);
372
373 let incremental = get_incremental_output(&last_rendered, &new_rendered);
375 rstrip_lines(&incremental)
376}
377
378fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
379 static PROMPT_RE: std::sync::OnceLock<Option<Regex>> = std::sync::OnceLock::new();
380 let prompt_regex =
381 PROMPT_RE.get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()).as_ref()?;
382 let stripped = strip_ansi_codes(output);
383
384 prompt_regex
385 .captures_iter(&stripped)
386 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
387 .filter(|cwd| !cwd.is_empty())
388 .last()
389 .map(PathBuf::from)
390}
391
392fn rstrip_lines(lines: &[String]) -> String {
394 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
395}
396
397fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
399 if old_output.is_empty() {
400 return new_output.to_vec();
401 }
402
403 let nold = old_output.len();
404 let nnew = new_output.len();
405
406 for i in (0..nnew).rev() {
408 if new_output[i] != old_output[nold - 1] {
409 continue;
410 }
411
412 let mut matched = true;
413 for j in (0..i).rev() {
414 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
415 if old_idx < 0 {
416 break;
417 }
418 if new_output[j] != old_output[old_idx as usize] {
419 matched = false;
420 break;
421 }
422 }
423
424 if matched {
425 return new_output[i + 1..].to_vec();
426 }
427 }
428
429 new_output.to_vec()
430}
431
432fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
433 let mut start = 0;
434
435 while start < text.len() {
436 let mut end = (start + chunk_size).min(text.len());
437 while !text.is_char_boundary(end) {
438 end -= 1;
439 }
440 if end == start {
441 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
442 }
443
444 shell.send_text(&text[start..end]).map_err(|e| {
445 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
446 })?;
447 start = end;
448 }
449
450 Ok(())
451}
452
453async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
458 tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
459 let mut guard = shell_arc.lock().await;
460 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
461 bash.send_special_key("Enter")
462 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
463 Ok(())
464}
465
466#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
473pub async fn handle_tool_call(
474 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
475 bash_command: BashCommand,
476) -> Result<String> {
477 info!("BashCommand tool called with: {:?}", bash_command);
478
479 let thread_id = normalize_thread_id(&bash_command.thread_id);
480
481 if thread_id.is_empty() {
483 error!("Empty thread_id provided in BashCommand");
484 return Err(WinxError::ThreadIdMismatch(
485 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
486 ));
487 }
488
489 let mut bash_state: BashState;
491 {
492 let bash_state_guard = bash_state_arc.lock().await;
493
494 let Some(state) = &*bash_state_guard else {
495 error!("BashState not initialized");
496 return Err(WinxError::BashStateNotInitialized);
497 };
498
499 bash_state = state.clone();
500 }
501
502 if thread_id != bash_state.current_thread_id {
504 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
506 return Err(WinxError::ThreadIdMismatch(format!(
507 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
508 )));
509 }
510 }
511
512 let timeout_s = bash_command
515 .wait_for_seconds
516 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
517 .min(TIMEOUT_WHILE_OUTPUT);
518
519 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
521
522 {
523 let mut bash_state_guard = bash_state_arc.lock().await;
524 if let Some(state) = bash_state_guard.as_mut() {
525 state.cwd.clone_from(&bash_state.cwd);
526 }
527 }
528
529 match result {
531 Ok(mut output) => {
532 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
533 let cmd_trimmed = command.trim();
534 if output.starts_with(cmd_trimmed) {
535 output = output[cmd_trimmed.len()..].to_string();
536 }
537 }
538 Ok(output)
539 }
540 Err(e) => Err(e),
541 }
542}
543
544#[allow(clippy::too_many_lines)]
546async fn execute_bash_action(
547 bash_state: &mut BashState,
548 action: &BashCommandAction,
549 timeout_s: f64,
550) -> Result<String> {
551 let mut is_bg = false;
552 let mut bg_id: Option<String> = None;
553
554 let bg_shell: Option<SharedPtyShell> = match action {
556 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
558 | BashCommandAction::SendText { bg_command_id, .. }
559 | BashCommandAction::SendSpecials { bg_command_id, .. }
560 | BashCommandAction::SendAscii { bg_command_id, .. }
561 | BashCommandAction::Screen { bg_command_id, .. }
562 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
563 if let Some(id) = bg_command_id {
564 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
565 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
566 })?;
567 manager.prune_finished_shells();
568
569 if let Some(shell) = manager.get_shell(id) {
570 is_bg = true;
571 bg_id = Some(id.clone());
572 Some(shell)
573 } else if let Some(tombstone) = manager.peek_tombstone(id) {
574 drop(manager);
579 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
580 } else {
581 let error = format!(
583 "No shell found running with command id {}.\n{}",
584 id,
585 manager.get_running_info()
586 );
587 return Err(WinxError::CommandExecutionError(error));
588 }
589 } else {
590 None
591 }
592 }
593 };
594
595 match action {
597 BashCommandAction::Command { command, is_background, allow_multi } => {
598 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
599 }
600 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
601 execute_status_check(
602 bash_state,
603 bg_shell,
604 is_bg,
605 bg_id.as_deref(),
606 timeout_s,
607 *scrollback_lines,
608 *verbose,
609 )
610 .await
611 }
612 BashCommandAction::SendText { send_text, submit, .. } => {
613 execute_send_text(
614 bash_state,
615 send_text,
616 *submit,
617 bg_shell,
618 is_bg,
619 bg_id.as_deref(),
620 timeout_s,
621 )
622 .await
623 }
624 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
625 execute_send_specials(
626 bash_state,
627 send_specials,
628 *submit,
629 bg_shell,
630 is_bg,
631 bg_id.as_deref(),
632 timeout_s,
633 )
634 .await
635 }
636 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
637 execute_send_ascii(
638 bash_state,
639 send_ascii,
640 *submit,
641 bg_shell,
642 is_bg,
643 bg_id.as_deref(),
644 timeout_s,
645 )
646 .await
647 }
648 BashCommandAction::Screen { lines, diff, .. } => {
649 execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
650 }
651 BashCommandAction::WaitForTurn { recognizer, quiet_ms, timeout_seconds, lines, .. } => {
652 execute_wait_for_turn(
653 bash_state,
654 bg_shell,
655 is_bg,
656 bg_id.as_deref(),
657 recognizer.as_deref(),
658 *quiet_ms,
659 *timeout_seconds,
660 *lines,
661 )
662 .await
663 }
664 }
665}
666
667fn strip_tail_pipe(command: &str) -> String {
677 strip_tail_pipe_impl(command, keep_tail_pipe())
678}
679
680fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
683 static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
684 if keep {
685 return command.to_string();
686 }
687 let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
688 match re.as_ref().and_then(|re| re.find(command)) {
689 Some(matched) => command[..matched.start()].trim_end().to_string(),
690 None => command.to_string(),
691 }
692}
693
694fn keep_tail_pipe() -> bool {
696 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
697 let value = value.trim();
698 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
699 })
700}
701
702async fn execute_command(
704 bash_state: &mut BashState,
705 command: &str,
706 is_background: bool,
707 allow_multi: bool,
708 timeout_s: f64,
709) -> Result<String> {
710 let stripped_command = strip_tail_pipe(command);
712 let command = stripped_command.as_str();
713 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
714
715 if !bash_state.is_command_allowed(command) {
717 error!("Command '{}' not allowed in current mode", command);
718 return Err(WinxError::CommandNotAllowed(
719 "Error: BashCommand not allowed in current mode".to_string(),
720 ));
721 }
722
723 let command = command.trim();
727 if !allow_multi {
728 crate::utils::bash_parser::assert_single_statement(command)?;
729 }
730
731 if is_background {
733 return execute_in_background(bash_state, command, timeout_s).await;
734 }
735
736 {
738 let bash_guard = bash_state.pty_shell.lock().await;
739
740 if let Some(ref bash) = *bash_guard {
741 if bash.command_running {
742 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
743 }
744 }
745 }
746
747 if bash_state.pty_shell.lock().await.is_none() {
749 bash_state
750 .init_pty_shell()
751 .await
752 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
753 }
754
755 {
760 let needs_reset = {
761 let mut bash_guard = bash_state.pty_shell.lock().await;
762 match bash_guard.as_mut() {
763 Some(bash) => match bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
764 Ok(true) => false,
765 Ok(false) => {
766 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
767 true
768 }
769 Err(e) => {
770 warn!("clear_to_run failed ({e}), resetting shell");
771 true
772 }
773 },
774 None => false,
775 }
776 };
777 if needs_reset {
781 if let Err(e) = bash_state.init_pty_shell().await {
782 warn!("Failed to reset shell after clear_to_run: {e}");
783 }
784 }
785 }
786
787 {
789 let mut bash_guard = bash_state.pty_shell.lock().await;
790
791 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
792
793 bash.output_buffer.clear();
794 bash.output_truncated = false;
795 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
797
798 bash.send_special_key("Enter").map_err(|e| {
800 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
801 })?;
802
803 bash.last_command = command.to_string();
804 bash.command_running = true;
805 }
806
807 let shell_arc = bash_state.pty_shell.clone();
809 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
810}
811
812async fn wait_for_output(
816 bash_state: &mut BashState,
817 shell_arc: &SharedPtyShell,
818 timeout_s: f64,
819 is_bg: bool,
820 bg_id: Option<&str>,
821 is_status_check: bool,
822) -> Result<String> {
823 let start = Instant::now();
824 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
825 let mut last_pending_output = String::new();
826 let mut complete = false;
827
828 let mut output = String::new();
836 loop {
837 let elapsed = start.elapsed().as_secs_f64();
838 if elapsed >= wait {
839 break;
840 }
841 let slice = (wait - elapsed).clamp(0.1, POLL_SLICE_SECS);
842 let (out, done) = {
843 let mut bash_guard = shell_arc.lock().await;
844 match bash_guard.as_mut() {
845 Some(bash) => bash.read_output(slice as f32).map_err(|e| {
846 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
847 })?,
848 None => (String::new(), true),
849 }
850 };
851 output = out;
852 complete = done;
853 if complete {
854 break;
855 }
856 }
857
858 if !complete && is_status_check {
867 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
868 let iter_wait_secs = 0.5_f64;
869 let mut patience = OUTPUT_WAIT_PATIENCE;
870
871 let incremental = wcgw_incremental_text(&output, &last_pending_output);
872 if incremental.is_empty() {
873 patience -= 1;
874 }
875
876 let mut last_incremental = incremental;
877
878 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
879 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
880 if remaining < 0.1 {
881 break;
882 }
883 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
884
885 let (new_output, done) = {
886 let mut bash_guard = shell_arc.lock().await;
887
888 if let Some(bash) = bash_guard.as_mut() {
889 bash.read_output(0.5).map_err(|e| {
890 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
891 })?
892 } else {
893 (String::new(), true)
894 }
895 };
896
897 if done {
898 complete = true;
899 output = new_output;
900 break;
901 }
902
903 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
905 if new_incremental == last_incremental {
906 patience -= 1;
907 } else {
908 patience = OUTPUT_WAIT_PATIENCE; }
910 last_incremental = new_incremental;
911
912 output = new_output;
913 }
914
915 if !complete {
916 last_pending_output = output.clone();
918 }
919 }
920
921 if complete {
922 if let Some(cwd) = extract_prompt_cwd(&output) {
923 bash_state.cwd = cwd;
924 }
925 }
926
927 let rendered = wcgw_incremental_text(&output, &last_pending_output);
929
930 let rendered = {
934 let before_lines = rendered.lines().count();
935 match crate::utils::output_compress::compress_output(&rendered) {
936 Some(compressed) => {
937 debug!(
938 from_lines = before_lines,
939 to_lines = compressed.lines().count(),
940 "winx collapsed mechanical repetition in shell output"
941 );
942 compressed
943 }
944 None => rendered,
945 }
946 };
947
948 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
950
951 let running_for =
953 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
954
955 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
957 Ok(format!("{rendered}{status}"))
958}
959
960fn finalize_tombstone(
967 cwd: &Path,
968 id: &str,
969 tombstone: ExitedShellInfo,
970 action: &BashCommandAction,
971) -> Result<String> {
972 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
973 match action {
974 BashCommandAction::StatusCheck { .. }
977 | BashCommandAction::Screen { .. }
978 | BashCommandAction::WaitForTurn { .. } => {
979 let rendered = wcgw_incremental_text(&final_output, "");
980 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
981 let mut status = "\n\n---\n\n".to_string();
983 let _ = writeln!(status, "bg_command_id = {id}");
984 status.push_str("status = process exited\n");
985 let _ = writeln!(status, "cwd = {}", cwd.display());
986 Ok(format!("{rendered}{}", status.trim_end()))
987 }
988 BashCommandAction::SendText { .. }
989 | BashCommandAction::SendSpecials { .. }
990 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
991 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
992 ))),
993 BashCommandAction::Command { .. } => {
994 unreachable!("finalize_tombstone called for non-bg action")
997 }
998 }
999}
1000
1001async fn execute_status_check(
1010 bash_state: &mut BashState,
1011 bg_shell: Option<SharedPtyShell>,
1012 is_bg: bool,
1013 bg_id: Option<&str>,
1014 timeout_s: f64,
1015 scrollback_lines: Option<usize>,
1016 verbose: bool,
1017) -> Result<String> {
1018 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1019
1020 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1023
1024 let is_running = {
1026 let guard = shell_arc.lock().await;
1027 if let Some(ref bash) = *guard {
1028 bash.command_running
1029 } else {
1030 false
1031 }
1032 };
1033
1034 if !is_running && !is_bg {
1036 let mut manager = lock_bg_manager();
1037 let error = format!(
1038 "No command is currently running, so there's nothing to check. The previous \
1039 command already finished and its output was returned when it completed. Start a \
1040 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1041 manager.get_running_info()
1042 );
1043 return Err(WinxError::CommandExecutionError(error));
1044 }
1045
1046 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1048
1049 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1053 if !verbose && scrollback_lines.is_none() {
1054 let mut guard = shell_arc.lock().await;
1055 if let Some(bash) = guard.as_mut() {
1056 let fingerprint = PtyShell::fingerprint(body);
1057 if Some(fingerprint) == bash.last_returned_hash {
1058 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
1059 return Ok(format!("no new output since last check{status}"));
1060 }
1061 bash.last_returned_hash = Some(fingerprint);
1062 }
1063 } else if !verbose {
1064 let mut guard = shell_arc.lock().await;
1066 if let Some(bash) = guard.as_mut() {
1067 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1068 }
1069 }
1070
1071 if let Some(lines) = scrollback_lines {
1073 if lines > 0 {
1074 let scrollback = {
1075 let guard = shell_arc.lock().await;
1076 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1077 };
1078 if !scrollback.is_empty() {
1079 let count = scrollback.lines().count();
1080 return Ok(format!(
1081 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1082 ));
1083 }
1084 }
1085 }
1086
1087 Ok(response)
1088}
1089
1090async fn execute_screen(
1094 bash_state: &BashState,
1095 bg_shell: Option<SharedPtyShell>,
1096 is_bg: bool,
1097 bg_id: Option<&str>,
1098 lines: Option<usize>,
1099 diff: bool,
1100) -> Result<String> {
1101 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1102 let max_lines = lines.unwrap_or(0);
1103
1104 if diff {
1105 let (update, is_running, in_alt, cursor) = {
1106 let guard = shell_arc.lock().await;
1107 match guard.as_ref() {
1108 Some(bash) => (
1109 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1110 bash.command_running,
1111 bash.live_in_alt_screen(),
1112 bash.live_cursor_position(),
1113 ),
1114 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1115 }
1116 };
1117 let (crow, ccol) = cursor;
1118 let alt = if in_alt { " [alt-screen]" } else { "" };
1119 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
1120 let body = match update {
1121 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1122 ScreenUpdate::Diff(changed) => {
1123 let mut out = String::from("(changed lines only)\n");
1124 for (row, content) in changed {
1125 let _ = writeln!(out, "{row:>4}: {content}");
1126 }
1127 out
1128 }
1129 ScreenUpdate::Full(snap) => {
1130 let joined = snap.join("\n");
1131 if joined.trim().is_empty() {
1132 "(screen is empty)".to_string()
1133 } else {
1134 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1135 }
1136 }
1137 };
1138 return Ok(format!(
1139 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1140 ));
1141 }
1142
1143 let (snapshot, is_running, in_alt, cursor) = {
1144 let guard = shell_arc.lock().await;
1145 match guard.as_ref() {
1146 Some(bash) => (
1147 bash.live_snapshot(max_lines),
1148 bash.command_running,
1149 bash.live_in_alt_screen(),
1150 bash.live_cursor_position(),
1151 ),
1152 None => (Vec::new(), false, false, (0, 0)),
1153 }
1154 };
1155
1156 let joined = snapshot.join("\n");
1157 let body = if joined.trim().is_empty() {
1158 "(screen is empty)".to_string()
1159 } else {
1160 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1161 };
1162 let alt = if in_alt { " [alt-screen]" } else { "" };
1163 let (crow, ccol) = cursor;
1164 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
1165 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1166}
1167
1168#[allow(clippy::too_many_arguments)]
1176async fn execute_wait_for_turn(
1177 bash_state: &BashState,
1178 bg_shell: Option<SharedPtyShell>,
1179 is_bg: bool,
1180 bg_id: Option<&str>,
1181 recognizer_hint: Option<&str>,
1182 quiet_ms: Option<u64>,
1183 timeout_seconds: Option<f32>,
1184 lines: Option<usize>,
1185) -> Result<String> {
1186 use crate::state::turn::{recognizer_for, TurnState};
1187
1188 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1189 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1190 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1191 let settle = quiet.min(Duration::from_millis(300));
1192 let hard_cap =
1193 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1194 let max_lines = lines.unwrap_or(0);
1195 let poll = Duration::from_millis(120);
1196 let warmup = Duration::from_millis(2500);
1199
1200 let start = Instant::now();
1201 let mut last_hash: Option<u64> = None;
1202 let mut initial_hash: Option<u64> = None;
1203 let mut stable_since = Instant::now();
1204 let mut seen_busy = false;
1205
1206 loop {
1207 let (snapshot, in_alt, alive, running) = {
1208 let mut guard = shell_arc.lock().await;
1209 match guard.as_mut() {
1210 Some(bash) => (
1211 bash.live_snapshot(max_lines),
1212 bash.live_in_alt_screen(),
1213 bash.is_alive(),
1214 bash.command_running,
1215 ),
1216 None => (Vec::new(), false, false, false),
1217 }
1218 };
1219
1220 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1221 if initial_hash.is_none() {
1222 initial_hash = Some(hash);
1223 }
1224 if Some(hash) != last_hash {
1225 last_hash = Some(hash);
1226 stable_since = Instant::now();
1227 }
1228
1229 let state = recognizer.detect(&snapshot);
1230 if state == TurnState::Busy {
1231 seen_busy = true;
1232 }
1233 let stable_for = stable_since.elapsed();
1234 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1238 let ready = match state {
1239 TurnState::Busy => false,
1240 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1243 activity && stable_for >= if seen_busy { settle } else { quiet }
1244 }
1245 TurnState::Unknown => activity && stable_for >= quiet,
1246 };
1247
1248 let timed_out = start.elapsed() >= hard_cap;
1249 if ready || !alive || timed_out {
1250 let reason = if !alive {
1251 "exited"
1252 } else if ready {
1253 "ready"
1254 } else {
1255 "timeout"
1256 };
1257 let joined = snapshot.join("\n");
1258 let body = if joined.trim().is_empty() {
1259 "(screen is empty)".to_string()
1260 } else {
1261 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1262 };
1263 let alt = if in_alt { " [alt-screen]" } else { "" };
1264 let header = format!(
1265 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1266 state.as_str(),
1267 reason,
1268 recognizer.name(),
1269 start.elapsed().as_secs_f64(),
1270 alt
1271 );
1272 let status = get_status(bash_state, is_bg, bg_id, running, None);
1273 return Ok(format!("{header}\n{body}{status}"));
1274 }
1275
1276 tokio::time::sleep(poll).await;
1277 }
1278}
1279
1280async fn execute_send_text(
1282 bash_state: &mut BashState,
1283 text: &str,
1284 submit: bool,
1285 bg_shell: Option<SharedPtyShell>,
1286 is_bg: bool,
1287 bg_id: Option<&str>,
1288 timeout_s: f64,
1289) -> Result<String> {
1290 debug!("Processing SendText action: {text:?} (submit={submit})");
1291
1292 if text.is_empty() {
1294 return Err(WinxError::CommandExecutionError(
1295 "Failure: send_text cannot be empty".to_string(),
1296 ));
1297 }
1298
1299 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1301
1302 {
1304 let mut guard = shell_arc.lock().await;
1305
1306 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1307
1308 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1310 }
1311
1312 if submit {
1316 submit_enter(&shell_arc).await?;
1317 }
1318
1319 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1321}
1322
1323async fn execute_send_specials(
1325 bash_state: &mut BashState,
1326 keys: &[SpecialKey],
1327 submit: bool,
1328 bg_shell: Option<SharedPtyShell>,
1329 is_bg: bool,
1330 bg_id: Option<&str>,
1331 timeout_s: f64,
1332) -> Result<String> {
1333 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1334
1335 if keys.is_empty() {
1337 return Err(WinxError::CommandExecutionError(
1338 "Failure: send_specials cannot be empty".to_string(),
1339 ));
1340 }
1341
1342 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1343 let mut is_interrupt = false;
1344
1345 {
1346 let mut guard = shell_arc.lock().await;
1347
1348 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1349
1350 for key in keys {
1352 match key {
1353 SpecialKey::KeyUp => {
1354 bash.send_special_key("KeyUp").map_err(|e| {
1356 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1357 })?;
1358 }
1359 SpecialKey::KeyDown => {
1360 bash.send_special_key("KeyDown").map_err(|e| {
1362 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1363 })?;
1364 }
1365 SpecialKey::KeyLeft => {
1366 bash.send_special_key("KeyLeft").map_err(|e| {
1368 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1369 })?;
1370 }
1371 SpecialKey::KeyRight => {
1372 bash.send_special_key("KeyRight").map_err(|e| {
1374 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1375 })?;
1376 }
1377 SpecialKey::Enter => {
1378 bash.send_special_key("Enter").map_err(|e| {
1380 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1381 })?;
1382 }
1383 SpecialKey::CtrlC => {
1384 bash.send_interrupt().map_err(|e| {
1386 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1387 })?;
1388 is_interrupt = true;
1389 }
1390 SpecialKey::CtrlD => {
1391 bash.send_eof().map_err(|e| {
1393 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1394 })?;
1395 is_interrupt = true;
1396 }
1397 SpecialKey::CtrlZ => {
1398 bash.send_suspend().map_err(|e| {
1400 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1401 })?;
1402 }
1403 }
1404 }
1405 }
1406
1407 if submit {
1410 submit_enter(&shell_arc).await?;
1411 }
1412
1413 let mut output =
1421 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1422
1423 if is_interrupt && output.contains("status = still running") {
1425 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1426 }
1427
1428 Ok(output)
1429}
1430
1431async fn execute_send_ascii(
1433 bash_state: &mut BashState,
1434 ascii_codes: &[u8],
1435 submit: bool,
1436 bg_shell: Option<SharedPtyShell>,
1437 is_bg: bool,
1438 bg_id: Option<&str>,
1439 timeout_s: f64,
1440) -> Result<String> {
1441 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1442
1443 if ascii_codes.is_empty() {
1445 return Err(WinxError::CommandExecutionError(
1446 "Failure: send_ascii cannot be empty".to_string(),
1447 ));
1448 }
1449
1450 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1451 let mut is_interrupt = false;
1452
1453 {
1454 let mut guard = shell_arc.lock().await;
1455
1456 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1457
1458 for &code in ascii_codes {
1460 bash.send_bytes(&[code]).map_err(|e| {
1462 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1463 })?;
1464
1465 if code == 3 {
1467 is_interrupt = true;
1468 }
1469 }
1470 }
1471
1472 if submit {
1475 submit_enter(&shell_arc).await?;
1476 }
1477
1478 let mut output =
1484 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1485
1486 if is_interrupt && output.contains("status = still running") {
1488 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1489 }
1490
1491 Ok(output)
1492}
1493
1494async fn execute_in_background(
1496 bash_state: &mut BashState,
1497 command: &str,
1498 timeout_s: f64,
1499) -> Result<String> {
1500 debug!("Executing command in background: {}", command);
1501
1502 let restricted_mode =
1504 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1505
1506 let bg_id = {
1507 let mut manager = lock_bg_manager();
1508 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1509 };
1510
1511 let shell_arc = {
1513 let manager = lock_bg_manager();
1514 manager.get_shell(&bg_id).ok_or_else(|| {
1515 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1516 })?
1517 };
1518
1519 {
1521 let mut guard = shell_arc.lock().await;
1522 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1523 bash.send_command(command).map_err(|e| {
1524 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1525 })?;
1526 }
1527 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1528
1529 let _ = timeout_s;
1530 let _ = shell_arc;
1531 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::strip_tail_pipe_impl;
1537
1538 #[test]
1539 fn strips_trailing_tail_by_default() {
1540 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1541 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1542 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1543 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1544 }
1545
1546 #[test]
1547 fn keeps_command_without_trailing_tail() {
1548 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1550 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1551 assert_eq!(
1552 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1553 "cat a | tail -5 | wc -l"
1554 );
1555 }
1556
1557 #[test]
1558 fn keep_mode_preserves_tail_pipe() {
1559 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1561 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1562 }
1563}