fix(input): address cross-platform encoding regressions

refs #2514
This commit is contained in:
Ogulcan Celik
2026-08-09 21:20:48 +03:00
parent faefa14b73
commit 6a15ddd0ca
13 changed files with 136 additions and 26 deletions
@@ -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"
+2
View File
@@ -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))]
+16 -1
View File
@@ -29,7 +29,7 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
modifier_part
};
let (modifier_text, event_type) = split_modifier_and_event(modifier_part);
let modifier = modifier_text.parse::<u8>().ok()?.checked_sub(1)?;
let modifier = u8::try_from(modifier_text.parse::<u16>().ok()?.checked_sub(1)?).ok()?;
let mut key_fields = key_part.split(':');
let codepoint = key_fields.next()?.parse::<u32>().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();
+9 -1
View File
@@ -60,7 +60,7 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec<KeyboardCorpusCase<'_>>
.get(9)
.filter(|field| !field.is_empty())
.map(|field| field.parse::<u32>().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);
}
+15 -10
View File
@@ -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"
);
}
}
+18 -3
View File
@@ -1334,10 +1334,14 @@ fn discard_oversized_csi_tail(buffer: &mut Vec<u8>) -> 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::<Vec<_>>();
assert_eq!(rebuilt, continuation[1..]);
assert_eq!(rebuilt, continuation);
assert!(!framer.has_pending_input());
assert!(framer.discard_until.is_none());
}
+8 -3
View File
@@ -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.
+1
View File
@@ -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 +
+17 -1
View File
@@ -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);
+8
View File
@@ -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" {
@@ -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
@@ -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 +
@@ -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);