返回 CodeWhale
notifications.rs
根目录 / crates / tui / src / tui / notifications.rs
1 //! Desktop notifications for turn completion.
2 //!
3 //! Supports five delivery mechanisms:
4 //! - **OSC 9** — terminal escape sequence (`\x1b]9;…\x07`) for iTerm2,
5 //! Ghostty, WezTerm, and tmux (with DCS passthrough).
6 //! - **Kitty** — OSC 99 protocol with ST terminator (no audible beep).
7 //! - **Ghostty** — OSC 777 notification protocol.
8 //! - **BEL** — an explicit audio-only notification transport.
9 //!
10 //! When `method = "auto"`, the resolver picks the best method for the
11 //! current terminal. Unknown terminals fail closed to `Off`; an audible BEL
12 //! is emitted only by an explicitly selected sound or `method = "bel"`.
13 //!
14 //! Every mechanism is fed a [`NotificationPayload`] — a typed, bounded,
15 //! redaction-aware value — rather than a free-form `String` (#4834). See
16 //! [`crate::tui::notification_payload`] for the per-kind disclosure
17 //! policy.
18 //!
19 //! Delivery is governed by one [`NotificationGate`] (#5041):
20 //! `[notifications].quiet` silences everything and
21 //! `[notifications.events]` disables individual categories, enforced at
22 //! the emission path so no protocol can leak a suppressed event.
23
24 use std::io::{self, Write};
25 use std::sync::atomic::{AtomicBool, Ordering};
26 use std::sync::atomic::{AtomicU8, AtomicU64};
27 use std::sync::{Mutex, OnceLock};
28 use std::time::Duration;
29
30 use super::notification_payload::NotificationKind;
31 pub use super::notification_payload::NotificationPayload;
32
33 /// Notification delivery method.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35 pub enum Method {
36 /// Automatically pick the best protocol for the current terminal.
37 /// See [`resolve_method`] for the canonical resolution table.
38 #[default]
39 Auto,
40 /// OSC 9 escape: `\x1b]9;<msg>\x07`
41 Osc9,
42 /// Plain BEL character: `\x07`
43 Bel,
44 /// macOS Notification Center via `osascript`.
45 ///
46 /// Only reachable through [`Method::Auto`], and only on the macOS
47 /// terminals that expose no notification escape of their own (Apple
48 /// Terminal, the VS Code and JetBrains embedded terminals, plain tmux
49 /// without `LC_TERMINAL`). iTerm2, WezTerm, Ghostty, and kitty are
50 /// matched earlier in [`resolve_method`] and never get here.
51 ///
52 /// Known limitation (#4834): `display notification` is a Standard
53 /// Additions command, so the banner is attributed to the *bundled*
54 /// host process. `/usr/bin/osascript` is unbundled, so macOS credits
55 /// `com.apple.ScriptEditor2` — which is what supplies the Script
56 /// Editor icon and owns the System Settings → Notifications entry
57 /// (alert style, previews, Do Not Disturb). `display notification`
58 /// takes no icon parameter; fixing the attribution requires shipping
59 /// a real `.app` bundle, not a change in this file.
60 MacOS,
61 /// Kitty notification protocol (OSC 99) with ST terminator.
62 /// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL.
63 Kitty,
64 /// Ghostty notification protocol (OSC 777).
65 /// Uses `ESC ] 777 ; notify ; title ; message BEL`.
66 Ghostty,
67 /// Suppress all notifications.
68 Off,
69 }
70
71 /// Truthful result from one notification delivery attempt.
72 ///
73 /// Callers that surface a receipt (notably the model-facing `notify` tool)
74 /// use this instead of claiming a notification was sent when user policy,
75 /// focus, or the configured delivery method suppressed it.
76 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
77 pub enum DeliveryOutcome {
78 /// The notification was handed to the resolved transport.
79 Delivered(Method),
80 /// A background OS/audio worker was started; acceptance is unverified.
81 Dispatched(Method),
82 /// Banner bytes were sent, but the selected audio could not be dispatched.
83 DeliveredWithoutSound(Method),
84 /// A native dispatch was attempted, but selected audio was unavailable.
85 DispatchedWithoutSound(Method),
86 /// The bell-only transport has no authorized cue (off or rate limited).
87 SuppressedBySound,
88 /// The terminal is still in the foreground, or has only just lost focus.
89 SuppressedByAttention,
90 /// The event completed before the configured duration threshold.
91 SuppressedByThreshold,
92 /// Notification delivery is explicitly disabled.
93 SuppressedByMethod,
94 /// Quiet mode or the per-event allow-list suppressed this category.
95 SuppressedByGate,
96 /// The selected terminal protocol produced no transport bytes.
97 UnsupportedTransport,
98 /// The terminal transport could not be written.
99 DeliveryFailed,
100 }
101
102 impl DeliveryOutcome {
103 /// Short, stable receipt text for command/tool surfaces.
104 #[must_use]
105 pub fn receipt(self) -> &'static str {
106 match self {
107 Self::Delivered(_) => "notification sent",
108 Self::Dispatched(_) => "notification dispatch attempted",
109 Self::DeliveredWithoutSound(_) => "notification sent; sound unavailable",
110 Self::DispatchedWithoutSound(_) => "notification dispatch attempted; sound unavailable",
111 Self::SuppressedBySound => "notification not sent: sound is off or rate limited",
112 Self::SuppressedByAttention => "notification not sent: attention policy blocked it",
113 Self::SuppressedByThreshold => "notification not sent: below the duration threshold",
114 Self::SuppressedByMethod => "notification not sent: notifications are off",
115 Self::SuppressedByGate => {
116 "notification not sent: quiet mode or event settings blocked it"
117 }
118 Self::UnsupportedTransport => {
119 "notification not sent: terminal transport is unsupported"
120 }
121 Self::DeliveryFailed => "notification not sent: terminal delivery failed",
122 }
123 }
124 }
125
126 /// Process-wide configured delivery method. Installed before the event loop
127 /// starts and updated by live Settings, so producers such as the model-facing
128 /// `notify` tool cannot silently bypass `method = "off"`.
129 static CONFIGURED_METHOD: AtomicU8 = AtomicU8::new(0);
130
131 fn method_to_u8(method: Method) -> u8 {
132 match method {
133 Method::Auto => 0,
134 Method::Osc9 => 1,
135 Method::Bel => 2,
136 Method::MacOS => 3,
137 Method::Kitty => 4,
138 Method::Ghostty => 5,
139 Method::Off => 6,
140 }
141 }
142
143 /// Install `method` as the process-wide notification method for paths that
144 /// do not resolve a method of their own (the `notify` tool); `pub(crate)`
145 /// so the tool's tests can arrange the installed method.
146 pub(crate) fn install_configured_method(method: Method) {
147 CONFIGURED_METHOD.store(method_to_u8(method), Ordering::SeqCst);
148 }
149
150 /// Delivery method currently selected by Settings.
151 #[must_use]
152 pub fn configured_method() -> Method {
153 match CONFIGURED_METHOD.load(Ordering::SeqCst) {
154 1 => Method::Osc9,
155 2 => Method::Bel,
156 3 => Method::MacOS,
157 4 => Method::Kitty,
158 5 => Method::Ghostty,
159 6 => Method::Off,
160 _ => Method::Auto,
161 }
162 }
163
164 /// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`,
165 /// `$LC_TERMINAL`, and `$TERM`.
166 ///
167 /// Resolution table:
168 /// - `iTerm.app`, `WezTerm`, `Cmux` → `Osc9`
169 /// - `Ghostty` → `Ghostty` (OSC 777)
170 /// - `kitty` → `Kitty` (OSC 99)
171 /// - `$LC_TERMINAL` matches OSC-9 capable → `Osc9` (Cmux that sets LC_TERMINAL)
172 /// - `$TERM` contains `ghostty` → `Osc9` (cmux etc.)
173 /// - `$TERM` contains `kitty` → `Kitty`
174 /// - Unknown terminal → `Off` (never invent an audible fallback)
175 #[must_use]
176 fn resolve_method() -> Method {
177 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
178 match term_program.as_str() {
179 "iTerm.app" | "WezTerm" | "Cmux" => return Method::Osc9,
180 "Ghostty" => return Method::Ghostty,
181 "kitty" => return Method::Kitty,
182 _ => {}
183 }
184
185 // LC_TERMINAL fallback for terminals (e.g. Cmux) that set
186 // LC_TERMINAL instead of TERM_PROGRAM.
187 let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
188 match lc_terminal.as_str() {
189 "iTerm.app" | "Ghostty" | "WezTerm" | "Cmux" => return Method::Osc9,
190 _ => {}
191 }
192
193 // A banner selection must never invent audio. Windows users who want the
194 // system sound can explicitly select `method = "bel"` or a completion
195 // sound; unknown automatic transports fail closed.
196 if cfg!(target_os = "windows") {
197 return Method::Off;
198 }
199
200 if cfg!(target_os = "macos") {
201 return Method::MacOS;
202 }
203
204 // Ghostty-based terminals (cmux, etc.) may not set their own
205 // TERM_PROGRAM but do set TERM=xterm-ghostty. Likewise for Kitty.
206 let term = std::env::var("TERM").unwrap_or_default();
207 if term.contains("ghostty") {
208 Method::Osc9
209 } else if term.contains("kitty") {
210 Method::Kitty
211 } else {
212 Method::Off
213 }
214 }
215
216 /// Wrap an escape sequence for terminal multiplexer passthrough.
217 ///
218 /// tmux intercepts escape sequences; DCS passthrough tunnels them to
219 /// the outer terminal unmodified. Every ESC inside the payload is
220 /// doubled so tmux does not interpret it as DCS end.
221 fn wrap_for_multiplexer(seq: &str, in_tmux: bool) -> String {
222 if in_tmux {
223 let escaped = seq.replace('\x1b', "\x1b\x1b");
224 format!("\x1bPtmux;{escaped}\x1b\\")
225 } else {
226 seq.to_string()
227 }
228 }
229
230 /// Build the raw escape bytes for the given method and message.
231 ///
232 /// When `in_tmux` is `true`, OSC sequences are wrapped in DCS passthrough
233 /// so tmux forwards them to the outer terminal.
234 #[must_use]
235 fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
236 match method {
237 Method::Bel => vec![b'\x07'],
238 Method::Osc9 => {
239 let inner = format!("\x1b]9;{msg}\x07");
240 if in_tmux {
241 let escaped_inner = inner.replace('\x1b', "\x1b\x1b");
242 format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes()
243 } else {
244 inner.into_bytes()
245 }
246 }
247 Method::Kitty => {
248 // Kitty notification: OSC 99 ; params ST
249 // ST terminator (ESC \) instead of BEL to avoid audible beep.
250 let title_seq = "\x1b]99;d=0:p=title\x1b\\";
251 let body_seq = format!("\x1b]99;p=body;{msg}\x1b\\");
252 let focus_seq = "\x1b]99;d=1:a=focus\x1b\\";
253 let combined = format!("{title_seq}{body_seq}{focus_seq}");
254 wrap_for_multiplexer(&combined, in_tmux).into_bytes()
255 }
256 Method::Ghostty => {
257 // Ghostty notification: OSC 777 ; notify ; title ; message BEL
258 let seq = format!("\x1b]777;notify;codewhale;{msg}\x07");
259 wrap_for_multiplexer(&seq, in_tmux).into_bytes()
260 }
261 // Auto and Off and MacOS should not reach build_escape.
262 Method::Auto | Method::Off | Method::MacOS => vec![],
263 }
264 }
265
266 // ── Notification gate (#5041) ────────────────────────────────────────
267 //
268 // One policy switchboard between "an event happened" and "the user's
269 // desktop is interrupted". `[notifications].quiet` silences every
270 // category; `[notifications.events]` disables individual categories. The
271 // gate is installed from config by [`settings`] and consulted by
272 // [`notify_done`] ahead of every delivery mechanism, so a disabled
273 // category can never leak through one specific protocol.
274
275 /// Which notification categories may reach the user's desktop.
276 ///
277 /// The category set mirrors [`NotificationKind`] one-to-one. Default:
278 /// everything enabled, quiet off — matching the pre-#5041 behavior.
279 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
280 pub struct NotificationGate {
281 /// Suppress every category when `true` (`[notifications].quiet`).
282 pub quiet: bool,
283 pub turn_complete: bool,
284 pub subagent_terminal: bool,
285 pub approval_needed: bool,
286 pub input_needed: bool,
287 pub elevation_needed: bool,
288 pub model_notify: bool,
289 }
290
291 impl Default for NotificationGate {
292 fn default() -> Self {
293 Self {
294 quiet: false,
295 turn_complete: true,
296 subagent_terminal: true,
297 approval_needed: true,
298 input_needed: true,
299 elevation_needed: true,
300 model_notify: true,
301 }
302 }
303 }
304
305 impl NotificationGate {
306 /// Project the `[notifications]` config block onto a gate.
307 #[must_use]
308 pub fn from_config(notif: &crate::config::NotificationsConfig) -> Self {
309 Self {
310 quiet: notif.quiet,
311 turn_complete: notif.events.turn_complete,
312 subagent_terminal: notif.events.subagent_terminal,
313 approval_needed: notif.events.approval_needed,
314 input_needed: notif.events.input_needed,
315 elevation_needed: notif.events.elevation_needed,
316 model_notify: notif.events.model_notify,
317 }
318 }
319
320 /// Whether an event of `kind` may be delivered under this gate.
321 #[must_use]
322 pub fn allows(self, kind: NotificationKind) -> bool {
323 if self.quiet {
324 return false;
325 }
326 match kind {
327 NotificationKind::TurnComplete => self.turn_complete,
328 NotificationKind::SubagentTerminal => self.subagent_terminal,
329 NotificationKind::ApprovalNeeded => self.approval_needed,
330 NotificationKind::InputNeeded => self.input_needed,
331 NotificationKind::ElevationNeeded => self.elevation_needed,
332 NotificationKind::ModelNotify => self.model_notify,
333 }
334 }
335
336 const QUIET_BIT: u8 = 1 << 0;
337 const TURN_COMPLETE_BIT: u8 = 1 << 1;
338 const SUBAGENT_TERMINAL_BIT: u8 = 1 << 2;
339 const APPROVAL_NEEDED_BIT: u8 = 1 << 3;
340 const INPUT_NEEDED_BIT: u8 = 1 << 4;
341 const ELEVATION_NEEDED_BIT: u8 = 1 << 5;
342 const MODEL_NOTIFY_BIT: u8 = 1 << 6;
343
344 const fn to_bits(self) -> u8 {
345 (self.quiet as u8 * Self::QUIET_BIT)
346 | (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT)
347 | (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT)
348 | (self.approval_needed as u8 * Self::APPROVAL_NEEDED_BIT)
349 | (self.input_needed as u8 * Self::INPUT_NEEDED_BIT)
350 | (self.elevation_needed as u8 * Self::ELEVATION_NEEDED_BIT)
351 | (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT)
352 }
353
354 const fn from_bits(bits: u8) -> Self {
355 Self {
356 quiet: bits & Self::QUIET_BIT != 0,
357 turn_complete: bits & Self::TURN_COMPLETE_BIT != 0,
358 subagent_terminal: bits & Self::SUBAGENT_TERMINAL_BIT != 0,
359 approval_needed: bits & Self::APPROVAL_NEEDED_BIT != 0,
360 input_needed: bits & Self::INPUT_NEEDED_BIT != 0,
361 elevation_needed: bits & Self::ELEVATION_NEEDED_BIT != 0,
362 model_notify: bits & Self::MODEL_NOTIFY_BIT != 0,
363 }
364 }
365 }
366
367 /// Everything on, quiet off — the pre-#5041 behavior, and the effective
368 /// policy until the first [`settings`] call installs the configured gate.
369 const GATE_DEFAULT_BITS: u8 = 0b0111_1110;
370
371 /// Process-wide gate, packed to one byte so reads on the emission path are
372 /// a single atomic load.
373 static NOTIFICATION_GATE: AtomicU8 = AtomicU8::new(GATE_DEFAULT_BITS);
374
375 /// Attention delivery policy installed from the resolved notification config.
376 ///
377 /// The default is background-only. A newly started TUI is treated as focused
378 /// until the terminal explicitly reports `FocusLost`, so the safe startup
379 /// behavior is silence rather than an unexpected banner or bell.
380 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
381 enum AttentionCondition {
382 Always = 0,
383 Unfocused = 1,
384 Never = 2,
385 }
386
387 const DEFAULT_UNFOCUSED_GRACE: Duration = Duration::from_secs(2);
388 static ATTENTION_CONDITION: AtomicU8 = AtomicU8::new(AttentionCondition::Unfocused as u8);
389 static UNFOCUSED_SINCE_MS: AtomicU64 = AtomicU64::new(0);
390
391 fn attention_clock_ms() -> u64 {
392 static STARTED_AT: OnceLock<std::time::Instant> = OnceLock::new();
393 // Reserve zero for "no observed focus loss".
394 STARTED_AT
395 .get_or_init(std::time::Instant::now)
396 .elapsed()
397 .as_millis()
398 .saturating_add(1) as u64
399 }
400
401 fn install_attention_condition(condition: AttentionCondition) {
402 ATTENTION_CONDITION.store(condition as u8, Ordering::SeqCst);
403 }
404
405 fn current_attention_condition() -> AttentionCondition {
406 match ATTENTION_CONDITION.load(Ordering::SeqCst) {
407 0 => AttentionCondition::Always,
408 2 => AttentionCondition::Never,
409 _ => AttentionCondition::Unfocused,
410 }
411 }
412
413 #[must_use]
414 fn attention_delivery_allowed_at(
415 condition: AttentionCondition,
416 focused: bool,
417 unfocused_since_ms: u64,
418 now_ms: u64,
419 ) -> bool {
420 match condition {
421 AttentionCondition::Always => true,
422 AttentionCondition::Never => false,
423 AttentionCondition::Unfocused => {
424 !focused
425 && unfocused_since_ms > 0
426 && now_ms.saturating_sub(unfocused_since_ms)
427 >= DEFAULT_UNFOCUSED_GRACE.as_millis() as u64
428 }
429 }
430 }
431
432 #[must_use]
433 fn attention_delivery_allowed() -> bool {
434 attention_delivery_allowed_at(
435 current_attention_condition(),
436 TERMINAL_FOCUSED.load(Ordering::SeqCst),
437 UNFOCUSED_SINCE_MS.load(Ordering::SeqCst),
438 attention_clock_ms(),
439 )
440 }
441
442 /// Native hosts provide focus observations; the same grace/condition rule
443 /// applies before either native sound or banner preparation.
444 pub(crate) fn native_attention_allowed(
445 config: &crate::config::NotificationsConfig,
446 focused: bool,
447 unfocused_for: Duration,
448 ) -> bool {
449 let condition = match config
450 .condition
451 .unwrap_or(crate::config::NotificationCondition::Unfocused)
452 {
453 crate::config::NotificationCondition::Always => AttentionCondition::Always,
454 crate::config::NotificationCondition::Unfocused => AttentionCondition::Unfocused,
455 crate::config::NotificationCondition::Never => AttentionCondition::Never,
456 };
457 let elapsed = unfocused_for.as_millis().min(u128::from(u64::MAX - 1)) as u64;
458 attention_delivery_allowed_at(condition, focused, 1, elapsed + 1)
459 }
460
461 /// Install `gate` as the process-wide notification policy.
462 pub fn install_notification_gate(gate: NotificationGate) {
463 NOTIFICATION_GATE.store(gate.to_bits(), Ordering::SeqCst);
464 }
465
466 /// The currently installed process-wide notification gate.
467 #[must_use]
468 pub fn current_notification_gate() -> NotificationGate {
469 NotificationGate::from_bits(NOTIFICATION_GATE.load(Ordering::SeqCst))
470 }
471
472 /// Emit a notification to `sink` if the elapsed time meets or exceeds
473 /// `threshold`, `method` is not `Off`, and `gate` allows the payload's
474 /// category.
475 ///
476 /// This variant takes a `W: Write` sink and an explicit gate for
477 /// testability; production callers go through [`notify_done`], which
478 /// loads the installed process-wide gate.
479 #[cfg(test)]
480 pub fn notify_done_to<W: Write>(
481 method: Method,
482 in_tmux: bool,
483 payload: &NotificationPayload,
484 threshold: Duration,
485 elapsed: Duration,
486 gate: NotificationGate,
487 sink: &mut W,
488 ) -> DeliveryOutcome {
489 let mut policy = super::sound_policy::EventSoundPolicy::default();
490 notify_with_sinks(
491 method,
492 in_tmux,
493 payload,
494 threshold,
495 elapsed,
496 gate,
497 true,
498 sink,
499 &mut |kind, bell| policy.decide(super::sound_policy::event_for_kind(kind), 0, bell),
500 &mut super::notification_audio::emit_terminal,
501 &mut |_| DeliveryOutcome::UnsupportedTransport,
502 )
503 }
504
505 /// All side effects sit behind injected sinks. A disallowed event reaches none.
506 #[allow(clippy::too_many_arguments)]
507 pub(crate) fn notify_with_sinks(
508 method: Method,
509 in_tmux: bool,
510 payload: &NotificationPayload,
511 threshold: Duration,
512 elapsed: Duration,
513 gate: NotificationGate,
514 attention_allowed: bool,
515 sink: &mut dyn Write,
516 decide_sound: &mut dyn FnMut(NotificationKind, bool) -> super::sound_policy::SoundDecision,
517 audio: &mut dyn FnMut(
518 &super::sound_policy::SoundCue,
519 &mut dyn Write,
520 ) -> super::notification_audio::AudioOutcome,
521 native: &mut dyn FnMut(&NotificationPayload) -> DeliveryOutcome,
522 ) -> DeliveryOutcome {
523 use super::notification_audio::AudioOutcome;
524 use super::sound_policy::SoundDecision;
525 if !attention_allowed {
526 return DeliveryOutcome::SuppressedByAttention;
527 }
528 if elapsed < threshold {
529 return DeliveryOutcome::SuppressedByThreshold;
530 }
531 if method == Method::Off {
532 return DeliveryOutcome::SuppressedByMethod;
533 }
534 if !gate.allows(payload.kind()) {
535 return DeliveryOutcome::SuppressedByGate;
536 }
537 let effective = if method == Method::Auto {
538 resolve_method()
539 } else {
540 method
541 };
542 if effective == Method::Off {
543 return DeliveryOutcome::UnsupportedTransport;
544 }
545 let banner = match effective {
546 Method::MacOS => native(payload),
547 Method::Bel => DeliveryOutcome::Delivered(effective),
548 _ => {
549 let bytes = build_escape(effective, in_tmux, &payload.render_inline());
550 if bytes.is_empty() {
551 return DeliveryOutcome::UnsupportedTransport;
552 }
553 if sink.write_all(&bytes).and_then(|()| sink.flush()).is_err() {
554 return DeliveryOutcome::DeliveryFailed;
555 }
556 DeliveryOutcome::Delivered(effective)
557 }
558 };
559 if !matches!(
560 banner,
561 DeliveryOutcome::Delivered(_) | DeliveryOutcome::Dispatched(_)
562 ) {
563 return banner;
564 }
565 // BEL is itself audio: select and emit one cue instead of adding a
566 // transport bell to the chosen sound. Never fall back after suppression.
567 match decide_sound(payload.kind(), effective == Method::Bel) {
568 SoundDecision::Suppress(_) if effective == Method::Bel => {
569 DeliveryOutcome::SuppressedBySound
570 }
571 SoundDecision::Suppress(_) => banner,
572 SoundDecision::Play(cue) => match audio(&cue, sink) {
573 AudioOutcome::Emitted => banner,
574 AudioOutcome::Dispatched if effective == Method::Bel => {
575 DeliveryOutcome::Dispatched(effective)
576 }
577 AudioOutcome::Dispatched => banner,
578 AudioOutcome::Busy if effective == Method::Bel => DeliveryOutcome::SuppressedBySound,
579 AudioOutcome::Unsupported if effective == Method::Bel => {
580 DeliveryOutcome::UnsupportedTransport
581 }
582 AudioOutcome::Failed if effective == Method::Bel => DeliveryOutcome::DeliveryFailed,
583 AudioOutcome::Busy => banner,
584 AudioOutcome::Unsupported | AudioOutcome::Failed => {
585 if matches!(banner, DeliveryOutcome::Dispatched(_)) {
586 DeliveryOutcome::DispatchedWithoutSound(effective)
587 } else {
588 DeliveryOutcome::DeliveredWithoutSound(effective)
589 }
590 }
591 },
592 }
593 }
594
595 /// Emit a notification to **stdout** if `elapsed >= threshold`.
596 ///
597 /// With `method = Auto`, selects the best protocol for the current terminal
598 /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or macOS). Unknown terminals
599 /// remain unsupported; explicit method Off suppresses audio and banners.
600 /// See [`resolve_method`] for the canonical resolution table. Pass
601 /// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC
602 /// sequences in a DCS passthrough.
603 pub fn notify_done(
604 method: Method,
605 in_tmux: bool,
606 payload: &NotificationPayload,
607 threshold: Duration,
608 elapsed: Duration,
609 ) -> DeliveryOutcome {
610 notify_with_sinks(
611 method,
612 in_tmux,
613 payload,
614 threshold,
615 elapsed,
616 current_notification_gate(),
617 attention_delivery_allowed(),
618 &mut io::stdout(),
619 &mut |kind, bell| {
620 super::sound_policy::decide(kind, super::sound_policy::epoch_millis_now(), bell)
621 },
622 &mut super::notification_audio::dispatch,
623 &mut dispatch_native,
624 )
625 }
626
627 /// Set the terminal taskbar progress state via OSC 9 ; 4.
628 ///
629 /// Windows Terminal supports this to show progress on the taskbar icon:
630 /// - `state = 0` — no progress (clear)
631 /// - `state = 1` — indeterminate (cycling green)
632 /// - `state = 2` — normal (0-100, requires progress param)
633 /// - `state = 3` — error (red)
634 /// - `state = 4` — paused (yellow)
635 ///
636 /// Other terminals (iTerm2, WezTerm) ignore the sequence silently.
637 /// Best-effort — write failures are ignored.
638 /// Build the OSC 9;4 taskbar-progress sequence. Split from the write so the
639 /// bytes can be asserted without depending on whether the test runner owns a
640 /// terminal.
641 #[must_use]
642 fn taskbar_progress_sequence(state: u8, progress: Option<u8>) -> String {
643 match progress {
644 Some(pct) => format!("\x1b]9;4;{state};{pct}\x07"),
645 None => format!("\x1b]9;4;{state}\x07"),
646 }
647 }
648
649 const MAX_TERMINAL_TITLE_CHARS: usize = 160;
650
651 /// Build a bounded OSC 0 window-title sequence. User-controlled session names
652 /// can reach this boundary, so control and bidi-format characters are removed
653 /// before the title is embedded in a terminal escape sequence.
654 #[must_use]
655 fn terminal_title_sequence(title: &str) -> String {
656 let safe: String = crate::session_manager::sanitize_session_title(title)
657 .chars()
658 .take(MAX_TERMINAL_TITLE_CHARS)
659 .collect();
660 format!("\x1b]0;{safe}\x07")
661 }
662
663 /// Whether raw terminal control sequences may be written to stdout.
664 ///
665 /// OSC 9;4 (taskbar progress) and OSC 0 (window title) are *control* bytes,
666 /// not content. A terminal that understands them renders nothing visible; a
667 /// pipe, a file, or a CI log renders them literally, so `cargo test` output
668 /// and redirected sessions pick up stray `]9;4;1]0;` noise. Gate on stdout
669 /// actually being a TTY — there is no one to control otherwise.
670 fn stdout_accepts_control_sequences() -> bool {
671 use std::io::IsTerminal;
672 io::stdout().is_terminal()
673 }
674
675 pub fn set_taskbar_progress(state: u8, progress: Option<u8>) {
676 if !stdout_accepts_control_sequences() {
677 return;
678 }
679 let seq = taskbar_progress_sequence(state, progress);
680 let mut stdout = io::stdout();
681 let _ = stdout.write_all(seq.as_bytes());
682 let _ = stdout.flush();
683 }
684
685 /// Set taskbar progress to indeterminate (cycling) — call at turn start.
686 pub fn set_taskbar_progress_busy() {
687 set_taskbar_progress(1, None);
688 }
689
690 /// Clear taskbar progress — call at turn end.
691 pub fn clear_taskbar_progress() {
692 set_taskbar_progress(0, None);
693 }
694
695 /// User-configured window-title prefix, rendered as `[prefix] …` in front of
696 /// every terminal window title. Empty means no prefix — the historical
697 /// byte-for-byte behavior. Set via the `/title` command (session level) or
698 /// the `title` config key (default level); the render loop syncs it here
699 /// through [`set_title_prefix`].
700 static TITLE_PREFIX: OnceLock<Mutex<String>> = OnceLock::new();
701
702 pub(crate) fn title_prefix_slot() -> &'static Mutex<String> {
703 TITLE_PREFIX.get_or_init(|| Mutex::new(String::new()))
704 }
705
706 /// Serialise tests that touch the process-global title prefix so parallel
707 /// threads cannot leak a prefix into an unrelated assertion. Also used by
708 /// `underwater` tests that drive [`set_title_prefix`] through the render
709 /// loop.
710 #[cfg(test)]
711 pub(crate) fn title_prefix_test_lock() -> std::sync::MutexGuard<'static, ()> {
712 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
713 LOCK.get_or_init(|| Mutex::new(()))
714 .lock()
715 .unwrap_or_else(|poisoned| poisoned.into_inner())
716 }
717
718 /// Set the `[prefix] …` window-title prefix, or clear it with `None`/empty.
719 ///
720 /// Change detection keeps the per-frame render-loop sync free when the title
721 /// did not move; on an actual change the running title is redrawn immediately
722 /// so alt-tabbed sessions pick up the new identity without waiting for the
723 /// next activity-verb update.
724 pub fn set_title_prefix(prefix: Option<&str>) {
725 let prefix = prefix.unwrap_or_default().trim();
726 let changed = {
727 let mut slot = title_prefix_slot()
728 .lock()
729 .unwrap_or_else(|poisoned| poisoned.into_inner());
730 if slot.as_str() == prefix {
731 false
732 } else {
733 slot.clear();
734 slot.push_str(prefix);
735 true
736 }
737 };
738 // Redraw only after the prefix lock is released: the title render path
739 // re-locks [`title_prefix_slot`] through `decorate_title`, and a `Mutex`
740 // is not reentrant — drawing while holding it would deadlock the
741 // render loop on the first `/title` during an active turn.
742 if !changed {
743 return;
744 }
745 if TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
746 let base = title_animation_base()
747 .lock()
748 .map_or_else(|_| "codewhale".to_string(), |base| base.clone());
749 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
750 set_terminal_title(&title_activity_label(
751 &base,
752 Duration::ZERO,
753 TERMINAL_FOCUSED.load(Ordering::SeqCst),
754 motion,
755 ));
756 } else {
757 // At rest nothing else repaints OSC 0 until the next turn starts, so
758 // `/title` or `/rename` between turns must redraw the resting title
759 // itself — otherwise the tab keeps the old name while the command
760 // already reported success.
761 set_terminal_title(&decorate_title(resting_title_body()));
762 }
763 }
764
765 /// The undecorated title body shown between turns: the completion marker
766 /// while it is still on display, otherwise the plain product name.
767 fn resting_title_body() -> &'static str {
768 if COMPLETION_MARKER_SHOWN.load(Ordering::SeqCst) {
769 "✓ done"
770 } else {
771 "codewhale"
772 }
773 }
774
775 /// Shared flag controlling the title activity marker. Set to `true` by
776 /// `start_title_animation()`, cleared by `stop_title_animation()`.
777 static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false);
778 /// Focus reporting starts enabled before the event loop begins, so treating
779 /// the terminal as focused is the safe default: never flood window chrome
780 /// unless the terminal has explicitly reported `FocusLost` or motion is on.
781 static TERMINAL_FOCUSED: AtomicBool = AtomicBool::new(true);
782 /// When false, the title keeps a static whale + state (reduced motion /
783 /// status animation off) instead of cycling frames.
784 static TITLE_MOTION_ENABLED: AtomicBool = AtomicBool::new(true);
785 /// Invalidates a previous animation worker when a new turn starts or ends.
786 static TITLE_ANIMATION_GENERATION: AtomicU64 = AtomicU64::new(0);
787 static TITLE_ANIMATION_BASE: OnceLock<Mutex<String>> = OnceLock::new();
788 static TITLE_ACTIVITY_VERB: OnceLock<Mutex<String>> = OnceLock::new();
789 /// Whale frames restored from #1871 (`cd357de0c`). Cycle slowly so the
790 /// terminal title communicates life without competing with in-app spinners.
791 const TITLE_FRAME_HOLD: Duration = Duration::from_millis(800);
792 const TITLE_WHALE_FRAMES: &[&str] = &["🐳", "🐋", "🐳", "🐋"];
793
794 fn title_animation_base() -> &'static Mutex<String> {
795 TITLE_ANIMATION_BASE.get_or_init(|| Mutex::new("codewhale".to_string()))
796 }
797
798 fn title_activity_verb() -> &'static Mutex<String> {
799 TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("in the current…".to_string()))
800 }
801
802 /// Configure whether the title whale cycles frames.
803 ///
804 /// Call once at startup (and whenever motion settings change). Reduced motion
805 /// and `status_indicator = "off"` both freeze the title to a single whale.
806 pub fn set_title_motion_enabled(enabled: bool) {
807 TITLE_MOTION_ENABLED.store(enabled, Ordering::SeqCst);
808 }
809
810 /// Update the truthful activity verb shown next to the title whale
811 /// (`in the current…`, `reasoning…`, `using tool…`, `verifying…`, `waiting on you…`).
812 pub fn set_title_activity_verb(verb: &str) {
813 let verb = verb.trim();
814 if verb.is_empty() {
815 return;
816 }
817 if let Ok(mut slot) = title_activity_verb().lock() {
818 if slot.as_str() == verb {
819 return;
820 }
821 verb.clone_into(&mut *slot);
822 }
823 if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
824 return;
825 }
826 let base = title_animation_base()
827 .lock()
828 .map_or_else(|_| "codewhale".to_string(), |base| base.clone());
829 set_terminal_title(&title_activity_label(
830 &base,
831 Duration::ZERO,
832 TERMINAL_FOCUSED.load(Ordering::SeqCst),
833 TITLE_MOTION_ENABLED.load(Ordering::SeqCst),
834 ));
835 }
836
837 #[must_use]
838 fn title_activity_label(base: &str, elapsed: Duration, focused: bool, motion: bool) -> String {
839 let verb = title_activity_verb()
840 .lock()
841 .map_or_else(|_| "in the current…".to_string(), |v| v.clone());
842 let body = if verb.is_empty() {
843 base.to_string()
844 } else {
845 verb
846 };
847 // Static title when motion is off or the window is focused: one whale +
848 // state, no competing spinner in the focused app chrome.
849 if !motion || focused {
850 return decorate_title(&format!("🐳 {body}"));
851 }
852 let frame = TITLE_WHALE_FRAMES
853 [(elapsed.as_millis() / TITLE_FRAME_HOLD.as_millis()) as usize % TITLE_WHALE_FRAMES.len()];
854 decorate_title(&format!("{frame} {body}"))
855 }
856
857 /// Apply the `[prefix] ` decoration to a raw window-title body.
858 ///
859 /// With no configured prefix this returns the input unchanged, so existing
860 /// installs keep the exact titles they had before this feature landed.
861 fn decorate_title(raw: &str) -> String {
862 let prefix = title_prefix_slot()
863 .lock()
864 .map_or_else(|_| String::new(), |prefix| prefix.clone());
865 if prefix.is_empty() {
866 raw.to_string()
867 } else {
868 format!("[{prefix}] {raw}")
869 }
870 }
871
872 /// Write OSC 0 (set window title) sequence.
873 fn set_terminal_title(title: &str) {
874 if !stdout_accepts_control_sequences() {
875 return;
876 }
877 let seq = terminal_title_sequence(title);
878 let mut stdout = io::stdout();
879 let _ = stdout.write_all(seq.as_bytes());
880 let _ = stdout.flush();
881 }
882
883 /// Tracks whether the completion marker was set, so
884 /// `reset_title_on_interaction()` can skip redundant writes.
885 static COMPLETION_MARKER_SHOWN: AtomicBool = AtomicBool::new(false);
886
887 /// Mark the terminal title as active with the animated whale + state verb.
888 ///
889 /// While focused (or under reduced motion), the title stays a static whale
890 /// with the current verb. After `FocusLost` with motion enabled, the whale
891 /// frames cycle so alt-tabbed sessions still communicate progress.
892 pub fn start_title_animation(original: &str) {
893 if let Ok(mut base) = title_animation_base().lock() {
894 original.clone_into(&mut base);
895 }
896 if let Ok(mut verb) = title_activity_verb().lock()
897 && verb.is_empty()
898 {
899 "in the current…".clone_into(&mut *verb);
900 }
901 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
902 TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst);
903 let generation = TITLE_ANIMATION_GENERATION
904 .fetch_add(1, Ordering::SeqCst)
905 .saturating_add(1);
906 let focused = TERMINAL_FOCUSED.load(Ordering::SeqCst);
907 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
908 set_terminal_title(&title_activity_label(
909 original,
910 Duration::ZERO,
911 focused,
912 motion,
913 ));
914
915 let base = original.to_string();
916 std::thread::spawn(move || {
917 let started_at = std::time::Instant::now();
918 loop {
919 std::thread::sleep(TITLE_FRAME_HOLD);
920 if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst)
921 || TITLE_ANIMATION_GENERATION.load(Ordering::SeqCst) != generation
922 {
923 break;
924 }
925 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
926 // Only advance frames when unfocused + motion is on. Focused
927 // windows keep the static whale so the title is not a second
928 // spinner competing with in-app activity chrome.
929 if motion && !TERMINAL_FOCUSED.load(Ordering::SeqCst) {
930 set_terminal_title(&title_activity_label(
931 &base,
932 started_at.elapsed(),
933 false,
934 true,
935 ));
936 }
937 }
938 });
939 }
940
941 /// Update the focus gate used by the title activity signal.
942 ///
943 /// Focus gain immediately restores the steady whale + verb. Focus loss emits
944 /// the first animation frame immediately, then the worker advances it at the
945 /// debounced whale cadence.
946 pub fn set_terminal_focused(focused: bool) {
947 let was_focused = TERMINAL_FOCUSED.swap(focused, Ordering::SeqCst);
948 if focused {
949 UNFOCUSED_SINCE_MS.store(0, Ordering::SeqCst);
950 } else if was_focused {
951 // Only a real focused -> unfocused transition starts the grace period;
952 // duplicate FocusLost reports must not keep postponing delivery.
953 UNFOCUSED_SINCE_MS.store(attention_clock_ms(), Ordering::SeqCst);
954 }
955 if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
956 return;
957 }
958 let base = title_animation_base()
959 .lock()
960 .map_or_else(|_| "codewhale".to_string(), |base| base.clone());
961 let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
962 set_terminal_title(&title_activity_label(
963 &base,
964 Duration::ZERO,
965 focused,
966 motion,
967 ));
968 }
969
970 /// Stop the title animation and show a completion marker.
971 ///
972 /// Sets the title to `✓ done` so alt-tabbed users see at a glance that
973 /// processing finished. The marker is overwritten on the next turn by
974 /// [`start_title_animation`].
975 pub fn stop_title_animation() {
976 stop_title_animation_with(set_terminal_title);
977 }
978
979 fn stop_title_animation_with(set_title: impl FnOnce(&str)) {
980 TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
981 TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
982 // Always show the completion marker so quiet-sound modes still communicate
983 // finish state in the window title; interaction clears it.
984 COMPLETION_MARKER_SHOWN.store(true, Ordering::SeqCst);
985 set_title(&decorate_title("✓ done"));
986 }
987
988 /// Stop the title animation without playing the completion sound.
989 ///
990 /// Cancellation and failed turns should return the terminal title to rest
991 /// without presenting them as completed work.
992 pub fn stop_title_animation_quietly() {
993 TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
994 TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
995 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
996 set_terminal_title(&decorate_title("codewhale"));
997 }
998
999 /// Clear the completion marker from the title when the user interacts.
1000 ///
1001 /// Call this on every user input event (key press, mouse click) so the
1002 /// marker doesn't persist once the user is back at the terminal.
1003 pub fn reset_title_on_interaction() {
1004 if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) {
1005 set_terminal_title(&decorate_title("codewhale"));
1006 }
1007 }
1008
1009 /// Show a macOS Notification Center alert via `osascript`.
1010 ///
1011 /// Runs on a dedicated background thread so the caller is not blocked.
1012 ///
1013 /// The notification includes:
1014 /// - **Title**: "Codewhale"
1015 /// - **Subtitle**: [`NotificationPayload::headline`] (≤ 80 chars)
1016 /// - **Body**: [`NotificationPayload::body`] (≤ 322 chars: a ≤ 120-char
1017 /// detail, a separator, and a ≤ 200-char preview)
1018 /// - **Sound**: none in the AppleScript; the unified notification decision
1019 /// dispatches the selected audio cue.
1020 ///
1021 /// Both fields arrive already sanitized, redacted, and character-bounded
1022 /// by [`NotificationPayload`]; this function does not re-derive them from
1023 /// free-form text (#4834).
1024 ///
1025 /// **Security**: The message is passed to `osascript` as a command-line
1026 /// argument via `ARGV`, never embedded inline in the AppleScript source.
1027 /// AppleScript does not treat backslash as an escape inside double-quoted
1028 /// string literals, so the previous `\"` approach would terminate the
1029 /// string at the `"` and leave any text between unbalanced quotes
1030 /// evaluated as raw AppleScript code — a code-injection vector for
1031 /// AI-generated notification text. Passing via `ARGV` avoids this
1032 /// entirely because the message is never parsed as AppleScript syntax.
1033 /// Keep it that way.
1034 ///
1035 /// **Attribution**: the banner is posted on behalf of `osascript`, which
1036 /// is unbundled, so macOS attributes it to `com.apple.ScriptEditor2`. See
1037 /// [`Method::MacOS`] — that is not fixable from here.
1038 ///
1039 /// This is best-effort: if `osascript` is not available (e.g. headless SSH
1040 /// session) the error is logged via `tracing::warn!` instead of silently
1041 /// swallowed.
1042 #[cfg(target_os = "macos")]
1043 const MACOS_DISPLAY_NOTIFICATION_SCRIPT: &str =
1044 "display notification theBody with title \"Codewhale\" subtitle theSubtitle";
1045
1046 #[cfg(all(target_os = "macos", not(test)))]
1047 fn macos_display_notification(payload: &NotificationPayload) -> DeliveryOutcome {
1048 let (subtitle, body) = macos_notification_parts(payload);
1049
1050 // Spawn on a background thread so we don't block the caller.
1051 // osascript itself is fast (~50 ms), but spawning a subprocess
1052 // synchronously from an async context steals a tokio thread.
1053 let result = std::thread::Builder::new()
1054 .name("osascript-notif".into())
1055 .spawn(move || {
1056 // Build AppleScript that receives the message via ARGV
1057 // instead of inline string interpolation. AppleScript does
1058 // not treat backslash as an escape inside double-quoted
1059 // string literals, so `\"` would terminate the string at
1060 // the `"` and leave a dangling `\`. Passing the message as
1061 // a command-line argument avoids any injection risk.
1062 let args = [
1063 "-e".to_string(),
1064 "on run argv".to_string(),
1065 "-e".to_string(),
1066 "set theBody to item 1 of argv".to_string(),
1067 "-e".to_string(),
1068 "set theSubtitle to item 2 of argv".to_string(),
1069 "-e".to_string(),
1070 MACOS_DISPLAY_NOTIFICATION_SCRIPT.to_string(),
1071 "-e".to_string(),
1072 "end run".to_string(),
1073 "--".to_string(),
1074 body,
1075 subtitle,
1076 ];
1077
1078 match std::process::Command::new("osascript").args(&args).output() {
1079 Ok(output) if !output.status.success() => {
1080 tracing::warn!("osascript notification failed");
1081 }
1082 Err(e) => {
1083 tracing::warn!(error = %e, "osascript notification error");
1084 }
1085 _ => {}
1086 }
1087 });
1088 if result.is_ok() {
1089 DeliveryOutcome::Dispatched(Method::MacOS)
1090 } else {
1091 DeliveryOutcome::DeliveryFailed
1092 }
1093 }
1094
1095 fn dispatch_native(payload: &NotificationPayload) -> DeliveryOutcome {
1096 #[cfg(all(target_os = "macos", not(test)))]
1097 {
1098 macos_display_notification(payload)
1099 }
1100 #[cfg(any(not(target_os = "macos"), test))]
1101 {
1102 let _ = payload;
1103 DeliveryOutcome::UnsupportedTransport
1104 }
1105 }
1106
1107 /// Split a payload into the `(subtitle, body)` pair `display notification`
1108 /// wants. Both halves are already bounded and redacted by the payload
1109 /// constructors, so this is a projection, not a sanitizer.
1110 #[cfg(target_os = "macos")]
1111 fn macos_notification_parts(payload: &NotificationPayload) -> (String, String) {
1112 (payload.headline().to_string(), payload.body())
1113 }
1114
1115 // ── Per-turn notification composition ────────────────────────────────
1116 //
1117 // The helpers below decide *whether* to notify on a completed turn and
1118 // *what message* to put in the body. The low-level dispatcher is
1119 // `notify_done`; everything in this block sits in front of it.
1120
1121 use crate::tools::subagent::SubAgentStatus;
1122 use crate::tui::app::App;
1123 use codewhale_localization::{Locale, MessageId, tr};
1124 use codewhale_models::{ContentBlock, Message};
1125
1126 /// Resolve the effective notification method/threshold/include-summary tuple
1127 /// for a completed turn, taking the high-level
1128 /// `[tui].notification_condition` override into account on top of the
1129 /// lower-level `[notifications]` block.
1130 ///
1131 /// Returns `None` only when the high-level attention policy is `never`.
1132 /// `Method::Off` remains a valid projection so the event gate can report
1133 /// that both banner and sound are disabled.
1134 #[must_use]
1135 pub fn settings_projection(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
1136 let notif = config.notifications_config();
1137 let method = match notif.method {
1138 crate::config::NotificationMethod::Auto => Method::Auto,
1139 crate::config::NotificationMethod::Osc9 => Method::Osc9,
1140 crate::config::NotificationMethod::Bel => Method::Bel,
1141 crate::config::NotificationMethod::Kitty => Method::Kitty,
1142 crate::config::NotificationMethod::Ghostty => Method::Ghostty,
1143 crate::config::NotificationMethod::Off => Method::Off,
1144 };
1145 match notif
1146 .condition
1147 .unwrap_or(crate::config::NotificationCondition::Unfocused)
1148 {
1149 crate::config::NotificationCondition::Always => {
1150 Some((method, Duration::ZERO, notif.include_summary))
1151 }
1152 crate::config::NotificationCondition::Unfocused => Some((
1153 method,
1154 Duration::from_secs(notif.threshold_secs),
1155 notif.include_summary,
1156 )),
1157 crate::config::NotificationCondition::Never => None,
1158 }
1159 }
1160
1161 pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
1162 let notif = config.notifications_config();
1163 // Install the category/quiet gate (#5041) so `notify_done` honors
1164 // `[notifications].quiet` and `[notifications.events]`.
1165 install_notification_gate(NotificationGate::from_config(&notif));
1166 super::sound_policy::reconfigure(super::sound_policy::EventSoundPolicy::from_config(&notif));
1167 let projection = settings_projection(config);
1168 let method = projection.map_or(Method::Off, |(method, _, _)| method);
1169 install_configured_method(method);
1170
1171 let condition = notif
1172 .condition
1173 .unwrap_or(crate::config::NotificationCondition::Unfocused);
1174 match condition {
1175 crate::config::NotificationCondition::Always => {
1176 install_attention_condition(AttentionCondition::Always);
1177 }
1178 crate::config::NotificationCondition::Unfocused => {
1179 install_attention_condition(AttentionCondition::Unfocused);
1180 }
1181 crate::config::NotificationCondition::Never => {
1182 install_attention_condition(AttentionCondition::Never);
1183 }
1184 }
1185
1186 projection
1187 }
1188
1189 /// Build the notification payload for a completed turn. Prefers the live
1190 /// streaming text the user just saw; falls back to the latest assistant
1191 /// message in `api_messages` if streaming text is empty (for example, the
1192 /// turn finished entirely through tool output). When `include_summary` is
1193 /// true, an elapsed/cost suffix is appended to the headline.
1194 ///
1195 /// The assistant text becomes the payload's *preview*, which means it is
1196 /// redacted and capped at 200 characters before it can reach the OS.
1197 pub fn completed_turn_payload(
1198 app: &App,
1199 current_streaming_text: &str,
1200 include_summary: bool,
1201 turn_elapsed: Duration,
1202 turn_cost: Option<crate::pricing::CostEstimate>,
1203 ) -> NotificationPayload {
1204 let headline = completion_status(
1205 &tr(app.ui_locale, MessageId::NotificationTurnComplete),
1206 include_summary,
1207 turn_elapsed,
1208 turn_cost.map(|cost| app.format_cost_estimate(cost)),
1209 );
1210
1211 let preview =
1212 text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages));
1213
1214 NotificationPayload::turn_complete(&headline).with_preview(preview.as_deref())
1215 }
1216
1217 /// Compose a notification payload for a terminal sub-agent outcome. The
1218 /// agent id is always the detail line; the child's first human-readable
1219 /// summary line, when there is one, becomes the (redacted, bounded)
1220 /// preview. The headline reflects the actual status so a Stop/failed
1221 /// worker is never announced as successfully complete (#4408).
1222 pub fn subagent_terminal_payload(
1223 locale: Locale,
1224 id: &str,
1225 result: &str,
1226 status: &SubAgentStatus,
1227 include_summary: bool,
1228 elapsed: Duration,
1229 ) -> NotificationPayload {
1230 let result_line = result
1231 .lines()
1232 .map(str::trim)
1233 .find(|line| !line.is_empty() && !line.starts_with("<codewhale:subagent.done>"));
1234 let headline = completion_status(
1235 &tr(locale, subagent_terminal_label(status)),
1236 include_summary,
1237 elapsed,
1238 None,
1239 );
1240 let preview = result_line.and_then(text_summary);
1241
1242 NotificationPayload::subagent_terminal(&headline, id).with_preview(preview.as_deref())
1243 }
1244
1245 pub(crate) fn subagent_terminal_label(status: &SubAgentStatus) -> MessageId {
1246 match status {
1247 SubAgentStatus::Completed => MessageId::NotificationSubagentComplete,
1248 SubAgentStatus::Failed(_) => MessageId::NotificationSubagentFailed,
1249 SubAgentStatus::Interrupted(_) => MessageId::NotificationSubagentInterrupted,
1250 SubAgentStatus::Cancelled => MessageId::NotificationSubagentCancelled,
1251 SubAgentStatus::BudgetExhausted => MessageId::NotificationSubagentBudgetExhausted,
1252 SubAgentStatus::Running => MessageId::SubagentsStatusRunning,
1253 }
1254 }
1255
1256 /// Action-first approval banner (#5041): leads with the decision the user
1257 /// must make and names the tool it concerns. The tool *description* — the
1258 /// pending command — intentionally stays in the terminal (#4834).
1259 #[must_use]
1260 pub fn approval_needed_payload(locale: Locale, tool_name: &str) -> NotificationPayload {
1261 NotificationPayload::approval_needed(
1262 &tr(locale, MessageId::NotificationApprovalNeeded).replace("{tool}", tool_name),
1263 tool_name,
1264 )
1265 }
1266
1267 /// Action-first blocked-on-input banner (#5041): says what to do and
1268 /// where. The question text itself never leaves the terminal (#4834).
1269 #[must_use]
1270 pub fn input_needed_payload(locale: Locale) -> NotificationPayload {
1271 NotificationPayload::input_needed(&tr(locale, MessageId::NotificationInputNeeded))
1272 }
1273
1274 /// Action-first sandbox-elevation banner (#5041): leads with the decision
1275 /// and names the blocked tool; the denial reason rides in the body.
1276 #[must_use]
1277 pub fn elevation_needed_payload(
1278 locale: Locale,
1279 tool_name: &str,
1280 denial_reason: &str,
1281 ) -> NotificationPayload {
1282 NotificationPayload::elevation_needed(
1283 &tr(locale, MessageId::NotificationElevationNeeded).replace("{tool}", tool_name),
1284 tool_name,
1285 denial_reason,
1286 )
1287 }
1288
1289 fn completion_status(
1290 label: &str,
1291 include_summary: bool,
1292 elapsed: Duration,
1293 cost: Option<String>,
1294 ) -> String {
1295 if !include_summary {
1296 return label.to_string();
1297 }
1298
1299 let human = crate::elapsed::format_elapsed_secs(elapsed.as_secs());
1300 match cost {
1301 Some(cost) => format!("{label} ({human}, {cost})"),
1302 None => format!("{label} ({human})"),
1303 }
1304 }
1305
1306 /// Find the latest assistant message in `messages` and return a
1307 /// notification-ready summary of its `Text` content. Thinking blocks,
1308 /// tool calls, and tool results are skipped — only the user-visible
1309 /// reply contributes to the body.
1310 pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
1311 messages
1312 .iter()
1313 .rev()
1314 .find(|message| {
1315 message.role == "assistant"
1316 || message.role == codewhale_models::INTERRUPTED_ASSISTANT_ROLE
1317 })
1318 .and_then(|message| {
1319 let text = message
1320 .content
1321 .iter()
1322 .filter_map(|block| match block {
1323 ContentBlock::Text { text, .. } => Some(text.as_str()),
1324 ContentBlock::Thinking { .. }
1325 | ContentBlock::ToolUse { .. }
1326 | ContentBlock::ToolResult { .. }
1327 | ContentBlock::ServerToolUse { .. }
1328 | ContentBlock::ToolSearchToolResult { .. }
1329 | ContentBlock::CodeExecutionToolResult { .. } => None,
1330 ContentBlock::ImageUrl { .. } => None,
1331 })
1332 .collect::<Vec<_>>()
1333 .join("\n");
1334 text_summary(&text)
1335 })
1336 }
1337
1338 /// Sanitize + collapse + truncate streaming text into something fit to
1339 /// hand the OS notification system. Returns `None` when nothing
1340 /// useful remains after sanitization.
1341 pub fn text_summary(text: &str) -> Option<String> {
1342 const MAX_CHARS: usize = 360;
1343
1344 let sanitized = super::ui::sanitize_stream_chunk(text);
1345 let collapsed = sanitized
1346 .lines()
1347 .map(str::trim)
1348 .filter(|line: &&str| !line.is_empty())
1349 .collect::<Vec<_>>()
1350 .join("\n");
1351 let trimmed = collapsed.trim();
1352 if trimmed.is_empty() {
1353 return None;
1354 }
1355
1356 if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) {
1357 let mut s = String::with_capacity(idx + 3);
1358 s.push_str(&trimmed[..idx]);
1359 s.push_str("...");
1360 Some(s)
1361 } else {
1362 Some(trimmed.to_string())
1363 }
1364 }
1365
1366 #[cfg(test)]
1367 mod tests {
1368
1369 use super::*;
1370
1371 #[test]
1372 fn title_whale_is_static_when_focused_or_motion_disabled() {
1373 let _guard = prefix_lock();
1374 if let Ok(mut verb) = title_activity_verb().lock() {
1375 "in the current…".clone_into(&mut *verb);
1376 }
1377 assert_eq!(
1378 title_activity_label("codewhale", Duration::ZERO, true, true),
1379 "🐳 in the current…"
1380 );
1381 assert_eq!(
1382 title_activity_label("codewhale", Duration::ZERO, false, false),
1383 "🐳 in the current…"
1384 );
1385 assert_eq!(
1386 title_activity_label("codewhale", Duration::ZERO, false, true),
1387 "🐳 in the current…"
1388 );
1389 assert_eq!(
1390 title_activity_label("codewhale", Duration::from_millis(800), false, true),
1391 "🐋 in the current…"
1392 );
1393 }
1394
1395 #[test]
1396 fn title_whale_frames_are_the_restored_emoji_pair() {
1397 assert_eq!(TITLE_WHALE_FRAMES, &["🐳", "🐋", "🐳", "🐋"]);
1398 assert_eq!(TITLE_FRAME_HOLD, Duration::from_millis(800));
1399 }
1400
1401 /// Serialise tests that touch the process-global title prefix so parallel
1402 /// threads cannot leak a prefix into an unrelated assertion.
1403 fn prefix_lock() -> std::sync::MutexGuard<'static, ()> {
1404 title_prefix_test_lock()
1405 }
1406
1407 #[test]
1408 fn title_prefix_decorates_activity_label() {
1409 let _guard = prefix_lock();
1410 set_title_prefix(Some("task-7"));
1411 if let Ok(mut verb) = title_activity_verb().lock() {
1412 "reasoning…".clone_into(&mut *verb);
1413 }
1414 assert_eq!(
1415 title_activity_label("codewhale", Duration::ZERO, true, true),
1416 "[task-7] 🐳 reasoning…"
1417 );
1418 assert_eq!(
1419 title_activity_label("codewhale", Duration::ZERO, false, true),
1420 "[task-7] 🐳 reasoning…"
1421 );
1422 set_title_prefix(None);
1423 assert_eq!(
1424 title_activity_label("codewhale", Duration::ZERO, true, true),
1425 "🐳 reasoning…"
1426 );
1427 }
1428
1429 #[test]
1430 fn title_prefix_decorates_rest_and_completion_titles() {
1431 let _guard = prefix_lock();
1432 set_title_prefix(Some("feature/x"));
1433 assert_eq!(decorate_title("codewhale"), "[feature/x] codewhale");
1434 assert_eq!(decorate_title("✓ done"), "[feature/x] ✓ done");
1435 set_title_prefix(None);
1436 assert_eq!(decorate_title("codewhale"), "codewhale");
1437 assert_eq!(decorate_title("✓ done"), "✓ done");
1438 // Empty/whitespace prefixes behave exactly like `None`.
1439 set_title_prefix(Some(" "));
1440 assert_eq!(decorate_title("codewhale"), "codewhale");
1441 set_title_prefix(None);
1442 }
1443
1444 #[test]
1445 fn title_prefix_change_detection_skips_redundant_writes() {
1446 let _guard = prefix_lock();
1447 set_title_prefix(Some("alpha"));
1448 assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "alpha");
1449 // Setting the same prefix again must not clear the stored value.
1450 set_title_prefix(Some("alpha"));
1451 assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "alpha");
1452 set_title_prefix(Some("beta"));
1453 assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "beta");
1454 set_title_prefix(None);
1455 assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "");
1456 }
1457
1458 #[test]
1459 fn set_title_prefix_redraws_without_deadlocking_while_animating() {
1460 // Regression: `set_title_prefix` used to redraw the title while still
1461 // holding the prefix lock. The redraw path (`title_activity_label` →
1462 // `decorate_title`) re-locks the same `Mutex`, and `Mutex` is not
1463 // reentrant — the first `/title` during an active turn froze the
1464 // whole render loop. Exercise the exact path: prefix change while
1465 // the animation worker is running.
1466 let _guard = prefix_lock();
1467 start_title_animation("codewhale");
1468 assert!(TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst));
1469 set_title_prefix(Some("task-7"));
1470 assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "task-7");
1471 set_title_prefix(None);
1472 assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "");
1473 stop_title_animation_quietly();
1474 assert!(!TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst));
1475 }
1476
1477 /// Serialise tests that mutate process-global environment or notification
1478 /// sound state while the test harness runs them in parallel threads.
1479 fn env_lock() -> crate::test_support::TestEnvLock {
1480 crate::test_support::lock_test_env()
1481 }
1482
1483 struct NotificationGateRestore(NotificationGate);
1484
1485 impl NotificationGateRestore {
1486 fn capture() -> Self {
1487 Self(current_notification_gate())
1488 }
1489 }
1490
1491 impl Drop for NotificationGateRestore {
1492 fn drop(&mut self) {
1493 install_notification_gate(self.0);
1494 }
1495 }
1496
1497 /// Escape-protocol tests care about the bytes, not the composition
1498 /// policy, so they go through the least-privileged constructor.
1499 fn capture(
1500 method: Method,
1501 in_tmux: bool,
1502 msg: &str,
1503 threshold_secs: u64,
1504 elapsed_secs: u64,
1505 ) -> Vec<u8> {
1506 let mut buf = Vec::new();
1507 notify_done_to(
1508 method,
1509 in_tmux,
1510 &NotificationPayload::input_needed(msg),
1511 Duration::from_secs(threshold_secs),
1512 Duration::from_secs(elapsed_secs),
1513 NotificationGate::default(),
1514 &mut buf,
1515 );
1516 buf
1517 }
1518
1519 /// Emit `payload` through OSC 9 under an explicit `gate`, returning the
1520 /// bytes. The gate is passed by value, so these tests never touch the
1521 /// process-wide gate and cannot race the other capture tests.
1522 fn capture_gated(payload: &NotificationPayload, gate: NotificationGate) -> Vec<u8> {
1523 let mut buf = Vec::new();
1524 notify_done_to(
1525 Method::Osc9,
1526 false,
1527 payload,
1528 Duration::ZERO,
1529 Duration::from_secs(1),
1530 gate,
1531 &mut buf,
1532 );
1533 buf
1534 }
1535
1536 #[test]
1537 fn gate_defaults_allow_every_kind() {
1538 let gate = NotificationGate::default();
1539 for kind in [
1540 NotificationKind::TurnComplete,
1541 NotificationKind::SubagentTerminal,
1542 NotificationKind::ApprovalNeeded,
1543 NotificationKind::InputNeeded,
1544 NotificationKind::ElevationNeeded,
1545 NotificationKind::ModelNotify,
1546 ] {
1547 assert!(gate.allows(kind), "default gate must allow {kind:?}");
1548 }
1549 }
1550
1551 #[test]
1552 fn quiet_gate_suppresses_every_kind() {
1553 let gate = NotificationGate {
1554 quiet: true,
1555 ..NotificationGate::default()
1556 };
1557 for kind in [
1558 NotificationKind::TurnComplete,
1559 NotificationKind::SubagentTerminal,
1560 NotificationKind::ApprovalNeeded,
1561 NotificationKind::InputNeeded,
1562 NotificationKind::ElevationNeeded,
1563 NotificationKind::ModelNotify,
1564 ] {
1565 assert!(!gate.allows(kind), "quiet gate must suppress {kind:?}");
1566 }
1567 }
1568
1569 #[test]
1570 fn disabled_category_suppresses_only_that_kind() {
1571 let gate = NotificationGate {
1572 approval_needed: false,
1573 ..NotificationGate::default()
1574 };
1575 assert!(!gate.allows(NotificationKind::ApprovalNeeded));
1576 assert!(gate.allows(NotificationKind::TurnComplete));
1577 assert!(gate.allows(NotificationKind::InputNeeded));
1578 assert!(gate.allows(NotificationKind::ModelNotify));
1579 }
1580
1581 #[test]
1582 fn gate_bits_roundtrip_and_default_constant_agree() {
1583 assert_eq!(NotificationGate::default().to_bits(), GATE_DEFAULT_BITS);
1584 let odd = NotificationGate {
1585 quiet: true,
1586 turn_complete: false,
1587 subagent_terminal: true,
1588 approval_needed: false,
1589 input_needed: true,
1590 elevation_needed: false,
1591 model_notify: true,
1592 };
1593 assert_eq!(NotificationGate::from_bits(odd.to_bits()), odd);
1594 }
1595
1596 #[test]
1597 fn background_attention_waits_for_a_real_focus_loss() {
1598 let grace_ms = DEFAULT_UNFOCUSED_GRACE.as_millis() as u64;
1599
1600 assert!(!attention_delivery_allowed_at(
1601 AttentionCondition::Unfocused,
1602 true,
1603 0,
1604 grace_ms + 10,
1605 ));
1606 assert!(!attention_delivery_allowed_at(
1607 AttentionCondition::Unfocused,
1608 false,
1609 0,
1610 grace_ms + 10,
1611 ));
1612 assert!(!attention_delivery_allowed_at(
1613 AttentionCondition::Unfocused,
1614 false,
1615 100,
1616 100 + grace_ms - 1,
1617 ));
1618 assert!(attention_delivery_allowed_at(
1619 AttentionCondition::Unfocused,
1620 false,
1621 100,
1622 100 + grace_ms,
1623 ));
1624 }
1625
1626 #[test]
1627 fn explicit_attention_conditions_override_focus() {
1628 assert!(attention_delivery_allowed_at(
1629 AttentionCondition::Always,
1630 true,
1631 0,
1632 0,
1633 ));
1634 assert!(!attention_delivery_allowed_at(
1635 AttentionCondition::Never,
1636 false,
1637 1,
1638 u64::MAX,
1639 ));
1640 }
1641
1642 #[test]
1643 fn duplicate_focus_lost_does_not_restart_attention_grace() {
1644 let _lock = env_lock();
1645 set_terminal_focused(true);
1646 set_terminal_focused(false);
1647 let first = UNFOCUSED_SINCE_MS.load(Ordering::SeqCst);
1648 assert!(first > 0);
1649
1650 set_terminal_focused(false);
1651 let duplicate = UNFOCUSED_SINCE_MS.load(Ordering::SeqCst);
1652 assert_eq!(duplicate, first);
1653
1654 set_terminal_focused(true);
1655 }
1656
1657 /// The gate acts on the emission path itself: a suppressed category
1658 /// produces zero bytes on every protocol entry point, not just a
1659 /// filtered list somewhere upstream.
1660 #[test]
1661 fn gated_emission_produces_no_bytes() {
1662 let payload = approval_needed_payload(Locale::En, "bash");
1663
1664 let quiet = NotificationGate {
1665 quiet: true,
1666 ..NotificationGate::default()
1667 };
1668 assert!(capture_gated(&payload, quiet).is_empty());
1669
1670 let no_approvals = NotificationGate {
1671 approval_needed: false,
1672 ..NotificationGate::default()
1673 };
1674 assert!(capture_gated(&payload, no_approvals).is_empty());
1675
1676 let out = capture_gated(&payload, NotificationGate::default());
1677 assert!(!out.is_empty(), "enabled category must still emit");
1678 }
1679
1680 #[test]
1681 fn delivery_outcome_reports_why_nothing_was_sent() {
1682 let payload = input_needed_payload(Locale::En);
1683 let mut out = Vec::new();
1684 assert_eq!(
1685 DeliveryOutcome::SuppressedByAttention.receipt(),
1686 "notification not sent: attention policy blocked it"
1687 );
1688 assert_eq!(
1689 notify_done_to(
1690 Method::Off,
1691 false,
1692 &payload,
1693 Duration::ZERO,
1694 Duration::ZERO,
1695 NotificationGate::default(),
1696 &mut out,
1697 ),
1698 DeliveryOutcome::SuppressedByMethod
1699 );
1700 assert_eq!(
1701 DeliveryOutcome::SuppressedByMethod.receipt(),
1702 "notification not sent: notifications are off"
1703 );
1704
1705 assert_eq!(
1706 notify_done_to(
1707 Method::Osc9,
1708 false,
1709 &payload,
1710 Duration::from_secs(30),
1711 Duration::ZERO,
1712 NotificationGate::default(),
1713 &mut out,
1714 ),
1715 DeliveryOutcome::SuppressedByThreshold
1716 );
1717
1718 assert_eq!(
1719 notify_done_to(
1720 Method::Osc9,
1721 false,
1722 &payload,
1723 Duration::ZERO,
1724 Duration::ZERO,
1725 NotificationGate {
1726 quiet: true,
1727 ..NotificationGate::default()
1728 },
1729 &mut out,
1730 ),
1731 DeliveryOutcome::SuppressedByGate
1732 );
1733 assert!(out.is_empty());
1734 }
1735
1736 /// `settings()` is the single place config reaches the emission path;
1737 /// it must install the configured gate for `notify_done` to load.
1738 #[test]
1739 fn settings_installs_gate_from_config() {
1740 let _lock = env_lock();
1741 let _gate_restore = NotificationGateRestore::capture();
1742 let config: crate::config::Config = toml::from_str(
1743 r#"
1744 [notifications]
1745 quiet = true
1746
1747 [notifications.events]
1748 approval-needed = false
1749 "#,
1750 )
1751 .expect("gated notifications config should parse");
1752
1753 let _ = settings(&config);
1754
1755 let gate = current_notification_gate();
1756 assert!(gate.quiet);
1757 assert!(!gate.approval_needed);
1758 assert!(gate.turn_complete);
1759 }
1760
1761 /// Restores the process-wide configured method after a test mutates it.
1762 struct ConfiguredMethodRestore(Method);
1763
1764 impl ConfiguredMethodRestore {
1765 fn capture() -> Self {
1766 Self(configured_method())
1767 }
1768 }
1769
1770 impl Drop for ConfiguredMethodRestore {
1771 fn drop(&mut self) {
1772 install_configured_method(self.0);
1773 }
1774 }
1775
1776 /// Same single-place contract as the gate: `settings()` must install the
1777 /// configured `[notifications].method` so the `notify` tool (which has
1778 /// no method of its own) honors it — including `off` (#1322 promise).
1779 #[test]
1780 fn settings_installs_configured_method_from_config() {
1781 let _lock = env_lock();
1782 let _method_restore = ConfiguredMethodRestore::capture();
1783 let off: crate::config::Config = toml::from_str(
1784 r#"
1785 [notifications]
1786 method = "off"
1787 "#,
1788 )
1789 .expect("method=off config should parse");
1790 let _ = settings(&off);
1791 assert_eq!(configured_method(), Method::Off);
1792
1793 let osc9: crate::config::Config = toml::from_str(
1794 r#"
1795 [notifications]
1796 method = "osc9"
1797 "#,
1798 )
1799 .expect("method=osc9 config should parse");
1800 let _ = settings(&osc9);
1801 assert_eq!(configured_method(), Method::Osc9);
1802 }
1803
1804 /// The installed encoding must round-trip every method so an install/read
1805 /// pair can never silently fall back to `Auto`.
1806 #[test]
1807 fn configured_method_round_trips_every_variant() {
1808 let _restore = ConfiguredMethodRestore::capture();
1809 for method in [
1810 Method::Auto,
1811 Method::Osc9,
1812 Method::Bel,
1813 Method::MacOS,
1814 Method::Kitty,
1815 Method::Ghostty,
1816 Method::Off,
1817 ] {
1818 install_configured_method(method);
1819 assert_eq!(configured_method(), method);
1820 }
1821 }
1822
1823 /// #5041 copy contract: interactive banners lead with the action and
1824 /// name the subject, instead of a bare "Approval needed".
1825 #[test]
1826 fn interactive_banners_are_action_first_and_name_the_subject() {
1827 let approval = approval_needed_payload(Locale::En, "bash");
1828 assert_eq!(approval.headline(), "Approve or deny 'bash' to continue");
1829
1830 let input = input_needed_payload(Locale::En);
1831 assert_eq!(
1832 input.headline(),
1833 "Answer the question in the terminal to continue"
1834 );
1835
1836 let elevation = elevation_needed_payload(Locale::En, "bash", "network blocked");
1837 assert_eq!(
1838 elevation.headline(),
1839 "Allow or deny elevated access for 'bash'"
1840 );
1841 assert!(elevation.body().contains("network blocked"));
1842 }
1843
1844 #[test]
1845 fn interactive_notification_locales_keep_action_severity_independent_of_tool_text() {
1846 use crate::tui::app::{StatusToast, StatusToastLevel};
1847 let _guard = crate::test_support::lock_test_env();
1848 for &locale in Locale::shipped_complete() {
1849 let mut app = App::new(
1850 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
1851 &crate::config::Config::default(),
1852 );
1853 app.ui_locale = locale;
1854 let tool = "failed";
1855 let payloads = [
1856 (
1857 approval_needed_payload(locale, tool),
1858 MessageId::NotificationApprovalNeeded,
1859 ),
1860 (
1861 input_needed_payload(locale),
1862 MessageId::NotificationInputNeeded,
1863 ),
1864 (
1865 elevation_needed_payload(locale, tool, "network-policy"),
1866 MessageId::NotificationElevationNeeded,
1867 ),
1868 ];
1869 for (index, (payload, key)) in payloads.into_iter().enumerate() {
1870 assert_eq!(payload.headline(), tr(locale, key).replace("{tool}", tool));
1871 app.push_status_toast_record(
1872 StatusToast::new(payload.headline(), StatusToastLevel::Warning, Some(12_000))
1873 .for_action(format!("request-{index}")),
1874 );
1875 app.status_message = Some(payload.headline().into());
1876 app.sync_status_message_to_toasts();
1877 assert_eq!(app.status_toasts.len(), index + 1);
1878 let toast = app.status_toasts.back().unwrap();
1879 assert_eq!(toast.level, StatusToastLevel::Warning);
1880 assert_eq!(toast.ttl_ms, Some(12_000));
1881 assert!(app.sticky_status.is_none());
1882 let facts = crate::tui::phase_strip::tideline_footer_from_app(&mut app, 500);
1883 assert_eq!(
1884 facts.right,
1885 Some((
1886 payload.headline().into(),
1887 codewhale_palette::ChromeInk::Attention
1888 ))
1889 );
1890 }
1891 }
1892 }
1893
1894 #[test]
1895 fn osc9_body_format() {
1896 let out = capture(Method::Osc9, false, "codewhale: done", 0, 1);
1897 assert_eq!(out, b"\x1b]9;codewhale: done\x07");
1898 }
1899
1900 #[test]
1901 fn bel_emits_exactly_one_byte() {
1902 let out = capture(Method::Bel, false, "ignored", 0, 1);
1903 assert_eq!(out, b"\x07");
1904 }
1905
1906 #[test]
1907 fn off_mode_emits_nothing() {
1908 let out = capture(Method::Off, false, "ignored", 0, 9999);
1909 assert!(out.is_empty());
1910 }
1911
1912 /// #4847 follow-up: OSC 9;4 and OSC 0 are *control* bytes, not content.
1913 /// A terminal renders nothing visible; a pipe, a file, or a CI log renders
1914 /// them literally — which is why `cargo test` output carried stray
1915 /// `]9;4;1]0;` noise. The write is now gated on stdout being a TTY; these
1916 /// assertions pin the bytes themselves so the gate cannot be "fixed" by
1917 /// quietly changing what gets emitted.
1918 #[test]
1919 fn control_sequences_have_the_exact_documented_bytes() {
1920 assert_eq!(taskbar_progress_sequence(1, None), "\x1b]9;4;1\x07");
1921 assert_eq!(taskbar_progress_sequence(1, Some(42)), "\x1b]9;4;1;42\x07");
1922 assert_eq!(taskbar_progress_sequence(0, None), "\x1b]9;4;0\x07");
1923 assert_eq!(
1924 terminal_title_sequence("🐳 in the current…"),
1925 "\x1b]0;🐳 in the current…\x07"
1926 );
1927 }
1928
1929 #[test]
1930 fn terminal_title_sequence_strips_control_and_bidi_injection() {
1931 assert_eq!(
1932 terminal_title_sequence("safe\u{1b}]2;owned\u{7}\u{202e}title"),
1933 "\x1b]0;safe]2;ownedtitle\x07"
1934 );
1935 let oversized = "x".repeat(MAX_TERMINAL_TITLE_CHARS + 20);
1936 assert_eq!(
1937 terminal_title_sequence(&oversized),
1938 format!("\x1b]0;{}\x07", "x".repeat(MAX_TERMINAL_TITLE_CHARS))
1939 );
1940 }
1941
1942 #[test]
1943 fn terminal_title_sequence_strips_zero_width_and_bidi_marks_but_keeps_cjk() {
1944 // C1 controls (0x9C ST, 0x9D OSC), zero-width joiners/spaces, bidi
1945 // marks and isolates, BOM, soft hyphen, and line separators are all
1946 // dropped; CJK, emoji, and ordinary punctuation survive untouched.
1947 assert_eq!(
1948 terminal_title_sequence(
1949 "会\u{9d}0;議\u{9c}\u{200b}A\u{200f}B\u{061c}C\u{2066}D\u{2069}\u{feff}E\u{00ad}F\u{2028}G 🐳!"
1950 ),
1951 "\x1b]0;会0;議ABCDEFG 🐳!\x07"
1952 );
1953 // Length is bounded by chars, so a CJK title keeps whole characters.
1954 let cjk = "漢".repeat(MAX_TERMINAL_TITLE_CHARS + 5);
1955 assert_eq!(
1956 terminal_title_sequence(&cjk),
1957 format!("\x1b]0;{}\x07", "漢".repeat(MAX_TERMINAL_TITLE_CHARS))
1958 );
1959 }
1960
1961 #[test]
1962 fn title_prefix_change_at_rest_repaints_the_resting_title() {
1963 let _guard = prefix_lock();
1964 TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
1965 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
1966 set_title_prefix(Some("Alpha"));
1967 assert_eq!(decorate_title(resting_title_body()), "[Alpha] codewhale");
1968 COMPLETION_MARKER_SHOWN.store(true, Ordering::SeqCst);
1969 assert_eq!(decorate_title(resting_title_body()), "[Alpha] ✓ done");
1970 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
1971 set_title_prefix(None);
1972 assert_eq!(decorate_title(resting_title_body()), "codewhale");
1973 }
1974
1975 #[test]
1976 fn kitty_escape_uses_st_terminator() {
1977 let out = capture(Method::Kitty, false, "done", 0, 1);
1978 let s = String::from_utf8(out).unwrap();
1979 assert!(s.contains("99;"), "should have kitty OSC 99");
1980 assert!(s.contains("\x1b\\"), "kitty uses ST terminator");
1981 assert!(!s.contains("\x07"), "kitty should NOT use BEL");
1982 }
1983
1984 #[test]
1985 fn ghostty_escape_format() {
1986 let out = capture(Method::Ghostty, false, "done", 0, 1);
1987 let s = String::from_utf8(out).unwrap();
1988 assert!(
1989 s.contains("777;notify;codewhale;done"),
1990 "should have ghostty seq"
1991 );
1992 }
1993
1994 #[test]
1995 fn kitty_tmux_dcs_passthrough() {
1996 let out = capture(Method::Kitty, true, "hello", 0, 1);
1997 let s = String::from_utf8(out).unwrap();
1998 assert!(s.starts_with("\x1bPtmux;"), "should start with DCS");
1999 assert!(s.ends_with("\x1b\\"), "should end with ST");
2000 }
2001
2002 #[test]
2003 fn ghostty_tmux_dcs_passthrough() {
2004 let out = capture(Method::Ghostty, true, "hello", 0, 1);
2005 let s = String::from_utf8(out).unwrap();
2006 assert!(s.starts_with("\x1bPtmux;"), "should start with DCS");
2007 assert!(s.ends_with("\x1b\\"), "should end with ST");
2008 }
2009
2010 #[test]
2011 fn below_threshold_emits_nothing() {
2012 let out = capture(Method::Osc9, false, "msg", 30, 29);
2013 assert!(out.is_empty());
2014 }
2015
2016 #[test]
2017 fn at_threshold_emits() {
2018 let out = capture(Method::Osc9, false, "msg", 30, 30);
2019 assert!(!out.is_empty());
2020 }
2021
2022 /// The subtitle is the localized status headline and the body is
2023 /// everything else. Previously this was re-derived by splitting a
2024 /// free-form string on its first newline; now it is a projection of
2025 /// the typed payload, so the split cannot drift from what the
2026 /// composer intended (#4834).
2027 #[cfg(target_os = "macos")]
2028 #[test]
2029 fn macos_notification_keeps_localized_status_as_subtitle() {
2030 let payload = NotificationPayload::turn_complete("ターン完了 (1m 5s)")
2031 .with_preview(Some("完了しました。"));
2032
2033 let (subtitle, body) = macos_notification_parts(&payload);
2034
2035 assert_eq!(subtitle, "ターン完了 (1m 5s)");
2036 assert_eq!(body, "完了しました。");
2037 }
2038
2039 #[cfg(target_os = "macos")]
2040 #[test]
2041 fn macos_banner_does_not_smuggle_in_an_independent_sound() {
2042 assert!(!MACOS_DISPLAY_NOTIFICATION_SCRIPT.contains("sound name"));
2043 assert_eq!(
2044 MACOS_DISPLAY_NOTIFICATION_SCRIPT,
2045 "display notification theBody with title \"Codewhale\" subtitle theSubtitle"
2046 );
2047 }
2048
2049 /// The preview is capped at `PREVIEW_MAX_CHARS` *inclusive* of the
2050 /// ellipsis, so the string handed to `osascript` never exceeds the
2051 /// declared bound.
2052 #[cfg(target_os = "macos")]
2053 #[test]
2054 fn macos_notification_truncates_preview() {
2055 let payload = NotificationPayload::turn_complete("Turn complete")
2056 .with_preview(Some(&"assistant preview ".repeat(40)));
2057
2058 let (subtitle, body) = macos_notification_parts(&payload);
2059
2060 assert_eq!(subtitle, "Turn complete");
2061 assert!(body.starts_with("assistant preview"));
2062 assert!(body.ends_with("..."));
2063 assert_eq!(
2064 body.chars().count(),
2065 super::super::notification_payload::PREVIEW_MAX_CHARS
2066 );
2067 }
2068
2069 /// #4834: an approval banner is the one place a raw shell command
2070 /// used to reach Notification Center. Pin the macOS projection, not
2071 /// just the payload, so a future refactor of either half is caught.
2072 #[cfg(target_os = "macos")]
2073 #[test]
2074 fn macos_approval_notification_never_carries_the_command() {
2075 let payload = NotificationPayload::approval_needed("Approval needed", "bash");
2076
2077 let (subtitle, body) = macos_notification_parts(&payload);
2078
2079 assert_eq!(subtitle, "Approval needed");
2080 assert_eq!(body, "bash");
2081 }
2082
2083 #[test]
2084 fn tmux_dcs_passthrough_wraps_osc9() {
2085 let out = capture(Method::Osc9, true, "hello", 0, 1);
2086 let s = String::from_utf8(out).unwrap();
2087 assert!(
2088 s.starts_with("\x1bPtmux;"),
2089 "should start with DCS passthrough"
2090 );
2091 assert!(s.ends_with("\x1b\\"), "should end with ST");
2092 assert!(s.contains("hello"), "should contain message");
2093 }
2094
2095 #[test]
2096 fn auto_detect_picks_osc9_for_iterm() {
2097 let _lock = env_lock();
2098 let prev = std::env::var_os("TERM_PROGRAM");
2099 // SAFETY: test-only; serialised by env_lock().
2100 unsafe { std::env::set_var("TERM_PROGRAM", "iTerm.app") };
2101 let resolved = resolve_method();
2102 // Restore previous value.
2103 // SAFETY: test-only; serialised by env_lock().
2104 unsafe {
2105 match prev {
2106 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2107 None => std::env::remove_var("TERM_PROGRAM"),
2108 }
2109 }
2110 assert_eq!(resolved, Method::Osc9);
2111 }
2112
2113 /// Cmux in typical configurations does not set `TERM_PROGRAM`; it sets
2114 /// `LC_TERMINAL=Cmux` instead. Verify the `LC_TERMINAL` fallback probe
2115 /// correctly resolves to `Osc9`.
2116 #[test]
2117 fn auto_detect_picks_osc9_for_cmux_via_lc_terminal() {
2118 let _lock = env_lock();
2119 let prev_tp = std::env::var_os("TERM_PROGRAM");
2120 let prev_lc = std::env::var_os("LC_TERMINAL");
2121 // SAFETY: test-only; serialised by env_lock().
2122 unsafe {
2123 std::env::remove_var("TERM_PROGRAM");
2124 std::env::set_var("LC_TERMINAL", "Cmux");
2125 }
2126 let resolved = resolve_method();
2127 // SAFETY: test-only; serialised by env_lock().
2128 unsafe {
2129 match prev_tp {
2130 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2131 None => std::env::remove_var("TERM_PROGRAM"),
2132 }
2133 match prev_lc {
2134 Some(v) => std::env::set_var("LC_TERMINAL", v),
2135 None => std::env::remove_var("LC_TERMINAL"),
2136 }
2137 }
2138 assert_eq!(resolved, Method::Osc9);
2139 }
2140
2141 /// `LC_TERMINAL` should also match other OSC-9 capable terminals in case
2142 /// they set it in addition to or instead of `TERM_PROGRAM`.
2143 #[test]
2144 fn auto_detect_picks_osc9_for_wezterm_via_lc_terminal() {
2145 let _lock = env_lock();
2146 let prev_tp = std::env::var_os("TERM_PROGRAM");
2147 let prev_lc = std::env::var_os("LC_TERMINAL");
2148 // SAFETY: test-only; serialised by env_lock().
2149 unsafe {
2150 std::env::remove_var("TERM_PROGRAM");
2151 std::env::set_var("LC_TERMINAL", "WezTerm");
2152 }
2153 let resolved = resolve_method();
2154 // SAFETY: test-only; serialised by env_lock().
2155 unsafe {
2156 match prev_tp {
2157 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2158 None => std::env::remove_var("TERM_PROGRAM"),
2159 }
2160 match prev_lc {
2161 Some(v) => std::env::set_var("LC_TERMINAL", v),
2162 None => std::env::remove_var("LC_TERMINAL"),
2163 }
2164 }
2165 assert_eq!(resolved, Method::Osc9);
2166 }
2167
2168 #[test]
2169 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
2170 fn auto_detect_stays_silent_for_unknown_on_unix() {
2171 let _lock = env_lock();
2172 let prev_tp = std::env::var_os("TERM_PROGRAM");
2173 let prev_lc = std::env::var_os("LC_TERMINAL");
2174 let prev_term = std::env::var_os("TERM");
2175 // SAFETY: test-only; serialised by env_lock().
2176 // Clear LC_TERMINAL and TERM so the fallback probes don't
2177 // accidentally pick up an OSC-9 / Kitty / Ghostty capable
2178 // terminal from the test runner environment.
2179 unsafe {
2180 std::env::set_var("TERM_PROGRAM", "xterm-256color");
2181 std::env::remove_var("LC_TERMINAL");
2182 std::env::set_var("TERM", "xterm-256color");
2183 }
2184 let resolved = resolve_method();
2185 // SAFETY: test-only; serialised by env_lock().
2186 unsafe {
2187 match prev_tp {
2188 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2189 None => std::env::remove_var("TERM_PROGRAM"),
2190 }
2191 match prev_lc {
2192 Some(v) => std::env::set_var("LC_TERMINAL", v),
2193 None => std::env::remove_var("LC_TERMINAL"),
2194 }
2195 match prev_term {
2196 Some(v) => std::env::set_var("TERM", v),
2197 None => std::env::remove_var("TERM"),
2198 }
2199 }
2200 assert_eq!(resolved, Method::Off);
2201 }
2202
2203 /// Unknown Windows terminals must not turn an automatic banner request
2204 /// into an audible system sound.
2205 #[test]
2206 #[cfg(target_os = "windows")]
2207 fn auto_detect_stays_silent_for_unknown_on_windows() {
2208 let _lock = env_lock();
2209 let prev = std::env::var_os("TERM_PROGRAM");
2210 // SAFETY: test-only; serialised by env_lock().
2211 unsafe { std::env::set_var("TERM_PROGRAM", "Windows Terminal") };
2212 let resolved = resolve_method();
2213 // SAFETY: test-only; serialised by env_lock().
2214 unsafe {
2215 match prev {
2216 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2217 None => std::env::remove_var("TERM_PROGRAM"),
2218 }
2219 }
2220 assert_eq!(resolved, Method::Off);
2221 }
2222
2223 /// #583: known OSC-9 terminals must still resolve to `Osc9` on
2224 /// Windows — the off-fallback only applies to unrecognised
2225 /// `TERM_PROGRAM`. The cross-platform iTerm test above is a thin
2226 /// proxy because iTerm itself only runs on macOS; if the WezTerm
2227 /// arm of the match silently disappeared, that test would still
2228 /// pass on the Windows runner and we'd lose the WezTerm-on-Windows
2229 /// compatibility guarantee. Pin it directly.
2230 #[test]
2231 #[cfg(target_os = "windows")]
2232 fn auto_detect_picks_osc9_for_wezterm_on_windows() {
2233 let _lock = env_lock();
2234 let prev = std::env::var_os("TERM_PROGRAM");
2235 // SAFETY: test-only; serialised by env_lock().
2236 unsafe { std::env::set_var("TERM_PROGRAM", "WezTerm") };
2237 let resolved = resolve_method();
2238 // SAFETY: test-only; serialised by env_lock().
2239 unsafe {
2240 match prev {
2241 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2242 None => std::env::remove_var("TERM_PROGRAM"),
2243 }
2244 }
2245 assert_eq!(resolved, Method::Osc9);
2246 }
2247
2248 /// Ghostty-based terminals (cmux, etc.) may not set
2249 /// `TERM_PROGRAM` but do set `TERM=xterm-ghostty`. The `$TERM`
2250 /// fallback should catch them.
2251 #[test]
2252 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
2253 fn auto_detect_picks_osc9_for_xterm_ghostty_term_fallback() {
2254 let _lock = env_lock();
2255 let prev_tp = std::env::var_os("TERM_PROGRAM");
2256 let prev_lc = std::env::var_os("LC_TERMINAL");
2257 let prev_term = std::env::var_os("TERM");
2258 // Simulate a Ghostty-based terminal that only sets TERM.
2259 // SAFETY: test-only; serialised by env_lock().
2260 unsafe {
2261 std::env::remove_var("TERM_PROGRAM");
2262 std::env::remove_var("LC_TERMINAL");
2263 std::env::set_var("TERM", "xterm-ghostty");
2264 }
2265 let resolved = resolve_method();
2266 // SAFETY: test-only; serialised by env_lock().
2267 unsafe {
2268 match prev_tp {
2269 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2270 None => std::env::remove_var("TERM_PROGRAM"),
2271 }
2272 match prev_lc {
2273 Some(v) => std::env::set_var("LC_TERMINAL", v),
2274 None => std::env::remove_var("LC_TERMINAL"),
2275 }
2276 match prev_term {
2277 Some(v) => std::env::set_var("TERM", v),
2278 None => std::env::remove_var("TERM"),
2279 }
2280 }
2281 assert_eq!(resolved, Method::Osc9);
2282 }
2283
2284 /// Ghostty now has its own protocol (OSC 777).
2285 #[test]
2286 fn auto_detect_picks_ghostty_from_term_program() {
2287 let _lock = env_lock();
2288 let prev = std::env::var_os("TERM_PROGRAM");
2289 // SAFETY: test-only; serialised by env_lock().
2290 unsafe { std::env::set_var("TERM_PROGRAM", "Ghostty") };
2291 let resolved = resolve_method();
2292 // SAFETY: test-only; serialised by env_lock().
2293 unsafe {
2294 match prev {
2295 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2296 None => std::env::remove_var("TERM_PROGRAM"),
2297 }
2298 }
2299 assert_eq!(resolved, Method::Ghostty);
2300 }
2301
2302 #[test]
2303 fn auto_detect_picks_kitty_from_term_program() {
2304 let _lock = env_lock();
2305 let prev = std::env::var_os("TERM_PROGRAM");
2306 // SAFETY: test-only; serialised by env_lock().
2307 unsafe { std::env::set_var("TERM_PROGRAM", "kitty") };
2308 let resolved = resolve_method();
2309 // SAFETY: test-only; serialised by env_lock().
2310 unsafe {
2311 match prev {
2312 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2313 None => std::env::remove_var("TERM_PROGRAM"),
2314 }
2315 }
2316 assert_eq!(resolved, Method::Kitty);
2317 }
2318
2319 #[test]
2320 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
2321 fn auto_detect_picks_kitty_from_term_fallback() {
2322 let _lock = env_lock();
2323 let prev_tp = std::env::var_os("TERM_PROGRAM");
2324 let prev_lc = std::env::var_os("LC_TERMINAL");
2325 let prev_term = std::env::var_os("TERM");
2326 // SAFETY: test-only; serialised by env_lock().
2327 unsafe {
2328 std::env::remove_var("TERM_PROGRAM");
2329 std::env::remove_var("LC_TERMINAL");
2330 std::env::set_var("TERM", "xterm-kitty");
2331 }
2332 let resolved = resolve_method();
2333 // SAFETY: test-only; serialised by env_lock().
2334 unsafe {
2335 match prev_tp {
2336 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2337 None => std::env::remove_var("TERM_PROGRAM"),
2338 }
2339 match prev_lc {
2340 Some(v) => std::env::set_var("LC_TERMINAL", v),
2341 None => std::env::remove_var("LC_TERMINAL"),
2342 }
2343 match prev_term {
2344 Some(v) => std::env::set_var("TERM", v),
2345 None => std::env::remove_var("TERM"),
2346 }
2347 }
2348 assert_eq!(resolved, Method::Kitty);
2349 }
2350
2351 /// When neither `TERM_PROGRAM` nor `TERM` suggests a known capable
2352 /// terminal, automatic delivery fails closed rather than ringing BEL.
2353 ///
2354 /// On macOS the `MacOS` method takes priority, so this test is
2355 /// excluded there.
2356 #[test]
2357 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
2358 fn auto_detect_falls_back_to_off_for_unrelated_term() {
2359 let _lock = env_lock();
2360 let prev_tp = std::env::var_os("TERM_PROGRAM");
2361 let prev_lc = std::env::var_os("LC_TERMINAL");
2362 let prev_term = std::env::var_os("TERM");
2363 // SAFETY: test-only; serialised by env_lock().
2364 unsafe {
2365 std::env::remove_var("TERM_PROGRAM");
2366 std::env::remove_var("LC_TERMINAL");
2367 std::env::set_var("TERM", "xterm-256color");
2368 }
2369 let resolved = resolve_method();
2370 // SAFETY: test-only; serialised by env_lock().
2371 unsafe {
2372 match prev_tp {
2373 Some(v) => std::env::set_var("TERM_PROGRAM", v),
2374 None => std::env::remove_var("TERM_PROGRAM"),
2375 }
2376 match prev_lc {
2377 Some(v) => std::env::set_var("LC_TERMINAL", v),
2378 None => std::env::remove_var("LC_TERMINAL"),
2379 }
2380 match prev_term {
2381 Some(v) => std::env::set_var("TERM", v),
2382 None => std::env::remove_var("TERM"),
2383 }
2384 }
2385 assert_eq!(resolved, Method::Off);
2386 }
2387 }
2388
2389 // ---------------------------------------------------------------------------
2390 // Tideline notifications inbox (spec §5a "Notifications inbox"): the
2391 // attention surface that replaces the toast soup. Records are typed — the
2392 // same `NotificationKind` disclosure policy the desktop payloads use — and
2393 // the unread mark is the sanctioned gold ◆. Translation scaffolding in the
2394 // topbar mold: a pure deterministic widget over injected records (`App`
2395 // projects `status_toasts`/`sticky_status` into it at the landing slice);
2396 // not wired into `ui/frame.rs` (#5698 gate).
2397
2398 #[cfg(test)]
2399 use ratatui::{
2400 buffer::Buffer,
2401 layout::Rect,
2402 style::{Modifier, Style},
2403 };
2404 #[cfg(test)]
2405 use unicode_width::UnicodeWidthStr;
2406
2407 #[cfg(test)]
2408 use codewhale_palette::{ChromeInk, UiTheme, chrome_style};
2409
2410 /// One attention record: a typed projection of a status toast / sticky
2411 /// status / desktop payload. `at` is an injected clock string so renders
2412 /// stay deterministic (spec §5a: caller owns the wall clock).
2413 #[derive(Debug, Clone)]
2414 #[cfg(test)] // translation scaffolding: wired by the landing slice
2415 pub struct TidelineInboxRecord {
2416 pub kind: NotificationKind,
2417 pub title: String,
2418 /// One-line body, already disclosure-approved per kind.
2419 pub body: Option<String>,
2420 /// Wall-clock label, e.g. `14:42`.
2421 pub at: String,
2422 pub read: bool,
2423 }
2424
2425 #[cfg(test)]
2426 impl TidelineInboxRecord {
2427 /// Kind word — a noun, never "Error" (spec §7 failure microcopy rule).
2428 #[must_use]
2429 pub fn kind_word(&self) -> &'static str {
2430 match self.kind {
2431 NotificationKind::TurnComplete => "turn done",
2432 NotificationKind::SubagentTerminal => "whale done",
2433 NotificationKind::ApprovalNeeded => "approval",
2434 NotificationKind::InputNeeded => "question",
2435 NotificationKind::ElevationNeeded => "sandbox",
2436 NotificationKind::ModelNotify => "notify",
2437 }
2438 }
2439
2440 /// Per-kind ink per the §5d table: interactive asks read as cognition
2441 /// (permission family), completions as outcome, terminal whales as info.
2442 #[must_use]
2443 pub fn kind_ink(&self) -> ChromeInk {
2444 match self.kind {
2445 NotificationKind::TurnComplete => ChromeInk::Outcome,
2446 NotificationKind::SubagentTerminal => ChromeInk::Info,
2447 NotificationKind::ApprovalNeeded | NotificationKind::InputNeeded => {
2448 ChromeInk::PermissionAsk
2449 }
2450 NotificationKind::ElevationNeeded => ChromeInk::PermissionFullAccess,
2451 NotificationKind::ModelNotify => ChromeInk::MetadataValue,
2452 }
2453 }
2454 }
2455
2456 /// What the caller owes the inbox render.
2457 #[cfg(test)] // translation scaffolding: wired by the landing slice
2458 pub struct TidelineInbox<'a> {
2459 pub theme: &'a UiTheme,
2460 pub records: &'a [TidelineInboxRecord],
2461 /// Selected row (Enter inspects, `r` marks read, Esc backs out).
2462 pub selected: usize,
2463 pub ascii_safe: bool,
2464 }
2465
2466 #[cfg(test)] // translation scaffolding: builder methods feed tests + the landing slice
2467 impl<'a> TidelineInbox<'a> {
2468 #[must_use]
2469 pub fn new(theme: &'a UiTheme, records: &'a [TidelineInboxRecord]) -> Self {
2470 Self {
2471 theme,
2472 records,
2473 selected: 0,
2474 ascii_safe: false,
2475 }
2476 }
2477
2478 #[must_use]
2479 pub fn selected(mut self, selected: usize) -> Self {
2480 self.selected = selected;
2481 self
2482 }
2483
2484 #[must_use]
2485 pub fn ascii_safe(mut self, ascii_safe: bool) -> Self {
2486 self.ascii_safe = ascii_safe;
2487 self
2488 }
2489
2490 fn sym(&self, glyph: &str) -> String {
2491 if !self.ascii_safe {
2492 return glyph.to_string();
2493 }
2494 if let Some(fb) = crate::tui::glyphs::ascii_fallback(glyph) {
2495 return fb.to_string();
2496 }
2497 glyph
2498 .chars()
2499 .map(|c| {
2500 crate::tui::glyphs::ascii_fallback(&c.to_string())
2501 .map(str::to_string)
2502 .unwrap_or_else(|| c.to_string())
2503 })
2504 .collect()
2505 }
2506 }
2507
2508 #[cfg(test)]
2509 fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style {
2510 chrome_style(theme, ink)
2511 }
2512
2513 #[cfg(test)]
2514 fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) {
2515 buf.set_stringn(x, y, text, text.width(), style);
2516 }
2517
2518 /// Paint the notifications inbox: header row (count of unread), then one
2519 /// row per record — unread gold ◆, read hollow ○, selected `▸`, kind word,
2520 /// title, injected time. Truncates, never wraps.
2521 #[cfg(test)] // translation scaffolding: wired by the landing slice
2522 pub fn render_tideline_inbox(area: Rect, buf: &mut Buffer, inbox: &TidelineInbox<'_>) {
2523 if area.width < 8 || area.height < 2 {
2524 return;
2525 }
2526 let theme = inbox.theme;
2527 let unread = inbox.records.iter().filter(|record| !record.read).count();
2528 let header = if unread == 0 {
2529 "NOTIFICATIONS".to_string()
2530 } else {
2531 format!("NOTIFICATIONS · {unread} unread")
2532 };
2533 put(
2534 buf,
2535 area.x,
2536 area.y,
2537 &header,
2538 chrome(theme, ChromeInk::Metadata).add_modifier(Modifier::BOLD),
2539 );
2540
2541 if inbox.records.is_empty() {
2542 put(
2543 buf,
2544 area.x,
2545 area.y + 1,
2546 "quiet water — nothing needs you",
2547 chrome(theme, ChromeInk::MetadataHint),
2548 );
2549 return;
2550 }
2551
2552 let width = area.width as usize;
2553 let mut y = area.y + 1;
2554 for (index, record) in inbox.records.iter().enumerate() {
2555 if y >= area.y + area.height {
2556 break;
2557 }
2558 let selected = inbox.selected == index;
2559 let marker = if selected { "▸ " } else { " " };
2560 let mark = if record.read { "○" } else { "◆" };
2561 let mark_ink = if record.read {
2562 ChromeInk::MetadataDim
2563 } else {
2564 ChromeInk::Attention
2565 };
2566 let row = format!("{} {} — {}", record.kind_word(), record.title, record.at);
2567 let row = truncate_to_width_owned(&inbox.sym(&row), width.saturating_sub(6));
2568 put(
2569 buf,
2570 area.x + 2,
2571 y,
2572 &inbox.sym(marker),
2573 chrome(theme, ChromeInk::Identity),
2574 );
2575 put(
2576 buf,
2577 area.x + 4,
2578 y,
2579 &inbox.sym(mark),
2580 chrome(theme, mark_ink),
2581 );
2582 let mut style = chrome(theme, record.kind_ink());
2583 if record.read {
2584 style = chrome(theme, ChromeInk::MetadataDim);
2585 }
2586 if selected {
2587 style = style.add_modifier(Modifier::BOLD);
2588 }
2589 put(buf, area.x + 6, y, &row, style);
2590 // The selected record's approved body earns its own indented row —
2591 // the inspect affordance; other bodies stay collapsed.
2592 if selected
2593 && let Some(body) = record.body.as_deref()
2594 && y + 1 < area.y + area.height
2595 {
2596 put(
2597 buf,
2598 area.x + 8,
2599 y + 1,
2600 &truncate_to_width_owned(&inbox.sym(body), width.saturating_sub(10)),
2601 chrome(theme, ChromeInk::MetadataHint),
2602 );
2603 y += 1;
2604 }
2605 y += 1;
2606 }
2607 }
2608
2609 #[cfg(test)]
2610 fn truncate_to_width_owned(text: &str, width: usize) -> String {
2611 let mut out = String::new();
2612 let mut used = 0;
2613 for ch in text.chars() {
2614 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
2615 if used + w > width {
2616 break;
2617 }
2618 out.push(ch);
2619 used += w;
2620 }
2621 out
2622 }
2623
2624 /// Row hitboxes for one render (spec §6): one rect per record, matching the
2625 /// painted rows exactly — the selected record's body row belongs to its
2626 /// rect. Must be called with the same inputs as [`render_tideline_inbox`].
2627 #[must_use]
2628 #[cfg(test)] // translation scaffolding: wired by the landing slice
2629 pub fn tideline_inbox_hitboxes(area: Rect, inbox: &TidelineInbox<'_>) -> Vec<Rect> {
2630 let mut out = Vec::new();
2631 if area.width < 8 || area.height < 2 {
2632 return out;
2633 }
2634 let mut y = area.y + 1;
2635 for (index, record) in inbox.records.iter().enumerate() {
2636 let mut height = 1;
2637 if inbox.selected == index && record.body.is_some() {
2638 height = 2;
2639 }
2640 if y + height > area.y + area.height {
2641 break;
2642 }
2643 out.push(Rect {
2644 x: area.x + 2,
2645 y,
2646 width: area.width.saturating_sub(2),
2647 height,
2648 });
2649 y += height;
2650 }
2651 out
2652 }
2653
2654 #[cfg(test)]
2655 mod tideline_tests;
2656
2657 #[cfg(test)]
2658 mod unified_audio_tests {
2659 use super::super::notification_audio::AudioOutcome;
2660 use super::super::sound_policy::{self, EventSoundPolicy, SoundCue, SoundDecision};
2661 use super::*;
2662 use crate::config::{CompletionSound, NotificationConfigUpdate, NotificationsConfig};
2663
2664 fn payloads() -> [NotificationPayload; 6] {
2665 [
2666 NotificationPayload::turn_complete("done"),
2667 NotificationPayload::subagent_terminal("done", "a"),
2668 NotificationPayload::approval_needed("approve", "shell"),
2669 NotificationPayload::input_needed("answer"),
2670 NotificationPayload::elevation_needed("access", "shell", "denied"),
2671 NotificationPayload::model_notify("notice", None),
2672 ]
2673 }
2674
2675 #[test]
2676 fn every_gate_blocks_terminal_native_audio_and_the_repeat_clock() {
2677 for payload in payloads() {
2678 let mut disabled = NotificationsConfig::default();
2679 disabled
2680 .apply_update(NotificationConfigUpdate::Event(
2681 sound_policy::event_for_kind(payload.kind()),
2682 false,
2683 ))
2684 .unwrap();
2685 for method in [Method::Kitty, Method::MacOS, Method::Bel] {
2686 for (selected, gate, attention, threshold, expected) in [
2687 (
2688 Method::Off,
2689 NotificationGate::default(),
2690 true,
2691 Duration::ZERO,
2692 DeliveryOutcome::SuppressedByMethod,
2693 ),
2694 (
2695 method,
2696 NotificationGate {
2697 quiet: true,
2698 ..Default::default()
2699 },
2700 true,
2701 Duration::ZERO,
2702 DeliveryOutcome::SuppressedByGate,
2703 ),
2704 (
2705 method,
2706 NotificationGate::from_config(&disabled),
2707 true,
2708 Duration::ZERO,
2709 DeliveryOutcome::SuppressedByGate,
2710 ),
2711 (
2712 method,
2713 NotificationGate::default(),
2714 false,
2715 Duration::ZERO,
2716 DeliveryOutcome::SuppressedByAttention,
2717 ),
2718 (
2719 method,
2720 NotificationGate::default(),
2721 true,
2722 Duration::from_secs(2),
2723 DeliveryOutcome::SuppressedByThreshold,
2724 ),
2725 ] {
2726 let mut out = Vec::new();
2727 let result = notify_with_sinks(
2728 selected,
2729 false,
2730 &payload,
2731 threshold,
2732 Duration::from_secs(1),
2733 gate,
2734 attention,
2735 &mut out,
2736 &mut |_, _| panic!("suppressed event reached sound decision"),
2737 &mut |_, _| panic!("suppressed event reached audio"),
2738 &mut |_| panic!("suppressed event reached native banner"),
2739 );
2740 assert_eq!(result, expected);
2741 assert!(out.is_empty());
2742 }
2743 }
2744 }
2745 }
2746
2747 #[test]
2748 fn explicit_bell_transport_and_selected_whale_emit_only_one_cue() {
2749 for payload in payloads() {
2750 let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
2751 sound: Some(CompletionSound::Whale),
2752 ..Default::default()
2753 });
2754 let mut out = Vec::new();
2755 let mut cues = Vec::new();
2756 let result = notify_with_sinks(
2757 Method::Bel,
2758 false,
2759 &payload,
2760 Duration::ZERO,
2761 Duration::ZERO,
2762 NotificationGate::default(),
2763 true,
2764 &mut out,
2765 &mut |kind, bell| policy.decide(sound_policy::event_for_kind(kind), 0, bell),
2766 &mut |cue, _| {
2767 cues.push(cue.clone());
2768 AudioOutcome::Dispatched
2769 },
2770 &mut |_| panic!("audio-only transport reached native banner"),
2771 );
2772 assert_eq!(result, DeliveryOutcome::Dispatched(Method::Bel));
2773 assert_eq!(cues, [SoundCue::Whale]);
2774 assert!(out.is_empty(), "no second transport BEL");
2775 }
2776 }
2777
2778 #[test]
2779 fn audio_off_keeps_banner_but_silences_bell_transport() {
2780 for method in [Method::Kitty, Method::Bel] {
2781 let mut policy = EventSoundPolicy::from_config(&NotificationsConfig {
2782 sound: Some(CompletionSound::Off),
2783 completion_sound: CompletionSound::Bell,
2784 ..Default::default()
2785 });
2786 let mut out = Vec::new();
2787 let result = notify_with_sinks(
2788 method,
2789 false,
2790 &NotificationPayload::turn_complete("done"),
2791 Duration::ZERO,
2792 Duration::ZERO,
2793 NotificationGate::default(),
2794 true,
2795 &mut out,
2796 &mut |kind, bell| policy.decide(sound_policy::event_for_kind(kind), 0, bell),
2797 &mut |_, _| panic!("off reached audio"),
2798 &mut |_| panic!("unexpected native"),
2799 );
2800 assert_eq!(
2801 result,
2802 if method == Method::Bel {
2803 DeliveryOutcome::SuppressedBySound
2804 } else {
2805 DeliveryOutcome::Delivered(method)
2806 }
2807 );
2808 assert!(!out.contains(&7));
2809 }
2810 }
2811
2812 #[test]
2813 fn unsupported_failed_and_busy_audio_have_truthful_receipts_without_fallback() {
2814 for (audio_result, expected) in [
2815 (
2816 AudioOutcome::Unsupported,
2817 DeliveryOutcome::UnsupportedTransport,
2818 ),
2819 (AudioOutcome::Failed, DeliveryOutcome::DeliveryFailed),
2820 (AudioOutcome::Busy, DeliveryOutcome::SuppressedBySound),
2821 ] {
2822 let mut out = Vec::new();
2823 let mut count = 0;
2824 let result = notify_with_sinks(
2825 Method::Bel,
2826 false,
2827 &NotificationPayload::input_needed("answer"),
2828 Duration::ZERO,
2829 Duration::ZERO,
2830 NotificationGate::default(),
2831 true,
2832 &mut out,
2833 &mut |_, _| SoundDecision::Play(SoundCue::Whale),
2834 &mut |_, _| {
2835 count += 1;
2836 audio_result
2837 },
2838 &mut |_| panic!("unexpected native"),
2839 );
2840 assert_eq!(result, expected);
2841 assert_eq!(count, 1);
2842 assert!(out.is_empty());
2843 }
2844 }
2845
2846 #[test]
2847 fn native_failure_and_terminal_failure_do_not_trigger_orphan_audio() {
2848 struct Broken;
2849 impl Write for Broken {
2850 fn write(&mut self, _: &[u8]) -> io::Result<usize> {
2851 Err(io::Error::other("injected"))
2852 }
2853 fn flush(&mut self) -> io::Result<()> {
2854 Ok(())
2855 }
2856 }
2857 for method in [Method::Kitty, Method::MacOS] {
2858 let result = notify_with_sinks(
2859 method,
2860 false,
2861 &NotificationPayload::input_needed("answer"),
2862 Duration::ZERO,
2863 Duration::ZERO,
2864 NotificationGate::default(),
2865 true,
2866 &mut Broken,
2867 &mut |_, _| panic!("failed delivery reached policy"),
2868 &mut |_, _| panic!("failed delivery reached audio"),
2869 &mut |_| DeliveryOutcome::DeliveryFailed,
2870 );
2871 assert_eq!(result, DeliveryOutcome::DeliveryFailed);
2872 }
2873 }
2874
2875 #[test]
2876 fn native_worker_receipt_does_not_claim_os_acceptance() {
2877 let mut out = Vec::new();
2878 let mut cues = 0;
2879 let result = notify_with_sinks(
2880 Method::MacOS,
2881 false,
2882 &NotificationPayload::input_needed("answer"),
2883 Duration::ZERO,
2884 Duration::ZERO,
2885 NotificationGate::default(),
2886 true,
2887 &mut out,
2888 &mut |_, _| SoundDecision::Play(SoundCue::Whale),
2889 &mut |_, _| {
2890 cues += 1;
2891 AudioOutcome::Dispatched
2892 },
2893 &mut |_| DeliveryOutcome::Dispatched(Method::MacOS),
2894 );
2895 assert_eq!(result.receipt(), "notification dispatch attempted");
2896 assert_eq!(cues, 1);
2897 assert!(out.is_empty());
2898 }
2899
2900 #[test]
2901 fn failed_audio_never_upgrades_native_dispatch_evidence() {
2902 let mut out = Vec::new();
2903 let result = notify_with_sinks(
2904 Method::MacOS,
2905 false,
2906 &NotificationPayload::input_needed("answer"),
2907 Duration::ZERO,
2908 Duration::ZERO,
2909 NotificationGate::default(),
2910 true,
2911 &mut out,
2912 &mut |_, _| SoundDecision::Play(SoundCue::Whale),
2913 &mut |_, _| AudioOutcome::Failed,
2914 &mut |_| DeliveryOutcome::Dispatched(Method::MacOS),
2915 );
2916 assert_eq!(
2917 result.receipt(),
2918 "notification dispatch attempted; sound unavailable"
2919 );
2920 }
2921
2922 #[test]
2923 fn title_completion_is_only_a_visual_marker_and_cannot_consume_audio() {
2924 let _guard = crate::test_support::lock_test_env();
2925 sound_policy::configure(EventSoundPolicy::from_config(&NotificationsConfig {
2926 sound: Some(CompletionSound::Whale),
2927 ..Default::default()
2928 }));
2929 let mut title = String::new();
2930 stop_title_animation_with(|value| title = value.to_string());
2931 assert!(title.contains("✓ done"));
2932 assert_eq!(
2933 sound_policy::decide(NotificationKind::TurnComplete, 0, false),
2934 SoundDecision::Play(SoundCue::Whale)
2935 );
2936 sound_policy::configure(EventSoundPolicy::default());
2937 COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
2938 }
2939 }
2940
2940 lines RUST