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.
This commit is contained in:
Eric Traut
2026-04-08 21:50:02 -07:00
committed by GitHub
Unverified
parent 2f9090be62
commit 6dc5391c7c
8 changed files with 191 additions and 48 deletions
+40 -9
View File
@@ -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`.
+29 -2
View File
@@ -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,
+55 -15
View File
@@ -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::<RootTomlTest>(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}"
);
}
+4 -14
View File
@@ -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<Vec<String>>,
/// 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),
+8 -2
View File
@@ -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
+1 -1
View File
@@ -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()
@@ -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(),
+48 -2
View File
@@ -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<CrosstermBackend<Stdout>>;
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<AtomicBool>,
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
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<str>) -> 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;
}