diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-03 02:49:27 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-03 02:49:27 +0300 |
| commit | aa0768b734d95a1f54df1719290d977df796068d (patch) | |
| tree | f173bf0e14b4c951e9ad2dc02dc7e66f102b89b4 /src | |
| parent | e1bbc252e2b40da6e0c6d7b0f8d7babb17cc8814 (diff) | |
fix: stop sgr mouse sequences leaking into panes
refs #939
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/input.rs | 60 | ||||
| -rw-r--r-- | src/client/mod.rs | 23 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/raw_input.rs | 180 | ||||
| -rw-r--r-- | src/terminal_modes.rs | 32 |
6 files changed, 292 insertions, 8 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index c9d54f46..7548e39e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1096,6 +1096,7 @@ impl App { if desired == *active { return Ok(()); } + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; if desired { execute!(io::stdout(), EnableMouseCapture)?; } else { diff --git a/src/client/input.rs b/src/client/input.rs index 7099311b..bc7faf35 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -39,15 +39,21 @@ pub fn stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_mouse_capture_active: Arc<AtomicBool>, ) { #[cfg(windows)] { - let _ = host_color_query_sent; + let _ = (host_color_query_sent, host_mouse_capture_active); windows_stdin_reader_loop(event_tx, should_quit); } #[cfg(unix)] - unix_stdin_reader_loop(event_tx, should_quit, host_color_query_sent); + unix_stdin_reader_loop( + event_tx, + should_quit, + host_color_query_sent, + host_mouse_capture_active, + ); } #[cfg(unix)] @@ -55,6 +61,7 @@ fn unix_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_mouse_capture_active: Arc<AtomicBool>, ) { let stdin = io::stdin(); let mut reader = stdin.lock(); @@ -78,7 +85,11 @@ fn unix_stdin_reader_loop( } } - if stdin_read_ready(&reader, 10) == Some(false) { + let timeout_ms = idle_flush_timeout_ms( + &framer, + host_mouse_capture_active.load(Ordering::Acquire), + ); + if stdin_read_ready(&reader, timeout_ms) == Some(false) { let had_pending = framer.has_pending_input(); let chunks = framer.flush_timeout(); let held_escape = had_pending && chunks.is_empty(); @@ -90,7 +101,12 @@ fn unix_stdin_reader_loop( return; } } - if held_escape && stdin_read_ready(&reader, 10) == Some(false) { + if held_escape + && stdin_read_ready( + &reader, + crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS, + ) == Some(false) + { for data in framer.flush_timeout() { if event_tx .blocking_send(ClientLoopEvent::StdinInput(data)) @@ -112,6 +128,18 @@ fn unix_stdin_reader_loop( } } +#[cfg(unix)] +fn idle_flush_timeout_ms( + framer: &crate::raw_input::RawInputByteFramer, + host_mouse_capture_active: bool, +) -> i32 { + if host_mouse_capture_active && framer.has_pending_lone_escape() { + crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS + } else { + crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + } +} + #[cfg(windows)] fn windows_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, @@ -366,6 +394,30 @@ mod tests { _ => panic!("expected StdinInput event"), } } + + #[test] + fn raw_input_idle_flush_timeout_keeps_escape_responsive() { + let timeout_ms = std::hint::black_box(crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS); + assert!(timeout_ms <= 20); + } + + #[test] + fn mouse_active_lone_escape_gets_longer_reassembly_window() { + let mut framer = crate::raw_input::RawInputByteFramer::default(); + assert!(framer.push(b"\x1b").is_empty()); + + assert_eq!( + idle_flush_timeout_ms(&framer, false), + crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + ); + assert_eq!( + idle_flush_timeout_ms(&framer, true), + crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS + ); + let mouse_timeout_ms = + std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS); + assert!(mouse_timeout_ms > 100); + } } #[cfg(test)] diff --git a/src/client/mod.rs b/src/client/mod.rs index 92edb900..ceed9826 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -312,7 +312,14 @@ impl std::error::Error for ClientError { impl From<protocol::FramingError> for ClientError { fn from(err: protocol::FramingError) -> Self { - ClientError::Protocol(err) + match err { + protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new( + io::ErrorKind::UnexpectedEof, + "server closed connection", + )), + protocol::FramingError::Io(err) => ClientError::ConnectionLost(err), + err => ClientError::Protocol(err), + } } } @@ -341,6 +348,7 @@ fn setup_terminal_with_capabilities( mouse_capture: bool, ) -> io::Result<TerminalGuard> { ratatui::init(); + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; let host_color_scheme_reports = should_enable_host_color_scheme_reports(enable_client_protocols); @@ -536,6 +544,7 @@ fn restore_windows_input_mode_value(mode: u32) { } fn set_mouse_capture(enabled: bool) -> io::Result<()> { + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; if enabled { execute!(io::stdout(), EnableMouseCapture) } else { @@ -569,6 +578,7 @@ fn restore_terminal_state( DisableBracketedPaste, DisableMouseCapture ); + let _ = crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout()); #[cfg(windows)] if let Some(mode) = restore_windows_input_mode { restore_windows_input_mode_value(mode); @@ -1100,6 +1110,7 @@ fn run_client_with_mode( init_logging(); let loaded_config = crate::config::Config::load(); + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; let mouse_capture = loaded_config.config.ui.mouse_capture; let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained; @@ -1283,6 +1294,7 @@ async fn run_client_loop( draw_host_cursor, }; debug!(?negotiated_encoding, "client render encoding active"); + let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -1292,8 +1304,14 @@ async fn run_client_loop( state.attach_escape.is_none() && should_query_host_terminal_theme(); let stdin_quit = should_quit.clone(); let stdin_tx = event_tx.clone(); + let stdin_mouse_capture_active = host_mouse_capture_active.clone(); std::thread::spawn(move || { - input::stdin_reader_loop(stdin_tx, &stdin_quit, will_query_host_terminal_theme); + input::stdin_reader_loop( + stdin_tx, + &stdin_quit, + will_query_host_terminal_theme, + stdin_mouse_capture_active, + ); }); if will_query_host_terminal_theme { @@ -1533,6 +1551,7 @@ async fn run_client_loop( let _ = enable_windows_virtual_terminal_input(); } state.mouse_capture_active = desired; + host_mouse_capture_active.store(desired, Ordering::Release); } } ServerMessage::Welcome { .. } => { diff --git a/src/main.rs b/src/main.rs index 0eba4968..1876c8e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,6 +90,7 @@ mod server; mod session; mod sound; mod terminal; +mod terminal_modes; mod terminal_notify; mod terminal_theme; mod ui; @@ -712,6 +713,7 @@ fn main() -> io::Result<()> { DisableBracketedPaste, DisableMouseCapture ); + let _ = crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout()); let _ = set_host_color_scheme_reports(false); let _ = pop_keyboard_enhancement_flags(); ratatui::restore(); @@ -733,6 +735,7 @@ fn main() -> io::Result<()> { let result = rt.block_on(async { let mut terminal = ratatui::init(); + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; if config.ui.mouse_capture { execute!(io::stdout(), EnableMouseCapture)?; } else { @@ -777,6 +780,7 @@ fn main() -> io::Result<()> { DisableBracketedPaste, DisableMouseCapture )?; + crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?; set_host_color_scheme_reports(false)?; ratatui::restore(); diff --git a/src/raw_input.rs b/src/raw_input.rs index b07641a5..284761c6 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -100,6 +100,8 @@ use crate::terminal_theme::{ }; const ESC: u8 = 0x1b; +pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10; +pub(crate) const MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS: i32 = 150; pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n"; pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; @@ -175,12 +177,14 @@ pub(crate) struct RawInputByteFramer { buffer: Vec<u8>, discard_until: Option<ControlStringFamily>, discarded_tail_bytes: usize, + lone_escape_recently_flushed: bool, host_color_replies_awaited: u8, held_pending_color_esc: bool, host_color_scheme_change_tracking: bool, } const HOST_COLOR_QUERY_REPLIES: u8 = 2; +const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32; impl RawInputByteFramer { pub(crate) fn push(&mut self, data: &[u8]) -> Vec<Vec<u8>> { @@ -203,6 +207,10 @@ impl RawInputByteFramer { !self.buffer.is_empty() } + pub(crate) fn has_pending_lone_escape(&self) -> bool { + self.buffer.as_slice() == [ESC] + } + #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.buffer.starts_with(BRACKETED_PASTE_START) @@ -232,6 +240,20 @@ impl RawInputByteFramer { return chunks; } + if self.lone_escape_recently_flushed && self.buffer.starts_with(b"[<") { + tracing::debug!( + len = self.buffer.len(), + "discarding incomplete orphaned SGR mouse tail after input timeout" + ); + discard_or_buffer_orphaned_sgr_mouse_tail( + &mut self.buffer, + &mut self.discard_until, + &mut self.discarded_tail_bytes, + ); + self.lone_escape_recently_flushed = false; + return chunks; + } + if self.buffer.starts_with(BRACKETED_PASTE_START) && find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none() { @@ -287,6 +309,7 @@ impl RawInputByteFramer { bytes = ?self.buffer, "flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc" ); + self.lone_escape_recently_flushed = true; chunks.push(std::mem::take(&mut self.buffer)); return chunks; } @@ -310,6 +333,7 @@ impl RawInputByteFramer { } tracing::debug!(bytes = ?self.buffer, "dropping incomplete raw input buffer after timeout"); + self.lone_escape_recently_flushed = false; self.buffer.clear(); chunks } @@ -318,6 +342,17 @@ impl RawInputByteFramer { let mut chunks = Vec::new(); loop { + if self.lone_escape_recently_flushed { + if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) { + break; + } + if discard_complete_orphaned_sgr_mouse_tail(&mut self.buffer) { + self.lone_escape_recently_flushed = false; + continue; + } + self.lone_escape_recently_flushed = false; + } + if let Some(family) = self.discard_until { let Some(terminator_len) = control_string_terminator_for_family(&self.buffer, family) @@ -378,6 +413,9 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> ControlStringFamily::HostColorSchemeCsi => buffer .iter() .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')), + ControlStringFamily::OrphanedSgrMouseTail => buffer + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')), } } @@ -415,12 +453,15 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { Ok(n) => { send_raw_input_events(framer.push(&scratch[..n]), &tx); - if stdin_read_ready(&reader, 10) == Some(false) { + if stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) == Some(false) { let had_pending = framer.has_pending_input(); let events = framer.flush_timeout(); let held_escape = had_pending && events.is_empty(); send_raw_input_events(events, &tx); - if held_escape && stdin_read_ready(&reader, 10) == Some(false) { + if held_escape + && stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) + == Some(false) + { send_raw_input_events(framer.flush_timeout(), &tx); } } @@ -589,6 +630,7 @@ enum ControlStringFamily { Osc, StTerminated, HostColorSchemeCsi, + OrphanedSgrMouseTail, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -709,6 +751,51 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> { Some(1 + escaped_char_width) } +fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool { + if buffer.len() > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES { + return false; + } + buffer.len() < 3 && b"[<".starts_with(buffer) + || buffer.starts_with(b"[<") + && buffer[2..] + .iter() + .all(|byte| byte.is_ascii_digit() || *byte == b';') +} + +fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>) -> bool { + let Some(terminator_len) = + control_string_terminator_for_family(buffer, ControlStringFamily::OrphanedSgrMouseTail) + else { + return false; + }; + if terminator_len > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES { + return false; + } + let mut sequence = Vec::with_capacity(terminator_len + 1); + sequence.push(ESC); + sequence.extend_from_slice(&buffer[..terminator_len]); + let Ok(sequence) = std::str::from_utf8(&sequence) else { + return false; + }; + if parse_sgr_mouse(sequence).is_none() { + return false; + } + buffer.drain(..terminator_len); + true +} + +fn discard_or_buffer_orphaned_sgr_mouse_tail( + buffer: &mut Vec<u8>, + discard_until: &mut Option<ControlStringFamily>, + discarded_tail_bytes: &mut usize, +) { + if !discard_complete_orphaned_sgr_mouse_tail(buffer) { + *discard_until = Some(ControlStringFamily::OrphanedSgrMouseTail); + *discarded_tail_bytes = 0; + buffer.clear(); + } +} + fn osc_string_terminator(buffer: &[u8]) -> Option<usize> { let st = find_subsequence(buffer, b"\x1b\\").map(|idx| idx + 2); let bel = buffer @@ -739,6 +826,10 @@ fn control_string_terminator_for_family( .iter() .position(|byte| *byte == b'n') .map(|idx| idx + 1), + ControlStringFamily::OrphanedSgrMouseTail => buffer + .iter() + .position(|byte| matches!(*byte, b'M' | b'm')) + .map(|idx| idx + 1), } } @@ -1379,6 +1470,91 @@ mod tests { } #[test] + fn escape_followed_by_sgr_mouse_before_flush_does_not_emit_text() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b").is_empty()); + let events = framer.push(b"[<65;43;26M"); + + assert_eq!(events.len(), 1); + assert!(matches!( + events[0], + RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 42, + row: 25, + .. + }) + )); + } + + #[test] + fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b").is_empty()); + let timeout_events = framer.flush_timeout(); + assert_eq!(timeout_events.len(), 1); + assert_raw_key( + timeout_events.into_iter().next().unwrap(), + KeyCode::Esc, + KeyModifiers::empty(), + ); + + assert!(framer.push(b"[<65;43;26M").is_empty()); + } + + #[test] + fn input_after_discarded_complete_sgr_mouse_tail_is_preserved() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout().len(), 1); + let events = framer.push(b"[<65;43;26Mx"); + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + + #[test] + fn invalid_orphaned_sgr_mouse_tail_after_escape_timeout_is_preserved() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout().len(), 1); + + let events = framer.push(b"[<x"); + + assert_eq!(events.len(), 3); + assert_raw_key( + events.into_iter().last().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + + #[test] + fn double_split_sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout().len(), 1); + + assert!(framer.push(b"[<65;4").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(b"3;26Mx"); + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + + #[test] fn escape_followed_by_alt_char_before_flush_becomes_alt_key() { let (tx, mut rx) = mpsc::channel(8); let mut buffer = Vec::new(); diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs new file mode 100644 index 00000000..953a5812 --- /dev/null +++ b/src/terminal_modes.rs @@ -0,0 +1,32 @@ +use std::io::{self, Write}; + +const DISABLE_HOST_MOUSE_REPORTING_SEQUENCE: &[u8] = + b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; + +#[cfg(not(windows))] +pub(crate) fn clear_host_mouse_reporting<W: Write>(writer: &mut W) -> io::Result<()> { + writer.write_all(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE)?; + writer.flush() +} + +#[cfg(windows)] +pub(crate) fn clear_host_mouse_reporting<W: Write>(_writer: &mut W) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clears_all_known_host_mouse_modes() { + let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap(); + + for mode in ["1000", "1002", "1003", "1005", "1006", "1015", "1016"] { + assert!( + sequence.contains(&format!("\x1b[?{mode}l")), + "missing mouse mode {mode}" + ); + } + } +} |
