返回 CodeWhale
notifications.rs
根目录 / crates / config / src / notifications.rs
1 //! Shared notification configuration and typed, lossless leaf edits.
2
3 use anyhow::{Context, Result, bail};
4 use serde::Deserialize;
5 use std::path::{Path, PathBuf};
6
7 impl NotificationCondition {
8 pub fn parse(value: &str) -> Option<Self> {
9 match value.trim().to_ascii_lowercase().as_str() {
10 "always" => Some(Self::Always),
11 "unfocused" | "away" => Some(Self::Unfocused),
12 "never" => Some(Self::Never),
13 _ => None,
14 }
15 }
16 pub const fn as_str(self) -> &'static str {
17 match self {
18 Self::Always => "always",
19 Self::Unfocused => "unfocused",
20 Self::Never => "never",
21 }
22 }
23 }
24
25 /// Stable configuration vocabulary shared by category and sound policies.
26 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 pub enum NotificationEvent {
28 TurnComplete,
29 SubagentTerminal,
30 ApprovalNeeded,
31 InputNeeded,
32 ElevationNeeded,
33 ModelNotify,
34 }
35
36 impl NotificationEvent {
37 pub const ALL: [Self; 6] = [
38 Self::TurnComplete,
39 Self::SubagentTerminal,
40 Self::ApprovalNeeded,
41 Self::InputNeeded,
42 Self::ElevationNeeded,
43 Self::ModelNotify,
44 ];
45 pub const fn as_str(self) -> &'static str {
46 match self {
47 Self::TurnComplete => "turn-complete",
48 Self::SubagentTerminal => "subagent-terminal",
49 Self::ApprovalNeeded => "approval-needed",
50 Self::InputNeeded => "input-needed",
51 Self::ElevationNeeded => "elevation-needed",
52 Self::ModelNotify => "model-notify",
53 }
54 }
55 pub fn parse(value: &str) -> Option<Self> {
56 Self::ALL.into_iter().find(|event| event.as_str() == value)
57 }
58 pub const fn index(self) -> usize {
59 self as usize
60 }
61 }
62
63 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
64 pub enum NotificationSetting {
65 Method,
66 ThresholdSecs,
67 IncludeSummary,
68 Quiet,
69 Sound,
70 Condition,
71 CompletionSound,
72 SubagentCompletion,
73 SoundFile,
74 Event(NotificationEvent),
75 EventSoundEnabled,
76 EventSoundEvents,
77 EventSoundMinIntervalMs,
78 EventSoundQuiet,
79 }
80
81 impl NotificationSetting {
82 pub const ALL: [Self; 19] = [
83 Self::Method,
84 Self::ThresholdSecs,
85 Self::IncludeSummary,
86 Self::Quiet,
87 Self::Sound,
88 Self::Condition,
89 Self::CompletionSound,
90 Self::SubagentCompletion,
91 Self::SoundFile,
92 Self::Event(NotificationEvent::TurnComplete),
93 Self::Event(NotificationEvent::SubagentTerminal),
94 Self::Event(NotificationEvent::ApprovalNeeded),
95 Self::Event(NotificationEvent::InputNeeded),
96 Self::Event(NotificationEvent::ElevationNeeded),
97 Self::Event(NotificationEvent::ModelNotify),
98 Self::EventSoundEnabled,
99 Self::EventSoundEvents,
100 Self::EventSoundMinIntervalMs,
101 Self::EventSoundQuiet,
102 ];
103
104 pub const fn key(self) -> &'static str {
105 match self {
106 Self::Method => "method",
107 Self::ThresholdSecs => "threshold_secs",
108 Self::IncludeSummary => "include_summary",
109 Self::Quiet => "quiet",
110 Self::Sound => "sound",
111 Self::Condition => "condition",
112 Self::CompletionSound => "completion_sound",
113 Self::SubagentCompletion => "subagent_completion",
114 Self::SoundFile => "sound_file",
115 Self::Event(NotificationEvent::TurnComplete) => "events.turn-complete",
116 Self::Event(NotificationEvent::SubagentTerminal) => "events.subagent-terminal",
117 Self::Event(NotificationEvent::ApprovalNeeded) => "events.approval-needed",
118 Self::Event(NotificationEvent::InputNeeded) => "events.input-needed",
119 Self::Event(NotificationEvent::ElevationNeeded) => "events.elevation-needed",
120 Self::Event(NotificationEvent::ModelNotify) => "events.model-notify",
121 Self::EventSoundEnabled => "event_sound.enabled",
122 Self::EventSoundEvents => "event_sound.events",
123 Self::EventSoundMinIntervalMs => "event_sound.min_interval_ms",
124 Self::EventSoundQuiet => "event_sound.quiet",
125 }
126 }
127 pub fn parse(key: &str) -> Option<Self> {
128 let key = key.trim().to_ascii_lowercase();
129 let key = key.strip_prefix("notifications.").unwrap_or(&key);
130 let key = match key {
131 "threshold" => "threshold_secs",
132 "summary" => "include_summary",
133 other => other,
134 };
135 Self::ALL
136 .into_iter()
137 .find(|setting| setting.key().replace('-', "_") == key.replace('-', "_"))
138 }
139 pub fn required(key: &str) -> Result<Self> {
140 Self::parse(key).context("unknown notification setting; use config get notifications or /config notifications status")
141 }
142 pub fn segments(self) -> Vec<&'static str> {
143 std::iter::once("notifications")
144 .chain(self.key().split('.'))
145 .collect()
146 }
147 pub const fn choices(self) -> &'static str {
148 match self {
149 Self::Method => "auto, osc9, bel, kitty, ghostty, off",
150 Self::Sound => "off, whale, bell, beep, file, legacy",
151 Self::CompletionSound => "off, whale, bell, beep, file",
152 Self::Condition => "always, unfocused, never",
153 Self::SubagentCompletion => "always, final-only, off",
154 Self::ThresholdSecs | Self::EventSoundMinIntervalMs => "0..=9223372036854775807",
155 Self::SoundFile => "\"/path/call.wav\"",
156 Self::EventSoundEvents => r#"["turn-complete", "approval-needed", ...]"#,
157 _ => "on, off, true, false, yes, no",
158 }
159 }
160 pub fn unset(self, path: &Path) -> Result<()> {
161 crate::mutate_config_document(path, |doc| {
162 crate::unset_config_document_value(doc, &self.segments())?;
163 crate::unset_config_document_value(doc, &[&format!("notifications.{}", self.key())])?;
164 Ok(())
165 })
166 }
167 }
168
169 /// One validated live edit, also used for CLI overlays and targeted persistence.
170 #[derive(Debug, Clone, PartialEq, Eq)]
171 pub enum NotificationConfigUpdate {
172 Method(NotificationMethod),
173 ThresholdSecs(u64),
174 IncludeSummary(bool),
175 Quiet(bool),
176 Sound(Option<CompletionSound>),
177 Condition(NotificationCondition),
178 CompletionSound(CompletionSound),
179 SubagentCompletion(SubagentCompletionNotification),
180 SoundFile(PathBuf),
181 Event(NotificationEvent, bool),
182 EventSoundEnabled(bool),
183 EventSoundEvents(Vec<String>),
184 EventSoundMinIntervalMs(u64),
185 EventSoundQuiet(bool),
186 }
187
188 impl NotificationConfigUpdate {
189 pub fn parse(setting: NotificationSetting, raw: &str) -> Result<Self> {
190 use NotificationSetting as K;
191 let boolean = || match raw.trim().to_ascii_lowercase().as_str() {
192 "true" | "on" | "yes" | "1" => Ok(true),
193 "false" | "off" | "no" | "0" => Ok(false),
194 _ => bail!("expected a notification boolean"),
195 };
196 let integer = || {
197 let value: u64 = raw
198 .trim()
199 .parse()
200 .context("expected a nonnegative notification integer")?;
201 anyhow::ensure!(
202 value <= i64::MAX as u64,
203 "notification integer exceeds TOML range"
204 );
205 Ok(value)
206 };
207 Ok(match setting {
208 K::Method => {
209 Self::Method(NotificationMethod::parse(raw).context("invalid notification method")?)
210 }
211 K::ThresholdSecs => Self::ThresholdSecs(integer()?),
212 K::IncludeSummary => Self::IncludeSummary(boolean()?),
213 K::Quiet => Self::Quiet(boolean()?),
214 K::Sound if raw.trim().eq_ignore_ascii_case("legacy") => Self::Sound(None),
215 K::Sound => Self::Sound(Some(
216 CompletionSound::parse(raw).context("invalid notification sound")?,
217 )),
218 K::CompletionSound => Self::CompletionSound(
219 CompletionSound::parse(raw).context("invalid completion sound")?,
220 ),
221 K::Condition => Self::Condition(
222 NotificationCondition::parse(raw).context("invalid notification condition")?,
223 ),
224 K::SubagentCompletion => Self::SubagentCompletion(
225 SubagentCompletionNotification::parse(raw)
226 .context("invalid subagent notification policy")?,
227 ),
228 K::SoundFile => {
229 let value = raw.trim();
230 let value = if value.starts_with(['\'', '"']) {
231 let parsed: toml::Table = toml::from_str(&format!("value = {value}"))
232 .context("invalid quoted sound path")?;
233 parsed
234 .get("value")
235 .and_then(toml::Value::as_str)
236 .context("expected sound path string")?
237 .to_string()
238 } else {
239 value.to_string()
240 };
241 anyhow::ensure!(
242 !value.is_empty()
243 && value.len() <= 4096
244 && !value.chars().any(char::is_control),
245 "invalid sound path"
246 );
247 Self::SoundFile(PathBuf::from(value))
248 }
249 K::Event(event) => Self::Event(event, boolean()?),
250 K::EventSoundEnabled => Self::EventSoundEnabled(boolean()?),
251 K::EventSoundQuiet => Self::EventSoundQuiet(boolean()?),
252 K::EventSoundMinIntervalMs => Self::EventSoundMinIntervalMs(integer()?),
253 K::EventSoundEvents => {
254 anyhow::ensure!(raw.len() <= 1024, "notification event list is too large");
255 let parsed: toml::Table = toml::from_str(&format!("events = {raw}"))
256 .context("expected TOML notification event array")?;
257 let values = parsed
258 .get("events")
259 .and_then(toml::Value::as_array)
260 .context("expected notification event array")?;
261 anyhow::ensure!(values.len() <= 6, "too many notification event names");
262 let mut events = Vec::new();
263 for value in values {
264 let event = value
265 .as_str()
266 .and_then(NotificationEvent::parse)
267 .context("unknown notification event")?;
268 if !events.iter().any(|value| value == event.as_str()) {
269 events.push(event.as_str().to_string());
270 }
271 }
272 Self::EventSoundEvents(events)
273 }
274 })
275 }
276 pub const fn setting(&self) -> NotificationSetting {
277 use NotificationSetting as K;
278 match self {
279 Self::Method(_) => K::Method,
280 Self::ThresholdSecs(_) => K::ThresholdSecs,
281 Self::IncludeSummary(_) => K::IncludeSummary,
282 Self::Quiet(_) => K::Quiet,
283 Self::Sound(_) => K::Sound,
284 Self::Condition(_) => K::Condition,
285 Self::CompletionSound(_) => K::CompletionSound,
286 Self::SubagentCompletion(_) => K::SubagentCompletion,
287 Self::SoundFile(_) => K::SoundFile,
288 Self::Event(event, _) => K::Event(*event),
289 Self::EventSoundEnabled(_) => K::EventSoundEnabled,
290 Self::EventSoundEvents(_) => K::EventSoundEvents,
291 Self::EventSoundMinIntervalMs(_) => K::EventSoundMinIntervalMs,
292 Self::EventSoundQuiet(_) => K::EventSoundQuiet,
293 }
294 }
295 pub fn validate(&self) -> Result<()> {
296 match self {
297 Self::ThresholdSecs(v) | Self::EventSoundMinIntervalMs(v) => anyhow::ensure!(
298 *v <= i64::MAX as u64,
299 "notification integer exceeds TOML range"
300 ),
301 Self::SoundFile(path) => {
302 let value = path.to_str().context("sound path must be UTF-8")?;
303 anyhow::ensure!(
304 !value.is_empty()
305 && value.len() <= 4096
306 && !value.chars().any(char::is_control),
307 "invalid sound path"
308 );
309 }
310 Self::EventSoundEvents(events) => {
311 anyhow::ensure!(events.len() <= 6, "too many notification events");
312 for (index, event) in events.iter().enumerate() {
313 anyhow::ensure!(
314 NotificationEvent::parse(event).is_some()
315 && !events[..index].contains(event),
316 "invalid or duplicate notification event"
317 );
318 }
319 }
320 _ => (),
321 }
322 Ok(())
323 }
324 pub fn value(&self) -> Result<Option<toml::Value>> {
325 self.validate()?;
326 Ok(Some(match self {
327 Self::Method(value) => value.as_str().into(),
328 Self::Sound(Some(value)) | Self::CompletionSound(value) => value.as_str().into(),
329 Self::Sound(None) => return Ok(None),
330 Self::Condition(value) => value.as_str().into(),
331 Self::SubagentCompletion(value) => value.as_str().into(),
332 Self::ThresholdSecs(value) | Self::EventSoundMinIntervalMs(value) => {
333 (*value as i64).into()
334 }
335 Self::IncludeSummary(value)
336 | Self::Quiet(value)
337 | Self::Event(_, value)
338 | Self::EventSoundEnabled(value)
339 | Self::EventSoundQuiet(value) => (*value).into(),
340 Self::SoundFile(value) => value.to_string_lossy().into_owned().into(),
341 Self::EventSoundEvents(values) => {
342 toml::Value::Array(values.iter().cloned().map(toml::Value::String).collect())
343 }
344 }))
345 }
346 pub fn display(&self) -> String {
347 self.value()
348 .map(display_value)
349 .unwrap_or_else(|_| "<invalid notification edit>".into())
350 }
351 pub fn persist(&self, path: &Path) -> Result<()> {
352 self.persist_for_profile(path, None)
353 }
354
355 /// Edit the existing notification owner. A profile with a notification
356 /// table owns that whole table under the current Config merge semantics;
357 /// otherwise the root table remains the owner. Never create an empty
358 /// profile override that would reset inherited notification choices.
359 pub fn persist_for_profile(&self, path: &Path, profile: Option<&str>) -> Result<()> {
360 self.validate()?;
361 crate::mutate_config_document(path, |doc| {
362 let mut prefix = Vec::new();
363 if let Some(profile) = profile {
364 let table = doc
365 .get("profiles")
366 .and_then(|profiles| profiles.get(profile))
367 .and_then(toml_edit::Item::as_table_like)
368 .context("active profile is missing or malformed")?;
369 if table.contains_key("notifications") {
370 prefix.extend(["profiles", profile]);
371 }
372 }
373 let mut segments = prefix.clone();
374 segments.extend(self.setting().segments());
375 if let Some(value) = self.value()? {
376 let value = match value {
377 toml::Value::String(v) => toml_edit::Value::from(v),
378 toml::Value::Integer(v) => v.into(),
379 toml::Value::Boolean(v) => v.into(),
380 toml::Value::Array(v) => toml_edit::Value::Array(
381 v.into_iter()
382 .map(|v| v.as_str().expect("validated string array").to_string())
383 .collect(),
384 ),
385 _ => unreachable!("notification leaf"),
386 };
387 crate::set_config_document_value(doc, &segments, value)?;
388 } else {
389 crate::unset_config_document_value(doc, &segments)?;
390 }
391 let old_literal = format!("notifications.{}", self.setting().key());
392 prefix.push(&old_literal);
393 crate::unset_config_document_value(doc, &prefix)?;
394 Ok(())
395 })
396 }
397 }
398
399 /// Decode the existing raw config table without taking ownership of its unknown fields.
400 pub fn from_extras(
401 extras: &std::collections::BTreeMap<String, toml::Value>,
402 ) -> Result<NotificationsConfig> {
403 let mut config: NotificationsConfig = extras
404 .get("notifications")
405 .cloned()
406 .unwrap_or_else(|| toml::Value::Table(toml::Table::new()))
407 .try_into()
408 .context("invalid notification configuration")?;
409 if config.condition.is_none() {
410 config.condition = extras
411 .get("tui")
412 .and_then(|tui| tui.get("notification_condition"))
413 .cloned()
414 .map(toml::Value::try_into)
415 .transpose()
416 .context("invalid legacy notification condition")?;
417 }
418 config.condition = Some(config.condition.unwrap_or(NotificationCondition::Unfocused));
419 Ok(config)
420 }
421
422 /// Apply a validated leaf to CLI's raw table; siblings and future keys survive.
423 pub fn in_namespace(key: &str) -> bool {
424 let key = key.to_ascii_lowercase();
425 key == "notifications" || key.starts_with("notifications.")
426 }
427
428 pub fn edit_extras(
429 extras: &mut std::collections::BTreeMap<String, toml::Value>,
430 setting: NotificationSetting,
431 value: Option<toml::Value>,
432 ) -> Result<()> {
433 if let Some(value) = &value {
434 let parsed = match (setting, value) {
435 (NotificationSetting::SoundFile, toml::Value::String(path)) => {
436 let update = NotificationConfigUpdate::SoundFile(PathBuf::from(path));
437 update.validate()?;
438 update
439 }
440 _ => NotificationConfigUpdate::parse(setting, &display_value(Some(value.clone())))?,
441 };
442 anyhow::ensure!(
443 parsed.value()?.as_ref() == Some(value),
444 "notification leaf has the wrong type or noncanonical value"
445 );
446 }
447 let mut updated = extras.clone();
448 if value.is_some() || updated.contains_key("notifications") {
449 let root = updated
450 .entry("notifications".into())
451 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
452 let mut table = root
453 .as_table_mut()
454 .context("notifications must be a TOML table")?;
455 let segments = setting.key().split('.').collect::<Vec<_>>();
456 let (key, parents) = segments.split_last().expect("notification key");
457 let mut missing = false;
458 for parent in parents {
459 if value.is_none() && !table.contains_key(*parent) {
460 missing = true;
461 break;
462 }
463 table = table
464 .entry((*parent).to_string())
465 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
466 .as_table_mut()
467 .context("notification parent must be a TOML table")?;
468 }
469 if !missing {
470 if let Some(value) = value {
471 table.insert((*key).into(), value);
472 } else {
473 table.remove(*key);
474 }
475 }
476 }
477 updated.remove(&format!("notifications.{}", setting.key()));
478 *extras = updated;
479 Ok(())
480 }
481
482 fn display_value(value: Option<toml::Value>) -> String {
483 match value {
484 Some(toml::Value::String(value)) => value,
485 Some(value) => value.to_string(),
486 None => "legacy".into(),
487 }
488 }
489
490 impl NotificationsConfig {
491 pub fn apply_update(&mut self, update: NotificationConfigUpdate) -> Result<()> {
492 update.validate()?;
493 match update {
494 NotificationConfigUpdate::Method(v) => self.method = v,
495 NotificationConfigUpdate::ThresholdSecs(v) => self.threshold_secs = v,
496 NotificationConfigUpdate::IncludeSummary(v) => self.include_summary = v,
497 NotificationConfigUpdate::Quiet(v) => self.quiet = v,
498 NotificationConfigUpdate::Sound(v) => self.sound = v,
499 NotificationConfigUpdate::Condition(v) => self.condition = Some(v),
500 NotificationConfigUpdate::CompletionSound(v) => self.completion_sound = v,
501 NotificationConfigUpdate::SubagentCompletion(v) => self.subagent_completion = v,
502 NotificationConfigUpdate::SoundFile(v) => self.sound_file = Some(v),
503 NotificationConfigUpdate::Event(event, value) => *self.events.value_mut(event) = value,
504 NotificationConfigUpdate::EventSoundEnabled(v) => self.event_sound.enabled = v,
505 NotificationConfigUpdate::EventSoundEvents(v) => self.event_sound.events = v,
506 NotificationConfigUpdate::EventSoundMinIntervalMs(v) => {
507 self.event_sound.min_interval_ms = v
508 }
509 NotificationConfigUpdate::EventSoundQuiet(v) => self.event_sound.quiet = v,
510 }
511 Ok(())
512 }
513 pub fn display(&self, setting: NotificationSetting) -> String {
514 use NotificationSetting as K;
515 match setting {
516 K::Method => self.method.as_str().into(),
517 K::ThresholdSecs => self.threshold_secs.to_string(),
518 K::IncludeSummary => self.include_summary.to_string(),
519 K::Quiet => self.quiet.to_string(),
520 K::Sound => self.sound.map_or("legacy", CompletionSound::as_str).into(),
521 K::Condition => self
522 .condition
523 .unwrap_or(NotificationCondition::Unfocused)
524 .as_str()
525 .into(),
526 K::CompletionSound => self.completion_sound.as_str().into(),
527 K::SubagentCompletion => self.subagent_completion.as_str().into(),
528 K::SoundFile => self
529 .sound_file
530 .as_ref()
531 .map_or_else(String::new, |path| path.to_string_lossy().into_owned()),
532 K::Event(event) => self.events.value(event).to_string(),
533 K::EventSoundEnabled => self.event_sound.enabled.to_string(),
534 K::EventSoundQuiet => self.event_sound.quiet.to_string(),
535 K::EventSoundMinIntervalMs => self.event_sound.min_interval_ms.to_string(),
536 K::EventSoundEvents => toml::Value::Array(
537 self.event_sound
538 .events
539 .iter()
540 .cloned()
541 .map(toml::Value::String)
542 .collect(),
543 )
544 .to_string(),
545 }
546 }
547 }
548
549 impl NotificationEventsConfig {
550 pub fn value(&self, event: NotificationEvent) -> bool {
551 match event {
552 NotificationEvent::TurnComplete => self.turn_complete,
553 NotificationEvent::SubagentTerminal => self.subagent_terminal,
554 NotificationEvent::ApprovalNeeded => self.approval_needed,
555 NotificationEvent::InputNeeded => self.input_needed,
556 NotificationEvent::ElevationNeeded => self.elevation_needed,
557 NotificationEvent::ModelNotify => self.model_notify,
558 }
559 }
560 fn value_mut(&mut self, event: NotificationEvent) -> &mut bool {
561 match event {
562 NotificationEvent::TurnComplete => &mut self.turn_complete,
563 NotificationEvent::SubagentTerminal => &mut self.subagent_terminal,
564 NotificationEvent::ApprovalNeeded => &mut self.approval_needed,
565 NotificationEvent::InputNeeded => &mut self.input_needed,
566 NotificationEvent::ElevationNeeded => &mut self.elevation_needed,
567 NotificationEvent::ModelNotify => &mut self.model_notify,
568 }
569 }
570 }
571
572 /// High-level notification trigger override. See
573 /// The canonical notification condition; the old TUI field remains a read fallback.
574 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
575 #[serde(rename_all = "snake_case")]
576 pub enum NotificationCondition {
577 /// Allow configured operator notifications in the foreground; completed
578 /// turns have no duration threshold.
579 Always,
580 /// Notify only while the terminal is genuinely in the background.
581 Unfocused,
582 /// Suppress all operator notifications.
583 Never,
584 }
585
586 /// Notification delivery method (mirrors `tui::notifications::Method`).
587 #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
588 #[serde(rename_all = "kebab-case")]
589 pub enum NotificationMethod {
590 /// Auto-detect: picks the best protocol for the current terminal
591 /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).
592 #[default]
593 Auto,
594 /// OSC 9 escape.
595 Osc9,
596 /// Plain BEL character.
597 Bel,
598 /// Kitty notification protocol (OSC 99).
599 Kitty,
600 /// Ghostty notification protocol (OSC 777).
601 Ghostty,
602 /// Disable notifications.
603 Off,
604 }
605
606 impl NotificationMethod {
607 #[must_use]
608 pub fn parse(value: &str) -> Option<Self> {
609 match value.trim().to_ascii_lowercase().as_str() {
610 "auto" => Some(Self::Auto),
611 "osc9" | "osc-9" | "osc_9" => Some(Self::Osc9),
612 "bel" | "bell" => Some(Self::Bel),
613 "kitty" => Some(Self::Kitty),
614 "ghostty" => Some(Self::Ghostty),
615 "off" | "none" | "disable" | "disabled" => Some(Self::Off),
616 _ => None,
617 }
618 }
619
620 #[must_use]
621 pub fn as_str(self) -> &'static str {
622 match self {
623 Self::Auto => "auto",
624 Self::Osc9 => "osc9",
625 Self::Bel => "bel",
626 Self::Kitty => "kitty",
627 Self::Ghostty => "ghostty",
628 Self::Off => "off",
629 }
630 }
631
632 #[must_use]
633 pub fn names_hint() -> &'static str {
634 "auto, osc9, bel, kitty, ghostty, off"
635 }
636 }
637
638 fn default_threshold_secs() -> u64 {
639 30
640 }
641
642 /// Completion sound options.
643 #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
644 #[serde(rename_all = "kebab-case")]
645 pub enum CompletionSound {
646 /// No sound on turn completion.
647 #[default]
648 Off,
649 /// System notification beep. On Windows uses `MessageBeep`.
650 Beep,
651 /// Terminal BEL character (`\x07`).
652 Bell,
653 /// Play the bundled Codewhale whale call.
654 Whale,
655 /// Play a configured WAV sound file.
656 File,
657 }
658
659 impl CompletionSound {
660 #[must_use]
661 pub fn parse(value: &str) -> Option<Self> {
662 match value.trim().to_ascii_lowercase().as_str() {
663 "off" | "none" | "disable" | "disabled" => Some(Self::Off),
664 "beep" => Some(Self::Beep),
665 "bell" | "bel" => Some(Self::Bell),
666 "file" => Some(Self::File),
667 "whale" => Some(Self::Whale),
668 _ => None,
669 }
670 }
671
672 #[must_use]
673 pub fn as_str(self) -> &'static str {
674 match self {
675 Self::Off => "off",
676 Self::Beep => "beep",
677 Self::Bell => "bell",
678 Self::File => "file",
679 Self::Whale => "whale",
680 }
681 }
682
683 #[must_use]
684 pub fn names_hint() -> &'static str {
685 "off, whale, bell, beep, file"
686 }
687 }
688
689 /// Controls when per-subagent completion notifications fire during fleet /
690 /// workflow runs. Turn-completion notifications are unaffected.
691 #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
692 #[serde(rename_all = "kebab-case")]
693 pub enum SubagentCompletionNotification {
694 /// Notify on every subagent completion.
695 Always,
696 /// Notify only when the last subagent in a batch finishes — no other
697 /// subagents running and no workflow run in progress. Default: stays quiet
698 /// mid-run and fires once when the fleet drains.
699 #[default]
700 FinalOnly,
701 /// Never fire a subagent-completion notification.
702 Off,
703 }
704
705 impl SubagentCompletionNotification {
706 #[must_use]
707 pub fn parse(value: &str) -> Option<Self> {
708 match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
709 "always" => Some(Self::Always),
710 "final-only" | "finalonly" | "final" => Some(Self::FinalOnly),
711 "off" | "none" | "never" | "disable" | "disabled" => Some(Self::Off),
712 _ => None,
713 }
714 }
715
716 #[must_use]
717 pub fn as_str(self) -> &'static str {
718 match self {
719 Self::Always => "always",
720 Self::FinalOnly => "final-only",
721 Self::Off => "off",
722 }
723 }
724
725 #[must_use]
726 pub fn names_hint() -> &'static str {
727 "always, final-only, off"
728 }
729 }
730
731 /// Operator notification configuration (native and terminal transports).
732 #[derive(Debug, Clone, Deserialize, PartialEq)]
733 pub struct NotificationsConfig {
734 /// One sound choice for every enabled category. Absent preserves legacy sound settings.
735 #[serde(default)]
736 pub sound: Option<CompletionSound>,
737 /// Canonical attention condition; absent falls back to the legacy TUI field.
738 #[serde(default)]
739 pub condition: Option<NotificationCondition>,
740 /// Delivery method: `auto` | `osc9` | `kitty` | `ghostty` | `bel` |
741 /// `off`. Default: `auto`.
742 /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux
743 /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`) and the native macOS
744 /// transport where appropriate; unknown terminals fail closed to `off`.
745 /// Audible BEL is explicit only. On Windows explicit BEL is routed through
746 /// `MessageBeep(MB_OK)`.
747 /// Use `method = "osc9"` explicitly when your terminal is OSC-9 capable
748 /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).
749 #[serde(default)]
750 pub method: NotificationMethod,
751 /// Only notify when the turn took at least this many seconds. Default: 30.
752 #[serde(default = "default_threshold_secs")]
753 pub threshold_secs: u64,
754 /// Include a short summary (elapsed time + cost) in the notification body.
755 /// Default: `false`.
756 #[serde(default)]
757 pub include_summary: bool,
758
759 /// When to fire per-subagent completion notifications during fleet /
760 /// workflow runs: `always` | `final-only` | `off`. Default: `final-only`
761 /// (quiet mid-run, one notification when the batch drains). Set `off` to
762 /// silence subagent notifications entirely.
763 #[serde(default)]
764 pub subagent_completion: SubagentCompletionNotification,
765
766 /// Legacy completion cue, used only when `sound` is absent. Default: `"off"`.
767 /// This is opt-in and follows the same foreground/quiet attention policy
768 /// as desktop notifications.
769 #[serde(default)]
770 pub completion_sound: CompletionSound,
771
772 /// Local WAV path for the File choice in canonical or legacy sound mode.
773 #[serde(default)]
774 pub sound_file: Option<PathBuf>,
775
776 /// Opt-in per-event sound policy (`[notifications.event_sound]`).
777 /// Disabled by default; canonical `sound` overrides its enable/list/quiet choices.
778 #[serde(default)]
779 pub event_sound: EventSoundConfig,
780
781 /// Quiet mode: suppress every desktop notification (all categories, all
782 /// delivery methods) and the paired `[notifications.event_sound]` cues,
783 /// without editing `method`, `completion_sound`, or the per-category
784 /// switches under `[notifications.events]`. Default: `false`.
785 #[serde(default)]
786 pub quiet: bool,
787
788 /// Per-category desktop-notification switches
789 /// (`[notifications.events]`). Every category defaults to enabled; set
790 /// one to `false` to silence that event kind without touching the rest.
791 #[serde(default)]
792 pub events: NotificationEventsConfig,
793 }
794
795 impl Default for NotificationsConfig {
796 fn default() -> Self {
797 Self {
798 sound: None,
799 condition: None,
800 method: NotificationMethod::default(),
801 threshold_secs: default_threshold_secs(),
802 include_summary: false,
803 subagent_completion: SubagentCompletionNotification::default(),
804 completion_sound: CompletionSound::default(),
805 sound_file: None,
806 event_sound: EventSoundConfig::default(),
807 quiet: false,
808 events: NotificationEventsConfig::default(),
809 }
810 }
811 }
812
813 fn default_notification_event_enabled() -> bool {
814 true
815 }
816
817 /// Per-category desktop-notification switches (`[notifications.events]`).
818 ///
819 /// Categories mirror the closed set of notification kinds in
820 /// `tui::notification_payload::NotificationKind`. Each defaults to `true`;
821 /// a disabled category is suppressed across every delivery mechanism
822 /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, BEL, macOS Notification Center).
823 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
824 #[serde(rename_all = "kebab-case")]
825 pub struct NotificationEventsConfig {
826 /// An agent turn finished successfully. Default: `true`.
827 #[serde(default = "default_notification_event_enabled")]
828 pub turn_complete: bool,
829 /// A sub-agent reached a terminal status. Default: `true`.
830 #[serde(default = "default_notification_event_enabled")]
831 pub subagent_terminal: bool,
832 /// A tool call is blocked waiting for approval. Default: `true`.
833 #[serde(default = "default_notification_event_enabled")]
834 pub approval_needed: bool,
835 /// The agent asked a question and is blocked on the answer.
836 /// Default: `true`.
837 #[serde(default = "default_notification_event_enabled")]
838 pub input_needed: bool,
839 /// The sandbox denied an operation and the user must decide.
840 /// Default: `true`.
841 #[serde(default = "default_notification_event_enabled")]
842 pub elevation_needed: bool,
843 /// The model called the `notify` tool. Default: `true`.
844 #[serde(default = "default_notification_event_enabled")]
845 pub model_notify: bool,
846 }
847
848 impl Default for NotificationEventsConfig {
849 fn default() -> Self {
850 Self {
851 turn_complete: true,
852 subagent_terminal: true,
853 approval_needed: true,
854 input_needed: true,
855 elevation_needed: true,
856 model_notify: true,
857 }
858 }
859 }
860
861 fn default_event_sound_events() -> Vec<String> {
862 vec!["turn-complete".to_string(), "approval-needed".to_string()]
863 }
864
865 fn default_event_sound_min_interval_ms() -> u64 {
866 2000
867 }
868
869 /// Opt-in, deterministic per-event sound policy (#4817). Terminal-bell
870 /// level only: cues are BEL (`\x07`) bytes, a platform-safe no-op on
871 /// terminals that ignore them. Off by default.
872 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
873 pub struct EventSoundConfig {
874 /// Master switch. Default: `false` (nothing is emitted unless opted in).
875 #[serde(default)]
876 pub enabled: bool,
877 /// Allow-list of event names, kebab-case (`"turn-complete"`,
878 /// `"subagent-terminal"`, `"approval-needed"`, `"input-needed"`,
879 /// `"elevation-needed"`, `"model-notify"`). Unknown names are ignored.
880 /// Default: `["turn-complete", "approval-needed"]`.
881 #[serde(default = "default_event_sound_events")]
882 pub events: Vec<String>,
883 /// Minimum milliseconds between two plays of the same event. Default: 2000.
884 #[serde(default = "default_event_sound_min_interval_ms")]
885 pub min_interval_ms: u64,
886 /// Quiet mode: suppress all event sounds without editing the allow-list.
887 /// Default: `false`.
888 #[serde(default)]
889 pub quiet: bool,
890 }
891
892 impl Default for EventSoundConfig {
893 fn default() -> Self {
894 Self {
895 enabled: false,
896 events: default_event_sound_events(),
897 min_interval_ms: default_event_sound_min_interval_ms(),
898 quiet: false,
899 }
900 }
901 }
902
902 lines RUST