From 6dc5391c7c38a903139aa2bb8e8e3a5e588f2b39 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 8 Apr 2026 21:50:02 -0700 Subject: [PATCH] Add TUI notification condition config (#17175) Problem: TUI desktop notifications are hard-gated on terminal focus, so terminal/IDE hosts that want in-focus notifications cannot opt in. Solution: Add a flat `[tui] notification_condition` setting (`unfocused` by default, `always` opt-in), carry grouped TUI notification settings through runtime config, apply method + condition together in the TUI, and regenerate the config schema. --- codex-rs/config/src/types.rs | 49 ++++++++++--- codex-rs/core/config.schema.json | 31 +++++++- codex-rs/core/src/config/config_tests.rs | 70 +++++++++++++++---- codex-rs/core/src/config/mod.rs | 18 ++--- codex-rs/tui/src/app.rs | 10 ++- codex-rs/tui/src/chatwidget.rs | 2 +- .../tui/src/chatwidget/tests/plan_mode.rs | 9 ++- codex-rs/tui/src/tui.rs | 50 ++++++++++++- 8 files changed, 191 insertions(+), 48 deletions(-) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index f26d33601..cee727169 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -472,6 +472,44 @@ impl fmt::Display for NotificationMethod { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationCondition { + /// Emit TUI notifications only while the terminal is unfocused. + #[default] + Unfocused, + /// Emit TUI notifications regardless of terminal focus. + Always, +} + +impl fmt::Display for NotificationCondition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationCondition::Unfocused => write!(f, "unfocused"), + NotificationCondition::Always => write!(f, "always"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiNotificationSettings { + /// Enable desktop notifications from the TUI. + /// Defaults to `true`. + #[serde(default, rename = "notifications")] + pub notifications: Notifications, + + /// Notification method to use for terminal notifications. + /// Defaults to `auto`. + #[serde(default, rename = "notification_method")] + pub method: NotificationMethod, + + /// Controls whether TUI notifications are delivered only when the terminal is unfocused or + /// regardless of focus. Defaults to `unfocused`. + #[serde(default, rename = "notification_condition")] + pub condition: NotificationCondition, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ModelAvailabilityNuxConfig { @@ -484,15 +522,8 @@ pub struct ModelAvailabilityNuxConfig { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct Tui { - /// Enable desktop notifications from the TUI when the terminal is unfocused. - /// Defaults to `true`. - #[serde(default)] - pub notifications: Notifications, - - /// Notification method to use for unfocused terminal notifications. - /// Defaults to `auto`. - #[serde(default)] - pub notification_method: NotificationMethod, + #[serde(default, flatten)] + pub notification_settings: TuiNotificationSettings, /// Enable animations (welcome screen, shimmer effects, spinners). /// Defaults to `true`. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ce43d3cec..bcc5193b2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1108,6 +1108,24 @@ }, "type": "object" }, + "NotificationCondition": { + "oneOf": [ + { + "description": "Emit TUI notifications only while the terminal is unfocused.", + "enum": [ + "unfocused" + ], + "type": "string" + }, + { + "description": "Emit TUI notifications regardless of terminal focus.", + "enum": [ + "always" + ], + "type": "string" + } + ] + }, "NotificationMethod": { "enum": [ "auto", @@ -1812,6 +1830,15 @@ "default": {}, "description": "Startup tooltip availability NUX state persisted by the TUI." }, + "notification_condition": { + "allOf": [ + { + "$ref": "#/definitions/NotificationCondition" + } + ], + "default": "unfocused", + "description": "Controls whether TUI notifications are delivered only when the terminal is unfocused or regardless of focus. Defaults to `unfocused`." + }, "notification_method": { "allOf": [ { @@ -1819,7 +1846,7 @@ } ], "default": "auto", - "description": "Notification method to use for unfocused terminal notifications. Defaults to `auto`." + "description": "Notification method to use for terminal notifications. Defaults to `auto`." }, "notifications": { "allOf": [ @@ -1828,7 +1855,7 @@ } ], "default": true, - "description": "Enable desktop notifications from the TUI when the terminal is unfocused. Defaults to `true`." + "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, "show_tooltips": { "default": true, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 5bc34b457..478f8182f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -34,12 +34,14 @@ use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; +use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; +use codex_config::types::TuiNotificationSettings; use codex_features::Feature; use codex_features::FeaturesToml; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; @@ -355,8 +357,7 @@ fn config_toml_deserializes_model_availability_nux() { assert_eq!( cfg.tui.expect("tui config should deserialize"), Tui { - notifications: Notifications::default(), - notification_method: NotificationMethod::default(), + notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, alternate_screen: AltScreenMode::default(), @@ -1052,8 +1053,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { assert_eq!( tui, Tui { - notifications: Notifications::Enabled(true), - notification_method: NotificationMethod::Auto, + notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, alternate_screen: AltScreenMode::Auto, @@ -4583,7 +4583,6 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -4730,7 +4729,6 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -4875,7 +4873,6 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -5006,7 +5003,6 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -6559,10 +6555,8 @@ speaker = "Desk Speakers" #[derive(Deserialize, Debug, PartialEq)] struct TuiTomlTest { - #[serde(default)] - notifications: Notifications, - #[serde(default)] - notification_method: NotificationMethod, + #[serde(default, flatten)] + notifications: TuiNotificationSettings, } #[derive(Deserialize, Debug, PartialEq)] @@ -6577,7 +6571,10 @@ fn test_tui_notifications_true() { notifications = true "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); - assert_matches!(parsed.tui.notifications, Notifications::Enabled(true)); + assert_matches!( + parsed.tui.notifications.notifications, + Notifications::Enabled(true) + ); } #[test] @@ -6588,7 +6585,7 @@ fn test_tui_notifications_custom_array() { "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); assert_matches!( - parsed.tui.notifications, + parsed.tui.notifications.notifications, Notifications::Custom(ref v) if v == &vec!["foo".to_string()] ); } @@ -6601,5 +6598,48 @@ fn test_tui_notification_method() { "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notification_method=\"bel\""); - assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); + assert_eq!(parsed.tui.notifications.method, NotificationMethod::Bel); +} + +#[test] +fn test_tui_notification_condition_defaults_to_unfocused() { + let toml = r#" + [tui] + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize default notification condition"); + assert_eq!( + parsed.tui.notifications.condition, + NotificationCondition::Unfocused + ); +} + +#[test] +fn test_tui_notification_condition_always() { + let toml = r#" + [tui] + notification_condition = "always" + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notification_condition=\"always\""); + assert_eq!( + parsed.tui.notifications.condition, + NotificationCondition::Always + ); +} + +#[test] +fn test_tui_notification_condition_rejects_unknown_value() { + let toml = r#" + [tui] + notification_condition = "background" + "#; + let err = toml::from_str::(toml).expect_err("reject unknown condition"); + let err = err.to_string(); + assert!( + err.contains("unknown variant `background`") + && err.contains("unfocused") + && err.contains("always"), + "unexpected error: {err}" + ); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 904862eaf..ebb98e48a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -38,8 +38,6 @@ use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; -use codex_config::types::NotificationMethod; -use codex_config::types::Notifications; use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; @@ -47,6 +45,7 @@ use codex_config::types::OtelExporterKind; use codex_config::types::ShellEnvironmentPolicy; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; +use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; @@ -301,12 +300,8 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, - /// TUI notifications preference. When set, the TUI will send terminal notifications on - /// approvals and turn completions when not focused. - pub tui_notifications: Notifications, - - /// Notification method for terminal notifications (osc9 or bel). - pub tui_notification_method: NotificationMethod, + /// TUI notification settings, including enabled events, delivery method, and focus condition. + pub tui_notifications: TuiNotificationSettings, /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, @@ -2136,12 +2131,7 @@ impl Config { tui_notifications: cfg .tui .as_ref() - .map(|t| t.notifications.clone()) - .unwrap_or_default(), - tui_notification_method: cfg - .tui - .as_ref() - .map(|t| t.notification_method) + .map(|t| t.notification_settings.clone()) .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6a8a32316..3ca5edb00 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3604,7 +3604,10 @@ impl App { let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); emit_system_bwrap_warning(&app_event_tx, &config); - tui.set_notification_method(config.tui_notification_method); + tui.set_notification_settings( + config.tui_notifications.method, + config.tui_notifications.condition, + ); let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; @@ -4145,7 +4148,10 @@ impl App { Ok(resumed) => { self.shutdown_current_thread(app_server).await; self.config = resume_config; - tui.set_notification_method(self.config.tui_notification_method); + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 07c8f0b75..fd87c58f6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7224,7 +7224,7 @@ impl ChatWidget { } fn notify(&mut self, notification: Notification) { - if !notification.allowed_for(&self.config.tui_notifications) { + if !notification.allowed_for(&self.config.tui_notifications.notifications) { return; } if let Some(existing) = self.pending_notification.as_ref() diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 29b1b8c59..660bb8fa9 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -287,7 +287,8 @@ fn user_input_requested_notification_uses_dedicated_type_name() { #[tokio::test] async fn open_plan_implementation_prompt_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.config.tui_notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); + chat.config.tui_notifications.notifications = + Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.open_plan_implementation_prompt(); @@ -300,7 +301,8 @@ async fn open_plan_implementation_prompt_sets_pending_notification() { #[tokio::test] async fn open_plan_reasoning_scope_prompt_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.config.tui_notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); + chat.config.tui_notifications.notifications = + Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.open_plan_reasoning_scope_prompt( "gpt-5.1-codex-max".to_string(), @@ -363,7 +365,8 @@ async fn user_input_notification_overrides_pending_agent_turn_complete_notificat #[tokio::test] async fn handle_request_user_input_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.config.tui_notifications = Notifications::Custom(vec!["user-input-requested".to_string()]); + chat.config.tui_notifications.notifications = + Notifications::Custom(vec!["user-input-requested".to_string()]); chat.handle_request_user_input_now(RequestUserInputEvent { call_id: "call-1".to_string(), diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 9fc67d0a5..5cff8521a 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -46,6 +46,7 @@ use crate::tui::event_stream::EventBroker; use crate::tui::event_stream::TuiEventStream; #[cfg(unix)] use crate::tui::job_control::SuspendContext; +use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; mod event_stream; @@ -60,6 +61,43 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool { + match condition { + NotificationCondition::Unfocused => !terminal_focused, + NotificationCondition::Always => true, + } +} + +#[cfg(test)] +mod tests { + use super::should_emit_notification; + use codex_config::types::NotificationCondition; + + #[test] + fn unfocused_notification_condition_is_suppressed_when_focused() { + assert!(!should_emit_notification( + NotificationCondition::Unfocused, + /*terminal_focused*/ true + )); + } + + #[test] + fn always_notification_condition_emits_when_focused() { + assert!(should_emit_notification( + NotificationCondition::Always, + /*terminal_focused*/ true + )); + } + + #[test] + fn unfocused_notification_condition_emits_when_unfocused() { + assert!(should_emit_notification( + NotificationCondition::Unfocused, + /*terminal_focused*/ false + )); + } +} + pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; @@ -254,6 +292,7 @@ pub struct Tui { terminal_focused: Arc, enhanced_keys_supported: bool, notification_backend: Option, + notification_condition: NotificationCondition, is_zellij: bool, // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) alt_screen_enabled: bool, @@ -288,6 +327,7 @@ impl Tui { terminal_focused: Arc::new(AtomicBool::new(true)), enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), + notification_condition: NotificationCondition::default(), is_zellij, alt_screen_enabled: true, } @@ -298,8 +338,13 @@ impl Tui { self.alt_screen_enabled = enabled; } - pub fn set_notification_method(&mut self, method: NotificationMethod) { + pub fn set_notification_settings( + &mut self, + method: NotificationMethod, + condition: NotificationCondition, + ) { self.notification_backend = Some(detect_backend(method)); + self.notification_condition = condition; } pub fn frame_requester(&self) -> FrameRequester { @@ -367,7 +412,8 @@ impl Tui { /// Emit a desktop notification now if the terminal is unfocused. /// Returns true if a notification was posted. pub fn notify(&mut self, message: impl AsRef) -> bool { - if self.terminal_focused.load(Ordering::Relaxed) { + let terminal_focused = self.terminal_focused.load(Ordering::Relaxed); + if !should_emit_notification(self.notification_condition, terminal_focused) { return false; }