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 (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<u8>,
alternate_screen: bool,
mouse_tracking: bool,
) -> (Vec<u8>, bool) {
if !should_strip_unowned_terminal_reports(alternate_screen, mouse_tracking) {
fn strip_unowned_terminal_reports(bytes: Vec<u8>, mouse_tracking: bool) -> (Vec<u8>, 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<bool>,
mouse_tracking: Option<bool>,
mouse_tracking_enable: Option<TerminalMouseMode>,
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<TerminalMouseMode>, 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<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.
@@ -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<u8>,
@@ -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");
}