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 COMMAND_CHUNK_SIZE: usize = 64;
41
42const TEXT_CHUNK_SIZE: usize = 128;
44
45const MAX_OUTPUT_LENGTH: usize = 100_000;
49
50const MAX_OUTPUT_TOKENS: usize = 25_000;
55
56fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
64 if text.len() <= MAX_OUTPUT_LENGTH {
65 return std::borrow::Cow::Borrowed(text);
66 }
67
68 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
69 return std::borrow::Cow::Owned(format!(
71 "(...truncated)\n{}",
72 &text[text.len() - MAX_OUTPUT_LENGTH..]
73 ));
74 };
75
76 if tokens.len() <= max_tokens {
77 return std::borrow::Cow::Borrowed(text);
78 }
79
80 let keep = max_tokens.saturating_sub(1);
82 let tail = &tokens[tokens.len() - keep..];
83 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
84 text[text.len() - MAX_OUTPUT_LENGTH.min(text.len())..].to_string()
86 });
87 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
88}
89
90const 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.
921. Get its output using status check.
932. Use `send_ascii` or `send_specials` to give inputs to the running program OR
943. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
954. Interrupt and run the process in background
96";
97
98#[derive(Debug, Clone)]
104pub struct ExitedShellInfo {
105 pub last_command: String,
106 pub final_output: String,
107 pub exited_at: Instant,
108}
109
110#[derive(Debug, Default)]
112pub struct BackgroundShellManager {
113 shells: HashMap<String, SharedPtyShell>,
114 tombstones: HashMap<String, ExitedShellInfo>,
117}
118
119impl BackgroundShellManager {
120 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
122
123 pub fn new() -> Self {
125 Self { shells: HashMap::new(), tombstones: HashMap::new() }
126 }
127
128 pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
130 let cid = format!("{:010x}", rand::rng().random::<u32>());
131
132 let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
133 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
134 })?;
135
136 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
137
138 info!("Started background shell with id: {}", cid);
139 Ok(cid)
140 }
141
142 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
144 self.shells.get(bg_command_id).cloned()
145 }
146
147 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
149 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
150 if let Ok(mut guard) = shell_arc.try_lock() {
151 *guard = None;
152 }
153 info!("Removed background shell: {}", bg_command_id);
154 true
155 } else {
156 false
157 }
158 }
159
160 fn prune_finished_shells(&mut self) {
161 let now = Instant::now();
163 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
164
165 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
166
167 for (id, shell_arc) in &self.shells {
168 let Ok(mut guard) = shell_arc.try_lock() else {
169 continue;
170 };
171
172 let Some(shell) = guard.as_mut() else {
173 finished.push((id.clone(), None));
174 continue;
175 };
176
177 if !shell.is_alive() {
178 let tombstone = ExitedShellInfo {
179 last_command: shell.last_command.clone(),
180 final_output: shell.output_buffer.clone(),
181 exited_at: now,
182 };
183 finished.push((id.clone(), Some(tombstone)));
184 continue;
185 }
186
187 if shell.last_command.is_empty() {
192 continue;
193 }
194
195 if shell.command_running {
196 let _ = shell.read_output(0.1);
197 }
198
199 if !shell.command_running {
200 let tombstone = ExitedShellInfo {
201 last_command: shell.last_command.clone(),
202 final_output: shell.output_buffer.clone(),
203 exited_at: now,
204 };
205 finished.push((id.clone(), Some(tombstone)));
206 }
207 }
208
209 for (id, tombstone) in finished {
210 self.remove_shell(&id);
211 if let Some(info) = tombstone {
212 self.tombstones.insert(id, info);
213 }
214 }
215 }
216
217 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
224 self.tombstones.get(bg_command_id).cloned()
225 }
226
227 pub fn get_running_info(&mut self) -> String {
229 self.prune_finished_shells();
230
231 if self.shells.is_empty() {
232 return "No command running in background.\n".to_string();
233 }
234
235 let mut running = Vec::new();
236 for (id, shell_arc) in &self.shells {
237 if let Ok(guard) = shell_arc.try_lock() {
238 if let Some(bash) = guard.as_ref() {
239 if bash.command_running {
240 running
241 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
242 }
243 }
244 } else {
245 running.push(format!("Command: <busy>, bg_command_id: {id}"));
246 }
247 }
248
249 if running.is_empty() {
250 "No command running in background.\n".to_string()
251 } else {
252 format!("Following background commands are attached:\n{}\n", running.join("\n"))
253 }
254 }
255}
256
257lazy_static::lazy_static! {
259 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
260}
261
262fn get_status(
266 bash_state: &BashState,
267 is_bg: bool,
268 bg_id: Option<&str>,
269 is_running: bool,
270 running_for: Option<&str>,
271) -> String {
272 let mut status = "\n\n---\n\n".to_string();
273
274 if is_bg {
275 if let Some(id) = bg_id {
276 let _ = writeln!(status, "bg_command_id = {id}");
277 }
278 }
279
280 if is_running {
281 status.push_str("status = still running\n");
282 if let Some(duration) = running_for {
283 let _ = writeln!(status, "running for = {duration}");
284 }
285 } else {
286 status.push_str("status = process exited\n");
287 }
288
289 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
290
291 if !is_bg {
292 if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
294 status.push_str("This is the main shell. ");
295 status.push_str(&manager.get_running_info());
296 }
297 }
298
299 status.trim_end().to_string()
300}
301
302fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
304 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
305 let text = truncated.as_ref();
306
307 if last_pending_output.is_empty() {
308 let rendered = render_terminal_output(text);
309 return rstrip_lines(&rendered).trim_start().to_string();
310 }
311
312 let last_rendered = render_terminal_output(last_pending_output);
313 if last_rendered.is_empty() {
314 return rstrip_lines(&render_terminal_output(text));
315 }
316
317 let text_after_last = if text.len() > last_pending_output.len() {
319 &text[last_pending_output.len()..]
320 } else {
321 text
322 };
323
324 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
325 let new_rendered = render_terminal_output(&combined);
326
327 let incremental = get_incremental_output(&last_rendered, &new_rendered);
329 rstrip_lines(&incremental)
330}
331
332fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
333 let stripped = strip_ansi_codes(output);
334 let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
335
336 prompt_regex
337 .captures_iter(&stripped)
338 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
339 .filter(|cwd| !cwd.is_empty())
340 .last()
341 .map(PathBuf::from)
342}
343
344fn rstrip_lines(lines: &[String]) -> String {
346 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
347}
348
349fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
351 if old_output.is_empty() {
352 return new_output.to_vec();
353 }
354
355 let nold = old_output.len();
356 let nnew = new_output.len();
357
358 for i in (0..nnew).rev() {
360 if new_output[i] != old_output[nold - 1] {
361 continue;
362 }
363
364 let mut matched = true;
365 for j in (0..i).rev() {
366 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
367 if old_idx < 0 {
368 break;
369 }
370 if new_output[j] != old_output[old_idx as usize] {
371 matched = false;
372 break;
373 }
374 }
375
376 if matched {
377 return new_output[i + 1..].to_vec();
378 }
379 }
380
381 new_output.to_vec()
382}
383
384fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
385 let mut start = 0;
386
387 while start < text.len() {
388 let mut end = (start + chunk_size).min(text.len());
389 while !text.is_char_boundary(end) {
390 end -= 1;
391 }
392 if end == start {
393 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
394 }
395
396 shell.send_text(&text[start..end]).map_err(|e| {
397 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
398 })?;
399 start = end;
400 }
401
402 Ok(())
403}
404
405#[allow(dead_code)]
407fn is_status_check_action(action: &BashCommandAction) -> bool {
408 match action {
409 BashCommandAction::StatusCheck { .. } => true,
410 BashCommandAction::SendSpecials { send_specials, .. } => {
411 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
412 }
413 BashCommandAction::SendAscii { send_ascii, .. } => {
414 send_ascii.len() == 1 && send_ascii[0] == 10 }
416 _ => false,
417 }
418}
419
420#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
427pub async fn handle_tool_call(
428 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
429 bash_command: BashCommand,
430) -> Result<String> {
431 info!("BashCommand tool called with: {:?}", bash_command);
432
433 let thread_id = normalize_thread_id(&bash_command.thread_id);
434
435 if thread_id.is_empty() {
437 error!("Empty thread_id provided in BashCommand");
438 return Err(WinxError::ThreadIdMismatch(
439 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
440 ));
441 }
442
443 let mut bash_state: BashState;
445 {
446 let bash_state_guard = bash_state_arc.lock().await;
447
448 let Some(state) = &*bash_state_guard else {
449 error!("BashState not initialized");
450 return Err(WinxError::BashStateNotInitialized);
451 };
452
453 bash_state = state.clone();
454 }
455
456 if thread_id != bash_state.current_thread_id {
458 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
460 return Err(WinxError::ThreadIdMismatch(format!(
461 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
462 )));
463 }
464 }
465
466 let timeout_s = bash_command
469 .wait_for_seconds
470 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
471 .min(TIMEOUT_WHILE_OUTPUT);
472
473 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
475
476 {
477 let mut bash_state_guard = bash_state_arc.lock().await;
478 if let Some(state) = bash_state_guard.as_mut() {
479 state.cwd.clone_from(&bash_state.cwd);
480 }
481 }
482
483 match result {
485 Ok(mut output) => {
486 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
487 let cmd_trimmed = command.trim();
488 if output.starts_with(cmd_trimmed) {
489 output = output[cmd_trimmed.len()..].to_string();
490 }
491 }
492 Ok(output)
493 }
494 Err(e) => Err(e),
495 }
496}
497
498async fn execute_bash_action(
500 bash_state: &mut BashState,
501 action: &BashCommandAction,
502 timeout_s: f64,
503) -> Result<String> {
504 let mut is_bg = false;
505 let mut bg_id: Option<String> = None;
506
507 let bg_shell: Option<SharedPtyShell> = match action {
509 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
511 | BashCommandAction::SendText { bg_command_id, .. }
512 | BashCommandAction::SendSpecials { bg_command_id, .. }
513 | BashCommandAction::SendAscii { bg_command_id, .. } => {
514 if let Some(id) = bg_command_id {
515 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
516 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
517 })?;
518 manager.prune_finished_shells();
519
520 if let Some(shell) = manager.get_shell(id) {
521 is_bg = true;
522 bg_id = Some(id.clone());
523 Some(shell)
524 } else if let Some(tombstone) = manager.peek_tombstone(id) {
525 drop(manager);
530 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
531 } else {
532 let error = format!(
534 "No shell found running with command id {}.\n{}",
535 id,
536 manager.get_running_info()
537 );
538 return Err(WinxError::CommandExecutionError(error));
539 }
540 } else {
541 None
542 }
543 }
544 };
545
546 match action {
548 BashCommandAction::Command { command, is_background, allow_multi } => {
549 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
550 }
551 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
552 execute_status_check(
553 bash_state,
554 bg_shell,
555 is_bg,
556 bg_id.as_deref(),
557 timeout_s,
558 *scrollback_lines,
559 *verbose,
560 )
561 .await
562 }
563 BashCommandAction::SendText { send_text, submit, .. } => {
564 execute_send_text(
565 bash_state,
566 send_text,
567 *submit,
568 bg_shell,
569 is_bg,
570 bg_id.as_deref(),
571 timeout_s,
572 )
573 .await
574 }
575 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
576 execute_send_specials(
577 bash_state,
578 send_specials,
579 *submit,
580 bg_shell,
581 is_bg,
582 bg_id.as_deref(),
583 timeout_s,
584 )
585 .await
586 }
587 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
588 execute_send_ascii(
589 bash_state,
590 send_ascii,
591 *submit,
592 bg_shell,
593 is_bg,
594 bg_id.as_deref(),
595 timeout_s,
596 )
597 .await
598 }
599 }
600}
601
602fn strip_tail_pipe(command: &str) -> String {
612 strip_tail_pipe_impl(command, keep_tail_pipe())
613}
614
615fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
618 static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
619 if keep {
620 return command.to_string();
621 }
622 let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
623 match re.as_ref().and_then(|re| re.find(command)) {
624 Some(matched) => command[..matched.start()].trim_end().to_string(),
625 None => command.to_string(),
626 }
627}
628
629fn keep_tail_pipe() -> bool {
631 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
632 let value = value.trim();
633 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
634 })
635}
636
637async fn execute_command(
639 bash_state: &mut BashState,
640 command: &str,
641 is_background: bool,
642 allow_multi: bool,
643 timeout_s: f64,
644) -> Result<String> {
645 let stripped_command = strip_tail_pipe(command);
647 let command = stripped_command.as_str();
648 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
649
650 if !bash_state.is_command_allowed(command) {
652 error!("Command '{}' not allowed in current mode", command);
653 return Err(WinxError::CommandNotAllowed(
654 "Error: BashCommand not allowed in current mode".to_string(),
655 ));
656 }
657
658 let command = command.trim();
662 if !allow_multi {
663 crate::utils::bash_parser::assert_single_statement(command)?;
664 }
665
666 if is_background {
668 return execute_in_background(bash_state, command, timeout_s).await;
669 }
670
671 {
673 let bash_guard = bash_state.pty_shell.lock().await;
674
675 if let Some(ref bash) = *bash_guard {
676 if bash.command_running {
677 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
678 }
679 }
680 }
681
682 if bash_state.pty_shell.lock().await.is_none() {
684 bash_state
685 .init_pty_shell()
686 .await
687 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
688 }
689
690 {
695 let mut bash_guard = bash_state.pty_shell.lock().await;
696 if let Some(bash) = bash_guard.as_mut() {
697 if let Err(e) = bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
698 warn!("clear_to_run failed before send: {e}");
699 }
700 }
701 }
702
703 {
705 let mut bash_guard = bash_state.pty_shell.lock().await;
706
707 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
708
709 bash.output_buffer.clear();
710 bash.output_truncated = false;
711 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
713
714 bash.send_special_key("Enter").map_err(|e| {
716 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
717 })?;
718
719 bash.last_command = command.to_string();
720 bash.command_running = true;
721 }
722
723 let shell_arc = bash_state.pty_shell.clone();
725 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
726}
727
728async fn wait_for_output(
732 bash_state: &mut BashState,
733 shell_arc: &SharedPtyShell,
734 timeout_s: f64,
735 is_bg: bool,
736 bg_id: Option<&str>,
737 is_status_check: bool,
738) -> Result<String> {
739 let start = Instant::now();
740 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
741 let mut last_pending_output = String::new();
742 let mut complete = false;
743
744 sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
746
747 let mut output = {
749 let mut bash_guard = shell_arc.lock().await;
750
751 if let Some(bash) = bash_guard.as_mut() {
752 let (out, done) = bash.read_output(0.5).map_err(|e| {
753 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
754 })?;
755 complete = done;
756 out
757 } else {
758 String::new()
759 }
760 };
761
762 if !complete && is_status_check {
771 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
772 let iter_wait_secs = 0.5_f64;
773 let mut patience = OUTPUT_WAIT_PATIENCE;
774
775 let incremental = wcgw_incremental_text(&output, &last_pending_output);
776 if incremental.is_empty() {
777 patience -= 1;
778 }
779
780 let mut last_incremental = incremental;
781
782 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
783 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
784 if remaining < 0.1 {
785 break;
786 }
787 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
788
789 let (new_output, done) = {
790 let mut bash_guard = shell_arc.lock().await;
791
792 if let Some(bash) = bash_guard.as_mut() {
793 bash.read_output(0.5).map_err(|e| {
794 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
795 })?
796 } else {
797 (String::new(), true)
798 }
799 };
800
801 if done {
802 complete = true;
803 output = new_output;
804 break;
805 }
806
807 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
809 if new_incremental == last_incremental {
810 patience -= 1;
811 } else {
812 patience = OUTPUT_WAIT_PATIENCE; }
814 last_incremental = new_incremental;
815
816 output = new_output;
817 }
818
819 if !complete {
820 last_pending_output = output.clone();
822 }
823 }
824
825 if complete {
826 if let Some(cwd) = extract_prompt_cwd(&output) {
827 bash_state.cwd = cwd;
828 }
829 }
830
831 let rendered = wcgw_incremental_text(&output, &last_pending_output);
833
834 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
836
837 let running_for = if complete {
839 None
840 } else {
841 Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
842 };
843
844 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
846 Ok(format!("{rendered}{status}"))
847}
848
849fn finalize_tombstone(
856 cwd: &Path,
857 id: &str,
858 tombstone: ExitedShellInfo,
859 action: &BashCommandAction,
860) -> Result<String> {
861 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
862 match action {
863 BashCommandAction::StatusCheck { .. } => {
864 let rendered = wcgw_incremental_text(&final_output, "");
865 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
866 let mut status = "\n\n---\n\n".to_string();
868 let _ = writeln!(status, "bg_command_id = {id}");
869 status.push_str("status = process exited\n");
870 let _ = writeln!(status, "cwd = {}", cwd.display());
871 Ok(format!("{rendered}{}", status.trim_end()))
872 }
873 BashCommandAction::SendText { .. }
874 | BashCommandAction::SendSpecials { .. }
875 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
876 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
877 ))),
878 BashCommandAction::Command { .. } => {
879 unreachable!("finalize_tombstone called for non-bg action")
882 }
883 }
884}
885
886async fn execute_status_check(
895 bash_state: &mut BashState,
896 bg_shell: Option<SharedPtyShell>,
897 is_bg: bool,
898 bg_id: Option<&str>,
899 timeout_s: f64,
900 scrollback_lines: Option<usize>,
901 verbose: bool,
902) -> Result<String> {
903 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
904
905 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
908
909 let is_running = {
911 let guard = shell_arc.lock().await;
912 if let Some(ref bash) = *guard {
913 bash.command_running
914 } else {
915 false
916 }
917 };
918
919 if !is_running && !is_bg {
921 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
922 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
923 })?;
924 let error =
925 format!("No running command to check status of.\n{}", manager.get_running_info());
926 return Err(WinxError::CommandExecutionError(error));
927 }
928
929 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
931
932 let body = response.split("\n\n---\n").next().unwrap_or(&response);
936 if !verbose && scrollback_lines.is_none() {
937 let mut guard = shell_arc.lock().await;
938 if let Some(bash) = guard.as_mut() {
939 let fingerprint = PtyShell::fingerprint(body);
940 if Some(fingerprint) == bash.last_returned_hash {
941 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
942 return Ok(format!("no new output since last check{status}"));
943 }
944 bash.last_returned_hash = Some(fingerprint);
945 }
946 } else if !verbose {
947 let mut guard = shell_arc.lock().await;
949 if let Some(bash) = guard.as_mut() {
950 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
951 }
952 }
953
954 if let Some(lines) = scrollback_lines {
956 if lines > 0 {
957 let scrollback = {
958 let guard = shell_arc.lock().await;
959 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
960 };
961 if !scrollback.is_empty() {
962 let count = scrollback.lines().count();
963 return Ok(format!(
964 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
965 ));
966 }
967 }
968 }
969
970 Ok(response)
971}
972
973async fn execute_send_text(
975 bash_state: &mut BashState,
976 text: &str,
977 submit: bool,
978 bg_shell: Option<SharedPtyShell>,
979 is_bg: bool,
980 bg_id: Option<&str>,
981 timeout_s: f64,
982) -> Result<String> {
983 debug!("Processing SendText action: {text:?} (submit={submit})");
984
985 if text.is_empty() {
987 return Err(WinxError::CommandExecutionError(
988 "Failure: send_text cannot be empty".to_string(),
989 ));
990 }
991
992 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
994
995 {
997 let mut guard = shell_arc.lock().await;
998
999 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1000
1001 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1003
1004 if submit {
1008 bash.send_special_key("Enter").map_err(|e| {
1009 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
1010 })?;
1011 }
1012 }
1013
1014 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1016}
1017
1018async fn execute_send_specials(
1020 bash_state: &mut BashState,
1021 keys: &[SpecialKey],
1022 submit: bool,
1023 bg_shell: Option<SharedPtyShell>,
1024 is_bg: bool,
1025 bg_id: Option<&str>,
1026 timeout_s: f64,
1027) -> Result<String> {
1028 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1029
1030 if keys.is_empty() {
1032 return Err(WinxError::CommandExecutionError(
1033 "Failure: send_specials cannot be empty".to_string(),
1034 ));
1035 }
1036
1037 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1038 let mut is_interrupt = false;
1039
1040 {
1041 let mut guard = shell_arc.lock().await;
1042
1043 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1044
1045 for key in keys {
1047 match key {
1048 SpecialKey::KeyUp => {
1049 bash.send_special_key("KeyUp").map_err(|e| {
1051 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1052 })?;
1053 }
1054 SpecialKey::KeyDown => {
1055 bash.send_special_key("KeyDown").map_err(|e| {
1057 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1058 })?;
1059 }
1060 SpecialKey::KeyLeft => {
1061 bash.send_special_key("KeyLeft").map_err(|e| {
1063 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1064 })?;
1065 }
1066 SpecialKey::KeyRight => {
1067 bash.send_special_key("KeyRight").map_err(|e| {
1069 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1070 })?;
1071 }
1072 SpecialKey::Enter => {
1073 bash.send_special_key("Enter").map_err(|e| {
1075 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1076 })?;
1077 }
1078 SpecialKey::CtrlC => {
1079 bash.send_interrupt().map_err(|e| {
1081 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1082 })?;
1083 is_interrupt = true;
1084 }
1085 SpecialKey::CtrlD => {
1086 bash.send_eof().map_err(|e| {
1088 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1089 })?;
1090 is_interrupt = true;
1091 }
1092 SpecialKey::CtrlZ => {
1093 bash.send_suspend().map_err(|e| {
1095 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1096 })?;
1097 }
1098 }
1099 }
1100 if submit {
1102 bash.send_special_key("Enter")
1103 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1104 }
1105 }
1106
1107 let mut output =
1115 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1116
1117 if is_interrupt && output.contains("status = still running") {
1119 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1120 }
1121
1122 Ok(output)
1123}
1124
1125async fn execute_send_ascii(
1127 bash_state: &mut BashState,
1128 ascii_codes: &[u8],
1129 submit: bool,
1130 bg_shell: Option<SharedPtyShell>,
1131 is_bg: bool,
1132 bg_id: Option<&str>,
1133 timeout_s: f64,
1134) -> Result<String> {
1135 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1136
1137 if ascii_codes.is_empty() {
1139 return Err(WinxError::CommandExecutionError(
1140 "Failure: send_ascii cannot be empty".to_string(),
1141 ));
1142 }
1143
1144 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1145 let mut is_interrupt = false;
1146
1147 {
1148 let mut guard = shell_arc.lock().await;
1149
1150 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1151
1152 for &code in ascii_codes {
1154 bash.send_bytes(&[code]).map_err(|e| {
1156 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1157 })?;
1158
1159 if code == 3 {
1161 is_interrupt = true;
1162 }
1163 }
1164 if submit {
1166 bash.send_special_key("Enter")
1167 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1168 }
1169 }
1170
1171 let mut output =
1177 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1178
1179 if is_interrupt && output.contains("status = still running") {
1181 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1182 }
1183
1184 Ok(output)
1185}
1186
1187async fn execute_in_background(
1189 bash_state: &mut BashState,
1190 command: &str,
1191 timeout_s: f64,
1192) -> Result<String> {
1193 debug!("Executing command in background: {}", command);
1194
1195 let restricted_mode =
1197 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1198
1199 let bg_id = {
1200 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1201 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1202 })?;
1203 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1204 };
1205
1206 let shell_arc = {
1208 let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1209 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1210 })?;
1211 manager.get_shell(&bg_id).ok_or_else(|| {
1212 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1213 })?
1214 };
1215
1216 {
1218 let mut guard = shell_arc.lock().await;
1219 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1220 bash.send_command(command).map_err(|e| {
1221 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1222 })?;
1223 }
1224 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1225
1226 let _ = timeout_s;
1227 let _ = shell_arc;
1228 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1229}
1230
1231#[allow(dead_code)]
1235#[tracing::instrument(level = "debug", skip(command, cwd))]
1236async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1237 debug!("Executing command: {}", command);
1238
1239 let start_time = Instant::now();
1240 let mut cmd = Command::new("sh");
1241 cmd.arg("-c")
1242 .arg(command)
1243 .current_dir(cwd)
1244 .stdin(Stdio::null())
1245 .stdout(Stdio::piped())
1246 .stderr(Stdio::piped());
1247
1248 let output = cmd.output().context("Failed to execute command")?;
1249 let elapsed = start_time.elapsed();
1250
1251 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1252 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1253
1254 let raw_result = format!("{stdout}{stderr}");
1255 let mut result = raw_result.clone();
1256 if !raw_result.is_empty() {
1257 let rendered_lines = render_terminal_output(&raw_result);
1258 if rendered_lines.is_empty() {
1259 result = strip_ansi_codes(&raw_result);
1261 } else {
1262 result = rendered_lines.join("\n");
1263 }
1264 }
1265
1266 result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
1267
1268 let exit_status = if output.status.success() {
1269 "Command completed successfully".to_string()
1270 } else {
1271 format!("Command failed with status: {}", output.status)
1272 };
1273
1274 let current_dir = std::env::current_dir()
1275 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1276
1277 debug!("Command executed in {:.2?}", elapsed);
1278 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1279}
1280
1281#[allow(dead_code)]
1283#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1284async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1285 debug!("Executing command in screen session '{}': {}", screen_name, command);
1286
1287 let screen_check = Command::new("which")
1288 .arg("screen")
1289 .output()
1290 .context("Failed to check for screen command")?;
1291
1292 if !screen_check.status.success() {
1293 warn!("Screen command not found, falling back to direct execution");
1294 return execute_simple_command(command, cwd).await;
1295 }
1296
1297 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1298
1299 let screen_cmd = format!(
1300 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1301 screen_name,
1302 command.replace('\'', "'\\''")
1303 );
1304
1305 let screen_start = Command::new("sh")
1306 .arg("-c")
1307 .arg(&screen_cmd)
1308 .current_dir(cwd)
1309 .output()
1310 .context("Failed to start screen session")?;
1311
1312 if !screen_start.status.success() {
1313 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1314 error!("Failed to start screen session: {}", stderr);
1315 return Err(WinxError::CommandExecutionError(format!(
1316 "Failed to start screen session: {stderr}"
1317 )));
1318 }
1319
1320 sleep(Duration::from_millis(300)).await;
1321
1322 let screen_check =
1323 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1324
1325 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1326
1327 let current_dir = std::env::current_dir()
1328 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1329
1330 Ok(format!(
1331 "Started command in background screen session '{screen_name}'.\n\
1332 Use status_check to get output.\n\n\
1333 Screen sessions:\n{screen_list}\n\
1334 ---\n\n\
1335 status = running in background\n\
1336 cwd = {current_dir}\n"
1337 ))
1338}
1339
1340#[allow(dead_code)]
1342fn special_key_to_screen_input(key: SpecialKey) -> String {
1343 match key {
1344 SpecialKey::Enter => String::from("\r"),
1345 SpecialKey::KeyUp => String::from("\x1b[A"),
1346 SpecialKey::KeyDown => String::from("\x1b[B"),
1347 SpecialKey::KeyLeft => String::from("\x1b[D"),
1348 SpecialKey::KeyRight => String::from("\x1b[C"),
1349 SpecialKey::CtrlC => String::from("\x03"),
1350 SpecialKey::CtrlD => String::from("\x04"),
1351 SpecialKey::CtrlZ => String::from("\x1a"),
1352 }
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357 use super::strip_tail_pipe_impl;
1358
1359 #[test]
1360 fn strips_trailing_tail_by_default() {
1361 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1362 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1363 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1364 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1365 }
1366
1367 #[test]
1368 fn keeps_command_without_trailing_tail() {
1369 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1371 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1372 assert_eq!(
1373 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1374 "cat a | tail -5 | wc -l"
1375 );
1376 }
1377
1378 #[test]
1379 fn keep_mode_preserves_tail_pipe() {
1380 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1382 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1383 }
1384}