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 Ok(bpe) = tiktoken_rs::cl100k_base() else {
69 return std::borrow::Cow::Owned(format!(
71 "(...truncated)\n{}",
72 &text[text.len() - MAX_OUTPUT_LENGTH..]
73 ));
74 };
75
76 let tokens = bpe.encode_with_special_tokens(text);
77 if tokens.len() <= max_tokens {
78 return std::borrow::Cow::Borrowed(text);
79 }
80
81 let keep = max_tokens.saturating_sub(1);
83 let tail = &tokens[tokens.len() - keep..];
84 let decoded = bpe.decode(tail).unwrap_or_default();
85 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
86}
87
88const 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.
901. Get its output using status check.
912. Use `send_ascii` or `send_specials` to give inputs to the running program OR
923. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
934. Interrupt and run the process in background
94";
95
96#[derive(Debug, Clone)]
102pub struct ExitedShellInfo {
103 pub last_command: String,
104 pub final_output: String,
105 pub exited_at: Instant,
106}
107
108#[derive(Debug, Default)]
110pub struct BackgroundShellManager {
111 shells: HashMap<String, SharedPtyShell>,
112 tombstones: HashMap<String, ExitedShellInfo>,
115}
116
117impl BackgroundShellManager {
118 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
120
121 pub fn new() -> Self {
123 Self { shells: HashMap::new(), tombstones: HashMap::new() }
124 }
125
126 pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
128 let cid = format!("{:010x}", rand::rng().random::<u32>());
129
130 let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
131 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
132 })?;
133
134 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
135
136 info!("Started background shell with id: {}", cid);
137 Ok(cid)
138 }
139
140 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
142 self.shells.get(bg_command_id).cloned()
143 }
144
145 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
147 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
148 if let Ok(mut guard) = shell_arc.try_lock() {
149 *guard = None;
150 }
151 info!("Removed background shell: {}", bg_command_id);
152 true
153 } else {
154 false
155 }
156 }
157
158 fn prune_finished_shells(&mut self) {
159 let now = Instant::now();
161 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
162
163 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
164
165 for (id, shell_arc) in &self.shells {
166 let Ok(mut guard) = shell_arc.try_lock() else {
167 continue;
168 };
169
170 let Some(shell) = guard.as_mut() else {
171 finished.push((id.clone(), None));
172 continue;
173 };
174
175 if !shell.is_alive() {
176 let tombstone = ExitedShellInfo {
177 last_command: shell.last_command.clone(),
178 final_output: shell.output_buffer.clone(),
179 exited_at: now,
180 };
181 finished.push((id.clone(), Some(tombstone)));
182 continue;
183 }
184
185 if shell.last_command.is_empty() {
190 continue;
191 }
192
193 if shell.command_running {
194 let _ = shell.read_output(0.1);
195 }
196
197 if !shell.command_running {
198 let tombstone = ExitedShellInfo {
199 last_command: shell.last_command.clone(),
200 final_output: shell.output_buffer.clone(),
201 exited_at: now,
202 };
203 finished.push((id.clone(), Some(tombstone)));
204 }
205 }
206
207 for (id, tombstone) in finished {
208 self.remove_shell(&id);
209 if let Some(info) = tombstone {
210 self.tombstones.insert(id, info);
211 }
212 }
213 }
214
215 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
222 self.tombstones.get(bg_command_id).cloned()
223 }
224
225 pub fn get_running_info(&mut self) -> String {
227 self.prune_finished_shells();
228
229 if self.shells.is_empty() {
230 return "No command running in background.\n".to_string();
231 }
232
233 let mut running = Vec::new();
234 for (id, shell_arc) in &self.shells {
235 if let Ok(guard) = shell_arc.try_lock() {
236 if let Some(bash) = guard.as_ref() {
237 if bash.command_running {
238 running
239 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
240 }
241 }
242 } else {
243 running.push(format!("Command: <busy>, bg_command_id: {id}"));
244 }
245 }
246
247 if running.is_empty() {
248 "No command running in background.\n".to_string()
249 } else {
250 format!("Following background commands are attached:\n{}\n", running.join("\n"))
251 }
252 }
253}
254
255lazy_static::lazy_static! {
257 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
258}
259
260fn get_status(
264 bash_state: &BashState,
265 is_bg: bool,
266 bg_id: Option<&str>,
267 is_running: bool,
268 running_for: Option<&str>,
269) -> String {
270 let mut status = "\n\n---\n\n".to_string();
271
272 if is_bg {
273 if let Some(id) = bg_id {
274 let _ = writeln!(status, "bg_command_id = {id}");
275 }
276 }
277
278 if is_running {
279 status.push_str("status = still running\n");
280 if let Some(duration) = running_for {
281 let _ = writeln!(status, "running for = {duration}");
282 }
283 } else {
284 status.push_str("status = process exited\n");
285 }
286
287 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
288
289 if !is_bg {
290 if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
292 status.push_str("This is the main shell. ");
293 status.push_str(&manager.get_running_info());
294 }
295 }
296
297 status.trim_end().to_string()
298}
299
300fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
302 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
303 let text = truncated.as_ref();
304
305 if last_pending_output.is_empty() {
306 let rendered = render_terminal_output(text);
307 return rstrip_lines(&rendered).trim_start().to_string();
308 }
309
310 let last_rendered = render_terminal_output(last_pending_output);
311 if last_rendered.is_empty() {
312 return rstrip_lines(&render_terminal_output(text));
313 }
314
315 let text_after_last = if text.len() > last_pending_output.len() {
317 &text[last_pending_output.len()..]
318 } else {
319 text
320 };
321
322 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
323 let new_rendered = render_terminal_output(&combined);
324
325 let incremental = get_incremental_output(&last_rendered, &new_rendered);
327 rstrip_lines(&incremental)
328}
329
330fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
331 let stripped = strip_ansi_codes(output);
332 let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
333
334 prompt_regex
335 .captures_iter(&stripped)
336 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
337 .filter(|cwd| !cwd.is_empty())
338 .last()
339 .map(PathBuf::from)
340}
341
342fn rstrip_lines(lines: &[String]) -> String {
344 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
345}
346
347fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
349 if old_output.is_empty() {
350 return new_output.to_vec();
351 }
352
353 let nold = old_output.len();
354 let nnew = new_output.len();
355
356 for i in (0..nnew).rev() {
358 if new_output[i] != old_output[nold - 1] {
359 continue;
360 }
361
362 let mut matched = true;
363 for j in (0..i).rev() {
364 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
365 if old_idx < 0 {
366 break;
367 }
368 if new_output[j] != old_output[old_idx as usize] {
369 matched = false;
370 break;
371 }
372 }
373
374 if matched {
375 return new_output[i + 1..].to_vec();
376 }
377 }
378
379 new_output.to_vec()
380}
381
382fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
383 let mut start = 0;
384
385 while start < text.len() {
386 let mut end = (start + chunk_size).min(text.len());
387 while !text.is_char_boundary(end) {
388 end -= 1;
389 }
390 if end == start {
391 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
392 }
393
394 shell.send_text(&text[start..end]).map_err(|e| {
395 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
396 })?;
397 start = end;
398 }
399
400 Ok(())
401}
402
403#[allow(dead_code)]
405fn is_status_check_action(action: &BashCommandAction) -> bool {
406 match action {
407 BashCommandAction::StatusCheck { .. } => true,
408 BashCommandAction::SendSpecials { send_specials, .. } => {
409 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
410 }
411 BashCommandAction::SendAscii { send_ascii, .. } => {
412 send_ascii.len() == 1 && send_ascii[0] == 10 }
414 _ => false,
415 }
416}
417
418#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
425pub async fn handle_tool_call(
426 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
427 bash_command: BashCommand,
428) -> Result<String> {
429 info!("BashCommand tool called with: {:?}", bash_command);
430
431 let thread_id = normalize_thread_id(&bash_command.thread_id);
432
433 if thread_id.is_empty() {
435 error!("Empty thread_id provided in BashCommand");
436 return Err(WinxError::ThreadIdMismatch(
437 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
438 ));
439 }
440
441 let mut bash_state: BashState;
443 {
444 let bash_state_guard = bash_state_arc.lock().await;
445
446 let Some(state) = &*bash_state_guard else {
447 error!("BashState not initialized");
448 return Err(WinxError::BashStateNotInitialized);
449 };
450
451 bash_state = state.clone();
452 }
453
454 if thread_id != bash_state.current_thread_id {
456 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
458 return Err(WinxError::ThreadIdMismatch(format!(
459 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
460 )));
461 }
462 }
463
464 let timeout_s = bash_command
467 .wait_for_seconds
468 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
469 .min(TIMEOUT_WHILE_OUTPUT);
470
471 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
473
474 {
475 let mut bash_state_guard = bash_state_arc.lock().await;
476 if let Some(state) = bash_state_guard.as_mut() {
477 state.cwd.clone_from(&bash_state.cwd);
478 }
479 }
480
481 match result {
483 Ok(mut output) => {
484 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
485 let cmd_trimmed = command.trim();
486 if output.starts_with(cmd_trimmed) {
487 output = output[cmd_trimmed.len()..].to_string();
488 }
489 }
490 Ok(output)
491 }
492 Err(e) => Err(e),
493 }
494}
495
496async fn execute_bash_action(
498 bash_state: &mut BashState,
499 action: &BashCommandAction,
500 timeout_s: f64,
501) -> Result<String> {
502 let mut is_bg = false;
503 let mut bg_id: Option<String> = None;
504
505 let bg_shell: Option<SharedPtyShell> = match action {
507 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
509 | BashCommandAction::SendText { bg_command_id, .. }
510 | BashCommandAction::SendSpecials { bg_command_id, .. }
511 | BashCommandAction::SendAscii { bg_command_id, .. } => {
512 if let Some(id) = bg_command_id {
513 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
514 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
515 })?;
516 manager.prune_finished_shells();
517
518 if let Some(shell) = manager.get_shell(id) {
519 is_bg = true;
520 bg_id = Some(id.clone());
521 Some(shell)
522 } else if let Some(tombstone) = manager.peek_tombstone(id) {
523 drop(manager);
528 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
529 } else {
530 let error = format!(
532 "No shell found running with command id {}.\n{}",
533 id,
534 manager.get_running_info()
535 );
536 return Err(WinxError::CommandExecutionError(error));
537 }
538 } else {
539 None
540 }
541 }
542 };
543
544 match action {
546 BashCommandAction::Command { command, is_background, allow_multi } => {
547 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
548 }
549 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
550 execute_status_check(
551 bash_state,
552 bg_shell,
553 is_bg,
554 bg_id.as_deref(),
555 timeout_s,
556 *scrollback_lines,
557 *verbose,
558 )
559 .await
560 }
561 BashCommandAction::SendText { send_text, submit, .. } => {
562 execute_send_text(
563 bash_state,
564 send_text,
565 *submit,
566 bg_shell,
567 is_bg,
568 bg_id.as_deref(),
569 timeout_s,
570 )
571 .await
572 }
573 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
574 execute_send_specials(
575 bash_state,
576 send_specials,
577 *submit,
578 bg_shell,
579 is_bg,
580 bg_id.as_deref(),
581 timeout_s,
582 )
583 .await
584 }
585 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
586 execute_send_ascii(
587 bash_state,
588 send_ascii,
589 *submit,
590 bg_shell,
591 is_bg,
592 bg_id.as_deref(),
593 timeout_s,
594 )
595 .await
596 }
597 }
598}
599
600async fn execute_command(
602 bash_state: &mut BashState,
603 command: &str,
604 is_background: bool,
605 allow_multi: bool,
606 timeout_s: f64,
607) -> Result<String> {
608 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
609
610 if !bash_state.is_command_allowed(command) {
612 error!("Command '{}' not allowed in current mode", command);
613 return Err(WinxError::CommandNotAllowed(
614 "Error: BashCommand not allowed in current mode".to_string(),
615 ));
616 }
617
618 let command = command.trim();
622 if !allow_multi {
623 crate::utils::bash_parser::assert_single_statement(command)?;
624 }
625
626 if is_background {
628 return execute_in_background(bash_state, command, timeout_s).await;
629 }
630
631 {
633 let bash_guard = bash_state.pty_shell.lock().await;
634
635 if let Some(ref bash) = *bash_guard {
636 if bash.command_running {
637 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
638 }
639 }
640 }
641
642 if bash_state.pty_shell.lock().await.is_none() {
644 bash_state
645 .init_pty_shell()
646 .await
647 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
648 }
649
650 {
655 let mut bash_guard = bash_state.pty_shell.lock().await;
656 if let Some(bash) = bash_guard.as_mut() {
657 if let Err(e) = bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
658 warn!("clear_to_run failed before send: {e}");
659 }
660 }
661 }
662
663 {
665 let mut bash_guard = bash_state.pty_shell.lock().await;
666
667 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
668
669 bash.output_buffer.clear();
670 bash.output_truncated = false;
671 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
673
674 bash.send_special_key("Enter").map_err(|e| {
676 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
677 })?;
678
679 bash.last_command = command.to_string();
680 bash.command_running = true;
681 }
682
683 let shell_arc = bash_state.pty_shell.clone();
685 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
686}
687
688async fn wait_for_output(
692 bash_state: &mut BashState,
693 shell_arc: &SharedPtyShell,
694 timeout_s: f64,
695 is_bg: bool,
696 bg_id: Option<&str>,
697 is_status_check: bool,
698) -> Result<String> {
699 let start = Instant::now();
700 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
701 let mut last_pending_output = String::new();
702 let mut complete = false;
703
704 sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
706
707 let mut output = {
709 let mut bash_guard = shell_arc.lock().await;
710
711 if let Some(bash) = bash_guard.as_mut() {
712 let (out, done) = bash.read_output(0.5).map_err(|e| {
713 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
714 })?;
715 complete = done;
716 out
717 } else {
718 String::new()
719 }
720 };
721
722 if !complete && is_status_check {
731 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
732 let iter_wait_secs = 0.5_f64;
733 let mut patience = OUTPUT_WAIT_PATIENCE;
734
735 let incremental = wcgw_incremental_text(&output, &last_pending_output);
736 if incremental.is_empty() {
737 patience -= 1;
738 }
739
740 let mut last_incremental = incremental;
741
742 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
743 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
744 if remaining < 0.1 {
745 break;
746 }
747 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
748
749 let (new_output, done) = {
750 let mut bash_guard = shell_arc.lock().await;
751
752 if let Some(bash) = bash_guard.as_mut() {
753 bash.read_output(0.5).map_err(|e| {
754 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
755 })?
756 } else {
757 (String::new(), true)
758 }
759 };
760
761 if done {
762 complete = true;
763 output = new_output;
764 break;
765 }
766
767 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
769 if new_incremental == last_incremental {
770 patience -= 1;
771 } else {
772 patience = OUTPUT_WAIT_PATIENCE; }
774 last_incremental = new_incremental;
775
776 output = new_output;
777 }
778
779 if !complete {
780 last_pending_output = output.clone();
782 }
783 }
784
785 if complete {
786 if let Some(cwd) = extract_prompt_cwd(&output) {
787 bash_state.cwd = cwd;
788 }
789 }
790
791 let rendered = wcgw_incremental_text(&output, &last_pending_output);
793
794 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
796
797 let running_for = if complete {
799 None
800 } else {
801 Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
802 };
803
804 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
806 Ok(format!("{rendered}{status}"))
807}
808
809fn finalize_tombstone(
816 cwd: &Path,
817 id: &str,
818 tombstone: ExitedShellInfo,
819 action: &BashCommandAction,
820) -> Result<String> {
821 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
822 match action {
823 BashCommandAction::StatusCheck { .. } => {
824 let rendered = wcgw_incremental_text(&final_output, "");
825 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
826 let mut status = "\n\n---\n\n".to_string();
828 let _ = writeln!(status, "bg_command_id = {id}");
829 status.push_str("status = process exited\n");
830 let _ = writeln!(status, "cwd = {}", cwd.display());
831 Ok(format!("{rendered}{}", status.trim_end()))
832 }
833 BashCommandAction::SendText { .. }
834 | BashCommandAction::SendSpecials { .. }
835 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
836 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
837 ))),
838 BashCommandAction::Command { .. } => {
839 unreachable!("finalize_tombstone called for non-bg action")
842 }
843 }
844}
845
846async fn execute_status_check(
855 bash_state: &mut BashState,
856 bg_shell: Option<SharedPtyShell>,
857 is_bg: bool,
858 bg_id: Option<&str>,
859 timeout_s: f64,
860 scrollback_lines: Option<usize>,
861 verbose: bool,
862) -> Result<String> {
863 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
864
865 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
868
869 let is_running = {
871 let guard = shell_arc.lock().await;
872 if let Some(ref bash) = *guard {
873 bash.command_running
874 } else {
875 false
876 }
877 };
878
879 if !is_running && !is_bg {
881 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
882 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
883 })?;
884 let error =
885 format!("No running command to check status of.\n{}", manager.get_running_info());
886 return Err(WinxError::CommandExecutionError(error));
887 }
888
889 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
891
892 let body = response.split("\n\n---\n").next().unwrap_or(&response);
896 if !verbose && scrollback_lines.is_none() {
897 let mut guard = shell_arc.lock().await;
898 if let Some(bash) = guard.as_mut() {
899 let fingerprint = PtyShell::fingerprint(body);
900 if Some(fingerprint) == bash.last_returned_hash {
901 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
902 return Ok(format!("no new output since last check{status}"));
903 }
904 bash.last_returned_hash = Some(fingerprint);
905 }
906 } else if !verbose {
907 let mut guard = shell_arc.lock().await;
909 if let Some(bash) = guard.as_mut() {
910 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
911 }
912 }
913
914 if let Some(lines) = scrollback_lines {
916 if lines > 0 {
917 let scrollback = {
918 let guard = shell_arc.lock().await;
919 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
920 };
921 if !scrollback.is_empty() {
922 let count = scrollback.lines().count();
923 return Ok(format!(
924 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
925 ));
926 }
927 }
928 }
929
930 Ok(response)
931}
932
933async fn execute_send_text(
935 bash_state: &mut BashState,
936 text: &str,
937 submit: bool,
938 bg_shell: Option<SharedPtyShell>,
939 is_bg: bool,
940 bg_id: Option<&str>,
941 timeout_s: f64,
942) -> Result<String> {
943 debug!("Processing SendText action: {text:?} (submit={submit})");
944
945 if text.is_empty() {
947 return Err(WinxError::CommandExecutionError(
948 "Failure: send_text cannot be empty".to_string(),
949 ));
950 }
951
952 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
954
955 {
957 let mut guard = shell_arc.lock().await;
958
959 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
960
961 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
963
964 if submit {
968 bash.send_special_key("Enter").map_err(|e| {
969 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
970 })?;
971 }
972 }
973
974 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
976}
977
978async fn execute_send_specials(
980 bash_state: &mut BashState,
981 keys: &[SpecialKey],
982 submit: bool,
983 bg_shell: Option<SharedPtyShell>,
984 is_bg: bool,
985 bg_id: Option<&str>,
986 timeout_s: f64,
987) -> Result<String> {
988 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
989
990 if keys.is_empty() {
992 return Err(WinxError::CommandExecutionError(
993 "Failure: send_specials cannot be empty".to_string(),
994 ));
995 }
996
997 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
998 let mut is_interrupt = false;
999
1000 {
1001 let mut guard = shell_arc.lock().await;
1002
1003 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1004
1005 for key in keys {
1007 match key {
1008 SpecialKey::KeyUp => {
1009 bash.send_special_key("KeyUp").map_err(|e| {
1011 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1012 })?;
1013 }
1014 SpecialKey::KeyDown => {
1015 bash.send_special_key("KeyDown").map_err(|e| {
1017 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1018 })?;
1019 }
1020 SpecialKey::KeyLeft => {
1021 bash.send_special_key("KeyLeft").map_err(|e| {
1023 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1024 })?;
1025 }
1026 SpecialKey::KeyRight => {
1027 bash.send_special_key("KeyRight").map_err(|e| {
1029 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1030 })?;
1031 }
1032 SpecialKey::Enter => {
1033 bash.send_special_key("Enter").map_err(|e| {
1035 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1036 })?;
1037 }
1038 SpecialKey::CtrlC => {
1039 bash.send_interrupt().map_err(|e| {
1041 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1042 })?;
1043 is_interrupt = true;
1044 }
1045 SpecialKey::CtrlD => {
1046 bash.send_eof().map_err(|e| {
1048 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1049 })?;
1050 is_interrupt = true;
1051 }
1052 SpecialKey::CtrlZ => {
1053 bash.send_suspend().map_err(|e| {
1055 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1056 })?;
1057 }
1058 }
1059 }
1060 if submit {
1062 bash.send_special_key("Enter")
1063 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1064 }
1065 }
1066
1067 let mut output =
1075 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1076
1077 if is_interrupt && output.contains("status = still running") {
1079 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1080 }
1081
1082 Ok(output)
1083}
1084
1085async fn execute_send_ascii(
1087 bash_state: &mut BashState,
1088 ascii_codes: &[u8],
1089 submit: bool,
1090 bg_shell: Option<SharedPtyShell>,
1091 is_bg: bool,
1092 bg_id: Option<&str>,
1093 timeout_s: f64,
1094) -> Result<String> {
1095 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1096
1097 if ascii_codes.is_empty() {
1099 return Err(WinxError::CommandExecutionError(
1100 "Failure: send_ascii cannot be empty".to_string(),
1101 ));
1102 }
1103
1104 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1105 let mut is_interrupt = false;
1106
1107 {
1108 let mut guard = shell_arc.lock().await;
1109
1110 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1111
1112 for &code in ascii_codes {
1114 bash.send_bytes(&[code]).map_err(|e| {
1116 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1117 })?;
1118
1119 if code == 3 {
1121 is_interrupt = true;
1122 }
1123 }
1124 if submit {
1126 bash.send_special_key("Enter")
1127 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1128 }
1129 }
1130
1131 let mut output =
1137 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1138
1139 if is_interrupt && output.contains("status = still running") {
1141 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1142 }
1143
1144 Ok(output)
1145}
1146
1147async fn execute_in_background(
1149 bash_state: &mut BashState,
1150 command: &str,
1151 timeout_s: f64,
1152) -> Result<String> {
1153 debug!("Executing command in background: {}", command);
1154
1155 let restricted_mode =
1157 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1158
1159 let bg_id = {
1160 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1161 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1162 })?;
1163 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1164 };
1165
1166 let shell_arc = {
1168 let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1169 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1170 })?;
1171 manager.get_shell(&bg_id).ok_or_else(|| {
1172 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1173 })?
1174 };
1175
1176 {
1178 let mut guard = shell_arc.lock().await;
1179 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1180 bash.send_command(command).map_err(|e| {
1181 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1182 })?;
1183 }
1184 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1185
1186 let _ = timeout_s;
1187 let _ = shell_arc;
1188 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1189}
1190
1191#[allow(dead_code)]
1195#[tracing::instrument(level = "debug", skip(command, cwd))]
1196async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1197 debug!("Executing command: {}", command);
1198
1199 let start_time = Instant::now();
1200 let mut cmd = Command::new("sh");
1201 cmd.arg("-c")
1202 .arg(command)
1203 .current_dir(cwd)
1204 .stdin(Stdio::null())
1205 .stdout(Stdio::piped())
1206 .stderr(Stdio::piped());
1207
1208 let output = cmd.output().context("Failed to execute command")?;
1209 let elapsed = start_time.elapsed();
1210
1211 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1212 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1213
1214 let raw_result = format!("{stdout}{stderr}");
1215 let mut result = raw_result.clone();
1216 if !raw_result.is_empty() {
1217 let rendered_lines = render_terminal_output(&raw_result);
1218 if rendered_lines.is_empty() {
1219 result = strip_ansi_codes(&raw_result);
1221 } else {
1222 result = rendered_lines.join("\n");
1223 }
1224 }
1225
1226 result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
1227
1228 let exit_status = if output.status.success() {
1229 "Command completed successfully".to_string()
1230 } else {
1231 format!("Command failed with status: {}", output.status)
1232 };
1233
1234 let current_dir = std::env::current_dir()
1235 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1236
1237 debug!("Command executed in {:.2?}", elapsed);
1238 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1239}
1240
1241#[allow(dead_code)]
1243#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1244async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1245 debug!("Executing command in screen session '{}': {}", screen_name, command);
1246
1247 let screen_check = Command::new("which")
1248 .arg("screen")
1249 .output()
1250 .context("Failed to check for screen command")?;
1251
1252 if !screen_check.status.success() {
1253 warn!("Screen command not found, falling back to direct execution");
1254 return execute_simple_command(command, cwd).await;
1255 }
1256
1257 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1258
1259 let screen_cmd = format!(
1260 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1261 screen_name,
1262 command.replace('\'', "'\\''")
1263 );
1264
1265 let screen_start = Command::new("sh")
1266 .arg("-c")
1267 .arg(&screen_cmd)
1268 .current_dir(cwd)
1269 .output()
1270 .context("Failed to start screen session")?;
1271
1272 if !screen_start.status.success() {
1273 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1274 error!("Failed to start screen session: {}", stderr);
1275 return Err(WinxError::CommandExecutionError(format!(
1276 "Failed to start screen session: {stderr}"
1277 )));
1278 }
1279
1280 sleep(Duration::from_millis(300)).await;
1281
1282 let screen_check =
1283 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1284
1285 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1286
1287 let current_dir = std::env::current_dir()
1288 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1289
1290 Ok(format!(
1291 "Started command in background screen session '{screen_name}'.\n\
1292 Use status_check to get output.\n\n\
1293 Screen sessions:\n{screen_list}\n\
1294 ---\n\n\
1295 status = running in background\n\
1296 cwd = {current_dir}\n"
1297 ))
1298}
1299
1300#[allow(dead_code)]
1302fn special_key_to_screen_input(key: SpecialKey) -> String {
1303 match key {
1304 SpecialKey::Enter => String::from("\r"),
1305 SpecialKey::KeyUp => String::from("\x1b[A"),
1306 SpecialKey::KeyDown => String::from("\x1b[B"),
1307 SpecialKey::KeyLeft => String::from("\x1b[D"),
1308 SpecialKey::KeyRight => String::from("\x1b[C"),
1309 SpecialKey::CtrlC => String::from("\x03"),
1310 SpecialKey::CtrlD => String::from("\x04"),
1311 SpecialKey::CtrlZ => String::from("\x1a"),
1312 }
1313}