Enforce animations = false for screen readers (#20564)

## Why

Issue #20489 calls out that animated TUI affordances can be noisy for
screen-reader users. Codex already has `tui.animations = false` as a
reduced-motion setting, but some live activity rows render spinner-style
prefixes in that mode. These were relatively recent regressions.

We have also regressed this pattern more than once by adding new
spinner/shimmer callsites that do not think through the reduced-motion
path, so this PR adds a small guardrail while fixing the current
surfaces.

## What changed

- Omit the live status-row spinner when animations are disabled, so the
row starts with stable text like `Working (...)`.
- Render running hook headers without the spinner prefix when animations
are disabled, while preserving shimmer/spinner behavior when animations
are enabled.
- Centralize TUI activity indicators in `tui/src/motion.rs`, with
explicit reduced-motion choices for hidden prefixes, static bullets, and
plain shimmer-text fallbacks.
- Route existing spinner/shimmer callsites through the central motion
helper, including exec rows, MCP/web-search/loading rows, hook rows,
plugin loading, and onboarding loading text.
- Add a source-scan regression test that rejects direct `spinner(...)`
or `shimmer_spans(...)` usage outside the central module and primitive
definition.
- Add focused coverage that reduced-motion active exec rows are stable,
status rows start without a spinner, running hooks omit the spinner, and
MCP inventory loading stays stable.
- Update the one affected status-indicator snapshot; the existing detail
tree prefix remains unchanged.

## Verification

- `cargo test -p codex-tui`
This commit is contained in:
Eric Traut
2026-05-01 09:07:56 -07:00
committed by GitHub
Unverified
parent f476338f93
commit 227bee0445
11 changed files with 354 additions and 45 deletions
+6 -2
View File
@@ -16,10 +16,11 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::history_cell;
use crate::key_hint;
use crate::legacy_core::config::Config;
use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use crate::onboarding::mark_url_hyperlink;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
@@ -100,7 +101,10 @@ impl Renderable for DelayedLoadingHeader {
} else if self.animations_enabled {
self.frame_requester
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
lines.push(Line::from(shimmer_spans(self.loading_text.as_str())));
lines.push(Line::from(shimmer_text(
self.loading_text.as_str(),
MotionMode::Animated,
)));
} else {
lines.push(Line::from(self.loading_text.as_str().dim()));
}
-1
View File
@@ -9,4 +9,3 @@ pub(crate) use render::OutputLinesParams;
pub(crate) use render::TOOL_CALL_MAX_LINES;
pub(crate) use render::new_active_exec_command;
pub(crate) use render::output_lines;
pub(crate) use render::spinner;
+41 -17
View File
@@ -5,10 +5,12 @@ use super::model::ExecCall;
use super::model::ExecCell;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::shimmer::shimmer_spans;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
@@ -180,20 +182,13 @@ pub(crate) fn output_lines(
}
}
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
if !animations_enabled {
return "".dim();
}
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false)
{
shimmer_spans("")[0].clone()
} else {
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
if blink_on { "".into() } else { "".dim() }
}
fn activity_marker(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
activity_indicator(
start_time,
MotionMode::from_animations_enabled(animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim())
}
impl HistoryCell for ExecCell {
@@ -263,7 +258,7 @@ impl ExecCell {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time(), self.animations_enabled())
activity_marker(self.active_start_time(), self.animations_enabled())
} else {
"".dim()
},
@@ -371,7 +366,7 @@ impl ExecCell {
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time, self.animations_enabled()),
None => activity_marker(call.start_time, self.animations_enabled()),
};
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {
@@ -957,6 +952,35 @@ mod tests {
);
}
#[test]
fn active_command_without_animations_is_stable() {
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let first: Vec<String> = cell
.command_display_lines(/*width*/ 80)
.iter()
.map(render_line_text)
.collect();
let second: Vec<String> = cell
.command_display_lines(/*width*/ 80)
.iter()
.map(render_line_text)
.collect();
assert_eq!(first, second);
assert_eq!(first, vec!["• Running echo done".to_string()]);
}
#[test]
fn exploring_display_does_not_split_long_url_like_search_query() {
let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path";
+31 -4
View File
@@ -17,12 +17,14 @@ use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::legacy_core::config::Config;
use crate::live_wrap::take_prefix_by_width;
use crate::markdown::append_markdown;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
@@ -1668,7 +1670,12 @@ impl HistoryCell for McpToolCallCell {
let bullet = match status {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(Some(self.start_time), self.animations_enabled),
None => activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim()),
};
let header_text = if status.is_some() {
"Called"
@@ -1858,7 +1865,12 @@ impl HistoryCell for WebSearchCell {
let bullet = if self.completed {
"".dim()
} else {
spinner(Some(self.start_time), self.animations_enabled)
activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim())
};
let header = web_search_header(self.completed);
let detail = web_search_detail(self.action.as_ref(), &self.query);
@@ -2468,7 +2480,12 @@ impl HistoryCell for McpInventoryLoadingCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![
vec![
spinner(Some(self.start_time), self.animations_enabled),
activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim()),
" ".into(),
"Loading MCP inventory".bold(),
"".dim(),
@@ -3966,6 +3983,16 @@ mod tests {
insta::assert_snapshot!(rendered);
}
#[test]
fn mcp_inventory_loading_without_animations_is_stable() {
let cell = new_mcp_inventory_loading(/*animations_enabled*/ false);
let first = render_lines(&cell.display_lines(/*width*/ 80));
let second = render_lines(&cell.display_lines(/*width*/ 80));
assert_eq!(first, second);
assert_eq!(first, vec!["• Loading MCP inventory…".to_string()]);
}
#[test]
fn completed_mcp_tool_call_success_snapshot() {
let invocation = McpInvocation {
+41 -7
View File
@@ -11,9 +11,11 @@
//! first drawn.
//! 4. Completed runs only persist when they have output or a non-success status.
use super::HistoryCell;
use crate::exec_cell::spinner;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind;
@@ -626,11 +628,17 @@ fn push_running_hook_header(
status_message: Option<&str>,
animations_enabled: bool,
) {
let mut header = vec![spinner(start_time, animations_enabled), " ".into()];
if animations_enabled {
header.extend(shimmer_spans(hook_text));
} else {
header.push(hook_text.to_string().bold());
let mut header = Vec::new();
let motion_mode = MotionMode::from_animations_enabled(animations_enabled);
if let Some(indicator) =
activity_indicator(start_time, motion_mode, ReducedMotionIndicator::Hidden)
{
header.push(indicator);
header.push(" ".into());
}
header.extend(shimmer_text(hook_text, motion_mode));
if !animations_enabled && let Some(span) = header.last_mut() {
span.style = span.style.patch(Style::default().bold());
}
if let Some(status_message) = status_message
&& !status_message.is_empty()
@@ -761,6 +769,32 @@ mod tests {
assert_eq!(cell.transcript_animation_tick(), None);
}
#[test]
fn visible_hook_without_animations_omits_spinner() {
let mut cell = HookCell::new_active(
hook_run_summary("hook-1"),
/*animations_enabled*/ false,
);
cell.reveal_running_runs_now_for_test();
cell.advance_time(Instant::now());
let rendered: Vec<String> = cell
.display_lines(/*width*/ 80)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect();
assert_eq!(
rendered,
vec!["Running PostToolUse hook: checking output policy".to_string()]
);
}
fn hook_run_summary(id: &str) -> HookRunSummary {
HookRunSummary {
id: id.to_string(),
+1
View File
@@ -140,6 +140,7 @@ mod markdown_stream;
mod mention_codec;
mod model_catalog;
mod model_migration;
mod motion;
mod multi_agents;
mod notifications;
#[cfg(any(not(debug_assertions), test))]
+184
View File
@@ -0,0 +1,184 @@
//! Centralized motion primitives for the TUI.
//!
//! Callers choose an explicit reduced-motion fallback here instead of reaching
//! directly for time-varying spinner or shimmer helpers.
use std::time::Instant;
use ratatui::style::Stylize;
use ratatui::text::Span;
use crate::shimmer::shimmer_spans;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MotionMode {
Animated,
Reduced,
}
impl MotionMode {
pub(crate) fn from_animations_enabled(animations_enabled: bool) -> Self {
if animations_enabled {
Self::Animated
} else {
Self::Reduced
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReducedMotionIndicator {
Hidden,
StaticBullet,
}
pub(crate) fn activity_indicator(
start_time: Option<Instant>,
motion_mode: MotionMode,
reduced_motion_indicator: ReducedMotionIndicator,
) -> Option<Span<'static>> {
match motion_mode {
MotionMode::Animated => Some(animated_activity_indicator(start_time)),
MotionMode::Reduced => match reduced_motion_indicator {
ReducedMotionIndicator::Hidden => None,
ReducedMotionIndicator::StaticBullet => Some("".dim()),
},
}
}
pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec<Span<'static>> {
match motion_mode {
MotionMode::Animated => shimmer_spans(text),
MotionMode::Reduced => {
if text.is_empty() {
Vec::new()
} else {
vec![text.to_string().into()]
}
}
}
}
fn animated_activity_indicator(start_time: Option<Instant>) -> Span<'static> {
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false)
{
shimmer_spans("")
.into_iter()
.next()
.unwrap_or_else(|| "".into())
} else {
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
if blink_on { "".into() } else { "".dim() }
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn reduced_motion_activity_indicator_uses_explicit_fallback() {
assert_eq!(
activity_indicator(
/*start_time*/ None,
MotionMode::Reduced,
ReducedMotionIndicator::Hidden,
),
None
);
assert_eq!(
activity_indicator(
/*start_time*/ None,
MotionMode::Reduced,
ReducedMotionIndicator::StaticBullet,
),
Some("".dim())
);
}
#[test]
fn reduced_motion_shimmer_text_is_plain_text() {
assert_eq!(
shimmer_text("Loading", MotionMode::Reduced),
vec!["Loading".into()]
);
assert_eq!(
shimmer_text("", MotionMode::Reduced),
Vec::<Span<'static>>::new()
);
}
#[test]
fn animation_primitives_are_only_used_by_motion_module() {
let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap();
let direct_shimmer =
regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])shimmer_spans\s*\(").unwrap();
let lib_rs = codex_utils_cargo_bin::find_resource!("src/lib.rs")
.expect("failed to locate TUI source");
let src_dir = lib_rs.parent().expect("lib.rs should have a parent");
let mut source_files = Vec::new();
collect_rust_files(src_dir, &mut source_files).expect("failed to collect TUI source files");
let mut violations = Vec::new();
for path in source_files {
let relative_path = path
.strip_prefix(src_dir)
.expect("source file should be under src")
.to_string_lossy()
.replace('\\', "/");
if animation_primitive_allowlisted_path(&relative_path) {
continue;
}
let contents = fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {relative_path}: {err}"));
for (line_number, line) in contents.lines().enumerate() {
let code = line.split_once("//").map_or(line, |(code, _)| code);
if direct_spinner.is_match(code) {
violations.push(format!(
"{relative_path}:{} contains a direct `spinner(...)` call; use crate::motion instead",
line_number + 1
));
}
if direct_shimmer.is_match(code) {
violations.push(format!(
"{relative_path}:{} contains a direct `shimmer_spans(...)` call; use crate::motion instead",
line_number + 1
));
}
}
}
assert!(
violations.is_empty(),
"direct animation primitive usage found:\n{}",
violations.join("\n")
);
}
fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_rust_files(&path, files)?;
} else if path.extension().is_some_and(|ext| ext == "rs") {
files.push(path);
}
}
Ok(())
}
fn animation_primitive_allowlisted_path(relative_path: &str) -> bool {
matches!(relative_path, "motion.rs" | "shimmer.rs")
}
}
+6 -2
View File
@@ -46,10 +46,11 @@ use uuid::Uuid;
use crate::LoginStatus;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink.
@@ -511,7 +512,10 @@ impl AuthModeWidget {
// Schedule a follow-up frame to keep the shimmer animation going.
self.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans("Finish signing in via your browser"));
spans.extend(shimmer_text(
"Finish signing in via your browser",
MotionMode::Animated,
));
} else {
spans.push("Finish signing in via your browser".into());
}
@@ -10,7 +10,8 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use uuid::Uuid;
use crate::shimmer::shimmer_spans;
use crate::motion::MotionMode;
use crate::motion::shimmer_text;
use super::AuthModeWidget;
use super::ContinueWithDeviceCodeState;
@@ -98,7 +99,7 @@ pub(super) fn render_device_code_login(
widget
.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans(banner));
spans.extend(shimmer_text(banner, MotionMode::Animated));
} else {
spans.push(banner.into());
}
@@ -2,6 +2,6 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
"Working (0s) "
"Working (0s) "
" └ A man a plan a canal "
" panama "
+40 -9
View File
@@ -19,11 +19,13 @@ use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event_sender::AppEventSender;
use crate::exec_cell::spinner;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
@@ -240,16 +242,21 @@ impl Renderable for StatusIndicatorWidget {
let now = Instant::now();
let elapsed_duration = self.elapsed_duration_at(now);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
let mut spans = Vec::with_capacity(5);
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
if let Some(indicator) = activity_indicator(
Some(self.last_resume_at),
motion_mode,
ReducedMotionIndicator::Hidden,
) {
spans.push(indicator);
spans.push(" ".into());
}
spans.extend(shimmer_text(&self.header, motion_mode));
if !spans.is_empty() {
spans.push(" ".into());
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed}").dim(),
@@ -374,6 +381,30 @@ mod tests {
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn renders_without_spinner_when_animations_disabled() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
/*animations_enabled*/ false,
);
w.is_paused = true;
w.elapsed_running = Duration::ZERO;
let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal");
terminal
.draw(|f| w.render(f.area(), f.buffer_mut()))
.expect("draw");
let line = terminal.backend().buffer().content()[..80]
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(line.starts_with("Working (0s • esc to interrupt)"));
}
#[test]
fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();