Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3e790a8
test(input): add keyboard pipeline compatibility corpus
ogulcancelik Aug 9, 2026
5fe634b
fix(ghostty): retain key event text through encoding
ogulcancelik Aug 9, 2026
ca56ad2
test(input): characterize pane encoder parity
ogulcancelik Aug 9, 2026
b30b3c2
feat(input): preserve kitty key metadata
ogulcancelik Aug 9, 2026
e10a261
refactor(input): type raw decode outcomes
ogulcancelik Aug 9, 2026
18a8fb0
fix(input): preserve modifyotherkeys mode in handoff
ogulcancelik Aug 9, 2026
926d641
fix(input): close ghostty text adapter gaps
ogulcancelik Aug 9, 2026
491935a
feat(input): preserve proxied kitty metadata
ogulcancelik Aug 9, 2026
5ebce8a
refactor(input): type pane key encoding outcomes
ogulcancelik Aug 9, 2026
47ad1c6
fix(input): close pane encoder adapter gaps
ogulcancelik Aug 9, 2026
b9fd158
refactor(input): make ghostty the pane key encoder
ogulcancelik Aug 9, 2026
1a1289e
refactor(input): remove the duplicate key encoder
ogulcancelik Aug 9, 2026
aefb265
docs: note robust pane key forwarding
ogulcancelik Aug 9, 2026
44c0a49
fix(input): close final proxy key compatibility gaps
ogulcancelik Aug 9, 2026
faefa14
refactor(input): simplify pane encoder surface
ogulcancelik Aug 9, 2026
6a15ddd
fix(input): address cross-platform encoding regressions
ogulcancelik Aug 9, 2026
fe1443a
refactor(input): harden terminal proxy key encoding
ogulcancelik Aug 9, 2026
0f363a1
test(input): account for windows conpty key fallback
ogulcancelik Aug 9, 2026
0690d0a
fix(input): encode no-text proxy alt events consistently
ogulcancelik Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.

