aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-03 02:49:27 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-03 02:49:27 +0300
commitaa0768b734d95a1f54df1719290d977df796068d (patch)
treef173bf0e14b4c951e9ad2dc02dc7e66f102b89b4 /src
parente1bbc252e2b40da6e0c6d7b0f8d7babb17cc8814 (diff)
fix: stop sgr mouse sequences leaking into panes
refs #939
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/client/input.rs60
-rw-r--r--src/client/mod.rs23
-rw-r--r--src/main.rs4
-rw-r--r--src/raw_input.rs180
-rw-r--r--src/terminal_modes.rs32
6 files changed, 292 insertions, 8 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index c9d54f46..7548e39e 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1096,6 +1096,7 @@ impl App {
if desired == *active {
return Ok(());
}
+ crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
if desired {
execute!(io::stdout(), EnableMouseCapture)?;
} else {
diff --git a/src/client/input.rs b/src/client/input.rs
index 7099311b..bc7faf35 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -39,15 +39,21 @@ pub fn stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
host_color_query_sent: bool,
+ host_mouse_capture_active: Arc<AtomicBool>,
) {
#[cfg(windows)]
{
- let _ = host_color_query_sent;
+ let _ = (host_color_query_sent, host_mouse_capture_active);
windows_stdin_reader_loop(event_tx, should_quit);
}
#[cfg(unix)]
- unix_stdin_reader_loop(event_tx, should_quit, host_color_query_sent);
+ unix_stdin_reader_loop(
+ event_tx,
+ should_quit,
+ host_color_query_sent,
+ host_mouse_capture_active,
+ );
}
#[cfg(unix)]
@@ -55,6 +61,7 @@ fn unix_stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
host_color_query_sent: bool,
+ host_mouse_capture_active: Arc<AtomicBool>,
) {
let stdin = io::stdin();
let mut reader = stdin.lock();
@@ -78,7 +85,11 @@ fn unix_stdin_reader_loop(
}
}
- if stdin_read_ready(&reader, 10) == Some(false) {
+ let timeout_ms = idle_flush_timeout_ms(
+ &framer,
+ host_mouse_capture_active.load(Ordering::Acquire),
+ );
+ if stdin_read_ready(&reader, timeout_ms) == Some(false) {
let had_pending = framer.has_pending_input();
let chunks = framer.flush_timeout();
let held_escape = had_pending && chunks.is_empty();
@@ -90,7 +101,12 @@ fn unix_stdin_reader_loop(
return;
}
}
- if held_escape && stdin_read_ready(&reader, 10) == Some(false) {
+ if held_escape
+ && stdin_read_ready(
+ &reader,
+ crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS,
+ ) == Some(false)
+ {
for data in framer.flush_timeout() {
if event_tx
.blocking_send(ClientLoopEvent::StdinInput(data))
@@ -112,6 +128,18 @@ fn unix_stdin_reader_loop(
}
}
+#[cfg(unix)]
+fn idle_flush_timeout_ms(
+ framer: &crate::raw_input::RawInputByteFramer,
+ host_mouse_capture_active: bool,
+) -> i32 {
+ if host_mouse_capture_active && framer.has_pending_lone_escape() {
+ crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS
+ } else {
+ crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
+ }
+}
+
#[cfg(windows)]
fn windows_stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
@@ -366,6 +394,30 @@ mod tests {
_ => panic!("expected StdinInput event"),
}
}
+
+ #[test]
+ fn raw_input_idle_flush_timeout_keeps_escape_responsive() {
+ let timeout_ms = std::hint::black_box(crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS);
+ assert!(timeout_ms <= 20);
+ }
+
+ #[test]
+ fn mouse_active_lone_escape_gets_longer_reassembly_window() {
+ let mut framer = crate::raw_input::RawInputByteFramer::default();
+ assert!(framer.push(b"\x1b").is_empty());
+
+ assert_eq!(
+ idle_flush_timeout_ms(&framer, false),
+ crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
+ );
+ assert_eq!(
+ idle_flush_timeout_ms(&framer, true),
+ crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS
+ );
+ let mouse_timeout_ms =
+ std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS);
+ assert!(mouse_timeout_ms > 100);
+ }
}
#[cfg(test)]
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 92edb900..ceed9826 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -312,7 +312,14 @@ impl std::error::Error for ClientError {
impl From<protocol::FramingError> for ClientError {
fn from(err: protocol::FramingError) -> Self {
- ClientError::Protocol(err)
+ match err {
+ protocol::FramingError::UnexpectedEof => ClientError::ConnectionLost(io::Error::new(
+ io::ErrorKind::UnexpectedEof,
+ "server closed connection",
+ )),
+ protocol::FramingError::Io(err) => ClientError::ConnectionLost(err),
+ err => ClientError::Protocol(err),
+ }
}
}
@@ -341,6 +348,7 @@ fn setup_terminal_with_capabilities(
mouse_capture: bool,
) -> io::Result<TerminalGuard> {
ratatui::init();
+ crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
let host_color_scheme_reports =
should_enable_host_color_scheme_reports(enable_client_protocols);
@@ -536,6 +544,7 @@ fn restore_windows_input_mode_value(mode: u32) {
}
fn set_mouse_capture(enabled: bool) -> io::Result<()> {
+ crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
if enabled {
execute!(io::stdout(), EnableMouseCapture)
} else {
@@ -569,6 +578,7 @@ fn restore_terminal_state(
DisableBracketedPaste,
DisableMouseCapture
);
+ let _ = crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout());
#[cfg(windows)]
if let Some(mode) = restore_windows_input_mode {
restore_windows_input_mode_value(mode);
@@ -1100,6 +1110,7 @@ fn run_client_with_mode(
init_logging();
let loaded_config = crate::config::Config::load();
+ crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
let mouse_capture = loaded_config.config.ui.mouse_capture;
let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines();
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
@@ -1283,6 +1294,7 @@ async fn run_client_loop(
draw_host_cursor,
};
debug!(?negotiated_encoding, "client render encoding active");
+ let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active));
// Channel for events from the stdin, resize, and server reader threads.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
@@ -1292,8 +1304,14 @@ async fn run_client_loop(
state.attach_escape.is_none() && should_query_host_terminal_theme();
let stdin_quit = should_quit.clone();
let stdin_tx = event_tx.clone();
+ let stdin_mouse_capture_active = host_mouse_capture_active.clone();
std::thread::spawn(move || {
- input::stdin_reader_loop(stdin_tx, &stdin_quit, will_query_host_terminal_theme);
+ input::stdin_reader_loop(
+ stdin_tx,
+ &stdin_quit,
+ will_query_host_terminal_theme,
+ stdin_mouse_capture_active,
+ );
});
if will_query_host_terminal_theme {
@@ -1533,6 +1551,7 @@ async fn run_client_loop(
let _ = enable_windows_virtual_terminal_input();
}
state.mouse_capture_active = desired;
+ host_mouse_capture_active.store(desired, Ordering::Release);
}
}
ServerMessage::Welcome { .. } => {
diff --git a/src/main.rs b/src/main.rs
index 0eba4968..1876c8e5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -90,6 +90,7 @@ mod server;
mod session;
mod sound;
mod terminal;
+mod terminal_modes;
mod terminal_notify;
mod terminal_theme;
mod ui;
@@ -712,6 +713,7 @@ fn main() -> io::Result<()> {
DisableBracketedPaste,
DisableMouseCapture
);
+ let _ = crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout());
let _ = set_host_color_scheme_reports(false);
let _ = pop_keyboard_enhancement_flags();
ratatui::restore();
@@ -733,6 +735,7 @@ fn main() -> io::Result<()> {
let result = rt.block_on(async {
let mut terminal = ratatui::init();
+ crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
if config.ui.mouse_capture {
execute!(io::stdout(), EnableMouseCapture)?;
} else {
@@ -777,6 +780,7 @@ fn main() -> io::Result<()> {
DisableBracketedPaste,
DisableMouseCapture
)?;
+ crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
set_host_color_scheme_reports(false)?;
ratatui::restore();
diff --git a/src/raw_input.rs b/src/raw_input.rs
index b07641a5..284761c6 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -100,6 +100,8 @@ use crate::terminal_theme::{
};
const ESC: u8 = 0x1b;
+pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10;
+pub(crate) const MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS: i32 = 150;
pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n";
pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n";
const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
@@ -175,12 +177,14 @@ pub(crate) struct RawInputByteFramer {
buffer: Vec<u8>,
discard_until: Option<ControlStringFamily>,
discarded_tail_bytes: usize,
+ lone_escape_recently_flushed: bool,
host_color_replies_awaited: u8,
held_pending_color_esc: bool,
host_color_scheme_change_tracking: bool,
}
const HOST_COLOR_QUERY_REPLIES: u8 = 2;
+const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32;
impl RawInputByteFramer {
pub(crate) fn push(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
@@ -203,6 +207,10 @@ impl RawInputByteFramer {
!self.buffer.is_empty()
}
+ pub(crate) fn has_pending_lone_escape(&self) -> bool {
+ self.buffer.as_slice() == [ESC]
+ }
+
#[cfg(any(windows, test))]
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
self.buffer.starts_with(BRACKETED_PASTE_START)
@@ -232,6 +240,20 @@ impl RawInputByteFramer {
return chunks;
}
+ if self.lone_escape_recently_flushed && self.buffer.starts_with(b"[<") {
+ tracing::debug!(
+ len = self.buffer.len(),
+ "discarding incomplete orphaned SGR mouse tail after input timeout"
+ );
+ discard_or_buffer_orphaned_sgr_mouse_tail(
+ &mut self.buffer,
+ &mut self.discard_until,
+ &mut self.discarded_tail_bytes,
+ );
+ self.lone_escape_recently_flushed = false;
+ return chunks;
+ }
+
if self.buffer.starts_with(BRACKETED_PASTE_START)
&& find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none()
{
@@ -287,6 +309,7 @@ impl RawInputByteFramer {
bytes = ?self.buffer,
"flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc"
);
+ self.lone_escape_recently_flushed = true;
chunks.push(std::mem::take(&mut self.buffer));
return chunks;
}
@@ -310,6 +333,7 @@ impl RawInputByteFramer {
}
tracing::debug!(bytes = ?self.buffer, "dropping incomplete raw input buffer after timeout");
+ self.lone_escape_recently_flushed = false;
self.buffer.clear();
chunks
}
@@ -318,6 +342,17 @@ impl RawInputByteFramer {
let mut chunks = Vec::new();
loop {
+ if self.lone_escape_recently_flushed {
+ if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) {
+ break;
+ }
+ if discard_complete_orphaned_sgr_mouse_tail(&mut self.buffer) {
+ self.lone_escape_recently_flushed = false;
+ continue;
+ }
+ self.lone_escape_recently_flushed = false;
+ }
+
if let Some(family) = self.discard_until {
let Some(terminator_len) =
control_string_terminator_for_family(&self.buffer, family)
@@ -378,6 +413,9 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) ->
ControlStringFamily::HostColorSchemeCsi => buffer
.iter()
.all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')),
+ ControlStringFamily::OrphanedSgrMouseTail => buffer
+ .iter()
+ .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')),
}
}
@@ -415,12 +453,15 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
Ok(n) => {
send_raw_input_events(framer.push(&scratch[..n]), &tx);
- if stdin_read_ready(&reader, 10) == Some(false) {
+ if stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) == Some(false) {
let had_pending = framer.has_pending_input();
let events = framer.flush_timeout();
let held_escape = had_pending && events.is_empty();
send_raw_input_events(events, &tx);
- if held_escape && stdin_read_ready(&reader, 10) == Some(false) {
+ if held_escape
+ && stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS)
+ == Some(false)
+ {
send_raw_input_events(framer.flush_timeout(), &tx);
}
}
@@ -589,6 +630,7 @@ enum ControlStringFamily {
Osc,
StTerminated,
HostColorSchemeCsi,
+ OrphanedSgrMouseTail,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -709,6 +751,51 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
Some(1 + escaped_char_width)
}
+fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool {
+ if buffer.len() > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES {
+ return false;
+ }
+ buffer.len() < 3 && b"[<".starts_with(buffer)
+ || buffer.starts_with(b"[<")
+ && buffer[2..]
+ .iter()
+ .all(|byte| byte.is_ascii_digit() || *byte == b';')
+}
+
+fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>) -> bool {
+ let Some(terminator_len) =
+ control_string_terminator_for_family(buffer, ControlStringFamily::OrphanedSgrMouseTail)
+ else {
+ return false;
+ };
+ if terminator_len > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES {
+ return false;
+ }
+ let mut sequence = Vec::with_capacity(terminator_len + 1);
+ sequence.push(ESC);
+ sequence.extend_from_slice(&buffer[..terminator_len]);
+ let Ok(sequence) = std::str::from_utf8(&sequence) else {
+ return false;
+ };
+ if parse_sgr_mouse(sequence).is_none() {
+ return false;
+ }
+ buffer.drain(..terminator_len);
+ true
+}
+
+fn discard_or_buffer_orphaned_sgr_mouse_tail(
+ buffer: &mut Vec<u8>,
+ discard_until: &mut Option<ControlStringFamily>,
+ discarded_tail_bytes: &mut usize,
+) {
+ if !discard_complete_orphaned_sgr_mouse_tail(buffer) {
+ *discard_until = Some(ControlStringFamily::OrphanedSgrMouseTail);
+ *discarded_tail_bytes = 0;
+ buffer.clear();
+ }
+}
+
fn osc_string_terminator(buffer: &[u8]) -> Option<usize> {
let st = find_subsequence(buffer, b"\x1b\\").map(|idx| idx + 2);
let bel = buffer
@@ -739,6 +826,10 @@ fn control_string_terminator_for_family(
.iter()
.position(|byte| *byte == b'n')
.map(|idx| idx + 1),
+ ControlStringFamily::OrphanedSgrMouseTail => buffer
+ .iter()
+ .position(|byte| matches!(*byte, b'M' | b'm'))
+ .map(|idx| idx + 1),
}
}
@@ -1379,6 +1470,91 @@ mod tests {
}
#[test]
+ fn escape_followed_by_sgr_mouse_before_flush_does_not_emit_text() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ let events = framer.push(b"[<65;43;26M");
+
+ assert_eq!(events.len(), 1);
+ assert!(matches!(
+ events[0],
+ RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::ScrollDown,
+ column: 42,
+ row: 25,
+ ..
+ })
+ ));
+ }
+
+ #[test]
+ fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ let timeout_events = framer.flush_timeout();
+ assert_eq!(timeout_events.len(), 1);
+ assert_raw_key(
+ timeout_events.into_iter().next().unwrap(),
+ KeyCode::Esc,
+ KeyModifiers::empty(),
+ );
+
+ assert!(framer.push(b"[<65;43;26M").is_empty());
+ }
+
+ #[test]
+ fn input_after_discarded_complete_sgr_mouse_tail_is_preserved() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout().len(), 1);
+ let events = framer.push(b"[<65;43;26Mx");
+ assert_eq!(events.len(), 1);
+ assert_raw_key(
+ events.into_iter().next().unwrap(),
+ KeyCode::Char('x'),
+ KeyModifiers::empty(),
+ );
+ }
+
+ #[test]
+ fn invalid_orphaned_sgr_mouse_tail_after_escape_timeout_is_preserved() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout().len(), 1);
+
+ let events = framer.push(b"[<x");
+
+ assert_eq!(events.len(), 3);
+ assert_raw_key(
+ events.into_iter().last().unwrap(),
+ KeyCode::Char('x'),
+ KeyModifiers::empty(),
+ );
+ }
+
+ #[test]
+ fn double_split_sgr_mouse_tail_after_lone_escape_timeout_is_discarded() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout().len(), 1);
+
+ assert!(framer.push(b"[<65;4").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ let events = framer.push(b"3;26Mx");
+ assert_eq!(events.len(), 1);
+ assert_raw_key(
+ events.into_iter().next().unwrap(),
+ KeyCode::Char('x'),
+ KeyModifiers::empty(),
+ );
+ }
+
+ #[test]
fn escape_followed_by_alt_char_before_flush_becomes_alt_key() {
let (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs
new file mode 100644
index 00000000..953a5812
--- /dev/null
+++ b/src/terminal_modes.rs
@@ -0,0 +1,32 @@
+use std::io::{self, Write};
+
+const DISABLE_HOST_MOUSE_REPORTING_SEQUENCE: &[u8] =
+ b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
+
+#[cfg(not(windows))]
+pub(crate) fn clear_host_mouse_reporting<W: Write>(writer: &mut W) -> io::Result<()> {
+ writer.write_all(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE)?;
+ writer.flush()
+}
+
+#[cfg(windows)]
+pub(crate) fn clear_host_mouse_reporting<W: Write>(_writer: &mut W) -> io::Result<()> {
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn clears_all_known_host_mouse_modes() {
+ let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap();
+
+ for mode in ["1000", "1002", "1003", "1005", "1006", "1015", "1016"] {
+ assert!(
+ sequence.contains(&format!("\x1b[?{mode}l")),
+ "missing mouse mode {mode}"
+ );
+ }
+ }
+}