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 exit_code: Option<i32>,
314) -> String {
315 let mut status = "\n\n---\n\n".to_string();
316
317 if is_bg {
318 if let Some(id) = bg_id {
319 let _ = writeln!(status, "bg_command_id = {id}");
320 }
321 }
322
323 if is_running {
324 status.push_str("status = still running\n");
325 if let Some(duration) = running_for {
326 let _ = writeln!(status, "running for = {duration}");
327 }
328 } else {
329 status.push_str("status = process exited\n");
330 if let Some(code) = exit_code {
333 let _ = writeln!(status, "exit code = {code}");
334 }
335 }
336
337 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
338
339 if !is_bg {
340 {
342 let mut manager = lock_bg_manager();
343 status.push_str("This is the main shell. ");
344 status.push_str(&manager.get_running_info());
345 }
346 }
347
348 status.trim_end().to_string()
349}
350
351fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
353 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
354 let text = truncated.as_ref();
355
356 if last_pending_output.is_empty() {
357 let rendered = render_terminal_output(text);
358 return rstrip_lines(&rendered).trim_start().to_string();
359 }
360
361 let last_rendered = render_terminal_output(last_pending_output);
362 if last_rendered.is_empty() {
363 return rstrip_lines(&render_terminal_output(text));
364 }
365
366 let text_after_last = if text.len() > last_pending_output.len() {
370 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
371 &text[cut..]
372 } else {
373 text
374 };
375
376 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
377 let new_rendered = render_terminal_output(&combined);
378
379 let incremental = get_incremental_output(&last_rendered, &new_rendered);
381 rstrip_lines(&incremental)
382}
383
384fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
385 static PROMPT_RE: std::sync::OnceLock<Option<Regex>> = std::sync::OnceLock::new();
386 let prompt_regex =
387 PROMPT_RE.get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()).as_ref()?;
388 let stripped = strip_ansi_codes(output);
389
390 prompt_regex
391 .captures_iter(&stripped)
392 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
393 .filter(|cwd| !cwd.is_empty())
394 .last()
395 .map(PathBuf::from)
396}
397
398fn rstrip_lines(lines: &[String]) -> String {
400 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
401}
402
403fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
405 if old_output.is_empty() {
406 return new_output.to_vec();
407 }
408
409 let nold = old_output.len();
410 let nnew = new_output.len();
411
412 for i in (0..nnew).rev() {
414 if new_output[i] != old_output[nold - 1] {
415 continue;
416 }
417
418 let mut matched = true;
419 for j in (0..i).rev() {
420 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
421 if old_idx < 0 {
422 break;
423 }
424 if new_output[j] != old_output[old_idx as usize] {
425 matched = false;
426 break;
427 }
428 }
429
430 if matched {
431 return new_output[i + 1..].to_vec();
432 }
433 }
434
435 new_output.to_vec()
436}
437
438fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
439 let mut start = 0;
440
441 while start < text.len() {
442 let mut end = (start + chunk_size).min(text.len());
443 while !text.is_char_boundary(end) {
444 end -= 1;
445 }
446 if end == start {
447 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
448 }
449
450 shell.send_text(&text[start..end]).map_err(|e| {
451 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
452 })?;
453 start = end;
454 }
455
456 Ok(())
457}
458
459async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
464 tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
465 let mut guard = shell_arc.lock().await;
466 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
467 bash.send_special_key("Enter")
468 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
469 Ok(())
470}
471
472#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
479pub async fn handle_tool_call(
480 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
481 bash_command: BashCommand,
482) -> Result<String> {
483 info!("BashCommand tool called with: {:?}", bash_command);
484
485 let thread_id = normalize_thread_id(&bash_command.thread_id);
486
487 if thread_id.is_empty() {
489 error!("Empty thread_id provided in BashCommand");
490 return Err(WinxError::ThreadIdMismatch(
491 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
492 ));
493 }
494
495 let mut bash_state: BashState;
497 {
498 let bash_state_guard = bash_state_arc.lock().await;
499
500 let Some(state) = &*bash_state_guard else {
501 error!("BashState not initialized");
502 return Err(WinxError::BashStateNotInitialized);
503 };
504
505 bash_state = state.clone();
506 }
507
508 if thread_id != bash_state.current_thread_id {
510 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
512 return Err(WinxError::ThreadIdMismatch(format!(
513 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
514 )));
515 }
516 }
517
518 let timeout_s = bash_command
521 .wait_for_seconds
522 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
523 .min(TIMEOUT_WHILE_OUTPUT);
524
525 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
527
528 {
529 let mut bash_state_guard = bash_state_arc.lock().await;
530 if let Some(state) = bash_state_guard.as_mut() {
531 state.cwd.clone_from(&bash_state.cwd);
532 }
533 }
534
535 match result {
537 Ok(mut output) => {
538 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
539 let cmd_trimmed = command.trim();
540 if output.starts_with(cmd_trimmed) {
541 output = output[cmd_trimmed.len()..].to_string();
542 }
543 }
544 Ok(output)
545 }
546 Err(e) => Err(e),
547 }
548}
549
550#[allow(clippy::too_many_lines)]
552async fn execute_bash_action(
553 bash_state: &mut BashState,
554 action: &BashCommandAction,
555 timeout_s: f64,
556) -> Result<String> {
557 let mut is_bg = false;
558 let mut bg_id: Option<String> = None;
559
560 let bg_shell: Option<SharedPtyShell> = match action {
562 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
564 | BashCommandAction::SendText { bg_command_id, .. }
565 | BashCommandAction::SendSpecials { bg_command_id, .. }
566 | BashCommandAction::SendAscii { bg_command_id, .. }
567 | BashCommandAction::Screen { bg_command_id, .. }
568 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
569 if let Some(id) = bg_command_id {
570 let mut manager = lock_bg_manager();
573 manager.prune_finished_shells();
574
575 if let Some(shell) = manager.get_shell(id) {
576 is_bg = true;
577 bg_id = Some(id.clone());
578 Some(shell)
579 } else if let Some(tombstone) = manager.peek_tombstone(id) {
580 drop(manager);
585 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
586 } else {
587 let error = format!(
589 "No shell found running with command id {}.\n{}",
590 id,
591 manager.get_running_info()
592 );
593 return Err(WinxError::CommandExecutionError(error));
594 }
595 } else {
596 None
597 }
598 }
599 };
600
601 match action {
603 BashCommandAction::Command { command, is_background, allow_multi } => {
604 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
605 }
606 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
607 execute_status_check(
608 bash_state,
609 bg_shell,
610 is_bg,
611 bg_id.as_deref(),
612 timeout_s,
613 *scrollback_lines,
614 *verbose,
615 )
616 .await
617 }
618 BashCommandAction::SendText { send_text, submit, .. } => {
619 execute_send_text(
620 bash_state,
621 send_text,
622 *submit,
623 bg_shell,
624 is_bg,
625 bg_id.as_deref(),
626 timeout_s,
627 )
628 .await
629 }
630 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
631 execute_send_specials(
632 bash_state,
633 send_specials,
634 *submit,
635 bg_shell,
636 is_bg,
637 bg_id.as_deref(),
638 timeout_s,
639 )
640 .await
641 }
642 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
643 execute_send_ascii(
644 bash_state,
645 send_ascii,
646 *submit,
647 bg_shell,
648 is_bg,
649 bg_id.as_deref(),
650 timeout_s,
651 )
652 .await
653 }
654 BashCommandAction::Screen { lines, diff, .. } => {
655 execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
656 }
657 BashCommandAction::WaitForTurn { recognizer, quiet_ms, timeout_seconds, lines, .. } => {
658 execute_wait_for_turn(
659 bash_state,
660 bg_shell,
661 is_bg,
662 bg_id.as_deref(),
663 recognizer.as_deref(),
664 *quiet_ms,
665 *timeout_seconds,
666 *lines,
667 )
668 .await
669 }
670 }
671}
672
673fn strip_tail_pipe(command: &str) -> String {
683 strip_tail_pipe_impl(command, keep_tail_pipe())
684}
685
686fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
689 static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
690 if keep {
691 return command.to_string();
692 }
693 let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
694 match re.as_ref().and_then(|re| re.find(command)) {
695 Some(matched) => command[..matched.start()].trim_end().to_string(),
696 None => command.to_string(),
697 }
698}
699
700fn keep_tail_pipe() -> bool {
702 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
703 let value = value.trim();
704 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
705 })
706}
707
708async fn execute_command(
710 bash_state: &mut BashState,
711 command: &str,
712 is_background: bool,
713 allow_multi: bool,
714 timeout_s: f64,
715) -> Result<String> {
716 let stripped_command = strip_tail_pipe(command);
718 let command = stripped_command.as_str();
719 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
720
721 if !bash_state.is_command_allowed(command) {
723 error!("Command '{}' not allowed in current mode", command);
724 return Err(WinxError::CommandNotAllowed(
725 "Error: BashCommand not allowed in current mode".to_string(),
726 ));
727 }
728
729 let command = command.trim();
733 if !allow_multi {
734 let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
738 crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
739 }
740
741 if is_background {
743 return execute_in_background(bash_state, command, timeout_s).await;
744 }
745
746 {
748 let bash_guard = bash_state.pty_shell.lock().await;
749
750 if let Some(ref bash) = *bash_guard {
751 if bash.command_running {
752 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
753 }
754 }
755 }
756
757 if bash_state.pty_shell.lock().await.is_none() {
759 bash_state
760 .init_pty_shell()
761 .await
762 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
763 }
764
765 {
770 let needs_reset = {
771 let mut bash_guard = bash_state.pty_shell.lock().await;
772 match bash_guard.as_mut() {
773 Some(bash) => match bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
774 Ok(true) => false,
775 Ok(false) => {
776 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
777 true
778 }
779 Err(e) => {
780 warn!("clear_to_run failed ({e}), resetting shell");
781 true
782 }
783 },
784 None => false,
785 }
786 };
787 if needs_reset {
791 if let Err(e) = bash_state.init_pty_shell().await {
792 warn!("Failed to reset shell after clear_to_run: {e}");
793 }
794 }
795 }
796
797 let scratch_root = bash_state.workspace_root.clone();
799 {
800 let mut bash_guard = bash_state.pty_shell.lock().await;
801
802 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
803
804 bash.output_buffer.clear();
805 bash.output_truncated = false;
806 bash.reset_scratch();
809 bash.set_scratch_root(&scratch_root);
810 bash.last_exit_code = None;
815 bash.last_returned_hash = None;
816 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
818
819 bash.send_special_key("Enter").map_err(|e| {
821 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
822 })?;
823
824 bash.last_command = command.to_string();
825 bash.command_running = true;
826 }
827
828 let shell_arc = bash_state.pty_shell.clone();
830 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
831}
832
833async fn wait_for_output(
837 bash_state: &mut BashState,
838 shell_arc: &SharedPtyShell,
839 timeout_s: f64,
840 is_bg: bool,
841 bg_id: Option<&str>,
842 is_status_check: bool,
843) -> Result<String> {
844 let start = Instant::now();
845 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
846 let mut last_pending_output = String::new();
847 let mut complete = false;
848
849 let mut output = String::new();
857 loop {
858 let elapsed = start.elapsed().as_secs_f64();
859 if elapsed >= wait {
860 break;
861 }
862 let slice = (wait - elapsed).clamp(0.1, POLL_SLICE_SECS);
863 let (out, done) = {
864 let mut bash_guard = shell_arc.lock().await;
865 match bash_guard.as_mut() {
866 Some(bash) => bash.read_output(slice as f32).map_err(|e| {
867 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
868 })?,
869 None => (String::new(), true),
870 }
871 };
872 output = out;
873 complete = done;
874 if complete {
875 break;
876 }
877 }
878
879 if !complete && is_status_check {
888 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
889 let iter_wait_secs = 0.5_f64;
890 let mut patience = OUTPUT_WAIT_PATIENCE;
891
892 let incremental = wcgw_incremental_text(&output, &last_pending_output);
893 if incremental.is_empty() {
894 patience -= 1;
895 }
896
897 let mut last_incremental = incremental;
898
899 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
900 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
901 if remaining < 0.1 {
902 break;
903 }
904 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
905
906 let (new_output, done) = {
907 let mut bash_guard = shell_arc.lock().await;
908
909 if let Some(bash) = bash_guard.as_mut() {
910 bash.read_output(0.5).map_err(|e| {
911 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
912 })?
913 } else {
914 (String::new(), true)
915 }
916 };
917
918 if done {
919 complete = true;
920 output = new_output;
921 break;
922 }
923
924 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
926 if new_incremental == last_incremental {
927 patience -= 1;
928 } else {
929 patience = OUTPUT_WAIT_PATIENCE; }
931 last_incremental = new_incremental;
932
933 output = new_output;
934 }
935
936 if !complete {
937 last_pending_output = output.clone();
939 }
940 }
941
942 if complete {
943 if let Some(cwd) = extract_prompt_cwd(&output) {
944 bash_state.cwd = cwd;
945 }
946 }
947
948 let rendered = wcgw_incremental_text(&output, &last_pending_output);
950
951 let rendered = {
955 let before_lines = rendered.lines().count();
956 match crate::utils::output_compress::compress_output(&rendered) {
957 Some(compressed) => {
958 debug!(
959 from_lines = before_lines,
960 to_lines = compressed.lines().count(),
961 "winx collapsed mechanical repetition in shell output"
962 );
963 compressed
964 }
965 None => rendered,
966 }
967 };
968
969 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
971
972 let running_for =
974 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
975
976 let (exit_code, scratch_pointer) = read_completion_extras(shell_arc, complete).await;
981
982 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref(), exit_code);
984 Ok(format!("{rendered}{status}{scratch_pointer}"))
985}
986
987async fn read_completion_extras(
991 shell_arc: &SharedPtyShell,
992 complete: bool,
993) -> (Option<i32>, String) {
994 let guard = shell_arc.lock().await;
995 let Some(shell) = guard.as_ref() else {
996 return (None, String::new());
997 };
998 let exit_code = if complete { shell.last_exit_code } else { None };
999 let pointer = match (shell.output_truncated, shell.scratch_path()) {
1000 (true, Some(path)) => format!(
1001 "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1002 saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1003 path.display()
1004 ),
1005 _ => String::new(),
1006 };
1007 (exit_code, pointer)
1008}
1009
1010fn finalize_tombstone(
1017 cwd: &Path,
1018 id: &str,
1019 tombstone: ExitedShellInfo,
1020 action: &BashCommandAction,
1021) -> Result<String> {
1022 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
1023 match action {
1024 BashCommandAction::StatusCheck { .. }
1027 | BashCommandAction::Screen { .. }
1028 | BashCommandAction::WaitForTurn { .. } => {
1029 let rendered = wcgw_incremental_text(&final_output, "");
1030 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1031 let mut status = "\n\n---\n\n".to_string();
1033 let _ = writeln!(status, "bg_command_id = {id}");
1034 status.push_str("status = process exited\n");
1035 let _ = writeln!(status, "cwd = {}", cwd.display());
1036 Ok(format!("{rendered}{}", status.trim_end()))
1037 }
1038 BashCommandAction::SendText { .. }
1039 | BashCommandAction::SendSpecials { .. }
1040 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1041 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1042 ))),
1043 BashCommandAction::Command { .. } => {
1044 unreachable!("finalize_tombstone called for non-bg action")
1047 }
1048 }
1049}
1050
1051async fn execute_status_check(
1060 bash_state: &mut BashState,
1061 bg_shell: Option<SharedPtyShell>,
1062 is_bg: bool,
1063 bg_id: Option<&str>,
1064 timeout_s: f64,
1065 scrollback_lines: Option<usize>,
1066 verbose: bool,
1067) -> Result<String> {
1068 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1069
1070 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1073
1074 let is_running = {
1076 let guard = shell_arc.lock().await;
1077 if let Some(ref bash) = *guard {
1078 bash.command_running
1079 } else {
1080 false
1081 }
1082 };
1083
1084 if !is_running && !is_bg {
1086 let mut manager = lock_bg_manager();
1087 let error = format!(
1088 "No command is currently running, so there's nothing to check. The previous \
1089 command already finished and its output was returned when it completed. Start a \
1090 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1091 manager.get_running_info()
1092 );
1093 return Err(WinxError::CommandExecutionError(error));
1094 }
1095
1096 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1098
1099 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1103 if !verbose && scrollback_lines.is_none() {
1104 let mut guard = shell_arc.lock().await;
1105 if let Some(bash) = guard.as_mut() {
1106 let fingerprint = PtyShell::fingerprint(body);
1107 if Some(fingerprint) == bash.last_returned_hash {
1108 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1109 return Ok(format!("no new output since last check{status}"));
1110 }
1111 bash.last_returned_hash = Some(fingerprint);
1112 }
1113 } else if !verbose {
1114 let mut guard = shell_arc.lock().await;
1116 if let Some(bash) = guard.as_mut() {
1117 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1118 }
1119 }
1120
1121 if let Some(lines) = scrollback_lines {
1123 if lines > 0 {
1124 let scrollback = {
1125 let guard = shell_arc.lock().await;
1126 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1127 };
1128 if !scrollback.is_empty() {
1129 let count = scrollback.lines().count();
1130 return Ok(format!(
1131 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1132 ));
1133 }
1134 }
1135 }
1136
1137 Ok(response)
1138}
1139
1140async fn execute_screen(
1144 bash_state: &BashState,
1145 bg_shell: Option<SharedPtyShell>,
1146 is_bg: bool,
1147 bg_id: Option<&str>,
1148 lines: Option<usize>,
1149 diff: bool,
1150) -> Result<String> {
1151 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1152 let max_lines = lines.unwrap_or(0);
1153
1154 if diff {
1155 let (update, is_running, in_alt, cursor) = {
1156 let guard = shell_arc.lock().await;
1157 match guard.as_ref() {
1158 Some(bash) => (
1159 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1160 bash.command_running,
1161 bash.live_in_alt_screen(),
1162 bash.live_cursor_position(),
1163 ),
1164 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1165 }
1166 };
1167 let (crow, ccol) = cursor;
1168 let alt = if in_alt { " [alt-screen]" } else { "" };
1169 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1170 let body = match update {
1171 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1172 ScreenUpdate::Diff(changed) => {
1173 let mut out = String::from("(changed lines only)\n");
1174 for (row, content) in changed {
1175 let _ = writeln!(out, "{row:>4}: {content}");
1176 }
1177 out
1178 }
1179 ScreenUpdate::Full(snap) => {
1180 let joined = snap.join("\n");
1181 if joined.trim().is_empty() {
1182 "(screen is empty)".to_string()
1183 } else {
1184 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1185 }
1186 }
1187 };
1188 return Ok(format!(
1189 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1190 ));
1191 }
1192
1193 let (snapshot, is_running, in_alt, cursor) = {
1194 let guard = shell_arc.lock().await;
1195 match guard.as_ref() {
1196 Some(bash) => (
1197 bash.live_snapshot(max_lines),
1198 bash.command_running,
1199 bash.live_in_alt_screen(),
1200 bash.live_cursor_position(),
1201 ),
1202 None => (Vec::new(), false, false, (0, 0)),
1203 }
1204 };
1205
1206 let joined = snapshot.join("\n");
1207 let body = if joined.trim().is_empty() {
1208 "(screen is empty)".to_string()
1209 } else {
1210 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1211 };
1212 let alt = if in_alt { " [alt-screen]" } else { "" };
1213 let (crow, ccol) = cursor;
1214 let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1215 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1216}
1217
1218#[allow(clippy::too_many_arguments)]
1226async fn execute_wait_for_turn(
1227 bash_state: &BashState,
1228 bg_shell: Option<SharedPtyShell>,
1229 is_bg: bool,
1230 bg_id: Option<&str>,
1231 recognizer_hint: Option<&str>,
1232 quiet_ms: Option<u64>,
1233 timeout_seconds: Option<f32>,
1234 lines: Option<usize>,
1235) -> Result<String> {
1236 use crate::state::turn::{recognizer_for, TurnState};
1237
1238 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1239 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1240 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1241 let settle = quiet.min(Duration::from_millis(300));
1242 let hard_cap =
1243 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1244 let max_lines = lines.unwrap_or(0);
1245 let poll = Duration::from_millis(120);
1246 let warmup = Duration::from_millis(2500);
1249
1250 let start = Instant::now();
1251 let mut last_hash: Option<u64> = None;
1252 let mut initial_hash: Option<u64> = None;
1253 let mut stable_since = Instant::now();
1254 let mut seen_busy = false;
1255
1256 loop {
1257 let (snapshot, in_alt, alive, running) = {
1258 let mut guard = shell_arc.lock().await;
1259 match guard.as_mut() {
1260 Some(bash) => (
1261 bash.live_snapshot(max_lines),
1262 bash.live_in_alt_screen(),
1263 bash.is_alive(),
1264 bash.command_running,
1265 ),
1266 None => (Vec::new(), false, false, false),
1267 }
1268 };
1269
1270 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1271 if initial_hash.is_none() {
1272 initial_hash = Some(hash);
1273 }
1274 if Some(hash) != last_hash {
1275 last_hash = Some(hash);
1276 stable_since = Instant::now();
1277 }
1278
1279 let state = recognizer.detect(&snapshot);
1280 if state == TurnState::Busy {
1281 seen_busy = true;
1282 }
1283 let stable_for = stable_since.elapsed();
1284 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1288 let ready = match state {
1289 TurnState::Busy => false,
1290 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1293 activity && stable_for >= if seen_busy { settle } else { quiet }
1294 }
1295 TurnState::Unknown => activity && stable_for >= quiet,
1296 };
1297
1298 let timed_out = start.elapsed() >= hard_cap;
1299 if ready || !alive || timed_out {
1300 let reason = if !alive {
1301 "exited"
1302 } else if ready {
1303 "ready"
1304 } else {
1305 "timeout"
1306 };
1307 let joined = snapshot.join("\n");
1308 let body = if joined.trim().is_empty() {
1309 "(screen is empty)".to_string()
1310 } else {
1311 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1312 };
1313 let alt = if in_alt { " [alt-screen]" } else { "" };
1314 let header = format!(
1315 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1316 state.as_str(),
1317 reason,
1318 recognizer.name(),
1319 start.elapsed().as_secs_f64(),
1320 alt
1321 );
1322 let status = get_status(bash_state, is_bg, bg_id, running, None, None);
1323 return Ok(format!("{header}\n{body}{status}"));
1324 }
1325
1326 tokio::time::sleep(poll).await;
1327 }
1328}
1329
1330async fn execute_send_text(
1332 bash_state: &mut BashState,
1333 text: &str,
1334 submit: bool,
1335 bg_shell: Option<SharedPtyShell>,
1336 is_bg: bool,
1337 bg_id: Option<&str>,
1338 timeout_s: f64,
1339) -> Result<String> {
1340 debug!("Processing SendText action: {text:?} (submit={submit})");
1341
1342 if text.is_empty() {
1344 return Err(WinxError::CommandExecutionError(
1345 "Failure: send_text cannot be empty".to_string(),
1346 ));
1347 }
1348
1349 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1351
1352 {
1354 let mut guard = shell_arc.lock().await;
1355
1356 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1357
1358 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1360 }
1361
1362 if submit {
1366 submit_enter(&shell_arc).await?;
1367 }
1368
1369 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1371}
1372
1373async fn execute_send_specials(
1375 bash_state: &mut BashState,
1376 keys: &[SpecialKey],
1377 submit: bool,
1378 bg_shell: Option<SharedPtyShell>,
1379 is_bg: bool,
1380 bg_id: Option<&str>,
1381 timeout_s: f64,
1382) -> Result<String> {
1383 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1384
1385 if keys.is_empty() {
1387 return Err(WinxError::CommandExecutionError(
1388 "Failure: send_specials cannot be empty".to_string(),
1389 ));
1390 }
1391
1392 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1393 let mut is_interrupt = false;
1394
1395 {
1396 let mut guard = shell_arc.lock().await;
1397
1398 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1399
1400 for key in keys {
1402 match key {
1403 SpecialKey::KeyUp => {
1404 bash.send_special_key("KeyUp").map_err(|e| {
1406 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1407 })?;
1408 }
1409 SpecialKey::KeyDown => {
1410 bash.send_special_key("KeyDown").map_err(|e| {
1412 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1413 })?;
1414 }
1415 SpecialKey::KeyLeft => {
1416 bash.send_special_key("KeyLeft").map_err(|e| {
1418 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1419 })?;
1420 }
1421 SpecialKey::KeyRight => {
1422 bash.send_special_key("KeyRight").map_err(|e| {
1424 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1425 })?;
1426 }
1427 SpecialKey::Enter => {
1428 bash.send_special_key("Enter").map_err(|e| {
1430 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1431 })?;
1432 }
1433 SpecialKey::CtrlC => {
1434 bash.send_interrupt().map_err(|e| {
1436 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1437 })?;
1438 is_interrupt = true;
1439 }
1440 SpecialKey::CtrlD => {
1441 bash.send_eof().map_err(|e| {
1443 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1444 })?;
1445 is_interrupt = true;
1446 }
1447 SpecialKey::CtrlZ => {
1448 bash.send_suspend().map_err(|e| {
1450 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1451 })?;
1452 }
1453 }
1454 }
1455 }
1456
1457 if submit {
1460 submit_enter(&shell_arc).await?;
1461 }
1462
1463 let mut output =
1471 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1472
1473 if is_interrupt && output.contains("status = still running") {
1475 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1476 }
1477
1478 Ok(output)
1479}
1480
1481async fn execute_send_ascii(
1483 bash_state: &mut BashState,
1484 ascii_codes: &[u8],
1485 submit: bool,
1486 bg_shell: Option<SharedPtyShell>,
1487 is_bg: bool,
1488 bg_id: Option<&str>,
1489 timeout_s: f64,
1490) -> Result<String> {
1491 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1492
1493 if ascii_codes.is_empty() {
1495 return Err(WinxError::CommandExecutionError(
1496 "Failure: send_ascii cannot be empty".to_string(),
1497 ));
1498 }
1499
1500 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1501 let mut is_interrupt = false;
1502
1503 {
1504 let mut guard = shell_arc.lock().await;
1505
1506 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1507
1508 for &code in ascii_codes {
1510 bash.send_bytes(&[code]).map_err(|e| {
1512 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1513 })?;
1514
1515 if code == 3 {
1517 is_interrupt = true;
1518 }
1519 }
1520 }
1521
1522 if submit {
1525 submit_enter(&shell_arc).await?;
1526 }
1527
1528 let mut output =
1534 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1535
1536 if is_interrupt && output.contains("status = still running") {
1538 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1539 }
1540
1541 Ok(output)
1542}
1543
1544async fn execute_in_background(
1546 bash_state: &mut BashState,
1547 command: &str,
1548 timeout_s: f64,
1549) -> Result<String> {
1550 debug!("Executing command in background: {}", command);
1551
1552 let restricted_mode =
1554 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1555
1556 let bg_id = {
1557 let mut manager = lock_bg_manager();
1558 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1559 };
1560
1561 let shell_arc = {
1563 let manager = lock_bg_manager();
1564 manager.get_shell(&bg_id).ok_or_else(|| {
1565 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1566 })?
1567 };
1568
1569 let scratch_root = bash_state.workspace_root.clone();
1571 {
1572 let mut guard = shell_arc.lock().await;
1573 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1574 bash.set_scratch_root(&scratch_root);
1577 bash.send_command(command).map_err(|e| {
1578 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1579 })?;
1580 }
1581 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1582
1583 let _ = timeout_s;
1584 let _ = shell_arc;
1585 Ok(get_status(bash_state, true, Some(&bg_id), true, None, None))
1586}
1587
1588#[cfg(test)]
1589mod tests {
1590 use super::strip_tail_pipe_impl;
1591
1592 #[test]
1593 fn strips_trailing_tail_by_default() {
1594 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1595 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1596 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1597 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1598 }
1599
1600 #[test]
1601 fn keeps_command_without_trailing_tail() {
1602 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1604 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1605 assert_eq!(
1606 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1607 "cat a | tail -5 | wc -l"
1608 );
1609 }
1610
1611 #[test]
1612 fn keep_mode_preserves_tail_pipe() {
1613 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1615 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1616 }
1617}