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::process::{Command, Stdio};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::{Duration, Instant};
16use tokio::sync::Mutex;
17use tokio::time::sleep;
18use tracing::{debug, error, info, warn};
19
20use crate::errors::{Result, WinxError};
21use crate::state::bash_state::BashState;
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
60fn char_safe_tail(text: &str, max_len: usize) -> &str {
63 if text.len() <= max_len {
64 return text;
65 }
66 let mut start = text.len() - max_len;
67 while start < text.len() && !text.is_char_boundary(start) {
68 start += 1;
69 }
70 &text[start..]
71}
72
73fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
81 if text.len() <= MAX_OUTPUT_LENGTH {
82 return std::borrow::Cow::Borrowed(text);
83 }
84
85 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
86 return std::borrow::Cow::Owned(format!(
88 "(...truncated)\n{}",
89 char_safe_tail(text, MAX_OUTPUT_LENGTH)
90 ));
91 };
92
93 if tokens.len() <= max_tokens {
94 return std::borrow::Cow::Borrowed(text);
95 }
96
97 let keep = max_tokens.saturating_sub(1);
99 let tail = &tokens[tokens.len() - keep..];
100 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
101 char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
103 });
104 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
105}
106
107const 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.
1091. Get its output using status check.
1102. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1113. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1124. Interrupt and run the process in background
113";
114
115#[derive(Debug, Clone)]
121pub struct ExitedShellInfo {
122 pub last_command: String,
123 pub final_output: String,
124 pub exited_at: Instant,
125}
126
127#[derive(Debug, Default)]
129pub struct BackgroundShellManager {
130 shells: HashMap<String, SharedPtyShell>,
131 tombstones: HashMap<String, ExitedShellInfo>,
134}
135
136impl BackgroundShellManager {
137 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
139
140 pub fn new() -> Self {
142 Self { shells: HashMap::new(), tombstones: HashMap::new() }
143 }
144
145 pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
147 let cid = format!("{:010x}", rand::rng().random::<u32>());
148
149 let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
150 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
151 })?;
152
153 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
154
155 info!("Started background shell with id: {}", cid);
156 Ok(cid)
157 }
158
159 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
161 self.shells.get(bg_command_id).cloned()
162 }
163
164 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
166 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
167 if let Ok(mut guard) = shell_arc.try_lock() {
168 *guard = None;
169 }
170 info!("Removed background shell: {}", bg_command_id);
171 true
172 } else {
173 false
174 }
175 }
176
177 fn prune_finished_shells(&mut self) {
178 let now = Instant::now();
180 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
181
182 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
183
184 for (id, shell_arc) in &self.shells {
185 let Ok(mut guard) = shell_arc.try_lock() else {
186 continue;
187 };
188
189 let Some(shell) = guard.as_mut() else {
190 finished.push((id.clone(), None));
191 continue;
192 };
193
194 if !shell.is_alive() {
195 let tombstone = ExitedShellInfo {
196 last_command: shell.last_command.clone(),
197 final_output: shell.output_buffer.clone(),
198 exited_at: now,
199 };
200 finished.push((id.clone(), Some(tombstone)));
201 continue;
202 }
203
204 if shell.last_command.is_empty() {
209 continue;
210 }
211
212 if shell.command_running {
213 let _ = shell.read_output(0.1);
214 }
215
216 if !shell.command_running {
217 let tombstone = ExitedShellInfo {
218 last_command: shell.last_command.clone(),
219 final_output: shell.output_buffer.clone(),
220 exited_at: now,
221 };
222 finished.push((id.clone(), Some(tombstone)));
223 }
224 }
225
226 for (id, tombstone) in finished {
227 self.remove_shell(&id);
228 if let Some(info) = tombstone {
229 self.tombstones.insert(id, info);
230 }
231 }
232 }
233
234 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
241 self.tombstones.get(bg_command_id).cloned()
242 }
243
244 pub fn get_running_info(&mut self) -> String {
246 self.prune_finished_shells();
247
248 if self.shells.is_empty() {
249 return "No command running in background.\n".to_string();
250 }
251
252 let mut running = Vec::new();
253 for (id, shell_arc) in &self.shells {
254 if let Ok(guard) = shell_arc.try_lock() {
255 if let Some(bash) = guard.as_ref() {
256 if bash.command_running {
257 running
258 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
259 }
260 }
261 } else {
262 running.push(format!("Command: <busy>, bg_command_id: {id}"));
263 }
264 }
265
266 if running.is_empty() {
267 "No command running in background.\n".to_string()
268 } else {
269 format!("Following background commands are attached:\n{}\n", running.join("\n"))
270 }
271 }
272}
273
274lazy_static::lazy_static! {
276 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
277}
278
279fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
286 BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
287}
288
289fn get_status(
293 bash_state: &BashState,
294 is_bg: bool,
295 bg_id: Option<&str>,
296 is_running: bool,
297 running_for: Option<&str>,
298) -> String {
299 let mut status = "\n\n---\n\n".to_string();
300
301 if is_bg {
302 if let Some(id) = bg_id {
303 let _ = writeln!(status, "bg_command_id = {id}");
304 }
305 }
306
307 if is_running {
308 status.push_str("status = still running\n");
309 if let Some(duration) = running_for {
310 let _ = writeln!(status, "running for = {duration}");
311 }
312 } else {
313 status.push_str("status = process exited\n");
314 }
315
316 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
317
318 if !is_bg {
319 {
321 let mut manager = lock_bg_manager();
322 status.push_str("This is the main shell. ");
323 status.push_str(&manager.get_running_info());
324 }
325 }
326
327 status.trim_end().to_string()
328}
329
330fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
332 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
333 let text = truncated.as_ref();
334
335 if last_pending_output.is_empty() {
336 let rendered = render_terminal_output(text);
337 return rstrip_lines(&rendered).trim_start().to_string();
338 }
339
340 let last_rendered = render_terminal_output(last_pending_output);
341 if last_rendered.is_empty() {
342 return rstrip_lines(&render_terminal_output(text));
343 }
344
345 let text_after_last = if text.len() > last_pending_output.len() {
347 &text[last_pending_output.len()..]
348 } else {
349 text
350 };
351
352 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
353 let new_rendered = render_terminal_output(&combined);
354
355 let incremental = get_incremental_output(&last_rendered, &new_rendered);
357 rstrip_lines(&incremental)
358}
359
360fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
361 static PROMPT_RE: std::sync::OnceLock<Option<Regex>> = std::sync::OnceLock::new();
362 let prompt_regex =
363 PROMPT_RE.get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()).as_ref()?;
364 let stripped = strip_ansi_codes(output);
365
366 prompt_regex
367 .captures_iter(&stripped)
368 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
369 .filter(|cwd| !cwd.is_empty())
370 .last()
371 .map(PathBuf::from)
372}
373
374fn rstrip_lines(lines: &[String]) -> String {
376 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
377}
378
379fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
381 if old_output.is_empty() {
382 return new_output.to_vec();
383 }
384
385 let nold = old_output.len();
386 let nnew = new_output.len();
387
388 for i in (0..nnew).rev() {
390 if new_output[i] != old_output[nold - 1] {
391 continue;
392 }
393
394 let mut matched = true;
395 for j in (0..i).rev() {
396 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
397 if old_idx < 0 {
398 break;
399 }
400 if new_output[j] != old_output[old_idx as usize] {
401 matched = false;
402 break;
403 }
404 }
405
406 if matched {
407 return new_output[i + 1..].to_vec();
408 }
409 }
410
411 new_output.to_vec()
412}
413
414fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
415 let mut start = 0;
416
417 while start < text.len() {
418 let mut end = (start + chunk_size).min(text.len());
419 while !text.is_char_boundary(end) {
420 end -= 1;
421 }
422 if end == start {
423 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
424 }
425
426 shell.send_text(&text[start..end]).map_err(|e| {
427 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
428 })?;
429 start = end;
430 }
431
432 Ok(())
433}
434
435#[allow(dead_code)]
437fn is_status_check_action(action: &BashCommandAction) -> bool {
438 match action {
439 BashCommandAction::StatusCheck { .. } => true,
440 BashCommandAction::SendSpecials { send_specials, .. } => {
441 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
442 }
443 BashCommandAction::SendAscii { send_ascii, .. } => {
444 send_ascii.len() == 1 && send_ascii[0] == 10 }
446 _ => false,
447 }
448}
449
450#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
457pub async fn handle_tool_call(
458 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
459 bash_command: BashCommand,
460) -> Result<String> {
461 info!("BashCommand tool called with: {:?}", bash_command);
462
463 let thread_id = normalize_thread_id(&bash_command.thread_id);
464
465 if thread_id.is_empty() {
467 error!("Empty thread_id provided in BashCommand");
468 return Err(WinxError::ThreadIdMismatch(
469 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
470 ));
471 }
472
473 let mut bash_state: BashState;
475 {
476 let bash_state_guard = bash_state_arc.lock().await;
477
478 let Some(state) = &*bash_state_guard else {
479 error!("BashState not initialized");
480 return Err(WinxError::BashStateNotInitialized);
481 };
482
483 bash_state = state.clone();
484 }
485
486 if thread_id != bash_state.current_thread_id {
488 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
490 return Err(WinxError::ThreadIdMismatch(format!(
491 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
492 )));
493 }
494 }
495
496 let timeout_s = bash_command
499 .wait_for_seconds
500 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
501 .min(TIMEOUT_WHILE_OUTPUT);
502
503 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
505
506 {
507 let mut bash_state_guard = bash_state_arc.lock().await;
508 if let Some(state) = bash_state_guard.as_mut() {
509 state.cwd.clone_from(&bash_state.cwd);
510 }
511 }
512
513 match result {
515 Ok(mut output) => {
516 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
517 let cmd_trimmed = command.trim();
518 if output.starts_with(cmd_trimmed) {
519 output = output[cmd_trimmed.len()..].to_string();
520 }
521 }
522 Ok(output)
523 }
524 Err(e) => Err(e),
525 }
526}
527
528async fn execute_bash_action(
530 bash_state: &mut BashState,
531 action: &BashCommandAction,
532 timeout_s: f64,
533) -> Result<String> {
534 let mut is_bg = false;
535 let mut bg_id: Option<String> = None;
536
537 let bg_shell: Option<SharedPtyShell> = match action {
539 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
541 | BashCommandAction::SendText { bg_command_id, .. }
542 | BashCommandAction::SendSpecials { bg_command_id, .. }
543 | BashCommandAction::SendAscii { bg_command_id, .. } => {
544 if let Some(id) = bg_command_id {
545 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
546 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
547 })?;
548 manager.prune_finished_shells();
549
550 if let Some(shell) = manager.get_shell(id) {
551 is_bg = true;
552 bg_id = Some(id.clone());
553 Some(shell)
554 } else if let Some(tombstone) = manager.peek_tombstone(id) {
555 drop(manager);
560 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
561 } else {
562 let error = format!(
564 "No shell found running with command id {}.\n{}",
565 id,
566 manager.get_running_info()
567 );
568 return Err(WinxError::CommandExecutionError(error));
569 }
570 } else {
571 None
572 }
573 }
574 };
575
576 match action {
578 BashCommandAction::Command { command, is_background, allow_multi } => {
579 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
580 }
581 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
582 execute_status_check(
583 bash_state,
584 bg_shell,
585 is_bg,
586 bg_id.as_deref(),
587 timeout_s,
588 *scrollback_lines,
589 *verbose,
590 )
591 .await
592 }
593 BashCommandAction::SendText { send_text, submit, .. } => {
594 execute_send_text(
595 bash_state,
596 send_text,
597 *submit,
598 bg_shell,
599 is_bg,
600 bg_id.as_deref(),
601 timeout_s,
602 )
603 .await
604 }
605 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
606 execute_send_specials(
607 bash_state,
608 send_specials,
609 *submit,
610 bg_shell,
611 is_bg,
612 bg_id.as_deref(),
613 timeout_s,
614 )
615 .await
616 }
617 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
618 execute_send_ascii(
619 bash_state,
620 send_ascii,
621 *submit,
622 bg_shell,
623 is_bg,
624 bg_id.as_deref(),
625 timeout_s,
626 )
627 .await
628 }
629 }
630}
631
632fn strip_tail_pipe(command: &str) -> String {
642 strip_tail_pipe_impl(command, keep_tail_pipe())
643}
644
645fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
648 static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
649 if keep {
650 return command.to_string();
651 }
652 let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
653 match re.as_ref().and_then(|re| re.find(command)) {
654 Some(matched) => command[..matched.start()].trim_end().to_string(),
655 None => command.to_string(),
656 }
657}
658
659fn keep_tail_pipe() -> bool {
661 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
662 let value = value.trim();
663 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
664 })
665}
666
667async fn execute_command(
669 bash_state: &mut BashState,
670 command: &str,
671 is_background: bool,
672 allow_multi: bool,
673 timeout_s: f64,
674) -> Result<String> {
675 let stripped_command = strip_tail_pipe(command);
677 let command = stripped_command.as_str();
678 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
679
680 if !bash_state.is_command_allowed(command) {
682 error!("Command '{}' not allowed in current mode", command);
683 return Err(WinxError::CommandNotAllowed(
684 "Error: BashCommand not allowed in current mode".to_string(),
685 ));
686 }
687
688 let command = command.trim();
692 if !allow_multi {
693 crate::utils::bash_parser::assert_single_statement(command)?;
694 }
695
696 if is_background {
698 return execute_in_background(bash_state, command, timeout_s).await;
699 }
700
701 {
703 let bash_guard = bash_state.pty_shell.lock().await;
704
705 if let Some(ref bash) = *bash_guard {
706 if bash.command_running {
707 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
708 }
709 }
710 }
711
712 if bash_state.pty_shell.lock().await.is_none() {
714 bash_state
715 .init_pty_shell()
716 .await
717 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
718 }
719
720 {
725 let needs_reset = {
726 let mut bash_guard = bash_state.pty_shell.lock().await;
727 match bash_guard.as_mut() {
728 Some(bash) => match bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
729 Ok(true) => false,
730 Ok(false) => {
731 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
732 true
733 }
734 Err(e) => {
735 warn!("clear_to_run failed ({e}), resetting shell");
736 true
737 }
738 },
739 None => false,
740 }
741 };
742 if needs_reset {
746 if let Err(e) = bash_state.init_pty_shell().await {
747 warn!("Failed to reset shell after clear_to_run: {e}");
748 }
749 }
750 }
751
752 {
754 let mut bash_guard = bash_state.pty_shell.lock().await;
755
756 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
757
758 bash.output_buffer.clear();
759 bash.output_truncated = false;
760 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
762
763 bash.send_special_key("Enter").map_err(|e| {
765 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
766 })?;
767
768 bash.last_command = command.to_string();
769 bash.command_running = true;
770 }
771
772 let shell_arc = bash_state.pty_shell.clone();
774 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
775}
776
777async fn wait_for_output(
781 bash_state: &mut BashState,
782 shell_arc: &SharedPtyShell,
783 timeout_s: f64,
784 is_bg: bool,
785 bg_id: Option<&str>,
786 is_status_check: bool,
787) -> Result<String> {
788 let start = Instant::now();
789 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
790 let mut last_pending_output = String::new();
791 let mut complete = false;
792
793 let mut output = String::new();
801 loop {
802 let elapsed = start.elapsed().as_secs_f64();
803 if elapsed >= wait {
804 break;
805 }
806 let slice = (wait - elapsed).clamp(0.1, POLL_SLICE_SECS);
807 let (out, done) = {
808 let mut bash_guard = shell_arc.lock().await;
809 match bash_guard.as_mut() {
810 Some(bash) => bash.read_output(slice as f32).map_err(|e| {
811 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
812 })?,
813 None => (String::new(), true),
814 }
815 };
816 output = out;
817 complete = done;
818 if complete {
819 break;
820 }
821 }
822
823 if !complete && is_status_check {
832 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
833 let iter_wait_secs = 0.5_f64;
834 let mut patience = OUTPUT_WAIT_PATIENCE;
835
836 let incremental = wcgw_incremental_text(&output, &last_pending_output);
837 if incremental.is_empty() {
838 patience -= 1;
839 }
840
841 let mut last_incremental = incremental;
842
843 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
844 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
845 if remaining < 0.1 {
846 break;
847 }
848 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
849
850 let (new_output, done) = {
851 let mut bash_guard = shell_arc.lock().await;
852
853 if let Some(bash) = bash_guard.as_mut() {
854 bash.read_output(0.5).map_err(|e| {
855 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
856 })?
857 } else {
858 (String::new(), true)
859 }
860 };
861
862 if done {
863 complete = true;
864 output = new_output;
865 break;
866 }
867
868 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
870 if new_incremental == last_incremental {
871 patience -= 1;
872 } else {
873 patience = OUTPUT_WAIT_PATIENCE; }
875 last_incremental = new_incremental;
876
877 output = new_output;
878 }
879
880 if !complete {
881 last_pending_output = output.clone();
883 }
884 }
885
886 if complete {
887 if let Some(cwd) = extract_prompt_cwd(&output) {
888 bash_state.cwd = cwd;
889 }
890 }
891
892 let rendered = wcgw_incremental_text(&output, &last_pending_output);
894
895 let rendered = crate::utils::output_compress::compress_output(&rendered).unwrap_or(rendered);
899
900 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
902
903 let running_for =
905 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
906
907 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
909 Ok(format!("{rendered}{status}"))
910}
911
912fn finalize_tombstone(
919 cwd: &Path,
920 id: &str,
921 tombstone: ExitedShellInfo,
922 action: &BashCommandAction,
923) -> Result<String> {
924 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
925 match action {
926 BashCommandAction::StatusCheck { .. } => {
927 let rendered = wcgw_incremental_text(&final_output, "");
928 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
929 let mut status = "\n\n---\n\n".to_string();
931 let _ = writeln!(status, "bg_command_id = {id}");
932 status.push_str("status = process exited\n");
933 let _ = writeln!(status, "cwd = {}", cwd.display());
934 Ok(format!("{rendered}{}", status.trim_end()))
935 }
936 BashCommandAction::SendText { .. }
937 | BashCommandAction::SendSpecials { .. }
938 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
939 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
940 ))),
941 BashCommandAction::Command { .. } => {
942 unreachable!("finalize_tombstone called for non-bg action")
945 }
946 }
947}
948
949async fn execute_status_check(
958 bash_state: &mut BashState,
959 bg_shell: Option<SharedPtyShell>,
960 is_bg: bool,
961 bg_id: Option<&str>,
962 timeout_s: f64,
963 scrollback_lines: Option<usize>,
964 verbose: bool,
965) -> Result<String> {
966 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
967
968 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
971
972 let is_running = {
974 let guard = shell_arc.lock().await;
975 if let Some(ref bash) = *guard {
976 bash.command_running
977 } else {
978 false
979 }
980 };
981
982 if !is_running && !is_bg {
984 let mut manager = lock_bg_manager();
985 let error = format!(
986 "No command is currently running, so there's nothing to check. The previous \
987 command already finished and its output was returned when it completed. Start a \
988 new command, or pass a bg_command_id if you launched one in the background.\n{}",
989 manager.get_running_info()
990 );
991 return Err(WinxError::CommandExecutionError(error));
992 }
993
994 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
996
997 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1001 if !verbose && scrollback_lines.is_none() {
1002 let mut guard = shell_arc.lock().await;
1003 if let Some(bash) = guard.as_mut() {
1004 let fingerprint = PtyShell::fingerprint(body);
1005 if Some(fingerprint) == bash.last_returned_hash {
1006 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
1007 return Ok(format!("no new output since last check{status}"));
1008 }
1009 bash.last_returned_hash = Some(fingerprint);
1010 }
1011 } else if !verbose {
1012 let mut guard = shell_arc.lock().await;
1014 if let Some(bash) = guard.as_mut() {
1015 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1016 }
1017 }
1018
1019 if let Some(lines) = scrollback_lines {
1021 if lines > 0 {
1022 let scrollback = {
1023 let guard = shell_arc.lock().await;
1024 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1025 };
1026 if !scrollback.is_empty() {
1027 let count = scrollback.lines().count();
1028 return Ok(format!(
1029 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1030 ));
1031 }
1032 }
1033 }
1034
1035 Ok(response)
1036}
1037
1038async fn execute_send_text(
1040 bash_state: &mut BashState,
1041 text: &str,
1042 submit: bool,
1043 bg_shell: Option<SharedPtyShell>,
1044 is_bg: bool,
1045 bg_id: Option<&str>,
1046 timeout_s: f64,
1047) -> Result<String> {
1048 debug!("Processing SendText action: {text:?} (submit={submit})");
1049
1050 if text.is_empty() {
1052 return Err(WinxError::CommandExecutionError(
1053 "Failure: send_text cannot be empty".to_string(),
1054 ));
1055 }
1056
1057 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1059
1060 {
1062 let mut guard = shell_arc.lock().await;
1063
1064 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1065
1066 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1068
1069 if submit {
1073 bash.send_special_key("Enter").map_err(|e| {
1074 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
1075 })?;
1076 }
1077 }
1078
1079 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1081}
1082
1083async fn execute_send_specials(
1085 bash_state: &mut BashState,
1086 keys: &[SpecialKey],
1087 submit: bool,
1088 bg_shell: Option<SharedPtyShell>,
1089 is_bg: bool,
1090 bg_id: Option<&str>,
1091 timeout_s: f64,
1092) -> Result<String> {
1093 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1094
1095 if keys.is_empty() {
1097 return Err(WinxError::CommandExecutionError(
1098 "Failure: send_specials cannot be empty".to_string(),
1099 ));
1100 }
1101
1102 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1103 let mut is_interrupt = false;
1104
1105 {
1106 let mut guard = shell_arc.lock().await;
1107
1108 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1109
1110 for key in keys {
1112 match key {
1113 SpecialKey::KeyUp => {
1114 bash.send_special_key("KeyUp").map_err(|e| {
1116 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1117 })?;
1118 }
1119 SpecialKey::KeyDown => {
1120 bash.send_special_key("KeyDown").map_err(|e| {
1122 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1123 })?;
1124 }
1125 SpecialKey::KeyLeft => {
1126 bash.send_special_key("KeyLeft").map_err(|e| {
1128 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1129 })?;
1130 }
1131 SpecialKey::KeyRight => {
1132 bash.send_special_key("KeyRight").map_err(|e| {
1134 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1135 })?;
1136 }
1137 SpecialKey::Enter => {
1138 bash.send_special_key("Enter").map_err(|e| {
1140 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1141 })?;
1142 }
1143 SpecialKey::CtrlC => {
1144 bash.send_interrupt().map_err(|e| {
1146 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1147 })?;
1148 is_interrupt = true;
1149 }
1150 SpecialKey::CtrlD => {
1151 bash.send_eof().map_err(|e| {
1153 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1154 })?;
1155 is_interrupt = true;
1156 }
1157 SpecialKey::CtrlZ => {
1158 bash.send_suspend().map_err(|e| {
1160 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1161 })?;
1162 }
1163 }
1164 }
1165 if submit {
1167 bash.send_special_key("Enter")
1168 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1169 }
1170 }
1171
1172 let mut output =
1180 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1181
1182 if is_interrupt && output.contains("status = still running") {
1184 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1185 }
1186
1187 Ok(output)
1188}
1189
1190async fn execute_send_ascii(
1192 bash_state: &mut BashState,
1193 ascii_codes: &[u8],
1194 submit: bool,
1195 bg_shell: Option<SharedPtyShell>,
1196 is_bg: bool,
1197 bg_id: Option<&str>,
1198 timeout_s: f64,
1199) -> Result<String> {
1200 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1201
1202 if ascii_codes.is_empty() {
1204 return Err(WinxError::CommandExecutionError(
1205 "Failure: send_ascii cannot be empty".to_string(),
1206 ));
1207 }
1208
1209 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1210 let mut is_interrupt = false;
1211
1212 {
1213 let mut guard = shell_arc.lock().await;
1214
1215 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1216
1217 for &code in ascii_codes {
1219 bash.send_bytes(&[code]).map_err(|e| {
1221 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1222 })?;
1223
1224 if code == 3 {
1226 is_interrupt = true;
1227 }
1228 }
1229 if submit {
1231 bash.send_special_key("Enter")
1232 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1233 }
1234 }
1235
1236 let mut output =
1242 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1243
1244 if is_interrupt && output.contains("status = still running") {
1246 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1247 }
1248
1249 Ok(output)
1250}
1251
1252async fn execute_in_background(
1254 bash_state: &mut BashState,
1255 command: &str,
1256 timeout_s: f64,
1257) -> Result<String> {
1258 debug!("Executing command in background: {}", command);
1259
1260 let restricted_mode =
1262 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1263
1264 let bg_id = {
1265 let mut manager = lock_bg_manager();
1266 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1267 };
1268
1269 let shell_arc = {
1271 let manager = lock_bg_manager();
1272 manager.get_shell(&bg_id).ok_or_else(|| {
1273 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1274 })?
1275 };
1276
1277 {
1279 let mut guard = shell_arc.lock().await;
1280 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1281 bash.send_command(command).map_err(|e| {
1282 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1283 })?;
1284 }
1285 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1286
1287 let _ = timeout_s;
1288 let _ = shell_arc;
1289 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1290}
1291
1292#[allow(dead_code)]
1296#[tracing::instrument(level = "debug", skip(command, cwd))]
1297async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1298 debug!("Executing command: {}", command);
1299
1300 let start_time = Instant::now();
1301 let mut cmd = Command::new("sh");
1302 cmd.arg("-c")
1303 .arg(command)
1304 .current_dir(cwd)
1305 .stdin(Stdio::null())
1306 .stdout(Stdio::piped())
1307 .stderr(Stdio::piped());
1308
1309 let output = cmd.output().context("Failed to execute command")?;
1310 let elapsed = start_time.elapsed();
1311
1312 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1313 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1314
1315 let raw_result = format!("{stdout}{stderr}");
1316 let mut result = raw_result.clone();
1317 if !raw_result.is_empty() {
1318 let rendered_lines = render_terminal_output(&raw_result);
1319 if rendered_lines.is_empty() {
1320 result = strip_ansi_codes(&raw_result);
1322 } else {
1323 result = rendered_lines.join("\n");
1324 }
1325 }
1326
1327 result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
1328
1329 let exit_status = if output.status.success() {
1330 "Command completed successfully".to_string()
1331 } else {
1332 format!("Command failed with status: {}", output.status)
1333 };
1334
1335 let current_dir = std::env::current_dir()
1336 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1337
1338 debug!("Command executed in {:.2?}", elapsed);
1339 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1340}
1341
1342#[allow(dead_code)]
1344#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1345async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1346 debug!("Executing command in screen session '{}': {}", screen_name, command);
1347
1348 let screen_check = Command::new("which")
1349 .arg("screen")
1350 .output()
1351 .context("Failed to check for screen command")?;
1352
1353 if !screen_check.status.success() {
1354 warn!("Screen command not found, falling back to direct execution");
1355 return execute_simple_command(command, cwd).await;
1356 }
1357
1358 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1359
1360 let screen_cmd = format!(
1361 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1362 screen_name,
1363 command.replace('\'', "'\\''")
1364 );
1365
1366 let screen_start = Command::new("sh")
1367 .arg("-c")
1368 .arg(&screen_cmd)
1369 .current_dir(cwd)
1370 .output()
1371 .context("Failed to start screen session")?;
1372
1373 if !screen_start.status.success() {
1374 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1375 error!("Failed to start screen session: {}", stderr);
1376 return Err(WinxError::CommandExecutionError(format!(
1377 "Failed to start screen session: {stderr}"
1378 )));
1379 }
1380
1381 sleep(Duration::from_millis(300)).await;
1382
1383 let screen_check =
1384 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1385
1386 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1387
1388 let current_dir = std::env::current_dir()
1389 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1390
1391 Ok(format!(
1392 "Started command in background screen session '{screen_name}'.\n\
1393 Use status_check to get output.\n\n\
1394 Screen sessions:\n{screen_list}\n\
1395 ---\n\n\
1396 status = running in background\n\
1397 cwd = {current_dir}\n"
1398 ))
1399}
1400
1401#[allow(dead_code)]
1403fn special_key_to_screen_input(key: SpecialKey) -> String {
1404 match key {
1405 SpecialKey::Enter => String::from("\r"),
1406 SpecialKey::KeyUp => String::from("\x1b[A"),
1407 SpecialKey::KeyDown => String::from("\x1b[B"),
1408 SpecialKey::KeyLeft => String::from("\x1b[D"),
1409 SpecialKey::KeyRight => String::from("\x1b[C"),
1410 SpecialKey::CtrlC => String::from("\x03"),
1411 SpecialKey::CtrlD => String::from("\x04"),
1412 SpecialKey::CtrlZ => String::from("\x1a"),
1413 }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::strip_tail_pipe_impl;
1419
1420 #[test]
1421 fn strips_trailing_tail_by_default() {
1422 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1423 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1424 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1425 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1426 }
1427
1428 #[test]
1429 fn keeps_command_without_trailing_tail() {
1430 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1432 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1433 assert_eq!(
1434 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1435 "cat a | tail -5 | wc -l"
1436 );
1437 }
1438
1439 #[test]
1440 fn keep_mode_preserves_tail_pipe() {
1441 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1443 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1444 }
1445}