From e7fc85bfdb51f89488430adbfe5bbced3be79c2f Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 23 Jul 2026 03:47:34 +0300 Subject: [PATCH] fix: preserve kitty printable key releases refs #1746 --- docs/next/api/herdr-api.schema.json | 2 +- src/app/api/panes.rs | 74 ++++ src/app/input/mod.rs | 13 +- src/app/input/terminal.rs | 175 ++++++++-- src/app/mod.rs | 503 +++++++++++++++++++++++++++- src/app/runtime.rs | 46 ++- src/client/input.rs | 48 ++- src/client/input/windows_vti.rs | 31 +- src/client/mod.rs | 13 + src/input/encode.rs | 71 +++- src/input/model.rs | 21 ++ src/input/parse.rs | 1 + src/protocol/wire.rs | 29 +- src/raw_input.rs | 4 +- src/server/clients.rs | 3 + src/server/headless.rs | 133 +++++++- src/terminal_modes.rs | 31 ++ tests/api_ping.rs | 2 +- tests/cli/sessions.rs | 12 +- tests/support/mod.rs | 2 +- 20 files changed, 1124 insertions(+), 90 deletions(-) diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index dab07fb2..93cf653a 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": 17, + "protocol": 18, "schema_version": 1, "schemas": { "error_response": { diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 9947a3b8..2da6d308 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2571,6 +2571,80 @@ mod tests { assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); } + #[tokio::test] + async fn key_release_follows_pane_moved_across_workspaces() { + let mut app = app_with_linked_worktree(); + let source = app.state.workspaces[0].tabs[0].root_pane; + let source_terminal_id = app.state.workspaces[0].tabs[0] + .terminal_id(source) + .unwrap() + .clone(); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>15u", + 2, + ); + app.terminal_runtimes.insert(source_terminal_id, runtime); + app.state.workspaces.push(Workspace::test_new("other")); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + let source_public = app.public_pane_id(0, source).unwrap(); + let target = app.state.workspaces[1].tabs[0].root_pane; + let target_tab_id = app.public_tab_id(1, 0).unwrap(); + let target_pane_id = app.public_pane_id(1, target).unwrap(); + + app.route_client_events_from( + 42, + vec![crate::raw_input::RawInputEvent::Key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::empty(), + ), + )], + false, + ); + let response = app.handle_pane_move( + "req".into(), + PaneMoveParams { + pane_id: source_public, + destination: PaneMoveDestination::Tab { + tab_id: target_tab_id, + target_pane_id: Some(target_pane_id), + split: SplitDirection::Down, + ratio: None, + }, + focus: false, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!(success.result, ResponseResult::PaneMove { .. })); + app.route_client_events_from( + 42, + vec![crate::raw_input::RawInputEvent::Key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::empty(), + ) + .with_kind(crossterm::event::KeyEventKind::Release), + )], + false, + ); + + assert_eq!( + rx.try_recv().expect("forwarded press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("forwarded release after pane move"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 5eee4250..738b7963 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -71,21 +71,23 @@ use super::App; // --------------------------------------------------------------------------- impl App { - pub(super) async fn handle_key(&mut self, key: TerminalKey) { + pub(super) async fn handle_key( + &mut self, + key: TerminalKey, + ) -> Option { if self.state.popup_pane.is_some() { - self.handle_terminal_key(key).await; - return; + return self.handle_terminal_key(key).await; } let key_event = key.as_key_event(); if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) { if let Some(text) = crate::platform::read_clipboard_text() { self.paste_into_active_text_input(&text); } - return; + return None; } match self.state.mode { - Mode::Terminal => self.handle_terminal_key(key).await, + Mode::Terminal => return self.handle_terminal_key(key).await, Mode::Prefix => self.handle_prefix_key(key), Mode::Navigate => self.handle_navigate_key(key), Mode::Copy => self.handle_copy_mode_key(key), @@ -114,6 +116,7 @@ impl App { Mode::Terminal => unreachable!(), }, } + None } pub(super) async fn handle_paste(&mut self, text: String) { diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 51a3899c..61c6bbb3 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -3,20 +3,24 @@ use crossterm::event::KeyCode; use tracing::{debug, warn}; use crate::{ - app::{App, Mode}, + app::{App, Mode, TerminalInputTarget}, input::TerminalKey, }; struct PreparedPaneInput { ws_idx: usize, pane_id: crate::layout::PaneId, + target: TerminalInputTarget, bytes: Bytes, } enum PreparedPopupInput { NotOpen, Consumed, - Bytes(Bytes), + Bytes { + target: TerminalInputTarget, + bytes: Bytes, + }, } fn is_modifier_only_key(code: &KeyCode) -> bool { @@ -24,26 +28,27 @@ fn is_modifier_only_key(code: &KeyCode) -> bool { } impl App { - pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) { + pub(crate) fn handle_terminal_key_headless( + &mut self, + key: TerminalKey, + ) -> Option { match self.prepare_popup_key_forward(key) { PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return, - PreparedPopupInput::Bytes(bytes) => { + PreparedPopupInput::Consumed => return None, + PreparedPopupInput::Bytes { target, bytes } => { let Some(runtime) = self.popup_runtime() else { self.close_popup_pane(); - return; + return None; }; - let _ = runtime.try_send_bytes(bytes); - return; + return runtime.try_send_bytes(bytes).is_ok().then_some(target); } } - let Some(input) = self.prepare_terminal_key_forward(key) else { - return; - }; - if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { - let _ = runtime.try_send_bytes(input.bytes); - } + let input = self.prepare_terminal_key_forward(key)?; + let sent = self + .lookup_runtime_sender(input.ws_idx, input.pane_id) + .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok()); + sent.then_some(input.target) } fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option { @@ -116,6 +121,7 @@ impl App { let ws_idx = self.state.active?; let ws = self.state.workspaces.get(ws_idx)?; let pane_id = ws.focused_pane_id()?; + let terminal_id = ws.terminal_id(pane_id)?.clone(); let rt = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?; @@ -207,6 +213,7 @@ impl App { Some(PreparedPaneInput { ws_idx, pane_id, + target: TerminalInputTarget { terminal_id }, bytes: Bytes::from(bytes), }) } @@ -215,7 +222,15 @@ impl App { if self.state.popup_pane.is_none() { return PreparedPopupInput::NotOpen; } - let Some(rt) = self.popup_runtime() else { + let Some(terminal_id) = self + .state + .popup_pane + .as_ref() + .map(|popup| popup.terminal_id.clone()) + else { + return PreparedPopupInput::NotOpen; + }; + let Some(rt) = self.terminal_runtimes.get(&terminal_id) else { self.close_popup_pane(); return PreparedPopupInput::Consumed; }; @@ -225,30 +240,136 @@ impl App { if bytes.is_empty() { PreparedPopupInput::Consumed } else { - PreparedPopupInput::Bytes(Bytes::from(bytes)) + PreparedPopupInput::Bytes { + target: TerminalInputTarget { terminal_id }, + bytes: Bytes::from(bytes), + } } } - pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) { + pub(crate) fn host_keyboard_report_all_requested(&self) -> bool { + let runtime = if self.state.popup_pane.is_some() { + self.popup_runtime() + } else if self.state.mode == Mode::Terminal { + self.state.active.and_then(|ws_idx| { + self.state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + }) + } else { + None + }; + + runtime.is_some_and(|runtime| runtime.keyboard_protocol().reports_all_keys()) + } + + fn terminal_input_runtime( + &self, + target: &TerminalInputTarget, + ) -> Option<&crate::terminal::TerminalRuntime> { + if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) { + return Some(runtime); + } + #[cfg(test)] + for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() { + for tab in &workspace.tabs { + for (&pane_id, pane) in &tab.panes { + if pane.attached_terminal_id == target.terminal_id { + return self.state.runtime_for_pane_in_workspace( + &self.terminal_runtimes, + ws_idx, + pane_id, + ); + } + } + } + } + None + } + + pub(crate) fn forward_terminal_key_to_target_headless( + &self, + target: &TerminalInputTarget, + key: TerminalKey, + ) -> bool { + let Some(runtime) = self.terminal_input_runtime(target) else { + return false; + }; + let bytes = runtime.encode_terminal_key(key); + bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok() + } + + pub(crate) async fn forward_terminal_key_to_target( + &self, + target: &TerminalInputTarget, + key: TerminalKey, + ) -> bool { + let Some(runtime) = self.terminal_input_runtime(target) else { + return false; + }; + let bytes = runtime.encode_terminal_key(key); + bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok() + } + + fn take_pressed_keys_for_source( + &mut self, + source_id: crate::app::InputSourceId, + ) -> Vec { + let pressed = self + .pressed_terminal_keys + .iter() + .filter(|((id, _), _)| *id == source_id) + .map(|(_, pressed)| pressed.clone()) + .collect(); + self.pressed_terminal_keys + .retain(|(id, _), _| *id != source_id); + self.suppressed_repeat_keys + .retain(|(id, _)| *id != source_id); + pressed + } + + pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { + for pressed in self.take_pressed_keys_for_source(source_id) { + let release = pressed + .key + .with_kind(crossterm::event::KeyEventKind::Release); + let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); + } + } + + pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) { + for pressed in self.take_pressed_keys_for_source(source_id) { + let release = pressed + .key + .with_kind(crossterm::event::KeyEventKind::Release); + let _ = self + .forward_terminal_key_to_target(&pressed.target, release) + .await; + } + } + + pub(super) async fn handle_terminal_key( + &mut self, + key: TerminalKey, + ) -> Option { match self.prepare_popup_key_forward(key) { PreparedPopupInput::NotOpen => {} - PreparedPopupInput::Consumed => return, - PreparedPopupInput::Bytes(bytes) => { + PreparedPopupInput::Consumed => return None, + PreparedPopupInput::Bytes { target, bytes } => { let Some(runtime) = self.popup_runtime() else { self.close_popup_pane(); - return; + return None; }; - let _ = runtime.send_bytes(bytes).await; - return; + return runtime.send_bytes(bytes).await.is_ok().then_some(target); } } - let Some(input) = self.prepare_terminal_key_forward(key) else { - return; + let input = self.prepare_terminal_key_forward(key)?; + let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { + runtime.send_bytes(input.bytes).await.is_ok() + } else { + false }; - if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) { - let _ = runtime.send_bytes(input.bytes).await; - } + sent.then_some(input.target) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 8a9e7389..b56c128c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -135,8 +135,9 @@ pub struct App { pub(crate) detached_custom_command_children: Vec, pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option, - pub(crate) suppressed_repeat_keys: - HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub(crate) pressed_terminal_keys: + HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>, + pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>, pub render_notify: Arc, pub render_dirty: Arc, pub(crate) full_redraw_pending: bool, @@ -197,10 +198,25 @@ async fn sleep_until_or_pending(deadline: Option) { } } -fn repeat_key_identity( +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TerminalInputTarget { + terminal_id: crate::terminal::TerminalId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PressedTerminalKey { + target: TerminalInputTarget, + key: crate::input::TerminalKey, +} + +pub(crate) type InputSourceId = u64; +const LOCAL_INPUT_SOURCE: InputSourceId = 0; + +fn pressed_key_identity( + source_id: InputSourceId, key: &crate::input::TerminalKey, -) -> (crossterm::event::KeyCode, crossterm::event::KeyModifiers) { - (key.code, key.modifiers) +) -> (InputSourceId, crossterm::event::KeyCode) { + (source_id, key.code) } fn auto_updates_enabled(no_session: bool) -> bool { @@ -746,6 +762,7 @@ impl App { selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, + pressed_terminal_keys: HashMap::new(), suppressed_repeat_keys: HashSet::new(), api_rx, event_hub, @@ -895,6 +912,7 @@ impl App { let mut needs_render = true; let mut host_mouse_capture_active = self.state.mouse_capture; + let mut host_keyboard_report_all_active = false; while !self.state.should_quit { self.reap_finished_custom_commands(); @@ -1021,6 +1039,7 @@ impl App { let now = Instant::now(); self.sync_animation_timer(now); self.sync_host_mouse_capture(&mut host_mouse_capture_active)?; + self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?; if needs_render && self.can_render_now(now) { self.render_dirty.swap(false, Ordering::AcqRel); @@ -1137,6 +1156,16 @@ impl App { Ok(()) } + fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> { + let desired = self.host_keyboard_report_all_requested(); + if desired == *active { + return Ok(()); + } + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?; + *active = desired; + Ok(()) + } + fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> { let desired = self .state @@ -1570,35 +1599,66 @@ impl App { &mut self, events: Vec, apply_host_terminal_theme: bool, + ) { + self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme); + } + + pub(crate) fn route_client_events_from( + &mut self, + source_id: InputSourceId, + events: Vec, + apply_host_terminal_theme: bool, ) { for event in events { let previous_mode = self.state.mode; match event { crate::raw_input::RawInputEvent::Key(key) => { - let key_id = repeat_key_identity(&key); + let pressed_key_id = pressed_key_identity(source_id, &key); match key.kind { crossterm::event::KeyEventKind::Press => { if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal { - self.suppressed_repeat_keys.remove(&key_id); - self.handle_terminal_key_headless(key); + self.suppressed_repeat_keys.remove(&pressed_key_id); + if let Some(target) = self.handle_terminal_key_headless(key) { + if !key.is_text_commit { + self.pressed_terminal_keys.insert( + pressed_key_id, + PressedTerminalKey { target, key }, + ); + } + } else { + self.pressed_terminal_keys.remove(&pressed_key_id); + } } else { - self.suppressed_repeat_keys.insert(key_id); + self.pressed_terminal_keys.remove(&pressed_key_id); + self.suppressed_repeat_keys.insert(pressed_key_id); self.handle_non_terminal_key_headless(key); } } crossterm::event::KeyEventKind::Repeat => { - if (self.state.popup_pane.is_some() - || self.state.mode == Mode::Terminal) - && !self.suppressed_repeat_keys.contains(&key_id) + if let Some(pressed) = + self.pressed_terminal_keys.get(&pressed_key_id).cloned() { - self.handle_terminal_key_headless(key); + if !self + .forward_terminal_key_to_target_headless(&pressed.target, key) + { + self.pressed_terminal_keys.remove(&pressed_key_id); + } + } else if (self.state.popup_pane.is_some() + || self.state.mode == Mode::Terminal) + && !self.suppressed_repeat_keys.contains(&pressed_key_id) + { + let _ = self.handle_terminal_key_headless(key); } - // Repeats in non-terminal modes are ignored - // (same as monolithic behavior). } crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&key_id); + self.suppressed_repeat_keys.remove(&pressed_key_id); + if let Some(pressed) = + self.pressed_terminal_keys.remove(&pressed_key_id) + { + let _ = self + .forward_terminal_key_to_target_headless(&pressed.target, key); + } } } } @@ -1634,6 +1694,7 @@ impl App { self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); } crate::raw_input::RawInputEvent::OuterFocusLost => { + self.release_input_source_headless(source_id); self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); } crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { @@ -1652,6 +1713,10 @@ impl App { } } + pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { + self.release_input_source_headless(source_id); + } + /// Handles a key event in non-terminal mode for the headless server. /// /// Uses the standalone handler functions that work on `&mut AppState` @@ -3298,6 +3363,43 @@ mod tests { assert!(handled); } + #[tokio::test] + async fn monolithic_input_forwards_report_all_printable_event_kinds() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + 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; + + for kind in [ + KeyEventKind::Press, + KeyEventKind::Repeat, + KeyEventKind::Release, + ] { + app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind)) + .await; + } + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + 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") + ); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn outer_focus_gained_marks_visible_done_panes_seen() { let mut app = test_app(); @@ -4908,6 +5010,375 @@ last_pane = "prefix+tab" ); } + #[tokio::test] + async fn host_report_all_follows_the_focused_terminal_protocol() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, _rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1); + 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; + + assert!(app.host_keyboard_report_all_requested()); + + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused) + .unwrap() + .test_process_pty_bytes(b"\x1b[15u"); + let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + let (other_runtime, _rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1); + app.state.workspaces[0].tabs[0] + .runtimes + .insert(other_pane, other_runtime); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane)); + assert!(!app.host_keyboard_report_all_requested()); + + assert!(app.state.focus_pane_in_workspace(0, focused)); + app.state.mode = Mode::Prefix; + assert!(!app.host_keyboard_report_all_requested()); + } + + #[tokio::test] + async fn route_client_input_forwards_report_all_printable_event_kinds() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + 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; + + app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + 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") + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn committed_ime_text_bypasses_report_all_key_encoding() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + 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; + + app.route_client_input("你".as_bytes().to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static("你".as_bytes()) + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + 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; + + app.route_client_input(b"A".to_vec()); + + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn committed_text_does_not_erase_owned_physical_key() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + 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; + + app.route_client_input(b"\x1b[106u".to_vec()); + app.route_client_input(b"j".to_vec()); + app.route_client_input(b"\x1b[106;1:3u".to_vec()); + + assert_eq!( + rx.try_recv().expect("physical press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("committed text"), + bytes::Bytes::from_static(b"j") + ); + assert_eq!( + rx.try_recv().expect("physical release"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn outer_focus_loss_releases_owned_report_all_keys() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + 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; + + app.route_client_events( + vec![ + raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + ), + crate::raw_input::RawInputEvent::OuterFocusLost, + ], + false, + ); + + assert_eq!( + rx.try_recv().expect("forwarded press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("synthetic release on focus loss"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn disconnected_input_source_releases_owned_report_all_keys() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + 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; + + app.route_client_events_from( + 42, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + app.clear_input_source(42); + + assert_eq!( + rx.try_recv().expect("forwarded press"), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.try_recv().expect("synthetic release on disconnect"), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(app.pressed_terminal_keys.is_empty()); + } + + #[tokio::test] + async fn report_all_repeat_and_release_return_to_the_pressed_pane() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + let (other_runtime, mut other_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4); + 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; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[106u".to_vec()); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); + app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec()); + + assert_eq!( + pressed_rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + pressed_rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:2u") + ); + assert_eq!( + pressed_rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(pressed_rx.try_recv().is_err()); + assert!(other_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn report_all_key_ownership_is_isolated_by_client() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let first_pane = workspace.focused_pane_id().unwrap(); + let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(first_pane); + let (first_runtime, mut first_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + let (second_runtime, mut second_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + workspace.tabs[0].runtimes.insert(first_pane, first_runtime); + workspace.tabs[0] + .runtimes + .insert(second_pane, second_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_events_from( + 1, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, second_pane)); + app.route_client_events_from( + 2, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + app.route_client_events_from( + 1, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Release, + )], + false, + ); + app.route_client_events_from( + 2, + vec![raw_key( + KeyCode::Char('j'), + KeyModifiers::empty(), + KeyEventKind::Release, + )], + false, + ); + + for rx in [&mut first_rx, &mut second_rx] { + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + } + } + + #[tokio::test] + async fn report_all_release_survives_modifier_release_order() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3); + 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; + + app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106:74;2:1u") + ); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn route_client_input_does_not_forward_release_for_consumed_prefix() { + 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_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + 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; + + app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec()); + + assert_eq!(app.state.mode, Mode::Prefix); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() { let mut app = test_app(); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 97edc471..269de37d 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -3,7 +3,7 @@ use std::time::{Duration, Instant}; use crossterm::terminal; use super::{ - background_update_check_enabled, repeat_key_identity, App, Mode, ANIMATION_INTERVAL, + background_update_check_enabled, pressed_key_identity, App, ANIMATION_INTERVAL, AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; @@ -139,20 +139,42 @@ impl App { let previous_mode = self.state.mode; let changed = match event { crate::raw_input::RawInputEvent::Key(key) => { - let key_id = repeat_key_identity(&key); + let pressed_key_id = pressed_key_identity(super::LOCAL_INPUT_SOURCE, &key); match key.kind { crossterm::event::KeyEventKind::Press => { - if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal { - self.suppressed_repeat_keys.remove(&key_id); + if self.state.popup_pane.is_some() + || self.state.mode == crate::app::Mode::Terminal + { + self.suppressed_repeat_keys.remove(&pressed_key_id); } else { - self.suppressed_repeat_keys.insert(key_id); + self.suppressed_repeat_keys.insert(pressed_key_id); + } + if let Some(target) = self.handle_key(key).await { + if !key.is_text_commit { + self.pressed_terminal_keys.insert( + pressed_key_id, + super::PressedTerminalKey { target, key }, + ); + } + } else { + self.pressed_terminal_keys.remove(&pressed_key_id); } - self.handle_key(key).await; true } crossterm::event::KeyEventKind::Repeat => { - if (self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal) - && !self.suppressed_repeat_keys.contains(&key_id) + if let Some(pressed) = + self.pressed_terminal_keys.get(&pressed_key_id).cloned() + { + if !self + .forward_terminal_key_to_target(&pressed.target, key) + .await + { + self.pressed_terminal_keys.remove(&pressed_key_id); + } + true + } else if (self.state.popup_pane.is_some() + || self.state.mode == crate::app::Mode::Terminal) + && !self.suppressed_repeat_keys.contains(&pressed_key_id) { self.handle_key(key).await; true @@ -161,7 +183,12 @@ impl App { } } crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&key_id); + self.suppressed_repeat_keys.remove(&pressed_key_id); + if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) { + let _ = self + .forward_terminal_key_to_target(&pressed.target, key) + .await; + } false } } @@ -189,6 +216,7 @@ impl App { true } crate::raw_input::RawInputEvent::OuterFocusLost => { + self.release_input_source(super::LOCAL_INPUT_SOURCE).await; self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); self.state.outer_terminal_focus = Some(false); false diff --git a/src/client/input.rs b/src/client/input.rs index 1d557543..c37a15c7 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -210,7 +210,7 @@ fn windows_crossterm_reader_loop( tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); } - let Some(event) = crate::protocol::ClientInputEvent::from_crossterm(event) else { + let Some(event) = windows_crossterm_input_event(event) else { continue; }; if event_tx @@ -226,6 +226,21 @@ fn windows_crossterm_reader_loop( } } +#[cfg(any(windows, test))] +fn windows_crossterm_input_event( + event: crossterm::event::Event, +) -> Option { + let event = crate::protocol::ClientInputEvent::from_crossterm(event)?; + match event { + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char(codepoint), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + } => Some(crate::protocol::ClientInputEvent::Text { codepoint }), + event => Some(event), + } +} + #[cfg(windows)] fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool { use crossterm::event::{Event, KeyModifiers}; @@ -308,11 +323,22 @@ fn windows_client_input_event_from_raw( event: crate::raw_input::RawInputEvent, ) -> Option { match event { - crate::raw_input::RawInputEvent::Key(key) => Some(crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::from_crossterm(key.code)?, - modifiers: key.modifiers.bits(), - kind: crate::protocol::ClientKeyKind::from_crossterm(key.kind), - }), + crate::raw_input::RawInputEvent::Key(key) if key.is_text_commit => { + let crossterm::event::KeyCode::Char(codepoint) = key.code else { + return None; + }; + Some(crate::protocol::ClientInputEvent::Text { codepoint }) + } + crate::raw_input::RawInputEvent::Key(key) => { + let code = crate::protocol::ClientKeyCode::from_crossterm(key.code)?; + let modifiers = key.modifiers.bits(); + let kind = crate::protocol::ClientKeyKind::from_crossterm(key.kind); + Some(crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + }) + } crate::raw_input::RawInputEvent::Mouse(mouse) => { Some(crate::protocol::ClientInputEvent::Mouse { kind: crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, @@ -473,6 +499,16 @@ mod windows_tests { ); } + #[test] + fn windows_crossterm_printable_press_is_text() { + let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); + + assert_eq!( + windows_crossterm_input_event(event), + Some(crate::protocol::ClientInputEvent::Text { codepoint: '你' }) + ); + } + #[test] fn windows_ctrl_bracket_starts_raw_escape_sequence() { let ctrl_bracket = Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::CONTROL)); diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 5742b825..8966112e 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -622,6 +622,12 @@ impl WindowsInputMapper { kind: crate::protocol::ClientKeyKind, ) -> Option { let modifiers = windows_key_modifiers(key.control_key_state); + if key.virtual_key_code == 0 { + let codepoint = self.utf16_unit_to_char(key.unicode)?; + if !codepoint.is_control() { + return Some(crate::protocol::ClientInputEvent::Text { codepoint }); + } + } if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) && key.unicode == 0x000a && (key.virtual_key_code == 0x4a || key.virtual_scan_code == 0x24) @@ -1219,6 +1225,25 @@ mod tests { ); } + #[test] + fn vti_win32_input_mode_marks_ime_commit_as_text() { + for control_key_state in [0, 0x0010, 0x0008] { + let records = win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0, + virtual_scan_code: 0, + unicode: '你' as u16, + control_key_state, + }); + + assert_eq!( + translate(records), + vec![crate::protocol::ClientInputEvent::Text { codepoint: '你' }] + ); + } + } + #[test] fn vti_win32_input_mode_decoded_paste_flag_clears_after_raw_completion() { let mut records = win32_input_mode_encoded_key_bytes(b"\x1b[200~"); @@ -1694,11 +1719,7 @@ mod tests { assert_eq!( translate(records), vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - }, + crate::protocol::ClientInputEvent::Text { codepoint: 'a' }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Char('a'), modifiers: 0, diff --git a/src/client/mod.rs b/src/client/mod.rs index 011de937..a22fd227 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -69,6 +69,8 @@ struct ClientState { blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, + /// Whether the host terminal currently reports all keys as Kitty sequences. + keyboard_report_all_active: bool, /// The terminal size we reported to the server in our last Hello/Resize. reported_size: (u16, u16), /// Client-local sound playback config, refreshed on server request. @@ -1290,6 +1292,7 @@ async fn run_client_loop( let mut state = ClientState { blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active: config.mouse_capture_active, + keyboard_report_all_active: false, reported_size: (cols, rows), sound_config: config.sound_config, kitty_graphics_enabled: config.kitty_graphics_enabled, @@ -1585,6 +1588,16 @@ async fn run_client_loop( host_mouse_capture_active.store(desired, Ordering::Release); } } + ServerMessage::KittyKeyboardReportAll { enabled } => { + if enabled != state.keyboard_report_all_active { + crate::terminal_modes::set_host_kitty_keyboard_report_all( + &mut io::stdout(), + enabled, + ) + .map_err(ClientError::ConnectionFailed)?; + state.keyboard_report_all_active = enabled; + } + } ServerMessage::PrefixInputSource { active } => { if active { prefix_input_source.switch_to_ascii(); diff --git a/src/input/encode.rs b/src/input/encode.rs index b10f8003..0ea011ed 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -1,10 +1,10 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; +use super::model::KITTY_FLAG_REPORT_ALL_KEYS; use super::{KeyboardProtocol, MouseProtocolEncoding, TerminalKey}; const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010; const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100; -const KITTY_FLAG_REPORT_ALL_KEYS: u16 = 0b0000_1000; /// Encode a key event for a PTY child using the pane's negotiated keyboard protocol. #[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers @@ -13,6 +13,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec { } pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec { + if key.is_text_commit { + return encode_text_input(&key).unwrap_or_default(); + } + // A release event only produces bytes when the pane protocol reports event // types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single // legacy byte per keystroke, so re-emitting it on release would double keys @@ -22,13 +26,26 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< return Vec::new(); } + let kitty_first = protocol.reports_all_keys() + || (key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types()); + + if kitty_first { + if let KeyboardProtocol::Kitty { flags } = protocol { + if let Some(bytes) = try_encode_csi_u(&key, flags) { + return bytes; + } + } + } + if let Some(bytes) = encode_text_input(&key) { return bytes; } - if let KeyboardProtocol::Kitty { flags } = protocol { - if let Some(bytes) = try_encode_csi_u(&key, flags) { - return bytes; + if !kitty_first { + if let KeyboardProtocol::Kitty { flags } = protocol { + if let Some(bytes) = try_encode_csi_u(&key, flags) { + return bytes; + } } } if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() { @@ -837,6 +854,43 @@ mod tests { ); } + #[test] + fn kitty_report_all_keys_encodes_printable_event_kinds() { + for (kind, expected) in [ + ( + crossterm::event::KeyEventKind::Press, + b"\x1b[106;1:1u".as_slice(), + ), + ( + crossterm::event::KeyEventKind::Repeat, + b"\x1b[106;1:2u".as_slice(), + ), + ( + crossterm::event::KeyEventKind::Release, + b"\x1b[106;1:3u".as_slice(), + ), + ] { + let key = KeyEvent::new_with_kind(KeyCode::Char('j'), KeyModifiers::empty(), kind); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 15 }), + expected + ); + } + } + + #[test] + fn kitty_printable_release_is_encoded_without_report_all() { + let release = KeyEvent::new_with_kind( + KeyCode::Char('j'), + KeyModifiers::empty(), + crossterm::event::KeyEventKind::Release, + ); + assert_eq!( + encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), + b"\x1b[106;1:3u" + ); + } + #[test] fn kitty_shift_tab() { let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); @@ -869,13 +923,16 @@ mod tests { } #[test] - fn kitty_shift_letter_release_does_not_emit_text() { + fn kitty_shift_letter_release_uses_csi_u() { let key = KeyEvent::new_with_kind( KeyCode::Char('L'), KeyModifiers::SHIFT, crossterm::event::KeyEventKind::Release, ); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b""); + assert_eq!( + encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), + b"\x1b[108:76;2:3u" + ); } #[test] @@ -893,7 +950,7 @@ mod tests { .with_kind(crossterm::event::KeyEventKind::Release); assert_eq!( encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"" + b"\x1b[63;2:3u" ); } diff --git a/src/input/model.rs b/src/input/model.rs index a3e74056..f6072bc6 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -9,6 +9,7 @@ pub struct TerminalKey { pub modifiers: KeyModifiers, pub kind: crossterm::event::KeyEventKind, pub shifted_codepoint: Option, + pub is_text_commit: bool, } impl TerminalKey { @@ -18,6 +19,7 @@ impl TerminalKey { modifiers, kind: crossterm::event::KeyEventKind::Press, shifted_codepoint: None, + is_text_commit: false, } } @@ -32,6 +34,19 @@ impl TerminalKey { 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() => { + self.modifiers == KeyModifiers::SHIFT || self.modifiers.is_empty() + } + KeyCode::Char(_) => self.modifiers.is_empty(), + _ => false, + }; + self.is_text_commit = + has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press; + self + } + pub fn as_key_event(self) -> KeyEvent { KeyEvent::new_with_kind(self.code, self.modifiers, self.kind) } @@ -43,6 +58,8 @@ impl From for TerminalKey { } } +pub(crate) const KITTY_FLAG_REPORT_ALL_KEYS: u16 = 0b0000_1000; + #[cfg(not(windows))] pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags { KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES @@ -117,6 +134,10 @@ impl KeyboardProtocol { pub(crate) fn reports_event_types(self) -> bool { matches!(self, Self::Kitty { flags } if flags & 0b0000_0010 != 0) } + + pub(crate) fn reports_all_keys(self) -> bool { + matches!(self, Self::Kitty { flags } if flags & KITTY_FLAG_REPORT_ALL_KEYS != 0) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/input/parse.rs b/src/input/parse.rs index 0071596b..36de52e8 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -45,6 +45,7 @@ fn parse_kitty_key_sequence(data: &str) -> Option { modifiers: key_modifiers_from_u8(modifier), kind, shifted_codepoint, + is_text_commit: false, }) } diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 9cc2924b..d724a5ae 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 = 17; +pub const PROTOCOL_VERSION: u32 = 18; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -116,6 +116,9 @@ pub enum ClientInputEvent { modifiers: u8, kind: ClientKeyKind, }, + Text { + codepoint: char, + }, Mouse { kind: ClientMouseKind, column: u16, @@ -251,7 +254,7 @@ impl ClientMouseKind { } impl ClientInputEvent { - #[cfg(windows)] + #[cfg(any(windows, test))] pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option { match event { crossterm::event::Event::Key(key) => Some(Self::Key { @@ -285,6 +288,13 @@ impl ClientInputEvent { ) .with_kind(kind.to_crossterm()), ), + Self::Text { codepoint } => crate::raw_input::RawInputEvent::Key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char(*codepoint), + crossterm::event::KeyModifiers::empty(), + ) + .as_text_commit(), + ), Self::Mouse { kind, column, @@ -657,6 +667,12 @@ pub enum ServerMessage { enabled: bool, }, + /// Whether the focused terminal requests Kitty report-all keyboard input. + KittyKeyboardReportAll { + /// True only while the focused pane requests `REPORT_ALL_KEYS_AS_ESCAPE_CODES`. + enabled: bool, + }, + /// Apply the prefix-mode ASCII input-source change on the foreground client. /// `active = true` → switch to an ASCII-capable source (saving the current one); /// `active = false` → restore the saved source. @@ -1412,6 +1428,15 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn server_kitty_keyboard_report_all_roundtrip() { + let msg = ServerMessage::KittyKeyboardReportAll { enabled: true }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + #[test] fn server_prefix_input_source_roundtrip() { for active in [true, false] { diff --git a/src/raw_input.rs b/src/raw_input.rs index 191688c3..575b3ff6 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -68,7 +68,7 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec Option<(RawInputEvent, usize)> { let consumed = first_complete_utf8_char_len(buffer)?; let text = std::str::from_utf8(&buffer[..consumed]).ok()?; - let key = parse_terminal_key_sequence(text)?; + let key = parse_terminal_key_sequence(text)?.as_text_commit(); Some((RawInputEvent::Key(key), consumed)) } diff --git a/src/server/clients.rs b/src/server/clients.rs index 243a131d..b1a56f0e 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -64,6 +64,8 @@ pub(crate) struct ClientConnection { pane_graphics_render_pending: bool, /// Last host mouse capture mode sent to this client. pub(crate) host_mouse_capture_active: Option, + /// Last Kitty report-all mode sent to this client's host terminal. + pub(crate) host_keyboard_report_all_active: Option, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec, /// Channels for sending framed ServerMessage data to the client writer thread. @@ -127,6 +129,7 @@ impl ClientConnection { render_pending: false, pane_graphics_render_pending: false, host_mouse_capture_active: None, + host_keyboard_report_all_active: None, staged_clipboard_files: Vec::new(), writer, } diff --git a/src/server/headless.rs b/src/server/headless.rs index a1c0e257..6817e2ad 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -613,6 +613,7 @@ impl HeadlessServer { self.drain_client_config_reload_request(); self.stream_host_mouse_capture_mode(); + self.stream_host_keyboard_enhancement_flags(); self.app.sync_headless_animation_timer(now); @@ -1397,6 +1398,7 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); + self.app.clear_input_source(client_id); self.send_client_graphics_cleanup(client_id); let removed = self.clients.remove(&client_id); if let Some(removed) = removed { @@ -2595,6 +2597,12 @@ impl HeadlessServer { } if source_is_full_app { self.update_client_outer_focus_from_events(client_id, &events); + if events + .iter() + .any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost)) + { + self.app.clear_input_source(client_id); + } } let events = events_for_app_routing(events, source_was_foreground, source_is_full_app); let interaction = events_include_interaction(&events); @@ -2607,8 +2615,11 @@ impl HeadlessServer { self.resize_shared_runtime_to_effective_size_before_input(); } let theme_changed = self.update_client_host_theme_from_events(client_id, &events); - self.app - .route_client_events(events, self.foreground_client_id == Some(client_id)); + self.app.route_client_events_from( + client_id, + events, + self.foreground_client_id == Some(client_id), + ); if self.app.take_config_reloaded_from_disk() { self.reload_server_config(false); } else { @@ -3352,6 +3363,44 @@ impl HeadlessServer { } } + fn stream_host_keyboard_enhancement_flags(&mut self) { + let report_all_keys = self.app.host_keyboard_report_all_requested(); + let serialized = match Self::frame_server_message(&ServerMessage::KittyKeyboardReportAll { + enabled: report_all_keys, + }) { + Ok(framed) => framed, + Err(err) => { + warn!(err = %err, "failed to serialize keyboard enhancement flags for clients"); + return; + } + }; + + let mut broken_clients = Vec::new(); + for (&client_id, client) in &mut self.clients { + if !client.is_full_app_client() + || client.host_keyboard_report_all_active == Some(report_all_keys) + { + continue; + } + let Some(writer) = &client.writer else { + continue; + }; + if writer.control.send(serialized.clone()).is_err() { + debug!( + client_id, + "client writer channel closed during keyboard enhancement update" + ); + broken_clients.push(client_id); + continue; + } + client.host_keyboard_report_all_active = Some(report_all_keys); + } + + for client_id in broken_clients { + self.remove_client_and_resize_if_needed(client_id); + } + } + fn render_retained_pty_update_and_stream(&mut self) -> bool { crate::render_prof::event("retained.attempt"); let retained_started = crate::render_prof::timer(); @@ -6819,6 +6868,43 @@ next_tab = "" )); } + #[tokio::test] + async fn background_client_focus_loss_releases_its_owned_keys() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>15u"); + server.clients.insert(1, test_app_client(Some(true), 1)); + server.clients.insert(2, test_app_client(Some(true), 2)); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + assert!(server.handle_server_event(ServerEvent::ClientInputEvents { + client_id: 1, + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('j'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + }], + })); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { + client_id: 1, + events: vec![crate::protocol::ClientInputEvent::FocusLost], + })); + assert_eq!( + input_rx.try_recv().expect("forwarded press"), + Bytes::from_static(b"\x1b[106;1:1u") + ); + assert_eq!( + input_rx + .try_recv() + .expect("synthetic release from background client"), + Bytes::from_static(b"\x1b[106;1:3u") + ); + assert!(server.app.pressed_terminal_keys.is_empty()); + } + #[tokio::test] async fn structured_outer_focus_events_reach_reporting_pane() { let mut server = test_headless_server(); @@ -7932,6 +8018,49 @@ next_tab = "" )); } + #[tokio::test] + async fn focused_report_all_pane_updates_headless_client_keyboard_flags() { + let mut server = test_headless_server(); + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + let popup_runtime = + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[>15u"); + server.app.install_test_popup_runtime(popup_runtime); + + server.stream_host_keyboard_enhancement_flags(); + + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("keyboard enhancement message") + ), + ServerMessage::KittyKeyboardReportAll { enabled: true } + )); + + assert!(server.app.close_popup_pane()); + server.stream_host_keyboard_enhancement_flags(); + assert!(matches!( + read_server_message( + client_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("IME-compatible keyboard enhancement message") + ), + ServerMessage::KittyKeyboardReportAll { enabled: false } + )); + } + #[tokio::test] async fn virtual_render_uses_popup_cursor() { let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H"); diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index e07de629..8f0276e0 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -15,10 +15,41 @@ pub(crate) fn clear_host_mouse_reporting(_writer: &mut W) -> io::Resul Ok(()) } +#[cfg(not(windows))] +pub(crate) fn set_host_kitty_keyboard_report_all( + writer: &mut W, + report_all_keys: bool, +) -> io::Result<()> { + let mut flags = crate::input::ime_compatible_keyboard_enhancement_flags(); + if report_all_keys { + flags |= crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; + } + write!(writer, "\x1b[={}u", flags.bits())?; + writer.flush() +} + +#[cfg(windows)] +pub(crate) fn set_host_kitty_keyboard_report_all( + _writer: &mut W, + _report_all_keys: bool, +) -> io::Result<()> { + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn host_keyboard_report_all_only_changes_the_current_herdr_stack_entry() { + let mut output = Vec::new(); + + set_host_kitty_keyboard_report_all(&mut output, true).unwrap(); + set_host_kitty_keyboard_report_all(&mut output, false).unwrap(); + + assert_eq!(output, b"\x1b[=15u\x1b[=7u"); + } + #[test] fn clears_all_known_host_mouse_modes() { let sequence = std::str::from_utf8(DISABLE_HOST_MOUSE_REPORTING_SEQUENCE).unwrap(); diff --git a/tests/api_ping.rs b/tests/api_ping.rs index f63b93dd..d05f3f7f 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"], 17); + assert_eq!(value["result"]["protocol"], 18); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 4a4430e1..126cb616 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: 17"), + full_stdout.contains(" protocol: 18"), "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: 17"), + server_stdout.contains("protocol: 18"), "stdout: {server_stdout}" ); @@ -371,7 +371,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 17"), + client_stdout.contains("protocol: 18"), "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"], 17); + assert_eq!(full_json["client"]["protocol"], 18); 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"], 17); + assert_eq!(server_json["protocol"], 18); 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"], 17); + assert_eq!(client_json["protocol"], 18); 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 ce723e97..c9645129 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 = 17; +pub const CURRENT_PROTOCOL: u32 = 18; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else {