| 1 | //! Notification policy shared by every host: the typed payload, the |
| 2 | //! delivery-method and category gate, attention (focus) rules, the |
| 3 | //! per-event sound policy and audio cues. |
| 4 | //! |
| 5 | //! Runtime code owns *what* may notify and *which* sound it selects. The |
| 6 | //! terminal UI owns *delivery*: `tui::notifications` is the only code that |
| 7 | //! writes OSC 9 / 99 / 777 escapes, the taskbar progress sequence or the |
| 8 | //! window title. Runtime callers that need a delivery (the `notify` tool, |
| 9 | //! headless exec) reach it through `crate::host_terminal`, never by |
| 10 | //! importing the TUI. |
| 11 | //! |
| 12 | //! Known limitation: `tui::notifications::notify_with_sinks` still combines |
| 13 | //! the policy below with transport selection, so the runtime API's native |
| 14 | //! notification preparation calls it with null sinks. Splitting transport |
| 15 | //! out of it is the remaining step before this module can own the whole |
| 16 | //! decision. |
| 17 | |
| 18 | pub mod audio; |
| 19 | pub mod payload; |
| 20 | pub mod sound_policy; |
| 21 | |
| 22 | use std::time::Duration; |
| 23 | |
| 24 | use crate::config::{NotificationCondition, NotificationMethod, NotificationsConfig}; |
| 25 | use payload::NotificationKind; |
| 26 | |
| 27 | /// Notification delivery method. |
| 28 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 29 | pub enum Method { |
| 30 | /// Automatically pick the best protocol for the current terminal. |
| 31 | /// See `tui::notifications::resolve_method` for the canonical resolution table. |
| 32 | #[default] |
| 33 | Auto, |
| 34 | /// OSC 9 escape: `\x1b]9;<msg>\x07` |
| 35 | Osc9, |
| 36 | /// Plain BEL character: `\x07` |
| 37 | Bel, |
| 38 | /// macOS Notification Center via `osascript`. |
| 39 | /// |
| 40 | /// Only reachable through [`Method::Auto`], and only on the macOS |
| 41 | /// terminals that expose no notification escape of their own (Apple |
| 42 | /// Terminal, the VS Code and JetBrains embedded terminals, plain tmux |
| 43 | /// without `LC_TERMINAL`). iTerm2, WezTerm, Ghostty, and kitty are |
| 44 | /// matched earlier in `tui::notifications::resolve_method` and never get here. |
| 45 | /// |
| 46 | /// Known limitation (#4834): `display notification` is a Standard |
| 47 | /// Additions command, so the banner is attributed to the *bundled* |
| 48 | /// host process. `/usr/bin/osascript` is unbundled, so macOS credits |
| 49 | /// `com.apple.ScriptEditor2` — which is what supplies the Script |
| 50 | /// Editor icon and owns the System Settings → Notifications entry |
| 51 | /// (alert style, previews, Do Not Disturb). `display notification` |
| 52 | /// takes no icon parameter; fixing the attribution requires shipping |
| 53 | /// a real `.app` bundle, not a change in this file. |
| 54 | MacOS, |
| 55 | /// Kitty notification protocol (OSC 99) with ST terminator. |
| 56 | /// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL. |
| 57 | Kitty, |
| 58 | /// Ghostty notification protocol (OSC 777). |
| 59 | /// Uses `ESC ] 777 ; notify ; title ; message BEL`. |
| 60 | Ghostty, |
| 61 | /// Suppress all notifications. |
| 62 | Off, |
| 63 | } |
| 64 | |
| 65 | /// Truthful result from one notification delivery attempt. |
| 66 | /// |
| 67 | /// Callers that surface a receipt (notably the model-facing `notify` tool) |
| 68 | /// use this instead of claiming a notification was sent when user policy, |
| 69 | /// focus, or the configured delivery method suppressed it. |
| 70 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 71 | pub enum DeliveryOutcome { |
| 72 | /// The notification was handed to the resolved transport. |
| 73 | Delivered(Method), |
| 74 | /// A background OS/audio worker was started; acceptance is unverified. |
| 75 | Dispatched(Method), |
| 76 | /// Banner bytes were sent, but the selected audio could not be dispatched. |
| 77 | DeliveredWithoutSound(Method), |
| 78 | /// A native dispatch was attempted, but selected audio was unavailable. |
| 79 | DispatchedWithoutSound(Method), |
| 80 | /// The bell-only transport has no authorized cue (off or rate limited). |
| 81 | SuppressedBySound, |
| 82 | /// The terminal is still in the foreground, or has only just lost focus. |
| 83 | SuppressedByAttention, |
| 84 | /// The event completed before the configured duration threshold. |
| 85 | SuppressedByThreshold, |
| 86 | /// Notification delivery is explicitly disabled. |
| 87 | SuppressedByMethod, |
| 88 | /// Quiet mode or the per-event allow-list suppressed this category. |
| 89 | SuppressedByGate, |
| 90 | /// The selected terminal protocol produced no transport bytes. |
| 91 | UnsupportedTransport, |
| 92 | /// The terminal transport could not be written. |
| 93 | DeliveryFailed, |
| 94 | } |
| 95 | |
| 96 | impl DeliveryOutcome { |
| 97 | /// Short, stable receipt text for command/tool surfaces. |
| 98 | #[must_use] |
| 99 | pub fn receipt(self) -> &'static str { |
| 100 | match self { |
| 101 | Self::Delivered(_) => "notification sent", |
| 102 | Self::Dispatched(_) => "notification dispatch attempted", |
| 103 | Self::DeliveredWithoutSound(_) => "notification sent; sound unavailable", |
| 104 | Self::DispatchedWithoutSound(_) => "notification dispatch attempted; sound unavailable", |
| 105 | Self::SuppressedBySound => "notification not sent: sound is off or rate limited", |
| 106 | Self::SuppressedByAttention => "notification not sent: attention policy blocked it", |
| 107 | Self::SuppressedByThreshold => "notification not sent: below the duration threshold", |
| 108 | Self::SuppressedByMethod => "notification not sent: notifications are off", |
| 109 | Self::SuppressedByGate => { |
| 110 | "notification not sent: quiet mode or event settings blocked it" |
| 111 | } |
| 112 | Self::UnsupportedTransport => { |
| 113 | "notification not sent: terminal transport is unsupported" |
| 114 | } |
| 115 | Self::DeliveryFailed => "notification not sent: terminal delivery failed", |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // ── Notification gate (#5041) ──────────────────────────────────────── |
| 121 | // |
| 122 | // One policy switchboard between "an event happened" and "the user's |
| 123 | // desktop is interrupted". `[notifications].quiet` silences every |
| 124 | // category; `[notifications.events]` disables individual categories. The |
| 125 | // gate is installed from config by `tui::notifications::settings` and consulted by |
| 126 | // `tui::notifications::notify_done` ahead of every delivery mechanism, so a disabled |
| 127 | // category can never leak through one specific protocol. |
| 128 | |
| 129 | /// Which notification categories may reach the user's desktop. |
| 130 | /// |
| 131 | /// The category set mirrors [`NotificationKind`] one-to-one. Default: |
| 132 | /// everything enabled, quiet off — matching the pre-#5041 behavior. |
| 133 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 134 | pub struct NotificationGate { |
| 135 | /// Suppress every category when `true` (`[notifications].quiet`). |
| 136 | pub quiet: bool, |
| 137 | pub turn_complete: bool, |
| 138 | pub subagent_terminal: bool, |
| 139 | pub approval_needed: bool, |
| 140 | pub input_needed: bool, |
| 141 | pub elevation_needed: bool, |
| 142 | pub model_notify: bool, |
| 143 | } |
| 144 | |
| 145 | impl Default for NotificationGate { |
| 146 | fn default() -> Self { |
| 147 | Self { |
| 148 | quiet: false, |
| 149 | turn_complete: true, |
| 150 | subagent_terminal: true, |
| 151 | approval_needed: true, |
| 152 | input_needed: true, |
| 153 | elevation_needed: true, |
| 154 | model_notify: true, |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | impl NotificationGate { |
| 160 | /// Project the `[notifications]` config block onto a gate. |
| 161 | #[must_use] |
| 162 | pub fn from_config(notif: &NotificationsConfig) -> Self { |
| 163 | Self { |
| 164 | quiet: notif.quiet, |
| 165 | turn_complete: notif.events.turn_complete, |
| 166 | subagent_terminal: notif.events.subagent_terminal, |
| 167 | approval_needed: notif.events.approval_needed, |
| 168 | input_needed: notif.events.input_needed, |
| 169 | elevation_needed: notif.events.elevation_needed, |
| 170 | model_notify: notif.events.model_notify, |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /// Whether an event of `kind` may be delivered under this gate. |
| 175 | #[must_use] |
| 176 | pub fn allows(self, kind: NotificationKind) -> bool { |
| 177 | if self.quiet { |
| 178 | return false; |
| 179 | } |
| 180 | match kind { |
| 181 | NotificationKind::TurnComplete => self.turn_complete, |
| 182 | NotificationKind::SubagentTerminal | NotificationKind::BackgroundTerminal => { |
| 183 | self.subagent_terminal |
| 184 | } |
| 185 | NotificationKind::ApprovalNeeded => self.approval_needed, |
| 186 | NotificationKind::InputNeeded => self.input_needed, |
| 187 | NotificationKind::ElevationNeeded => self.elevation_needed, |
| 188 | NotificationKind::ModelNotify => self.model_notify, |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | const QUIET_BIT: u8 = 1 << 0; |
| 193 | const TURN_COMPLETE_BIT: u8 = 1 << 1; |
| 194 | const SUBAGENT_TERMINAL_BIT: u8 = 1 << 2; |
| 195 | const APPROVAL_NEEDED_BIT: u8 = 1 << 3; |
| 196 | const INPUT_NEEDED_BIT: u8 = 1 << 4; |
| 197 | const ELEVATION_NEEDED_BIT: u8 = 1 << 5; |
| 198 | const MODEL_NOTIFY_BIT: u8 = 1 << 6; |
| 199 | |
| 200 | pub(crate) const fn to_bits(self) -> u8 { |
| 201 | (self.quiet as u8 * Self::QUIET_BIT) |
| 202 | | (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT) |
| 203 | | (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT) |
| 204 | | (self.approval_needed as u8 * Self::APPROVAL_NEEDED_BIT) |
| 205 | | (self.input_needed as u8 * Self::INPUT_NEEDED_BIT) |
| 206 | | (self.elevation_needed as u8 * Self::ELEVATION_NEEDED_BIT) |
| 207 | | (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT) |
| 208 | } |
| 209 | |
| 210 | pub(crate) const fn from_bits(bits: u8) -> Self { |
| 211 | Self { |
| 212 | quiet: bits & Self::QUIET_BIT != 0, |
| 213 | turn_complete: bits & Self::TURN_COMPLETE_BIT != 0, |
| 214 | subagent_terminal: bits & Self::SUBAGENT_TERMINAL_BIT != 0, |
| 215 | approval_needed: bits & Self::APPROVAL_NEEDED_BIT != 0, |
| 216 | input_needed: bits & Self::INPUT_NEEDED_BIT != 0, |
| 217 | elevation_needed: bits & Self::ELEVATION_NEEDED_BIT != 0, |
| 218 | model_notify: bits & Self::MODEL_NOTIFY_BIT != 0, |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /// Attention delivery policy installed from the resolved notification config. |
| 224 | /// |
| 225 | /// The default is background-only. A newly started TUI is treated as focused |
| 226 | /// until the terminal explicitly reports `FocusLost`, so the safe startup |
| 227 | /// behavior is silence rather than an unexpected banner or bell. |
| 228 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 229 | pub(crate) enum AttentionCondition { |
| 230 | Always = 0, |
| 231 | Unfocused = 1, |
| 232 | Never = 2, |
| 233 | } |
| 234 | |
| 235 | pub(crate) const DEFAULT_UNFOCUSED_GRACE: Duration = Duration::from_secs(2); |
| 236 | #[must_use] |
| 237 | pub(crate) fn attention_delivery_allowed_at( |
| 238 | condition: AttentionCondition, |
| 239 | focused: bool, |
| 240 | unfocused_since_ms: u64, |
| 241 | now_ms: u64, |
| 242 | ) -> bool { |
| 243 | match condition { |
| 244 | AttentionCondition::Always => true, |
| 245 | AttentionCondition::Never => false, |
| 246 | AttentionCondition::Unfocused => { |
| 247 | !focused |
| 248 | && unfocused_since_ms > 0 |
| 249 | && now_ms.saturating_sub(unfocused_since_ms) |
| 250 | >= DEFAULT_UNFOCUSED_GRACE.as_millis() as u64 |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// Native hosts provide focus observations; the same grace/condition rule |
| 256 | /// applies before either native sound or banner preparation. |
| 257 | pub(crate) fn native_attention_allowed( |
| 258 | config: &NotificationsConfig, |
| 259 | focused: bool, |
| 260 | unfocused_for: Duration, |
| 261 | ) -> bool { |
| 262 | let condition = match config.condition.unwrap_or(NotificationCondition::Unfocused) { |
| 263 | NotificationCondition::Always => AttentionCondition::Always, |
| 264 | NotificationCondition::Unfocused => AttentionCondition::Unfocused, |
| 265 | NotificationCondition::Never => AttentionCondition::Never, |
| 266 | }; |
| 267 | let elapsed = unfocused_for.as_millis().min(u128::from(u64::MAX - 1)) as u64; |
| 268 | attention_delivery_allowed_at(condition, focused, 1, elapsed + 1) |
| 269 | } |
| 270 | |
| 271 | /// Resolve the effective notification method/threshold/include-summary tuple |
| 272 | /// for a completed turn, taking the high-level |
| 273 | /// `[tui].notification_condition` override into account on top of the |
| 274 | /// lower-level `[notifications]` block. |
| 275 | /// |
| 276 | /// Returns `None` only when the high-level attention policy is `never`. |
| 277 | /// `Method::Off` remains a valid projection so the event gate can report |
| 278 | /// that both banner and sound are disabled. |
| 279 | #[must_use] |
| 280 | pub fn settings_projection(notif: &NotificationsConfig) -> Option<(Method, Duration, bool)> { |
| 281 | let method = match notif.method { |
| 282 | NotificationMethod::Auto => Method::Auto, |
| 283 | NotificationMethod::Osc9 => Method::Osc9, |
| 284 | NotificationMethod::Bel => Method::Bel, |
| 285 | NotificationMethod::Kitty => Method::Kitty, |
| 286 | NotificationMethod::Ghostty => Method::Ghostty, |
| 287 | NotificationMethod::Off => Method::Off, |
| 288 | }; |
| 289 | match notif.condition.unwrap_or(NotificationCondition::Unfocused) { |
| 290 | NotificationCondition::Always => Some((method, Duration::ZERO, notif.include_summary)), |
| 291 | NotificationCondition::Unfocused => Some(( |
| 292 | method, |
| 293 | Duration::from_secs(notif.threshold_secs), |
| 294 | notif.include_summary, |
| 295 | )), |
| 296 | NotificationCondition::Never => None, |
| 297 | } |
| 298 | } |
| 299 |