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;
47
48const 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.
501. Get its output using status check.
512. Use `send_ascii` or `send_specials` to give inputs to the running program OR
523. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
534. Interrupt and run the process in background
54";
55
56#[derive(Debug, Clone)]
62pub struct ExitedShellInfo {
63 pub last_command: String,
64 pub final_output: String,
65 pub exited_at: Instant,
66}
67
68#[derive(Debug, Default)]
70pub struct BackgroundShellManager {
71 shells: HashMap<String, SharedPtyShell>,
72 tombstones: HashMap<String, ExitedShellInfo>,
75}
76
77impl BackgroundShellManager {
78 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
80
81 pub fn new() -> Self {
83 Self { shells: HashMap::new(), tombstones: HashMap::new() }
84 }
85
86 pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
88 let cid = format!("{:010x}", rand::rng().random::<u32>());
89
90 let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
91 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
92 })?;
93
94 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
95
96 info!("Started background shell with id: {}", cid);
97 Ok(cid)
98 }
99
100 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
102 self.shells.get(bg_command_id).cloned()
103 }
104
105 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
107 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
108 if let Ok(mut guard) = shell_arc.try_lock() {
109 *guard = None;
110 }
111 info!("Removed background shell: {}", bg_command_id);
112 true
113 } else {
114 false
115 }
116 }
117
118 fn prune_finished_shells(&mut self) {
119 let now = Instant::now();
121 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
122
123 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
124
125 for (id, shell_arc) in &self.shells {
126 let Ok(mut guard) = shell_arc.try_lock() else {
127 continue;
128 };
129
130 let Some(shell) = guard.as_mut() else {
131 finished.push((id.clone(), None));
132 continue;
133 };
134
135 if !shell.is_alive() {
136 let tombstone = ExitedShellInfo {
137 last_command: shell.last_command.clone(),
138 final_output: shell.output_buffer.clone(),
139 exited_at: now,
140 };
141 finished.push((id.clone(), Some(tombstone)));
142 continue;
143 }
144
145 if shell.last_command.is_empty() {
150 continue;
151 }
152
153 if shell.command_running {
154 let _ = shell.read_output(0.1);
155 }
156
157 if !shell.command_running {
158 let tombstone = ExitedShellInfo {
159 last_command: shell.last_command.clone(),
160 final_output: shell.output_buffer.clone(),
161 exited_at: now,
162 };
163 finished.push((id.clone(), Some(tombstone)));
164 }
165 }
166
167 for (id, tombstone) in finished {
168 self.remove_shell(&id);
169 if let Some(info) = tombstone {
170 self.tombstones.insert(id, info);
171 }
172 }
173 }
174
175 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
182 self.tombstones.get(bg_command_id).cloned()
183 }
184
185 pub fn get_running_info(&mut self) -> String {
187 self.prune_finished_shells();
188
189 if self.shells.is_empty() {
190 return "No command running in background.\n".to_string();
191 }
192
193 let mut running = Vec::new();
194 for (id, shell_arc) in &self.shells {
195 if let Ok(guard) = shell_arc.try_lock() {
196 if let Some(bash) = guard.as_ref() {
197 if bash.command_running {
198 running
199 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
200 }
201 }
202 } else {
203 running.push(format!("Command: <busy>, bg_command_id: {id}"));
204 }
205 }
206
207 if running.is_empty() {
208 "No command running in background.\n".to_string()
209 } else {
210 format!("Following background commands are attached:\n{}\n", running.join("\n"))
211 }
212 }
213}
214
215lazy_static::lazy_static! {
217 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
218}
219
220fn get_status(
224 bash_state: &BashState,
225 is_bg: bool,
226 bg_id: Option<&str>,
227 is_running: bool,
228 running_for: Option<&str>,
229) -> String {
230 let mut status = "\n\n---\n\n".to_string();
231
232 if is_bg {
233 if let Some(id) = bg_id {
234 let _ = writeln!(status, "bg_command_id = {id}");
235 }
236 }
237
238 if is_running {
239 status.push_str("status = still running\n");
240 if let Some(duration) = running_for {
241 let _ = writeln!(status, "running for = {duration}");
242 }
243 } else {
244 status.push_str("status = process exited\n");
245 }
246
247 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
248
249 if !is_bg {
250 if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
252 status.push_str("This is the main shell. ");
253 status.push_str(&manager.get_running_info());
254 }
255 }
256
257 status.trim_end().to_string()
258}
259
260fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
262 let text =
263 if text.len() > MAX_OUTPUT_LENGTH { &text[text.len() - MAX_OUTPUT_LENGTH..] } else { text };
264
265 if last_pending_output.is_empty() {
266 let rendered = render_terminal_output(text);
267 return rstrip_lines(&rendered).trim_start().to_string();
268 }
269
270 let last_rendered = render_terminal_output(last_pending_output);
271 if last_rendered.is_empty() {
272 return rstrip_lines(&render_terminal_output(text));
273 }
274
275 let text_after_last = if text.len() > last_pending_output.len() {
277 &text[last_pending_output.len()..]
278 } else {
279 text
280 };
281
282 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
283 let new_rendered = render_terminal_output(&combined);
284
285 let incremental = get_incremental_output(&last_rendered, &new_rendered);
287 rstrip_lines(&incremental)
288}
289
290fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
291 let stripped = strip_ansi_codes(output);
292 let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
293
294 prompt_regex
295 .captures_iter(&stripped)
296 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
297 .filter(|cwd| !cwd.is_empty())
298 .last()
299 .map(PathBuf::from)
300}
301
302fn rstrip_lines(lines: &[String]) -> String {
304 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
305}
306
307fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
309 if old_output.is_empty() {
310 return new_output.to_vec();
311 }
312
313 let nold = old_output.len();
314 let nnew = new_output.len();
315
316 for i in (0..nnew).rev() {
318 if new_output[i] != old_output[nold - 1] {
319 continue;
320 }
321
322 let mut matched = true;
323 for j in (0..i).rev() {
324 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
325 if old_idx < 0 {
326 break;
327 }
328 if new_output[j] != old_output[old_idx as usize] {
329 matched = false;
330 break;
331 }
332 }
333
334 if matched {
335 return new_output[i + 1..].to_vec();
336 }
337 }
338
339 new_output.to_vec()
340}
341
342fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
343 let mut start = 0;
344
345 while start < text.len() {
346 let mut end = (start + chunk_size).min(text.len());
347 while !text.is_char_boundary(end) {
348 end -= 1;
349 }
350 if end == start {
351 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
352 }
353
354 shell.send_text(&text[start..end]).map_err(|e| {
355 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
356 })?;
357 start = end;
358 }
359
360 Ok(())
361}
362
363#[allow(dead_code)]
365fn is_status_check_action(action: &BashCommandAction) -> bool {
366 match action {
367 BashCommandAction::StatusCheck { .. } => true,
368 BashCommandAction::SendSpecials { send_specials, .. } => {
369 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
370 }
371 BashCommandAction::SendAscii { send_ascii, .. } => {
372 send_ascii.len() == 1 && send_ascii[0] == 10 }
374 _ => false,
375 }
376}
377
378#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
385pub async fn handle_tool_call(
386 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
387 bash_command: BashCommand,
388) -> Result<String> {
389 info!("BashCommand tool called with: {:?}", bash_command);
390
391 let thread_id = normalize_thread_id(&bash_command.thread_id);
392
393 if thread_id.is_empty() {
395 error!("Empty thread_id provided in BashCommand");
396 return Err(WinxError::ThreadIdMismatch(
397 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
398 ));
399 }
400
401 let mut bash_state: BashState;
403 {
404 let bash_state_guard = bash_state_arc.lock().await;
405
406 let Some(state) = &*bash_state_guard else {
407 error!("BashState not initialized");
408 return Err(WinxError::BashStateNotInitialized);
409 };
410
411 bash_state = state.clone();
412 }
413
414 if thread_id != bash_state.current_thread_id {
416 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
418 return Err(WinxError::ThreadIdMismatch(format!(
419 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
420 )));
421 }
422 }
423
424 let timeout_s = bash_command
427 .wait_for_seconds
428 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
429 .min(TIMEOUT_WHILE_OUTPUT);
430
431 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
433
434 {
435 let mut bash_state_guard = bash_state_arc.lock().await;
436 if let Some(state) = bash_state_guard.as_mut() {
437 state.cwd.clone_from(&bash_state.cwd);
438 }
439 }
440
441 match result {
443 Ok(mut output) => {
444 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
445 let cmd_trimmed = command.trim();
446 if output.starts_with(cmd_trimmed) {
447 output = output[cmd_trimmed.len()..].to_string();
448 }
449 }
450 Ok(output)
451 }
452 Err(e) => Err(e),
453 }
454}
455
456async fn execute_bash_action(
458 bash_state: &mut BashState,
459 action: &BashCommandAction,
460 timeout_s: f64,
461) -> Result<String> {
462 let mut is_bg = false;
463 let mut bg_id: Option<String> = None;
464
465 let bg_shell: Option<SharedPtyShell> = match action {
467 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
469 | BashCommandAction::SendText { bg_command_id, .. }
470 | BashCommandAction::SendSpecials { bg_command_id, .. }
471 | BashCommandAction::SendAscii { bg_command_id, .. } => {
472 if let Some(id) = bg_command_id {
473 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
474 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
475 })?;
476 manager.prune_finished_shells();
477
478 if let Some(shell) = manager.get_shell(id) {
479 is_bg = true;
480 bg_id = Some(id.clone());
481 Some(shell)
482 } else if let Some(tombstone) = manager.peek_tombstone(id) {
483 drop(manager);
488 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
489 } else {
490 let error = format!(
492 "No shell found running with command id {}.\n{}",
493 id,
494 manager.get_running_info()
495 );
496 return Err(WinxError::CommandExecutionError(error));
497 }
498 } else {
499 None
500 }
501 }
502 };
503
504 match action {
506 BashCommandAction::Command { command, is_background } => {
507 execute_command(bash_state, command, *is_background, timeout_s).await
508 }
509 BashCommandAction::StatusCheck { .. } => {
510 execute_status_check(bash_state, bg_shell, is_bg, bg_id.as_deref(), timeout_s).await
511 }
512 BashCommandAction::SendText { send_text, submit, .. } => {
513 execute_send_text(
514 bash_state,
515 send_text,
516 *submit,
517 bg_shell,
518 is_bg,
519 bg_id.as_deref(),
520 timeout_s,
521 )
522 .await
523 }
524 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
525 execute_send_specials(
526 bash_state,
527 send_specials,
528 *submit,
529 bg_shell,
530 is_bg,
531 bg_id.as_deref(),
532 timeout_s,
533 )
534 .await
535 }
536 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
537 execute_send_ascii(
538 bash_state,
539 send_ascii,
540 *submit,
541 bg_shell,
542 is_bg,
543 bg_id.as_deref(),
544 timeout_s,
545 )
546 .await
547 }
548 }
549}
550
551async fn execute_command(
553 bash_state: &mut BashState,
554 command: &str,
555 is_background: bool,
556 timeout_s: f64,
557) -> Result<String> {
558 debug!("Processing Command action: {}", command);
559
560 if !bash_state.is_command_allowed(command) {
562 error!("Command '{}' not allowed in current mode", command);
563 return Err(WinxError::CommandNotAllowed(
564 "Error: BashCommand not allowed in current mode".to_string(),
565 ));
566 }
567
568 let command = command.trim();
570 crate::utils::bash_parser::assert_single_statement(command)?;
571
572 if is_background {
574 return execute_in_background(bash_state, command, timeout_s).await;
575 }
576
577 {
579 let bash_guard = bash_state.pty_shell.lock().await;
580
581 if let Some(ref bash) = *bash_guard {
582 if bash.command_running {
583 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
584 }
585 }
586 }
587
588 if bash_state.pty_shell.lock().await.is_none() {
590 bash_state
591 .init_pty_shell()
592 .await
593 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
594 }
595
596 {
601 let mut bash_guard = bash_state.pty_shell.lock().await;
602
603 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
604
605 bash.output_buffer.clear();
606 bash.output_truncated = false;
607 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
609
610 bash.send_special_key("Enter").map_err(|e| {
612 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
613 })?;
614
615 bash.last_command = command.to_string();
616 bash.command_running = true;
617 }
618
619 let shell_arc = bash_state.pty_shell.clone();
621 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
622}
623
624async fn wait_for_output(
628 bash_state: &mut BashState,
629 shell_arc: &SharedPtyShell,
630 timeout_s: f64,
631 is_bg: bool,
632 bg_id: Option<&str>,
633 is_status_check: bool,
634) -> Result<String> {
635 let start = Instant::now();
636 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
637 let mut last_pending_output = String::new();
638 let mut complete = false;
639
640 sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
642
643 let mut output = {
645 let mut bash_guard = shell_arc.lock().await;
646
647 if let Some(bash) = bash_guard.as_mut() {
648 let (out, done) = bash.read_output(0.5).map_err(|e| {
649 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
650 })?;
651 complete = done;
652 out
653 } else {
654 String::new()
655 }
656 };
657
658 if !complete && is_status_check {
661 let mut remaining = TIMEOUT_WHILE_OUTPUT - wait;
662 let mut patience = OUTPUT_WAIT_PATIENCE;
663
664 let incremental = wcgw_incremental_text(&output, &last_pending_output);
665 if incremental.is_empty() {
666 patience -= 1;
667 }
668
669 let mut last_incremental = incremental;
670
671 while remaining > 0.0 && patience > 0 {
672 sleep(Duration::from_secs_f64(wait.min(remaining))).await;
673
674 let (new_output, done) = {
675 let mut bash_guard = shell_arc.lock().await;
676
677 if let Some(bash) = bash_guard.as_mut() {
678 bash.read_output(0.5).map_err(|e| {
679 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
680 })?
681 } else {
682 (String::new(), true)
683 }
684 };
685
686 if done {
687 complete = true;
688 output = new_output;
689 break;
690 }
691
692 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
694 if new_incremental == last_incremental {
695 patience -= 1;
696 } else {
697 patience = OUTPUT_WAIT_PATIENCE; }
699 last_incremental = new_incremental;
700
701 output = new_output;
702 remaining -= wait;
703 }
704
705 if !complete {
706 last_pending_output = output.clone();
708 }
709 }
710
711 if complete {
712 if let Some(cwd) = extract_prompt_cwd(&output) {
713 bash_state.cwd = cwd;
714 }
715 }
716
717 let rendered = wcgw_incremental_text(&output, &last_pending_output);
719
720 let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
722 format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
723 } else {
724 rendered
725 };
726
727 let running_for = if complete {
729 None
730 } else {
731 Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
732 };
733
734 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
736 Ok(format!("{rendered}{status}"))
737}
738
739fn finalize_tombstone(
746 cwd: &Path,
747 id: &str,
748 tombstone: ExitedShellInfo,
749 action: &BashCommandAction,
750) -> Result<String> {
751 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
752 match action {
753 BashCommandAction::StatusCheck { .. } => {
754 let rendered = wcgw_incremental_text(&final_output, "");
755 let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
756 format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
757 } else {
758 rendered
759 };
760 let mut status = "\n\n---\n\n".to_string();
762 let _ = writeln!(status, "bg_command_id = {id}");
763 status.push_str("status = process exited\n");
764 let _ = writeln!(status, "cwd = {}", cwd.display());
765 Ok(format!("{rendered}{}", status.trim_end()))
766 }
767 BashCommandAction::SendText { .. }
768 | BashCommandAction::SendSpecials { .. }
769 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
770 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
771 ))),
772 BashCommandAction::Command { .. } => {
773 unreachable!("finalize_tombstone called for non-bg action")
776 }
777 }
778}
779
780async fn execute_status_check(
782 bash_state: &mut BashState,
783 bg_shell: Option<SharedPtyShell>,
784 is_bg: bool,
785 bg_id: Option<&str>,
786 timeout_s: f64,
787) -> Result<String> {
788 debug!("Processing StatusCheck action");
789
790 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
793
794 let is_running = {
796 let guard = shell_arc.lock().await;
797 if let Some(ref bash) = *guard {
798 bash.command_running
799 } else {
800 false
801 }
802 };
803
804 if !is_running && !is_bg {
806 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
807 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
808 })?;
809 let error =
810 format!("No running command to check status of.\n{}", manager.get_running_info());
811 return Err(WinxError::CommandExecutionError(error));
812 }
813
814 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await
816}
817
818async fn execute_send_text(
820 bash_state: &mut BashState,
821 text: &str,
822 submit: bool,
823 bg_shell: Option<SharedPtyShell>,
824 is_bg: bool,
825 bg_id: Option<&str>,
826 timeout_s: f64,
827) -> Result<String> {
828 debug!("Processing SendText action: {text:?} (submit={submit})");
829
830 if text.is_empty() {
832 return Err(WinxError::CommandExecutionError(
833 "Failure: send_text cannot be empty".to_string(),
834 ));
835 }
836
837 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
839
840 {
842 let mut guard = shell_arc.lock().await;
843
844 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
845
846 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
848
849 if submit {
853 bash.send_special_key("Enter").map_err(|e| {
854 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
855 })?;
856 }
857 }
858
859 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
861}
862
863async fn execute_send_specials(
865 bash_state: &mut BashState,
866 keys: &[SpecialKey],
867 submit: bool,
868 bg_shell: Option<SharedPtyShell>,
869 is_bg: bool,
870 bg_id: Option<&str>,
871 timeout_s: f64,
872) -> Result<String> {
873 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
874
875 if keys.is_empty() {
877 return Err(WinxError::CommandExecutionError(
878 "Failure: send_specials cannot be empty".to_string(),
879 ));
880 }
881
882 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
883 let mut is_interrupt = false;
884
885 {
886 let mut guard = shell_arc.lock().await;
887
888 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
889
890 for key in keys {
892 match key {
893 SpecialKey::KeyUp => {
894 bash.send_special_key("KeyUp").map_err(|e| {
896 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
897 })?;
898 }
899 SpecialKey::KeyDown => {
900 bash.send_special_key("KeyDown").map_err(|e| {
902 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
903 })?;
904 }
905 SpecialKey::KeyLeft => {
906 bash.send_special_key("KeyLeft").map_err(|e| {
908 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
909 })?;
910 }
911 SpecialKey::KeyRight => {
912 bash.send_special_key("KeyRight").map_err(|e| {
914 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
915 })?;
916 }
917 SpecialKey::Enter => {
918 bash.send_special_key("Enter").map_err(|e| {
920 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
921 })?;
922 }
923 SpecialKey::CtrlC => {
924 bash.send_interrupt().map_err(|e| {
926 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
927 })?;
928 is_interrupt = true;
929 }
930 SpecialKey::CtrlD => {
931 bash.send_eof().map_err(|e| {
933 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
934 })?;
935 is_interrupt = true;
936 }
937 SpecialKey::CtrlZ => {
938 bash.send_suspend().map_err(|e| {
940 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
941 })?;
942 }
943 }
944 }
945 if submit {
947 bash.send_special_key("Enter")
948 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
949 }
950 }
951
952 let mut output =
954 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
955
956 if is_interrupt && output.contains("status = still running") {
958 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
959 }
960
961 Ok(output)
962}
963
964async fn execute_send_ascii(
966 bash_state: &mut BashState,
967 ascii_codes: &[u8],
968 submit: bool,
969 bg_shell: Option<SharedPtyShell>,
970 is_bg: bool,
971 bg_id: Option<&str>,
972 timeout_s: f64,
973) -> Result<String> {
974 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
975
976 if ascii_codes.is_empty() {
978 return Err(WinxError::CommandExecutionError(
979 "Failure: send_ascii cannot be empty".to_string(),
980 ));
981 }
982
983 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
984 let mut is_interrupt = false;
985
986 {
987 let mut guard = shell_arc.lock().await;
988
989 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
990
991 for &code in ascii_codes {
993 bash.send_bytes(&[code]).map_err(|e| {
995 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
996 })?;
997
998 if code == 3 {
1000 is_interrupt = true;
1001 }
1002 }
1003 if submit {
1005 bash.send_special_key("Enter")
1006 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1007 }
1008 }
1009
1010 let mut output =
1012 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1013
1014 if is_interrupt && output.contains("status = still running") {
1016 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1017 }
1018
1019 Ok(output)
1020}
1021
1022async fn execute_in_background(
1024 bash_state: &mut BashState,
1025 command: &str,
1026 timeout_s: f64,
1027) -> Result<String> {
1028 debug!("Executing command in background: {}", command);
1029
1030 let restricted_mode =
1032 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1033
1034 let bg_id = {
1035 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1036 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1037 })?;
1038 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1039 };
1040
1041 let shell_arc = {
1043 let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1044 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1045 })?;
1046 manager.get_shell(&bg_id).ok_or_else(|| {
1047 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1048 })?
1049 };
1050
1051 {
1053 let mut guard = shell_arc.lock().await;
1054 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1055 bash.send_command(command).map_err(|e| {
1056 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1057 })?;
1058 }
1059 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1060
1061 let _ = timeout_s;
1062 let _ = shell_arc;
1063 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1064}
1065
1066#[allow(dead_code)]
1070#[tracing::instrument(level = "debug", skip(command, cwd))]
1071async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1072 debug!("Executing command: {}", command);
1073
1074 let start_time = Instant::now();
1075 let mut cmd = Command::new("sh");
1076 cmd.arg("-c")
1077 .arg(command)
1078 .current_dir(cwd)
1079 .stdin(Stdio::null())
1080 .stdout(Stdio::piped())
1081 .stderr(Stdio::piped());
1082
1083 let output = cmd.output().context("Failed to execute command")?;
1084 let elapsed = start_time.elapsed();
1085
1086 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1087 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1088
1089 let raw_result = format!("{stdout}{stderr}");
1090 let mut result = raw_result.clone();
1091 if !raw_result.is_empty() {
1092 let rendered_lines = render_terminal_output(&raw_result);
1093 if rendered_lines.is_empty() {
1094 result = strip_ansi_codes(&raw_result);
1096 } else {
1097 result = rendered_lines.join("\n");
1098 }
1099 }
1100
1101 if result.len() > MAX_OUTPUT_LENGTH {
1102 result = format!("(...truncated)\n{}", &result[result.len() - MAX_OUTPUT_LENGTH..]);
1103 }
1104
1105 let exit_status = if output.status.success() {
1106 "Command completed successfully".to_string()
1107 } else {
1108 format!("Command failed with status: {}", output.status)
1109 };
1110
1111 let current_dir = std::env::current_dir()
1112 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1113
1114 debug!("Command executed in {:.2?}", elapsed);
1115 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1116}
1117
1118#[allow(dead_code)]
1120#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1121async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1122 debug!("Executing command in screen session '{}': {}", screen_name, command);
1123
1124 let screen_check = Command::new("which")
1125 .arg("screen")
1126 .output()
1127 .context("Failed to check for screen command")?;
1128
1129 if !screen_check.status.success() {
1130 warn!("Screen command not found, falling back to direct execution");
1131 return execute_simple_command(command, cwd).await;
1132 }
1133
1134 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1135
1136 let screen_cmd = format!(
1137 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1138 screen_name,
1139 command.replace('\'', "'\\''")
1140 );
1141
1142 let screen_start = Command::new("sh")
1143 .arg("-c")
1144 .arg(&screen_cmd)
1145 .current_dir(cwd)
1146 .output()
1147 .context("Failed to start screen session")?;
1148
1149 if !screen_start.status.success() {
1150 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1151 error!("Failed to start screen session: {}", stderr);
1152 return Err(WinxError::CommandExecutionError(format!(
1153 "Failed to start screen session: {stderr}"
1154 )));
1155 }
1156
1157 sleep(Duration::from_millis(300)).await;
1158
1159 let screen_check =
1160 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1161
1162 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1163
1164 let current_dir = std::env::current_dir()
1165 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1166
1167 Ok(format!(
1168 "Started command in background screen session '{screen_name}'.\n\
1169 Use status_check to get output.\n\n\
1170 Screen sessions:\n{screen_list}\n\
1171 ---\n\n\
1172 status = running in background\n\
1173 cwd = {current_dir}\n"
1174 ))
1175}
1176
1177#[allow(dead_code)]
1179fn special_key_to_screen_input(key: SpecialKey) -> String {
1180 match key {
1181 SpecialKey::Enter => String::from("\r"),
1182 SpecialKey::KeyUp => String::from("\x1b[A"),
1183 SpecialKey::KeyDown => String::from("\x1b[B"),
1184 SpecialKey::KeyLeft => String::from("\x1b[D"),
1185 SpecialKey::KeyRight => String::from("\x1b[C"),
1186 SpecialKey::CtrlC => String::from("\x03"),
1187 SpecialKey::CtrlD => String::from("\x04"),
1188 SpecialKey::CtrlZ => String::from("\x1a"),
1189 }
1190}