diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-28 01:05:33 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-28 01:05:33 +0300 |
| commit | b6308ef3bec7ebd46ac830b3d36910171e94edf2 (patch) | |
| tree | 634866d39a3c98f0973d0f08c3da5018d051e337 /src/app | |
| parent | 5a61ae2f9d89320c2e91d58fc3646b5a6a88f44a (diff) | |
feat: notify active panes when terminal is unfocused
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 47 | ||||
| -rw-r--r-- | src/app/api.rs | 17 | ||||
| -rw-r--r-- | src/app/mod.rs | 5 | ||||
| -rw-r--r-- | src/app/runtime.rs | 8 | ||||
| -rw-r--r-- | src/app/state.rs | 4 |
5 files changed, 70 insertions, 11 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 210b6ccf..83e9bec6 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -15,8 +15,15 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS && matches!(prev_state, AgentState::Working | AgentState::Blocked) } -pub fn notification_sound_for_state_change( +pub fn active_tab_suppresses_notifications( is_active_tab: bool, + outer_terminal_focus: Option<bool>, +) -> bool { + is_active_tab && outer_terminal_focus != Some(false) +} + +pub fn notification_sound_for_state_change( + suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, ) -> Option<crate::sound::Sound> { @@ -27,7 +34,8 @@ pub fn notification_sound_for_state_change( match new_state { AgentState::Blocked => Some(crate::sound::Sound::Request), AgentState::Idle - if is_background_completion_transition(prev_state, new_state) && !is_active_tab => + if is_background_completion_transition(prev_state, new_state) + && !suppress_active_tab_notifications => { Some(crate::sound::Sound::Done) } @@ -36,11 +44,11 @@ pub fn notification_sound_for_state_change( } pub fn notification_toast_for_state_change( - is_active_tab: bool, + suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, ) -> Option<ToastKind> { - if is_active_tab || new_state == prev_state { + if suppress_active_tab_notifications || new_state == prev_state { return None; } @@ -573,6 +581,8 @@ impl AppState { change: &EffectiveStateChange, ) { let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); + let suppress_active_tab_notifications = + active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); let Some(pane) = self.workspaces[ws_idx] .tabs .iter_mut() @@ -582,14 +592,14 @@ impl AppState { }; if is_background_completion_transition(change.previous_state, change.state) - && !is_active_tab + && !suppress_active_tab_notifications { pane.seen = false; } if self.local_sound_playback && self.sound.allows(change.known_agent) { if let Some(sound) = notification_sound_for_state_change( - is_active_tab, + suppress_active_tab_notifications, change.previous_state, change.state, ) { @@ -1066,6 +1076,31 @@ mod tests { } #[test] + fn active_workspace_active_tab_keeps_herdr_toast_suppressed_when_outer_terminal_is_unfocused() { + let mut state = app_with_workspaces(&["active"]); + state.active = Some(0); + state.outer_terminal_focus = Some(false); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Blocked, + }); + + assert!(state.toast.is_none()); + } + + #[test] + fn active_tab_suppression_preserves_unknown_focus_behavior() { + assert!(active_tab_suppresses_notifications(true, None)); + assert!(active_tab_suppresses_notifications(true, Some(true))); + assert!(!active_tab_suppresses_notifications(true, Some(false))); + assert!(!active_tab_suppresses_notifications(false, None)); + } + + #[test] fn update_ready_sets_manual_update_toast() { let mut state = AppState::test_new(); state.toast_config.delivery = crate::config::ToastDelivery::Herdr; diff --git a/src/app/api.rs b/src/app/api.rs index d7893d6d..5cfa3409 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -70,10 +70,12 @@ impl App { self.restore_overlay_after_exit(overlay); } - if matches!( - self.state.toast_config.delivery, - crate::config::ToastDelivery::Terminal - ) { + if self.local_terminal_notifications + && matches!( + self.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) + { if let Some(version) = update_ready_version { let _ = crate::terminal_notify::show_notification( &format!("v{version} available"), @@ -84,8 +86,13 @@ impl App { let is_active_tab = self .state .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + crate::app::actions::active_tab_suppresses_notifications( + is_active_tab, + self.state.outer_terminal_focus, + ); let Some(kind) = crate::app::actions::notification_toast_for_state_change( - is_active_tab, + suppress_active_tab_notifications, update.previous_state, update.state, ) else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 26090ebf..3d588403 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -77,6 +77,7 @@ pub struct App { pub render_notify: Arc<Notify>, pub render_dirty: Arc<AtomicBool>, pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, + pub(crate) local_terminal_notifications: bool, } pub(crate) enum LoopEvent { @@ -315,6 +316,7 @@ impl App { update_dismissed: false, config_diagnostic, toast: None, + outer_terminal_focus: None, prefix_code, prefix_mods, default_sidebar_width: config.ui.sidebar_width, @@ -395,6 +397,7 @@ impl App { render_notify, render_dirty, overlay_panes: HashMap::new(), + local_terminal_notifications: true, } } @@ -761,6 +764,8 @@ impl App { } } } + crate::raw_input::RawInputEvent::OuterFocusGained + | crate::raw_input::RawInputEvent::OuterFocusLost => {} crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { if apply_host_terminal_theme { self.update_host_terminal_theme(kind, color); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 739a3670..094b26f1 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -89,6 +89,14 @@ impl App { self.handle_mouse(mouse); true } + crate::raw_input::RawInputEvent::OuterFocusGained => { + self.state.outer_terminal_focus = Some(true); + false + } + crate::raw_input::RawInputEvent::OuterFocusLost => { + self.state.outer_terminal_focus = Some(false); + false + } crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { self.update_host_terminal_theme(kind, color) } diff --git a/src/app/state.rs b/src/app/state.rs index 971b1476..facb5187 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -631,6 +631,9 @@ pub struct AppState { pub update_dismissed: bool, pub config_diagnostic: Option<String>, pub toast: Option<ToastNotification>, + /// Last reported focus state for the outer terminal hosting herdr. + /// None means unsupported or not yet reported, which preserves active-pane suppression. + pub outer_terminal_focus: Option<bool>, // Config pub prefix_code: KeyCode, pub prefix_mods: KeyModifiers, @@ -765,6 +768,7 @@ impl AppState { update_dismissed: false, config_diagnostic: None, toast: None, + outer_terminal_focus: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, default_sidebar_width: 26, |
