mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
2f9090be62
commit
6dc5391c7c
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user