Track terminal mouse ownership accurately
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s

This commit is contained in:
DuProcess
2026-07-17 18:09:35 -04:00
parent b88a347eba
commit 0c7b1b7706
+149 -58
View File
@@ -6801,8 +6801,7 @@ async fn run_terminal(
let before_mouse_strip = bytes.len(); let before_mouse_strip = bytes.len();
let (stripped_bytes, stripped_unowned_mouse) = strip_unowned_terminal_reports( let (stripped_bytes, stripped_unowned_mouse) = strip_unowned_terminal_reports(
bytes, bytes,
predictor.alternate_screen, predictor.mouse_tracking.enabled(),
predictor.mouse_tracking,
); );
bytes = stripped_bytes; bytes = stripped_bytes;
if stripped_unowned_mouse { 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 { fn should_strip_unowned_terminal_reports(mouse_tracking: bool) -> bool {
// Only a full-screen app that explicitly enabled mouse reporting owns these // Mouse mode, not alternate-screen mode, establishes ownership. Inline TUIs
// bytes. In every other state, local terminal mouse reports are stale UI // and multiplexers can legitimately request mouse reports on the main screen.
// noise and forwarding them can type SGR fragments at a normal shell prompt. !mouse_tracking
!(alternate_screen && mouse_tracking)
} }
fn strip_unowned_terminal_reports( fn strip_unowned_terminal_reports(bytes: Vec<u8>, mouse_tracking: bool) -> (Vec<u8>, bool) {
bytes: Vec<u8>, if !should_strip_unowned_terminal_reports(mouse_tracking) {
alternate_screen: bool,
mouse_tracking: bool,
) -> (Vec<u8>, bool) {
if !should_strip_unowned_terminal_reports(alternate_screen, mouse_tracking) {
return (bytes, false); return (bytes, false);
} }
let before = bytes.len(); let before = bytes.len();
@@ -9424,17 +9418,35 @@ const GLITCH_FORCE_MS: u128 = 250;
/// without retaining arbitrary terminal output. /// without retaining arbitrary terminal output.
const TERMINAL_OUTPUT_PARSE_TAIL: usize = 64; 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)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct TerminalModeChanges { struct TerminalModeChanges {
alternate_screen: Option<bool>, alternate_screen: Option<bool>,
mouse_tracking: Option<bool>, mouse_tracking_enable: Option<TerminalMouseMode>,
mouse_tracking_disable_mask: u8,
} }
fn terminal_modes_after_output( fn terminal_modes_after_output(
mut alternate_screen: bool, mut alternate_screen: bool,
mut mouse_tracking: bool, mut mouse_tracking: TerminalMouseMode,
bytes: &[u8], bytes: &[u8],
) -> (bool, bool) { ) -> (bool, TerminalMouseMode) {
let mut offset = 0usize; let mut offset = 0usize;
while offset < bytes.len() { while offset < bytes.len() {
let Some((next, changes)) = terminal_private_mode_changes(bytes, offset) else { 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 { if let Some(enable) = changes.alternate_screen {
alternate_screen = enable; alternate_screen = enable;
} }
if let Some(enable) = changes.mouse_tracking { if let Some(mode) = changes.mouse_tracking_enable {
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); offset = next.max(offset + 1);
} }
@@ -9481,13 +9497,15 @@ fn terminal_private_mode_changes(
b'h' | b'l' => { b'h' | b'l' => {
let params = &bytes[params_start..cursor]; let params = &bytes[params_start..cursor];
let enable = byte == b'h'; let enable = byte == b'h';
let (mouse_tracking_enable, mouse_tracking_disable_mask) =
terminal_private_mouse_tracking_changes(params, enable);
return Some(( return Some((
cursor + 1, cursor + 1,
TerminalModeChanges { TerminalModeChanges {
alternate_screen: terminal_private_params_include_alt_screen(params) alternate_screen: terminal_private_params_include_alt_screen(params)
.then_some(enable), .then_some(enable),
mouse_tracking: terminal_private_params_include_mouse_tracking(params) mouse_tracking_enable,
.then_some(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")) .any(|param| matches!(param, b"47" | b"1047" | b"1049"))
} }
fn terminal_private_params_include_mouse_tracking(params: &[u8]) -> bool { fn terminal_private_mouse_tracking_changes(
params params: &[u8],
.split(|byte| matches!(byte, b';' | b':')) enable: bool,
.any(|param| { ) -> (Option<TerminalMouseMode>, u8) {
matches!( let mut enabled_mode = None;
param, let mut disabled_modes = 0u8;
b"1000" | b"1001" | b"1002" | b"1003" | b"1005" | b"1006" | b"1015" | b"1016" 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<TerminalMouseMode> {
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. /// 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. /// 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 /// When false, SGR mouse bytes from the local terminal are stale UI noise and
/// must not be forwarded into the shell prompt. /// must not be forwarded into the shell prompt.
mouse_tracking: bool, mouse_tracking: TerminalMouseMode,
/// Tail of recent terminal output, retained so alternate-screen transitions /// Tail of recent terminal output, retained so alternate-screen transitions
/// split across UDP frames are still detected. /// split across UDP frames are still detected.
output_parse_tail: Vec<u8>, output_parse_tail: Vec<u8>,
@@ -9585,7 +9633,7 @@ impl Predictor {
mode, mode,
enabled: enabled && mode != PredictMode::Off, enabled: enabled && mode != PredictMode::Off,
alternate_screen: false, alternate_screen: false,
mouse_tracking: false, mouse_tracking: TerminalMouseMode::None,
output_parse_tail: Vec::new(), output_parse_tail: Vec::new(),
cells: Vec::new(), cells: Vec::new(),
cursor: 0, cursor: 0,
@@ -9609,7 +9657,7 @@ impl Predictor {
self.epoch += 1; self.epoch += 1;
self.confirmed_epoch = self.epoch - 1; self.confirmed_epoch = self.epoch - 1;
self.alternate_screen = false; self.alternate_screen = false;
self.mouse_tracking = false; self.mouse_tracking = TerminalMouseMode::None;
self.output_parse_tail.clear(); self.output_parse_tail.clear();
self.oldest_pending_at = None; self.oldest_pending_at = None;
} }
@@ -9787,7 +9835,7 @@ impl Predictor {
"client.terminal_modes", "client.terminal_modes",
&[ &[
("alt", self.alternate_screen.to_string()), ("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)), ("bytes", dosh::trace::bytes_summary(bytes)),
], ],
); );
@@ -11883,29 +11931,84 @@ mod tests {
predictor.observe_output(b"\x1b[?1000;1006h"); predictor.observe_output(b"\x1b[?1000;1006h");
assert!(!predictor.alternate_screen); assert!(!predictor.alternate_screen);
assert!(predictor.mouse_tracking); assert!(predictor.mouse_tracking.enabled());
predictor.observe_output(b"\x1b[?1006;1000l"); predictor.observe_output(b"\x1b[?1006;1000l");
assert!(!predictor.mouse_tracking); assert!(!predictor.mouse_tracking.enabled());
predictor.observe_output(b"\x1b[?1000;1049h"); predictor.observe_output(b"\x1b[?1000;1049h");
assert!(predictor.alternate_screen); assert!(predictor.alternate_screen);
assert!(predictor.mouse_tracking); assert!(predictor.mouse_tracking.enabled());
predictor.observe_output(b"\x1b[?1049l"); predictor.observe_output(b"\x1b[?1049l");
assert!(!predictor.alternate_screen); assert!(!predictor.alternate_screen);
assert!(predictor.mouse_tracking); assert!(predictor.mouse_tracking.enabled());
} }
#[test] #[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); let mut predictor = Predictor::new(true);
predictor.observe_output(b"\x1b[?1016h"); 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"); 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] #[test]
@@ -11925,11 +12028,9 @@ mod tests {
} }
#[test] #[test]
fn unowned_mouse_reports_are_stripped_unless_tui_owns_mouse() { fn mouse_tracking_alone_establishes_terminal_report_ownership() {
assert!(should_strip_unowned_terminal_reports(false, false)); assert!(should_strip_unowned_terminal_reports(false));
assert!(should_strip_unowned_terminal_reports(false, true)); assert!(!should_strip_unowned_terminal_reports(true));
assert!(should_strip_unowned_terminal_reports(true, false));
assert!(!should_strip_unowned_terminal_reports(true, true));
} }
#[test] #[test]
@@ -13856,24 +13957,14 @@ mod tests {
} }
#[test] #[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 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!(changed);
assert_eq!(stripped, b"cmd\r"); assert_eq!(stripped, b"cmd\r");
let (stripped, changed) = strip_unowned_terminal_reports(input.clone(), false, true); let (preserved, changed) = strip_unowned_terminal_reports(b"\x1b[<35;10;1M".to_vec(), 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);
assert!(!changed); assert!(!changed);
assert_eq!(preserved, b"\x1b[<35;10;1M"); assert_eq!(preserved, b"\x1b[<35;10;1M");
} }