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 [