1use anyhow::Context as AnyhowContext;
8use regex::Regex;
9use std::fmt::Write as FmtWrite;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13use tokio::sync::Mutex;
14use tokio::time::sleep;
15use tracing::{debug, error, info, warn};
16
17pub use super::background_shell::{BackgroundShellManager, ExitedShellInfo};
18use crate::errors::{Result, WinxError};
19use crate::runtime::{lock_session_store, EmbeddedShellRuntime, ShellRuntime, ShellTarget};
20use crate::state::bash_state::BashState;
21use crate::state::live_terminal::ScreenUpdate;
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
28#[derive(Debug, Default)]
31pub(crate) struct ShellDeliveryCursor {
32 generation: Option<u64>,
33 delivered_output: String,
34 last_returned_hash: Option<u64>,
35 screen_snapshot: Option<Vec<String>>,
36}
37
38impl ShellDeliveryCursor {
39 fn sync_generation(&mut self, generation: u64) {
40 if self.generation != Some(generation) {
41 self.generation = Some(generation);
42 self.delivered_output.clear();
43 self.last_returned_hash = None;
44 self.screen_snapshot = None;
45 }
46 }
47
48 fn screen_update(&mut self, current: Vec<String>, threshold: usize) -> ScreenUpdate {
49 let update = match self.screen_snapshot.as_ref() {
50 Some(previous) => {
51 let rows = previous.len().max(current.len());
52 let changed = (0..rows)
53 .filter_map(|index| {
54 let before = previous.get(index).map_or("", String::as_str);
55 let after = current.get(index).map_or("", String::as_str);
56 (before != after).then(|| (index + 1, after.to_string()))
57 })
58 .collect::<Vec<_>>();
59 if changed.is_empty() {
60 ScreenUpdate::Unchanged
61 } else if changed.len() <= threshold {
62 ScreenUpdate::Diff(changed)
63 } else {
64 ScreenUpdate::Full(current.clone())
65 }
66 }
67 None => ScreenUpdate::Full(current.clone()),
68 };
69 self.screen_snapshot = Some(current);
70 update
71 }
72}
73
74fn main_shell(bash_state: &BashState) -> SharedPtyShell {
75 let mut store = lock_session_store();
76 store.bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
77 store
78 .resolve(&bash_state.current_thread_id, &ShellTarget::Main)
79 .unwrap_or_else(|| bash_state.pty_shell.clone())
80}
81
82const DEFAULT_TIMEOUT: f64 = 5.0;
86
87const OUTPUT_WAIT_PATIENCE: i32 = 3;
89
90fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
91 wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
92}
93
94const POLL_INTERVAL_MS: u64 = 20;
98
99const POST_PROMPT_DRAIN_MS: u64 = 100;
102
103const COMMAND_CHUNK_SIZE: usize = 64;
105
106const TEXT_CHUNK_SIZE: usize = 128;
108
109const MAX_OUTPUT_LENGTH: usize = 100_000;
111
112const MAX_OUTPUT_TOKENS: usize = 25_000;
117
118const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
125
126const SCREEN_DIFF_THRESHOLD: usize = 10;
129
130fn char_safe_tail(text: &str, max_len: usize) -> &str {
133 if text.len() <= max_len {
134 return text;
135 }
136 let mut start = text.len() - max_len;
137 while start < text.len() && !text.is_char_boundary(start) {
138 start += 1;
139 }
140 &text[start..]
141}
142
143fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
151 if crate::utils::encoder::definitely_fits_token_budget(text, max_tokens) {
152 return std::borrow::Cow::Borrowed(text);
153 }
154
155 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
156 return std::borrow::Cow::Owned(format!(
158 "(...truncated)\n{}",
159 char_safe_tail(text, MAX_OUTPUT_LENGTH)
160 ));
161 };
162
163 if tokens.len() <= max_tokens {
164 return std::borrow::Cow::Borrowed(text);
165 }
166
167 let keep = max_tokens.saturating_sub(1);
169 let tail = &tokens[tokens.len() - keep..];
170 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
171 char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
173 });
174 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
175}
176
177const 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.
1791. Get its output using status check.
1802. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1813. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1824. Interrupt and run the process in background
183";
184
185fn spawn_background_reaper(owner_thread_id: String, bg_command_id: String) {
190 tokio::spawn(async move {
191 loop {
192 sleep(Duration::from_millis(100)).await;
193 let shell_arc = {
194 let manager = lock_session_store();
195 manager.get_shell(&owner_thread_id, &bg_command_id)
196 };
197 let Some(shell_arc) = shell_arc else {
198 return;
199 };
200
201 let finished = {
202 let mut guard = shell_arc.lock().await;
203 match guard.as_mut() {
204 Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
205 None => true,
206 }
207 };
208 if finished {
209 drop(shell_arc);
213 let removed = {
214 let mut manager = lock_session_store();
215 manager.prune_finished_shells();
216 manager.get_shell(&owner_thread_id, &bg_command_id).is_none()
217 };
218 if removed {
219 return;
220 }
221 }
222 }
223 });
224}
225
226fn get_status(
230 bash_state: &BashState,
231 is_bg: bool,
232 bg_id: Option<&str>,
233 is_running: bool,
234 running_for: Option<&str>,
235 exit_code: Option<i32>,
236 reported_cwd: Option<&Path>,
237) -> String {
238 let mut status = "\n\n---\n\n".to_string();
239
240 if is_bg {
241 if let Some(id) = bg_id {
242 let _ = writeln!(status, "bg_command_id = {id}");
243 }
244 }
245
246 if is_running {
247 status.push_str("status = still running\n");
248 if let Some(duration) = running_for {
249 let _ = writeln!(status, "running for = {duration}");
250 }
251 } else {
252 status.push_str("status = process exited\n");
253 if let Some(code) = exit_code {
256 let _ = writeln!(status, "exit code = {code}");
257 }
258 }
259
260 let cwd = reported_cwd.unwrap_or(&bash_state.cwd);
261 let _ = writeln!(status, "cwd = {}", cwd.display());
262
263 if !is_bg {
264 {
266 let mut manager = lock_session_store();
267 status.push_str("This is the main shell. ");
268 status.push_str(&manager.get_running_info(&bash_state.current_thread_id));
269 }
270 }
271
272 status.trim_end().to_string()
273}
274
275fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
277 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
278 let text = truncated.as_ref();
279
280 if last_pending_output.is_empty() {
281 let rendered = render_terminal_output(text);
282 return rstrip_lines(&rendered).trim_start().to_string();
283 }
284
285 let last_rendered = render_terminal_output(last_pending_output);
286 if last_rendered.is_empty() {
287 return rstrip_lines(&render_terminal_output(text));
288 }
289
290 let text_after_last = if text.len() > last_pending_output.len() {
294 let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
295 &text[cut..]
296 } else {
297 text
298 };
299
300 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
301 let new_rendered = render_terminal_output(&combined);
302
303 let incremental = get_incremental_output(&last_rendered, &new_rendered);
305 rstrip_lines(&incremental)
306}
307
308fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
309 static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
310 #[allow(clippy::expect_used)]
315 let prompt_regex = PROMPT_RE
316 .get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
317 let stripped = strip_ansi_codes(output);
318
319 prompt_regex
320 .captures_iter(&stripped)
321 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
322 .filter(|cwd| !cwd.is_empty())
323 .last()
324 .map(PathBuf::from)
325}
326
327fn rstrip_lines(lines: &[String]) -> String {
329 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
330}
331
332fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
334 if old_output.is_empty() {
335 return new_output.to_vec();
336 }
337
338 let nold = old_output.len();
339 let nnew = new_output.len();
340
341 for i in (0..nnew).rev() {
343 if new_output[i] != old_output[nold - 1] {
344 continue;
345 }
346
347 let mut matched = true;
348 for j in (0..i).rev() {
349 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
350 if old_idx < 0 {
351 break;
352 }
353 if new_output[j] != old_output[old_idx as usize] {
354 matched = false;
355 break;
356 }
357 }
358
359 if matched {
360 return new_output[i + 1..].to_vec();
361 }
362 }
363
364 new_output.to_vec()
365}
366
367fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
368 let mut start = 0;
369
370 while start < text.len() {
371 let mut end = (start + chunk_size).min(text.len());
372 while !text.is_char_boundary(end) {
373 end -= 1;
374 }
375 if end == start {
376 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
377 }
378
379 shell.send_text(&text[start..end]).map_err(|e| {
380 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
381 })?;
382 start = end;
383 }
384
385 Ok(())
386}
387
388async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
393 tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
394 let mut guard = shell_arc.lock().await;
395 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
396 ensure_interactive_target(bash)?;
397 bash.send_special_key("Enter")
398 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
399 Ok(())
400}
401
402fn ensure_interactive_target(shell: &mut PtyShell) -> Result<()> {
407 shell.poll_output_nonblocking();
408 if shell.command_running {
409 Ok(())
410 } else {
411 Err(WinxError::CommandExecutionError(
412 "No interactive command is running. Start an allowed command first; send_text/send_ascii/send_specials cannot execute commands in an idle shell."
413 .to_string(),
414 ))
415 }
416}
417
418pub async fn handle_tool_call(
425 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
426 bash_command: BashCommand,
427) -> Result<String> {
428 handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state_arc, bash_command).await
429}
430
431pub async fn handle_tool_call_with_runtime(
433 runtime: &dyn ShellRuntime,
434 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
435 bash_command: BashCommand,
436) -> Result<String> {
437 runtime.run_action(bash_state_arc, bash_command).await
438}
439
440pub(crate) async fn handle_embedded_tool_call(
441 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
442 bash_command: BashCommand,
443) -> Result<String> {
444 handle_embedded_tool_call_inner(bash_state_arc, bash_command, None).await
445}
446
447pub(crate) async fn handle_embedded_tool_call_with_cursor(
448 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
449 bash_command: BashCommand,
450 delivery_cursor: &Arc<Mutex<ShellDeliveryCursor>>,
451) -> Result<String> {
452 let mut delivery_cursor = delivery_cursor.lock().await;
453 handle_embedded_tool_call_inner(bash_state_arc, bash_command, Some(&mut delivery_cursor)).await
454}
455
456#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command, delivery_cursor))]
457async fn handle_embedded_tool_call_inner(
458 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
459 bash_command: BashCommand,
460 delivery_cursor: Option<&mut ShellDeliveryCursor>,
461) -> Result<String> {
462 let action_kind = match &bash_command.action_json {
463 BashCommandAction::Command { .. } => "command",
464 BashCommandAction::StatusCheck { .. } => "status_check",
465 BashCommandAction::SendText { .. } => "send_text",
466 BashCommandAction::SendSpecials { .. } => "send_specials",
467 BashCommandAction::SendAscii { .. } => "send_ascii",
468 BashCommandAction::Screen { .. } => "screen",
469 BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
470 };
471 info!(thread_id = %bash_command.thread_id, action = action_kind, "BashCommand tool called");
472
473 let thread_id = normalize_thread_id(&bash_command.thread_id);
474
475 if thread_id.is_empty() {
477 error!("Empty thread_id provided in BashCommand");
478 return Err(WinxError::ThreadIdMismatch(
479 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
480 ));
481 }
482
483 let mut bash_state: BashState;
485 {
486 let bash_state_guard = bash_state_arc.lock().await;
487
488 let Some(state) = &*bash_state_guard else {
489 error!("BashState not initialized");
490 return Err(WinxError::BashStateNotInitialized);
491 };
492
493 bash_state = state.clone();
494 }
495
496 if thread_id != bash_state.current_thread_id {
498 let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
503 WinxError::CommandExecutionError(format!(
504 "Failed to load saved bash state for thread_id `{thread_id}`: {e}"
505 ))
506 })?;
507 if !loaded {
508 return Err(WinxError::ThreadIdMismatch(format!(
509 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
510 )));
511 }
512 if let Some(state) = bash_state_arc.lock().await.as_mut() {
518 *state = bash_state.clone();
519 }
520 }
521
522 lock_session_store().bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
523
524 let timeout_s = effective_wait_for_seconds(bash_command.wait_for_seconds);
526
527 let result =
529 execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s, delivery_cursor)
530 .await;
531
532 {
533 let mut bash_state_guard = bash_state_arc.lock().await;
534 if let Some(state) = bash_state_guard.as_mut() {
535 state.cwd.clone_from(&bash_state.cwd);
536 }
537 }
538
539 match result {
541 Ok(mut output) => {
542 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
543 let cmd_trimmed = command.trim();
544 if output.starts_with(cmd_trimmed) {
545 output = output[cmd_trimmed.len()..].to_string();
546 }
547 }
548 Ok(output)
549 }
550 Err(e) => Err(e),
551 }
552}
553
554#[allow(clippy::too_many_lines)]
556async fn execute_bash_action(
557 bash_state: &mut BashState,
558 action: &BashCommandAction,
559 timeout_s: f64,
560 delivery_cursor: Option<&mut ShellDeliveryCursor>,
561) -> Result<String> {
562 let mut is_bg = false;
563 let mut bg_id: Option<String> = None;
564
565 let bg_shell: Option<SharedPtyShell> = match action {
567 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
569 | BashCommandAction::SendText { bg_command_id, .. }
570 | BashCommandAction::SendSpecials { bg_command_id, .. }
571 | BashCommandAction::SendAscii { bg_command_id, .. }
572 | BashCommandAction::Screen { bg_command_id, .. }
573 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
574 if let Some(id) = bg_command_id {
575 let mut manager = lock_session_store();
578 manager.prune_finished_shells();
579
580 if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
581 is_bg = true;
582 bg_id = Some(id.clone());
583 Some(shell)
584 } else if let Some(tombstone) =
585 manager.peek_tombstone(&bash_state.current_thread_id, id)
586 {
587 drop(manager);
592 return finalize_tombstone(id, tombstone, action);
593 } else {
594 let error = format!(
596 "No shell found running with command id {}.\n{}",
597 id,
598 manager.get_running_info(&bash_state.current_thread_id)
599 );
600 return Err(WinxError::CommandExecutionError(error));
601 }
602 } else {
603 None
604 }
605 }
606 };
607
608 match action {
610 BashCommandAction::Command { command, is_background, allow_multi } => {
611 execute_command(
612 bash_state,
613 command,
614 *is_background,
615 *allow_multi,
616 timeout_s,
617 delivery_cursor,
618 )
619 .await
620 }
621 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
622 execute_status_check(
623 bash_state,
624 bg_shell,
625 is_bg,
626 bg_id.as_deref(),
627 timeout_s,
628 *scrollback_lines,
629 *verbose,
630 delivery_cursor,
631 )
632 .await
633 }
634 BashCommandAction::SendText { send_text, submit, .. } => {
635 execute_send_text(
636 bash_state,
637 send_text,
638 *submit,
639 bg_shell,
640 is_bg,
641 bg_id.as_deref(),
642 timeout_s,
643 delivery_cursor,
644 )
645 .await
646 }
647 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
648 execute_send_specials(
649 bash_state,
650 send_specials,
651 *submit,
652 bg_shell,
653 is_bg,
654 bg_id.as_deref(),
655 timeout_s,
656 delivery_cursor,
657 )
658 .await
659 }
660 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
661 execute_send_ascii(
662 bash_state,
663 send_ascii,
664 *submit,
665 bg_shell,
666 is_bg,
667 bg_id.as_deref(),
668 timeout_s,
669 delivery_cursor,
670 )
671 .await
672 }
673 BashCommandAction::Screen { lines, diff, .. } => {
674 execute_screen(
675 bash_state,
676 bg_shell,
677 is_bg,
678 bg_id.as_deref(),
679 *lines,
680 *diff,
681 delivery_cursor,
682 )
683 .await
684 }
685 BashCommandAction::WaitForTurn {
686 recognizer,
687 quiet_ms,
688 timeout_seconds,
689 lines,
690 wait_through_busy,
691 ..
692 } => {
693 execute_wait_for_turn(
694 bash_state,
695 bg_shell,
696 is_bg,
697 bg_id.as_deref(),
698 recognizer.as_deref(),
699 *quiet_ms,
700 *timeout_seconds,
701 *lines,
702 *wait_through_busy,
703 )
704 .await
705 }
706 }
707}
708
709fn strip_tail_pipe(command: &str) -> String {
719 strip_tail_pipe_impl(command, keep_tail_pipe())
720}
721
722fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
725 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
726 if keep {
727 return command.to_string();
728 }
729 #[allow(clippy::expect_used)]
733 let re = RE.get_or_init(|| {
734 regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
735 .expect("tail-pipe regex must compile")
736 });
737 match re.find(command) {
738 Some(matched) => command[..matched.start()].trim_end().to_string(),
739 None => command.to_string(),
740 }
741}
742
743fn keep_tail_pipe() -> bool {
745 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
746 let value = value.trim();
747 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
748 })
749}
750
751async fn execute_command(
753 bash_state: &mut BashState,
754 command: &str,
755 is_background: bool,
756 allow_multi: bool,
757 timeout_s: f64,
758 delivery_cursor: Option<&mut ShellDeliveryCursor>,
759) -> Result<String> {
760 let stripped_command = strip_tail_pipe(command);
762 let command = stripped_command.as_str();
763 debug!(bytes = command.len(), allow_multi, "Processing Command action");
764
765 if !bash_state.is_command_allowed(command) {
767 error!(bytes = command.len(), "Command not allowed in current mode");
768 return Err(WinxError::CommandNotAllowed(
769 "Error: BashCommand not allowed in current mode".to_string(),
770 ));
771 }
772
773 let command = command.trim();
777 if !allow_multi {
778 let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
782 crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
783 }
784
785 if is_background {
787 return execute_in_background(bash_state, command, timeout_s, delivery_cursor).await;
788 }
789
790 let foreground_gate = bash_state.foreground_command_gate.clone();
795 let _foreground_guard = foreground_gate.lock_owned().await;
796 let shell_arc = main_shell(bash_state);
797
798 {
800 let bash_guard = shell_arc.lock().await;
801
802 if let Some(ref bash) = *bash_guard {
803 if bash.command_running {
804 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
805 }
806 }
807 }
808
809 if shell_arc.lock().await.is_none() {
811 bash_state
812 .init_pty_shell()
813 .await
814 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
815 }
816
817 {
822 let needs_reset = {
823 if shell_arc.lock().await.is_some() {
827 if clear_to_run_async(&shell_arc, DEFAULT_TIMEOUT).await {
828 false
829 } else {
830 warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
831 true
832 }
833 } else {
834 false
835 }
836 };
837 if needs_reset {
841 if let Err(e) = bash_state.init_pty_shell().await {
842 warn!("Failed to reset shell after clear_to_run: {e}");
843 }
844 }
845 }
846
847 let scratch_root = bash_state.workspace_root.clone();
849 {
850 let mut bash_guard = shell_arc.lock().await;
851
852 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
853
854 bash.output_buffer.clear();
855 bash.output_truncated = false;
856 bash.reset_scratch();
859 bash.set_scratch_root(&scratch_root);
860 bash.last_exit_code = None;
865 bash.last_returned_hash = None;
866 bash.mark_output_delivered("");
867 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
869
870 bash.send_special_key("Enter").map_err(|e| {
872 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
873 })?;
874
875 bash.last_command = command.to_string();
876 bash.command_running = true;
877 bash.mark_command_started();
878 }
879
880 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false, delivery_cursor).await
882}
883
884async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
891 let mut guard = shell_arc.lock().await;
892 match guard.as_mut() {
893 Some(bash) => bash.poll_output_nonblocking(),
894 None => true,
895 }
896}
897
898async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
900 let mut guard = shell_arc.lock().await;
901 guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
902}
903
904async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
908 let start = Instant::now();
909 loop {
910 if poll_shell(shell_arc).await {
911 return true;
912 }
913 if start.elapsed().as_secs_f64() >= budget_secs {
914 return false;
915 }
916 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
917 }
918}
919
920async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
927 if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
929 return true;
930 }
931 {
933 let mut guard = shell_arc.lock().await;
934 if let Some(bash) = guard.as_mut() {
935 if let Err(e) = bash.send_interrupt() {
936 warn!("clear_to_run: failed to send Ctrl-C: {e}");
937 }
938 }
939 }
940 drain_until_prompt(shell_arc, max_wait_secs).await
941}
942
943async fn wait_for_output(
945 bash_state: &mut BashState,
946 shell_arc: &SharedPtyShell,
947 timeout_s: f64,
948 is_bg: bool,
949 bg_id: Option<&str>,
950 is_status_check: bool,
951 mut delivery_cursor: Option<&mut ShellDeliveryCursor>,
952) -> Result<String> {
953 let start = Instant::now();
954 let wait = timeout_s;
955 let (generation, legacy_delivered) = {
956 let guard = shell_arc.lock().await;
957 guard.as_ref().map_or((0, String::new()), |shell| {
958 (shell.command_generation(), shell.delivered_output())
959 })
960 };
961 let mut previously_delivered = match delivery_cursor.as_deref_mut() {
962 Some(cursor) => {
963 cursor.sync_generation(generation);
964 cursor.delivered_output.clone()
965 }
966 None => legacy_delivered,
967 };
968 let mut complete = false;
969
970 let mut output = String::new();
982 loop {
983 if start.elapsed().as_secs_f64() >= wait {
984 break;
985 }
986 complete = poll_shell(shell_arc).await;
987 if complete {
988 break;
989 }
990 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
991 }
992 if complete {
995 sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
996 poll_shell(shell_arc).await;
997 }
998 output = snapshot_shell(shell_arc).await;
999
1000 if let Some(cursor) = delivery_cursor.as_deref_mut() {
1001 let generation = {
1002 let guard = shell_arc.lock().await;
1003 guard.as_ref().map_or(0, PtyShell::command_generation)
1004 };
1005 if cursor.generation != Some(generation) {
1006 cursor.sync_generation(generation);
1007 previously_delivered.clear();
1008 }
1009 }
1010
1011 if !complete && is_status_check {
1017 let budget_secs = timeout_s;
1018 let iter_wait_secs = 0.5_f64;
1019 let mut patience = OUTPUT_WAIT_PATIENCE;
1020
1021 let incremental = wcgw_incremental_text(&output, &previously_delivered);
1022 if incremental.is_empty() {
1023 patience -= 1;
1024 }
1025
1026 let mut last_incremental = incremental;
1027
1028 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
1029 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
1030 if remaining < 0.1 {
1031 break;
1032 }
1033 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
1034
1035 let done = poll_shell(shell_arc).await;
1039 let new_output = snapshot_shell(shell_arc).await;
1040
1041 if done {
1042 complete = true;
1043 output = new_output;
1044 break;
1045 }
1046
1047 let new_incremental = wcgw_incremental_text(&new_output, &previously_delivered);
1049 if new_incremental == last_incremental {
1050 patience -= 1;
1051 } else {
1052 patience = OUTPUT_WAIT_PATIENCE; }
1054 last_incremental = new_incremental;
1055
1056 output = new_output;
1057 }
1058 }
1059
1060 if complete && !is_bg {
1061 if let Some(cwd) = extract_prompt_cwd(&output) {
1062 bash_state.cwd = cwd;
1063 }
1064 }
1065
1066 let rendered = wcgw_incremental_text(&output, &previously_delivered);
1068 if let Some(cursor) = delivery_cursor.as_deref_mut() {
1072 cursor.delivered_output.clone_from(&output);
1073 } else {
1074 let mut guard = shell_arc.lock().await;
1075 if let Some(shell) = guard.as_mut() {
1076 shell.mark_output_delivered(&output);
1077 }
1078 }
1079
1080 let rendered = {
1084 let before_lines = rendered.lines().count();
1085 match crate::utils::output_compress::compress_output(&rendered) {
1086 Some(compressed) => {
1087 debug!(
1088 from_lines = before_lines,
1089 to_lines = compressed.lines().count(),
1090 "winx collapsed mechanical repetition in shell output"
1091 );
1092 compressed
1093 }
1094 None => rendered,
1095 }
1096 };
1097
1098 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1100
1101 let (running_for, exit_code, shell_cwd, scratch_pointer) =
1103 read_status_extras(shell_arc, complete).await;
1104 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1105
1106 let status = get_status(
1108 bash_state,
1109 is_bg,
1110 bg_id,
1111 !complete,
1112 running_for.as_deref(),
1113 exit_code,
1114 shell_cwd.as_deref(),
1115 );
1116 Ok(format!("{rendered}{status}{scratch_pointer}"))
1117}
1118
1119async fn read_status_extras(
1121 shell_arc: &SharedPtyShell,
1122 complete: bool,
1123) -> (Option<Duration>, Option<i32>, Option<PathBuf>, String) {
1124 let guard = shell_arc.lock().await;
1125 let Some(shell) = guard.as_ref() else {
1126 return (None, None, None, String::new());
1127 };
1128 let running_for = if complete { None } else { shell.command_elapsed() };
1129 let exit_code = if complete { shell.last_exit_code } else { None };
1130 let cwd = Some(shell.current_cwd().to_path_buf());
1131 let pointer = scratch_pointer(shell.output_truncated, shell.scratch_path());
1132 (running_for, exit_code, cwd, pointer)
1133}
1134
1135fn scratch_pointer(output_truncated: bool, scratch_path: Option<&Path>) -> String {
1136 match (output_truncated, scratch_path) {
1137 (true, Some(path)) => format!(
1138 "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1139 saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1140 path.display()
1141 ),
1142 _ => String::new(),
1143 }
1144}
1145
1146fn finalize_tombstone(
1153 id: &str,
1154 tombstone: ExitedShellInfo,
1155 action: &BashCommandAction,
1156) -> Result<String> {
1157 let ExitedShellInfo {
1158 last_command,
1159 final_output,
1160 exit_code,
1161 cwd,
1162 output_truncated,
1163 scratch_path,
1164 ..
1165 } = tombstone;
1166 match action {
1167 BashCommandAction::StatusCheck { .. }
1170 | BashCommandAction::Screen { .. }
1171 | BashCommandAction::WaitForTurn { .. } => {
1172 let rendered = wcgw_incremental_text(final_output.as_ref(), "");
1173 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1174 let mut status = "\n\n---\n\n".to_string();
1176 let _ = writeln!(status, "bg_command_id = {id}");
1177 status.push_str("status = process exited\n");
1178 if let Some(code) = exit_code {
1179 let _ = writeln!(status, "exit code = {code}");
1180 }
1181 let _ = writeln!(status, "cwd = {}", cwd.display());
1182 let pointer = scratch_pointer(output_truncated, scratch_path.as_deref());
1183 Ok(format!("{rendered}{}{pointer}", status.trim_end()))
1184 }
1185 BashCommandAction::SendText { .. }
1186 | BashCommandAction::SendSpecials { .. }
1187 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1188 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1189 ))),
1190 BashCommandAction::Command { .. } => {
1191 unreachable!("finalize_tombstone called for non-bg action")
1194 }
1195 }
1196}
1197
1198async fn execute_status_check(
1207 bash_state: &mut BashState,
1208 bg_shell: Option<SharedPtyShell>,
1209 is_bg: bool,
1210 bg_id: Option<&str>,
1211 timeout_s: f64,
1212 scrollback_lines: Option<usize>,
1213 verbose: bool,
1214 mut delivery_cursor: Option<&mut ShellDeliveryCursor>,
1215) -> Result<String> {
1216 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1217
1218 let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
1221
1222 let is_running = {
1224 let guard = shell_arc.lock().await;
1225 if let Some(ref bash) = *guard {
1226 bash.command_running
1227 } else {
1228 false
1229 }
1230 };
1231
1232 if !is_running && !is_bg {
1234 let mut manager = lock_session_store();
1235 let error = format!(
1236 "No command is currently running, so there's nothing to check. The previous \
1237 command already finished and its output was returned when it completed. Start a \
1238 new command, or pass a bg_command_id if you launched one in the background.\n{}",
1239 manager.get_running_info(&bash_state.current_thread_id)
1240 );
1241 return Err(WinxError::CommandExecutionError(error));
1242 }
1243
1244 let response = wait_for_output(
1246 bash_state,
1247 &shell_arc,
1248 timeout_s,
1249 is_bg,
1250 bg_id,
1251 true,
1252 delivery_cursor.as_deref_mut(),
1253 )
1254 .await?;
1255
1256 if !verbose && scrollback_lines.is_none() {
1260 let (fingerprint, running_for, running, exit_code, cwd) = {
1261 let guard = shell_arc.lock().await;
1262 let Some(bash) = guard.as_ref() else {
1263 return Err(WinxError::BashStateNotInitialized);
1264 };
1265 (
1266 PtyShell::fingerprint(&bash.output_snapshot()),
1267 bash.command_elapsed().map(|elapsed| format!("{} seconds", elapsed.as_secs())),
1268 bash.command_running,
1269 (!bash.command_running).then_some(bash.last_exit_code).flatten(),
1270 bash.current_cwd().to_path_buf(),
1271 )
1272 };
1273 let previous_hash = match delivery_cursor.as_deref_mut() {
1274 Some(cursor) => cursor.last_returned_hash.replace(fingerprint),
1275 None => {
1276 let mut guard = shell_arc.lock().await;
1277 guard.as_mut().and_then(|bash| bash.last_returned_hash.replace(fingerprint))
1278 }
1279 };
1280 if previous_hash == Some(fingerprint) {
1281 let status = get_status(
1282 bash_state,
1283 is_bg,
1284 bg_id,
1285 running,
1286 running_for.as_deref(),
1287 exit_code,
1288 Some(&cwd),
1289 );
1290 return Ok(format!("no new output since last check{status}"));
1291 }
1292 } else if !verbose {
1293 let fingerprint = {
1295 let guard = shell_arc.lock().await;
1296 guard.as_ref().map(|bash| PtyShell::fingerprint(&bash.output_snapshot()))
1297 };
1298 if let Some(fingerprint) = fingerprint {
1299 if let Some(cursor) = delivery_cursor.as_deref_mut() {
1300 cursor.last_returned_hash = Some(fingerprint);
1301 } else {
1302 let mut guard = shell_arc.lock().await;
1303 if let Some(bash) = guard.as_mut() {
1304 bash.last_returned_hash = Some(fingerprint);
1305 }
1306 }
1307 }
1308 }
1309
1310 if let Some(lines) = scrollback_lines {
1312 if lines > 0 {
1313 let scrollback = {
1314 let guard = shell_arc.lock().await;
1315 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1316 };
1317 if !scrollback.is_empty() {
1318 let count = scrollback.lines().count();
1319 return Ok(format!(
1320 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1321 ));
1322 }
1323 }
1324 }
1325
1326 Ok(response)
1327}
1328
1329async fn execute_screen(
1333 bash_state: &BashState,
1334 bg_shell: Option<SharedPtyShell>,
1335 is_bg: bool,
1336 bg_id: Option<&str>,
1337 lines: Option<usize>,
1338 diff: bool,
1339 mut delivery_cursor: Option<&mut ShellDeliveryCursor>,
1340) -> Result<String> {
1341 let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
1342 let max_lines = lines.unwrap_or(0);
1343
1344 if diff {
1345 let (update, is_running, in_alt, cursor, running_for, exit_code, cwd) = {
1346 let mut guard = shell_arc.lock().await;
1347 match guard.as_mut() {
1348 Some(bash) => {
1349 bash.poll_output_nonblocking();
1350 let running = bash.command_running;
1351 let update = if let Some(cursor) = delivery_cursor.as_deref_mut() {
1352 cursor.sync_generation(bash.command_generation());
1353 cursor.screen_update(bash.live_snapshot(max_lines), SCREEN_DIFF_THRESHOLD)
1354 } else {
1355 bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD)
1356 };
1357 (
1358 update,
1359 running,
1360 bash.live_in_alt_screen(),
1361 bash.live_cursor_position(),
1362 running.then(|| bash.command_elapsed()).flatten(),
1363 (!running).then_some(bash.last_exit_code).flatten(),
1364 Some(bash.current_cwd().to_path_buf()),
1365 )
1366 }
1367 None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0), None, None, None),
1368 }
1369 };
1370 let (crow, ccol) = cursor;
1371 let alt = if in_alt { " [alt-screen]" } else { "" };
1372 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1373 let status = get_status(
1374 bash_state,
1375 is_bg,
1376 bg_id,
1377 is_running,
1378 running_for.as_deref(),
1379 exit_code,
1380 cwd.as_deref(),
1381 );
1382 let body = match update {
1383 ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1384 ScreenUpdate::Diff(changed) => {
1385 let mut out = String::from("(changed lines only)\n");
1386 for (row, content) in changed {
1387 let _ = writeln!(out, "{row:>4}: {content}");
1388 }
1389 out
1390 }
1391 ScreenUpdate::Full(snap) => {
1392 let joined = snap.join("\n");
1393 if joined.trim().is_empty() {
1394 "(screen is empty)".to_string()
1395 } else {
1396 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1397 }
1398 }
1399 };
1400 return Ok(format!(
1401 "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1402 ));
1403 }
1404
1405 let (snapshot, is_running, in_alt, cursor, running_for, exit_code, cwd) = {
1406 let mut guard = shell_arc.lock().await;
1407 match guard.as_mut() {
1408 Some(bash) => {
1409 bash.poll_output_nonblocking();
1410 let running = bash.command_running;
1411 (
1412 bash.live_snapshot(max_lines),
1413 running,
1414 bash.live_in_alt_screen(),
1415 bash.live_cursor_position(),
1416 running.then(|| bash.command_elapsed()).flatten(),
1417 (!running).then_some(bash.last_exit_code).flatten(),
1418 Some(bash.current_cwd().to_path_buf()),
1419 )
1420 }
1421 None => (Vec::new(), false, false, (0, 0), None, None, None),
1422 }
1423 };
1424
1425 let joined = snapshot.join("\n");
1426 let body = if joined.trim().is_empty() {
1427 "(screen is empty)".to_string()
1428 } else {
1429 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1430 };
1431 let alt = if in_alt { " [alt-screen]" } else { "" };
1432 let (crow, ccol) = cursor;
1433 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1434 let status = get_status(
1435 bash_state,
1436 is_bg,
1437 bg_id,
1438 is_running,
1439 running_for.as_deref(),
1440 exit_code,
1441 cwd.as_deref(),
1442 );
1443 Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1444}
1445
1446#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1455fn wait_turn_outcome(
1456 state: crate::state::turn::TurnState,
1457 alive: bool,
1458 activity: bool,
1459 seen_busy: bool,
1460 stable_for: Duration,
1461 busy_for: Duration,
1462 settle: Duration,
1463 quiet: Duration,
1464 timed_out: bool,
1465 wait_through_busy: bool,
1466) -> Option<&'static str> {
1467 use crate::state::turn::TurnState;
1468 if !alive {
1469 return Some("exited");
1470 }
1471 let ready = match state {
1472 TurnState::Busy => false,
1473 TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1476 activity && stable_for >= if seen_busy { settle } else { quiet }
1477 }
1478 TurnState::Unknown => activity && stable_for >= quiet,
1479 };
1480 if ready {
1481 return Some("ready");
1482 }
1483 if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
1488 return Some("busy");
1489 }
1490 if timed_out {
1491 return Some("timeout");
1492 }
1493 None
1494}
1495
1496#[allow(clippy::too_many_arguments)]
1506async fn execute_wait_for_turn(
1507 bash_state: &BashState,
1508 bg_shell: Option<SharedPtyShell>,
1509 is_bg: bool,
1510 bg_id: Option<&str>,
1511 recognizer_hint: Option<&str>,
1512 quiet_ms: Option<u64>,
1513 timeout_seconds: Option<f32>,
1514 lines: Option<usize>,
1515 wait_through_busy: bool,
1516) -> Result<String> {
1517 use crate::state::turn::{recognizer_for, TurnState};
1518
1519 let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
1520 let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1521 let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1522 let settle = quiet.min(Duration::from_millis(300));
1523 let hard_cap =
1524 Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1525 let max_lines = lines.unwrap_or(0);
1526 let poll = Duration::from_millis(120);
1527 let warmup = Duration::from_millis(2500);
1530
1531 let start = Instant::now();
1532 let mut last_hash: Option<u64> = None;
1533 let mut initial_hash: Option<u64> = None;
1534 let mut stable_since = Instant::now();
1535 let mut seen_busy = false;
1536 let mut busy_since: Option<Instant> = None;
1537
1538 loop {
1539 let (snapshot, in_alt, alive, running, running_for, exit_code, cwd) = {
1540 let mut guard = shell_arc.lock().await;
1541 match guard.as_mut() {
1542 Some(bash) => {
1543 bash.poll_output_nonblocking();
1544 let running = bash.command_running;
1545 (
1546 bash.live_snapshot(max_lines),
1547 bash.live_in_alt_screen(),
1548 bash.is_alive(),
1549 running,
1550 running.then(|| bash.command_elapsed()).flatten(),
1551 (!running).then_some(bash.last_exit_code).flatten(),
1552 Some(bash.current_cwd().to_path_buf()),
1553 )
1554 }
1555 None => (Vec::new(), false, false, false, None, None, None),
1556 }
1557 };
1558
1559 let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1560 if initial_hash.is_none() {
1561 initial_hash = Some(hash);
1562 }
1563 if Some(hash) != last_hash {
1564 last_hash = Some(hash);
1565 stable_since = Instant::now();
1566 }
1567
1568 let state = recognizer.detect(&snapshot);
1569 if state == TurnState::Busy {
1570 seen_busy = true;
1571 if busy_since.is_none() {
1572 busy_since = Some(Instant::now());
1573 }
1574 } else {
1575 busy_since = None;
1576 }
1577 let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
1578 let stable_for = stable_since.elapsed();
1579 let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1583 let timed_out = start.elapsed() >= hard_cap;
1584 if let Some(reason) = wait_turn_outcome(
1585 state,
1586 alive,
1587 activity,
1588 seen_busy,
1589 stable_for,
1590 busy_for,
1591 settle,
1592 quiet,
1593 timed_out,
1594 wait_through_busy,
1595 ) {
1596 let joined = snapshot.join("\n");
1597 let body = if joined.trim().is_empty() {
1598 "(screen is empty)".to_string()
1599 } else {
1600 truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1601 };
1602 let alt = if in_alt { " [alt-screen]" } else { "" };
1603 let header = format!(
1604 "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1605 state.as_str(),
1606 reason,
1607 recognizer.name(),
1608 start.elapsed().as_secs_f64(),
1609 alt
1610 );
1611 let running_for = running_for.map(|elapsed| format!("{} seconds", elapsed.as_secs()));
1612 let status = get_status(
1613 bash_state,
1614 is_bg,
1615 bg_id,
1616 running,
1617 running_for.as_deref(),
1618 exit_code,
1619 cwd.as_deref(),
1620 );
1621 return Ok(format!("{header}\n{body}{status}"));
1622 }
1623
1624 tokio::time::sleep(poll).await;
1625 }
1626}
1627
1628async fn execute_send_text(
1630 bash_state: &mut BashState,
1631 text: &str,
1632 submit: bool,
1633 bg_shell: Option<SharedPtyShell>,
1634 is_bg: bool,
1635 bg_id: Option<&str>,
1636 timeout_s: f64,
1637 delivery_cursor: Option<&mut ShellDeliveryCursor>,
1638) -> Result<String> {
1639 debug!(bytes = text.len(), submit, "Processing SendText action");
1640
1641 if text.is_empty() {
1643 return Err(WinxError::CommandExecutionError(
1644 "Failure: send_text cannot be empty".to_string(),
1645 ));
1646 }
1647
1648 let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
1650
1651 {
1653 let mut guard = shell_arc.lock().await;
1654
1655 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1656 ensure_interactive_target(bash)?;
1657
1658 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1660 }
1661
1662 if submit {
1666 submit_enter(&shell_arc).await?;
1667 }
1668
1669 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false, delivery_cursor).await
1671}
1672
1673async fn execute_send_specials(
1675 bash_state: &mut BashState,
1676 keys: &[SpecialKey],
1677 submit: bool,
1678 bg_shell: Option<SharedPtyShell>,
1679 is_bg: bool,
1680 bg_id: Option<&str>,
1681 timeout_s: f64,
1682 delivery_cursor: Option<&mut ShellDeliveryCursor>,
1683) -> Result<String> {
1684 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1685
1686 if keys.is_empty() {
1688 return Err(WinxError::CommandExecutionError(
1689 "Failure: send_specials cannot be empty".to_string(),
1690 ));
1691 }
1692
1693 let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
1694 let mut is_interrupt = false;
1695
1696 {
1697 let mut guard = shell_arc.lock().await;
1698
1699 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1700 ensure_interactive_target(bash)?;
1701
1702 for key in keys {
1704 match key {
1705 SpecialKey::KeyUp => {
1706 bash.send_special_key("KeyUp").map_err(|e| {
1708 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1709 })?;
1710 }
1711 SpecialKey::KeyDown => {
1712 bash.send_special_key("KeyDown").map_err(|e| {
1714 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1715 })?;
1716 }
1717 SpecialKey::KeyLeft => {
1718 bash.send_special_key("KeyLeft").map_err(|e| {
1720 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1721 })?;
1722 }
1723 SpecialKey::KeyRight => {
1724 bash.send_special_key("KeyRight").map_err(|e| {
1726 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1727 })?;
1728 }
1729 SpecialKey::Enter => {
1730 bash.send_special_key("Enter").map_err(|e| {
1732 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1733 })?;
1734 }
1735 SpecialKey::CtrlC => {
1736 bash.send_interrupt().map_err(|e| {
1738 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1739 })?;
1740 is_interrupt = true;
1741 }
1742 SpecialKey::CtrlD => {
1743 bash.send_eof().map_err(|e| {
1746 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1747 })?;
1748 }
1749 SpecialKey::CtrlZ => {
1750 bash.send_suspend().map_err(|e| {
1752 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1753 })?;
1754 }
1755 }
1756 }
1757 }
1758
1759 if submit {
1762 submit_enter(&shell_arc).await?;
1763 }
1764
1765 let mut output =
1773 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false, delivery_cursor)
1774 .await?;
1775
1776 if is_interrupt && output.contains("status = still running") {
1778 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1779 }
1780
1781 Ok(output)
1782}
1783
1784async fn execute_send_ascii(
1786 bash_state: &mut BashState,
1787 ascii_codes: &[u8],
1788 submit: bool,
1789 bg_shell: Option<SharedPtyShell>,
1790 is_bg: bool,
1791 bg_id: Option<&str>,
1792 timeout_s: f64,
1793 delivery_cursor: Option<&mut ShellDeliveryCursor>,
1794) -> Result<String> {
1795 debug!(bytes = ascii_codes.len(), submit, "Processing SendAscii action");
1796
1797 if ascii_codes.is_empty() {
1799 return Err(WinxError::CommandExecutionError(
1800 "Failure: send_ascii cannot be empty".to_string(),
1801 ));
1802 }
1803
1804 let shell_arc = bg_shell.unwrap_or_else(|| main_shell(bash_state));
1805 let mut is_interrupt = false;
1806
1807 {
1808 let mut guard = shell_arc.lock().await;
1809
1810 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1811 ensure_interactive_target(bash)?;
1812
1813 for &code in ascii_codes {
1815 bash.send_bytes(&[code]).map_err(|e| {
1817 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1818 })?;
1819
1820 if code == 3 {
1822 is_interrupt = true;
1823 }
1824 }
1825 }
1826
1827 if submit {
1830 submit_enter(&shell_arc).await?;
1831 }
1832
1833 let mut output =
1839 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false, delivery_cursor)
1840 .await?;
1841
1842 if is_interrupt && output.contains("status = still running") {
1844 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1845 }
1846
1847 Ok(output)
1848}
1849
1850async fn execute_in_background(
1852 bash_state: &mut BashState,
1853 command: &str,
1854 timeout_s: f64,
1855 delivery_cursor: Option<&mut ShellDeliveryCursor>,
1856) -> Result<String> {
1857 debug!(bytes = command.len(), "Executing command in background");
1858
1859 let restricted_mode =
1861 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1862
1863 let bg_id = {
1867 let cwd = bash_state.cwd.clone();
1868 let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1869 .await
1870 .map_err(|e| {
1871 WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1872 })?
1873 .map_err(|e| {
1874 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1875 })?;
1876 lock_session_store().register_shell(&bash_state.current_thread_id, shell)?
1877 };
1878
1879 let shell_arc = {
1881 let manager = lock_session_store();
1882 manager.get_shell(&bash_state.current_thread_id, &bg_id).ok_or_else(|| {
1883 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1884 })?
1885 };
1886
1887 let scratch_root = bash_state.workspace_root.clone();
1889 let send_result = {
1890 let mut guard = shell_arc.lock().await;
1891 guard.as_mut().map(|bash| {
1892 bash.set_scratch_root(&scratch_root);
1895 bash.send_command(command)
1896 })
1897 };
1898 let Some(send_result) = send_result else {
1899 lock_session_store().remove_shell(&bg_id);
1900 return Err(WinxError::BashStateNotInitialized);
1901 };
1902 if let Err(error) = send_result {
1903 lock_session_store().remove_shell(&bg_id);
1906 return Err(WinxError::CommandExecutionError(format!(
1907 "Failed to send bg command: {error}"
1908 )));
1909 }
1910 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1911
1912 spawn_background_reaper(bash_state.current_thread_id.clone(), bg_id.clone());
1913
1914 let _ = (timeout_s, delivery_cursor);
1915 let _ = shell_arc;
1916 Ok(get_status(bash_state, true, Some(&bg_id), true, None, None, Some(&bash_state.cwd)))
1917}
1918
1919#[cfg(test)]
1920mod wait_turn_tests {
1921 use super::wait_turn_outcome;
1922 use crate::state::turn::TurnState;
1923 use std::time::Duration;
1924
1925 const SETTLE: Duration = Duration::from_millis(300);
1926 const QUIET: Duration = Duration::from_millis(600);
1927
1928 fn call(
1929 state: TurnState,
1930 busy_for: Duration,
1931 stable_for: Duration,
1932 seen_busy: bool,
1933 timed_out: bool,
1934 wait_through_busy: bool,
1935 ) -> Option<&'static str> {
1936 wait_turn_outcome(
1938 state,
1939 true,
1940 true,
1941 seen_busy,
1942 stable_for,
1943 busy_for,
1944 SETTLE,
1945 QUIET,
1946 timed_out,
1947 wait_through_busy,
1948 )
1949 }
1950
1951 #[test]
1952 fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
1953 let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
1956 assert_eq!(out, Some("busy"));
1957 }
1958
1959 #[test]
1960 fn busy_not_yet_confirmed_keeps_waiting() {
1961 let out =
1963 call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
1964 assert_eq!(out, None);
1965 }
1966
1967 #[test]
1968 fn wait_through_busy_blocks_through_busy_until_timeout() {
1969 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
1971 assert_eq!(out, None);
1972 let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
1974 assert_eq!(out, Some("timeout"));
1975 }
1976
1977 #[test]
1978 fn awaiting_input_after_busy_is_ready_on_short_settle() {
1979 let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
1980 assert_eq!(out, Some("ready"));
1981 }
1982
1983 #[test]
1984 fn awaiting_input_without_prior_busy_needs_full_quiet() {
1985 let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
1987 assert_eq!(short, None);
1988 let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
1989 assert_eq!(full, Some("ready"));
1990 }
1991
1992 #[test]
1993 fn dead_shell_reports_exited_even_if_busy() {
1994 let out = wait_turn_outcome(
1995 TurnState::Busy,
1996 false,
1997 true,
1998 true,
1999 Duration::ZERO,
2000 SETTLE,
2001 SETTLE,
2002 QUIET,
2003 false,
2004 false,
2005 );
2006 assert_eq!(out, Some("exited"));
2007 }
2008
2009 #[test]
2010 fn nothing_happening_keeps_waiting() {
2011 let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
2012 assert_eq!(out, None);
2013 }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018 use super::{effective_wait_for_seconds, strip_tail_pipe_impl, truncate_to_token_budget};
2019
2020 #[test]
2021 fn strips_trailing_tail_by_default() {
2022 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
2023 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
2024 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
2025 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
2026 }
2027
2028 #[test]
2029 fn keeps_command_without_trailing_tail() {
2030 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
2032 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
2033 assert_eq!(
2034 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
2035 "cat a | tail -5 | wc -l"
2036 );
2037 }
2038
2039 #[test]
2040 fn keep_mode_preserves_tail_pipe() {
2041 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
2043 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
2044 }
2045
2046 #[test]
2047 fn requested_wait_is_not_silently_capped() {
2048 assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
2049 assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
2050 }
2051
2052 #[test]
2053 fn token_budget_applies_below_the_byte_fallback_cap() {
2054 let input = "alpha beta gamma delta ".repeat(100);
2055 let rendered = truncate_to_token_budget(&input, 20);
2056
2057 assert!(rendered.starts_with("(...truncated)\n"));
2058 assert_ne!(rendered.as_ref(), input);
2059 let tail = rendered.strip_prefix("(...truncated)\n").unwrap_or_default();
2060 let tail_tokens = crate::utils::encoder::encode_ids(tail);
2061 assert!(tail_tokens.as_ref().is_some_and(|tokens| tokens.len() <= 19));
2062 }
2063}