From 0366a1da38b8f150bd4d341b9da56130b9b82ec6 Mon Sep 17 00:00:00 2001 From: dengos Date: Tue, 4 Aug 2026 14:01:38 +0000 Subject: [PATCH] fix(client): re-assert host mouse modes after a terminal reconnect A host terminal can be re-created underneath a live client. Web VS Code restores a reconnected terminal from a serialized snapshot that brings back the mouse tracking mode without the SGR encoding herdr asked for, so every mouse report then arrives in an encoding the client cannot parse and leaks into the focused pane as text. Recovering required detaching and reattaching. Re-assert the host mouse mode set on regained focus and on resize, both of which fire when a client reconnects. set_mouse_capture already clears host mouse reporting first, so this also drops the stale tracking mode. --- src/client/mod.rs | 20 ++++++++++++++++++++ src/raw_input.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/client/mod.rs b/src/client/mod.rs index c0fe73d348..390244db4c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -566,6 +566,16 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> { } } +/// Re-apply the host mouse mode set after the host surface may have been +/// re-created underneath us. `set_mouse_capture` clears host mouse reporting +/// first, so this also drops a tracking mode that a reconnect restored behind +/// herdr's back in an encoding the client cannot parse. +fn refresh_host_mouse_capture(enabled: bool) { + if let Err(err) = set_mouse_capture(enabled) { + warn!(err = %err, "failed to re-assert host mouse capture"); + } +} + fn restore_terminal_state( reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool, @@ -1452,6 +1462,9 @@ async fn run_client_loop( ) { state.request_repaint(); } + if crate::raw_input::events_require_host_mode_refresh(&events) { + refresh_host_mouse_capture(state.mouse_capture_active); + } if crate::raw_input::events_require_host_terminal_theme_query(&events) { query_host_terminal_theme(); } @@ -1527,6 +1540,9 @@ async fn run_client_loop( ) { state.request_repaint(); } + if crate::raw_input::events_require_host_mode_refresh(&raw_events) { + refresh_host_mouse_capture(state.mouse_capture_active); + } let msg = ClientMessage::InputEvents { events }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); @@ -1536,6 +1552,10 @@ async fn run_client_loop( state.reported_size = (new_cols, new_rows); // Resizing invalidates the host-side blit baseline. state.request_repaint(); + // A reconnect re-syncs the terminal size before the user touches + // anything, so this usually restores the mode set before the + // first stray mouse report can leak into a pane. + refresh_host_mouse_capture(state.mouse_capture_active); let msg = ClientMessage::Resize { cols: new_cols, rows: new_rows, diff --git a/src/raw_input.rs b/src/raw_input.rs index 5bd5771916..5fa9e2c492 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -578,6 +578,21 @@ pub(crate) fn events_require_host_surface_redraw( .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) } +/// A host terminal can be re-created underneath a live client. Web VS Code, for +/// example, restores a reconnected terminal from a serialized snapshot that +/// brings back mouse tracking without the SGR encoding herdr asked for, so every +/// mouse report then arrives in an encoding the client cannot parse and leaks +/// into the focused pane as text. Regained focus is the earliest signal that the +/// surface may be new, so use it to re-assert the host terminal modes. +/// +/// This is deliberately independent of `redraw_on_focus_gained`: that option +/// only controls repainting, while a stale mode set corrupts input regardless. +pub(crate) fn events_require_host_mode_refresh(events: &[RawInputEvent]) -> bool { + events + .iter() + .any(|event| matches!(event, RawInputEvent::OuterFocusGained)) +} + #[cfg(any(not(windows), test))] pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) -> bool { events @@ -1484,6 +1499,18 @@ mod tests { assert!(!events_require_host_surface_redraw(&events, true)); } + #[test] + fn outer_focus_gained_requests_host_mode_refresh() { + let events = parse_raw_input_bytes_sync(b"\x1b[I"); + assert!(events_require_host_mode_refresh(&events)); + + let events = parse_raw_input_bytes_sync(b"\x1b[O"); + assert!(!events_require_host_mode_refresh(&events)); + + let events = parse_raw_input_bytes_sync(b"a"); + assert!(!events_require_host_mode_refresh(&events)); + } + #[test] fn parses_ghostty_color_scheme_reports() { for bytes in [