From 02c65ec9e4cca7f17312898abefa0fe506db7082 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Mon, 27 Jul 2026 20:43:29 +0200 Subject: [PATCH 1/6] fix(windows): preserve native key input through conpty Carry physical Windows key records through the existing semantic routing path. The pane send-text Escape case remains separate because it has no native record to preserve. refs #2077 discussion: https://github.com/herdrdev/herdr/discussions/1934 --- src/app/input/clipboard.rs | 21 ++++- src/app/mod.rs | 66 ++++++++++++- src/app/runtime.rs | 1 + src/client/input/windows_vti.rs | 160 ++++++++++++++++++++++++-------- src/input/mod.rs | 2 +- src/input/model.rs | 20 ++++ src/input/parse.rs | 1 + src/pane/terminal.rs | 2 +- src/platform/windows.rs | 36 +++++-- src/protocol/wire.rs | 44 ++++++++- 10 files changed, 303 insertions(+), 50 deletions(-) diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs index db3fa9f2b6..0b7e67bfd1 100644 --- a/src/app/input/clipboard.rs +++ b/src/app/input/clipboard.rs @@ -46,7 +46,8 @@ impl App { return false; } - self.suppressed_repeat_keys.insert((source_id, key.code)); + self.suppressed_repeat_keys + .insert(super::super::pressed_key_identity(source_id, &key)); true } } @@ -132,7 +133,15 @@ mod tests { assert_visible_selection(&app); assert!(app.event_rx.try_recv().is_err()); - let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x43, + virtual_scan_code: 0x2e, + unicode: 'c' as u16, + control_key_state: 0x0008, + }); let source_id = 41; app.route_client_events_from( source_id, @@ -171,14 +180,20 @@ mod tests { )], false, ); + assert!(app.suppressed_repeat_keys.is_empty()); app.route_client_events_from( source_id, vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], false, ); + let expected = if cfg!(windows) { + b"\x1b[67;46;99;1;8;1_".as_slice() + } else { + b"\x03".as_slice() + }; assert_eq!( input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" + expected ); assert!(app.event_rx.try_recv().is_err()); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1733e23465..98b46ebad6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -209,6 +209,15 @@ pub(crate) struct PressedTerminalKey { key: crate::input::TerminalKey, } +impl PressedTerminalKey { + fn release_key(&self, mut key: crate::input::TerminalKey) -> crate::input::TerminalKey { + if matches!(self.key.windows_record, Some(record) if record.repeat_count > 1) { + key.windows_record = None; + } + key + } +} + pub(crate) type InputSourceId = u64; const LOCAL_INPUT_SOURCE: InputSourceId = 0; @@ -216,7 +225,18 @@ fn pressed_key_identity( source_id: InputSourceId, key: &crate::input::TerminalKey, ) -> (InputSourceId, crossterm::event::KeyCode) { - (source_id, key.code) + const ENHANCED_KEY: u32 = 0x0100; + let native_code = key.windows_record.and_then(|record| { + (record.virtual_scan_code != 0) + .then(|| { + 0xf0000 + + u32::from(record.virtual_scan_code) + + (u32::from(record.control_key_state & ENHANCED_KEY != 0) << 16) + }) + .and_then(char::from_u32) + .map(crossterm::event::KeyCode::Char) + }); + (source_id, native_code.unwrap_or(key.code)) } fn auto_updates_enabled(no_session: bool) -> bool { @@ -1660,6 +1680,7 @@ impl App { if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) { + let key = pressed.release_key(key); let _ = self .forward_terminal_key_to_target_headless(&pressed.target, key); } @@ -1835,6 +1856,49 @@ mod tests { ) } + #[cfg(windows)] + #[test] + fn native_pressed_key_identity_and_grouped_release_follow_press_record() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 88, + virtual_scan_code: 45, + unicode: 120, + control_key_state: 0, + }; + let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + .with_windows_record(record); + let mut enhanced = key; + enhanced.windows_record.as_mut().unwrap().control_key_state = 0x0100; + assert_ne!( + pressed_key_identity(7, &key), + pressed_key_identity(7, &enhanced) + ); + let encode = |key: crate::input::TerminalKey| { + crate::platform::encode_windows_conpty_fallback(key).unwrap_or_else(|| { + crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy) + }) + }; + assert_eq!(encode(key), b"\x1b[88;45;120;1;0;1_"); + let grouped = key.with_windows_record(crate::input::WindowsKeyRecord { + repeat_count: 2, + ..record + }); + let pressed = PressedTerminalKey { + target: TerminalInputTarget { + terminal_id: crate::terminal::TerminalId::alloc(), + }, + key: grouped, + }; + let mut release = key; + release.windows_record.as_mut().unwrap().key_down = false; + let release = pressed.release_key(release.with_kind(KeyEventKind::Release)); + assert_eq!(encode(grouped), b"x"); + assert_eq!(encode(grouped.with_kind(KeyEventKind::Repeat)), b"x"); + assert!(encode(release).is_empty()); + } + fn release_notes_state() -> state::ReleaseNotesState { state::ReleaseNotesState { version: "0.1.0".into(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ef0a452d2f..73569c9609 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -156,6 +156,7 @@ impl App { crossterm::event::KeyEventKind::Release => { self.suppressed_repeat_keys.remove(&pressed_key_id); if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) { + let key = pressed.release_key(key); let _ = self .forward_terminal_key_to_target(&pressed.target, key) .await; diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 8966112e0d..fd2a686d50 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -9,6 +9,7 @@ use tokio::sync::mpsc; use super::windows_client_input_event_from_raw; #[cfg(windows)] use super::ClientLoopEvent; +use crate::input::WindowsKeyRecord; #[cfg(windows)] pub(super) fn raw_console_reader_loop( @@ -164,16 +165,6 @@ enum WindowsInputRecord { Focus(bool), } -#[derive(Clone, Copy, Debug)] -struct WindowsKeyRecord { - key_down: bool, - repeat_count: u16, - virtual_key_code: u16, - virtual_scan_code: u16, - unicode: u16, - control_key_state: u32, -} - #[derive(Clone, Copy, Debug)] struct WindowsMouseRecord { x: u16, @@ -574,6 +565,12 @@ impl WindowsInputMapper { } let is_alt_code = Self::is_alt_code(key); + let carries_native_record = key.repeat_count != 0 + && key.virtual_key_code != 0 + && key.virtual_key_code != 0x03 + && !matches!(key.virtual_key_code, 0xe5 | 0xe7) + && !is_alt_code + && !(0xd800..=0xdfff).contains(&key.unicode); (0..key.repeat_count.max(1)) .filter_map(|repeat_idx| { self.translate_semantic_key_event( @@ -581,6 +578,25 @@ impl WindowsInputMapper { Self::semantic_key_kind(key, is_alt_code, repeat_idx), ) }) + .map(|event| { + if carries_native_record { + match event { + crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + } => crate::protocol::ClientInputEvent::WindowsKey { + code, + modifiers, + kind, + record: key, + }, + event => event, + } + } else { + event + } + }) .collect() } @@ -1063,7 +1079,7 @@ mod tests { key_down: true, repeat_count, virtual_key_code: vk, - virtual_scan_code: 0, + virtual_scan_code: 1, unicode: 0, control_key_state: 0, }) @@ -1114,6 +1130,33 @@ mod tests { fn translate( records: impl IntoIterator, + ) -> Vec { + semantic_only(translate_with_provenance(records)) + } + + fn semantic_only( + events: impl IntoIterator, + ) -> Vec { + events + .into_iter() + .map(|event| match event { + crate::protocol::ClientInputEvent::WindowsKey { + code, + modifiers, + kind, + .. + } => crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + }, + event => event, + }) + .collect() + } + + fn translate_with_provenance( + records: impl IntoIterator, ) -> Vec { let mut translator = WindowsInputTranslator::default(); records @@ -1384,41 +1427,74 @@ mod tests { #[test] fn vti_physical_escape_key_record_is_immediately_semantic() { - let mut translator = WindowsInputTranslator::default(); + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x1b, + virtual_scan_code: 0x01, + unicode: 0x1b, + control_key_state: 0, + }; assert_eq!( - translator.translate(key_vk_with_scan_unicode(0x1b, 0x01, '\x1b', 0)), - vec![crate::protocol::ClientInputEvent::Key { + translate_with_provenance([WindowsInputRecord::Key(record)]), + vec![crate::protocol::ClientInputEvent::WindowsKey { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + record, }] ); } #[test] - fn vti_repeated_escape_record_stays_semantic() { + fn vti_grouped_escape_down_and_up_keep_native_ownership_record() { + use crate::protocol::ClientKeyKind::{Press, Release, Repeat}; + + let events = translate_with_provenance([ + key_vk_with_repeat(0x1b, 3), + key_vk_up_with_scan_unicode(0x1b, 1, '\0', 0), + ]); + assert_eq!( - translate([key_vk_with_repeat(0x1b, 3)]), - vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Repeat, - }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Repeat, - }, + events + .iter() + .map(|event| match event { + crate::protocol::ClientInputEvent::WindowsKey { + code: crate::protocol::ClientKeyCode::Esc, + kind, + record, + .. + } => (*kind, record.key_down, record.repeat_count), + event => panic!("unexpected grouped event: {event:?}"), + }) + .collect::>(), + [ + (Press, true, 3), + (Repeat, true, 3), + (Repeat, true, 3), + (Release, false, 1) ] ); } + #[test] + fn vti_ctrl_break_record_keeps_semantic_path() { + assert!(matches!( + translate_with_provenance([key_vk(0x03, 0x0008)]).as_slice(), + [crate::protocol::ClientInputEvent::Key { .. }] + )); + } + + #[test] + fn vti_ime_virtual_keys_keep_semantic_path() { + for vk in [0xe5, 0xe7] { + assert!(matches!( + translate_with_provenance([key_vk_with_unicode(vk, 'é', 0)]).as_slice(), + [crate::protocol::ClientInputEvent::Key { .. }] + )); + } + } + #[test] fn vti_modified_escape_remains_semantic() { assert_eq!( @@ -1813,24 +1889,33 @@ mod tests { let mut translator = WindowsInputTranslator::default(); assert_eq!( translator.translate(key_vk_with_scan_unicode(0x1b, 0x02, '\0', 0)), - vec![crate::protocol::ClientInputEvent::Key { + vec![crate::protocol::ClientInputEvent::WindowsKey { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + record: WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x1b, + virtual_scan_code: 0x02, + unicode: 0, + control_key_state: 0, + }, }] ); } #[test] fn vti_win32_input_mode_physical_escape_is_immediately_semantic() { - let records = win32_input_mode_encoded_record(WindowsKeyRecord { + let record = WindowsKeyRecord { key_down: true, repeat_count: 1, virtual_key_code: 0x1b, virtual_scan_code: 0x01, unicode: 0x1b, control_key_state: 0, - }); + }; + let records = win32_input_mode_encoded_record(record); let mut translator = WindowsInputTranslator::default(); assert_eq!( @@ -1838,10 +1923,11 @@ mod tests { .into_iter() .flat_map(|record| translator.translate(record)) .collect::>(), - vec![crate::protocol::ClientInputEvent::Key { + vec![crate::protocol::ClientInputEvent::WindowsKey { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + record, }] ); } @@ -1912,7 +1998,7 @@ mod tests { let mut translator = WindowsInputTranslator::default(); assert!(translator.translate(key_char('\x1b')).is_empty()); assert_eq!( - translator.translate(key_vk(0x26, 0)), + semantic_only(translator.translate(key_vk(0x26, 0))), vec![ crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Esc, diff --git a/src/input/mod.rs b/src/input/mod.rs index 3882b03507..7dd0b115a2 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -10,6 +10,6 @@ pub use encode::{ pub use model::ime_compatible_keyboard_enhancement_flags; pub use model::{ host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, - TerminalKey, + TerminalKey, WindowsKeyRecord, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index f6072bc680..edbe6a8dd6 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -3,6 +3,16 @@ use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowsKeyRecord { + pub key_down: bool, + pub repeat_count: u16, + pub virtual_key_code: u16, + pub virtual_scan_code: u16, + pub unicode: u16, + pub control_key_state: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TerminalKey { pub code: KeyCode, @@ -10,6 +20,7 @@ pub struct TerminalKey { pub kind: crossterm::event::KeyEventKind, pub shifted_codepoint: Option, pub is_text_commit: bool, + pub windows_record: Option, } impl TerminalKey { @@ -20,10 +31,14 @@ impl TerminalKey { kind: crossterm::event::KeyEventKind::Press, shifted_codepoint: None, is_text_commit: false, + windows_record: None, } } pub fn with_kind(mut self, kind: crossterm::event::KeyEventKind) -> Self { + if let Some(record) = self.windows_record.as_mut() { + record.key_down = kind != crossterm::event::KeyEventKind::Release; + } self.kind = kind; self } @@ -34,6 +49,11 @@ impl TerminalKey { self } + pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self { + self.windows_record = Some(record); + self + } + pub fn as_text_commit(mut self) -> Self { let has_text_only_modifiers = match self.code { KeyCode::Char(ch) if ch.is_ascii_uppercase() => { diff --git a/src/input/parse.rs b/src/input/parse.rs index e4c554f83f..e840211d98 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -46,6 +46,7 @@ fn parse_kitty_key_sequence(data: &str) -> Option { kind, shifted_codepoint, is_text_commit: false, + windows_record: None, }) } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 76ab20d398..3b765cca7f 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1625,7 +1625,7 @@ impl GhosttyPaneTerminal { protocol: crate::input::KeyboardProtocol, ) -> Vec { #[cfg(windows)] - if let Some(bytes) = crate::platform::encode_windows_conpty_shift_enter(key) { + if let Some(bytes) = crate::platform::encode_windows_conpty_fallback(key) { if self.core.lock().is_ok_and(|core| { core.terminal .kitty_keyboard_flags() diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 35ecfd827f..70272cc1a9 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -64,15 +64,39 @@ use super::{ClipboardImage, ForegroundJob, Signal}; const STILL_ACTIVE: u32 = 259; const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250); -pub(crate) fn encode_windows_conpty_shift_enter(key: crate::input::TerminalKey) -> Option> { +/// Native Win32 input, with targeted fallbacks for semantic-only input. +pub(crate) fn encode_windows_conpty_fallback(key: crate::input::TerminalKey) -> Option> { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; - if key.code != KeyCode::Enter || key.modifiers != KeyModifiers::SHIFT { - return None; - } + let record = match key.windows_record { + Some(record) if record.repeat_count == 1 => record, + Some(_) | None => { + if key.code != KeyCode::Enter || key.modifiers != KeyModifiers::SHIFT { + return None; + } + crate::input::WindowsKeyRecord { + key_down: !matches!(key.kind, KeyEventKind::Release), + repeat_count: 1, + virtual_key_code: 13, + virtual_scan_code: 28, + unicode: 13, + control_key_state: 16, + } + } + }; - let key_down = !matches!(key.kind, KeyEventKind::Release); - Some(format!("\x1b[13;28;13;{};16;1_", u8::from(key_down)).into_bytes()) + Some( + format!( + "\x1b[{};{};{};{};{};{}_", + record.virtual_key_code, + record.virtual_scan_code, + record.unicode, + u8::from(record.key_down), + record.control_key_state, + record.repeat_count, + ) + .into_bytes(), + ) } #[derive(Debug)] diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 0d4b14bba7..3fdc3736c6 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -130,6 +130,12 @@ pub enum ClientInputEvent { }, FocusGained, FocusLost, + WindowsKey { + code: ClientKeyCode, + modifiers: u8, + kind: ClientKeyKind, + record: crate::input::WindowsKeyRecord, + }, } impl ClientKeyKind { @@ -309,6 +315,19 @@ impl ClientInputEvent { Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()), Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained, Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost, + Self::WindowsKey { + code, + modifiers, + kind, + record, + } => crate::raw_input::RawInputEvent::Key( + crate::input::TerminalKey::new( + code.to_crossterm(), + crossterm::event::KeyModifiers::from_bits_truncate(*modifiers), + ) + .with_windows_record(*record) + .with_kind(kind.to_crossterm()), + ), } } } @@ -1083,6 +1102,19 @@ mod tests { modifiers: 0, kind: ClientKeyKind::Press, }, + ClientInputEvent::WindowsKey { + code: ClientKeyCode::Esc, + modifiers: 0, + kind: ClientKeyKind::Release, + record: crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }, + }, ClientInputEvent::Mouse { kind: ClientMouseKind::Down(ClientMouseButton::Left), column: 3, @@ -1099,10 +1131,19 @@ mod tests { #[test] fn client_input_events_convert_to_raw_keys() { - let shifted = ClientInputEvent::Key { + let record = crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 78, + virtual_scan_code: 49, + unicode: 78, + control_key_state: 16, + }; + let shifted = ClientInputEvent::WindowsKey { code: ClientKeyCode::Char('N'), modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, + record, } .to_raw_input_event(); match shifted { @@ -1110,6 +1151,7 @@ mod tests { assert_eq!(key.code, crossterm::event::KeyCode::Char('N')); assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT); assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!(key.windows_record.map(|record| record.key_down), Some(true)); } other => panic!("expected shifted key event, got {other:?}"), } From c1fd5d1ce6cbcc19093272ecb81aab6e3e56aa18 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Thu, 30 Jul 2026 18:49:37 +0200 Subject: [PATCH 2/6] fix(windows): preserve key releases after repeats --- src/app/mod.rs | 92 ++++++++++++++++++++------------- src/app/runtime.rs | 1 - src/client/input/windows_vti.rs | 11 ++-- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 98b46ebad6..935b64e0c9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -209,15 +209,6 @@ pub(crate) struct PressedTerminalKey { key: crate::input::TerminalKey, } -impl PressedTerminalKey { - fn release_key(&self, mut key: crate::input::TerminalKey) -> crate::input::TerminalKey { - if matches!(self.key.windows_record, Some(record) if record.repeat_count > 1) { - key.windows_record = None; - } - key - } -} - pub(crate) type InputSourceId = u64; const LOCAL_INPUT_SOURCE: InputSourceId = 0; @@ -1680,7 +1671,6 @@ impl App { if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) { - let key = pressed.release_key(key); let _ = self .forward_terminal_key_to_target_headless(&pressed.target, key); } @@ -1857,17 +1847,17 @@ mod tests { } #[cfg(windows)] - #[test] - fn native_pressed_key_identity_and_grouped_release_follow_press_record() { + #[tokio::test] + async fn native_pressed_key_identity_and_held_escape_releases_follow_press_record() { let record = crate::input::WindowsKeyRecord { key_down: true, repeat_count: 1, - virtual_key_code: 88, - virtual_scan_code: 45, - unicode: 120, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, control_key_state: 0, }; - let key = crate::input::TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()) + let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) .with_windows_record(record); let mut enhanced = key; enhanced.windows_record.as_mut().unwrap().control_key_state = 0x0100; @@ -1875,28 +1865,56 @@ mod tests { pressed_key_identity(7, &key), pressed_key_identity(7, &enhanced) ); - let encode = |key: crate::input::TerminalKey| { - crate::platform::encode_windows_conpty_fallback(key).unwrap_or_else(|| { - crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy) + + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + unicode: 0, + ..record }) - }; - assert_eq!(encode(key), b"\x1b[88;45;120;1;0;1_"); - let grouped = key.with_windows_record(crate::input::WindowsKeyRecord { - repeat_count: 2, - ..record - }); - let pressed = PressedTerminalKey { - target: TerminalInputTarget { - terminal_id: crate::terminal::TerminalId::alloc(), - }, - key: grouped, - }; - let mut release = key; - release.windows_record.as_mut().unwrap().key_down = false; - let release = pressed.release_key(release.with_kind(KeyEventKind::Release)); - assert_eq!(encode(grouped), b"x"); - assert_eq!(encode(grouped.with_kind(KeyEventKind::Repeat)), b"x"); - assert!(encode(release).is_empty()); + .with_kind(KeyEventKind::Release); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(key), + crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Repeat)), + crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Repeat)), + crate::raw_input::RawInputEvent::Key(release), + crate::raw_input::RawInputEvent::Key(key), + crate::raw_input::RawInputEvent::OuterFocusLost, + ], + false, + ); + + for _ in 0..3 { + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") + ); + } + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") + ); + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") + ); + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") + ); + assert!(rx.try_recv().is_err()); } fn release_notes_state() -> state::ReleaseNotesState { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 73569c9609..ef0a452d2f 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -156,7 +156,6 @@ impl App { crossterm::event::KeyEventKind::Release => { self.suppressed_repeat_keys.remove(&pressed_key_id); if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) { - let key = pressed.release_key(key); let _ = self .forward_terminal_key_to_target(&pressed.target, key) .await; diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index fd2a686d50..974d652cad 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -589,7 +589,10 @@ impl WindowsInputMapper { code, modifiers, kind, - record: key, + record: WindowsKeyRecord { + repeat_count: 1, + ..key + }, }, event => event, } @@ -1469,9 +1472,9 @@ mod tests { }) .collect::>(), [ - (Press, true, 3), - (Repeat, true, 3), - (Repeat, true, 3), + (Press, true, 1), + (Repeat, true, 1), + (Repeat, true, 1), (Release, false, 1) ] ); From 7f936c717bef0b7f390584c991f9be94b0d436af Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Fri, 31 Jul 2026 01:22:09 +0200 Subject: [PATCH 3/6] fix(windows): preserve printable native key records Treat nonzero scan codes as physical input before printable records are reduced to text. This keeps actual text, paste, and VT framing on their existing paths while preserving press, grouped repeats, and release. refs #2077 --- src/client/input/windows_vti.rs | 84 +++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 974d652cad..88a7efccfe 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -493,6 +493,10 @@ impl WindowsInputMapper { return Some(vec![0x1b]); } + if record.virtual_scan_code != 0 { + return None; + } + if !record.key_down || record.repeat_count.max(1) != 1 || windows_key_modifiers(record.control_key_state).bits() != 0 @@ -1790,22 +1794,74 @@ mod tests { } #[test] - fn vti_win32_input_mode_printable_key_becomes_char() { - let records = "\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_" - .chars() - .map(key_char); - + fn vti_win32_input_mode_printable_keys_preserve_physical_records() { assert_eq!( - translate(records), - vec![ - crate::protocol::ClientInputEvent::Text { codepoint: 'a' }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - }, - ] + translate(win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0, + virtual_scan_code: 0, + unicode: b'a'.into(), + control_key_state: 0, + })), + vec![crate::protocol::ClientInputEvent::Text { codepoint: 'a' }] ); + + use crate::protocol::ClientKeyKind::{Press, Release, Repeat}; + for (repeat_count, expected) in [ + (1, vec![(Press, true), (Release, false)]), + ( + 3, + vec![ + (Press, true), + (Repeat, true), + (Repeat, true), + (Release, false), + ], + ), + ] { + let mut records = win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x41, + virtual_scan_code: 30, + unicode: b'a'.into(), + control_key_state: 0, + }); + records.extend(win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 0x41, + virtual_scan_code: 30, + unicode: b'a'.into(), + control_key_state: 0, + })); + + assert_eq!( + translate_with_provenance(records) + .iter() + .map(|event| match event { + crate::protocol::ClientInputEvent::WindowsKey { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind, + record: + WindowsKeyRecord { + repeat_count: 1, + virtual_key_code: 0x41, + virtual_scan_code: 30, + unicode, + control_key_state: 0, + key_down, + }, + } if *unicode == u16::from(b'a') => (*kind, *key_down), + event => panic!("unexpected printable key event: {event:?}"), + }) + .collect::>(), + expected, + "repeat_count={repeat_count}" + ); + } } #[test] From b6cee42af9c6854c7a7d34b188188c223633fb0e Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Fri, 31 Jul 2026 02:45:30 +0200 Subject: [PATCH 4/6] fix(windows): keep count-one repeats with pressed pane Treat a native press for an already-owned physical key as a repeat. Windows may emit a held key as separate count-one records, so this keeps subsequent downs and the final release routed to the pane that received the first press. refs #2077 --- src/app/mod.rs | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 935b64e0c9..8dfffc22f8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1627,6 +1627,14 @@ impl App { match event { crate::raw_input::RawInputEvent::Key(key) => { let pressed_key_id = pressed_key_identity(source_id, &key); + let key = if key.kind == crossterm::event::KeyEventKind::Press + && key.windows_record.is_some() + && self.pressed_terminal_keys.contains_key(&pressed_key_id) + { + key.with_kind(crossterm::event::KeyEventKind::Repeat) + } else { + key + }; match key.kind { crossterm::event::KeyEventKind::Press => { if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal @@ -1848,7 +1856,7 @@ mod tests { #[cfg(windows)] #[tokio::test] - async fn native_pressed_key_identity_and_held_escape_releases_follow_press_record() { + async fn native_repeats_and_releases_follow_the_pressed_pane() { let record = crate::input::WindowsKeyRecord { key_down: true, repeat_count: 1, @@ -1868,9 +1876,16 @@ mod tests { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); - let focused = workspace.focused_pane_id().unwrap(); - let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); - workspace.tabs[0].runtimes.insert(focused, runtime); + let pressed_pane = workspace.focused_pane_id().unwrap(); + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(pressed_pane); + let (pressed_runtime, mut pressed_rx) = + TerminalRuntime::test_with_channel_capacity(80, 24, 6); + let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); + workspace.tabs[0] + .runtimes + .insert(pressed_pane, pressed_runtime); + workspace.tabs[0].runtimes.insert(other_pane, other_runtime); app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; @@ -1884,11 +1899,12 @@ mod tests { ..record }) .with_kind(KeyEventKind::Release); + app.route_client_events(vec![crate::raw_input::RawInputEvent::Key(key)], false); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); app.route_client_events( vec![ crate::raw_input::RawInputEvent::Key(key), crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Repeat)), - crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Repeat)), crate::raw_input::RawInputEvent::Key(release), crate::raw_input::RawInputEvent::Key(key), crate::raw_input::RawInputEvent::OuterFocusLost, @@ -1898,23 +1914,25 @@ mod tests { for _ in 0..3 { assert_eq!( - rx.try_recv().unwrap(), + pressed_rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") ); } assert_eq!( - rx.try_recv().unwrap(), + pressed_rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") ); + assert!(pressed_rx.try_recv().is_err()); + assert_eq!( - rx.try_recv().unwrap(), + other_rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") ); assert_eq!( - rx.try_recv().unwrap(), + other_rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") ); - assert!(rx.try_recv().is_err()); + assert!(other_rx.try_recv().is_err()); } fn release_notes_state() -> state::ReleaseNotesState { From 676a57748fd0b36a36a59bd38b5b4292e74d1575 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Fri, 31 Jul 2026 13:43:28 +0200 Subject: [PATCH 5/6] test(windows): verify native key ownership cleanup --- src/app/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8dfffc22f8..a9a6fb5195 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1933,6 +1933,7 @@ mod tests { bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") ); assert!(other_rx.try_recv().is_err()); + assert!(app.pressed_terminal_keys.is_empty()); } fn release_notes_state() -> state::ReleaseNotesState { From fb8827e2aa7363f51b335f1350cb823723296e60 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 31 Jul 2026 17:01:47 +0300 Subject: [PATCH 6/6] fix(windows): preserve suppressed native repeats refs #2077 --- docs/next/api/herdr-api.schema.json | 2 +- src/app/input/clipboard.rs | 8 +++++--- src/app/mod.rs | 3 ++- src/protocol/wire.rs | 2 +- tests/api_ping.rs | 2 +- tests/cli/sessions.rs | 12 ++++++------ tests/support/mod.rs | 2 +- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 59a0c6a331..b4704cd15c 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "protocol": 18, + "protocol": 19, "schema_version": 1, "schemas": { "error_response": { diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs index 0b7e67bfd1..ea3640c3f0 100644 --- a/src/app/input/clipboard.rs +++ b/src/app/input/clipboard.rs @@ -165,11 +165,13 @@ mod tests { app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.with_kind(KeyEventKind::Repeat), - )], + vec![ + crate::raw_input::RawInputEvent::Key(ctrl_c), + crate::raw_input::RawInputEvent::Key(ctrl_c.with_kind(KeyEventKind::Repeat)), + ], false, ); + assert_eq!(app.suppressed_repeat_keys.len(), 1); assert!(app.event_rx.try_recv().is_err()); assert!(input_rx.try_recv().is_err()); diff --git a/src/app/mod.rs b/src/app/mod.rs index a9a6fb5195..feb30ab2b5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1629,7 +1629,8 @@ impl App { let pressed_key_id = pressed_key_identity(source_id, &key); let key = if key.kind == crossterm::event::KeyEventKind::Press && key.windows_record.is_some() - && self.pressed_terminal_keys.contains_key(&pressed_key_id) + && (self.pressed_terminal_keys.contains_key(&pressed_key_id) + || self.suppressed_repeat_keys.contains(&pressed_key_id)) { key.with_kind(crossterm::event::KeyEventKind::Repeat) } else { diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 3fdc3736c6..0a96a4a157 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 18; +pub const PROTOCOL_VERSION: u32 = 19; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 344fe6ecd0..1033628a92 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 18); + assert_eq!(value["result"]["protocol"], 19); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index b7655542ec..8cb0f4b799 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -326,7 +326,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 18"), + full_stdout.contains(" protocol: 19"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -359,7 +359,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 18"), + server_stdout.contains("protocol: 19"), "stdout: {server_stdout}" ); @@ -371,7 +371,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 18"), + client_stdout.contains("protocol: 19"), "stdout: {client_stdout}" ); assert!( @@ -381,7 +381,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 18); + assert_eq!(full_json["client"]["protocol"], 19); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -395,12 +395,12 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 18); + assert_eq!(server_json["protocol"], 19); assert_eq!(server_json["compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 18); + assert_eq!(client_json["protocol"], 19); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index c964512991..b10e2a93ae 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -15,7 +15,7 @@ static INIT: Once = Once::new(); static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; -pub const CURRENT_PROTOCOL: u32 = 18; +pub const CURRENT_PROTOCOL: u32 = 19; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else {