aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-24 02:47:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-24 02:47:07 +0300
commit520d0b8b9c1b9d621274411d5fc26e35df9d60d2 (patch)
treef40e2a70743388073290a7c157f35bab4eb9bcde
parent74fa90cecc6f6dfd93dc64893659771001ad2d82 (diff)
fix: prevent release key fallback from doubling input
refs #769
-rw-r--r--src/input/encode.rs59
-rw-r--r--src/input/model.rs4
-rw-r--r--src/pane/terminal.rs77
3 files changed, 136 insertions, 4 deletions
diff --git a/src/input/encode.rs b/src/input/encode.rs
index 2778a78b..b7259f38 100644
--- a/src/input/encode.rs
+++ b/src/input/encode.rs
@@ -12,6 +12,15 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> {
}
pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> {
+ // A release event only produces bytes when the pane protocol reports event
+ // types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single
+ // legacy byte per keystroke, so re-emitting it on release would double keys
+ // like Enter/Backspace. The Ghostty wrapper can route release events through
+ // this fallback, so guard the fallback encoder too.
+ if key.kind == crossterm::event::KeyEventKind::Release && !protocol.reports_event_types() {
+ return Vec::new();
+ }
+
if let Some(bytes) = encode_text_input(&key) {
return bytes;
}
@@ -21,6 +30,9 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<
return bytes;
}
}
+ if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() {
+ return Vec::new();
+ }
encode_legacy(key.as_key_event())
}
@@ -145,9 +157,10 @@ fn push_mouse_codepoint(bytes: &mut Vec<u8>, value: u32) -> Option<()> {
/// Returns None if the key doesn't need CSI u (unmodified basic keys).
fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
let mods = key.modifiers;
+ let event_suffix = kitty_event_suffix(key, flags);
// Unmodified keys use legacy encoding (more compatible)
- if mods.is_empty() {
+ if mods.is_empty() && event_suffix.is_none() {
return None;
}
@@ -166,7 +179,9 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
| KeyCode::PageDown
| KeyCode::Insert
| KeyCode::Delete
- | KeyCode::F(_) => {
+ | KeyCode::F(_)
+ if event_suffix.is_none() =>
+ {
return None; // let legacy handle these
}
_ => {}
@@ -182,11 +197,20 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
KeyCode::Tab => (9, None),
KeyCode::Backspace => (127, None),
KeyCode::Esc => (27, None),
+ KeyCode::Left => (57417, None),
+ KeyCode::Right => (57418, None),
+ KeyCode::Up => (57419, None),
+ KeyCode::Down => (57420, None),
+ KeyCode::PageUp => (57421, None),
+ KeyCode::PageDown => (57422, None),
+ KeyCode::Home => (57423, None),
+ KeyCode::End => (57424, None),
+ KeyCode::Insert => (57425, None),
+ KeyCode::Delete => (57426, None),
_ => return None, // fall back to legacy for unhandled keys
};
let modifier = kitty_modifier(mods);
- let event_suffix = kitty_event_suffix(key, flags);
let sequence = match (alternate_shifted, event_suffix) {
(Some(shifted), Some(event)) => format!("\x1b[{codepoint}:{shifted};{modifier}:{event}u"),
@@ -719,6 +743,35 @@ mod tests {
}
#[test]
+ fn release_bytes_gated_on_report_event_types() {
+ for code in [KeyCode::Enter, KeyCode::Backspace] {
+ let release = KeyEvent::new_with_kind(
+ code,
+ KeyModifiers::empty(),
+ crossterm::event::KeyEventKind::Release,
+ );
+
+ // Legacy and Kitty disambiguate-only (no REPORT_EVENT_TYPES) must not
+ // emit a byte on release, otherwise Enter/Backspace double (issue #769).
+ assert_eq!(encode_key(release, KeyboardProtocol::Legacy), b"");
+ assert_eq!(
+ encode_key(release, KeyboardProtocol::Kitty { flags: 1 }),
+ b""
+ );
+ }
+
+ let modified_release = KeyEvent::new_with_kind(
+ KeyCode::Enter,
+ KeyModifiers::CONTROL,
+ crossterm::event::KeyEventKind::Release,
+ );
+ assert_eq!(
+ encode_key(modified_release, KeyboardProtocol::Kitty { flags: 3 }),
+ b"\x1b[13;5:3u"
+ );
+ }
+
+ #[test]
fn kitty_shifted_symbol_sends_text() {
let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
.with_shifted_codepoint('!' as u32);
diff --git a/src/input/model.rs b/src/input/model.rs
index a439217f..1ce107d4 100644
--- a/src/input/model.rs
+++ b/src/input/model.rs
@@ -95,6 +95,10 @@ impl KeyboardProtocol {
Self::Kitty { flags }
}
}
+
+ pub(crate) fn reports_event_types(self) -> bool {
+ matches!(self, Self::Kitty { flags } if flags & 0b0000_0010 != 0)
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index ed5d1061..e573f958 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -1019,7 +1019,11 @@ impl GhosttyPaneTerminal {
return crate::input::encode_terminal_key(key, protocol);
};
match encoder.encode(&event) {
- Ok(bytes) if !bytes.is_empty() => bytes,
+ Ok(bytes)
+ if !bytes.is_empty() && encoded_key_preserves_event_kind(&bytes, key, protocol) =>
+ {
+ bytes
+ }
Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol),
}
}
@@ -1291,6 +1295,23 @@ impl GhosttyPaneTerminal {
}
}
+fn encoded_key_preserves_event_kind(
+ bytes: &[u8],
+ key: crate::input::TerminalKey,
+ protocol: crate::input::KeyboardProtocol,
+) -> bool {
+ if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press {
+ return true;
+ }
+
+ std::str::from_utf8(bytes)
+ .ok()
+ .and_then(crate::input::parse_terminal_key_sequence)
+ .is_some_and(|parsed| {
+ parsed.code == key.code && parsed.modifiers == key.modifiers && parsed.kind == key.kind
+ })
+}
+
fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool {
core.cursor_settle_state.pending()
}
@@ -2433,6 +2454,60 @@ mod tests {
}
#[test]
+ fn ghostty_enter_backspace_release_in_legacy_pane_emits_nothing() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ for code in [
+ crossterm::event::KeyCode::Enter,
+ crossterm::event::KeyCode::Backspace,
+ ] {
+ let press = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+ let release = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty())
+ .with_kind(crossterm::event::KeyEventKind::Release),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+ assert!(!press.is_empty(), "{code:?} press should emit bytes");
+ assert!(
+ release.is_empty(),
+ "{code:?} release should emit nothing in a legacy pane, got {release:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn ghostty_release_still_encoded_for_report_event_pane() {
+ let (tx, _rx) = mpsc::channel(4);
+ // Push kitty flags including REPORT_EVENT_TYPES (0b10) + DISAMBIGUATE (0b1).
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[>3u");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let release = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Enter,
+ crossterm::event::KeyModifiers::empty(),
+ )
+ .with_kind(crossterm::event::KeyEventKind::Release),
+ pane.keyboard_protocol().unwrap(),
+ );
+ let parsed =
+ crate::input::parse_terminal_key_sequence(std::str::from_utf8(&release).unwrap())
+ .unwrap();
+ assert_eq!(parsed.code, crossterm::event::KeyCode::Enter);
+ assert_eq!(
+ parsed.kind,
+ crossterm::event::KeyEventKind::Release,
+ "report-event pane should encode a release, got {release:?}"
+ );
+ }
+
+ #[test]
fn ghostty_char_keys_still_use_herdr_encoding() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();