aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPierre Kisters <1524059+lhns@users.noreply.github.com>2026-07-17 15:24:52 +0200
committerGitHub <noreply@github.com>2026-07-17 15:24:52 +0200
commitd9682a2eef5ac0236808f05337f4f224bcf9d55f (patch)
treed0fc3b71324b4caa14ca99a89f0e07f74bd4a7fe
parentdocs(changelog): pane rename clear fix (diff)
downloadsl0ppij-d9682a2eef5ac0236808f05337f4f224bcf9d55f.tar.gz
sl0ppij-d9682a2eef5ac0236808f05337f4f224bcf9d55f.tar.xz
sl0ppij-d9682a2eef5ac0236808f05337f4f224bcf9d55f.zip
fix: input spam when moving mouse while typing by splitting the input bytes into their respective events (#5323)
-rw-r--r--src/tests/e2e/cases.rs2
-rw-r--r--src/tests/e2e/snapshots/zellij__tests__e2e__cases__send_blocking_command_through_the_cli.snap6
-rw-r--r--zellij-client/src/stdin_handler.rs37
-rw-r--r--zellij-utils/src/vendored/termwiz/input.rs264
4 files changed, 258 insertions, 51 deletions
diff --git a/src/tests/e2e/cases.rs b/src/tests/e2e/cases.rs
index b83a7c6a..f7d36999 100644
--- a/src/tests/e2e/cases.rs
+++ b/src/tests/e2e/cases.rs
@@ -23,7 +23,7 @@ use super::remote_runner::{RemoteRunner, RemoteTerminal, Step};
pub const QUIT: [u8; 1] = [17]; // ctrl-q
pub const ESC: [u8; 1] = [27];
-pub const ENTER: [u8; 2] = [10, 13]; // '\n\r'
+pub const ENTER: [u8; 1] = [13]; // '\r'
pub const SPACE: [u8; 1] = [32];
pub const LOCK_MODE: [u8; 1] = [7]; // ctrl-g
diff --git a/src/tests/e2e/snapshots/zellij__tests__e2e__cases__send_blocking_command_through_the_cli.snap b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__send_blocking_command_through_the_cli.snap
index c1a0ac4c..1dbe9a30 100644
--- a/src/tests/e2e/snapshots/zellij__tests__e2e__cases__send_blocking_command_through_the_cli.snap
+++ b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__send_blocking_command_through_the_cli.snap
@@ -4,10 +4,8 @@ expression: last_snapshot
---
Zellij (e2e-test)  Tab #1  + 
$ /usr/src/zellij/zellij run --blocking --floating --close-on-exit -- bash -c 'sleep 2 && exit 42'
-$
$ echo $?
42
-$
$ █
@@ -25,4 +23,6 @@ $ █
- Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  Alt +  <n> New Pane  <f> Floating 
+
+
+ Ctrl + <g> LOCK  <p> PANE  <t> TAB  <n> RESIZE  <h> MOVE  <s> SEARCH  <o> SESSION  <q> QUIT  Alt +  <n> New Pane  <f> Floating 
diff --git a/zellij-client/src/stdin_handler.rs b/zellij-client/src/stdin_handler.rs
index 535d9b67..29b7de7e 100644
--- a/zellij-client/src/stdin_handler.rs
+++ b/zellij-client/src/stdin_handler.rs
@@ -177,11 +177,11 @@ pub(crate) fn stdin_loop(
// Ambiguous events (if any) will be finalized later only if 50ms
// passes with no new input
let maybe_more = true;
- let mut events = vec![];
- input_parser.parse(
+ let mut events: Vec<(InputEvent, usize)> = vec![];
+ input_parser.parse_with_consumed(
&residue,
- |input_event: InputEvent| {
- events.push(input_event);
+ |input_event: InputEvent, consumed: usize| {
+ events.push((input_event, consumed));
},
maybe_more,
);
@@ -190,12 +190,14 @@ pub(crate) fn stdin_loop(
// reports — `StdinAnsiParser::feed` strips both
// before the keyboard parser sees the bytes.
// Every termwiz event is a key/mouse/paste/etc.
- for input_event in events.into_iter() {
+ // Each event is forwarded with exactly the bytes
+ // that produced it, never bytes belonging to other
+ // events decoded from the same read.
+ for (input_event, consumed) in events.into_iter() {
+ let take = consumed.min(current_buffer.len());
+ let raw_bytes: Vec<u8> = current_buffer.drain(..take).collect();
send_input_instructions
- .send(InputInstruction::KeyEvent(
- input_event,
- current_buffer.drain(..).collect(),
- ))
+ .send(InputInstruction::KeyEvent(input_event, raw_bytes))
.unwrap();
}
@@ -289,20 +291,19 @@ fn drain_partial_to_keyboard(
current_buffer.extend_from_slice(&drained);
}
- let mut events = vec![];
- input_parser.parse(
+ let mut events: Vec<(InputEvent, usize)> = vec![];
+ input_parser.parse_with_consumed(
&drained,
- |input_event: InputEvent| {
- events.push(input_event);
+ |input_event: InputEvent, consumed: usize| {
+ events.push((input_event, consumed));
},
false,
);
- for input_event in events {
+ for (input_event, consumed) in events {
+ let take = consumed.min(current_buffer.len());
+ let raw_bytes: Vec<u8> = current_buffer.drain(..take).collect();
send_input_instructions
- .send(InputInstruction::KeyEvent(
- input_event,
- current_buffer.drain(..).collect(),
- ))
+ .send(InputInstruction::KeyEvent(input_event, raw_bytes))
.unwrap();
}
}
diff --git a/zellij-utils/src/vendored/termwiz/input.rs b/zellij-utils/src/vendored/termwiz/input.rs
index e9797412..6010896c 100644
--- a/zellij-utils/src/vendored/termwiz/input.rs
+++ b/zellij-utils/src/vendored/termwiz/input.rs
@@ -1016,7 +1016,7 @@ mod windows {
let mut buf = [0u8; 4];
self.buf
.extend_with(unicode.encode_utf8(&mut buf).as_bytes());
- self.process_bytes(callback, true);
+ self.process_bytes(|e, _consumed| callback(e), true);
return;
},
_ => match event.wVirtualKeyCode as i32 {
@@ -1209,7 +1209,7 @@ mod windows {
_ => {},
}
}
- self.process_bytes(callback, false);
+ self.process_bytes(|e, _consumed| callback(e), false);
}
}
}
@@ -1594,7 +1594,13 @@ impl InputParser {
}
}
- fn dispatch_callback<F: FnMut(InputEvent)>(&mut self, mut callback: F, event: InputEvent) {
+ fn dispatch_callback<F: FnMut(InputEvent, usize)>(
+ &mut self,
+ mut callback: F,
+ event: InputEvent,
+ ) {
+ // `self.buf` is already advanced past this event, so `self.buf.len()` is
+ // the remainder `parse_with_consumed` diffs into a per-event byte count.
match (self.state, &event) {
(
InputState::Normal,
@@ -1614,10 +1620,13 @@ impl InputParser {
) => {
// The prior ESC was not part of an ALT sequence, so emit
// it before we start collecting for paste.
- callback(InputEvent::Key(KeyEvent {
- key: KeyCode::Escape,
- modifiers: Modifiers::NONE,
- }));
+ callback(
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Escape,
+ modifiers: Modifiers::NONE,
+ }),
+ self.buf.len(),
+ );
self.state = InputState::Pasting(0);
},
(InputState::EscapeMaybeAlt, InputEvent::Key(KeyEvent { key, modifiers })) => {
@@ -1625,21 +1634,27 @@ impl InputParser {
let key = *key;
let modifiers = *modifiers;
self.state = InputState::Normal;
- callback(InputEvent::Key(KeyEvent {
- key,
- modifiers: modifiers | Modifiers::ALT,
- }));
+ callback(
+ InputEvent::Key(KeyEvent {
+ key,
+ modifiers: modifiers | Modifiers::ALT,
+ }),
+ self.buf.len(),
+ );
},
(InputState::EscapeMaybeAlt, _) => {
// The prior ESC was not part of an ALT sequence, so emit
// both it and the current event
- callback(InputEvent::Key(KeyEvent {
- key: KeyCode::Escape,
- modifiers: Modifiers::NONE,
- }));
- callback(event);
+ callback(
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Escape,
+ modifiers: Modifiers::NONE,
+ }),
+ self.buf.len(),
+ );
+ callback(event, self.buf.len());
},
- (_, _) => callback(event),
+ (_, _) => callback(event, self.buf.len()),
}
}
@@ -1650,17 +1665,20 @@ impl InputParser {
/// match — those sequences are autonomous host events and cannot be
/// ALT-combined with the parked ESC, so the ESC must be flushed
/// before the sequence is emitted.
- fn flush_parked_esc_if_held<F: FnMut(InputEvent)>(&mut self, callback: &mut F) {
+ fn flush_parked_esc_if_held<F: FnMut(InputEvent, usize)>(&mut self, callback: &mut F) {
if self.state == InputState::EscapeMaybeAlt {
- callback(InputEvent::Key(KeyEvent {
- key: KeyCode::Escape,
- modifiers: Modifiers::NONE,
- }));
+ callback(
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Escape,
+ modifiers: Modifiers::NONE,
+ }),
+ self.buf.len(),
+ );
self.state = InputState::Normal;
}
}
- fn process_bytes<F: FnMut(InputEvent)>(&mut self, mut callback: F, maybe_more: bool) {
+ fn process_bytes<F: FnMut(InputEvent, usize)>(&mut self, mut callback: F, maybe_more: bool) {
while !self.buf.is_empty() {
match self.state {
InputState::Pasting(offset) => {
@@ -1669,7 +1687,7 @@ impl InputParser {
let pasted =
String::from_utf8_lossy(&self.buf.as_slice()[0..idx]).to_string();
self.buf.advance(pasted.len() + end_paste.len());
- callback(InputEvent::Paste(pasted));
+ callback(InputEvent::Paste(pasted), self.buf.len());
self.state = InputState::Normal;
} else {
self.state =
@@ -1693,7 +1711,7 @@ impl InputParser {
if let Some((event, len)) = parse_sgr_mouse(self.buf.as_slice()) {
self.flush_parked_esc_if_held(&mut callback);
self.buf.advance(len);
- callback(event);
+ callback(event, self.buf.len());
continue;
}
@@ -1701,7 +1719,7 @@ impl InputParser {
if let Some((event, len)) = parse_osc(self.buf.as_slice()) {
self.flush_parked_esc_if_held(&mut callback);
self.buf.advance(len);
- callback(event);
+ callback(event, self.buf.len());
continue;
}
@@ -1724,7 +1742,7 @@ impl InputParser {
if let Some((event, len)) = parse_csi_report(self.buf.as_slice()) {
self.flush_parked_esc_if_held(&mut callback);
self.buf.advance(len);
- callback(event);
+ callback(event, self.buf.len());
continue;
}
@@ -1761,8 +1779,10 @@ impl InputParser {
self.buf.advance(len);
},
(Found::Exact(len, event), _) | (Found::Ambiguous(len, event), false) => {
- self.dispatch_callback(&mut callback, event.clone());
+ // Advance before dispatching so `self.buf.len()` inside
+ // `dispatch_callback` already reflects this key's consumption.
self.buf.advance(len);
+ self.dispatch_callback(&mut callback, event.clone());
},
(Found::Ambiguous(_, _), true) | (Found::NeedData, true) => {
// The keymap is signalling "this buffer
@@ -1841,8 +1861,34 @@ impl InputParser {
/// with an empty slice and `maybe_more=false` to allow the partial
/// data to be recognized and processed.
pub fn parse<F: FnMut(InputEvent)>(&mut self, bytes: &[u8], callback: F, maybe_more: bool) {
+ // rebind (not `mut callback: F`) to keep the upstream signature intact
+ let mut callback = callback;
+ self.parse_with_consumed(bytes, |event, _consumed| callback(event), maybe_more);
+ }
+
+ /// Like [`InputParser::parse`], but the callback also receives the number
+ /// of input bytes consumed to produce each event. This allows a caller
+ /// that forwards raw bytes alongside decoded events to attribute to each
+ /// event exactly the bytes that produced it when a single chunk of input
+ /// decodes into multiple events.
+ pub fn parse_with_consumed<F: FnMut(InputEvent, usize)>(
+ &mut self,
+ bytes: &[u8],
+ mut callback: F,
+ maybe_more: bool,
+ ) {
self.buf.extend_with(bytes);
- self.process_bytes(callback, maybe_more);
+ // `process_bytes` reports the bytes still buffered after each event; the
+ // drop between successive remainders is what that event consumed.
+ let mut prev_remaining = self.buf.len();
+ self.process_bytes(
+ |event, remaining| {
+ let consumed = prev_remaining.saturating_sub(remaining);
+ prev_remaining = remaining;
+ callback(event, consumed);
+ },
+ maybe_more,
+ );
}
pub fn parse_as_vec(&mut self, bytes: &[u8], maybe_more: bool) -> Vec<InputEvent> {
@@ -1950,6 +1996,166 @@ mod test {
);
}
+ /// Parse `bytes` and pair each event with the raw bytes it consumed,
+ /// draining from a copy of the input the same way the client's stdin
+ /// loop attributes raw bytes to events.
+ fn parse_with_raw_bytes(bytes: &[u8], maybe_more: bool) -> Vec<(InputEvent, Vec<u8>)> {
+ let mut p = InputParser::new();
+ let mut collected: Vec<(InputEvent, usize)> = Vec::new();
+ p.parse_with_consumed(bytes, |ev, n| collected.push((ev, n)), maybe_more);
+ let mut buffer: Vec<u8> = bytes.to_vec();
+ collected
+ .into_iter()
+ .map(|(ev, n)| {
+ let take = n.min(buffer.len());
+ let raw: Vec<u8> = buffer.drain(..take).collect();
+ (ev, raw)
+ })
+ .collect()
+ }
+
+ #[test]
+ fn typed_char_keeps_only_its_own_bytes_before_mouse_reports() {
+ // A keystroke and two mouse reports arrive in one read: the key must be
+ // paired with only its own byte and each report with its own bytes.
+ let events = parse_with_raw_bytes(b"a\x1b[<35;52;16M\x1b[<35;49;16M", MAYBE_MORE);
+ assert_eq!(
+ events.len(),
+ 3,
+ "expected key + 2 mouse events, got {:?}",
+ events
+ );
+ assert!(
+ matches!(
+ events[0].0,
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Char('a'),
+ ..
+ })
+ ),
+ "first event should be the typed key, got {:?}",
+ events[0].0
+ );
+ assert_eq!(
+ events[0].1, b"a",
+ "the keystroke must not carry the trailing mouse bytes"
+ );
+ assert!(matches!(events[1].0, InputEvent::Mouse(_)));
+ assert_eq!(events[1].1, b"\x1b[<35;52;16M");
+ assert!(matches!(events[2].0, InputEvent::Mouse(_)));
+ assert_eq!(events[2].1, b"\x1b[<35;49;16M");
+ }
+
+ #[test]
+ fn typed_char_keeps_only_its_own_bytes_after_mouse_reports() {
+ // A mouse report precedes the keystroke in the read; the key must still
+ // be paired with only its own byte.
+ let events = parse_with_raw_bytes(b"\x1b[<35;52;16Ma", MAYBE_MORE);
+ assert_eq!(events.len(), 2, "got {:?}", events);
+ assert!(matches!(events[0].0, InputEvent::Mouse(_)));
+ assert_eq!(events[0].1, b"\x1b[<35;52;16M");
+ assert!(matches!(
+ events[1].0,
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Char('a'),
+ ..
+ })
+ ));
+ assert_eq!(events[1].1, b"a");
+ }
+
+ #[test]
+ fn consecutive_chars_before_mouse_each_keep_one_byte() {
+ // Consecutive keystrokes in one read are each paired with their own byte.
+ let events = parse_with_raw_bytes(b"ab\x1b[<35;52;16M", MAYBE_MORE);
+ assert_eq!(events.len(), 3, "got {:?}", events);
+ assert_eq!(events[0].1, b"a");
+ assert_eq!(events[1].1, b"b");
+ assert_eq!(events[2].1, b"\x1b[<35;52;16M");
+ }
+
+ #[test]
+ fn single_event_keeps_all_its_bytes() {
+ // A read that decodes into a single event pairs it with all of the
+ // read's bytes, including a multi-byte sequence (`\x1bOA`).
+ let events = parse_with_raw_bytes(b"\x1bOA", NO_MORE);
+ assert_eq!(events.len(), 1, "got {:?}", events);
+ assert!(matches!(
+ events[0].0,
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::ApplicationUpArrow,
+ ..
+ })
+ ));
+ assert_eq!(events[0].1, b"\x1bOA");
+ }
+
+ #[test]
+ fn lone_esc_batch_then_mouse_report_batch() {
+ // A lone ESC arrives in one batch and a complete mouse report in the
+ // next. The ESC is held until the following batch disambiguates it;
+ // both events are then emitted, each paired with its own bytes.
+ let mut p = InputParser::new();
+ let mut events: Vec<(InputEvent, usize)> = Vec::new();
+ let mut buffer: Vec<u8> = Vec::new();
+
+ buffer.extend_from_slice(b"\x1b");
+ p.parse_with_consumed(b"\x1b", |ev, n| events.push((ev, n)), MAYBE_MORE);
+ assert!(
+ events.is_empty(),
+ "a lone ESC with more data possibly coming must be held, got {:?}",
+ events
+ );
+
+ buffer.extend_from_slice(b"\x1b[<35;62;16M");
+ p.parse_with_consumed(b"\x1b[<35;62;16M", |ev, n| events.push((ev, n)), MAYBE_MORE);
+ assert_eq!(events.len(), 2, "got {:?}", events);
+ assert!(matches!(
+ events[0].0,
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Escape,
+ ..
+ })
+ ));
+ assert_eq!(events[0].1, 1, "the ESC consumed its single byte");
+ assert!(matches!(events[1].0, InputEvent::Mouse(_)));
+ assert_eq!(events[1].1, 12, "the mouse report consumed its 12 bytes");
+
+ // Draining the accumulated bytes per event, the way the client's
+ // stdin loop does, pairs each event with its own raw bytes.
+ let esc_bytes: Vec<u8> = buffer.drain(..events[0].1).collect();
+ let mouse_bytes: Vec<u8> = buffer.drain(..events[1].1).collect();
+ assert_eq!(esc_bytes, b"\x1b");
+ assert_eq!(mouse_bytes, b"\x1b[<35;62;16M");
+ assert!(buffer.is_empty());
+ }
+
+ #[test]
+ fn newline_then_carriage_return_are_two_enter_events_with_their_own_bytes() {
+ // In the legacy encoding a terminal sends `\r` for the Enter key and
+ // `\n` for a control-j style newline; the keymap decodes both to
+ // Enter. Arriving together they are two Enter events, each paired
+ // with its own byte.
+ let events = parse_with_raw_bytes(b"\n\r", MAYBE_MORE);
+ assert_eq!(events.len(), 2, "got {:?}", events);
+ for (event, raw) in &events {
+ assert!(
+ matches!(
+ event,
+ InputEvent::Key(KeyEvent {
+ key: KeyCode::Enter,
+ ..
+ })
+ ),
+ "expected an Enter key event, got {:?}",
+ event
+ );
+ assert_eq!(raw.len(), 1, "each Enter is paired with a single byte");
+ }
+ assert_eq!(events[0].1, b"\n");
+ assert_eq!(events[1].1, b"\r");
+ }
+
#[test]
fn partial() {
let mut p = InputParser::new();