aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/input.rs85
-rw-r--r--src/client/loop_config.rs2
-rw-r--r--src/client/mod.rs16
-rw-r--r--src/client/terminal_setup.rs296
-rw-r--r--src/platform/mod.rs2
-rw-r--r--src/platform/unix_common.rs23
-rw-r--r--src/raw_input.rs109
7 files changed, 500 insertions, 33 deletions
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<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()
}
// ---------------------------------------------------------------------------
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<u8>,
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<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)]
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<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();
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<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)
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
@@ -2017,6 +2061,71 @@ mod tests {
}
#[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();