feat(input): preserve kitty key metadata

refs #2514
This commit is contained in:
Ogulcan Celik
2026-08-09 20:39:28 +03:00
parent ca56ad259a
commit b30b3c2ff3
12 changed files with 453 additions and 78 deletions
+61 -4
View File
@@ -365,7 +365,7 @@ fn windows_crossterm_reader_loop(
Err(_) => break,
};
let raw_sequence_pending = framer.has_pending_input();
let raw_sequence_pending = framer.requires_raw_continuation();
if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) {
tracing::debug!(
bytes = ?bytes,
@@ -521,6 +521,8 @@ fn windows_client_input_event_from_raw(
let source = if let Some(bytes) = key.vt_bytes() {
crate::protocol::ClientKeySource::Vt {
bytes: bytes.to_vec(),
shifted_codepoint: key.shifted_codepoint,
base_layout_codepoint: key.base_layout_codepoint,
}
} else if let Some(record) = key.windows_record() {
crate::protocol::ClientKeySource::WindowsConsole { record }
@@ -748,6 +750,25 @@ mod windows_tests {
use super::*;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
#[test]
fn windows_oversized_csi_final_is_routed_through_discard_state() {
let mut framer = crate::raw_input::RawInputFramer::default();
let mut oversized = b"\x1b[".to_vec();
oversized.extend(std::iter::repeat_n(b'1', 4096));
assert!(framer.push(&oversized).is_empty());
assert!(framer.requires_raw_continuation());
let final_key = Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()));
let final_bytes = windows_key_raw_bytes(&final_key, framer.requires_raw_continuation())
.expect("discard continuation routes through raw framer");
assert!(framer.push(&final_bytes).is_empty());
assert!(!framer.requires_raw_continuation());
let following_key = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
assert_eq!(windows_key_raw_bytes(&following_key, false), None);
}
#[test]
fn windows_control_chars_are_reframed_as_raw_bytes() {
let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
@@ -883,7 +904,11 @@ mod windows_tests {
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] },
source: crate::protocol::ClientKeySource::Vt {
bytes: vec![4],
shifted_codepoint: None,
base_layout_codepoint: None,
},
}
);
}
@@ -907,12 +932,40 @@ mod windows_tests {
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt {
bytes: b"\x1b[A".to_vec()
bytes: b"\x1b[A".to_vec(),
shifted_codepoint: None,
base_layout_codepoint: None,
},
}
);
}
#[test]
fn windows_raw_kitty_input_preserves_alternate_codepoints() {
let mut framer = crate::raw_input::RawInputFramer::default();
let mut events = framer.push(b"\x1b[97:65:113;;65:769u");
assert_eq!(events.len(), 1);
let event =
windows_client_input_event_from_raw(events.remove(0)).expect("raw key converts");
let crate::protocol::ClientInputEvent::Key {
generated_text,
source:
crate::protocol::ClientKeySource::Vt {
shifted_codepoint,
base_layout_codepoint,
..
},
..
} = event
else {
panic!("expected VT key event");
};
assert_eq!(generated_text.as_deref(), Some("A\u{301}"));
assert_eq!(shifted_codepoint, Some('A' as u32));
assert_eq!(base_layout_codepoint, Some('q' as u32));
}
#[test]
fn windows_bare_escape_flushes_to_semantic_escape() {
let mut framer = crate::raw_input::RawInputFramer::default();
@@ -930,7 +983,11 @@ mod windows_tests {
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
source: crate::protocol::ClientKeySource::Vt {
bytes: vec![0x1b],
shifted_codepoint: None,
base_layout_codepoint: None,
},
}
);
}
+20 -4
View File
@@ -1467,7 +1467,11 @@ mod tests {
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
source: crate::protocol::ClientKeySource::Vt {
bytes: vec![0x1b],
shifted_codepoint: None,
base_layout_codepoint: None,
},
}]
);
}
@@ -1487,7 +1491,11 @@ mod tests {
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
source: crate::protocol::ClientKeySource::Vt {
bytes: vec![0x1b],
shifted_codepoint: None,
base_layout_codepoint: None,
},
}]
);
}
@@ -2201,7 +2209,11 @@ mod tests {
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
source: crate::protocol::ClientKeySource::Vt {
bytes: vec![0x1b],
shifted_codepoint: None,
base_layout_codepoint: None,
},
}]
);
}
@@ -2220,7 +2232,11 @@ mod tests {
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
source: crate::protocol::ClientKeySource::Vt {
bytes: vec![0x1b],
shifted_codepoint: None,
base_layout_codepoint: None,
},
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
+31 -18
View File
@@ -218,26 +218,27 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
_ => {}
}
let (codepoint, alternate_shifted) = match key.code {
let (codepoint, alternate_shifted, alternate_base_layout) = match key.code {
KeyCode::Char(c) => {
let base = canonical_kitty_char(c, mods);
let shifted = alternate_shifted_codepoint(key, flags);
(base as u32, shifted)
let base_layout = alternate_base_layout_codepoint(key, flags);
(base as u32, shifted, base_layout)
}
KeyCode::Enter => (13, None),
KeyCode::Tab => (9, None),
KeyCode::Backspace => (127, None),
KeyCode::Esc => (27, None),
KeyCode::Left => (57417, None),
KeyCode::Right => (57418, None),
KeyCode::Up => (57419, None),
KeyCode::Down => (57420, None),
KeyCode::PageUp => (57421, None),
KeyCode::PageDown => (57422, None),
KeyCode::Home => (57423, None),
KeyCode::End => (57424, None),
KeyCode::Insert => (57425, None),
KeyCode::Delete => (57426, None),
KeyCode::Enter => (13, None, None),
KeyCode::Tab => (9, None, None),
KeyCode::Backspace => (127, None, None),
KeyCode::Esc => (27, None, None),
KeyCode::Left => (57417, None, None),
KeyCode::Right => (57418, None, None),
KeyCode::Up => (57419, None, None),
KeyCode::Down => (57420, None, None),
KeyCode::PageUp => (57421, None, None),
KeyCode::PageDown => (57422, None, None),
KeyCode::Home => (57423, None, None),
KeyCode::End => (57424, None, None),
KeyCode::Insert => (57425, None, None),
KeyCode::Delete => (57426, None, None),
_ => return None, // fall back to legacy for unhandled keys
};
@@ -246,8 +247,14 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
let mut sequence = String::with_capacity(32);
sequence.push_str("\x1b[");
write!(&mut sequence, "{codepoint}").ok()?;
if let Some(shifted) = alternate_shifted {
write!(&mut sequence, ":{shifted}").ok()?;
if alternate_shifted.is_some() || alternate_base_layout.is_some() {
sequence.push(':');
if let Some(shifted) = alternate_shifted {
write!(&mut sequence, "{shifted}").ok()?;
}
if let Some(base_layout) = alternate_base_layout {
write!(&mut sequence, ":{base_layout}").ok()?;
}
}
write!(&mut sequence, ";{modifier}").ok()?;
if let Some(event) = event_suffix {
@@ -466,6 +473,12 @@ fn alternate_shifted_codepoint(key: &TerminalKey, flags: u16) -> Option<u32> {
}
}
fn alternate_base_layout_codepoint(key: &TerminalKey, flags: u16) -> Option<u32> {
(flags & KITTY_FLAG_REPORT_ALTERNATE_KEYS != 0)
.then_some(key.base_layout_codepoint)
.flatten()
}
fn kitty_event_suffix(key: &TerminalKey, flags: u16) -> Option<u8> {
if flags & KITTY_FLAG_REPORT_EVENT_TYPES == 0 {
return None;
+17 -1
View File
@@ -72,6 +72,7 @@ pub struct TerminalKey {
pub kind: crossterm::event::KeyEventKind,
pub repeat_count: u16,
pub shifted_codepoint: Option<u32>,
pub base_layout_codepoint: Option<u32>,
pub generated_text: Option<String>,
source: KeySource,
}
@@ -84,6 +85,7 @@ impl TerminalKey {
kind: crossterm::event::KeyEventKind::Press,
repeat_count: 1,
shifted_codepoint: None,
base_layout_codepoint: None,
generated_text: None,
source: KeySource::Synthesized,
}
@@ -112,12 +114,26 @@ impl TerminalKey {
self
}
#[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs.
pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self {
self.shifted_codepoint = Some(shifted_codepoint);
self
}
pub fn with_base_layout_codepoint(mut self, base_layout_codepoint: u32) -> Self {
self.base_layout_codepoint = Some(base_layout_codepoint);
self
}
pub(crate) fn with_alternate_codepoints(
mut self,
shifted_codepoint: Option<u32>,
base_layout_codepoint: Option<u32>,
) -> Self {
self.shifted_codepoint = shifted_codepoint;
self.base_layout_codepoint = base_layout_codepoint;
self
}
pub(crate) fn with_generated_text(mut self, text: Option<String>) -> Self {
self.generated_text = if self.kind == crossterm::event::KeyEventKind::Release {
None
+86 -21
View File
@@ -2,6 +2,8 @@ use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode};
use super::TerminalKey;
const MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS: usize = 64;
#[allow(dead_code)] // Next step: raw stdin parser will feed TerminalKey directly through this path.
pub fn parse_terminal_key_sequence(data: &str) -> Option<TerminalKey> {
parse_kitty_key_sequence(data)
@@ -16,35 +18,38 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
let mut fields = body.split(';');
let key_part = fields.next()?;
let modifier_part = fields.next().unwrap_or("1");
let associated_text = fields.next();
let associated_text = fields.next().filter(|field| !field.is_empty());
if fields.next().is_some() {
return None;
}
let modifier_part = if modifier_part.is_empty() {
"1"
} else {
modifier_part
};
let (modifier_text, event_type) = split_modifier_and_event(modifier_part);
let modifier = modifier_text.parse::<u8>().ok()?.checked_sub(1)?;
let mut key_fields = key_part.split(':');
let codepoint = key_fields.next()?.parse::<u32>().ok()?;
let shifted_codepoint = key_fields
.next()
.filter(|field| !field.is_empty())
.and_then(|field| field.parse::<u32>().ok());
if let Some(text) = associated_text {
if text.parse::<u32>().ok()? != codepoint {
return None;
}
let shifted_codepoint = parse_optional_kitty_codepoint(key_fields.next())?;
let base_layout_codepoint = parse_optional_kitty_codepoint(key_fields.next())?;
if key_fields.next().is_some() {
return None;
}
let generated_text = match associated_text {
Some(text) => Some(parse_kitty_associated_text(text)?),
None => None,
};
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())
&& shifted_codepoint.is_some_and(|shifted| shifted != codepoint)
{
modifiers |= KeyModifiers::SHIFT;
}
@@ -53,7 +58,33 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
if let Some(shifted_codepoint) = shifted_codepoint {
key = key.with_shifted_codepoint(shifted_codepoint);
}
Some(key)
if let Some(base_layout_codepoint) = base_layout_codepoint {
key = key.with_base_layout_codepoint(base_layout_codepoint);
}
Some(key.with_generated_text(generated_text))
}
fn parse_optional_kitty_codepoint(field: Option<&str>) -> Option<Option<u32>> {
let Some(field) = field.filter(|field| !field.is_empty()) else {
return Some(None);
};
let codepoint = field.parse::<u32>().ok()?;
char::from_u32(codepoint).map(|_| Some(codepoint))
}
fn parse_kitty_associated_text(text: &str) -> Option<String> {
let mut generated = String::new();
for (index, field) in text.split(':').enumerate() {
if index >= MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS {
return None;
}
let ch = field.parse::<u32>().ok().and_then(char::from_u32)?;
if ch.is_control() {
return None;
}
generated.push(ch);
}
Some(generated)
}
#[allow(dead_code)] // Reserved for the upcoming raw stdin parser.
@@ -662,8 +693,8 @@ mod tests {
}
#[test]
fn parse_kitty_sequence_with_associated_emoji_text() {
let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap();
fn parse_kitty_sequence_preserves_associated_text() {
let key = parse_terminal_key_sequence("\x1b[128512;1;128512:65039u").unwrap();
assert_terminal_key_eq(
key.clone(),
KeyCode::Char('😀'),
@@ -671,15 +702,44 @@ mod tests {
crossterm::event::KeyEventKind::Press,
None,
);
assert_eq!(key.generated_text.as_deref(), Some("😀\u{fe0f}"));
}
#[test]
fn reject_unmodeled_kitty_associated_text() {
assert_eq!(parse_terminal_key_sequence("\x1b[128512;1;128513u"), None);
assert_eq!(
parse_terminal_key_sequence("\x1b[128512;1;128512:65039u"),
None
);
fn parse_kitty_sequence_preserves_full_alternates_and_omitted_modifiers() {
let key = parse_terminal_key_sequence("\x1b[97:65:113;;65:769u").unwrap();
assert_eq!(key.code, KeyCode::Char('a'));
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
assert_eq!(key.shifted_codepoint, Some('A' as u32));
assert_eq!(key.base_layout_codepoint, Some('q' as u32));
assert_eq!(key.generated_text.as_deref(), Some("A\u{301}"));
}
#[test]
fn parse_kitty_sequence_discards_associated_text_on_release() {
let key = parse_terminal_key_sequence("\x1b[97:65;2:3;65u").unwrap();
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
assert_eq!(key.generated_text, None);
}
#[test]
fn reject_malformed_kitty_alternates_and_associated_text() {
for sequence in [
"\x1b[97:65:113:120;1u",
"\x1b[97;1;1114112u",
"\x1b[97;1;65::66u",
"\x1b[97;1;3u",
"\x1b[97;1;27u",
"\x1b[97;1;133u",
] {
assert_eq!(parse_terminal_key_sequence(sequence), None, "{sequence:?}");
}
let associated = vec!["97"; MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS + 1].join(":");
let oversized = format!("\x1b[97;1;{associated}u");
assert_eq!(parse_terminal_key_sequence(&oversized), None);
}
#[test]
@@ -976,6 +1036,11 @@ mod tests {
"{} shifted codepoint",
case.family
);
assert_eq!(
parsed.base_layout_codepoint, case.base_layout_codepoint,
"{} base layout codepoint",
case.family
);
}
}
+8 -4
View File
@@ -8,6 +8,7 @@ pub(crate) struct KeyboardCorpusCase<'a> {
pub modifiers: KeyModifiers,
pub kind: KeyEventKind,
pub shifted_codepoint: Option<u32>,
pub base_layout_codepoint: Option<u32>,
pub generated_text: Option<String>,
pub pane_profile: &'a str,
pub expected_pane_hex: &'a str,
@@ -23,10 +24,9 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec<KeyboardCorpusCase<'_>>
}
let columns: Vec<_> = line.split('\t').collect();
assert_eq!(
columns.len(),
9,
"keyboard corpus row must have 9 columns: {line}"
assert!(
matches!(columns.len(), 9 | 10),
"keyboard corpus row must have 9 or 10 columns: {line}"
);
Some(KeyboardCorpusCase {
@@ -37,6 +37,10 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec<KeyboardCorpusCase<'_>>
kind: parse_kind(columns[4]),
shifted_codepoint: (!columns[5].is_empty())
.then(|| columns[5].parse::<u32>().expect("shifted codepoint")),
base_layout_codepoint: columns
.get(9)
.filter(|field| !field.is_empty())
.map(|field| field.parse::<u32>().expect("base layout codepoint")),
generated_text: (columns[6] != "-").then(|| {
String::from_utf8(decode_hex(columns[6])).expect("generated text must be UTF-8")
}),
+8
View File
@@ -3323,6 +3323,11 @@ mod tests {
"{} shifted codepoint",
case.family
);
assert_eq!(
key.base_layout_codepoint, case.base_layout_codepoint,
"{} base layout codepoint",
case.family
);
assert_eq!(
key.generated_text, case.generated_text,
"{} generated text",
@@ -3350,6 +3355,7 @@ mod tests {
"kitty_5" => b"\x1b[>5u".as_slice(),
"kitty_7" => b"\x1b[>7u".as_slice(),
"kitty_11" => b"\x1b[>11u".as_slice(),
"kitty_13" => b"\x1b[>13u".as_slice(),
"kitty_15" => b"\x1b[>15u".as_slice(),
"kitty_25" => b"\x1b[>25u".as_slice(),
"kitty_31" => b"\x1b[>31u".as_slice(),
@@ -3491,6 +3497,8 @@ mod tests {
| "kitty_text_policy"
| "equivalent_press_suffix"
| "missing_modifier"
| "adapter_generated_text_loss"
| "adapter_base_layout_loss"
),
"unknown difference category: {}",
columns[2]
+42 -8
View File
@@ -140,6 +140,8 @@ pub enum ClientKeySource {
Synthesized,
Vt {
bytes: Vec<u8>,
shifted_codepoint: Option<u32>,
base_layout_codepoint: Option<u32>,
},
WindowsConsole {
record: crate::input::WindowsKeyRecord,
@@ -309,7 +311,13 @@ impl ClientInputEvent {
.with_generated_text(generated_text.clone());
key = match source {
ClientKeySource::Synthesized => key,
ClientKeySource::Vt { bytes } => key.with_vt_bytes(bytes.clone()),
ClientKeySource::Vt {
bytes,
shifted_codepoint,
base_layout_codepoint,
} => key
.with_vt_bytes(bytes.clone())
.with_alternate_codepoints(*shifted_codepoint, *base_layout_codepoint),
ClientKeySource::WindowsConsole { record } => key.with_windows_record(*record),
};
key = key
@@ -1148,13 +1156,15 @@ mod tests {
source: crate::protocol::ClientKeySource::Synthesized,
},
ClientInputEvent::Key {
code: ClientKeyCode::Backspace,
modifiers: 0,
code: ClientKeyCode::Char('l'),
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: ClientKeyKind::Press,
repeat_count: 3,
generated_text: None,
generated_text: Some("L".to_owned()),
source: crate::protocol::ClientKeySource::Vt {
bytes: b"\x1b[127;1u".to_vec(),
bytes: b"\x1b[108:76:113;2;76u".to_vec(),
shifted_codepoint: Some('L' as u32),
base_layout_codepoint: Some('q' as u32),
},
},
ClientInputEvent::Key {
@@ -1188,9 +1198,10 @@ mod tests {
assert_eq!(
encoded,
vec![
7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49,
117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153,
130, 2, 0, 0, 3, 4, 0,
7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 15, 108, 1, 0, 3, 1, 1, 76, 1, 18, 27, 91, 49,
48, 56, 58, 55, 54, 58, 49, 49, 51, 59, 50, 59, 55, 54, 117, 1, 76, 1, 113, 0, 14,
0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, 130, 2, 0,
0, 3, 4, 0,
]
);
let (decoded, _): (ClientMessage, _) =
@@ -1219,6 +1230,29 @@ mod tests {
}
}
#[test]
fn vt_client_input_preserves_alternate_codepoints() {
let event = ClientInputEvent::Key {
code: ClientKeyCode::Char('a'),
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: ClientKeyKind::Press,
repeat_count: 1,
generated_text: Some("A\u{301}".to_owned()),
source: ClientKeySource::Vt {
bytes: b"\x1b[97:65:113;;65:769u".to_vec(),
shifted_codepoint: Some('A' as u32),
base_layout_codepoint: Some('q' as u32),
},
};
let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else {
panic!("expected key event");
};
assert_eq!(key.shifted_codepoint, Some('A' as u32));
assert_eq!(key.base_layout_codepoint, Some('q' as u32));
assert_eq!(key.generated_text.as_deref(), Some("A\u{301}"));
}
#[test]
fn client_input_events_convert_to_raw_keys() {
let record = crate::input::WindowsKeyRecord {
+174 -16
View File
@@ -181,6 +181,11 @@ impl RawInputFramer {
self.byte_framer.has_pending_input()
}
#[cfg(any(windows, test))]
pub(crate) fn requires_raw_continuation(&self) -> bool {
self.byte_framer.has_pending_input() || self.byte_framer.discard_until.is_some()
}
pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool {
self.byte_framer.has_pending_incomplete_mouse_sequence()
}
@@ -232,6 +237,9 @@ const HOST_COLOR_QUERY_REPLIES: u16 = 258;
#[cfg(any(unix, test))]
const HOST_CELL_SIZE_QUERY_REPLIES: u16 = 1;
const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32;
const MAX_CSI_SEQUENCE_BYTES: usize = 4096;
const MAX_LEGACY_ESCAPE_PREFIXES: usize = 64;
const CSI_FINAL_BYTES: &[u8] = b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
impl RawInputByteFramer {
pub(crate) fn for_host_input() -> Self {
@@ -313,7 +321,10 @@ impl RawInputByteFramer {
let mut chunks = self.drain_available_chunks();
if let Some(family) = self.discard_until {
if family == ControlStringFamily::HostReplyCsi {
if matches!(
family,
ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi
) {
return chunks;
}
if family == ControlStringFamily::OrphanedSgrMouseTail {
@@ -408,7 +419,7 @@ impl RawInputByteFramer {
);
self.host_cell_size_replies_awaited = 0;
self.held_pending_host_reply_esc = false;
self.discard_until = Some(ControlStringFamily::HostReplyCsi);
self.discard_until = Some(ControlStringFamily::CsiTail);
self.discarded_tail_bytes = 0;
self.buffer.clear();
return chunks;
@@ -429,7 +440,7 @@ impl RawInputByteFramer {
);
self.host_appearance_reply_awaited = false;
self.held_pending_host_reply_esc = false;
self.discard_until = Some(ControlStringFamily::HostReplyCsi);
self.discard_until = Some(ControlStringFamily::CsiTail);
self.discarded_tail_bytes = 0;
self.buffer.clear();
return chunks;
@@ -496,6 +507,19 @@ impl RawInputByteFramer {
let mut chunks = Vec::new();
loop {
let escape_count = self.buffer.iter().take_while(|byte| **byte == ESC).count();
if self.discard_until.is_none() && escape_count > MAX_LEGACY_ESCAPE_PREFIXES {
let excess = escape_count - MAX_LEGACY_ESCAPE_PREFIXES;
tracing::warn!(excess, "splitting excessive legacy escape prefixes");
chunks.extend((0..excess).map(|_| vec![ESC]));
self.buffer.drain(..excess);
continue;
}
if self.discard_oversized_csi_prefix() {
continue;
}
if self.lone_escape_recently_flushed {
if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) {
break;
@@ -508,15 +532,21 @@ impl RawInputByteFramer {
}
if let Some(family) = self.discard_until {
if family == ControlStringFamily::HostReplyCsi {
if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes)
{
if family == ControlStringFamily::CsiTail {
if discard_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) {
self.discard_until = None;
self.discarded_tail_bytes = 0;
continue;
}
break;
}
if family == ControlStringFamily::OversizedCsi {
if discard_oversized_csi_tail(&mut self.buffer) {
self.discard_until = None;
continue;
}
break;
}
if family == ControlStringFamily::OrphanedSgrMouseTail {
if discard_orphaned_sgr_mouse_tail(
&mut self.buffer,
@@ -574,6 +604,36 @@ impl RawInputByteFramer {
chunks
}
fn discard_oversized_csi_prefix(&mut self) -> bool {
if self.discard_until.is_some() {
return false;
}
let Some(offset) = csi_sequence_offset(&self.buffer) else {
return false;
};
let csi = &self.buffer[offset..];
match find_csi_final(csi, CSI_FINAL_BYTES) {
Some(len) if len > MAX_CSI_SEQUENCE_BYTES => {
let consumed = offset + len;
tracing::warn!(len = consumed, "discarding oversized CSI input sequence");
self.buffer.drain(..consumed);
true
}
None if csi.len() > MAX_CSI_SEQUENCE_BYTES => {
tracing::warn!(
len = self.buffer.len(),
"discarding oversized incomplete CSI input sequence"
);
self.buffer.clear();
self.discard_until = Some(ControlStringFamily::OversizedCsi);
self.discarded_tail_bytes = 0;
true
}
Some(_) | None => false,
}
}
}
const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128;
@@ -602,7 +662,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) ->
)
}),
ControlStringFamily::StTerminated => buffer.last() == Some(&ESC),
ControlStringFamily::HostReplyCsi => false,
ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => false,
ControlStringFamily::OrphanedSgrMouseTail => buffer
.iter()
.all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')),
@@ -901,7 +961,8 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
enum ControlStringFamily {
Osc,
StTerminated,
HostReplyCsi,
CsiTail,
OversizedCsi,
OrphanedSgrMouseTail,
}
@@ -1022,6 +1083,11 @@ fn utf8_char_width(first: u8) -> Option<usize> {
}
}
fn csi_sequence_offset(buffer: &[u8]) -> Option<usize> {
let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count();
(escape_count > 0 && buffer.get(escape_count) == Some(&b'[')).then(|| escape_count - 1)
}
fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
if buffer.len() == 1 {
return None;
@@ -1043,8 +1109,18 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
return Some(1);
}
if buffer.starts_with(b"\x1b\x1b") {
return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1);
let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count();
if escape_count > MAX_LEGACY_ESCAPE_PREFIXES {
return Some(1);
}
let escape_offset = escape_count.saturating_sub(1);
let sequence = &buffer[escape_offset..];
complete_single_escape_sequence_len(sequence).map(|len| escape_offset + len)
}
fn complete_single_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
if buffer.len() == 1 {
return None;
}
if buffer.starts_with(b"\x1b[") {
@@ -1054,10 +1130,7 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
if buffer.starts_with(b"\x1b[M") {
return (buffer.len() >= 6).then_some(6);
}
return find_csi_final(
buffer,
b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
);
return find_csi_final(buffer, CSI_FINAL_BYTES);
}
if let Some(control) = control_string(buffer) {
@@ -1136,7 +1209,22 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail(
}
}
fn discard_host_reply_csi_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool {
fn discard_oversized_csi_tail(buffer: &mut Vec<u8>) -> bool {
for (index, byte) in buffer.iter().copied().enumerate() {
match byte {
0x20..=0x3f => {}
_ => {
buffer.drain(..=index);
return true;
}
}
}
buffer.clear();
false
}
fn discard_csi_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool {
let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes);
let inspected = buffer.len().min(remaining);
@@ -1213,7 +1301,7 @@ fn control_string_terminator_for_family(
match family {
ControlStringFamily::Osc => osc_string_terminator(buffer),
ControlStringFamily::StTerminated => st_string_terminator(buffer),
ControlStringFamily::HostReplyCsi => None,
ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => None,
ControlStringFamily::OrphanedSgrMouseTail => buffer
.iter()
.position(|byte| matches!(*byte, b'M' | b'm'))
@@ -1945,6 +2033,11 @@ mod tests {
"{} shifted codepoint",
case.family
);
assert_eq!(
key.base_layout_codepoint, case.base_layout_codepoint,
"{} base layout codepoint",
case.family
);
assert_eq!(
key.generated_text, case.generated_text,
"{} generated text",
@@ -2311,6 +2404,71 @@ mod tests {
);
}
#[test]
fn oversized_csi_input_is_discarded_without_losing_following_keys() {
let mut incomplete = RawInputByteFramer::default();
let mut oversized = b"\x1b[".to_vec();
oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES));
assert!(incomplete.push(&oversized).is_empty());
assert!(!incomplete.has_pending_input());
assert!(incomplete
.push(&[b'2'; MAX_DISCARDED_CONTROL_TAIL_BYTES + 1])
.is_empty());
assert!(!incomplete.has_pending_input());
assert!(incomplete.push(b"u").is_empty());
assert_eq!(incomplete.push(b"x"), vec![b"x".to_vec()]);
let mut complete = RawInputByteFramer::default();
oversized.extend_from_slice(b"uy");
assert_eq!(complete.push(&oversized), vec![b"y".to_vec()]);
assert!(!complete.has_pending_input());
}
#[test]
fn oversized_csi_discard_precedes_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));
assert!(framer.push(&oversized).is_empty());
let mut continuation = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES + 1];
continuation.push(b'x');
let rebuilt = framer
.push(&continuation)
.into_iter()
.flatten()
.collect::<Vec<_>>();
assert_eq!(rebuilt, continuation[1..]);
assert!(!framer.has_pending_input());
assert!(framer.discard_until.is_none());
}
#[test]
fn doubled_escape_cannot_bypass_oversized_csi_limit() {
let mut framer = RawInputByteFramer::with_host_input_policy(true);
let mut oversized = b"\x1b\x1b[".to_vec();
oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES));
assert!(framer.push(&oversized).is_empty());
assert!(!framer.has_pending_input());
assert!(framer.push(b"u").is_empty());
assert_eq!(framer.push(b"x"), vec![b"x".to_vec()]);
}
#[test]
fn repeated_escape_prefixes_are_drained_iteratively() {
let mut framer = RawInputByteFramer::with_host_input_policy(true);
let mut input = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES * 1024];
input.extend_from_slice(b"[A");
let chunks = framer.push(&input);
let rebuilt = chunks.into_iter().flatten().collect::<Vec<_>>();
assert_eq!(rebuilt, input);
assert!(!framer.has_pending_input());
}
#[test]
fn chunked_bracketed_paste_waits_for_terminator() {
let (tx, mut rx) = mpsc::channel(8);
+1 -1
View File
@@ -451,7 +451,7 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit {
.saturating_mul(usize::from((*repeat_count).max(1))),
);
}
if let crate::protocol::ClientKeySource::Vt { bytes } = source {
if let crate::protocol::ClientKeySource::Vt { bytes, .. } = source {
input_bytes = input_bytes.saturating_add(bytes.len());
}
}
+2
View File
@@ -17,3 +17,5 @@ ghostty kitty_shift_letter_kitty_31 equivalent_press_suffix 1b5b3130383a37363b32
ghostty kitty_super_a_kitty_31 equivalent_press_suffix 1b5b39373b3975
ghostty kitty_hyper_a_kitty_31 missing_modifier 1b5b39373b3b393775
ghostty kitty_meta_a_kitty_31 missing_modifier 1b5b39373b3b393775
ghostty kitty_full_alternates_associated_text adapter_generated_text_loss 41
ghostty kitty_full_alternates_kitty_13 adapter_base_layout_loss 1b5b39373a36353b3275
1 # backend family category actual
17 ghostty kitty_super_a_kitty_31 equivalent_press_suffix 1b5b39373b3975
18 ghostty kitty_hyper_a_kitty_31 missing_modifier 1b5b39373b3b393775
19 ghostty kitty_meta_a_kitty_31 missing_modifier 1b5b39373b3b393775
20 ghostty kitty_full_alternates_associated_text adapter_generated_text_loss 41
21 ghostty kitty_full_alternates_kitty_13 adapter_base_layout_loss 1b5b39373a36353b3275
+3 -1
View File
@@ -1,4 +1,4 @@
# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex
# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex base_layout_codepoint(optional)
legacy_ctrl_space 00 char: control press - legacy 00
legacy_ctrl_a 01 char:a control press - legacy 01
legacy_ctrl_b 02 char:b control press - legacy 02
@@ -120,3 +120,5 @@ kitty_repeat_a_kitty_11 1b5b39373b313a3275 char:a - repeat - kitty_11 1b5b39373
kitty_super_a_kitty_31 1b5b39373b3975 char:a super press - kitty_31 1b5b39373b393a3175
kitty_hyper_a_kitty_31 1b5b39373b313775 char:a hyper press - kitty_31 1b5b39373b31373a3175
kitty_meta_a_kitty_31 1b5b39373b333375 char:a meta press - kitty_31 1b5b39373b33333a3175
kitty_full_alternates_kitty_13 1b5b39373a36353a3131333b3275 char:a shift press 65 - kitty_13 1b5b39373a36353a3131333b3275 113
kitty_full_alternates_associated_text 1b5b39373a36353a3131333b3b36353a37363975 char:a shift press 65 41cc81 legacy 41cc81 113
1 # family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex base_layout_codepoint(optional)
2 legacy_ctrl_space 00 char: control press - legacy 00
3 legacy_ctrl_a 01 char:a control press - legacy 01
4 legacy_ctrl_b 02 char:b control press - legacy 02
120 kitty_super_a_kitty_31 1b5b39373b3975 char:a super press - kitty_31 1b5b39373b393a3175
121 kitty_hyper_a_kitty_31 1b5b39373b313775 char:a hyper press - kitty_31 1b5b39373b31373a3175
122 kitty_meta_a_kitty_31 1b5b39373b333375 char:a meta press - kitty_31 1b5b39373b33333a3175
123 kitty_full_alternates_kitty_13 1b5b39373a36353a3131333b3275 char:a shift press 65 - kitty_13 1b5b39373a36353a3131333b3275 113
124 kitty_full_alternates_associated_text 1b5b39373a36353a3131333b3b36353a37363975 char:a shift press 65 41cc81 legacy 41cc81 113