diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 209 |
1 files changed, 196 insertions, 13 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index cffffe6a..5f9aad2c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -117,12 +117,15 @@ fn toast_event_text(kind: app::state::ToastKind) -> &'static str { fn toast_message_from_state_change( state: &AppState, pane_id: PaneId, - is_active_tab: bool, + suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, ) -> Option<String> { - let kind = - app::actions::notification_toast_for_state_change(is_active_tab, prev_state, new_state)?; + let kind = app::actions::notification_toast_for_state_change( + suppress_active_tab_notifications, + prev_state, + new_state, + )?; state .workspaces @@ -211,6 +214,8 @@ struct ClientConnection { terminal_size: (u16, u16), /// Last known host terminal default colors for this client. host_terminal_theme: crate::terminal_theme::TerminalTheme, + /// Last reported focus state for this client's outer terminal. + outer_terminal_focus: Option<bool>, /// Monotonic activity stamp used to choose the fallback foreground client. last_activity: u64, /// Last frame sent to this client. Used to skip identical frame sends. @@ -615,20 +620,35 @@ impl HeadlessServer { fn sync_foreground_client_state(&mut self) { let Some(client_id) = self.foreground_client_id else { self.effective_size = (MIN_COLS, MIN_ROWS); + self.app.state.outer_terminal_focus = None; return; }; let Some(client) = self.clients.get(&client_id) else { self.foreground_client_id = None; self.effective_size = (MIN_COLS, MIN_ROWS); + self.app.state.outer_terminal_focus = None; return; }; self.effective_size = client.terminal_size; + self.app.state.outer_terminal_focus = client.outer_terminal_focus; if !client.host_terminal_theme.is_empty() { self.app.set_host_terminal_theme(client.host_terminal_theme); } } + fn foreground_client_outer_focus(&self) -> Option<bool> { + let client_id = self.foreground_client_id?; + self.clients.get(&client_id)?.outer_terminal_focus + } + + fn active_tab_suppresses_notifications(&self, is_active_tab: bool) -> bool { + crate::app::actions::active_tab_suppresses_notifications( + is_active_tab, + self.foreground_client_outer_focus(), + ) + } + fn promote_client_to_foreground(&mut self, client_id: u64) -> bool { let stamp = self.allocate_activity_stamp(); let Some(client) = self.clients.get_mut(&client_id) else { @@ -692,6 +712,32 @@ impl HeadlessServer { } } + fn update_client_outer_focus_from_events( + &mut self, + client_id: u64, + events: &[crate::raw_input::RawInputEvent], + ) { + let next_focus = events + .iter() + .filter_map(|event| match event { + crate::raw_input::RawInputEvent::OuterFocusGained => Some(true), + crate::raw_input::RawInputEvent::OuterFocusLost => Some(false), + _ => None, + }) + .last(); + + let Some(next_focus) = next_focus else { + return; + }; + let Some(client) = self.clients.get_mut(&client_id) else { + return; + }; + client.outer_terminal_focus = Some(next_focus); + if self.foreground_client_id == Some(client_id) { + self.app.state.outer_terminal_focus = Some(next_focus); + } + } + fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool { events.iter().any(|event| { matches!( @@ -699,6 +745,7 @@ impl HeadlessServer { crate::raw_input::RawInputEvent::Key(_) | crate::raw_input::RawInputEvent::Mouse(_) | crate::raw_input::RawInputEvent::Paste(_) + | crate::raw_input::RawInputEvent::OuterFocusGained ) }) } @@ -804,6 +851,7 @@ impl HeadlessServer { // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the // sound policy so reloads can keep server-side notification policy live. + self.sync_foreground_client_state(); self.app.handle_internal_event(ev); // Forward sound notification to clients when server-side sound policy allows it. @@ -817,9 +865,12 @@ impl HeadlessServer { .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - is_active_tab, + suppress_active_tab_notifications, prev_state, state_val, ) { @@ -846,7 +897,7 @@ impl HeadlessServer { toast_message_from_state_change( &self.app.state, pane_id_val, - is_active_tab, + suppress_active_tab_notifications, prev_state, state_val, ) @@ -897,6 +948,7 @@ impl HeadlessServer { }) .unwrap_or(crate::detect::AgentState::Unknown); + self.sync_foreground_client_state(); self.app.handle_internal_event(ev); // Forward sound notification based on hook state transition when @@ -913,9 +965,12 @@ impl HeadlessServer { .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx) }); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - is_active_tab, + suppress_active_tab_notifications, prev_hook_state, hook_state_val, ) { @@ -942,7 +997,7 @@ impl HeadlessServer { toast_message_from_state_change( &self.app.state, pane_id_val, - is_active_tab, + suppress_active_tab_notifications, prev_hook_state, hook_state_val, ) @@ -1123,6 +1178,7 @@ impl HeadlessServer { ClientConnection { terminal_size: (cols, rows), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity, last_frame: None, writer: Some(writer), @@ -1143,6 +1199,7 @@ impl HeadlessServer { client.last_frame = None; } let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + self.update_client_outer_focus_from_events(client_id, &events); let interaction = Self::events_include_interaction(&events); let foreground_changed = if interaction { self.promote_client_to_foreground(client_id) @@ -1301,6 +1358,7 @@ impl HeadlessServer { }) .collect(); + self.sync_foreground_client_state(); let response = self.app.handle_api_request(msg.request); let _ = msg.respond_to.send(response); @@ -1370,6 +1428,8 @@ impl HeadlessServer { } let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); // Get the known agent for sound settings. Unknown custom labels // fall back to None so clients use the generic sound behavior. @@ -1390,7 +1450,7 @@ impl HeadlessServer { && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if let Some(kind) = crate::app::actions::notification_toast_for_state_change( - is_active_tab, + suppress_active_tab_notifications, prev_state, new_state, ) { @@ -1422,7 +1482,7 @@ impl HeadlessServer { // Clients still decide locally whether they can execute the side effect. if self.app.state.sound.allows(agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - is_active_tab, + suppress_active_tab_notifications, prev_state, new_state, ) { @@ -1965,10 +2025,11 @@ pub fn run_server() -> io::Result<()> { event_hub, ); - // The server runs headless — disable local sound playback. - // Sound notifications are forwarded to connected clients as - // ServerMessage::Notify instead of played locally. + // The server runs headless — disable local notification side effects. + // Sound and terminal notifications are forwarded to connected clients + // as ServerMessage::Notify instead of emitted by the server process. app.state.local_sound_playback = false; + app.local_terminal_notifications = false; // Create the headless server. let mut server = match HeadlessServer::new(app) { @@ -2013,7 +2074,10 @@ mod tests { fn test_headless_server() -> HeadlessServer { let config = crate::config::Config::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let app = crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default()); + let mut app = + crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default()); + app.state.local_sound_playback = false; + app.local_terminal_notifications = false; let dir = std::env::temp_dir().join(format!( "hh-{}-{}", @@ -2255,6 +2319,7 @@ mod tests { b: 0x33, }), }, + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: None, @@ -2276,6 +2341,7 @@ mod tests { b: 0xff, }), }, + outer_terminal_focus: None, last_activity: 2, last_frame: None, writer: None, @@ -2308,6 +2374,114 @@ mod tests { } #[test] + fn focus_lost_updates_client_without_promoting_foreground() { + let mut server = test_headless_server(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (120, 40), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, + last_activity: 1, + last_frame: None, + writer: None, + }, + ); + server.clients.insert( + 2, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: Some(true), + last_activity: 2, + last_frame: None, + writer: None, + }, + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + let changed = server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[O".to_vec(), + }); + + assert!(!changed); + assert_eq!(server.foreground_client_id, Some(2)); + assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + } + + #[test] + fn focus_gained_promotes_client_to_foreground() { + let mut server = test_headless_server(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (120, 40), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, + last_activity: 1, + last_frame: None, + writer: None, + }, + ); + server.clients.insert( + 2, + ClientConnection { + terminal_size: (80, 24), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: Some(true), + last_activity: 2, + last_frame: None, + writer: None, + }, + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + + let changed = server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[I".to_vec(), + }); + + assert!(changed); + assert_eq!(server.foreground_client_id, Some(1)); + assert_eq!(server.clients[&1].outer_terminal_focus, Some(true)); + assert_eq!(server.app.state.outer_terminal_focus, Some(true)); + } + + #[test] + fn foreground_client_focus_event_updates_app_focus_state() { + let mut server = test_headless_server(); + + server.clients.insert( + 1, + ClientConnection { + terminal_size: (120, 40), + host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: Some(true), + last_activity: 1, + last_frame: None, + writer: None, + }, + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + let changed = server.handle_server_event(ServerEvent::ClientInput { + client_id: 1, + data: b"\x1b[O".to_vec(), + }); + + assert!(!changed); + assert_eq!(server.clients[&1].outer_terminal_focus, Some(false)); + assert_eq!(server.app.state.outer_terminal_focus, Some(false)); + } + + #[test] fn render_and_stream_uses_each_client_terminal_size() { let mut server = test_headless_server(); server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")]; @@ -2323,6 +2497,7 @@ mod tests { ClientConnection { terminal_size: (120, 40), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: Some(desktop_tx), @@ -2333,6 +2508,7 @@ mod tests { ClientConnection { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 2, last_frame: None, writer: Some(phone_tx), @@ -2366,6 +2542,7 @@ mod tests { ClientConnection { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: Some(client_tx), @@ -2396,6 +2573,7 @@ mod tests { ClientConnection { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: Some(client_tx), @@ -2427,6 +2605,7 @@ mod tests { ClientConnection { terminal_size: (120, 40), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: Some(background_tx), @@ -2437,6 +2616,7 @@ mod tests { ClientConnection { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 2, last_frame: None, writer: Some(foreground_tx), @@ -2477,6 +2657,7 @@ mod tests { ClientConnection { terminal_size: (120, 40), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: Some(background_tx), @@ -2487,6 +2668,7 @@ mod tests { ClientConnection { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 2, last_frame: None, writer: Some(foreground_tx), @@ -2529,6 +2711,7 @@ mod tests { ClientConnection { terminal_size: (80, 24), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), + outer_terminal_focus: None, last_activity: 1, last_frame: None, writer: Some(client_tx), |
