From 2e5da968ea3fb7105c56f7674caf822de41ec176 Mon Sep 17 00:00:00 2001 From: akbash Date: Tue, 14 Jul 2026 01:06:11 +0300 Subject: [PATCH] fix: forward outer focus events to panes (#1388) * fix: forward outer focus events to panes refs #1337 * fix: preserve ordered focus forwarding refs #1337 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Ogulcan Celik --- docs/next/CHANGELOG.md | 1 + src/app/api.rs | 23 +++- src/app/mod.rs | 120 +++++++++++++++++++- src/app/runtime.rs | 2 + src/server/headless.rs | 243 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 385 insertions(+), 4 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 7db02764..384118f6 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -7,6 +7,7 @@ - Added maki detection with idle, working, and blocked screen states. (#1301, thanks @tontinton) ### Fixed +- Outer-terminal focus gained and lost reports now reach the focused pane when its application enables focus reporting, restoring Neovim file autoreload and other focus-aware terminal behavior. (#1337) - Native Windows servers now detach from the terminal console that launched them, so closing WezTerm, Windows Terminal, or another host terminal no longer stops persistent pane processes. (#1329) - Windows API clients now remain connected while waiting for initial named-pipe request bytes, so `status server`, `api snapshot`, and other socket commands no longer intermittently fail with BrokenPipe. (#1279) - `herdr --remote` now installs remote helper binaries without routing the binary stream through a multiline `/bin/sh -c` command, fixing installs for non-POSIX login shells such as xonsh. (#1203, thanks @nhumrich) diff --git a/src/app/api.rs b/src/app/api.rs index c013d271..d6d657b7 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -765,6 +765,17 @@ impl App { } pub(crate) fn sync_focus_events(&mut self) { + self.sync_focus_events_with_outer_event(None); + } + + pub(super) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { + self.sync_focus_events_with_outer_event(Some(event)); + } + + fn sync_focus_events_with_outer_event( + &mut self, + outer_event: Option, + ) { let current_focus = self.state.active.and_then(|idx| { self.state .workspaces @@ -772,6 +783,9 @@ impl App { .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); if current_focus == self.last_focus { + if let (Some((ws_idx, pane_id)), Some(event)) = (current_focus, outer_event) { + self.send_pane_focus_event(ws_idx, pane_id, event); + } return; } @@ -779,7 +793,14 @@ impl App { self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost); } if let Some((ws_idx, pane_id)) = current_focus { - self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained); + let event = outer_event.unwrap_or_else(|| { + if self.state.outer_terminal_focus == Some(false) { + crate::ghostty::FocusEvent::Lost + } else { + crate::ghostty::FocusEvent::Gained + } + }); + self.send_pane_focus_event(ws_idx, pane_id, event); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceFocused, data: crate::api::schema::EventData::WorkspaceFocused { diff --git a/src/app/mod.rs b/src/app/mod.rs index 16cfeafb..6a7470be 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1617,8 +1617,12 @@ impl App { } } } - crate::raw_input::RawInputEvent::OuterFocusGained - | crate::raw_input::RawInputEvent::OuterFocusLost => {} + crate::raw_input::RawInputEvent::OuterFocusGained => { + self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); + } + crate::raw_input::RawInputEvent::OuterFocusLost => { + self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); + } crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { if apply_host_terminal_theme { self.update_host_terminal_theme(kind, color); @@ -3262,6 +3266,118 @@ mod tests { assert!(!app.full_redraw_pending); } + #[tokio::test] + async fn monolithic_outer_focus_events_reach_reporting_pane() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("focus-reporting"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + workspace.insert_test_runtime(pane_id, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + assert!( + app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) + .await + ); + assert_eq!( + input_rx + .recv() + .await + .expect("forwarded focus gained report"), + bytes::Bytes::from_static(b"\x1b[I") + ); + + assert!( + !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) + .await + ); + assert_eq!( + input_rx.recv().await.expect("forwarded focus lost report"), + bytes::Bytes::from_static(b"\x1b[O") + ); + } + + #[tokio::test] + async fn outer_focus_events_reconcile_pending_pane_focus() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("focus-transition"); + let previous_pane = workspace.tabs[0].root_pane; + let next_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(previous_pane); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + workspace.insert_test_runtime(next_pane, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.last_focus = Some((0, previous_pane)); + + assert!(app.state.focus_pane_in_workspace(0, next_pane)); + assert!( + !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost) + .await + ); + app.sync_focus_events(); + assert_eq!( + input_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[O") + ); + assert!(input_rx.try_recv().is_err()); + + assert!(app.state.focus_pane_in_workspace(0, previous_pane)); + app.sync_focus_events(); + assert_eq!( + input_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[O") + ); + + assert!(app.state.focus_pane_in_workspace(0, next_pane)); + app.sync_focus_events(); + assert_eq!( + input_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[O") + ); + + assert!( + app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained) + .await + ); + assert_eq!( + input_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[I") + ); + + assert!(app.state.focus_pane_in_workspace(0, previous_pane)); + app.sync_focus_events(); + assert_eq!( + input_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[O") + ); + assert!(app.state.focus_pane_in_workspace(0, next_pane)); + app.sync_focus_events(); + assert_eq!( + input_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[I") + ); + } + #[tokio::test] async fn repeat_key_events_are_ignored_outside_terminal_mode() { let mut app = test_app(); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 25275cd7..6d63f139 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -180,6 +180,7 @@ impl App { true } crate::raw_input::RawInputEvent::OuterFocusGained => { + self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained); if self.state.redraw_on_focus_gained { self.request_full_redraw(); } @@ -188,6 +189,7 @@ impl App { true } crate::raw_input::RawInputEvent::OuterFocusLost => { + self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost); self.state.outer_terminal_focus = Some(false); false } diff --git a/src/server/headless.rs b/src/server/headless.rs index c965d8fb..bc64f0a8 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2414,6 +2414,11 @@ impl HeadlessServer { client_id: u64, events: Vec, ) -> bool { + let source_was_foreground = self.foreground_client_id == Some(client_id); + let source_is_full_app = self + .clients + .get(&client_id) + .is_some_and(ClientConnection::is_full_app_client); let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( &events, self.app.state.redraw_on_focus_gained, @@ -2430,7 +2435,10 @@ impl HeadlessServer { client.request_semantic_redraw_after_input(); } } - self.update_client_outer_focus_from_events(client_id, &events); + if source_is_full_app { + self.update_client_outer_focus_from_events(client_id, &events); + } + let events = events_for_app_routing(events, source_was_foreground, source_is_full_app); let interaction = events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) @@ -3819,6 +3827,36 @@ impl HeadlessServer { } } +fn events_for_app_routing( + events: Vec, + mut source_is_foreground: bool, + source_is_full_app: bool, +) -> Vec { + events + .into_iter() + .filter_map(|event| match event { + crate::raw_input::RawInputEvent::OuterFocusGained + | crate::raw_input::RawInputEvent::OuterFocusLost + if !source_is_full_app => + { + None + } + crate::raw_input::RawInputEvent::OuterFocusGained => { + source_is_foreground = true; + Some(event) + } + crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None, + crate::raw_input::RawInputEvent::Key(_) + | crate::raw_input::RawInputEvent::Mouse(_) + | crate::raw_input::RawInputEvent::Paste(_) => { + source_is_foreground = true; + Some(event) + } + _ => Some(event), + }) + .collect() +} + impl Drop for HeadlessServer { fn drop(&mut self) { let staged_files = self @@ -6473,6 +6511,209 @@ next_tab = "" assert_eq!(server.app.state.outer_terminal_focus, Some(true)); } + #[tokio::test] + async fn foreground_focus_gained_reaches_pane_with_focus_reporting() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); + + server.clients.insert(1, test_app_client(Some(false), 1)); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + assert!(server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[I".to_vec(), + })); + assert_eq!( + input_rx.try_recv().expect("forwarded focus gained report"), + Bytes::from_static(b"\x1b[I") + ); + + assert!(!server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[O".to_vec(), + })); + assert_eq!( + input_rx.try_recv().expect("forwarded focus lost report"), + Bytes::from_static(b"\x1b[O") + ); + } + + #[tokio::test] + async fn outer_focus_events_do_not_reach_pane_without_focus_reporting() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b""); + server.clients.insert(1, test_app_client(Some(false), 1)); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + assert!(server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[I".to_vec(), + })); + assert!(matches!( + input_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn background_focus_batch_only_forwards_events_after_promotion() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); + server.clients.insert(1, test_app_client(Some(true), 1)); + server.clients.insert(2, test_app_client(Some(false), 2)); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + assert!(server.handle_server_event(ServerEvent::ClientInput { + client_id: 2, + data: b"\x1b[O\x1b[I".to_vec(), + })); + assert_eq!(server.foreground_client_id, Some(2)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + assert_eq!( + input_rx + .try_recv() + .expect("focus gained after client promotion"), + Bytes::from_static(b"\x1b[I") + ); + assert!(matches!( + input_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn structured_outer_focus_events_reach_reporting_pane() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); + server.clients.insert(1, test_app_client(Some(true), 1)); + 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::FocusGained, + crate::protocol::ClientInputEvent::FocusLost, + ], + })); + assert_eq!( + input_rx.try_recv().expect("structured focus gained report"), + Bytes::from_static(b"\x1b[I") + ); + assert_eq!( + input_rx.try_recv().expect("structured focus lost report"), + Bytes::from_static(b"\x1b[O") + ); + } + + #[tokio::test] + async fn background_key_makes_later_focus_lost_eligible() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); + 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: 2, + events: vec![ + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Release, + }, + crate::protocol::ClientInputEvent::FocusLost, + ], + })); + assert_eq!(server.foreground_client_id, Some(2)); + assert_eq!( + input_rx.try_recv().expect("focus lost after promotion"), + Bytes::from_static(b"\x1b[O") + ); + } + + #[tokio::test] + async fn structured_non_app_focus_is_ignored_without_suppressing_keys() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h"); + server.clients.insert(1, test_app_client(Some(true), 1)); + + let mut attached = test_app_client(Some(false), 2); + attached.mode = ClientConnectionMode::TerminalAttach { + terminal_id: "attached".to_owned(), + }; + server.clients.insert(2, attached); + + let mut pending = test_app_client(Some(false), 3); + pending.pending_terminal_attach = true; + server.clients.insert(3, pending); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + for client_id in [2, 3] { + assert!(!server.handle_server_event(ServerEvent::ClientInputEvents { + client_id, + events: vec![crate::protocol::ClientInputEvent::FocusGained], + })); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + assert_eq!(server.clients[&client_id].outer_terminal_focus, Some(false)); + } + + assert!(matches!( + input_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + assert!(server.handle_server_event(ServerEvent::ClientInputEvents { + client_id: 3, + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Release, + }], + })); + assert_eq!(server.foreground_client_id, Some(3)); + } + + fn install_focused_test_runtime( + server: &mut HeadlessServer, + terminal_bytes: &[u8], + ) -> tokio::sync::mpsc::Receiver { + let mut workspace = crate::workspace::Workspace::test_new("focus-reporting"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + terminal_bytes, + 4, + ); + workspace.insert_test_runtime(pane_id, runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + input_rx + } + + fn test_app_client(outer_terminal_focus: Option, last_activity: u64) -> ClientConnection { + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus, + last_activity, + RenderEncoding::SemanticFrame, + None, + ) + } + #[test] fn foreground_client_focus_event_updates_app_focus_state() { let mut server = test_headless_server();