From d190c1f55e05419b299fc68f60ea0aabc1ff7379 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 7 Jul 2026 16:24:26 +0300 Subject: [PATCH] fix: send shifted punctuation as text in kitty mode refs #1105 --- docs/next/CHANGELOG.md | 1 + src/app/api/panes.rs | 29 +++++++++++++++ src/input/encode.rs | 80 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 36eade52..e4220a98 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -18,6 +18,7 @@ - Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility. ### Fixed +- Windows clients now send shifted punctuation such as `!`, `?`, and `:` as literal text to Kitty-keyboard-mode pane apps, fixing Kiro CLI TUI prompts while preserving modified key chords. (#1105) - `herdr --remote` now prints clean remote attach failures and SSH authentication guidance instead of Rust Debug-formatted I/O errors when SSH authentication is denied. (#1034) - `herdr server stop` now stops Windows named-pipe servers instead of failing with `named pipes do not support I/O timeouts`. (#1113) - `herdr server stop` now waits until both server sockets are unreachable before returning, avoiding an immediate first-start failure when restarting right after replacing the binary. diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 14c62942..fdda8750 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2012,6 +2012,35 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_pane_send_keys_sends_shifted_punctuation_as_text_in_kitty_mode() { + let (mut app, pane_id) = app_with_test_workspace(); + let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>7u", + 1, + ); + app.state.insert_test_runtime(internal_pane_id, runtime); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["shift+?".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from_static(b"?")); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn api_pane_send_input_keys_accept_key_combo_chords() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); diff --git a/src/input/encode.rs b/src/input/encode.rs index 79ec0bf6..b10f8003 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -379,9 +379,39 @@ fn shifted_text_char(key: &TerminalKey, ch: char) -> Option { return Some(ch.to_ascii_uppercase()); } + if is_shifted_ascii_punctuation(ch) { + return Some(ch); + } + None } +fn is_shifted_ascii_punctuation(ch: char) -> bool { + matches!( + ch, + '!' | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '*' + | '(' + | ')' + | '_' + | '+' + | '{' + | '}' + | '|' + | ':' + | '"' + | '<' + | '>' + | '?' + | '~' + ) +} + fn canonical_kitty_char(ch: char, mods: KeyModifiers) -> char { if mods.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() { ch.to_ascii_lowercase() @@ -848,6 +878,56 @@ mod tests { assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b""); } + #[test] + fn kitty_shifted_punctuation_literals_send_text() { + for ch in "!@#$%^&*()_+{}|:\"<>?~".chars() { + let key = TerminalKey::new(KeyCode::Char(ch), KeyModifiers::SHIFT); + let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); + assert_eq!(encoded, ch.to_string().into_bytes(), "ch={ch}"); + } + } + + #[test] + fn kitty_shifted_punctuation_release_does_not_emit_text() { + let key = TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT) + .with_kind(crossterm::event::KeyEventKind::Release); + assert_eq!( + encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"" + ); + } + + #[test] + fn kitty_shifted_punctuation_does_not_infer_layout() { + let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT); + assert_eq!( + encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"\x1b[49;2:1u" + ); + } + + #[test] + fn kitty_modified_shifted_punctuation_stays_modified_key() { + for (modifiers, expected) in [ + ( + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + b"\x1b[33;6:1u".as_slice(), + ), + ( + KeyModifiers::ALT | KeyModifiers::SHIFT, + b"\x1b[33;4:1u".as_slice(), + ), + ( + KeyModifiers::SUPER | KeyModifiers::SHIFT, + b"\x1b[33;10:1u".as_slice(), + ), + ] { + let key = TerminalKey::new(KeyCode::Char('!'), modifiers); + let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); + assert_eq!(encoded, expected, "modifiers={modifiers:?}"); + } + } + #[test] fn release_bytes_gated_on_report_event_types() { for code in [KeyCode::Enter, KeyCode::Backspace] {