From 0c7b1b770694e7c8e9fa8bb845579130522add0e Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:09:35 -0400 Subject: [PATCH] Track terminal mouse ownership accurately --- src/bin/dosh-client.rs | 207 +++++++++++++++++++++++++++++------------ 1 file changed, 149 insertions(+), 58 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index e49bf97..eac71a1 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -6801,8 +6801,7 @@ async fn run_terminal( let before_mouse_strip = bytes.len(); let (stripped_bytes, stripped_unowned_mouse) = strip_unowned_terminal_reports( bytes, - predictor.alternate_screen, - predictor.mouse_tracking, + predictor.mouse_tracking.enabled(), ); bytes = stripped_bytes; if stripped_unowned_mouse { @@ -8426,19 +8425,14 @@ fn trace_wake_repaint_retry_arm(trigger: &str, elapsed: Duration) { ); } -fn should_strip_unowned_terminal_reports(alternate_screen: bool, mouse_tracking: bool) -> bool { - // Only a full-screen app that explicitly enabled mouse reporting owns these - // bytes. In every other state, local terminal mouse reports are stale UI - // noise and forwarding them can type SGR fragments at a normal shell prompt. - !(alternate_screen && mouse_tracking) +fn should_strip_unowned_terminal_reports(mouse_tracking: bool) -> bool { + // Mouse mode, not alternate-screen mode, establishes ownership. Inline TUIs + // and multiplexers can legitimately request mouse reports on the main screen. + !mouse_tracking } -fn strip_unowned_terminal_reports( - bytes: Vec, - alternate_screen: bool, - mouse_tracking: bool, -) -> (Vec, bool) { - if !should_strip_unowned_terminal_reports(alternate_screen, mouse_tracking) { +fn strip_unowned_terminal_reports(bytes: Vec, mouse_tracking: bool) -> (Vec, bool) { + if !should_strip_unowned_terminal_reports(mouse_tracking) { return (bytes, false); } let before = bytes.len(); @@ -9424,17 +9418,35 @@ const GLITCH_FORCE_MS: u128 = 250; /// without retaining arbitrary terminal output. const TERMINAL_OUTPUT_PARSE_TAIL: usize = 64; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum TerminalMouseMode { + #[default] + None, + X10, + PressRelease, + Highlight, + ButtonMotion, + AnyMotion, +} + +impl TerminalMouseMode { + fn enabled(self) -> bool { + self != Self::None + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct TerminalModeChanges { alternate_screen: Option, - mouse_tracking: Option, + mouse_tracking_enable: Option, + mouse_tracking_disable_mask: u8, } fn terminal_modes_after_output( mut alternate_screen: bool, - mut mouse_tracking: bool, + mut mouse_tracking: TerminalMouseMode, bytes: &[u8], -) -> (bool, bool) { +) -> (bool, TerminalMouseMode) { let mut offset = 0usize; while offset < bytes.len() { let Some((next, changes)) = terminal_private_mode_changes(bytes, offset) else { @@ -9444,8 +9456,12 @@ fn terminal_modes_after_output( if let Some(enable) = changes.alternate_screen { alternate_screen = enable; } - if let Some(enable) = changes.mouse_tracking { - mouse_tracking = enable; + if let Some(mode) = changes.mouse_tracking_enable { + mouse_tracking = mode; + } else if terminal_mouse_mode_mask(mouse_tracking) & changes.mouse_tracking_disable_mask + != 0 + { + mouse_tracking = TerminalMouseMode::None; } offset = next.max(offset + 1); } @@ -9481,13 +9497,15 @@ fn terminal_private_mode_changes( b'h' | b'l' => { let params = &bytes[params_start..cursor]; let enable = byte == b'h'; + let (mouse_tracking_enable, mouse_tracking_disable_mask) = + terminal_private_mouse_tracking_changes(params, enable); return Some(( cursor + 1, TerminalModeChanges { alternate_screen: terminal_private_params_include_alt_screen(params) .then_some(enable), - mouse_tracking: terminal_private_params_include_mouse_tracking(params) - .then_some(enable), + mouse_tracking_enable, + mouse_tracking_disable_mask, }, )); } @@ -9504,15 +9522,45 @@ fn terminal_private_params_include_alt_screen(params: &[u8]) -> bool { .any(|param| matches!(param, b"47" | b"1047" | b"1049")) } -fn terminal_private_params_include_mouse_tracking(params: &[u8]) -> bool { - params - .split(|byte| matches!(byte, b';' | b':')) - .any(|param| { - matches!( - param, - b"1000" | b"1001" | b"1002" | b"1003" | b"1005" | b"1006" | b"1015" | b"1016" - ) - }) +fn terminal_private_mouse_tracking_changes( + params: &[u8], + enable: bool, +) -> (Option, u8) { + let mut enabled_mode = None; + let mut disabled_modes = 0u8; + for param in params.split(|byte| matches!(byte, b';' | b':')) { + let Some(mode) = terminal_mouse_mode(param) else { + continue; + }; + if enable { + enabled_mode = Some(mode); + } else { + disabled_modes |= terminal_mouse_mode_mask(mode); + } + } + (enabled_mode, disabled_modes) +} + +fn terminal_mouse_mode(param: &[u8]) -> Option { + match param { + b"9" => Some(TerminalMouseMode::X10), + b"1000" => Some(TerminalMouseMode::PressRelease), + b"1001" => Some(TerminalMouseMode::Highlight), + b"1002" => Some(TerminalMouseMode::ButtonMotion), + b"1003" => Some(TerminalMouseMode::AnyMotion), + _ => None, + } +} + +fn terminal_mouse_mode_mask(mode: TerminalMouseMode) -> u8 { + match mode { + TerminalMouseMode::None => 0, + TerminalMouseMode::X10 => 1 << 0, + TerminalMouseMode::PressRelease => 1 << 1, + TerminalMouseMode::Highlight => 1 << 2, + TerminalMouseMode::ButtonMotion => 1 << 3, + TerminalMouseMode::AnyMotion => 1 << 4, + } } /// One speculatively-echoed character on the current line. @@ -9535,7 +9583,7 @@ struct Predictor { /// True while the server-side program has requested terminal mouse reports. /// When false, SGR mouse bytes from the local terminal are stale UI noise and /// must not be forwarded into the shell prompt. - mouse_tracking: bool, + mouse_tracking: TerminalMouseMode, /// Tail of recent terminal output, retained so alternate-screen transitions /// split across UDP frames are still detected. output_parse_tail: Vec, @@ -9585,7 +9633,7 @@ impl Predictor { mode, enabled: enabled && mode != PredictMode::Off, alternate_screen: false, - mouse_tracking: false, + mouse_tracking: TerminalMouseMode::None, output_parse_tail: Vec::new(), cells: Vec::new(), cursor: 0, @@ -9609,7 +9657,7 @@ impl Predictor { self.epoch += 1; self.confirmed_epoch = self.epoch - 1; self.alternate_screen = false; - self.mouse_tracking = false; + self.mouse_tracking = TerminalMouseMode::None; self.output_parse_tail.clear(); self.oldest_pending_at = None; } @@ -9787,7 +9835,7 @@ impl Predictor { "client.terminal_modes", &[ ("alt", self.alternate_screen.to_string()), - ("mouse", self.mouse_tracking.to_string()), + ("mouse", self.mouse_tracking.enabled().to_string()), ("bytes", dosh::trace::bytes_summary(bytes)), ], ); @@ -11883,29 +11931,84 @@ mod tests { predictor.observe_output(b"\x1b[?1000;1006h"); assert!(!predictor.alternate_screen); - assert!(predictor.mouse_tracking); + assert!(predictor.mouse_tracking.enabled()); predictor.observe_output(b"\x1b[?1006;1000l"); - assert!(!predictor.mouse_tracking); + assert!(!predictor.mouse_tracking.enabled()); predictor.observe_output(b"\x1b[?1000;1049h"); assert!(predictor.alternate_screen); - assert!(predictor.mouse_tracking); + assert!(predictor.mouse_tracking.enabled()); predictor.observe_output(b"\x1b[?1049l"); assert!(!predictor.alternate_screen); - assert!(predictor.mouse_tracking); + assert!(predictor.mouse_tracking.enabled()); } #[test] - fn predictor_tracks_pixel_mouse_mode() { + fn main_screen_tui_mouse_input_survives_until_mode_is_disabled() { + let mut predictor = Predictor::new(true); + let report = b"\x1b[<0;12;8M".to_vec(); + + predictor.observe_output(b"\x1b[?1000;1006h"); + assert!(!predictor.alternate_screen); + let (preserved, changed) = + strip_unowned_terminal_reports(report.clone(), predictor.mouse_tracking.enabled()); + assert!(!changed); + assert_eq!(preserved, report); + + predictor.observe_output(b"\x1b[?1006;1000l"); + let (stripped, changed) = + strip_unowned_terminal_reports(report, predictor.mouse_tracking.enabled()); + assert!(changed); + assert!(stripped.is_empty()); + } + + #[test] + fn mouse_encoding_alone_does_not_claim_input_and_cannot_disable_tracking() { let mut predictor = Predictor::new(true); predictor.observe_output(b"\x1b[?1016h"); - assert!(predictor.mouse_tracking); + assert!(!predictor.mouse_tracking.enabled()); + + predictor.observe_output(b"\x1b[?1003h"); + assert!(predictor.mouse_tracking.enabled()); predictor.observe_output(b"\x1b[?1016l"); - assert!(!predictor.mouse_tracking); + assert!(predictor.mouse_tracking.enabled()); + + predictor.observe_output(b"\x1b[?1003l"); + assert!(!predictor.mouse_tracking.enabled()); + } + + #[test] + fn disabling_replaced_mouse_protocol_does_not_clear_active_mode() { + let mut predictor = Predictor::new(true); + + predictor.observe_output(b"\x1b[?1000h\x1b[?1003h"); + predictor.observe_output(b"\x1b[?1000l"); + assert!(predictor.mouse_tracking.enabled()); + + predictor.observe_output(b"\x1b[?1003l"); + assert!(!predictor.mouse_tracking.enabled()); + } + + #[test] + fn mouse_protocol_state_survives_sequences_split_across_frames() { + let mut predictor = Predictor::new(true); + + predictor.observe_output(b"\x1b[?10"); + assert!(!predictor.mouse_tracking.enabled()); + predictor.observe_output(b"00;1006h"); + assert!(predictor.mouse_tracking.enabled()); + + predictor.observe_output(b"\x1b[?10"); + predictor.observe_output(b"06l"); + assert!(predictor.mouse_tracking.enabled()); + + predictor.observe_output(b"\x1b[?100"); + predictor.observe_output(b"0l"); + assert!(!predictor.mouse_tracking.enabled()); } #[test] @@ -11925,11 +12028,9 @@ mod tests { } #[test] - fn unowned_mouse_reports_are_stripped_unless_tui_owns_mouse() { - assert!(should_strip_unowned_terminal_reports(false, false)); - assert!(should_strip_unowned_terminal_reports(false, true)); - assert!(should_strip_unowned_terminal_reports(true, false)); - assert!(!should_strip_unowned_terminal_reports(true, true)); + fn mouse_tracking_alone_establishes_terminal_report_ownership() { + assert!(should_strip_unowned_terminal_reports(false)); + assert!(!should_strip_unowned_terminal_reports(true)); } #[test] @@ -13856,24 +13957,14 @@ mod tests { } #[test] - fn unowned_terminal_mouse_reports_strip_unless_tui_owns_mouse() { + fn unowned_terminal_mouse_reports_strip_unless_application_owns_mouse() { let input = b"\x1b[<35;10;1Mcmd\r".to_vec(); - let (stripped, changed) = strip_unowned_terminal_reports(input.clone(), false, false); + let (stripped, changed) = strip_unowned_terminal_reports(input.clone(), false); assert!(changed); assert_eq!(stripped, b"cmd\r"); - let (stripped, changed) = strip_unowned_terminal_reports(input.clone(), false, true); - assert!(changed); - assert_eq!(stripped, b"cmd\r"); - - let (stripped, changed) = - strip_unowned_terminal_reports(b"\x1b[<35;10;1M".to_vec(), true, false); - assert!(changed); - assert!(stripped.is_empty()); - - let (preserved, changed) = - strip_unowned_terminal_reports(b"\x1b[<35;10;1M".to_vec(), true, true); + let (preserved, changed) = strip_unowned_terminal_reports(b"\x1b[<35;10;1M".to_vec(), true); assert!(!changed); assert_eq!(preserved, b"\x1b[<35;10;1M"); }