diff --git a/src/client/input.rs b/src/client/input.rs index 41850f96..45656f84 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -42,6 +42,8 @@ pub fn stdin_reader_loop( host_cell_size_query_sent: bool, host_mouse_capture_active: Arc, host_sgr_pixels_active: Arc, + host_escape_disambiguation_active: bool, + initial_host_input: Vec, #[cfg(unix)] direct_response: Arc>, #[cfg(unix)] direct_response_active: Arc, ) { @@ -53,6 +55,7 @@ pub fn stdin_reader_loop( host_mouse_capture_active, host_sgr_pixels_active, ); + let _ = (host_escape_disambiguation_active, initial_host_input); windows_stdin_reader_loop(event_tx, should_quit); } @@ -64,6 +67,8 @@ pub fn stdin_reader_loop( host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, + host_escape_disambiguation_active, + initial_host_input, direct_response, direct_response_active, ); @@ -77,6 +82,8 @@ fn unix_stdin_reader_loop( host_cell_size_query_sent: bool, host_mouse_capture_active: Arc, host_sgr_pixels_active: Arc, + host_escape_disambiguation_active: bool, + initial_host_input: Vec, direct_response: Arc>, direct_response_active: Arc, ) { @@ -84,6 +91,7 @@ fn unix_stdin_reader_loop( let mut reader = stdin.lock(); let mut scratch = [0u8; 4096]; let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + framer.set_host_escape_disambiguation_active(host_escape_disambiguation_active); if host_color_query_sent { framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); @@ -97,6 +105,57 @@ fn unix_stdin_reader_loop( let mut last_geometry = None; let mut direct_filter = super::direct_graphics::InputFilter::default(); + if !initial_host_input.is_empty() { + let sgr_pixels = host_sgr_pixels_active.load(Ordering::Acquire); + if sgr_pixels { + last_geometry = crate::input::mouse::HostGeometry::current(); + } + let chunks = framer.push(&initial_host_input); + if !send_unix_input_chunks( + chunks, + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) { + return; + } + if (framer.has_pending_input() || !pending_palette.is_empty()) + && stdin_read_ready( + &reader, + idle_flush_timeout_ms(&framer, host_mouse_capture_active.load(Ordering::Acquire)), + ) == Some(false) + { + let had_pending = framer.has_pending_input(); + let chunks = framer.flush_timeout(); + let held_escape = had_pending && chunks.is_empty(); + if !send_unix_input_chunks( + chunks, + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) || !flush_unix_palette_input(&event_tx, &mut pending_palette) + { + return; + } + if held_escape + && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) + == Some(false) + && !send_unix_input_chunks( + framer.flush_timeout(), + &event_tx, + &mut pending_palette, + sgr_pixels, + last_geometry, + ) + { + return; + } + } + pending_mode = framer.has_pending_input().then_some(sgr_pixels); + } + while !should_quit.load(Ordering::Acquire) { if direct_filter.has_pending() && stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) @@ -593,31 +652,7 @@ fn stdin_read_ready(reader: &R, timeout_ms: i32) -> Option { #[cfg(unix)] fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option { - #[repr(C)] - struct PollFd { - fd: i32, - events: i16, - revents: i16, - } - - unsafe extern "C" { - fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32; - } - - const POLLIN: i16 = 0x0001; - - let mut pfd = PollFd { - fd, - events: POLLIN, - revents: 0, - }; - - let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) }; - if result < 0 { - None - } else { - Some(result > 0) - } + crate::platform::poll_fd_readable(fd, timeout_ms).ok() } // --------------------------------------------------------------------------- diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs index 53ef9f38..97979bbf 100644 --- a/src/client/loop_config.rs +++ b/src/client/loop_config.rs @@ -9,6 +9,8 @@ pub(super) struct ClientLoopConfig { pub(super) pixel_geometry_enabled: bool, pub(super) pixel_geometry_fallback: bool, pub(super) mouse_capture_active: bool, + pub(super) host_escape_disambiguation_active: bool, + pub(super) initial_host_input: Vec, pub(super) endpoint_keybindings: bool, pub(super) remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, diff --git a/src/client/mod.rs b/src/client/mod.rs index 1cef87a7..a5094397 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -79,6 +79,7 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { warn!(err = %err, "failed to re-assert host mouse capture"); } } + #[cfg(windows)] use terminal_setup::{is_ssh_session, windows_vti_input_backend_enabled}; #[cfg(test)] @@ -179,7 +180,7 @@ fn run_client_with_mode( let endpoint_keybindings = shell_config .as_ref() .is_some_and(shell::ClientShellConfig::uses_endpoint_keybindings); - let loop_config = ClientLoopConfig { + let mut loop_config = ClientLoopConfig { sound_config: loaded_config.config.ui.sound, mouse_scroll_lines, redraw_on_focus_gained, @@ -188,6 +189,8 @@ fn run_client_with_mode( pixel_geometry_enabled, pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, + host_escape_disambiguation_active: false, + initial_host_input: Vec::new(), endpoint_keybindings, remote_image_paste_key, shell_config, @@ -278,7 +281,7 @@ fn run_client_with_mode( // The federated shell can show connection notices without any server snapshot. let direct_attach = attach_escape.is_some(); - let terminal_guard = if direct_attach { + let mut terminal_guard = if direct_attach { setup_direct_attach_terminal(mouse_capture) } else { setup_terminal(mouse_capture) @@ -287,6 +290,9 @@ fn run_client_with_mode( eprintln!("herdr: failed to set up terminal: {err}"); err })?; + loop_config.host_escape_disambiguation_active = + terminal_guard.host_escape_disambiguation_active(); + loop_config.initial_host_input = terminal_guard.take_buffered_host_input(); // Install a panic hook so the foreground client always restores its terminal. let panic_restore = terminal_guard.panic_restore(); @@ -374,7 +380,7 @@ async fn run_client_loop( initial_cell_height_px: u32, initial_pixel_geometry_exact: bool, should_quit: Arc, - config: ClientLoopConfig, + mut config: ClientLoopConfig, attach_escape: Option, _terminal_guard: &TerminalGuard, ) -> Result<(), ClientError> { @@ -475,6 +481,8 @@ async fn run_client_loop( let stdin_quit = should_quit.clone(); let stdin_mouse_capture_active = host_mouse_capture_active.clone(); let stdin_sgr_pixels_active = host_sgr_pixels_active.clone(); + let stdin_escape_disambiguation_active = config.host_escape_disambiguation_active; + let stdin_initial_host_input = std::mem::take(&mut config.initial_host_input); #[cfg(unix)] let stdin_direct_response = state.direct_graphics_response.clone(); #[cfg(unix)] @@ -490,6 +498,8 @@ async fn run_client_loop( will_query_host_cell_size, stdin_mouse_capture_active, stdin_sgr_pixels_active, + stdin_escape_disambiguation_active, + stdin_initial_host_input, #[cfg(unix)] stdin_direct_response, #[cfg(unix)] diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 3bb5c281..9c84985a 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -1,10 +1,14 @@ //! Terminal setup and restoration for the rendered client. use std::io::{self, Write as _}; +#[cfg(not(windows))] +use std::os::fd::AsRawFd as _; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; #[cfg(windows)] use std::sync::{Mutex, MutexGuard}; +#[cfg(not(windows))] +use std::time::{Duration, Instant}; use crossterm::event::{ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, @@ -44,6 +48,8 @@ pub(super) fn setup_terminal_with_capabilities( ) -> io::Result { ratatui::init(); let mut terminal_guard = TerminalGuard { + host_escape_disambiguation_active: false, + buffered_host_input: Vec::new(), reset_keyboard_enhancements: false, reset_modify_other_keys: false, reset_host_color_scheme_reports: false, @@ -68,22 +74,25 @@ pub(super) fn setup_terminal_with_capabilities( WindowsVirtualTerminalInputSetup::default() }; - if enable_client_protocols { + let (host_escape_disambiguation_active, buffered_host_input) = if enable_client_protocols { + terminal_guard.reset_keyboard_enhancements = true; + push_keyboard_enhancement_flags()?; + let (active, buffered_input) = query_host_escape_disambiguation(); set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; if host_color_scheme_reports { terminal_guard.reset_host_color_scheme_reports = true; write_host_color_scheme_report_mode(&mut io::stdout(), true)?; } - terminal_guard.reset_keyboard_enhancements = true; - push_keyboard_enhancement_flags()?; + (active, buffered_input) } else { if should_query_host_terminal_theme() { write_host_color_scheme_report_mode(&mut io::stdout(), false)?; } set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste)?; - } + (false, Vec::new()) + }; #[cfg(windows)] if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { @@ -113,6 +122,8 @@ pub(super) fn setup_terminal_with_capabilities( execute!(io::stdout(), DisableLineWrap)?; + terminal_guard.host_escape_disambiguation_active = host_escape_disambiguation_active; + terminal_guard.buffered_host_input = buffered_host_input; Ok(terminal_guard) } @@ -122,6 +133,8 @@ pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: b /// Guard that restores the terminal when dropped. pub(super) struct TerminalGuard { + host_escape_disambiguation_active: bool, + buffered_host_input: Vec, reset_keyboard_enhancements: bool, reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool, @@ -131,6 +144,189 @@ pub(super) struct TerminalGuard { restore_windows_input_mode: Arc, } +#[cfg(not(windows))] +const HOST_KEYBOARD_QUERY_TIMEOUT: Duration = Duration::from_millis(250); +#[cfg(not(windows))] +const MAX_BUFFERED_HOST_INPUT: usize = 64 * 1024; + +#[cfg(not(windows))] +#[derive(Default)] +struct HostKeyboardProbeResponses { + flags: Option, + primary_device_attributes: bool, +} + +#[cfg(not(windows))] +fn query_host_escape_disambiguation() -> (bool, Vec) { + const QUERY: &[u8] = b"\x1b[?u\x1b[c"; + + let mut buffered_input = Vec::new(); + if let Err(err) = io::stdout() + .write_all(QUERY) + .and_then(|()| io::stdout().flush()) + { + tracing::debug!(%err, "host keyboard enhancement query unavailable"); + return (false, buffered_input); + } + + // Bypass StdinLock's shared buffer so poll and read observe the same bytes. + let stdin = io::stdin(); + let stdin_fd = stdin.as_raw_fd(); + let deadline = Instant::now() + HOST_KEYBOARD_QUERY_TIMEOUT; + let mut responses = HostKeyboardProbeResponses::default(); + while !responses.primary_device_attributes && buffered_input.len() < MAX_BUFFERED_HOST_INPUT { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + let timeout_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; + match crate::platform::poll_fd_readable(stdin_fd, timeout_ms) { + Ok(true) => {} + Ok(false) => break, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + tracing::debug!(%err, "host keyboard enhancement query read unavailable"); + break; + } + } + + let mut scratch = [0u8; 4096]; + let capacity = MAX_BUFFERED_HOST_INPUT - buffered_input.len(); + let read_limit = capacity.min(scratch.len()); + match crate::platform::read_fd(stdin_fd, &mut scratch[..read_limit]) { + Ok(0) => break, + Ok(read) => { + buffered_input.extend_from_slice(&scratch[..read]); + consume_host_keyboard_probe_responses(&mut buffered_input, &mut responses); + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => { + tracing::debug!(%err, "host keyboard enhancement query read failed"); + break; + } + } + } + + ( + host_escape_disambiguation_confirmed(&responses), + buffered_input, + ) +} + +#[cfg(not(windows))] +fn host_escape_disambiguation_confirmed(responses: &HostKeyboardProbeResponses) -> bool { + responses.primary_device_attributes + && responses + .flags + .is_some_and(|flags| flags & 0b0000_0001 != 0) +} + +#[cfg(not(windows))] +fn consume_host_keyboard_probe_responses( + buffered_input: &mut Vec, + responses: &mut HostKeyboardProbeResponses, +) { + const PASTE_START: &[u8] = b"\x1b[200~"; + const PASTE_END: &[u8] = b"\x1b[201~"; + + let mut offset = 0; + while offset < buffered_input.len() { + if buffered_input[offset..].starts_with(PASTE_START) { + let payload_start = offset + PASTE_START.len(); + let Some(relative_end) = buffered_input[payload_start..] + .windows(PASTE_END.len()) + .position(|bytes| bytes == PASTE_END) + else { + break; + }; + offset = payload_start + relative_end + PASTE_END.len(); + continue; + } + if let Some(control_string_end) = host_control_string_end(&buffered_input[offset..]) { + let Some(control_string_end) = control_string_end else { + break; + }; + offset += control_string_end; + continue; + } + if !buffered_input[offset..].starts_with(b"\x1b[?") { + offset += 1; + continue; + } + + let start = offset; + let mut end = start + 3; + while end < buffered_input.len() + && (buffered_input[end].is_ascii_digit() || buffered_input[end] == b';') + { + end += 1; + } + if end == buffered_input.len() { + break; + } + let body = &buffered_input[start + 3..end]; + let recognized = match buffered_input[end] { + b'u' if !body.is_empty() && body.iter().all(u8::is_ascii_digit) => { + std::str::from_utf8(body) + .ok() + .and_then(|flags| flags.parse().ok()) + .map(|flags| { + if !responses.primary_device_attributes { + responses.flags = Some(flags); + } + }) + .is_some() + } + b'c' if !body.is_empty() + && body + .iter() + .all(|byte| byte.is_ascii_digit() || *byte == b';') => + { + responses.primary_device_attributes = true; + true + } + _ => false, + }; + if recognized { + buffered_input.drain(start..=end); + } else { + offset += 1; + } + } +} + +#[cfg(not(windows))] +fn host_control_string_end(bytes: &[u8]) -> Option> { + if bytes.first() != Some(&0x1b) { + return None; + } + + let allow_bel = if bytes.starts_with(b"\x1b]") { + true + } else if bytes + .get(1) + .is_some_and(|byte| matches!(*byte, b'P' | b'_' | b'^' | b'X')) + { + false + } else { + return None; + }; + + for offset in 2..bytes.len() { + if allow_bel && bytes[offset] == 0x07 { + return Some(Some(offset + 1)); + } + if bytes[offset..].starts_with(b"\x1b\\") { + return Some(Some(offset + 2)); + } + } + Some(None) +} + +#[cfg(windows)] +fn query_host_escape_disambiguation() -> (bool, Vec) { + (false, Vec::new()) +} + pub(super) fn write_host_color_scheme_report_mode( writer: &mut impl io::Write, enabled: bool, @@ -521,6 +717,14 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res } impl TerminalGuard { + pub(super) fn host_escape_disambiguation_active(&self) -> bool { + self.host_escape_disambiguation_active + } + + pub(super) fn take_buffered_host_input(&mut self) -> Vec { + std::mem::take(&mut self.buffered_host_input) + } + #[cfg(windows)] pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> { let active = enable_windows_virtual_terminal_input( @@ -645,6 +849,90 @@ mod tests { } } + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_consumes_fragmented_responses_and_preserves_input() { + let stream = b"before\x1b[?7u-middle-\x1b[?1;2cafter"; + + for split in 1..stream.len() { + let mut buffered = Vec::new(); + let mut responses = HostKeyboardProbeResponses::default(); + buffered.extend_from_slice(&stream[..split]); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + buffered.extend_from_slice(&stream[split..]); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, Some(7), "split {split}"); + assert!(responses.primary_device_attributes, "split {split}"); + assert_eq!(buffered, b"before-middle-after", "split {split}"); + } + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_typed_input_before_responses() { + let mut buffered = b"aPtyped\x1b[?7u\x1b[?1;2c".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(buffered, b"aPtyped"); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_requires_disambiguation_bit_and_device_attributes() { + for (flags, expected) in [(0, false), (2, false), (7, true)] { + let mut buffered = format!("\x1b[?{flags}u\x1b[?1;2c").into_bytes(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(host_escape_disambiguation_confirmed(&responses), expected); + assert!(buffered.is_empty()); + } + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_requires_flags_before_device_attributes() { + let mut buffered = b"\x1b[?1;2c\x1b[?7uinput".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, None); + assert!(responses.primary_device_attributes); + assert_eq!(buffered, b"input"); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_response_shaped_payloads() { + let opaque = b"\x1b[200~paste \x1b[?1u \x1b[?1;2c\x1b[201~-\x1bPdata \x1b[?7u\x1b\\"; + let mut buffered = [opaque.as_slice(), b"\x1b[?7u\x1b[?1;2c"].concat(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(buffered, opaque); + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_probe_preserves_malformed_responses() { + let mut buffered = b"a\x1b[?7;1ub\x1b[?65536uc".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!(responses.flags, None); + assert!(!responses.primary_device_attributes); + assert_eq!(buffered, b"a\x1b[?7;1ub\x1b[?65536uc"); + } + #[test] fn windows_native_mouse_capture_reasserts_final_encoding_after_native_enable() { let mut output = SharedOutput::default(); diff --git a/src/platform/mod.rs b/src/platform/mod.rs index eb84e0b1..2ba3e6f3 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -48,7 +48,7 @@ impl ChildExitReason { } #[cfg(unix)] -pub(crate) use unix_common::classify_child_exit; +pub(crate) use unix_common::{classify_child_exit, poll_fd_readable, read_fd}; #[cfg(not(any(unix, windows)))] pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason { diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index a937b863..6063edd9 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -8,6 +8,29 @@ pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::C } } +pub(crate) fn read_fd(fd: std::os::fd::RawFd, data: &mut [u8]) -> std::io::Result { + let result = unsafe { libc::read(fd, data.as_mut_ptr().cast(), data.len()) }; + if result < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(result as usize) + } +} + +pub(crate) fn poll_fd_readable(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result { + let mut descriptor = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if result < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(result > 0) + } +} + pub(crate) fn shutdown_client_stream(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { let crate::ipc::LocalStream::UdSocket(stream) = stream; stream.inner().shutdown(std::net::Shutdown::Both) diff --git a/src/raw_input.rs b/src/raw_input.rs index 147d6e39..cfbcba62 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -150,6 +150,7 @@ pub(crate) struct RawInputByteFramer { host_color_scheme_change_tracking: bool, host_appearance_query_on_focus: bool, split_coalesced_escape: bool, + host_escape_disambiguation_active: bool, } const HOST_COLOR_QUERY_REPLIES: u16 = 258; @@ -218,6 +219,11 @@ impl RawInputByteFramer { !self.buffer.is_empty() } + #[cfg(any(unix, test))] + pub(crate) fn set_host_escape_disambiguation_active(&mut self, active: bool) { + self.host_escape_disambiguation_active = active; + } + #[cfg(unix)] pub(crate) fn has_pending_lone_escape(&self) -> bool { self.buffer.as_slice() == [ESC] @@ -267,6 +273,16 @@ impl RawInputByteFramer { return chunks; } + if self.host_escape_disambiguation_active + && starts_with_bounded_incomplete_escape_sequence(&self.buffer) + { + tracing::trace!( + len = self.buffer.len(), + "holding incomplete host escape sequence with disambiguation active" + ); + return chunks; + } + if self.lone_escape_recently_flushed && self.buffer.starts_with(b"[<") { tracing::debug!( len = self.buffer.len(), @@ -472,6 +488,15 @@ impl RawInputByteFramer { continue; } + if self.host_escape_disambiguation_active + && self.buffer.first() == Some(&ESC) + && self.buffer.len() > 1 + && !starts_with_known_escape_introducer(&self.buffer) + { + self.buffer.drain(..1); + continue; + } + let Some((event, consumed)) = extract_one_event(&self.buffer) else { break; }; @@ -831,6 +856,25 @@ fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { .all(|byte| byte.is_ascii_digit() || *byte == b';') } +fn starts_with_known_escape_introducer(buffer: &[u8]) -> bool { + buffer + .get(1) + .is_some_and(|byte| matches!(*byte, b'[' | b'O' | b']' | b'P' | b'_' | b'^' | b'X' | ESC)) +} + +fn starts_with_bounded_incomplete_escape_sequence(buffer: &[u8]) -> bool { + if buffer.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES { + return false; + } + if buffer == [ESC] || buffer == b"\x1bO" { + return true; + } + let Some(body) = buffer.strip_prefix(b"\x1b[") else { + return false; + }; + body.iter().all(|byte| matches!(*byte, 0x20..=0x3f)) +} + #[cfg(any(unix, windows, test))] fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool { buffer.starts_with(b"\x1b[M") && buffer.len() < 6 @@ -2016,6 +2060,71 @@ mod tests { assert!(framer.timed_out_mouse_prefix.is_none()); } + #[test] + fn confirmed_host_disambiguation_retains_split_sgr_mouse_without_escape() { + for (prefix, tail) in [ + (b"\x1b".as_slice(), b"[<0;5;5M".as_slice()), + (b"\x1b[".as_slice(), b"<0;5;5M".as_slice()), + (b"\x1b[<0;".as_slice(), b"5;5M".as_slice()), + ] { + let mut framer = RawInputFramer::default(); + framer + .byte_framer + .set_host_escape_disambiguation_active(true); + + assert!(framer.push(prefix).is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(tail); + + assert!(matches!( + events.as_slice(), + [RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 4, + row: 4, + .. + })] + )); + } + } + + #[test] + fn confirmed_host_disambiguation_drops_stale_escape_before_plain_input() { + let mut framer = RawInputFramer::default(); + framer + .byte_framer + .set_host_escape_disambiguation_active(true); + + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(b"x"); + + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + + #[test] + fn confirmed_host_disambiguation_keeps_kitty_escape_immediate() { + let mut framer = RawInputFramer::default(); + framer + .byte_framer + .set_host_escape_disambiguation_active(true); + + let events = framer.push(b"\x1b[27u"); + + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Esc, + KeyModifiers::empty(), + ); + } + #[test] fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { let mut framer = RawInputFramer::default(); diff --git a/tests/client_mode.rs b/tests/client_mode.rs index b412d106..0b94b32e 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -344,6 +344,11 @@ fn direct_attach_initial_mouse_capture_follows_config() { "direct attach must enable host bracketed paste; output: {:?}", read_output(&output) ); + assert!( + !read_output(&output).contains("\x1b[?u"), + "direct attach must not query rendered-client keyboard state; output: {:?}", + read_output(&output) + ); let restore_watermark = output_len(&output); attach