From acd22b93e80b1358e3140258dbd58e3a6317df94 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:05:29 -0400 Subject: [PATCH] Gate manual TUI launch input --- src/bin/dosh-client.rs | 134 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index ae53a9b..ed9edd1 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -2844,6 +2844,7 @@ async fn run_terminal( let mut pending_user_input: VecDeque> = VecDeque::new(); let mut pending_user_input_bytes = 0usize; let mut startup_input_hold_until: Option = None; + let mut shell_command_line: Vec = Vec::new(); if let Some(frame) = first_frame { if !forward_only { render_frame(&frame)?; @@ -2920,6 +2921,28 @@ async fn run_terminal( ) .await?; } + let hold_after_submit = update_shell_command_line( + &mut shell_command_line, + &bytes, + predictor.alternate_screen, + ); + if hold_after_submit + && let Some((send_now, hold_for_later)) = + split_after_command_submit(&bytes) + { + predictor.observe_input(&send_now)?; + send_input(&socket, addr, &cred, &mut send_seq, send_now).await?; + startup_input_hold_until = + Some(Instant::now() + STARTUP_INPUT_HOLD); + if !hold_for_later.is_empty() { + queue_pending_user_input( + &mut pending_user_input, + &mut pending_user_input_bytes, + hold_for_later, + )?; + } + continue; + } if last_packet_at.elapsed() >= Duration::from_secs(2) { queue_pending_user_input( &mut pending_user_input, @@ -3790,6 +3813,81 @@ fn queue_pending_user_input( Ok(()) } +fn split_after_command_submit(bytes: &[u8]) -> Option<(Vec, Vec)> { + let split_at = bytes + .iter() + .position(|byte| matches!(byte, b'\r' | b'\n'))? + + 1; + if split_at == bytes.len() { + return Some((bytes.to_vec(), Vec::new())); + } + let later = bytes[split_at..].to_vec(); + let now = bytes[..split_at].to_vec(); + Some((now, later)) +} + +fn update_shell_command_line(line: &mut Vec, bytes: &[u8], alternate_screen: bool) -> bool { + if alternate_screen { + line.clear(); + return false; + } + let mut submitted_tui = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\r' | b'\n' => { + submitted_tui |= shell_line_starts_tui(line); + line.clear(); + i += 1; + } + 0x08 | 0x7f => { + line.pop(); + i += 1; + } + 0x20..=0x7e => { + line.push(bytes[i]); + i += 1; + } + 0x1b => { + // Cursor editing makes the cheap line model unreliable; drop it + // rather than guessing. The command will still run normally. + line.clear(); + i = bytes.len(); + } + _ => { + i += 1; + } + } + } + submitted_tui +} + +fn shell_line_starts_tui(line: &[u8]) -> bool { + let Ok(text) = std::str::from_utf8(line) else { + return false; + }; + let command = text + .trim_start() + .split([' ', '\t', ';', '&', '|']) + .next() + .unwrap_or(""); + matches!( + command, + "tm" | "tmux" + | "vim" + | "nvim" + | "vi" + | "less" + | "more" + | "man" + | "top" + | "htop" + | "btop" + | "btm" + | "watch" + ) +} + async fn flush_pending_user_input( socket: &UdpSocket, addr: SocketAddr, @@ -5258,8 +5356,9 @@ mod tests { queue_pending_user_input, raw_contains_host_table, recv_response_until, render_status_clear, render_status_overlay, requested_env, resolved_startup_command, rewrite_forward_command, selected_predict_mode, selected_udp_host, server_version_mismatch, - ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, - toml_bare_key_or_quoted, update_check_requested, valid_forward_host, + split_after_command_submit, ssh_destination_host, ssh_username, ssh_with_user, + startup_command, status_ssh_target, toml_bare_key_or_quoted, update_check_requested, + update_shell_command_line, valid_forward_host, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -5910,6 +6009,37 @@ mod tests { ); } + #[test] + fn split_after_command_submit_holds_followup_input() { + assert_eq!(split_after_command_submit(b"tm"), None); + assert_eq!( + split_after_command_submit(b"tm\r"), + Some((b"tm\r".to_vec(), Vec::new())) + ); + assert_eq!( + split_after_command_submit(b"tm\r\x1b[B"), + Some((b"tm\r".to_vec(), b"\x1b[B".to_vec())) + ); + } + + #[test] + fn shell_command_line_detects_tui_submit() { + let mut line = Vec::new(); + assert!(!update_shell_command_line(&mut line, b"echo ok\r", false)); + assert!(line.is_empty()); + + assert!(!update_shell_command_line(&mut line, b"t", false)); + assert!(!update_shell_command_line(&mut line, b"m", false)); + assert!(update_shell_command_line(&mut line, b"\r", false)); + assert!(line.is_empty()); + + assert!(update_shell_command_line( + &mut line, + b" btop\r\x1b[B", + false + )); + } + #[test] fn update_check_accepts_only_check_flag() { assert!(!update_check_requested(&[]).unwrap());