返回 CodeWhale
mod.rs
根目录 / crates / tui / src / notify / mod.rs
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
299 lines RUST