From 6a15ddd0caaacbc63dbde7481f8f2eae48e6e5c0 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 9 Aug 2026 21:20:48 +0300 Subject: [PATCH] fix(input): address cross-platform encoding regressions refs #2514 --- .../windows_conpty_enhanced_input_probe.ps1 | 6 ++--- src/input/mod.rs | 2 ++ src/input/parse.rs | 17 ++++++++++++- src/input/test_support.rs | 10 +++++++- src/pane/terminal.rs | 25 +++++++++++-------- src/raw_input.rs | 21 +++++++++++++--- vendor/libghostty-vt.patches.md | 11 +++++--- .../libghostty-vt/src/input/function_keys.zig | 1 + vendor/libghostty-vt/src/input/key_encode.zig | 18 ++++++++++++- vendor/libghostty-vt/src/input/key_mods.zig | 8 ++++++ .../0002-proxied-kitty-key-metadata.patch | 18 +++++++++++++ .../0004-encode-extended-function-keys.patch | 3 ++- ...007-preserve-proxy-key-compatibility.patch | 22 +++++++++++++--- 13 files changed, 136 insertions(+), 26 deletions(-) diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1 index 1e452521..4f314690 100644 --- a/scripts/windows_conpty_enhanced_input_probe.ps1 +++ b/scripts/windows_conpty_enhanced_input_probe.ps1 @@ -399,9 +399,9 @@ fn main() { $kittyInitialHex = Get-LatestProbeHex -PaneText $report.kitty_initial $report.device_attributes_response = $kittyInitialHex -match "1b5b3f(?:3[0-9]|3b)+63" $report.kitty_query_response = $kittyInitialHex.Contains("1b5b3f3775") - $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b333a3175" - $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b353a3175" - $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b353a3175" + $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b3375" + $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b3575" + $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b3575" $report.kitty_shift_enter = Send-KeyAndObserve -PaneId $kittyPane -Key "shift+enter" -ExpectedHex "1b5b31333b3275" $report.kitty_ctrl_backspace = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+backspace" -ExpectedHex "1b5b3132373b3575" $report.kitty_up = Send-KeyAndObserve -PaneId $kittyPane -Key "up" -ExpectedHex "1b5b313b313a3141" diff --git a/src/input/mod.rs b/src/input/mod.rs index 45e9bd6d..59aba3f9 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -5,6 +5,8 @@ mod parse; #[cfg(test)] pub(crate) mod test_support; +// Preserve mouse encoder re-exports for facade consumers even though Herdr's +// runtime routes mouse encoding through terminal backends. #[allow(unused_imports)] pub use encode::{encode_mouse_button, encode_mouse_scroll}; #[cfg(not(windows))] diff --git a/src/input/parse.rs b/src/input/parse.rs index c5279160..58c7cac0 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -29,7 +29,7 @@ fn parse_kitty_key_sequence(data: &str) -> Option { modifier_part }; let (modifier_text, event_type) = split_modifier_and_event(modifier_part); - let modifier = modifier_text.parse::().ok()?.checked_sub(1)?; + let modifier = u8::try_from(modifier_text.parse::().ok()?.checked_sub(1)?).ok()?; let mut key_fields = key_part.split(':'); let codepoint = key_fields.next()?.parse::().ok()?; @@ -628,6 +628,21 @@ mod tests { assert_eq!(parse_terminal_key_sequence("\x1b[14;3~"), None); } + #[test] + fn parse_kitty_sequence_accepts_every_modifier_bit() { + let key = parse_terminal_key_sequence("\x1b[97;256u").unwrap(); + + assert_eq!( + key.modifiers, + KeyModifiers::SHIFT + | KeyModifiers::ALT + | KeyModifiers::CONTROL + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META + ); + } + #[test] fn parse_kitty_sequence_preserves_shifted_symbol_pair() { let key = parse_terminal_key_sequence("\x1b[49:33;2:1u").unwrap(); diff --git a/src/input/test_support.rs b/src/input/test_support.rs index ae5e41b1..bfc5f7d8 100644 --- a/src/input/test_support.rs +++ b/src/input/test_support.rs @@ -60,7 +60,7 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec> .get(9) .filter(|field| !field.is_empty()) .map(|field| field.parse::().expect("base layout codepoint")), - generated_text: (columns[6] != "-").then(|| { + generated_text: (!columns[6].is_empty() && columns[6] != "-").then(|| { String::from_utf8(decode_hex(columns[6])).expect("generated text must be UTF-8") }), pane_profile: columns[7], @@ -144,3 +144,11 @@ fn parse_kind(value: &str) -> KeyEventKind { other => panic!("unsupported fixture kind: {other}"), } } + +#[test] +fn blank_generated_text_fixture_field_is_absent() { + let cases = keyboard_corpus_cases("legacy\t61\tchar:a\t-\tpress\t\t\tlegacy\t61"); + + assert_eq!(cases.len(), 1); + assert_eq!(cases[0].generated_text, None); +} diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 4cd5cb23..a908f4b6 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2184,25 +2184,30 @@ impl GhosttyPaneTerminal { } fn log_key_encoding_unavailable(reason: KeyEncodingUnavailable) { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicU64, Ordering}; - static EVENT_ALLOCATION_LOGGED: AtomicBool = AtomicBool::new(false); - static ENCODER_LOCK_LOGGED: AtomicBool = AtomicBool::new(false); - static ENCODER_ERROR_LOGGED: AtomicBool = AtomicBool::new(false); + const LOG_INTERVAL: u64 = 1024; + static EVENT_ALLOCATION_FAILURES: AtomicU64 = AtomicU64::new(0); + static ENCODER_LOCK_FAILURES: AtomicU64 = AtomicU64::new(0); + static ENCODER_ERROR_FAILURES: AtomicU64 = AtomicU64::new(0); - let first_failure = match reason { + let failures = match reason { KeyEncodingUnavailable::Adapter(GhosttyKeyEventAdapterError::UnsupportedKey) => { debug!(?reason, "Ghostty key encoding unavailable; suppressing key"); return; } KeyEncodingUnavailable::Adapter(GhosttyKeyEventAdapterError::EventAllocation) => { - &EVENT_ALLOCATION_LOGGED + &EVENT_ALLOCATION_FAILURES } - KeyEncodingUnavailable::EncoderLockPoisoned => &ENCODER_LOCK_LOGGED, - KeyEncodingUnavailable::EncoderError => &ENCODER_ERROR_LOGGED, + KeyEncodingUnavailable::EncoderLockPoisoned => &ENCODER_LOCK_FAILURES, + KeyEncodingUnavailable::EncoderError => &ENCODER_ERROR_FAILURES, }; - if !first_failure.swap(true, Ordering::Relaxed) { - error!(?reason, "Ghostty key encoding failed; suppressing key"); + let count = failures.fetch_add(1, Ordering::Relaxed) + 1; + if count == 1 || count.is_multiple_of(LOG_INTERVAL) { + error!( + ?reason, + count, "Ghostty key encoding failed; suppressing key" + ); } } diff --git a/src/raw_input.rs b/src/raw_input.rs index afeac3d8..4cee4cc1 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -1334,10 +1334,14 @@ fn discard_oversized_csi_tail(buffer: &mut Vec) -> bool { for (index, byte) in buffer.iter().copied().enumerate() { match byte { 0x20..=0x3f => {} - _ => { + 0x40..=0x7e => { buffer.drain(..=index); return true; } + _ => { + buffer.drain(..index); + return true; + } } } @@ -2567,7 +2571,18 @@ mod tests { } #[test] - fn oversized_csi_discard_precedes_excess_escape_batching() { + fn oversized_csi_discard_preserves_a_following_escape_sequence() { + let mut framer = RawInputByteFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + assert!(framer.push(&oversized).is_empty()); + + assert_eq!(framer.push(b"\x1b[A"), vec![b"\x1b[A".to_vec()]); + assert!(!framer.has_pending_input()); + } + + #[test] + fn oversized_csi_discard_preserves_excess_escape_batching() { let mut framer = RawInputByteFramer::default(); let mut oversized = b"\x1b[".to_vec(); oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); @@ -2581,7 +2596,7 @@ mod tests { .flatten() .collect::>(); - assert_eq!(rebuilt, continuation[1..]); + assert_eq!(rebuilt, continuation); assert!(!framer.has_pending_input()); assert!(framer.discard_until.is_none()); } diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index c5c32b7b..9fdb2c04 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -135,7 +135,10 @@ reason: libghostty-vt models F13-F25 but its legacy encoder has no entries for them, silently suppressing keys that Herdr receives through Kitty input. The extension uses the standard xterm/terminfo sequences, corrects modified F3 to that same standard, and composes additional modifiers with each extended key's -implicit Shift or Control modifier. +implicit Shift or Control modifier. Modified F3 therefore shares the +`CSI 1;modifier R` byte shape used by a cursor position report, but terminal +input and terminal responses travel in opposite directions and are interpreted +in that context. remove when: upstream libghostty-vt encodes F13-F25 in legacy mode with the standard xterm sequences and modifier composition, and emits the standard @@ -206,8 +209,10 @@ local files: reason: for legacy destinations, a Shift modifier consumed to produce an uppercase character must not turn Ctrl-Shift-C into CSI-u instead of the -traditional Ctrl-C byte. modifyOtherKeys mode 2 still receives every modifier, -and Kitty panes still preserve Ctrl-Shift as distinct metadata. +traditional Ctrl-C byte. That legacy protocol cannot reliably preserve the +physical Shift after it produced uppercase text. modifyOtherKeys mode 2 still +receives every modifier, and Kitty panes still preserve Ctrl-Shift as distinct +metadata. remove when: upstream libghostty-vt uses consumed modifier metadata for legacy control-sequence selection while preserving modifyOtherKeys and Kitty behavior. diff --git a/vendor/libghostty-vt/src/input/function_keys.zig b/vendor/libghostty-vt/src/input/function_keys.zig index 1227f1a3..3826e093 100644 --- a/vendor/libghostty-vt/src/input/function_keys.zig +++ b/vendor/libghostty-vt/src/input/function_keys.zig @@ -309,6 +309,7 @@ fn pcStyle(comptime fmt: []const u8) []Entry { fn pcStyleWithImplicitMods(comptime fmt: []const u8, comptime implicit: key.Mods) []Entry { comptime { + @setEvalBranchQuota(500_000); var entries: [modifiers.len]Entry = undefined; for (modifiers, 0..) |mods, i| { const code: u8 = 1 + diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig index 1ec89338..140cb15d 100644 --- a/vendor/libghostty-vt/src/input/key_encode.zig +++ b/vendor/libghostty-vt/src/input/key_encode.zig @@ -550,7 +550,8 @@ fn legacy( const codepoint = it.nextCodepoint() orelse break :unshifted; if (it.nextCodepoint() == null and codepoint != event.unshifted_codepoint) { var buf: [4]u8 = undefined; - const len = try std.unicode.utf8Encode(event.unshifted_codepoint, &buf); + const len = std.unicode.utf8Encode(event.unshifted_codepoint, &buf) catch + return try writer.writeAll(utf8); return try writer.writeAll(buf[0..len]); } } @@ -2044,6 +2045,21 @@ test "legacy: alt+unicode prefixes the complete utf8 text" { try testing.expectEqualStrings("\x1bé", writer.buffered()); } +test "legacy: alt with invalid unshifted codepoint preserves utf8 text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .unidentified, + .mods = .{ .alt = true }, + .utf8 = "é", + .unshifted_codepoint = 0xD800, + }, .{ + .alt_esc_prefix = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1bé", writer.buffered()); +} + test "legacy: alt+shift preserves shifted text" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); diff --git a/vendor/libghostty-vt/src/input/key_mods.zig b/vendor/libghostty-vt/src/input/key_mods.zig index d89e2674..0c7f6510 100644 --- a/vendor/libghostty-vt/src/input/key_mods.zig +++ b/vendor/libghostty-vt/src/input/key_mods.zig @@ -168,6 +168,14 @@ pub const Mods = packed struct(Mods.Backing) { @as(Backing, @bitCast(Mods{ .shift = true })), @as(Backing, 0b0000_0001), ); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .hyper = true })), + @as(Backing, 1 << 10), + ); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .meta = true })), + @as(Backing, 1 << 11), + ); } test "translation macos-option-as-alt" { diff --git a/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch b/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch index 4191da90..e4b1f56b 100644 --- a/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch +++ b/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch @@ -117,6 +117,24 @@ index 35e1c103..d89e2674 100644 }; } +@@ -159,9 +163,17 @@ pub const Mods = packed struct(Mods.Backing) { + // For our own understanding + test { + const testing = std.testing; + try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .shift = true })), + @as(Backing, 0b0000_0001), + ); ++ try testing.expectEqual( ++ @as(Backing, @bitCast(Mods{ .hyper = true })), ++ @as(Backing, 1 << 10), ++ ); ++ try testing.expectEqual( ++ @as(Backing, @bitCast(Mods{ .meta = true })), ++ @as(Backing, 1 << 11), ++ ); + } diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index e01cdbb8..096f1a87 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig diff --git a/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch b/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch index afa4d72f..3bece489 100644 --- a/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch +++ b/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch @@ -46,12 +46,13 @@ index 66ab4bc4..b1a0f0a5 100644 // Keypad keys result.set(.numpad_0, kpKeys("p")); -@@ -294,6 +307,24 @@ fn pcStyle(comptime fmt: []const u8) []Entry { +@@ -294,6 +307,25 @@ fn pcStyle(comptime fmt: []const u8) []Entry { } } +fn pcStyleWithImplicitMods(comptime fmt: []const u8, comptime implicit: key.Mods) []Entry { + comptime { ++ @setEvalBranchQuota(500_000); + var entries: [modifiers.len]Entry = undefined; + for (modifiers, 0..) |mods, i| { + const code: u8 = 1 + diff --git a/vendor/patches/libghostty-vt/0007-preserve-proxy-key-compatibility.patch b/vendor/patches/libghostty-vt/0007-preserve-proxy-key-compatibility.patch index 046a5199..7238faa9 100644 --- a/vendor/patches/libghostty-vt/0007-preserve-proxy-key-compatibility.patch +++ b/vendor/patches/libghostty-vt/0007-preserve-proxy-key-compatibility.patch @@ -31,7 +31,7 @@ index 4fdea85b..3d386179 100644 return; } -@@ -538,13 +537,26 @@ fn legacy( +@@ -538,13 +537,27 @@ fn legacy( // If we have alt-pressed and alt-esc-prefix is enabled, then // we need to prefix the utf8 sequence with an esc. @@ -55,7 +55,8 @@ index 4fdea85b..3d386179 100644 + const codepoint = it.nextCodepoint() orelse break :unshifted; + if (it.nextCodepoint() == null and codepoint != event.unshifted_codepoint) { + var buf: [4]u8 = undefined; -+ const len = try std.unicode.utf8Encode(event.unshifted_codepoint, &buf); ++ const len = std.unicode.utf8Encode(event.unshifted_codepoint, &buf) catch ++ return try writer.writeAll(utf8); + return try writer.writeAll(buf[0..len]); + } + } @@ -147,7 +148,7 @@ index 4fdea85b..3d386179 100644 test "legacy: ctrl+shift+minus (underscore on US)" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); -@@ -2026,6 +2029,37 @@ test "legacy: ctrl+alt+c" { +@@ -2026,6 +2029,52 @@ test "legacy: ctrl+alt+c" { try testing.expectEqualStrings("\x1b\x03", writer.buffered()); } @@ -166,6 +167,21 @@ index 4fdea85b..3d386179 100644 + try testing.expectEqualStrings("\x1bé", writer.buffered()); +} + ++test "legacy: alt with invalid unshifted codepoint preserves utf8 text" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .unidentified, ++ .mods = .{ .alt = true }, ++ .utf8 = "é", ++ .unshifted_codepoint = 0xD800, ++ }, .{ ++ .alt_esc_prefix = true, ++ .macos_option_as_alt = .true, ++ }); ++ try testing.expectEqualStrings("\x1bé", writer.buffered()); ++} ++ +test "legacy: alt+shift preserves shifted text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf);