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
This commit is contained in:
Eric Traut
2026-09-20 16:16:46 +00:00
committed by copyberry
parent 5c5308fc9a
commit bba48e5ce8
41 changed files with 341 additions and 71 deletions
+1
View File
@@ -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;
+37
View File
@@ -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,
}
}
}
+4 -4
View File
@@ -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<bool>,
/// 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`.
+53 -5
View File
@@ -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.",
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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
+1 -1
View File
@@ -227,7 +227,7 @@ fn new_config(model: Option<String>, 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,
@@ -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)
+9 -2
View File
@@ -43,9 +43,16 @@ impl App {
screen_size: Size,
) -> Result<Rect> {
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 {
@@ -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?;
@@ -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" => {
+1
View File
@@ -197,6 +197,7 @@ fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver<AppEvent>) {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: true,
animations_enabled: true,
effects: Default::default(),
skills: None,
}),
app_event_rx,
+1 -1
View File
@@ -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;
@@ -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
//!
@@ -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(),
);
@@ -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();
}
}
@@ -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<T>(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 {
+30 -2
View File
@@ -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<StatusIndicatorWidget>,
@@ -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<Vec<SkillMetadata>>,
}
@@ -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()),
});
+3 -1
View File
@@ -34,6 +34,7 @@ pub(crate) struct VoiceStripState {
pub(crate) speaker_history: Vec<u8>,
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));
@@ -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(),
)
+1 -1
View File
@@ -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();
@@ -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}");
@@ -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),
@@ -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()
@@ -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,
)),
@@ -293,6 +293,7 @@ impl ChatWidget {
.collect(),
activity,
animations: self.local_settings.tui.animations,
progress: self.local_settings.tui.effects.progress,
}));
}
}
+1 -1
View File
@@ -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();
@@ -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<Duration> {
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<String> {
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)
@@ -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;
@@ -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]
@@ -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::<StatusLineItem>(),
@@ -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));
+1
View File
@@ -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)
+1 -1
View File
@@ -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,
+7 -3
View File
@@ -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::<String>::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;
@@ -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 {
+6 -2
View File
@@ -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());
+1
View File
@@ -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(),
+25 -4
View File
@@ -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;
@@ -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::<AppEvent>();
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"));
},
);
}
@@ -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()]