diff --git a/src/client/shell/tests/input_conformance.rs b/src/client/shell/tests/input_conformance.rs index b8fd82986..6ecbff26a 100644 --- a/src/client/shell/tests/input_conformance.rs +++ b/src/client/shell/tests/input_conformance.rs @@ -252,6 +252,19 @@ fn legacy_shell_exception(pane_mode: &[u8], key: &str, mods: u16) -> Option b"\r", ("i", ghostty::MOD_CTRL) | ("tab", ghostty::MOD_CTRL) => b"\t", ("[", ghostty::MOD_CTRL) => b"\x1b", + // Real terminals (lab: Alacritty, WezTerm) send the control byte. + (letter, m) + if m == ghostty::MOD_CTRL | ghostty::MOD_SHIFT + && letter.len() == 1 + && letter.as_bytes()[0].is_ascii_lowercase() => + { + let control = [letter.as_bytes()[0] & 0x1f]; + return Some(if alt { + [b"\x1b".as_slice(), &control].concat() + } else { + control.to_vec() + }); + } _ => return None, }; Some(if alt { diff --git a/src/pane/input.rs b/src/pane/input.rs index a333c41a0..13cc67701 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -118,6 +118,24 @@ pub(super) fn legacy_shell_key(key: crate::input::TerminalKey) -> crate::input:: KeyCode::Tab } (KeyCode::Char('['), KeyModifiers::CONTROL) => KeyCode::Esc, + // Ghostty reports Ctrl+Shift+letter as CSI u; every terminal sends the + // plain control byte, which is all a shell can read. + // WezTerm names it Ctrl plus an uppercase letter, without Shift. + (KeyCode::Char(c), chord) + if chord - KeyModifiers::SHIFT == KeyModifiers::CONTROL + && c.is_ascii_alphabetic() + && (chord.contains(KeyModifiers::SHIFT) || c.is_ascii_uppercase()) => + { + // Ctrl+I and Ctrl+M then follow the rows above (Tab, Enter). + return legacy_shell_key( + crate::input::TerminalKey::new( + KeyCode::Char(c.to_ascii_lowercase()), + KeyModifiers::CONTROL | alt, + ) + .with_kind(key.kind) + .with_repeat_count(key.repeat_count), + ); + } _ => return key, }; crate::input::TerminalKey::new(classic, alt) diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 451a660cb..aedfd568b 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -5001,6 +5001,29 @@ mod tests { } } + #[test] + fn ctrl_shift_letters_reach_plain_shells_as_control_bytes() { + // Lab capture: Alacritty and WezTerm (with or without Kitty keyboard) + // send 0x01 for Ctrl+Shift+A; Ghostty's CSI u prints as garbage in a shell. + for (host_bytes, plain, kitty) in [ + ("\x1b[97;6u", &b"\x01"[..], &b"\x1b[97;6u"[..]), + ("\x1b[65;5u", b"\x01", b"\x1b[97;6u"), + ("\x1b[97;8u", b"\x1b\x01", b"\x1b[97;8u"), + ] { + let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("kitty key"); + assert_eq!( + test_encode_key_for_app(b"", key.clone()), + plain, + "{host_bytes:?} in a plain shell" + ); + assert_eq!( + test_encode_key_for_app(b"\x1b[>1u", key), + kitty, + "{host_bytes:?} in a Kitty pane" + ); + } + } + #[test] fn legacy_alt_prefixes_the_produced_non_ascii_text() { // A legacy host's Alt+Shift+\u{f6}. libghostty on macOS would prefix the