From 93a210b420af39d64329f3f3831e39e297cd8ac4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 11 Jun 2026 16:11:56 -0400 Subject: [PATCH] Normalize application cursor arrows --- src/bin/dosh-client.rs | 78 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index e7430c1..4eca73b 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -555,6 +555,7 @@ async fn run_terminal( let mut status_tick = tokio::time::interval(Duration::from_secs(1)); let mut resize_tick = tokio::time::interval(Duration::from_millis(250)); let mut last_size = size().unwrap_or((80, 24)); + let mut input_normalizer = InputNormalizer::default(); if let Some(frame) = first_frame { render_frame(&frame)?; if frame.closed { @@ -591,6 +592,7 @@ async fn run_terminal( break; } if cred.mode != "view-only" { + let bytes = input_normalizer.normalize(&bytes); let body = protocol::to_body(&Input { bytes })?; let packet = protocol::encode_encrypted( PacketKind::Input, @@ -673,6 +675,54 @@ async fn run_terminal( Ok(()) } +#[derive(Default)] +struct InputNormalizer { + state: InputNormalizerState, +} + +#[derive(Default)] +enum InputNormalizerState { + #[default] + Ground, + Esc, + Ss3, +} + +impl InputNormalizer { + fn normalize(&mut self, bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len()); + for &byte in bytes { + match self.state { + InputNormalizerState::Ground => { + out.push(byte); + if byte == 0x1b { + self.state = InputNormalizerState::Esc; + } + } + InputNormalizerState::Esc => { + if byte == b'O' { + self.state = InputNormalizerState::Ss3; + } else { + out.push(byte); + self.state = InputNormalizerState::Ground; + } + } + InputNormalizerState::Ss3 => { + if matches!(byte, b'A'..=b'D') { + out.push(b'['); + out.push(byte); + } else { + out.push(b'O'); + out.push(byte); + } + self.state = InputNormalizerState::Ground; + } + } + } + out + } +} + async fn send_resize( socket: &UdpSocket, addr: SocketAddr, @@ -813,3 +863,31 @@ const TERMINAL_CLEANUP: &[u8] = concat!( "\x1b[?1049l" ) .as_bytes(); + +#[cfg(test)] +mod tests { + use super::InputNormalizer; + + #[test] + fn normalizes_ss3_cursor_keys_to_csi() { + let mut normalizer = InputNormalizer::default(); + assert_eq!(normalizer.normalize(b"\x1bOA"), b"\x1b[A"); + assert_eq!(normalizer.normalize(b"\x1bOB"), b"\x1b[B"); + assert_eq!(normalizer.normalize(b"\x1bOC"), b"\x1b[C"); + assert_eq!(normalizer.normalize(b"\x1bOD"), b"\x1b[D"); + } + + #[test] + fn preserves_split_ss3_cursor_state_between_reads() { + let mut normalizer = InputNormalizer::default(); + assert_eq!(normalizer.normalize(b"\x1b"), b"\x1b"); + assert_eq!(normalizer.normalize(b"O"), b""); + assert_eq!(normalizer.normalize(b"B"), b"[B"); + } + + #[test] + fn leaves_non_cursor_ss3_sequences_raw() { + let mut normalizer = InputNormalizer::default(); + assert_eq!(normalizer.normalize(b"\x1bOP"), b"\x1bOP"); + } +}