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