### Fixed
- Fish `Ctrl+Alt` keybindings now work in panes after legacy Alt-prefixed control bytes are decoded with both modifiers. (#2514)
- Pane key forwarding now uses one state-aware encoder, fixing Fish `Ctrl+Alt` bindings and preserving Kitty alternate and associated text, extended modifiers, repeats and releases, F13–F25, and `modifyOtherKeys` state through handoff. (#2514)
- `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452)
- macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet)
- Prefix keybindings now preserve Shift in WezTerm Kitty keyboard mode, so commands such as config reload no longer trigger their unshifted action. (#2435)
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2746,7 +2746,7 @@ mod tests {

assert_eq!(
rx.try_recv().expect("forwarded press"),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.try_recv().expect("forwarded release after pane move"),
Expand Down
3 changes: 3 additions & 0 deletions src/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3577,6 +3577,7 @@ mod tests {
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
mouse_alternate_scroll: true,
modify_other_keys: false,
modify_other_keys_mode: None,
color_scheme_reporting: false,
};

Expand Down Expand Up @@ -4254,6 +4255,7 @@ mod tests {
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
mouse_alternate_scroll: true,
modify_other_keys: false,
modify_other_keys_mode: None,
color_scheme_reporting: false,
};

Expand All @@ -4271,6 +4273,7 @@ mod tests {
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
mouse_alternate_scroll: true,
modify_other_keys: false,
modify_other_keys_mode: None,
color_scheme_reporting: false,
};

Expand Down
25 changes: 14 additions & 11 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3622,7 +3622,7 @@ mod tests {

assert_eq!(
rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.recv().await.unwrap(),
Expand Down Expand Up @@ -5305,7 +5305,10 @@ last_pane = "prefix+tab"

app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec());
assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j"));
assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j"));
assert_eq!(
rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106;1:2u")
);
assert_eq!(
rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106;1:3u")
Expand Down Expand Up @@ -5392,7 +5395,7 @@ last_pane = "prefix+tab"

assert_eq!(
rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.recv().await.unwrap(),
Expand Down Expand Up @@ -5490,7 +5493,7 @@ last_pane = "prefix+tab"

assert_eq!(
rx.try_recv().expect("physical press"),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.try_recv().expect("committed text"),
Expand Down Expand Up @@ -5530,7 +5533,7 @@ last_pane = "prefix+tab"

assert_eq!(
rx.try_recv().expect("forwarded press"),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.try_recv().expect("synthetic release on focus loss"),
Expand Down Expand Up @@ -5565,7 +5568,7 @@ last_pane = "prefix+tab"

assert_eq!(
rx.try_recv().expect("forwarded press"),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.try_recv().expect("synthetic release on disconnect"),
Expand Down Expand Up @@ -5647,7 +5650,7 @@ last_pane = "prefix+tab"
);

for expected in [
b"\x1b[97;1:1u".as_slice(),
b"\x1b[97u".as_slice(),
b"\x1b[97;1:2u".as_slice(),
b"\x1b[97;1:2u".as_slice(),
b"\x1b[97;1:3u".as_slice(),
Expand Down Expand Up @@ -5710,7 +5713,7 @@ last_pane = "prefix+tab"

assert_eq!(
rx.try_recv().expect("grouped press"),
bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u")
bytes::Bytes::from_static(b"\x1b[97u\x1b[97;1:2u\x1b[97;1:2u")
);
assert_eq!(
rx.try_recv()
Expand Down Expand Up @@ -5748,7 +5751,7 @@ last_pane = "prefix+tab"

assert_eq!(
pressed_rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
pressed_rx.recv().await.unwrap(),
Expand Down Expand Up @@ -5823,7 +5826,7 @@ last_pane = "prefix+tab"
for rx in [&mut first_rx, &mut second_rx] {
assert_eq!(
rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106;1:1u")
bytes::Bytes::from_static(b"\x1b[106u")
);
assert_eq!(
rx.recv().await.unwrap(),
Expand All @@ -5850,7 +5853,7 @@ last_pane = "prefix+tab"

assert_eq!(
rx.recv().await.unwrap(),
bytes::Bytes::from_static(b"\x1b[106:74;2:1u")
bytes::Bytes::from_static(b"\x1b[106:74;2u")
);
assert_eq!(
rx.recv().await.unwrap(),
Expand Down
95 changes: 81 additions & 14 deletions src/client/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -521,6 +521,9 @@ 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,
text_commit: key.is_text_commit(),
}
} else if let Some(record) = key.windows_record() {
crate::protocol::ClientKeySource::WindowsConsole { record }
Expand Down Expand Up @@ -748,6 +751,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()));
Expand Down Expand Up @@ -776,18 +798,23 @@ mod windows_tests {
#[test]
fn windows_crossterm_printable_press_keeps_key_semantics_and_text() {
let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty()));
let event = windows_crossterm_input_event(event).expect("printable key converts");

assert_eq!(
windows_crossterm_input_event(event),
Some(crate::protocol::ClientInputEvent::Key {
event,
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('你'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: Some("你".to_string()),
source: crate::protocol::ClientKeySource::Synthesized,
})
}
);
let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else {
panic!("expected key event");
};
assert!(key.is_text_commit());
}

#[test]
Expand Down Expand Up @@ -825,9 +852,8 @@ mod windows_tests {
assert_eq!(windows_key_raw_bytes(&ctrl_shift_bracket, false), None);
}

#[cfg(windows)]
#[test]
fn windows_ctrl_d_semantic_event_encodes_to_eot() {
#[tokio::test]
async fn windows_ctrl_d_reaches_the_pane_as_eot() {
let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_eq!(windows_key_raw_bytes(&event, false), None);

Expand All @@ -839,10 +865,12 @@ mod windows_tests {
};
assert_eq!(key.code, KeyCode::Char('d'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
assert_eq!(
crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
b"\x04"
);

let (runtime, _rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
80, 24, 0, b"", 1,
);
assert_eq!(runtime.encode_terminal_key(key), b"\x04");
}

#[test]
Expand Down Expand Up @@ -883,7 +911,12 @@ 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,
text_commit: false,
},
}
);
}
Expand All @@ -907,12 +940,41 @@ 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,
text_commit: false,
},
}
);
}

#[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();
Expand All @@ -930,7 +992,12 @@ 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,
text_commit: false,
},
}
);
}
Expand Down
28 changes: 24 additions & 4 deletions src/client/input/windows_vti.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1467,7 +1467,12 @@ 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,
text_commit: false,
},
}]
);
}
Expand All @@ -1487,7 +1492,12 @@ 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,
text_commit: false,
},
}]
);
}
Expand Down Expand Up @@ -2201,7 +2211,12 @@ 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,
text_commit: false,
},
}]
);
}
Expand All @@ -2220,7 +2235,12 @@ 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,
text_commit: false,
},
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
Expand Down
14 changes: 14 additions & 0 deletions src/ghostty/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ pub const GHOSTTY_MODS_ALT: u32 = 4;
pub const GHOSTTY_MODS_SUPER: u32 = 8;
pub const GHOSTTY_MODS_CAPS_LOCK: u32 = 16;
pub const GHOSTTY_MODS_NUM_LOCK: u32 = 32;
pub const GHOSTTY_MODS_HYPER: u32 = 1024;
pub const GHOSTTY_MODS_META: u32 = 2048;
pub const GHOSTTY_MODS_SHIFT_SIDE: u32 = 64;
pub const GHOSTTY_MODS_CTRL_SIDE: u32 = 128;
pub const GHOSTTY_MODS_ALT_SIDE: u32 = 256;
Expand Down Expand Up @@ -3891,6 +3893,18 @@ unsafe extern "C" {
#[doc = " Set the unshifted Unicode codepoint.\n\n @param event The key event handle, must not be NULL\n @param codepoint The unshifted Unicode codepoint to set\n\n @ingroup key"]
pub fn ghostty_key_event_set_unshifted_codepoint(event: GhosttyKeyEvent, codepoint: u32);
}
unsafe extern "C" {
pub fn ghostty_key_event_set_shifted_codepoint(event: GhosttyKeyEvent, codepoint: u32);
}
unsafe extern "C" {
pub fn ghostty_key_event_get_shifted_codepoint(event: GhosttyKeyEvent) -> u32;
}
unsafe extern "C" {
pub fn ghostty_key_event_set_base_layout_codepoint(event: GhosttyKeyEvent, codepoint: u32);
}
unsafe extern "C" {
pub fn ghostty_key_event_get_base_layout_codepoint(event: GhosttyKeyEvent) -> u32;
}
unsafe extern "C" {
#[doc = " Get the unshifted Unicode codepoint.\n\n @param event The key event handle, must not be NULL\n @return The unshifted Unicode codepoint\n\n @ingroup key"]
pub fn ghostty_key_event_get_unshifted_codepoint(event: GhosttyKeyEvent) -> u32;
Expand Down
Loading
Loading