fix: preserve delayed mouse reports with confirmed keyboard input (#4247)

* fix: preserve delayed mouse reports with confirmed keyboard input

refs #3480

* fix: capture geometry before replaying buffered mouse input

refs #3480

---------

Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
akbash
2026-09-20 16:10:16 +02:00
committed by GitHub
co-authored by akbash-bot JJ Liebig
parent 29f9f4056f
commit c00a62dda1
8 changed files with 505 additions and 33 deletions
+60 -25
View File
@@ -42,6 +42,8 @@ pub fn stdin_reader_loop(
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
host_escape_disambiguation_active: bool,
initial_host_input: Vec<u8>,
#[cfg(unix)] direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>,
#[cfg(unix)] direct_response_active: Arc<AtomicBool>,
) {
@@ -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<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
host_escape_disambiguation_active: bool,
initial_host_input: Vec<u8>,
direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>,
direct_response_active: Arc<AtomicBool>,
) {
@@ -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<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
#[cfg(unix)]
fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
#[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()
}
// ---------------------------------------------------------------------------
+2
View File
@@ -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<u8>,
pub(super) endpoint_keybindings: bool,
pub(super) remote_image_paste_key:
Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+13 -3
View File
@@ -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<AtomicBool>,
config: ClientLoopConfig,
mut config: ClientLoopConfig,
attach_escape: Option<AttachEscapeState>,
_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)]
+292 -4
View File
@@ -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<TerminalGuard> {
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<u8>,
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<WindowsInputModeRestore>,
}
#[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<u16>,
primary_device_attributes: bool,
}
#[cfg(not(windows))]
fn query_host_escape_disambiguation() -> (bool, Vec<u8>) {
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<u8>,
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<Option<usize>> {
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<u8>) {
(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<u8> {
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();
+1 -1
View File
@@ -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 {
+23
View File
@@ -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<usize> {
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<bool> {
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)
+109
View File
@@ -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();
+5
View File
@@ -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