diff --git a/Cargo.lock b/Cargo.lock index d440d56..f2507a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "dosh" -version = "1.0.0-rc8" +version = "1.0.0-rc9" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 2ce2207..965f8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dosh" -version = "1.0.0-rc8" +version = "1.0.0-rc9" edition = "2024" license = "MIT" diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 864ba5b..e311d81 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -6296,6 +6296,60 @@ const FLAG_TRIGGER_LOW_MS: f64 = 50.0; /// (covers a sudden latency spike on the very first slow keystroke). const GLITCH_FORCE_MS: u128 = 250; +/// Enough recent bytes to join a CSI private-mode sequence split across frames +/// without retaining arbitrary terminal output. +const TERMINAL_OUTPUT_PARSE_TAIL: usize = 64; + +fn alternate_screen_after_output(mut active: bool, bytes: &[u8]) -> bool { + let mut offset = 0usize; + while offset < bytes.len() { + let Some((next, transition)) = terminal_private_mode_transition(bytes, offset) else { + offset += 1; + continue; + }; + if let Some(enable) = transition { + active = enable; + } + offset = next.max(offset + 1); + } + active +} + +fn terminal_private_mode_transition(bytes: &[u8], offset: usize) -> Option<(usize, Option)> { + let mut cursor = offset; + match bytes.get(cursor).copied()? { + 0x1b if bytes.get(cursor + 1) == Some(&b'[') => cursor += 2, + 0x9b => cursor += 1, + _ => return None, + } + + if bytes.get(cursor) != Some(&b'?') { + return None; + } + cursor += 1; + + let params_start = cursor; + while let Some(byte) = bytes.get(cursor).copied() { + match byte { + b'0'..=b'9' | b';' | b':' => cursor += 1, + b'h' | b'l' => { + let params = &bytes[params_start..cursor]; + let touches_alt = terminal_private_params_include_alt_screen(params); + return Some((cursor + 1, touches_alt.then_some(byte == b'h'))); + } + 0x40..=0x7e => return Some((cursor + 1, None)), + _ => return None, + } + } + None +} + +fn terminal_private_params_include_alt_screen(params: &[u8]) -> bool { + params + .split(|byte| matches!(byte, b';' | b':')) + .any(|param| matches!(param, b"47" | b"1047" | b"1049")) +} + /// One speculatively-echoed character on the current line. #[derive(Clone, Debug)] struct PredictedCell { @@ -6313,6 +6367,9 @@ struct Predictor { /// as vim/htop); we never speculate there because we cannot model arbitrary /// cursor addressing safely. alternate_screen: bool, + /// Tail of recent terminal output, retained so alternate-screen transitions + /// split across UDP frames are still detected. + output_parse_tail: Vec, /// Predicted cells for the current line, left-to-right starting at the /// column where the cursor sat when this run of predictions began. @@ -6359,6 +6416,7 @@ impl Predictor { mode, enabled: enabled && mode != PredictMode::Off, alternate_screen: false, + output_parse_tail: Vec::new(), cells: Vec::new(), cursor: 0, epoch: 1, @@ -6381,6 +6439,7 @@ impl Predictor { self.epoch += 1; self.confirmed_epoch = self.epoch - 1; self.alternate_screen = false; + self.output_parse_tail.clear(); self.oldest_pending_at = None; } @@ -6537,20 +6596,20 @@ impl Predictor { /// and confirms outstanding predictions: a fresh frame means the server has /// re-rendered the line, so our speculative glyphs are superseded and erased. fn observe_output(&mut self, bytes: &[u8]) { - if contains_bytes(bytes, b"\x1b[?1049h") - || contains_bytes(bytes, b"\x1b[?1047h") - || contains_bytes(bytes, b"\x1b[?47h") - { - self.alternate_screen = true; - let _ = self.discard_all(); - } - if contains_bytes(bytes, b"\x1b[?1049l") - || contains_bytes(bytes, b"\x1b[?1047l") - || contains_bytes(bytes, b"\x1b[?47l") - { - self.alternate_screen = false; + let mut parse = Vec::with_capacity(self.output_parse_tail.len() + bytes.len()); + parse.extend_from_slice(&self.output_parse_tail); + parse.extend_from_slice(bytes); + + let before = self.alternate_screen; + self.alternate_screen = alternate_screen_after_output(self.alternate_screen, &parse); + if self.alternate_screen != before { let _ = self.discard_all(); } + + self.output_parse_tail.clear(); + let keep = parse.len().min(TERMINAL_OUTPUT_PARSE_TAIL); + self.output_parse_tail + .extend_from_slice(&parse[parse.len().saturating_sub(keep)..]); } /// Sample SRTT from a newly arrived frame and confirm/clear outstanding @@ -7344,10 +7403,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!( "\x1b[?1002l", "\x1b[?1001l", "\x1b[?1000l", + "\x1b[?1004l", "\x1b[?1015l", "\x1b[?1006l", "\x1b[?1005l", "\x1b[?2004l", + "\x1b[?47l", + "\x1b[?1047l", "\x1b[?1049l" ) .as_bytes(); @@ -7372,8 +7434,9 @@ mod tests { should_hold_during_startup_gate, should_hold_post_submit_input, should_repaint_idle_alternate_screen, split_after_command_submit, ssh_destination_host, ssh_username, ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports, - strip_terminal_focus_reports, toml_bare_key_or_quoted, update_check_requested, - update_version_status, upsert_managed_block, valid_forward_host, vscode_safe_alias, + strip_terminal_focus_reports, terminal_private_mode_transition, toml_bare_key_or_quoted, + update_check_requested, update_version_status, upsert_managed_block, valid_forward_host, + vscode_safe_alias, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -7892,6 +7955,61 @@ mod tests { assert!(!predictor.alternate_screen); } + #[test] + fn predictor_tracks_legacy_and_combined_alternate_screen_modes() { + let mut predictor = Predictor::new(true); + + predictor.observe_output(b"\x1b[?1000;1006;1049h"); + assert!(predictor.alternate_screen); + + predictor.observe_output(b"\x1b[?1006;1000l"); + assert!( + predictor.alternate_screen, + "leaving mouse modes must not imply leaving alternate screen" + ); + + predictor.observe_output(b"\x1b[?1047l"); + assert!(!predictor.alternate_screen); + + predictor.observe_output(b"\x1b[?47h"); + assert!(predictor.alternate_screen); + predictor.observe_output(b"\x1b[?47l"); + assert!(!predictor.alternate_screen); + } + + #[test] + fn predictor_tracks_alternate_screen_split_across_frames() { + let mut predictor = Predictor::new(true); + + predictor.observe_output(b"\x1b"); + assert!(!predictor.alternate_screen); + predictor.observe_output(b"[?"); + assert!(!predictor.alternate_screen); + predictor.observe_output(b"10"); + assert!(!predictor.alternate_screen); + predictor.observe_output(b"49h"); + assert!(predictor.alternate_screen); + + predictor.observe_output(b"\x1b"); + assert!(predictor.alternate_screen); + predictor.observe_output(b"[?104"); + assert!(predictor.alternate_screen); + predictor.observe_output(b"9l"); + assert!(!predictor.alternate_screen); + } + + #[test] + fn private_mode_parser_ignores_non_alt_modes() { + assert_eq!( + terminal_private_mode_transition(b"\x1b[?1000;1006h", 0), + Some((13, None)) + ); + assert_eq!( + terminal_private_mode_transition(b"\x1b[?1000;1049h", 0), + Some((13, Some(true))) + ); + } + /// Typing printable characters builds an in-order predicted line and the /// local cursor tracks the end of it. #[test]