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 take_tombstone(&mut self, bg_command_id: &str) -> Option<ExitedShellInfo> {
178 self.tombstones.remove(bg_command_id)
179 }
180
181 pub fn get_running_info(&mut self) -> String {
183 self.prune_finished_shells();
184
185 if self.shells.is_empty() {
186 return "No command running in background.\n".to_string();
187 }
188
189 let mut running = Vec::new();
190 for (id, shell_arc) in &self.shells {
191 if let Ok(guard) = shell_arc.try_lock() {
192 if let Some(bash) = guard.as_ref() {
193 if bash.command_running {
194 running
195 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
196 }
197 }
198 } else {
199 running.push(format!("Command: <busy>, bg_command_id: {id}"));
200 }
201 }
202
203 if running.is_empty() {
204 "No command running in background.\n".to_string()
205 } else {
206 format!("Following background commands are attached:\n{}\n", running.join("\n"))
207 }
208 }
209}
210
211lazy_static::lazy_static! {
213 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
214}
215
216fn get_status(
220 bash_state: &BashState,
221 is_bg: bool,
222 bg_id: Option<&str>,
223 is_running: bool,
224 running_for: Option<&str>,
225) -> String {
226 let mut status = "\n\n---\n\n".to_string();
227
228 if is_bg {
229 if let Some(id) = bg_id {
230 let _ = writeln!(status, "bg_command_id = {id}");
231 }
232 }
233
234 if is_running {
235 status.push_str("status = still running\n");
236 if let Some(duration) = running_for {
237 let _ = writeln!(status, "running for = {duration}");
238 }
239 } else {
240 status.push_str("status = process exited\n");
241 }
242
243 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
244
245 if !is_bg {
246 if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
248 status.push_str("This is the main shell. ");
249 status.push_str(&manager.get_running_info());
250 }
251 }
252
253 status.trim_end().to_string()
254}
255
256fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
258 let text =
259 if text.len() > MAX_OUTPUT_LENGTH { &text[text.len() - MAX_OUTPUT_LENGTH..] } else { text };
260
261 if last_pending_output.is_empty() {
262 let rendered = render_terminal_output(text);
263 return rstrip_lines(&rendered).trim_start().to_string();
264 }
265
266 let last_rendered = render_terminal_output(last_pending_output);
267 if last_rendered.is_empty() {
268 return rstrip_lines(&render_terminal_output(text));
269 }
270
271 let text_after_last = if text.len() > last_pending_output.len() {
273 &text[last_pending_output.len()..]
274 } else {
275 text
276 };
277
278 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
279 let new_rendered = render_terminal_output(&combined);
280
281 let incremental = get_incremental_output(&last_rendered, &new_rendered);
283 rstrip_lines(&incremental)
284}
285
286fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
287 let stripped = strip_ansi_codes(output);
288 let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
289
290 prompt_regex
291 .captures_iter(&stripped)
292 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
293 .filter(|cwd| !cwd.is_empty())
294 .last()
295 .map(PathBuf::from)
296}
297
298fn rstrip_lines(lines: &[String]) -> String {
300 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
301}
302
303fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
305 if old_output.is_empty() {
306 return new_output.to_vec();
307 }
308
309 let nold = old_output.len();
310 let nnew = new_output.len();
311
312 for i in (0..nnew).rev() {
314 if new_output[i] != old_output[nold - 1] {
315 continue;
316 }
317
318 let mut matched = true;
319 for j in (0..i).rev() {
320 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
321 if old_idx < 0 {
322 break;
323 }
324 if new_output[j] != old_output[old_idx as usize] {
325 matched = false;
326 break;
327 }
328 }
329
330 if matched {
331 return new_output[i + 1..].to_vec();
332 }
333 }
334
335 new_output.to_vec()
336}
337
338fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
339 let mut start = 0;
340
341 while start < text.len() {
342 let mut end = (start + chunk_size).min(text.len());
343 while !text.is_char_boundary(end) {
344 end -= 1;
345 }
346 if end == start {
347 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
348 }
349
350 shell.send_text(&text[start..end]).map_err(|e| {
351 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
352 })?;
353 start = end;
354 }
355
356 Ok(())
357}
358
359#[allow(dead_code)]
361fn is_status_check_action(action: &BashCommandAction) -> bool {
362 match action {
363 BashCommandAction::StatusCheck { .. } => true,
364 BashCommandAction::SendSpecials { send_specials, .. } => {
365 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
366 }
367 BashCommandAction::SendAscii { send_ascii, .. } => {
368 send_ascii.len() == 1 && send_ascii[0] == 10 }
370 _ => false,
371 }
372}
373
374#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
381pub async fn handle_tool_call(
382 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
383 bash_command: BashCommand,
384) -> Result<String> {
385 info!("BashCommand tool called with: {:?}", bash_command);
386
387 let thread_id = normalize_thread_id(&bash_command.thread_id);
388
389 if thread_id.is_empty() {
391 error!("Empty thread_id provided in BashCommand");
392 return Err(WinxError::ThreadIdMismatch(
393 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
394 ));
395 }
396
397 let mut bash_state: BashState;
399 {
400 let bash_state_guard = bash_state_arc.lock().await;
401
402 let Some(state) = &*bash_state_guard else {
403 error!("BashState not initialized");
404 return Err(WinxError::BashStateNotInitialized);
405 };
406
407 bash_state = state.clone();
408 }
409
410 if thread_id != bash_state.current_thread_id {
412 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
414 return Err(WinxError::ThreadIdMismatch(format!(
415 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
416 )));
417 }
418 }
419
420 let timeout_s = bash_command
423 .wait_for_seconds
424 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
425 .min(TIMEOUT_WHILE_OUTPUT);
426
427 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
429
430 {
431 let mut bash_state_guard = bash_state_arc.lock().await;
432 if let Some(state) = bash_state_guard.as_mut() {
433 state.cwd.clone_from(&bash_state.cwd);
434 }
435 }
436
437 match result {
439 Ok(mut output) => {
440 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
441 let cmd_trimmed = command.trim();
442 if output.starts_with(cmd_trimmed) {
443 output = output[cmd_trimmed.len()..].to_string();
444 }
445 }
446 Ok(output)
447 }
448 Err(e) => Err(e),
449 }
450}
451
452async fn execute_bash_action(
454 bash_state: &mut BashState,
455 action: &BashCommandAction,
456 timeout_s: f64,
457) -> Result<String> {
458 let mut is_bg = false;
459 let mut bg_id: Option<String> = None;
460
461 let bg_shell: Option<SharedPtyShell> = match action {
463 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
465 | BashCommandAction::SendText { bg_command_id, .. }
466 | BashCommandAction::SendSpecials { bg_command_id, .. }
467 | BashCommandAction::SendAscii { bg_command_id, .. } => {
468 if let Some(id) = bg_command_id {
469 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
470 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
471 })?;
472 manager.prune_finished_shells();
473
474 if let Some(shell) = manager.get_shell(id) {
475 is_bg = true;
476 bg_id = Some(id.clone());
477 Some(shell)
478 } else if let Some(tombstone) = manager.take_tombstone(id) {
479 drop(manager);
484 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
485 } else {
486 let error = format!(
488 "No shell found running with command id {}.\n{}",
489 id,
490 manager.get_running_info()
491 );
492 return Err(WinxError::CommandExecutionError(error));
493 }
494 } else {
495 None
496 }
497 }
498 };
499
500 match action {
502 BashCommandAction::Command { command, is_background } => {
503 execute_command(bash_state, command, *is_background, timeout_s).await
504 }
505 BashCommandAction::StatusCheck { .. } => {
506 execute_status_check(bash_state, bg_shell, is_bg, bg_id.as_deref(), timeout_s).await
507 }
508 BashCommandAction::SendText { send_text, submit, .. } => {
509 execute_send_text(
510 bash_state,
511 send_text,
512 *submit,
513 bg_shell,
514 is_bg,
515 bg_id.as_deref(),
516 timeout_s,
517 )
518 .await
519 }
520 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
521 execute_send_specials(
522 bash_state,
523 send_specials,
524 *submit,
525 bg_shell,
526 is_bg,
527 bg_id.as_deref(),
528 timeout_s,
529 )
530 .await
531 }
532 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
533 execute_send_ascii(
534 bash_state,
535 send_ascii,
536 *submit,
537 bg_shell,
538 is_bg,
539 bg_id.as_deref(),
540 timeout_s,
541 )
542 .await
543 }
544 }
545}
546
547async fn execute_command(
549 bash_state: &mut BashState,
550 command: &str,
551 is_background: bool,
552 timeout_s: f64,
553) -> Result<String> {
554 debug!("Processing Command action: {}", command);
555
556 if !bash_state.is_command_allowed(command) {
558 error!("Command '{}' not allowed in current mode", command);
559 return Err(WinxError::CommandNotAllowed(
560 "Error: BashCommand not allowed in current mode".to_string(),
561 ));
562 }
563
564 let command = command.trim();
566 crate::utils::bash_parser::assert_single_statement(command)?;
567
568 if is_background {
570 return execute_in_background(bash_state, command, timeout_s).await;
571 }
572
573 {
575 let bash_guard = bash_state.pty_shell.lock().await;
576
577 if let Some(ref bash) = *bash_guard {
578 if bash.command_running {
579 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
580 }
581 }
582 }
583
584 if bash_state.pty_shell.lock().await.is_none() {
586 bash_state
587 .init_pty_shell()
588 .await
589 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
590 }
591
592 {
597 let mut bash_guard = bash_state.pty_shell.lock().await;
598
599 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
600
601 bash.output_buffer.clear();
602 bash.output_truncated = false;
603 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
605
606 bash.send_special_key("Enter").map_err(|e| {
608 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
609 })?;
610
611 bash.last_command = command.to_string();
612 bash.command_running = true;
613 }
614
615 let shell_arc = bash_state.pty_shell.clone();
617 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
618}
619
620async fn wait_for_output(
624 bash_state: &mut BashState,
625 shell_arc: &SharedPtyShell,
626 timeout_s: f64,
627 is_bg: bool,
628 bg_id: Option<&str>,
629 is_status_check: bool,
630) -> Result<String> {
631 let start = Instant::now();
632 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
633 let mut last_pending_output = String::new();
634 let mut complete = false;
635
636 sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
638
639 let mut output = {
641 let mut bash_guard = shell_arc.lock().await;
642
643 if let Some(bash) = bash_guard.as_mut() {
644 let (out, done) = bash.read_output(0.5).map_err(|e| {
645 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
646 })?;
647 complete = done;
648 out
649 } else {
650 String::new()
651 }
652 };
653
654 if !complete && is_status_check {
657 let mut remaining = TIMEOUT_WHILE_OUTPUT - wait;
658 let mut patience = OUTPUT_WAIT_PATIENCE;
659
660 let incremental = wcgw_incremental_text(&output, &last_pending_output);
661 if incremental.is_empty() {
662 patience -= 1;
663 }
664
665 let mut last_incremental = incremental;
666
667 while remaining > 0.0 && patience > 0 {
668 sleep(Duration::from_secs_f64(wait.min(remaining))).await;
669
670 let (new_output, done) = {
671 let mut bash_guard = shell_arc.lock().await;
672
673 if let Some(bash) = bash_guard.as_mut() {
674 bash.read_output(0.5).map_err(|e| {
675 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
676 })?
677 } else {
678 (String::new(), true)
679 }
680 };
681
682 if done {
683 complete = true;
684 output = new_output;
685 break;
686 }
687
688 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
690 if new_incremental == last_incremental {
691 patience -= 1;
692 } else {
693 patience = OUTPUT_WAIT_PATIENCE; }
695 last_incremental = new_incremental;
696
697 output = new_output;
698 remaining -= wait;
699 }
700
701 if !complete {
702 last_pending_output = output.clone();
704 }
705 }
706
707 if complete {
708 if let Some(cwd) = extract_prompt_cwd(&output) {
709 bash_state.cwd = cwd;
710 }
711 }
712
713 let rendered = wcgw_incremental_text(&output, &last_pending_output);
715
716 let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
718 format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
719 } else {
720 rendered
721 };
722
723 let running_for = if complete {
725 None
726 } else {
727 Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
728 };
729
730 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
732 Ok(format!("{rendered}{status}"))
733}
734
735fn finalize_tombstone(
742 cwd: &Path,
743 id: &str,
744 tombstone: ExitedShellInfo,
745 action: &BashCommandAction,
746) -> Result<String> {
747 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
748 match action {
749 BashCommandAction::StatusCheck { .. } => {
750 let rendered = wcgw_incremental_text(&final_output, "");
751 let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
752 format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
753 } else {
754 rendered
755 };
756 let mut status = "\n\n---\n\n".to_string();
758 let _ = writeln!(status, "bg_command_id = {id}");
759 status.push_str("status = process exited\n");
760 let _ = writeln!(status, "cwd = {}", cwd.display());
761 Ok(format!("{rendered}{}", status.trim_end()))
762 }
763 BashCommandAction::SendText { .. }
764 | BashCommandAction::SendSpecials { .. }
765 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
766 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
767 ))),
768 BashCommandAction::Command { .. } => {
769 unreachable!("finalize_tombstone called for non-bg action")
772 }
773 }
774}
775
776async fn execute_status_check(
778 bash_state: &mut BashState,
779 bg_shell: Option<SharedPtyShell>,
780 is_bg: bool,
781 bg_id: Option<&str>,
782 timeout_s: f64,
783) -> Result<String> {
784 debug!("Processing StatusCheck action");
785
786 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
789
790 let is_running = {
792 let guard = shell_arc.lock().await;
793 if let Some(ref bash) = *guard {
794 bash.command_running
795 } else {
796 false
797 }
798 };
799
800 if !is_running && !is_bg {
802 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
803 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
804 })?;
805 let error =
806 format!("No running command to check status of.\n{}", manager.get_running_info());
807 return Err(WinxError::CommandExecutionError(error));
808 }
809
810 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await
812}
813
814async fn execute_send_text(
816 bash_state: &mut BashState,
817 text: &str,
818 submit: bool,
819 bg_shell: Option<SharedPtyShell>,
820 is_bg: bool,
821 bg_id: Option<&str>,
822 timeout_s: f64,
823) -> Result<String> {
824 debug!("Processing SendText action: {text:?} (submit={submit})");
825
826 if text.is_empty() {
828 return Err(WinxError::CommandExecutionError(
829 "Failure: send_text cannot be empty".to_string(),
830 ));
831 }
832
833 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
835
836 {
838 let mut guard = shell_arc.lock().await;
839
840 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
841
842 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
844
845 if submit {
849 bash.send_special_key("Enter").map_err(|e| {
850 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
851 })?;
852 }
853 }
854
855 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
857}
858
859async fn execute_send_specials(
861 bash_state: &mut BashState,
862 keys: &[SpecialKey],
863 submit: bool,
864 bg_shell: Option<SharedPtyShell>,
865 is_bg: bool,
866 bg_id: Option<&str>,
867 timeout_s: f64,
868) -> Result<String> {
869 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
870
871 if keys.is_empty() {
873 return Err(WinxError::CommandExecutionError(
874 "Failure: send_specials cannot be empty".to_string(),
875 ));
876 }
877
878 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
879 let mut is_interrupt = false;
880
881 {
882 let mut guard = shell_arc.lock().await;
883
884 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
885
886 for key in keys {
888 match key {
889 SpecialKey::KeyUp => {
890 bash.send_special_key("KeyUp").map_err(|e| {
892 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
893 })?;
894 }
895 SpecialKey::KeyDown => {
896 bash.send_special_key("KeyDown").map_err(|e| {
898 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
899 })?;
900 }
901 SpecialKey::KeyLeft => {
902 bash.send_special_key("KeyLeft").map_err(|e| {
904 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
905 })?;
906 }
907 SpecialKey::KeyRight => {
908 bash.send_special_key("KeyRight").map_err(|e| {
910 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
911 })?;
912 }
913 SpecialKey::Enter => {
914 bash.send_special_key("Enter").map_err(|e| {
916 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
917 })?;
918 }
919 SpecialKey::CtrlC => {
920 bash.send_interrupt().map_err(|e| {
922 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
923 })?;
924 is_interrupt = true;
925 }
926 SpecialKey::CtrlD => {
927 bash.send_eof().map_err(|e| {
929 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
930 })?;
931 is_interrupt = true;
932 }
933 SpecialKey::CtrlZ => {
934 bash.send_suspend().map_err(|e| {
936 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
937 })?;
938 }
939 }
940 }
941 if submit {
943 bash.send_special_key("Enter")
944 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
945 }
946 }
947
948 let mut output =
950 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
951
952 if is_interrupt && output.contains("status = still running") {
954 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
955 }
956
957 Ok(output)
958}
959
960async fn execute_send_ascii(
962 bash_state: &mut BashState,
963 ascii_codes: &[u8],
964 submit: bool,
965 bg_shell: Option<SharedPtyShell>,
966 is_bg: bool,
967 bg_id: Option<&str>,
968 timeout_s: f64,
969) -> Result<String> {
970 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
971
972 if ascii_codes.is_empty() {
974 return Err(WinxError::CommandExecutionError(
975 "Failure: send_ascii cannot be empty".to_string(),
976 ));
977 }
978
979 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
980 let mut is_interrupt = false;
981
982 {
983 let mut guard = shell_arc.lock().await;
984
985 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
986
987 for &code in ascii_codes {
989 bash.send_bytes(&[code]).map_err(|e| {
991 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
992 })?;
993
994 if code == 3 {
996 is_interrupt = true;
997 }
998 }
999 if submit {
1001 bash.send_special_key("Enter")
1002 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1003 }
1004 }
1005
1006 let mut output =
1008 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1009
1010 if is_interrupt && output.contains("status = still running") {
1012 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1013 }
1014
1015 Ok(output)
1016}
1017
1018async fn execute_in_background(
1020 bash_state: &mut BashState,
1021 command: &str,
1022 timeout_s: f64,
1023) -> Result<String> {
1024 debug!("Executing command in background: {}", command);
1025
1026 let restricted_mode =
1028 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1029
1030 let bg_id = {
1031 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1032 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1033 })?;
1034 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1035 };
1036
1037 let shell_arc = {
1039 let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1040 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1041 })?;
1042 manager.get_shell(&bg_id).ok_or_else(|| {
1043 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1044 })?
1045 };
1046
1047 {
1049 let mut guard = shell_arc.lock().await;
1050 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1051 bash.send_command(command).map_err(|e| {
1052 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1053 })?;
1054 }
1055 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1056
1057 let _ = timeout_s;
1058 let _ = shell_arc;
1059 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1060}
1061
1062#[allow(dead_code)]
1066#[tracing::instrument(level = "debug", skip(command, cwd))]
1067async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1068 debug!("Executing command: {}", command);
1069
1070 let start_time = Instant::now();
1071 let mut cmd = Command::new("sh");
1072 cmd.arg("-c")
1073 .arg(command)
1074 .current_dir(cwd)
1075 .stdin(Stdio::null())
1076 .stdout(Stdio::piped())
1077 .stderr(Stdio::piped());
1078
1079 let output = cmd.output().context("Failed to execute command")?;
1080 let elapsed = start_time.elapsed();
1081
1082 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1083 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1084
1085 let raw_result = format!("{stdout}{stderr}");
1086 let mut result = raw_result.clone();
1087 if !raw_result.is_empty() {
1088 let rendered_lines = render_terminal_output(&raw_result);
1089 if rendered_lines.is_empty() {
1090 result = strip_ansi_codes(&raw_result);
1092 } else {
1093 result = rendered_lines.join("\n");
1094 }
1095 }
1096
1097 if result.len() > MAX_OUTPUT_LENGTH {
1098 result = format!("(...truncated)\n{}", &result[result.len() - MAX_OUTPUT_LENGTH..]);
1099 }
1100
1101 let exit_status = if output.status.success() {
1102 "Command completed successfully".to_string()
1103 } else {
1104 format!("Command failed with status: {}", output.status)
1105 };
1106
1107 let current_dir = std::env::current_dir()
1108 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1109
1110 debug!("Command executed in {:.2?}", elapsed);
1111 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1112}
1113
1114#[allow(dead_code)]
1116#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1117async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1118 debug!("Executing command in screen session '{}': {}", screen_name, command);
1119
1120 let screen_check = Command::new("which")
1121 .arg("screen")
1122 .output()
1123 .context("Failed to check for screen command")?;
1124
1125 if !screen_check.status.success() {
1126 warn!("Screen command not found, falling back to direct execution");
1127 return execute_simple_command(command, cwd).await;
1128 }
1129
1130 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1131
1132 let screen_cmd = format!(
1133 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1134 screen_name,
1135 command.replace('\'', "'\\''")
1136 );
1137
1138 let screen_start = Command::new("sh")
1139 .arg("-c")
1140 .arg(&screen_cmd)
1141 .current_dir(cwd)
1142 .output()
1143 .context("Failed to start screen session")?;
1144
1145 if !screen_start.status.success() {
1146 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1147 error!("Failed to start screen session: {}", stderr);
1148 return Err(WinxError::CommandExecutionError(format!(
1149 "Failed to start screen session: {stderr}"
1150 )));
1151 }
1152
1153 sleep(Duration::from_millis(300)).await;
1154
1155 let screen_check =
1156 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1157
1158 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1159
1160 let current_dir = std::env::current_dir()
1161 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1162
1163 Ok(format!(
1164 "Started command in background screen session '{screen_name}'.\n\
1165 Use status_check to get output.\n\n\
1166 Screen sessions:\n{screen_list}\n\
1167 ---\n\n\
1168 status = running in background\n\
1169 cwd = {current_dir}\n"
1170 ))
1171}
1172
1173#[allow(dead_code)]
1175fn special_key_to_screen_input(key: SpecialKey) -> String {
1176 match key {
1177 SpecialKey::Enter => String::from("\r"),
1178 SpecialKey::KeyUp => String::from("\x1b[A"),
1179 SpecialKey::KeyDown => String::from("\x1b[B"),
1180 SpecialKey::KeyLeft => String::from("\x1b[D"),
1181 SpecialKey::KeyRight => String::from("\x1b[C"),
1182 SpecialKey::CtrlC => String::from("\x03"),
1183 SpecialKey::CtrlD => String::from("\x04"),
1184 SpecialKey::CtrlZ => String::from("\x1a"),
1185 }
1186}