Skip to main content

winx_code_agent/tools/
bash_command.rs

1//! `BashCommand` facade and action dispatcher.
2//!
3//! Execution, output/status rendering, interactive input, and TUI driving live
4//! in focused submodules under `tools/bash_command/`.
5
6mod execution;
7mod interaction;
8mod output;
9mod tui;
10
11use std::sync::Arc;
12
13use tokio::sync::Mutex;
14use tracing::{error, info};
15
16pub use super::background_shell::{BackgroundShellManager, ExitedShellInfo};
17use crate::errors::{Result, WinxError};
18use crate::runtime::{lock_session_store, EmbeddedShellRuntime, ShellRuntime, ShellTarget};
19use crate::state::bash_state::BashState;
20use crate::state::live_terminal::ScreenUpdate;
21use crate::state::pty::PtyShell;
22use crate::types::{normalize_thread_id, BashCommand, BashCommandAction};
23
24use execution::execute_command;
25use interaction::{execute_send_ascii, execute_send_specials, execute_send_text};
26use output::{execute_status_check, finalize_tombstone};
27use tui::{execute_screen, execute_wait_for_turn};
28
29type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
30
31/// Per-adapter delivery state used by daemon guardians. Embedded callers keep
32/// using the cursor stored directly on `PtyShell`, preserving legacy behavior.
33#[derive(Debug, Default)]
34pub(crate) struct ShellDeliveryCursor {
35    generation: Option<u64>,
36    delivered_output: String,
37    last_returned_hash: Option<u64>,
38    screen_snapshot: Option<Vec<String>>,
39}
40
41impl ShellDeliveryCursor {
42    fn sync_generation(&mut self, generation: u64) {
43        if self.generation != Some(generation) {
44            self.generation = Some(generation);
45            self.delivered_output.clear();
46            self.last_returned_hash = None;
47            self.screen_snapshot = None;
48        }
49    }
50
51    fn screen_update(&mut self, current: Vec<String>, threshold: usize) -> ScreenUpdate {
52        let update = match self.screen_snapshot.as_ref() {
53            Some(previous) => {
54                let rows = previous.len().max(current.len());
55                let changed = (0..rows)
56                    .filter_map(|index| {
57                        let before = previous.get(index).map_or("", String::as_str);
58                        let after = current.get(index).map_or("", String::as_str);
59                        (before != after).then(|| (index + 1, after.to_string()))
60                    })
61                    .collect::<Vec<_>>();
62                if changed.is_empty() {
63                    ScreenUpdate::Unchanged
64                } else if changed.len() <= threshold {
65                    ScreenUpdate::Diff(changed)
66                } else {
67                    ScreenUpdate::Full(current.clone())
68                }
69            }
70            None => ScreenUpdate::Full(current.clone()),
71        };
72        self.screen_snapshot = Some(current);
73        update
74    }
75}
76
77fn main_shell(bash_state: &BashState) -> SharedPtyShell {
78    let mut store = lock_session_store();
79    store.bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
80    store
81        .resolve(&bash_state.current_thread_id, &ShellTarget::Main)
82        .unwrap_or_else(|| bash_state.pty_shell.clone())
83}
84
85// Default block window when the client omits `wait_for_seconds`. Usage logs
86// showed remote clients omit it and then burn one full LLM round-trip per 5 s
87// re-check on long commands (runs of up to 18 consecutive polls); a larger
88// window resolves most commands in a single call while staying far below the
89// 120 s HTTP request timeout.
90const DEFAULT_TIMEOUT: f64 = 15.0;
91
92fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
93    wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
94}
95
96fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
97    let mut start = 0;
98    while start < text.len() {
99        let mut end = (start + chunk_size).min(text.len());
100        while !text.is_char_boundary(end) {
101            end -= 1;
102        }
103        if end == start {
104            end =
105                text[start..].char_indices().nth(1).map_or(text.len(), |(index, _)| start + index);
106        }
107        shell.send_text(&text[start..end]).map_err(|error| {
108            WinxError::CommandExecutionError(format!("Failed to write PTY input: {error}"))
109        })?;
110        start = end;
111    }
112    Ok(())
113}
114
115/// Handle a `BashCommand` using the embedded runtime.
116pub async fn handle_tool_call(
117    bash_state: &Arc<Mutex<Option<BashState>>>,
118    command: BashCommand,
119) -> Result<String> {
120    handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state, command).await
121}
122
123/// Execute a `BashCommand` through the selected shell runtime.
124pub async fn handle_tool_call_with_runtime(
125    runtime: &dyn ShellRuntime,
126    bash_state: &Arc<Mutex<Option<BashState>>>,
127    command: BashCommand,
128) -> Result<String> {
129    runtime.run_action(bash_state, command).await
130}
131
132pub(crate) async fn handle_embedded_tool_call(
133    bash_state: &Arc<Mutex<Option<BashState>>>,
134    command: BashCommand,
135) -> Result<String> {
136    handle_embedded_tool_call_inner(bash_state, command, None).await
137}
138
139pub(crate) async fn handle_embedded_tool_call_with_cursor(
140    bash_state: &Arc<Mutex<Option<BashState>>>,
141    command: BashCommand,
142    delivery_cursor: &Arc<Mutex<ShellDeliveryCursor>>,
143) -> Result<String> {
144    let mut delivery_cursor = delivery_cursor.lock().await;
145    handle_embedded_tool_call_inner(bash_state, command, Some(&mut delivery_cursor)).await
146}
147
148#[tracing::instrument(level = "info", skip(bash_state, command, delivery_cursor))]
149async fn handle_embedded_tool_call_inner(
150    bash_state: &Arc<Mutex<Option<BashState>>>,
151    command: BashCommand,
152    delivery_cursor: Option<&mut ShellDeliveryCursor>,
153) -> Result<String> {
154    let action_kind = match &command.action_json {
155        BashCommandAction::Command { .. } => "command",
156        BashCommandAction::StatusCheck { .. } => "status_check",
157        BashCommandAction::SendText { .. } => "send_text",
158        BashCommandAction::SendSpecials { .. } => "send_specials",
159        BashCommandAction::SendAscii { .. } => "send_ascii",
160        BashCommandAction::Screen { .. } => "screen",
161        BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
162    };
163    info!(thread_id = %command.thread_id, action = action_kind, "BashCommand tool called");
164
165    let thread_id = normalize_thread_id(&command.thread_id);
166    if thread_id.is_empty() {
167        error!("Empty thread_id provided in BashCommand");
168        return Err(WinxError::ThreadIdMismatch(
169            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID."
170                .to_string(),
171        ));
172    }
173
174    let mut local_state = {
175        let guard = bash_state.lock().await;
176        let Some(state) = &*guard else {
177            error!("BashState not initialized");
178            return Err(WinxError::BashStateNotInitialized);
179        };
180        state.clone()
181    };
182
183    if thread_id != local_state.current_thread_id {
184        let loaded = local_state.load_state_from_disk(&thread_id).map_err(|error| {
185            WinxError::CommandExecutionError(format!(
186                "Failed to load saved bash state for thread_id `{thread_id}`: {error}"
187            ))
188        })?;
189        if !loaded {
190            return Err(WinxError::ThreadIdMismatch(format!(
191                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
192            )));
193        }
194        if let Some(state) = bash_state.lock().await.as_mut() {
195            *state = local_state.clone();
196        }
197    }
198
199    lock_session_store().bind_main(&local_state.current_thread_id, &local_state.pty_shell);
200    let timeout_secs = effective_wait_for_seconds(command.wait_for_seconds);
201    let result =
202        execute_bash_action(&mut local_state, &command.action_json, timeout_secs, delivery_cursor)
203            .await;
204
205    if let Some(state) = bash_state.lock().await.as_mut() {
206        state.cwd.clone_from(&local_state.cwd);
207    }
208
209    match result {
210        Ok(mut output) => {
211            if let BashCommandAction::Command { ref command, .. } = command.action_json {
212                let command = command.trim();
213                if output.starts_with(command) {
214                    output = output[command.len()..].to_string();
215                }
216            }
217            Ok(output)
218        }
219        Err(error) => Err(error),
220    }
221}
222
223#[allow(clippy::too_many_lines)]
224async fn execute_bash_action(
225    bash_state: &mut BashState,
226    action: &BashCommandAction,
227    timeout_secs: f64,
228    delivery_cursor: Option<&mut ShellDeliveryCursor>,
229) -> Result<String> {
230    let mut is_background = false;
231    let mut background_id: Option<String> = None;
232
233    let background_shell = match action {
234        BashCommandAction::Command { .. } => None,
235        BashCommandAction::StatusCheck { bg_command_id, .. }
236        | BashCommandAction::SendText { bg_command_id, .. }
237        | BashCommandAction::SendSpecials { bg_command_id, .. }
238        | BashCommandAction::SendAscii { bg_command_id, .. }
239        | BashCommandAction::Screen { bg_command_id, .. }
240        | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
241            if let Some(id) = bg_command_id {
242                let mut manager = lock_session_store();
243                manager.prune_finished_shells();
244                if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
245                    is_background = true;
246                    background_id = Some(id.clone());
247                    Some(shell)
248                } else if let Some(tombstone) =
249                    manager.peek_tombstone(&bash_state.current_thread_id, id)
250                {
251                    drop(manager);
252                    return finalize_tombstone(id, tombstone, action);
253                } else {
254                    let error = format!(
255                        "No shell found running with command id {}.\n{}",
256                        id,
257                        manager.get_running_info(&bash_state.current_thread_id)
258                    );
259                    return Err(WinxError::InvalidInput(error));
260                }
261            } else {
262                None
263            }
264        }
265    };
266
267    match action {
268        BashCommandAction::Command { command, is_background, allow_multi } => {
269            execute_command(
270                bash_state,
271                command,
272                *is_background,
273                *allow_multi,
274                timeout_secs,
275                delivery_cursor,
276            )
277            .await
278        }
279        BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
280            execute_status_check(
281                bash_state,
282                background_shell,
283                is_background,
284                background_id.as_deref(),
285                timeout_secs,
286                *scrollback_lines,
287                *verbose,
288                delivery_cursor,
289            )
290            .await
291        }
292        BashCommandAction::SendText { send_text, submit, .. } => {
293            execute_send_text(
294                bash_state,
295                send_text,
296                *submit,
297                background_shell,
298                is_background,
299                background_id.as_deref(),
300                timeout_secs,
301                delivery_cursor,
302            )
303            .await
304        }
305        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
306            execute_send_specials(
307                bash_state,
308                send_specials,
309                *submit,
310                background_shell,
311                is_background,
312                background_id.as_deref(),
313                timeout_secs,
314                delivery_cursor,
315            )
316            .await
317        }
318        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
319            execute_send_ascii(
320                bash_state,
321                send_ascii,
322                *submit,
323                background_shell,
324                is_background,
325                background_id.as_deref(),
326                timeout_secs,
327                delivery_cursor,
328            )
329            .await
330        }
331        BashCommandAction::Screen { lines, diff, .. } => {
332            execute_screen(
333                bash_state,
334                background_shell,
335                is_background,
336                background_id.as_deref(),
337                *lines,
338                *diff,
339                delivery_cursor,
340            )
341            .await
342        }
343        BashCommandAction::WaitForTurn {
344            recognizer,
345            quiet_ms,
346            timeout_seconds,
347            lines,
348            wait_through_busy,
349            ..
350        } => {
351            execute_wait_for_turn(
352                bash_state,
353                background_shell,
354                is_background,
355                background_id.as_deref(),
356                recognizer.as_deref(),
357                *quiet_ms,
358                *timeout_seconds,
359                *lines,
360                *wait_through_busy,
361            )
362            .await
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::effective_wait_for_seconds;
370
371    #[test]
372    fn requested_wait_is_not_silently_capped() {
373        assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
374        assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
375    }
376
377    #[test]
378    fn omitted_wait_uses_the_larger_default_window() {
379        assert!((effective_wait_for_seconds(None) - 15.0).abs() < f64::EPSILON);
380    }
381}