fix: restore keyboard protocol flags after a terminal reconnect (#5088)

* fix: restore keyboard protocol flags after a terminal reconnect

refs #4985

* test: cover keyboard protocol refresh with an outer kitty mode

refs #4985
This commit is contained in:
Can Celik
2026-10-11 23:19:42 +03:00
committed by GitHub
parent 75f46fe812
commit e73d21ea29
2 changed files with 235 additions and 0 deletions
+32
View File
@@ -85,6 +85,20 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) {
}
}
fn refresh_host_keyboard_protocol(
terminal_guard: &TerminalGuard,
report_all_keys: bool,
focus_reporting: bool,
) {
if let Err(err) = terminal_guard.refresh_host_keyboard_protocol(
&mut io::stdout(),
report_all_keys,
focus_reporting,
) {
warn!(err = %err, "failed to re-assert host keyboard protocol");
}
}
#[cfg(windows)]
use terminal_setup::is_ssh_session;
#[cfg(test)]
@@ -861,6 +875,11 @@ async fn run_client_loop(
state.mouse_capture_active,
host_sgr_pixels_active.load(Ordering::Acquire),
);
refresh_host_keyboard_protocol(
_terminal_guard,
state.keyboard_report_all_active,
false,
);
}
let (outcome, frame) = {
let shell = state.shell.as_mut().expect("checked shell mode");
@@ -1122,6 +1141,11 @@ async fn run_client_loop(
state.mouse_capture_active,
host_sgr_pixels_active.load(Ordering::Acquire),
);
refresh_host_keyboard_protocol(
_terminal_guard,
state.keyboard_report_all_active,
false,
);
}
let image_target = state
.shell
@@ -1237,6 +1261,14 @@ async fn run_client_loop(
host_sgr_pixels_active.load(Ordering::Acquire),
);
}
// A host that reset its modes (a reconnect or window reload)
// stops sending focus reports, so a resize is the one
// dependable trigger to restore them.
refresh_host_keyboard_protocol(
_terminal_guard,
state.keyboard_report_all_active,
true,
);
state.reported_size = (new_cols, new_rows);
state.reported_cell_size = (cell_width_px, cell_height_px);
state.pixel_geometry_exact = pixel_geometry_exact;
+203
View File
@@ -52,6 +52,7 @@ pub(super) fn setup_terminal_with_capabilities(
buffered_host_input: Vec::new(),
reset_keyboard_enhancements: false,
reset_modify_other_keys: false,
modify_other_keys_sequence: None,
reset_host_color_scheme_reports: false,
restore_claimed: Arc::new(AtomicBool::new(false)),
restored: false,
@@ -114,6 +115,7 @@ pub(super) fn setup_terminal_with_capabilities(
.flatten();
if let Some(mode) = modify_other_keys_mode {
terminal_guard.reset_modify_other_keys = true;
terminal_guard.modify_other_keys_sequence = Some(mode.set_sequence());
io::stdout().write_all(mode.set_sequence())?;
io::stdout().flush()?;
}
@@ -137,6 +139,8 @@ pub(super) struct TerminalGuard {
buffered_host_input: Vec<u8>,
reset_keyboard_enhancements: bool,
reset_modify_other_keys: bool,
/// The modifyOtherKeys sequence written at setup, re-sent on refresh.
modify_other_keys_sequence: Option<&'static [u8]>,
reset_host_color_scheme_reports: bool,
restore_claimed: Arc<AtomicBool>,
restored: bool,
@@ -749,6 +753,39 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res
writer.flush()
}
/// Host input modes to re-assert after the host may have reset them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct HostKeyboardProtocolRefresh {
/// Some when the host confirmed the kitty keyboard protocol at startup;
/// the value says whether all keys are currently reported as escapes.
pub(super) kitty_report_all_keys: Option<bool>,
pub(super) modify_other_keys_sequence: Option<&'static [u8]>,
/// Re-enable focus reporting (1004). Callers reacting to a focus-in skip
/// it: that host still reports focus, and re-enabling from a focus report
/// could loop on a host that answers the enable with another report.
pub(super) focus_reporting: bool,
}
pub(super) fn write_host_keyboard_protocol_refresh(
writer: &mut impl io::Write,
refresh: HostKeyboardProtocolRefresh,
) -> io::Result<()> {
if let Some(report_all_keys) = refresh.kitty_report_all_keys {
// Replace Herdr's stack entry rather than pushing, so repeated
// refreshes never grow the host's keyboard stack. On a host that
// reset its stack, the pop is a no-op and the push restores the flags.
crate::terminal_modes::set_host_kitty_keyboard_report_all(writer, report_all_keys)?;
}
if let Some(sequence) = refresh.modify_other_keys_sequence {
writer.write_all(sequence)?;
}
writer.write_all(b"\x1b[?2004h")?;
if refresh.focus_reporting {
writer.write_all(b"\x1b[?1004h")?;
}
writer.flush()
}
impl TerminalGuard {
pub(super) fn host_escape_disambiguation_active(&self) -> bool {
self.host_escape_disambiguation_active
@@ -763,6 +800,31 @@ impl TerminalGuard {
std::mem::take(&mut self.buffered_host_input)
}
/// Re-sends the keyboard and input modes enabled at setup. A host that
/// reset its terminal state (a reconnect or window reload) forgets them,
/// which turns Shift+Enter and Ctrl+Enter into plain Enter.
pub(super) fn refresh_host_keyboard_protocol(
&self,
writer: &mut impl io::Write,
report_all_keys: bool,
focus_reporting: bool,
) -> io::Result<()> {
// Direct attach leaves these modes to the attached pane.
if !self.reset_keyboard_enhancements || self.restore_claimed.load(Ordering::Acquire) {
return Ok(());
}
write_host_keyboard_protocol_refresh(
writer,
HostKeyboardProtocolRefresh {
kitty_report_all_keys: self
.host_escape_disambiguation_active
.then_some(report_all_keys),
modify_other_keys_sequence: self.modify_other_keys_sequence,
focus_reporting,
},
)
}
#[cfg(windows)]
pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> {
let active = enable_windows_virtual_terminal_input(
@@ -887,6 +949,147 @@ mod tests {
}
}
#[cfg(not(windows))]
fn refresh_test_guard(
kitty_confirmed: bool,
client_protocols: bool,
modify_other_keys_sequence: Option<&'static [u8]>,
) -> TerminalGuard {
TerminalGuard {
host_escape_disambiguation_active: kitty_confirmed,
host_sgr_pixel_mouse: None,
buffered_host_input: Vec::new(),
reset_keyboard_enhancements: client_protocols,
reset_modify_other_keys: modify_other_keys_sequence.is_some(),
modify_other_keys_sequence,
reset_host_color_scheme_reports: false,
restore_claimed: Arc::new(AtomicBool::new(false)),
// Keep Drop from writing restore sequences to the test's stdout.
restored: true,
}
}
#[cfg(not(windows))]
fn refresh_bytes(guard: &TerminalGuard, report_all_keys: bool, focus: bool) -> Vec<u8> {
let mut output = Vec::new();
guard
.refresh_host_keyboard_protocol(&mut output, report_all_keys, focus)
.unwrap();
output
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_protocol_refresh_resends_the_setup_modes() {
// Focus-in on a kitty host: replace Herdr's stack entry, keep paste on.
let kitty = refresh_test_guard(true, true, None);
assert_eq!(
refresh_bytes(&kitty, false, false),
b"\x1b[<1u\x1b[>7u\x1b[?2004h"
);
// Resize while a pane wants all keys, on a modifyOtherKeys host too.
let both = refresh_test_guard(true, true, Some(b"\x1b[>4;1m"));
assert_eq!(
refresh_bytes(&both, true, true),
b"\x1b[<1u\x1b[>31u\x1b[>4;1m\x1b[?2004h\x1b[?1004h"
);
// A host that never confirmed the kitty protocol gets no kitty flags.
let legacy = refresh_test_guard(false, true, None);
assert_eq!(
refresh_bytes(&legacy, false, true),
b"\x1b[?2004h\x1b[?1004h"
);
// Direct attach leaves keyboard modes to the attached pane.
let direct = refresh_test_guard(true, false, None);
assert!(refresh_bytes(&direct, false, true).is_empty());
// Nothing is re-enabled once the terminal is being restored.
kitty.restore_claimed.store(true, Ordering::Release);
assert!(refresh_bytes(&kitty, false, true).is_empty());
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread")]
async fn host_keyboard_protocol_refresh_restores_shift_enter_after_host_reset() {
use crate::input::{KeyboardProtocol, TerminalKey};
use crossterm::event::{KeyCode, KeyModifiers};
let guard = refresh_test_guard(true, true, None);
let shift_enter = TerminalKey::new(KeyCode::Enter, KeyModifiers::SHIFT);
// libghostty stands in for the host terminal. `reset` models a host
// that lost its modes, like a reloaded VS Code window (#4985).
for reset in [false, true] {
let (host, _rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
80, 24, 0, b"", 4096,
);
// Herdr's setup push.
host.test_process_pty_bytes(b"\x1b[>7u");
if reset {
host.test_process_pty_bytes(b"\x1bc");
assert_eq!(host.keyboard_protocol(), KeyboardProtocol::Legacy);
assert_eq!(host.encode_terminal_key(shift_enter.clone()), b"\r");
}
for _ in 0..3 {
host.test_process_pty_bytes(&refresh_bytes(&guard, false, true));
}
assert_eq!(
host.keyboard_protocol(),
KeyboardProtocol::Kitty { flags: 7 },
"reset={reset}"
);
assert_eq!(
host.encode_terminal_key(shift_enter.clone()),
b"\x1b[13;2u",
"reset={reset}"
);
// Refreshes never grow the stack: Herdr's one exit pop restores
// the host's own keyboard mode.
host.test_process_pty_bytes(b"\x1b[<1u");
assert_eq!(
host.keyboard_protocol(),
KeyboardProtocol::Legacy,
"reset={reset}"
);
}
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread")]
async fn host_keyboard_protocol_refresh_keeps_the_outer_mode_across_report_all_toggles() {
use crate::input::KeyboardProtocol;
let guard = refresh_test_guard(true, true, None);
let (host, _rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
80, 24, 0, b"", 4096,
);
// Something outside Herdr (a shell or wrapper) already set its own mode.
host.test_process_pty_bytes(b"\x1b[>1u");
// Herdr's setup push.
host.test_process_pty_bytes(b"\x1b[>7u");
for round in 0..6 {
let report_all = round % 2 == 0;
let mut toggle = Vec::new();
crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut toggle, report_all)
.unwrap();
host.test_process_pty_bytes(&toggle);
for focus in [false, true, false] {
host.test_process_pty_bytes(&refresh_bytes(&guard, report_all, focus));
}
let flags = if report_all { 31 } else { 7 };
assert_eq!(
host.keyboard_protocol(),
KeyboardProtocol::Kitty { flags },
"round {round}"
);
}
// Herdr's one exit pop restores exactly the outer mode.
host.test_process_pty_bytes(b"\x1b[<1u");
assert_eq!(
host.keyboard_protocol(),
KeyboardProtocol::Kitty { flags: 1 }
);
}
#[cfg(not(windows))]
#[test]
fn host_keyboard_probe_consumes_fragmented_responses_and_preserves_input() {