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() {
349 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
350 &text[cut..]
351 } else {
352 text
353 };
354
355 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
356 let new_rendered = render_terminal_output(&combined);
357
358 let incremental = get_incremental_output(&last_rendered, &new_rendered);
360 rstrip_lines(&incremental)
361}
362
363fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
364 static PROMPT_RE: std::sync::OnceLock<Option<Regex>> = std::sync::OnceLock::new();
365 let prompt_regex =
366 PROMPT_RE.get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()).as_ref()?;
367 let stripped = strip_ansi_codes(output);
368
369 prompt_regex
370 .captures_iter(&stripped)
371 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
372 .filter(|cwd| !cwd.is_empty())
373 .last()
374 .map(PathBuf::from)
375}
376
377fn rstrip_lines(lines: &[String]) -> String {
379 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
380}
381
382fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
384 if old_output.is_empty() {
385 return new_output.to_vec();
386 }
387
388 let nold = old_output.len();
389 let nnew = new_output.len();
390
391 for i in (0..nnew).rev() {
393 if new_output[i] != old_output[nold - 1] {
394 continue;
395 }
396
397 let mut matched = true;
398 for j in (0..i).rev() {
399 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
400 if old_idx < 0 {
401 break;
402 }
403 if new_output[j] != old_output[old_idx as usize] {
404 matched = false;
405 break;
406 }
407 }
408
409 if matched {
410 return new_output[i + 1..].to_vec();
411 }
412 }
413
414 new_output.to_vec()
415}
416
417fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
418 let mut start = 0;
419
420 while start < text.len() {
421 let mut end = (start + chunk_size).min(text.len());
422 while !text.is_char_boundary(end) {
423 end -= 1;
424 }
425 if end == start {
426 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
427 }
428
429 shell.send_text(&text[start..end]).map_err(|e| {
430 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
431 })?;
432 start = end;
433 }
434
435 Ok(())
436}
437
438#[allow(dead_code)]
440fn is_status_check_action(action: &BashCommandAction) -> bool {
441 match action {
442 BashCommandAction::StatusCheck { .. } => true,
443 BashCommandAction::SendSpecials { send_specials, .. } => {
444 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
445 }
446 BashCommandAction::SendAscii { send_ascii, .. } => {
447 send_ascii.len() == 1 && send_ascii[0] == 10 }
449 _ => false,
450 }
451}
452
453#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
460pub async fn handle_tool_call(
461 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
462 bash_command: BashCommand,
463) -> Result<String> {
464 info!("BashCommand tool called with: {:?}", bash_command);
465
466 let thread_id = normalize_thread_id(&bash_command.thread_id);
467
468 if thread_id.is_empty() {
470 error!("Empty thread_id provided in BashCommand");
471 return Err(WinxError::ThreadIdMismatch(
472 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
473 ));
474 }
475
476 let mut bash_state: BashState;
478 {
479 let bash_state_guard = bash_state_arc.lock().await;
480
481 let Some(state) = &*bash_state_guard else {
482 error!("BashState not initialized");
483 return Err(WinxError::BashStateNotInitialized);
484 };
485
486 bash_state = state.clone();
487 }
488
489 if thread_id != bash_state.current_thread_id {
491 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
493 return Err(WinxError::ThreadIdMismatch(format!(
494 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
495 )));
496 }
497 }
498
499 let timeout_s = bash_command
502 .wait_for_seconds
503 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
504 .min(TIMEOUT_WHILE_OUTPUT);
505
506 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
508
509 {
510 let mut bash_state_guard = bash_state_arc.lock().await;
511 if let Some(state) = bash_state_guard.as_mut() {
512 state.cwd.clone_from(&bash_state.cwd);
513 }
514 }
515
516 match result {
518 Ok(mut output) => {
519 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
520 let cmd_trimmed = command.trim();
521 if output.starts_with(cmd_trimmed) {
522 output = output[cmd_trimmed.len()..].to_string();
523 }
524 }
525 Ok(output)
526 }
527 Err(e) => Err(e),
528 }
529}
530
531async fn execute_bash_action(
533 bash_state: &mut BashState,
534 action: &BashCommandAction,
535 timeout_s: f64,
536) -> Result<String> {
537 let mut is_bg = false;
538 let mut bg_id: Option<String> = None;
539
540 let bg_shell: Option<SharedPtyShell> = match action {
542 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
544 | BashCommandAction::SendText { bg_command_id, .. }
545 | BashCommandAction::SendSpecials { bg_command_id, .. }
546 | BashCommandAction::SendAscii { bg_command_id, .. } => {
547 if let Some(id) = bg_command_id {
548 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
549 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
550 })?;
551 manager.prune_finished_shells();
552
553 if let Some(shell) = manager.get_shell(id) {
554 is_bg = true;
555 bg_id = Some(id.clone());
556 Some(shell)
557 } else if let Some(tombstone) = manager.peek_tombstone(id) {
558 drop(manager);
563 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
564 } else {
565 let error = format!(
567 "No shell found running with command id {}.\n{}",
568 id,
569 manager.get_running_info()
570 );
571 return Err(WinxError::CommandExecutionError(error));
572 }
573 } else {
574 None
575 }
576 }
577 };
578
579 match action {
581 BashCommandAction::Command { command, is_background, allow_multi } => {
582 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
583 }
584 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
585 execute_status_check(
586 bash_state,
587 bg_shell,
588 is_bg,
589 bg_id.as_deref(),
590 timeout_s,
591 *scrollback_lines,
592 *verbose,
593 )
594 .await
595 }
596 BashCommandAction::SendText { send_text, submit, .. } => {
597 execute_send_text(
598 bash_state,
599 send_text,
600 *submit,
601 bg_shell,
602 is_bg,
603 bg_id.as_deref(),
604 timeout_s,
605 )
606 .await
607 }
608 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
609 execute_send_specials(
610 bash_state,
611 send_specials,
612 *submit,
613 bg_shell,
614 is_bg,
615 bg_id.as_deref(),
616 timeout_s,
617 )
618 .await
619 }
620 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
621 execute_send_ascii(
622 bash_state,
623 send_ascii,
624 *submit,
625 bg_shell,
626 is_bg,
627 bg_id.as_deref(),
628 timeout_s,
629 )
630 .await
631 }
632 }
633}
634
635fn strip_tail_pipe(command: &str) -> String {
645 strip_tail_pipe_impl(command, keep_tail_pipe())
646}
647
648fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
651 static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
652 if keep {
653 return command.to_string();
654 }
655 let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
656 match re.as_ref().and_then(|re| re.find(command)) {
657 Some(matched) => command[..matched.start()].trim_end().to_string(),
658 None => command.to_string(),
659 }
660}
661
662fn keep_tail_pipe() -> bool {
664 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
665 let value = value.trim();
666 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
667 })
668}
669
670async fn execute_command(
672 bash_state: &mut BashState,
673 command: &str,
674 is_background: bool,
675 allow_multi: bool,
676 timeout_s: f64,
677) -> Result<String> {
678 let stripped_command = strip_tail_pipe(command);
680 let command = stripped_command.as_str();
681 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
682
683 if !bash_state.is_command_allowed(command) {
685 error!("Command '{}' not allowed in current mode", command);
686 return Err(WinxError::CommandNotAllowed(
687 "Error: BashCommand not allowed in current mode".to_string(),
688 ));
689 }
690
691 let command = command.trim();
695 if !allow_multi {
696 crate::utils::bash_parser::assert_single_statement(command)?;
697 }
698
699 if is_background {
701 return execute_in_background(bash_state, command, timeout_s).await;
702 }
703
704 {
706 let bash_guard = bash_state.pty_shell.lock().await;
707
708 if let Some(ref bash) = *bash_guard {
709 if bash.command_running {
710 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
711 }
712 }
713 }
714
715 if bash_state.pty_shell.lock().await.is_none() {
717 bash_state
718 .init_pty_shell()
719 .await
720 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
721 }
722
723 {
728 let needs_reset = {
729 let mut bash_guard = bash_state.pty_shell.lock().await;
730 match bash_guard.as_mut() {
731 Some(bash) => match bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
732 Ok(true) => false,
733 Ok(false) => {
734 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
735 true
736 }
737 Err(e) => {
738 warn!("clear_to_run failed ({e}), resetting shell");
739 true
740 }
741 },
742 None => false,
743 }
744 };
745 if needs_reset {
749 if let Err(e) = bash_state.init_pty_shell().await {
750 warn!("Failed to reset shell after clear_to_run: {e}");
751 }
752 }
753 }
754
755 {
757 let mut bash_guard = bash_state.pty_shell.lock().await;
758
759 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
760
761 bash.output_buffer.clear();
762 bash.output_truncated = false;
763 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
765
766 bash.send_special_key("Enter").map_err(|e| {
768 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
769 })?;
770
771 bash.last_command = command.to_string();
772 bash.command_running = true;
773 }
774
775 let shell_arc = bash_state.pty_shell.clone();
777 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
778}
779
780async fn wait_for_output(
784 bash_state: &mut BashState,
785 shell_arc: &SharedPtyShell,
786 timeout_s: f64,
787 is_bg: bool,
788 bg_id: Option<&str>,
789 is_status_check: bool,
790) -> Result<String> {
791 let start = Instant::now();
792 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
793 let mut last_pending_output = String::new();
794 let mut complete = false;
795
796 let mut output = String::new();
804 loop {
805 let elapsed = start.elapsed().as_secs_f64();
806 if elapsed >= wait {
807 break;
808 }
809 let slice = (wait - elapsed).clamp(0.1, POLL_SLICE_SECS);
810 let (out, done) = {
811 let mut bash_guard = shell_arc.lock().await;
812 match bash_guard.as_mut() {
813 Some(bash) => bash.read_output(slice as f32).map_err(|e| {
814 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
815 })?,
816 None => (String::new(), true),
817 }
818 };
819 output = out;
820 complete = done;
821 if complete {
822 break;
823 }
824 }
825
826 if !complete && is_status_check {
835 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
836 let iter_wait_secs = 0.5_f64;
837 let mut patience = OUTPUT_WAIT_PATIENCE;
838
839 let incremental = wcgw_incremental_text(&output, &last_pending_output);
840 if incremental.is_empty() {
841 patience -= 1;
842 }
843
844 let mut last_incremental = incremental;
845
846 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
847 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
848 if remaining < 0.1 {
849 break;
850 }
851 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
852
853 let (new_output, done) = {
854 let mut bash_guard = shell_arc.lock().await;
855
856 if let Some(bash) = bash_guard.as_mut() {
857 bash.read_output(0.5).map_err(|e| {
858 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
859 })?
860 } else {
861 (String::new(), true)
862 }
863 };
864
865 if done {
866 complete = true;
867 output = new_output;
868 break;
869 }
870
871 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
873 if new_incremental == last_incremental {
874 patience -= 1;
875 } else {
876 patience = OUTPUT_WAIT_PATIENCE; }
878 last_incremental = new_incremental;
879
880 output = new_output;
881 }
882
883 if !complete {
884 last_pending_output = output.clone();
886 }
887 }
888
889 if complete {
890 if let Some(cwd) = extract_prompt_cwd(&output) {
891 bash_state.cwd = cwd;
892 }
893 }
894
895 let rendered = wcgw_incremental_text(&output, &last_pending_output);
897
898 let rendered = crate::utils::output_compress::compress_output(&rendered).unwrap_or(rendered);
902
903 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
905
906 let running_for =
908 if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
909
910 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
912 Ok(format!("{rendered}{status}"))
913}
914
915fn finalize_tombstone(
922 cwd: &Path,
923 id: &str,
924 tombstone: ExitedShellInfo,
925 action: &BashCommandAction,
926) -> Result<String> {
927 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
928 match action {
929 BashCommandAction::StatusCheck { .. } => {
930 let rendered = wcgw_incremental_text(&final_output, "");
931 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
932 let mut status = "\n\n---\n\n".to_string();
934 let _ = writeln!(status, "bg_command_id = {id}");
935 status.push_str("status = process exited\n");
936 let _ = writeln!(status, "cwd = {}", cwd.display());
937 Ok(format!("{rendered}{}", status.trim_end()))
938 }
939 BashCommandAction::SendText { .. }
940 | BashCommandAction::SendSpecials { .. }
941 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
942 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
943 ))),
944 BashCommandAction::Command { .. } => {
945 unreachable!("finalize_tombstone called for non-bg action")
948 }
949 }
950}
951
952async fn execute_status_check(
961 bash_state: &mut BashState,
962 bg_shell: Option<SharedPtyShell>,
963 is_bg: bool,
964 bg_id: Option<&str>,
965 timeout_s: f64,
966 scrollback_lines: Option<usize>,
967 verbose: bool,
968) -> Result<String> {
969 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
970
971 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
974
975 let is_running = {
977 let guard = shell_arc.lock().await;
978 if let Some(ref bash) = *guard {
979 bash.command_running
980 } else {
981 false
982 }
983 };
984
985 if !is_running && !is_bg {
987 let mut manager = lock_bg_manager();
988 let error = format!(
989 "No command is currently running, so there's nothing to check. The previous \
990 command already finished and its output was returned when it completed. Start a \
991 new command, or pass a bg_command_id if you launched one in the background.\n{}",
992 manager.get_running_info()
993 );
994 return Err(WinxError::CommandExecutionError(error));
995 }
996
997 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
999
1000 let body = response.split("\n\n---\n").next().unwrap_or(&response);
1004 if !verbose && scrollback_lines.is_none() {
1005 let mut guard = shell_arc.lock().await;
1006 if let Some(bash) = guard.as_mut() {
1007 let fingerprint = PtyShell::fingerprint(body);
1008 if Some(fingerprint) == bash.last_returned_hash {
1009 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
1010 return Ok(format!("no new output since last check{status}"));
1011 }
1012 bash.last_returned_hash = Some(fingerprint);
1013 }
1014 } else if !verbose {
1015 let mut guard = shell_arc.lock().await;
1017 if let Some(bash) = guard.as_mut() {
1018 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1019 }
1020 }
1021
1022 if let Some(lines) = scrollback_lines {
1024 if lines > 0 {
1025 let scrollback = {
1026 let guard = shell_arc.lock().await;
1027 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1028 };
1029 if !scrollback.is_empty() {
1030 let count = scrollback.lines().count();
1031 return Ok(format!(
1032 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1033 ));
1034 }
1035 }
1036 }
1037
1038 Ok(response)
1039}
1040
1041async fn execute_send_text(
1043 bash_state: &mut BashState,
1044 text: &str,
1045 submit: bool,
1046 bg_shell: Option<SharedPtyShell>,
1047 is_bg: bool,
1048 bg_id: Option<&str>,
1049 timeout_s: f64,
1050) -> Result<String> {
1051 debug!("Processing SendText action: {text:?} (submit={submit})");
1052
1053 if text.is_empty() {
1055 return Err(WinxError::CommandExecutionError(
1056 "Failure: send_text cannot be empty".to_string(),
1057 ));
1058 }
1059
1060 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1062
1063 {
1065 let mut guard = shell_arc.lock().await;
1066
1067 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1068
1069 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1071
1072 if submit {
1076 bash.send_special_key("Enter").map_err(|e| {
1077 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
1078 })?;
1079 }
1080 }
1081
1082 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1084}
1085
1086async fn execute_send_specials(
1088 bash_state: &mut BashState,
1089 keys: &[SpecialKey],
1090 submit: bool,
1091 bg_shell: Option<SharedPtyShell>,
1092 is_bg: bool,
1093 bg_id: Option<&str>,
1094 timeout_s: f64,
1095) -> Result<String> {
1096 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1097
1098 if keys.is_empty() {
1100 return Err(WinxError::CommandExecutionError(
1101 "Failure: send_specials cannot be empty".to_string(),
1102 ));
1103 }
1104
1105 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1106 let mut is_interrupt = false;
1107
1108 {
1109 let mut guard = shell_arc.lock().await;
1110
1111 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1112
1113 for key in keys {
1115 match key {
1116 SpecialKey::KeyUp => {
1117 bash.send_special_key("KeyUp").map_err(|e| {
1119 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1120 })?;
1121 }
1122 SpecialKey::KeyDown => {
1123 bash.send_special_key("KeyDown").map_err(|e| {
1125 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1126 })?;
1127 }
1128 SpecialKey::KeyLeft => {
1129 bash.send_special_key("KeyLeft").map_err(|e| {
1131 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1132 })?;
1133 }
1134 SpecialKey::KeyRight => {
1135 bash.send_special_key("KeyRight").map_err(|e| {
1137 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1138 })?;
1139 }
1140 SpecialKey::Enter => {
1141 bash.send_special_key("Enter").map_err(|e| {
1143 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1144 })?;
1145 }
1146 SpecialKey::CtrlC => {
1147 bash.send_interrupt().map_err(|e| {
1149 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1150 })?;
1151 is_interrupt = true;
1152 }
1153 SpecialKey::CtrlD => {
1154 bash.send_eof().map_err(|e| {
1156 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1157 })?;
1158 is_interrupt = true;
1159 }
1160 SpecialKey::CtrlZ => {
1161 bash.send_suspend().map_err(|e| {
1163 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1164 })?;
1165 }
1166 }
1167 }
1168 if submit {
1170 bash.send_special_key("Enter")
1171 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1172 }
1173 }
1174
1175 let mut output =
1183 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1184
1185 if is_interrupt && output.contains("status = still running") {
1187 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1188 }
1189
1190 Ok(output)
1191}
1192
1193async fn execute_send_ascii(
1195 bash_state: &mut BashState,
1196 ascii_codes: &[u8],
1197 submit: bool,
1198 bg_shell: Option<SharedPtyShell>,
1199 is_bg: bool,
1200 bg_id: Option<&str>,
1201 timeout_s: f64,
1202) -> Result<String> {
1203 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1204
1205 if ascii_codes.is_empty() {
1207 return Err(WinxError::CommandExecutionError(
1208 "Failure: send_ascii cannot be empty".to_string(),
1209 ));
1210 }
1211
1212 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1213 let mut is_interrupt = false;
1214
1215 {
1216 let mut guard = shell_arc.lock().await;
1217
1218 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1219
1220 for &code in ascii_codes {
1222 bash.send_bytes(&[code]).map_err(|e| {
1224 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1225 })?;
1226
1227 if code == 3 {
1229 is_interrupt = true;
1230 }
1231 }
1232 if submit {
1234 bash.send_special_key("Enter")
1235 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1236 }
1237 }
1238
1239 let mut output =
1245 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1246
1247 if is_interrupt && output.contains("status = still running") {
1249 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1250 }
1251
1252 Ok(output)
1253}
1254
1255async fn execute_in_background(
1257 bash_state: &mut BashState,
1258 command: &str,
1259 timeout_s: f64,
1260) -> Result<String> {
1261 debug!("Executing command in background: {}", command);
1262
1263 let restricted_mode =
1265 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1266
1267 let bg_id = {
1268 let mut manager = lock_bg_manager();
1269 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1270 };
1271
1272 let shell_arc = {
1274 let manager = lock_bg_manager();
1275 manager.get_shell(&bg_id).ok_or_else(|| {
1276 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1277 })?
1278 };
1279
1280 {
1282 let mut guard = shell_arc.lock().await;
1283 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1284 bash.send_command(command).map_err(|e| {
1285 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1286 })?;
1287 }
1288 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1289
1290 let _ = timeout_s;
1291 let _ = shell_arc;
1292 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1293}
1294
1295#[allow(dead_code)]
1299#[tracing::instrument(level = "debug", skip(command, cwd))]
1300async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1301 debug!("Executing command: {}", command);
1302
1303 let start_time = Instant::now();
1304 let mut cmd = Command::new("sh");
1305 cmd.arg("-c")
1306 .arg(command)
1307 .current_dir(cwd)
1308 .stdin(Stdio::null())
1309 .stdout(Stdio::piped())
1310 .stderr(Stdio::piped());
1311
1312 let output = cmd.output().context("Failed to execute command")?;
1313 let elapsed = start_time.elapsed();
1314
1315 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1316 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1317
1318 let raw_result = format!("{stdout}{stderr}");
1319 let mut result = raw_result.clone();
1320 if !raw_result.is_empty() {
1321 let rendered_lines = render_terminal_output(&raw_result);
1322 if rendered_lines.is_empty() {
1323 result = strip_ansi_codes(&raw_result);
1325 } else {
1326 result = rendered_lines.join("\n");
1327 }
1328 }
1329
1330 result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
1331
1332 let exit_status = if output.status.success() {
1333 "Command completed successfully".to_string()
1334 } else {
1335 format!("Command failed with status: {}", output.status)
1336 };
1337
1338 let current_dir = std::env::current_dir()
1339 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1340
1341 debug!("Command executed in {:.2?}", elapsed);
1342 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1343}
1344
1345#[allow(dead_code)]
1347#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1348async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1349 debug!("Executing command in screen session '{}': {}", screen_name, command);
1350
1351 let screen_check = Command::new("which")
1352 .arg("screen")
1353 .output()
1354 .context("Failed to check for screen command")?;
1355
1356 if !screen_check.status.success() {
1357 warn!("Screen command not found, falling back to direct execution");
1358 return execute_simple_command(command, cwd).await;
1359 }
1360
1361 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1362
1363 let screen_cmd = format!(
1364 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1365 screen_name,
1366 command.replace('\'', "'\\''")
1367 );
1368
1369 let screen_start = Command::new("sh")
1370 .arg("-c")
1371 .arg(&screen_cmd)
1372 .current_dir(cwd)
1373 .output()
1374 .context("Failed to start screen session")?;
1375
1376 if !screen_start.status.success() {
1377 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1378 error!("Failed to start screen session: {}", stderr);
1379 return Err(WinxError::CommandExecutionError(format!(
1380 "Failed to start screen session: {stderr}"
1381 )));
1382 }
1383
1384 sleep(Duration::from_millis(300)).await;
1385
1386 let screen_check =
1387 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1388
1389 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1390
1391 let current_dir = std::env::current_dir()
1392 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1393
1394 Ok(format!(
1395 "Started command in background screen session '{screen_name}'.\n\
1396 Use status_check to get output.\n\n\
1397 Screen sessions:\n{screen_list}\n\
1398 ---\n\n\
1399 status = running in background\n\
1400 cwd = {current_dir}\n"
1401 ))
1402}
1403
1404#[allow(dead_code)]
1406fn special_key_to_screen_input(key: SpecialKey) -> String {
1407 match key {
1408 SpecialKey::Enter => String::from("\r"),
1409 SpecialKey::KeyUp => String::from("\x1b[A"),
1410 SpecialKey::KeyDown => String::from("\x1b[B"),
1411 SpecialKey::KeyLeft => String::from("\x1b[D"),
1412 SpecialKey::KeyRight => String::from("\x1b[C"),
1413 SpecialKey::CtrlC => String::from("\x03"),
1414 SpecialKey::CtrlD => String::from("\x04"),
1415 SpecialKey::CtrlZ => String::from("\x1a"),
1416 }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::strip_tail_pipe_impl;
1422
1423 #[test]
1424 fn strips_trailing_tail_by_default() {
1425 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1426 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1427 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1428 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1429 }
1430
1431 #[test]
1432 fn keeps_command_without_trailing_tail() {
1433 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1435 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1436 assert_eq!(
1437 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1438 "cat a | tail -5 | wc -l"
1439 );
1440 }
1441
1442 #[test]
1443 fn keep_mode_preserves_tail_pipe() {
1444 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1446 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1447 }
1448}