diff --git a/src/client/mod.rs b/src/client/mod.rs index 9ce1440e4..1e77e7375 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -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; diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 9c2ddf0e0..3a692a847 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -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, 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, 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, + 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 { + 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() {