Honor system reduced-motion preferences in the TUI (#44666)

## Why

The TUI should respect the host's accessibility preference even when `tui.animations` is enabled.

## What changed

- Read the system motion preference once at launch on macOS, Windows, and Linux. Changes to that preference require restarting the TUI.
- Suppress animations when reduced motion is requested without changing saved configuration. Preserve configured behavior when detection is unavailable, and limit the Linux portal query to 250 ms.
- Use the effective animation setting for voice controls and transcripts, and honor system reduced motion in the startup composer.

## Testing

Add tests for animation preference resolution and preservation of saved settings, plus composer snapshots without sparkles at widths of 40 and 80 columns.

GitOrigin-RevId: 3ea4e721ae23220a040a06123a27286a8c8f1c7c
This commit is contained in:
Ian MacLeod
2026-09-10 22:55:04 +00:00
committed by copyberry
parent 84ed5744d9
commit 78600239a1
15 changed files with 240 additions and 21 deletions
+2
View File
@@ -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]]
+8
View File
@@ -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"
+2 -2
View File
@@ -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));
@@ -268,7 +268,7 @@ impl ChatWidget {
.map(|(_, speaker)| *speaker)
.collect(),
activity,
animations: self.config.animations,
animations: self.local_settings.tui.animations,
}));
}
}
@@ -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),
@@ -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(),
@@ -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
@@ -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()],
+2
View File
@@ -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<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
system_motion::initialize().await;
match startup_orchestration::run_main_inner(
cli,
arg0_paths,
+9 -1
View File
@@ -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,
+98
View File
@@ -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::<String>()
})
.collect::<Vec<_>>()
.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 [
@@ -0,0 +1,9 @@
---
source: tui/src/local_settings_tests.rs
assertion_line: 57
expression: rows
---
› Explore the night sky
100% context left
@@ -0,0 +1,9 @@
---
source: tui/src/local_settings_tests.rs
assertion_line: 57
expression: rows
---
› Explore the night sky
100% context left
+1 -1
View File
@@ -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(),
+83
View File
@@ -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<MotionMode> = 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<MotionMode> {
let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
Some(if workspace.accessibilityDisplayShouldReduceMotion() {
MotionMode::Reduced
} else {
MotionMode::Animated
})
}
#[cfg(windows)]
async fn detect() -> Option<MotionMode> {
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<MotionMode> {
// 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<MotionMode> {
None
}