From 879149a439cf75d9bfb96f53d4c7063b0b638ef7 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 7 Aug 2026 16:07:42 +0300 Subject: [PATCH] fix(input): preserve shift in kitty alternate reports refs #2435 --- docs/next/CHANGELOG.md | 1 + src/app/input/navigate.rs | 36 ++++++++++++++++++++- src/input/parse.rs | 35 +++++++++++++++++++- tests/fixtures/keyboard_protocol_corpus.tsv | 1 + 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index e8deca6372..277ff405f6 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet) +- Prefix keybindings now preserve Shift in WezTerm Kitty keyboard mode, so commands such as config reload no longer trigger their unshifted action. (#2435) - Stable direct installs, self-updates, and remote helper downloads now require and verify the SHA-256 digest published for each GitHub release asset. - Configs containing the retired Herdr-written `ui.agent_panel_scope` setting no longer report it as an unknown key after upgrades. (#2292) - Claude Code confirmation prompts using `Enter to confirm ยท Esc to cancel` now report `blocked` instead of `idle`. (#2268) diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 68941e663f..1c89202832 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -1901,7 +1901,12 @@ mod tests { use super::super::{state_with_workspaces, unique_temp_path}; use super::*; use crate::{ - app::App, config::Config, input::TerminalKey, terminal::TerminalState, workspace::Workspace, + app::App, + config::Config, + input::TerminalKey, + raw_input::{parse_raw_input_bytes_sync, RawInputEvent}, + terminal::TerminalState, + workspace::Workspace, }; fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { @@ -2840,6 +2845,35 @@ command = "printf literal > '{}'" assert_eq!(app.state.mode, Mode::RenameWorkspace); } + #[tokio::test] + async fn kitty_shifted_alternate_without_modifier_prefers_reload_over_resize() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Prefix; + + let mut events = parse_raw_input_bytes_sync(b"\x1b[114:82;1u"); + assert_eq!(events.len(), 1); + let RawInputEvent::Key(key) = events.remove(0) else { + panic!("expected key event"); + }; + assert_eq!( + action_for_key(&app.state, key.clone(), BindingDispatch::Prefix), + Some(NavigateAction::ReloadConfig) + ); + app.handle_prefix_key(key); + + assert_eq!(app.state.mode, Mode::Terminal); + } + #[tokio::test] async fn legacy_uppercase_prefers_shifted_reload_binding_over_unshifted() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/input/parse.rs b/src/input/parse.rs index 861280dd84..085757c3d9 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -39,8 +39,17 @@ fn parse_kitty_key_sequence(data: &str) -> Option { let code = kitty_codepoint_to_keycode(codepoint)?; let kind = parse_kitty_event_type(event_type)?; + let mut modifiers = key_modifiers_from_u8(modifier); + // Kitty permits the shifted alternate only while Shift is active. Normalize + // contradictory reports here so they cannot dispatch an unshifted command. + if matches!(code, KeyCode::Char(_)) + && shifted_codepoint + .is_some_and(|shifted| shifted != codepoint && char::from_u32(shifted).is_some()) + { + modifiers |= KeyModifiers::SHIFT; + } - let mut key = TerminalKey::new(code, key_modifiers_from_u8(modifier)).with_kind(kind); + let mut key = TerminalKey::new(code, modifiers).with_kind(kind); if let Some(shifted_codepoint) = shifted_codepoint { key = key.with_shifted_codepoint(shifted_codepoint); } @@ -598,6 +607,30 @@ mod tests { assert_eq!(key.shifted_codepoint, Some('L' as u32)); } + #[test] + fn parse_kitty_sequence_recovers_omitted_shift_modifier() { + for (sequence, kind) in [ + ("\x1b[114:82;1u", crossterm::event::KeyEventKind::Press), + ("\x1b[114:82;1:2u", crossterm::event::KeyEventKind::Repeat), + ("\x1b[114:82;1:3u", crossterm::event::KeyEventKind::Release), + ] { + let key = parse_terminal_key_sequence(sequence).unwrap(); + assert_eq!(key.code, KeyCode::Char('r')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.kind, kind); + assert_eq!(key.shifted_codepoint, Some('R' as u32)); + } + } + + #[test] + fn parse_kitty_sequence_does_not_infer_shift_without_distinct_shifted_alternate() { + for sequence in ["\x1b[114;1u", "\x1b[114:114;1u", "\x1b[114::113;1u"] { + let key = parse_terminal_key_sequence(sequence).unwrap(); + assert_eq!(key.code, KeyCode::Char('r')); + assert_eq!(key.modifiers, KeyModifiers::empty()); + } + } + #[test] fn parse_kitty_sequence_preserves_non_us_shift_pairs() { for (sequence, base, shifted) in [ diff --git a/tests/fixtures/keyboard_protocol_corpus.tsv b/tests/fixtures/keyboard_protocol_corpus.tsv index bf9e7ff901..e405326dd5 100644 --- a/tests/fixtures/keyboard_protocol_corpus.tsv +++ b/tests/fixtures/keyboard_protocol_corpus.tsv @@ -16,6 +16,7 @@ modify_other_keys_ctrl_shift_l 1b5b32373b363b3130387e char:l control+shift press kitty_alt_backspace 1b5b3132373b3375 backspace alt press kitty_shift_letter 1b5b3130383a37363b323a3175 char:l shift press 76 kitty_shift_symbol 1b5b34393a33333b323a3175 char:1 shift press 33 +kitty_omitted_shift_modifier 1b5b3131343a38323b3175 char:r shift press 82 kitty_release_letter 1b5b3130383a37363b323a3375 char:l shift release 76 kitty_keypad_0 1b5b35373339393b3175 char:0 - press kitty_keypad_1 1b5b35373430303b3175 char:1 - press