1mod 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#[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
85const 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
115pub 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
123pub 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}