diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e62e1713b6..39c1d3accb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4768,6 +4768,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "libc", + "objc2-app-kit", "pathdiff", "pretty_assertions", "pulldown-cmark", @@ -4808,6 +4809,7 @@ dependencies = [ "windows-sys 0.52.0", "winsplit", "wiremock", + "zbus", ] [[package]] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f72429a992..a61f170beb 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -135,6 +135,13 @@ tokio-util = { workspace = true, features = ["time"] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSWorkspace"] } + +[target.'cfg(target_os = "linux")'.dependencies] +# Keep the same executor features as the credential store, which also uses zbus. +zbus = "4.4.0" + [target.'cfg(windows)'.dependencies] which = { workspace = true } windows-sys = { version = "0.52", features = [ @@ -145,6 +152,7 @@ windows-sys = { version = "0.52", features = [ "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", ] } winsplit = "0.1" diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 80b8e93f92..186c289596 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -1193,7 +1193,7 @@ impl ChatWidget { .wrapping_add(/*rhs*/ 1); self.realtime_conversation.transcript_input_generation = Some(self.realtime_conversation.input_generation); - if interrupted && self.config.animations { + if interrupted && self.local_settings.tui.animations { self.realtime_conversation.interruption_acknowledged_until = Some(Instant::now() + INTERRUPTION_ACKNOWLEDGMENT); } @@ -1273,7 +1273,7 @@ impl ChatWidget { &self.realtime_conversation.transcript, previous, discarded_prefix_bytes, - MotionMode::from_animations_enabled(self.config.animations), + MotionMode::from_animations_enabled(self.local_settings.tui.animations), self.frame_requester.clone(), ); self.realtime_conversation.live_transcript_cell = Some(Box::new(live_cell)); diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs index 31aa3b5a40..e42c7525d5 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs @@ -268,7 +268,7 @@ impl ChatWidget { .map(|(_, speaker)| *speaker) .collect(), activity, - animations: self.config.animations, + animations: self.local_settings.tui.animations, })); } } diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs index 21e6ca942b..5096465de2 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls_tests.rs @@ -120,7 +120,7 @@ async fn voice_composer_preserves_normal_colors_across_microphone_states() { assert_eq!(marker.fg, Color::Red); assert_ne!(marker.bg, Color::Red); } - if recording && chat.config.animations { + if recording && chat.local_settings.tui.animations { let rows = buffer .content .chunks(/*chunk_size*/ 47) @@ -159,7 +159,7 @@ async fn voice_composer_preserves_normal_colors_across_microphone_states() { .unwrap() }; let foreground = check(&mut chat, /*recording*/ true); - chat.config.animations = false; + chat.local_settings.tui.animations = false; assert_eq!(check(&mut chat, /*recording*/ true), foreground); chat.realtime_conversation.microphone_muted = true; assert_eq!(check(&mut chat, /*recording*/ false), foreground); @@ -188,7 +188,7 @@ async fn voice_preserves_the_normal_composer_prompt() { } for (muted, animations) in [(true, true), (false, false), (false, true)] { chat.realtime_conversation.microphone_muted = muted; - chat.config.animations = animations; + chat.local_settings.tui.animations = animations; assert_eq!(prompt(&mut chat), Some('›')); } chat.thread_id = Some(ThreadId::new()); @@ -356,7 +356,7 @@ async fn voice_footer_renders_the_main_conversation_states() { "Hello there", ), ] { - chat.config.animations = label != "connecting"; + chat.local_settings.tui.animations = label != "connecting"; chat.realtime_conversation.phase = phase; chat.realtime_conversation.microphone_muted = muted; chat.realtime_conversation.microphone_level = level; @@ -402,7 +402,7 @@ async fn voice_footer_renders_the_main_conversation_states() { #[tokio::test] async fn narrow_voice_footer_keeps_the_stop_control_before_meters() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = true; + chat.local_settings.tui.animations = true; activate_voice(&mut chat); chat.realtime_conversation.speaker_active_until = Some(std::time::Instant::now() + super::super::SPEAKER_ACTIVITY_HOLD); @@ -474,7 +474,7 @@ async fn narrow_voice_footer_keeps_the_stop_control_before_meters() { #[tokio::test] async fn voice_acknowledges_only_a_real_interruption_once() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = true; + chat.local_settings.tui.animations = true; activate_voice(&mut chat); chat.on_realtime_transcript_delta("user".to_string(), "hello".to_string()); @@ -503,7 +503,7 @@ async fn voice_acknowledges_only_a_real_interruption_once() { .is_none() ); - chat.config.animations = false; + chat.local_settings.tui.animations = false; chat.realtime_conversation.speaker_level = 3; chat.on_realtime_transcript_delta("user".to_string(), "stop".to_string()); assert!( @@ -619,7 +619,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() { use ratatui::prelude::Rect; let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; activate_voice(&mut chat); chat.bottom_pane .set_composer_text("typed".to_string(), Vec::new(), Vec::new()); @@ -666,7 +666,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() { #[tokio::test] async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_muted() { let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; let thread_id = activate_voice(&mut chat); chat.realtime_conversation.audio_meter_history = [ (0, 255), diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs index c1956f016a..91a58133a7 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs @@ -327,7 +327,7 @@ async fn captioned_voice_answer_does_not_duplicate_on_close() { #[tokio::test] async fn restored_partial_caption_accepts_late_completion_without_duplicate_history() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; chat.restore_realtime_transcript_cells(VecDeque::from([ super::super::RealtimeTranscriptRecord { role: "user".into(), diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs b/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs index 14cc273430..7388099886 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs @@ -194,7 +194,7 @@ async fn transcript_deltas_track_the_current_speaker() { #[tokio::test] async fn voice_transcripts_stream_in_the_conversation_instead_of_the_footer() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; activate_voice(&mut chat); chat.update_realtime_footer(); @@ -427,7 +427,7 @@ async fn direct_reset_preserves_both_partial_speakers_for_replay() { async fn stopping_voice_preserves_the_live_transcript_once() { for command in ["/voice", "/voice stop"] { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = true; + chat.local_settings.tui.animations = true; activate_voice(&mut chat); chat.on_realtime_transcript_done("user".to_string(), "Earlier question".to_string()); chat.on_realtime_transcript_done("assistant".to_string(), "Earlier answer".to_string()); @@ -526,7 +526,7 @@ async fn transcript_completion_waits_for_normal_agent_stream_consolidation() { #[tokio::test] async fn transcript_handoff_moves_deferred_repeats_and_partial_once() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; activate_voice(&mut chat); chat.on_agent_message_delta("ordinary stream".to_string()); for _ in 0..super::super::MAX_PENDING_TRANSCRIPT_CELLS { @@ -699,7 +699,7 @@ async fn completed_transcript_preserves_the_other_speakers_caption() { #[tokio::test] async fn live_voice_split_flap_animates_without_changing_final_history() { let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; - chat.config.animations = true; + chat.local_settings.tui.animations = true; activate_voice(&mut chat); chat.on_realtime_transcript_delta("assistant".to_string(), "gate 73".to_string()); @@ -749,7 +749,7 @@ async fn spoken_user_transcript_preserves_red_chevron_and_canonical_history() { .add_modifier .contains(ratatui::style::Modifier::BOLD) ); - chat.config.animations = false; + chat.local_settings.tui.animations = false; chat.on_realtime_transcript_delta("user".to_string(), " world".to_string()); assert!( chat.realtime_conversation diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 0569ae223b..9b76570dfc 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -14,7 +14,7 @@ use serial_test::serial; #[tokio::test] async fn voice_live_transcript_renders_beside_the_streamed_cell() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; activate_voice_for_thread(&mut chat, ThreadId::new()); chat.transcript.active_cell = Some(Box::new(history_cell::StreamingAgentTailCell::new( vec![Line::from("Agent answer arriving").into()], diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 43591eceed..bc0de7bda4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -204,6 +204,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod system_motion; mod task_mentions; mod temporary_structured_request; mod terminal_hyperlinks; @@ -1011,6 +1012,7 @@ pub async fn run_main( loader_overrides: LoaderOverrides, explicit_remote_endpoint: Option, ) -> std::io::Result { + system_motion::initialize().await; match startup_orchestration::run_main_inner( cli, arg0_paths, diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index 6a588f4502..3850e0aa0c 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -3,6 +3,7 @@ //! The resolved core config is a temporary input at local load/reload boundaries. Server thread //! responses must never refresh these values; live preference changes belong here. The remaining //! Config-based lifecycle adapters also use this conversion until their interfaces are migrated. +//! Effective animations also respect the TUI host's launch-time accessibility preference. use crate::legacy_core::config::Config; use crate::legacy_core::config::TerminalResizeReflowConfig; @@ -23,10 +24,17 @@ pub(crate) struct LocalSettings { impl From<&Config> for LocalSettings { fn from(config: &Config) -> Self { + Self::with_system_motion(config, crate::system_motion::mode()) + } +} + +impl LocalSettings { + fn with_system_motion(config: &Config, system_motion: crate::motion::MotionMode) -> Self { Self { tui: Tui { notification_settings: config.tui_notifications.clone(), - animations: config.animations, + animations: config.animations + && system_motion == crate::motion::MotionMode::Animated, whimsy: config.tui_whimsy, show_tooltips: config.show_tooltips, show_server_version_notice: config.tui_show_server_version_notice, diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index c01838ee32..7ad211c0e0 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -5,6 +5,104 @@ use codex_config::LoaderOverrides; use codex_config::types::SessionPickerViewMode; use pretty_assertions::assert_eq; +#[tokio::test] +async fn system_reduced_motion_renders_astra_composer_without_sparkles() -> anyhow::Result<()> { + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::BottomPane; + use crate::bottom_pane::BottomPaneParams; + use crate::motion::MotionMode; + use crate::render::renderable::Renderable; + use crate::tui::FrameRequester; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let home = tempfile::tempdir()?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await?; + let settings = LocalSettings::with_system_motion(&config, MotionMode::Reduced); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: AppEventSender::new(tx), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".into(), + disable_paste_burst: true, + animations_enabled: settings.tui.animations, + skills: None, + }); + pane.set_astra_sparkle("astra", &settings.tui); + pane.set_composer_text("Explore the night sky".into(), Vec::new(), Vec::new()); + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + || { + for width in [40, 80] { + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + pane.render(area, &mut buffer); + let rows = buffer + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(format!("system_reduced_motion_astra_{width}"), rows); + } + }, + ); + Ok(()) +} + +#[tokio::test] +async fn system_motion_suppresses_animations_without_changing_saved_preferences() +-> anyhow::Result<()> { + use crate::motion::MotionMode; + + for configured in [true, false] { + let home = tempfile::tempdir()?; + let config_text = format!("[tui]\nanimations = {configured}\nwhimsy = true\n"); + std::fs::write(home.path().join("config.toml"), &config_text)?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await?; + let animated = LocalSettings::with_system_motion(&config, MotionMode::Animated); + let reduced = LocalSettings::with_system_motion(&config, MotionMode::Reduced); + let mut expected = animated.clone(); + expected.tui.animations = false; + assert_eq!(reduced, expected); + assert_eq!(animated.tui.animations, configured); + assert_eq!(config.animations, configured); + assert_eq!( + std::fs::read_to_string(home.path().join("config.toml"))?, + config_text + ); + } + Ok(()) +} + #[tokio::test] async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Result<()> { for config_text in [ diff --git a/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_40.snap b/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_40.snap new file mode 100644 index 0000000000..80e58128bb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_40.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/local_settings_tests.rs +assertion_line: 57 +expression: rows +--- + +› Explore the night sky + + 100% context left diff --git a/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_80.snap b/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_80.snap new file mode 100644 index 0000000000..910f92e827 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_80.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/local_settings_tests.rs +assertion_line: 57 +expression: rows +--- + +› Explore the night sky + + 100% context left diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 4c3dbe00cd..ee3cc2bc6d 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -514,7 +514,7 @@ fn startup_draft_bottom_pane( enhanced_keys_supported, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, - animations_enabled: true, + animations_enabled: crate::system_motion::mode() == crate::motion::MotionMode::Animated, skills: None, }, ChatComposerConfig::plain_text(), diff --git a/codex-rs/tui/src/system_motion.rs b/codex-rs/tui/src/system_motion.rs new file mode 100644 index 0000000000..a3c941a21e --- /dev/null +++ b/codex-rs/tui/src/system_motion.rs @@ -0,0 +1,83 @@ +//! Reads the TUI host's accessibility preference once at launch. +//! +//! Detection never changes persisted configuration or consults the app server. An unavailable +//! preference preserves configured behavior; an explicit request for less motion suppresses all +//! effects governed by `tui.animations`. Restart the TUI after changing the OS preference. + +use std::sync::OnceLock; + +use crate::motion::MotionMode; + +static SYSTEM_MOTION: OnceLock = OnceLock::new(); + +pub(crate) async fn initialize() { + let mode = detect().await.unwrap_or(MotionMode::Animated); + let _ = SYSTEM_MOTION.set(mode); +} + +pub(crate) fn mode() -> MotionMode { + SYSTEM_MOTION.get().copied().unwrap_or(MotionMode::Animated) +} + +#[cfg(target_os = "macos")] +async fn detect() -> Option { + let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace(); + Some(if workspace.accessibilityDisplayShouldReduceMotion() { + MotionMode::Reduced + } else { + MotionMode::Animated + }) +} + +#[cfg(windows)] +async fn detect() -> Option { + use windows_sys::Win32::UI::WindowsAndMessaging::SPI_GETCLIENTAREAANIMATION; + use windows_sys::Win32::UI::WindowsAndMessaging::SystemParametersInfoW; + + let mut enabled: windows_sys::Win32::Foundation::BOOL = 0; + // SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL to this valid, aligned pointer. + let success = unsafe { + SystemParametersInfoW( + SPI_GETCLIENTAREAANIMATION, + /*uiparam*/ 0, + (&mut enabled as *mut windows_sys::Win32::Foundation::BOOL).cast(), + /*fwinini*/ 0, + ) + }; + (success != 0).then(|| MotionMode::from_animations_enabled(enabled != 0)) +} + +#[cfg(target_os = "linux")] +async fn detect() -> Option { + // A missing or unresponsive session bus must not hold up terminal startup. + tokio::time::timeout(std::time::Duration::from_millis(/*millis*/ 250), async { + let connection = zbus::Connection::session().await.ok()?; + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + ) + .await + .ok()?; + let value: zbus::zvariant::OwnedValue = proxy + .call("ReadOne", &("org.freedesktop.appearance", "reduced-motion")) + .await + .ok()?; + let value = u32::try_from(value).ok()?; + // The portal specifies that unknown values mean no preference. + Some(if value == 1 { + MotionMode::Reduced + } else { + MotionMode::Animated + }) + }) + .await + .ok() + .flatten() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +async fn detect() -> Option { + None +}