From bba48e5ce8bbcaee4af0bb2c71ad2936e4e24ea9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 20 Sep 2026 16:07:52 +0000 Subject: [PATCH] Add independent controls for TUI visual effects (#46832) ## What changed Replace `tui.whimsy` with `[tui.effects]` preferences for `starfield`, `shimmer`, `welcome`, `effort`, `progress`, and `title`. Each defaults to `true` and remains subordinate to the `tui.animations` master switch. Wire the preferences into composer effects, welcome artwork, status and loading text, progress indicators, and terminal-title animations. For example, disable shimmering text while keeping progress animations: ```toml [tui.effects] shimmer = false ``` Keep retired `tui.whimsy` entries loadable but ignore their value. Update the configuration schema to expose the new preferences. ## Testing Add and extend coverage for independent shimmer and progress effects, master-switch precedence, welcome and starfield controls, terminal-title blinking, and strict configuration loading with the retired setting. GitOrigin-RevId: 28b37ba52872133b8954543cabe9ff424147f1c5 --- codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/tui_effects.rs | 37 ++++++++++++ codex-rs/config/src/types.rs | 8 +-- codex-rs/core/config.schema.json | 58 +++++++++++++++++-- codex-rs/core/src/config/config_tests.rs | 4 +- codex-rs/core/src/config/mod.rs | 6 +- codex-rs/thread-manager-sample/src/main.rs | 2 +- .../src/app/empty_state_animation_tests.rs | 8 +++ codex-rs/tui/src/app/owned_transcript.rs | 11 +++- .../tests/background_task_defaults_tests.rs | 2 +- .../tui/src/app/tests/fresh_sparkle_tests.rs | 20 +++---- codex-rs/tui/src/app/tests/startup.rs | 1 + codex-rs/tui/src/app_backtrack.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- .../chat_composer/snapshot_tests.rs | 1 + .../src/bottom_pane/chat_composer/sparkle.rs | 4 +- .../chat_composer/sparkle_tests.rs | 8 ++- codex-rs/tui/src/bottom_pane/mod.rs | 32 +++++++++- codex-rs/tui/src/bottom_pane/voice_strip.rs | 4 +- .../tui/src/bottom_pane/voice_strip_tests.rs | 1 + codex-rs/tui/src/chatwidget.rs | 2 +- .../tui/src/chatwidget/command_lifecycle.rs | 6 +- codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/hook_lifecycle.rs | 1 + codex-rs/tui/src/chatwidget/plugin_catalog.rs | 8 +-- .../chatwidget/realtime/recording_controls.rs | 1 + codex-rs/tui/src/chatwidget/recap.rs | 2 +- .../tui/src/chatwidget/status_surfaces.rs | 11 +++- .../tests/sparkle_submission_tests.rs | 2 +- .../src/chatwidget/tests/terminal_title.rs | 12 ++++ .../tui/src/chatwidget/thread_title_status.rs | 12 ++-- codex-rs/tui/src/chatwidget/tool_lifecycle.rs | 10 ++-- codex-rs/tui/src/keymap_setup.rs | 1 + codex-rs/tui/src/local_settings.rs | 2 +- codex-rs/tui/src/local_settings_tests.rs | 10 +++- .../tui/src/onboarding/onboarding_screen.rs | 5 +- codex-rs/tui/src/screen_reader_tests.rs | 8 ++- codex-rs/tui/src/startup_draft.rs | 1 + codex-rs/tui/src/status_indicator_widget.rs | 29 ++++++++-- .../status_indicator_widget/effects_tests.rs | 57 ++++++++++++++++++ ...re_independent_and_obey_master_switch.snap | 19 ++++++ 41 files changed, 341 insertions(+), 71 deletions(-) create mode 100644 codex-rs/config/src/tui_effects.rs create mode 100644 codex-rs/tui/src/status_indicator_widget/effects_tests.rs create mode 100644 codex-rs/tui/src/status_indicator_widget/snapshots/codex_tui__status_indicator_widget__effects_tests__shimmer_and_progress_are_independent_and_obey_master_switch.snap diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 1b2c220f0a..3197988f78 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -42,6 +42,7 @@ mod state; mod strict_config; pub mod test_support; mod thread_config; +mod tui_effects; mod tui_keymap; pub mod types; diff --git a/codex-rs/config/src/tui_effects.rs b/codex-rs/config/src/tui_effects.rs new file mode 100644 index 0000000000..4eab024fe4 --- /dev/null +++ b/codex-rs/config/src/tui_effects.rs @@ -0,0 +1,37 @@ +//! Individual TUI effect preferences, subordinate to the animation master switch. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +/// Optional visual effects. Disabling an effect preserves its underlying activity. +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(default)] +#[schemars(deny_unknown_fields)] +pub struct TuiEffects { + /// Animate the composer starfield. + pub starfield: bool, + /// Shimmer status and loading text. + pub shimmer: bool, + /// Animate the welcome artwork. + pub welcome: bool, + /// Animate reasoning-effort changes in the composer and footer. + pub effort: bool, + /// Animate activity bullets and loading spinners. + pub progress: bool, + /// Blink the terminal-title indicator when user action is required. + pub title: bool, +} + +impl Default for TuiEffects { + fn default() -> Self { + Self { + starfield: true, + shimmer: true, + welcome: true, + effort: true, + progress: true, + title: true, + } + } +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 0d82d6f5b7..fc77dc0495 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -31,6 +31,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +pub use crate::tui_effects::TuiEffects; pub use crate::tui_keymap::KeybindingSpec; pub use crate::tui_keymap::KeybindingsSpec; pub use crate::tui_keymap::MAX_FUNCTION_KEY; @@ -753,10 +754,9 @@ pub struct Tui { /// Records the one-time screen-reader detection attempt. Either value skips detection. pub screen_reader_detection_done: Option, - /// Enable decorative effects such as Astra composer stars. Also requires animations. - /// Defaults to `true`. - #[serde(default = "default_true")] - pub whimsy: bool, + /// Individual visual effects. Each also requires animations to be enabled. + #[serde(default)] + pub effects: TuiEffects, /// Show startup tooltips in the TUI welcome screen. /// Defaults to `true`. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index db5bd7891d..90b8c944e9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4210,6 +4210,22 @@ "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts. Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.", "type": "boolean" }, + "effects": { + "allOf": [ + { + "$ref": "#/definitions/TuiEffects" + } + ], + "default": { + "effort": true, + "progress": true, + "shimmer": true, + "starfield": true, + "title": true, + "welcome": true + }, + "description": "Individual visual effects. Each also requires animations to be enabled." + }, "keymap": { "allOf": [ { @@ -4527,11 +4543,6 @@ "default": false, "description": "Start the composer in Vim mode (`Normal`) by default. Defaults to `false`.", "type": "boolean" - }, - "whimsy": { - "default": true, - "description": "Enable decorative effects such as Astra composer stars. Also requires animations. Defaults to `true`.", - "type": "boolean" } }, "type": "object" @@ -4971,6 +4982,43 @@ }, "type": "object" }, + "TuiEffects": { + "additionalProperties": false, + "description": "Optional visual effects. Disabling an effect preserves its underlying activity.", + "properties": { + "effort": { + "default": true, + "description": "Animate reasoning-effort changes in the composer and footer.", + "type": "boolean" + }, + "progress": { + "default": true, + "description": "Animate activity bullets and loading spinners.", + "type": "boolean" + }, + "shimmer": { + "default": true, + "description": "Shimmer status and loading text.", + "type": "boolean" + }, + "starfield": { + "default": true, + "description": "Animate the composer starfield.", + "type": "boolean" + }, + "title": { + "default": true, + "description": "Blink the terminal-title indicator when user action is required.", + "type": "boolean" + }, + "welcome": { + "default": true, + "description": "Animate the welcome artwork.", + "type": "boolean" + } + }, + "type": "object" + }, "TuiGlobalKeymap": { "additionalProperties": false, "description": "Global keybindings. These are used when a context does not define an override.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0e4a13835c..b4277cd2cc 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1253,7 +1253,7 @@ fn config_toml_deserializes_model_availability_nux() { notification_settings: TuiNotificationSettings::default(), animations: true, screen_reader_detection_done: None, - whimsy: true, + effects: Default::default(), show_tooltips: true, show_server_version_notice: true, auto_recap: true, @@ -4380,7 +4380,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { notification_settings: TuiNotificationSettings::default(), animations: true, screen_reader_detection_done: None, - whimsy: true, + effects: Default::default(), show_tooltips: true, show_server_version_notice: true, auto_recap: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2d7625cd6f..2bc67b91b5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -748,8 +748,8 @@ pub struct Config { /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, - /// Enable decorative TUI effects such as Astra composer stars. - pub tui_whimsy: bool, + /// Individual TUI effects, subordinate to the animation master switch. + pub tui_effects: codex_config::types::TuiEffects, /// Show startup tooltips in the TUI welcome screen. pub show_tooltips: bool, @@ -4382,7 +4382,7 @@ impl Config { .map(|t| t.notification_settings.clone()) .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), - tui_whimsy: cfg.tui.as_ref().map(|t| t.whimsy).unwrap_or(true), + tui_effects: cfg.tui.as_ref().map(|t| t.effects).unwrap_or_default(), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), tui_show_server_version_notice: cfg .tui diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 2165ee101c..55d29288ca 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -227,7 +227,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R notify: None, tui_notifications: TuiNotificationSettings::default(), animations: true, - tui_whimsy: true, + tui_effects: Default::default(), show_tooltips: true, tui_show_server_version_notice: true, tui_auto_recap: true, diff --git a/codex-rs/tui/src/app/empty_state_animation_tests.rs b/codex-rs/tui/src/app/empty_state_animation_tests.rs index c9f0e2d2b8..c304767a27 100644 --- a/codex-rs/tui/src/app/empty_state_animation_tests.rs +++ b/codex-rs/tui/src/app/empty_state_animation_tests.rs @@ -261,6 +261,14 @@ async fn empty_state_animation_preserves_header_draft_cursor_and_footer() -> Res let disabled = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); assert_eq!(disabled, &before); app.local_settings.tui.animations = true; + app.local_settings.tui.effects.welcome = false; + draw(&mut app, &mut tui, size)?; + assert_eq!( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + &before + ); + app.local_settings.tui.effects.welcome = true; + app.local_settings.tui.effects.shimmer = false; draw(&mut app, &mut tui, size)?; assert!(has_logo( crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal) diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 118810f2e4..8cf43c4073 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -43,9 +43,16 @@ impl App { screen_size: Size, ) -> Result { self.chat_widget.sync_warnings(&self.transcript_cells); - let motion = MotionMode::from_animations_enabled(self.local_settings.tui.animations); + let motion = MotionMode::from_animations_enabled( + self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, + ); let focused = tui.is_terminal_focused(); - let logo = self.empty_state_presentation(motion, focused); + let logo = self.empty_state_presentation( + MotionMode::from_animations_enabled( + self.local_settings.tui.animations && self.local_settings.tui.effects.welcome, + ), + focused, + ); let latest_navigation = if self.enter_returns_to_latest() { "enter/esc latest" } else { diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index bcff6753f0..e500ffe5b2 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -59,7 +59,7 @@ async fn review_regression_agents_overview_creation_is_fresh_but_returning_is_no trust_launch_folder(&mut app); app.cli_kv_overrides.extend([ ("tui.animations".into(), TomlValue::Boolean(true)), - ("tui.whimsy".into(), TomlValue::Boolean(true)), + ("tui.effects.starfield".into(), TomlValue::Boolean(true)), ]); app.harness_overrides.model = Some(model.into()); let mut server = start_config_write_test_app_server(&app).await?; diff --git a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs index 192fbbae5c..9885fa5f8a 100644 --- a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs +++ b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs @@ -95,7 +95,7 @@ async fn only_a_confirmed_empty_new_task_shows_the_sparkle() -> Result<()> { ] { let (mut app, _events, _ops) = make_test_app_with_channels().await; app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; let mut tui = crate::tui::test_support::make_test_tui()?; let mut thread = started("gpt-6-astra"); let presentation = if scenario == "empty_resume" { @@ -142,7 +142,7 @@ async fn disconnected_sparkle_input_routed_by_app_consumes_the_opportunity_witho for edited in [false, true] { let (mut app, _events, _ops) = make_test_app_with_channels().await; app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; let mut server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.replace_chat_widget_with_app_server_thread( @@ -197,7 +197,7 @@ async fn commands_can_precede_the_sparkle_but_inserted_or_typed_drafts_cannot() ] { let (mut app, mut events, _ops) = make_test_app_with_channels().await; app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; app.local_settings.tui.disable_paste_burst = Some(true); let mut server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; @@ -281,7 +281,7 @@ async fn commands_can_precede_the_sparkle_but_inserted_or_typed_drafts_cannot() async fn overlays_shortcuts_and_key_chords_leave_the_main_sparkle_untouched() -> Result<()> { let (mut app, _events, _ops) = make_test_app_with_channels().await; app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; let mut server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; app.replace_chat_widget_with_app_server_thread( @@ -346,7 +346,7 @@ async fn a_command_pending_on_fresh_astra_start_can_finish_before_the_sparkle() for (command, visible_after) in [("/status", true), ("/mention", false)] { let (mut app, _events, _ops) = make_test_app_with_channels().await; app.chat_widget.local_settings.tui.animations = true; - app.chat_widget.local_settings.tui.whimsy = true; + app.chat_widget.local_settings.tui.effects.starfield = true; app.pending_startup_thread_start = true; app.chat_widget.handle_paste(command.into()); let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; @@ -378,7 +378,7 @@ async fn only_confirmed_picker_model_changes_can_arm_the_sparkle() -> Result<()> ] { let (mut app, mut events, _ops) = make_test_app_with_channels().await; app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; app.local_settings.tui.disable_paste_burst = Some(true); let mut server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; @@ -489,7 +489,7 @@ async fn astra_picker_confirms_the_model_at_application_after_an_automatic_updat let (mut app, mut events, _ops) = make_test_app_with_channels().await; app.config.model = Some("gpt-5.5".into()); app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; let mut server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; let thread = server.start_thread(&app.config).await?; @@ -577,7 +577,7 @@ async fn session_only_astra_picker_shows_stars_only_on_an_untouched_task() -> Re app.config.model = Some("gpt-5.5".into()); app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; app.local_settings.tui.disable_paste_burst = Some(true); let config_path = app.config.codex_home.join("config.toml"); let defaults = "model = 'gpt-5.5'\nmodel_reasoning_effort = 'medium'\n"; @@ -665,7 +665,7 @@ async fn session_only_astra_picker_shows_stars_only_on_an_untouched_task() -> Re async fn a_read_only_command_can_return_to_active_stars_but_mention_ends_them() -> Result<()> { let (mut app, _events, _ops) = make_test_app_with_channels().await; app.local_settings.tui.animations = true; - app.local_settings.tui.whimsy = true; + app.local_settings.tui.effects.starfield = true; app.local_settings.tui.disable_paste_burst = Some(true); let mut tui = crate::tui::test_support::make_test_tui()?; app.replace_chat_widget_with_app_server_thread( @@ -695,7 +695,7 @@ async fn early_input_and_real_work_consume_the_sparkle() -> Result<()> { for scenario in ["untouched", "typed_then_erased", "paste", "turn", "review"] { let (mut app, _events, _ops) = make_test_app_with_channels().await; app.chat_widget.local_settings.tui.animations = true; - app.chat_widget.local_settings.tui.whimsy = true; + app.chat_widget.local_settings.tui.effects.starfield = true; app.pending_startup_thread_start = true; match scenario { "typed_then_erased" => { diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 07cf0778ca..2de17ffac1 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -197,6 +197,7 @@ fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver) { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: true, animations_enabled: true, + effects: Default::default(), skills: None, }), app_event_rx, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 67a7261128..1cdadf53b2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -363,7 +363,7 @@ impl App { { tui.set_overlay_input(tui::OverlayInput::Transcript)?; t.motion = crate::motion::MotionMode::from_animations_enabled( - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, ); let active_key = self.chat_widget.active_cell_transcript_key(); let chat_widget = &self.chat_widget; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 18f1cb8ee1..67bf132e6d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -182,7 +182,7 @@ //! focus loss hide the field without restarting that deadline. Rendering runs after the textarea, //! placeholder, effort ignition, and voice strip, and draws only in eligible blank cells without //! overwriting the placeholder or normal cursor. Hidden frames do not schedule animation redraws; -//! motion settings, whimsy settings, and true-color support also gate the effect. +//! motion settings, the starfield preference, and true-color support also gate the effect. //! //! # Large Paste Placeholders //! diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs index 900f14a219..c50c922f26 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs @@ -30,6 +30,7 @@ fn draft_and_voice_composer_snapshots() { speaker_history: vec![255, 146, 110, 73, 37, 0], activity: "listening", animations: false, + progress: true, }), FrameRequester::test_dummy(), ); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs index 6e365938a3..bb14fbe533 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs @@ -97,7 +97,7 @@ impl BottomPane { SparkleDraft::Untouched | SparkleDraft::Command ) && self.composer.sparkle.phase.get() == Phase::Unarmed && settings.animations - && settings.whimsy + && settings.effects.starfield && Self::is_sparkle_model(model) { self.composer.sparkle.phase.set(Phase::Waiting); @@ -112,7 +112,7 @@ impl BottomPane { } pub(crate) fn stop_ineligible_sparkle(&self, model: &str, settings: &Tui) { - if !settings.animations || !settings.whimsy || !Self::is_sparkle_model(model) { + if !settings.animations || !settings.effects.starfield || !Self::is_sparkle_model(model) { self.composer.stop_visible_sparkle(); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs index 8c39017d12..271fae1776 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs @@ -26,6 +26,7 @@ fn pane() -> BottomPane { placeholder_text: "Ask Codex to do anything".into(), disable_paste_burst: true, animations_enabled: true, + effects: Default::default(), skills: None, }) } @@ -43,7 +44,7 @@ fn palette(render: impl FnOnce() -> T) -> T { fn enabled() -> Tui { Tui { animations: true, - whimsy: true, + effects: Default::default(), ..Tui::default() } } @@ -1055,7 +1056,10 @@ fn focus_startup_work_and_configuration_do_not_extend_a_started_deadline() { for settings in [ Tui { - whimsy: false, + effects: codex_config::types::TuiEffects { + starfield: false, + ..Default::default() + }, ..enabled() }, Tui { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 563d0ffe51..a2d68f031f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -291,6 +291,7 @@ pub(crate) struct BottomPane { is_task_running: bool, esc_backtrack_hint: bool, animations_enabled: bool, + effects: codex_config::types::TuiEffects, /// Inline status indicator shown above the composer while a task is running. status: Option, @@ -321,6 +322,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) effects: codex_config::types::TuiEffects, pub(crate) skills: Option>, } @@ -342,6 +344,7 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + effects, skills, } = params; let mut composer = ChatComposer::new_with_config( @@ -379,6 +382,7 @@ impl BottomPane { pending_thread_approvals: PendingThreadApprovals::new(), esc_backtrack_hint: false, animations_enabled, + effects, context_window_percent: None, context_window_used_tokens: None, keymap, @@ -402,7 +406,7 @@ impl BottomPane { /// visible frame can play a one-shot Max/Ultra effect. pub(crate) fn set_active_reasoning_effort(&mut self, effort: Option<&ReasoningEffort>) { let animations_enabled = effort_ignition::effort_animation_enabled( - self.animations_enabled, + self.animations_enabled && self.effects.effort, effective_stdout_color_level(), ); if self @@ -1283,6 +1287,7 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.effects, )); } if let Some(status) = self.status.as_mut() { @@ -1319,6 +1324,7 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.effects, ) }); if let Some(status) = self.status.as_mut() { @@ -2107,7 +2113,7 @@ impl BottomPane { /*flex*/ 0, RenderableItem::Owned(Box::new(hook_status::HookStatus { message, - animations_enabled: self.animations_enabled, + animations_enabled: self.animations_enabled && self.effects.shimmer, })), ); } @@ -2362,6 +2368,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }) } @@ -2623,6 +2630,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: true, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); pane.push_approval_request(exec_request(), &features); @@ -2643,6 +2651,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: true, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); pane.insert_str("draft"); @@ -2676,6 +2685,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3001,6 +3011,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3068,6 +3079,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3142,6 +3154,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3173,6 +3186,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3196,6 +3210,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3225,6 +3240,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3262,6 +3278,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3294,6 +3311,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3325,6 +3343,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3354,6 +3373,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3377,6 +3397,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(vec![SkillMetadata { name: "test-skill".to_string(), description: "test skill".to_string(), @@ -3425,6 +3446,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3493,6 +3515,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3529,6 +3552,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3577,6 +3601,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3702,6 +3727,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3750,6 +3776,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); @@ -3828,6 +3855,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + effects: Default::default(), skills: Some(Vec::new()), }); diff --git a/codex-rs/tui/src/bottom_pane/voice_strip.rs b/codex-rs/tui/src/bottom_pane/voice_strip.rs index a165dc1f62..ce9c927a8a 100644 --- a/codex-rs/tui/src/bottom_pane/voice_strip.rs +++ b/codex-rs/tui/src/bottom_pane/voice_strip.rs @@ -34,6 +34,7 @@ pub(crate) struct VoiceStripState { pub(crate) speaker_history: Vec, pub(crate) activity: &'static str, pub(crate) animations: bool, + pub(crate) progress: bool, } pub(super) struct VoiceStrip { @@ -84,7 +85,8 @@ impl Renderable for VoiceStrip { return; } let connecting = self.state.phase == VoiceStripPhase::Connecting; - let mode = MotionMode::from_animations_enabled(self.state.animations); + let mode = + MotionMode::from_animations_enabled(self.state.animations && self.state.progress); if connecting && mode == MotionMode::Animated { self.frame_requester .schedule_frame_in(Duration::from_millis(100)); diff --git a/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs b/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs index 4a22988da0..ad28de1ad6 100644 --- a/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs +++ b/codex-rs/tui/src/bottom_pane/voice_strip_tests.rs @@ -20,6 +20,7 @@ fn active_strip() -> VoiceStrip { speaker_history: vec![0, 37, 73, 110, 146, 219], activity: "listening", animations: true, + progress: true, }, FrameRequester::test_dummy(), ) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ec4400967c..d30ad2c730 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1625,7 +1625,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.transcript.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ))); self.bump_active_cell_revision(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index 6374bf0bb6..a0533b315e 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -303,7 +303,7 @@ impl ChatWidget { parsed_cmd, source, /*interaction_input*/ None, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ))); self.bump_active_cell_revision(); } @@ -441,7 +441,7 @@ impl ChatWidget { parsed, source, /*interaction_input*/ None, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ); let completed = orphan.complete_call(&id, output, duration); debug_assert!(completed, "new orphan exec cell should contain {id}"); @@ -456,7 +456,7 @@ impl ChatWidget { parsed, source, /*interaction_input*/ None, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ); let completed = cell.complete_call(&id, output, duration); debug_assert!(completed, "new exec cell should contain {id}"); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index c978f57041..7a8ac683f6 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -110,6 +110,7 @@ impl ChatWidget { placeholder_text: placeholder.clone(), disable_paste_burst: local_settings.tui.disable_paste_burst.unwrap_or(false), animations_enabled: local_settings.tui.animations, + effects: local_settings.tui.effects, skills: None, }), transcript: TranscriptState::new(active_cell), diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs index db4f428f33..b2bb0d964f 100644 --- a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -128,6 +128,7 @@ impl ChatWidget { pub(super) fn schedule_hook_timer_if_needed(&self) { if self.local_settings.tui.animations + && self.local_settings.tui.effects.shimmer && self .active_hook_cell .as_ref() diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index 91e7844538..a21c09f9ac 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -343,7 +343,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, "Loading available plugins...".to_string(), Some("This updates when the marketplace list is ready.".to_string()), )), @@ -362,7 +362,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, "Adding marketplace...".to_string(), /*note*/ None, )), @@ -480,7 +480,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, loading_text.clone(), /*note*/ None, )), @@ -502,7 +502,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, format!("Loading details for {plugin_display_name}..."), /*note*/ None, )), diff --git a/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs b/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs index 595b5b77fa..52121116e2 100644 --- a/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs +++ b/codex-rs/tui/src/chatwidget/realtime/recording_controls.rs @@ -293,6 +293,7 @@ impl ChatWidget { .collect(), activity, animations: self.local_settings.tui.animations, + progress: self.local_settings.tui.effects.progress, })); } } diff --git a/codex-rs/tui/src/chatwidget/recap.rs b/codex-rs/tui/src/chatwidget/recap.rs index 9b2f57c55b..6a44839724 100644 --- a/codex-rs/tui/src/chatwidget/recap.rs +++ b/codex-rs/tui/src/chatwidget/recap.rs @@ -7,7 +7,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.transcript.active_cell = Some(Box::new(history_cell::ThreadRecapLoadingCell::new( - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ))); self.bump_active_cell_revision(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 5424267870..f84f8758f9 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -368,7 +368,7 @@ impl ChatWidget { } fn action_required_terminal_title_prefix_at(&self, now: Instant) -> &'static str { - if !self.local_settings.tui.animations { + if !(self.local_settings.tui.animations && self.local_settings.tui.effects.title) { return TERMINAL_TITLE_ACTION_REQUIRED_PREFIX; } @@ -396,6 +396,7 @@ impl ChatWidget { selections: &StatusSurfaceSelections, ) -> Option { if self.local_settings.tui.animations + && self.local_settings.tui.effects.progress && self.status_state.thread_title_generation_pending && (selections.status_line_items.iter().any(|item| { matches!( @@ -416,6 +417,7 @@ impl ChatWidget { return Some(TERMINAL_TITLE_SPINNER_INTERVAL); } if self.local_settings.tui.animations + && self.local_settings.tui.effects.title && self.terminal_title_shows_action_required_with_selections(selections) { return Some(TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL); @@ -1008,6 +1010,7 @@ impl ChatWidget { pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option { let spinner = (self.local_settings.tui.animations + && self.local_settings.tui.effects.progress && self.terminal_title_has_active_progress()) .then(|| self.terminal_title_spinner_frame_at(now)); if self.realtime_microphone_is_listening() { @@ -1049,12 +1052,15 @@ impl ChatWidget { pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { self.local_settings.tui.animations + && self.local_settings.tui.effects.progress && self.terminal_title_uses_activity() && self.terminal_title_has_active_progress() } pub(super) fn should_animate_terminal_title_action_required(&self) -> bool { - self.local_settings.tui.animations && self.terminal_title_shows_action_required() + self.local_settings.tui.animations + && self.local_settings.tui.effects.title + && self.terminal_title_shows_action_required() } fn should_animate_terminal_title_spinner_with_selections( @@ -1062,6 +1068,7 @@ impl ChatWidget { selections: &StatusSurfaceSelections, ) -> bool { self.local_settings.tui.animations + && self.local_settings.tui.effects.progress && selections .terminal_title_items .contains(&TerminalTitleItem::Spinner) diff --git a/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs b/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs index 01bbafae44..e56ea9b893 100644 --- a/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/sparkle_submission_tests.rs @@ -34,7 +34,7 @@ async fn ordinary_and_prepared_image_submissions_consume_the_sparkle_before_rend let (mut chat, _events, mut ops) = make_chatwidget_manual(Some("gpt-6-astra")).await; chat.thread_id = Some(ThreadId::new()); chat.local_settings.tui.animations = true; - chat.local_settings.tui.whimsy = true; + chat.local_settings.tui.effects.starfield = true; chat.bottom_pane .mark_fresh_task_for_sparkle("gpt-6-astra", &chat.local_settings.tui); let untouched = chat.bottom_pane.composer_draft_snapshot().sparkle_draft; diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 90a4c4c6bc..f454398803 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -172,7 +172,12 @@ async fn terminal_title_action_required_respects_spinner_setting() { #[tokio::test] async fn terminal_title_action_required_blinks_when_animations_are_enabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.tui.effects.title = false; chat.bottom_pane.set_task_running(/*running*/ true); + assert!(chat.should_animate_terminal_title_spinner()); + chat.local_settings.tui.effects.progress = false; + assert!(!chat.should_animate_terminal_title_spinner()); + chat.local_settings.tui.effects.title = true; chat.refresh_terminal_title(); let request = ExecApprovalRequestEvent { @@ -201,6 +206,13 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { Some("[ . ] Action Required | project".to_string()) ); assert!(chat.should_animate_terminal_title_action_required()); + chat.local_settings.tui.effects.title = false; + chat.refresh_terminal_title(); + assert_eq!( + chat.last_terminal_title, + Some("[ ! ] Action Required | project".into()) + ); + assert!(!chat.should_animate_terminal_title_action_required()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/thread_title_status.rs b/codex-rs/tui/src/chatwidget/thread_title_status.rs index d6c8a3528d..eec5a02e68 100644 --- a/codex-rs/tui/src/chatwidget/thread_title_status.rs +++ b/codex-rs/tui/src/chatwidget/thread_title_status.rs @@ -37,11 +37,12 @@ impl ChatWidget { if !self.status_state.thread_title_generation_pending { return value; } - let spinner = if self.local_settings.tui.animations { - self.terminal_title_spinner_frame_at(now) - } else { - TERMINAL_TITLE_SPINNER_FRAMES[0] - }; + let spinner = + if self.local_settings.tui.animations && self.local_settings.tui.effects.progress { + self.terminal_title_spinner_frame_at(now) + } else { + TERMINAL_TITLE_SPINNER_FRAMES[0] + }; Some(match value { Some(value) => format!("{value} {spinner}"), None => spinner.to_string(), @@ -51,6 +52,7 @@ impl ChatWidget { pub(crate) fn refresh_thread_title_progress_for_time_tick(&mut self) { if self.status_state.thread_title_generation_pending && self.local_settings.tui.animations + && self.local_settings.tui.effects.progress && self.configured_status_line_items().iter().any(|item| { matches!( item.parse::(), diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index 73f51dc301..e8ca99efa0 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -72,7 +72,7 @@ impl ChatWidget { self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( call_id, String::new(), - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -180,7 +180,7 @@ impl ChatWidget { let call = history_cell::new_active_mcp_tool_call( id, invocation, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ); self.update_computer_activity(|cell| cell.start(call)); self.bump_active_cell_revision(); @@ -191,7 +191,7 @@ impl ChatWidget { self.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( id, invocation, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -214,7 +214,7 @@ impl ChatWidget { let call = history_cell::new_active_mcp_tool_call( id, invocation, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ); self.update_computer_activity(|cell| cell.complete(call, duration, result)); self.bump_active_cell_revision(); @@ -234,7 +234,7 @@ impl ChatWidget { let mut cell = history_cell::new_active_mcp_tool_call( id, invocation, - self.local_settings.tui.animations, + self.local_settings.tui.animations && self.local_settings.tui.effects.progress, ); cell.complete(duration, result); self.transcript.active_cell = Some(Box::new(cell)); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index d906ee6353..a80c166664 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -789,6 +789,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: false, + effects: Default::default(), skills: Some(Vec::new()), }); (pane, tx, rx) diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index e853f25234..7723e8d521 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -63,7 +63,7 @@ impl LocalSettings { notification_settings: config.tui_notifications.clone(), animations: animations && system_motion == crate::motion::MotionMode::Animated, screen_reader_detection_done: None, - whimsy: config.tui_whimsy, + effects: config.tui_effects, show_tooltips: config.show_tooltips, show_server_version_notice: config.tui_show_server_version_notice, auto_recap: config.tui_auto_recap, diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index 6cc6eb67ee..12aae2c516 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -54,7 +54,7 @@ async fn system_motion_suppresses_animations_without_changing_saved_preferences( for configured in [true, false] { let home = tempfile::tempdir()?; - let config_text = format!("[tui]\nanimations = {configured}\nwhimsy = true\n"); + let config_text = format!("[tui]\nanimations = {configured}\n"); std::fs::write(home.path().join("config.toml"), &config_text)?; let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) @@ -94,13 +94,15 @@ async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Resul r#" [tui] animations = false -whimsy = false +whimsy = false # Retired: must not override effects or prevent strict loading. show_tooltips = false show_server_version_notice = false auto_recap = false vim_mode_default = true terminal_resize_reflow_max_rows = 0 session_picker_view = "comfortable" +[tui.effects] +shimmer = false [history] persistence = "none" max_bytes = 4096 @@ -112,6 +114,7 @@ fast_default_opt_out = true std::fs::write(home.path().join("config.toml"), config_text)?; let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) + .strict_config(true) .loader_overrides(LoaderOverrides { ignore_project_config: true, ..LoaderOverrides::without_managed_config_for_tests() @@ -119,13 +122,14 @@ fast_default_opt_out = true .cli_overrides(vec![("tui.disable_paste_burst".into(), true.into())]) .build() .await?; + assert_eq!(config.startup_warnings, Vec::::new()); let local = LocalSettings::from(&config); let mut expected: Tui = toml::from_str("")?; expected.disable_paste_burst = Some(true); expected.session_picker_view = Some(SessionPickerViewMode::Dense); if !config_text.is_empty() { expected.animations = false; - expected.whimsy = false; + expected.effects.shimmer = false; expected.show_tooltips = false; expected.show_server_version_notice = false; expected.auto_recap = false; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 8d369151b0..7db5443681 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -136,7 +136,7 @@ impl OnboardingScreen { steps.push(Step::Welcome(WelcomeWidget::new( !matches!(login_status, LoginStatus::NotAuthenticated), tui.frame_requester(), - local_settings.tui.animations, + local_settings.tui.animations && local_settings.tui.effects.welcome, ))); #[cfg(target_os = "windows")] let show_windows_create_sandbox_hint = if show_trust_screen @@ -171,7 +171,8 @@ impl OnboardingScreen { app_server_request_handle, auth_config, bedrock_setup_enabled, - animations_enabled: local_settings.tui.animations, + animations_enabled: local_settings.tui.animations + && local_settings.tui.effects.shimmer, animations_suppressed: std::cell::Cell::new(false), })); } else { diff --git a/codex-rs/tui/src/screen_reader_tests.rs b/codex-rs/tui/src/screen_reader_tests.rs index 025f5ba77a..cdd71ef5ce 100644 --- a/codex-rs/tui/src/screen_reader_tests.rs +++ b/codex-rs/tui/src/screen_reader_tests.rs @@ -129,8 +129,12 @@ async fn persisted_default_loads_and_renders_without_animation() -> anyhow::Resu assert!(!config.animations); let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let (frame_requester, mut frames) = FrameRequester::test_channel(); - let mut status = - StatusIndicatorWidget::new(AppEventSender::new(tx), frame_requester, config.animations); + let mut status = StatusIndicatorWidget::new( + AppEventSender::new(tx), + frame_requester, + config.animations, + Default::default(), + ); status.update_header("Reading the terminal output".into()); let mut timer = StatusTimer::default(); timer.pause_at(std::time::Instant::now()); diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index f7efd7937c..7e0a498bf9 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -505,6 +505,7 @@ fn startup_draft_bottom_pane( placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: crate::system_motion::mode() == crate::motion::MotionMode::Animated, + effects: Default::default(), skills: None, }, ChatComposerConfig::plain_text(), diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index f7d9fa5845..b3e5edec98 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -68,6 +68,7 @@ pub(crate) struct StatusIndicatorWidget { app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, + effects: codex_config::types::TuiEffects, } // Format elapsed seconds into a compact human-friendly form used by the status line. @@ -92,6 +93,7 @@ impl StatusIndicatorWidget { app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, + effects: codex_config::types::TuiEffects, ) -> Self { Self { header: String::from("Working"), @@ -105,6 +107,7 @@ impl StatusIndicatorWidget { app_event_tx, frame_requester, animations_enabled, + effects, } } @@ -224,12 +227,15 @@ impl StatusIndicator<'_> { |started_at| now.saturating_duration_since(started_at), ); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); - let motion_mode = MotionMode::from_animations_enabled(row.animations_enabled); + let progress = + MotionMode::from_animations_enabled(row.animations_enabled && row.effects.progress); + let shimmer = + MotionMode::from_animations_enabled(row.animations_enabled && row.effects.shimmer); let mut spans = Vec::with_capacity(5); if let Some(indicator) = activity_indicator( Some(self.timer.last_resume_at), - motion_mode, + progress, ReducedMotionIndicator::Hidden, ) { spans.push(indicator); @@ -238,7 +244,7 @@ impl StatusIndicator<'_> { spans.extend(summary_shimmer( &row.header, now.saturating_duration_since(row.header_started_at), - motion_mode, + shimmer, )); if !spans.is_empty() { spans.push(" ".into()); @@ -294,7 +300,9 @@ impl Renderable for StatusIndicator<'_> { return; } if self.row.animations_enabled || self.timer.display_started_at.is_some() { - let interval_ms = if self.row.animations_enabled { + let interval_ms = if self.row.animations_enabled + && (self.row.effects.progress || self.row.effects.shimmer) + { 32 } else { 1_000 @@ -325,6 +333,7 @@ mod tests { AppEventSender::new(tx), FrameRequester::test_dummy(), /*animations_enabled*/ true, + Default::default(), ); let previous = Instant::now() - Duration::from_secs(/*secs*/ 1); row.header_started_at = previous; @@ -357,6 +366,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ true, + Default::default(), ); // Render into a fixed-size test terminal and snapshot the backend. @@ -376,6 +386,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ true, + Default::default(), ); // Render into a fixed-size test terminal and snapshot the backend. @@ -394,6 +405,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ false, + Default::default(), ); w.update_details( Some("A man a plan a canal panama".to_string()), @@ -423,6 +435,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ false, + Default::default(), ); let mut timer = StatusTimer::default(); timer.pause_at(timer.last_resume_at); @@ -447,6 +460,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ false, + Default::default(), ); w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into())); let mut timer = StatusTimer::default(); @@ -466,6 +480,7 @@ mod tests { AppEventSender::new(tx), crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ false, + Default::default(), ); let mut timer = StatusTimer::default(); timer.pause_at(timer.last_resume_at); @@ -513,6 +528,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ true, + Default::default(), ); w.update_details( Some("abcd abcd abcd abcd".to_string()), @@ -537,6 +553,7 @@ mod tests { tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ true, + Default::default(), ); w.update_details( Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()), @@ -560,3 +577,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "status_indicator_widget/effects_tests.rs"] +mod effects_tests; diff --git a/codex-rs/tui/src/status_indicator_widget/effects_tests.rs b/codex-rs/tui/src/status_indicator_widget/effects_tests.rs new file mode 100644 index 0000000000..6d3ff20b0f --- /dev/null +++ b/codex-rs/tui/src/status_indicator_widget/effects_tests.rs @@ -0,0 +1,57 @@ +//! Independent status effects and master-switch precedence. + +use super::*; +use crate::app_event::AppEvent; +use crate::terminal_palette::with_test_default_colors; +use crate::terminal_probe::DefaultColors; + +#[test] +fn shimmer_and_progress_are_independent_and_obey_master_switch() { + with_test_default_colors( + DefaultColors { + fg: (240, 240, 240), + bg: (16, 16, 16), + }, + || { + let mut snapshots = Vec::new(); + for (animations, shimmer, progress) in [ + (true, false, false), + (true, false, true), + (true, true, false), + (false, true, true), + ] { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut row = StatusIndicatorWidget::new( + AppEventSender::new(tx), + FrameRequester::test_dummy(), + animations, + codex_config::types::TuiEffects { + shimmer, + progress, + ..Default::default() + }, + ); + // Pin the label timing and fallback bullet glyph to the first frame. + let start = Instant::now() + Duration::from_secs(/*secs*/ 3600); + row.header_started_at = start; + let timer = StatusTimer { + last_resume_at: start, + ..Default::default() + }; + let lines = StatusIndicator { + row: &row, + timer: &timer, + } + .lines(/*width*/ 80); + let header = &lines[0]; + // Omit the bullet's process-clock-driven styling from the snapshot. + let label_start = if animations && progress { 2 } else { 0 }; + let label = &header.spans[label_start..]; + snapshots.push(format!( + "animations={animations}, shimmer={shimmer}, progress={progress}\n{header}\n{label:?}" + )); + } + insta::assert_snapshot!(snapshots.join("\n\n")); + }, + ); +} diff --git a/codex-rs/tui/src/status_indicator_widget/snapshots/codex_tui__status_indicator_widget__effects_tests__shimmer_and_progress_are_independent_and_obey_master_switch.snap b/codex-rs/tui/src/status_indicator_widget/snapshots/codex_tui__status_indicator_widget__effects_tests__shimmer_and_progress_are_independent_and_obey_master_switch.snap new file mode 100644 index 0000000000..2214c90c45 --- /dev/null +++ b/codex-rs/tui/src/status_indicator_widget/snapshots/codex_tui__status_indicator_widget__effects_tests__shimmer_and_progress_are_independent_and_obey_master_switch.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status_indicator_widget/effects_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +animations=true, shimmer=false, progress=false +Working (0s • esc to interrupt) +[Span::from("Working"), Span::from(" "), Span::from("(0s • ").dim(), Span::from("esc").fg(Color::Rgb(240, 240, 240)).bold().not_dim(), Span::from(" to interrupt)").dim()] + +animations=true, shimmer=false, progress=true +• Working (0s • esc to interrupt) +[Span::from("Working"), Span::from(" "), Span::from("(0s • ").dim(), Span::from("esc").fg(Color::Rgb(240, 240, 240)).bold().not_dim(), Span::from(" to interrupt)").dim()] + +animations=true, shimmer=true, progress=false +Working (0s • esc to interrupt) +[Span::from("W").fg(Color::Rgb(128, 128, 128)), Span::from("o").fg(Color::Rgb(128, 128, 128)), Span::from("r").fg(Color::Rgb(128, 128, 128)), Span::from("k").fg(Color::Rgb(128, 128, 128)), Span::from("i").fg(Color::Rgb(128, 128, 128)), Span::from("n").fg(Color::Rgb(128, 128, 128)), Span::from("g").fg(Color::Rgb(128, 128, 128)), Span::from(" "), Span::from("(0s • ").dim(), Span::from("esc").fg(Color::Rgb(240, 240, 240)).bold().not_dim(), Span::from(" to interrupt)").dim()] + +animations=false, shimmer=true, progress=true +Working (0s • esc to interrupt) +[Span::from("Working"), Span::from(" "), Span::from("(0s • ").dim(), Span::from("esc").fg(Color::Rgb(240, 240, 240)).bold().not_dim(), Span::from(" to interrupt)").dim()]