Auto: address issue #110 - Let users choose their own loading spinner

Closes #110
This commit is contained in:
just-every-code
2025-09-09 11:07:05 +00:00
parent c20234a888
commit 247bb19c2b
10 changed files with 348 additions and 104 deletions
+27
View File
@@ -399,6 +399,33 @@ pub fn set_tui_theme_name(codex_home: &Path, theme: ThemeName) -> anyhow::Result
Ok(())
}
/// Persist the selected spinner into `CODEX_HOME/config.toml` at `[tui.spinner].name`.
pub fn set_tui_spinner_name(codex_home: &Path, spinner_name: &str) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
// Parse existing config if present; otherwise start a new document.
let mut doc = match std::fs::read_to_string(config_path.clone()) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Write `[tui.spinner].name = "…"`
doc["tui"]["spinner"]["name"] = toml_edit::value(spinner_name);
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
// create a tmp_file
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
// atomically move the tmp file into config.toml
tmp_file.persist(config_path)?;
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
+22
View File
@@ -138,6 +138,10 @@ pub struct Tui {
/// Streaming/animation behavior for assistant/reasoning output
#[serde(default)]
pub stream: StreamConfig,
/// Loading spinner style selection
#[serde(default)]
pub spinner: SpinnerSelection,
}
/// Streaming behavior configuration for the TUI.
@@ -217,6 +221,24 @@ impl Default for ThemeConfig {
}
}
/// Selected loading spinner style.
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct SpinnerSelection {
/// Name of the spinner to use. Accepts one of the names from
/// sindresorhus/cli-spinners (kebab-case), or custom names supported
/// by Codex. Defaults to "diamond".
#[serde(default = "default_spinner_name")]
pub name: String,
}
fn default_spinner_name() -> String { "diamond".to_string() }
impl Default for SpinnerSelection {
fn default() -> Self {
Self { name: default_spinner_name() }
}
}
/// Configuration for syntax highlighting in Markdown code blocks.
///
/// `theme` accepts the following values:
+15
View File
@@ -1024,6 +1024,21 @@ impl App<'_> {
self.clear_on_first_frame = true;
self.schedule_redraw();
}
AppEvent::UpdateSpinner(name) => {
// Switch spinner immediately
crate::spinner::switch_spinner(&name);
// Update config and save to file
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_spinner(name.clone());
}
self.schedule_redraw();
}
AppEvent::PreviewSpinner(name) => {
// Switch spinner immediately for preview (no history event)
crate::spinner::switch_spinner(&name);
// No config change on preview
self.schedule_redraw();
}
AppEvent::ComposerExpanded => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_composer_expanded();
+4
View File
@@ -91,6 +91,10 @@ pub(crate) enum AppEvent {
/// Preview theme (no history event)
PreviewTheme(ThemeName),
/// Update the loading spinner style (with history event)
UpdateSpinner(String),
/// Preview loading spinner (no history event)
PreviewSpinner(String),
/// Rotate access/safety preset (Read Only → Write with Approval → Full Access)
CycleAccessMode,
/// Bottom composer expanded (e.g., slash command popup opened)
+6 -44
View File
@@ -1625,56 +1625,18 @@ impl WidgetRef for ChatComposer {
if self.is_task_running {
use std::time::{SystemTime, UNIX_EPOCH};
// Call this when a task starts; store it on self (e.g. self.task_seed)
fn make_task_seed() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
}
// Mix bits so low bits aren't parity-biased
fn mix(mut x: u64) -> u64 {
x ^= x >> 30;
x = x.wrapping_mul(0xbf58476d1ce4e5b9);
x ^= x >> 27;
x = x.wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
// Generate a per-render seed; good enough for varied spinners
let seed = make_task_seed();
let r = (mix(seed) % 200) as u64;
// 1% ✨, 49.5% star, 49.5% diamond-family
let selected_spinner: &[char] = if r < 2 {
&['✨']
} else if r < 101 {
&['✧', '✦', '✧']
} else {
match r % 3 {
0 => &['✶'],
1 => &['◇'],
_ => &['◆'],
}
};
let frame_idx = (SystemTime::now()
// Use selected spinner style
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
/ 150) as usize;
let spinner = selected_spinner[frame_idx % selected_spinner.len()];
.as_millis();
let def = crate::spinner::current_spinner();
let spinner_str = crate::spinner::frame_at_time(def, now_ms);
// Create centered title with spinner and spaces
let title_line = Line::from(vec![
Span::raw(" "), // Space before spinner
Span::styled(
spinner.to_string(),
Style::default().fg(crate::colors::info()),
),
Span::styled(spinner_str.to_string(), Style::default().fg(crate::colors::info())),
Span::styled(
format!(" {}... ", self.status_message),
Style::default().fg(crate::colors::info()),
@@ -20,11 +20,17 @@ use crate::app_event_sender::AppEventSender;
use super::BottomPane;
use super::bottom_pane_view::BottomPaneView;
/// Interactive UI for selecting theme
/// Interactive UI for selecting appearance (Theme & Spinner)
pub(crate) struct ThemeSelectionView {
original_theme: ThemeName, // Theme to restore on cancel
current_theme: ThemeName, // Currently displayed theme
selected_index: usize,
selected_theme_index: usize,
// Spinner tab state
original_spinner: String,
current_spinner: String,
selected_spinner_index: usize,
// Tab selection
active_tab: Tab,
app_event_tx: AppEventSender,
is_complete: bool,
}
@@ -32,15 +38,27 @@ pub(crate) struct ThemeSelectionView {
impl ThemeSelectionView {
pub fn new(current_theme: ThemeName, app_event_tx: AppEventSender) -> Self {
let themes = Self::get_theme_options();
let selected_index = themes
let selected_theme_index = themes
.iter()
.position(|(t, _, _)| *t == current_theme)
.unwrap_or(0);
// Initialize spinner selection from current runtime spinner
let spinner_names = crate::spinner::spinner_names();
let current_spinner_name = crate::spinner::current_spinner().name.to_string();
let selected_spinner_index = spinner_names
.iter()
.position(|n| *n == current_spinner_name)
.unwrap_or(0);
Self {
original_theme: current_theme,
current_theme,
selected_index,
selected_theme_index,
original_spinner: current_spinner_name.clone(),
current_spinner: current_spinner_name,
selected_spinner_index,
active_tab: Tab::Themes,
app_event_tx,
is_complete: false,
}
@@ -125,47 +143,83 @@ impl ThemeSelectionView {
fn move_selection_up(&mut self) {
let options = Self::get_theme_options();
if self.selected_index == 0 {
self.selected_index = options.len() - 1;
if matches!(self.active_tab, Tab::Themes) {
if self.selected_theme_index == 0 {
self.selected_theme_index = options.len() - 1;
} else {
self.selected_theme_index -= 1;
}
self.current_theme = options[self.selected_theme_index].0;
// Live preview - update theme immediately (no history event)
self.app_event_tx
.send(AppEvent::PreviewTheme(self.current_theme));
} else {
self.selected_index -= 1;
let names = crate::spinner::spinner_names();
if self.selected_spinner_index == 0 {
self.selected_spinner_index = names.len().saturating_sub(1);
} else {
self.selected_spinner_index -= 1;
}
if let Some(name) = names.get(self.selected_spinner_index) {
self.current_spinner = (*name).to_string();
self.app_event_tx
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
}
}
self.current_theme = options[self.selected_index].0;
// Live preview - update theme immediately (no history event)
self.app_event_tx
.send(AppEvent::PreviewTheme(self.current_theme));
}
fn move_selection_down(&mut self) {
let options = Self::get_theme_options();
self.selected_index = (self.selected_index + 1) % options.len();
self.current_theme = options[self.selected_index].0;
// Live preview - update theme immediately (no history event)
self.app_event_tx
.send(AppEvent::PreviewTheme(self.current_theme));
if matches!(self.active_tab, Tab::Themes) {
let options = Self::get_theme_options();
self.selected_theme_index = (self.selected_theme_index + 1) % options.len();
self.current_theme = options[self.selected_theme_index].0;
// Live preview - update theme immediately (no history event)
self.app_event_tx
.send(AppEvent::PreviewTheme(self.current_theme));
} else {
let names = crate::spinner::spinner_names();
if !names.is_empty() {
self.selected_spinner_index = (self.selected_spinner_index + 1) % names.len();
if let Some(name) = names.get(self.selected_spinner_index) {
self.current_spinner = (*name).to_string();
self.app_event_tx
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
}
}
}
}
fn confirm_selection(&self) {
// Confirm the selection - this will add it to history
self.app_event_tx
.send(AppEvent::UpdateTheme(self.current_theme));
self.app_event_tx
.send(AppEvent::UpdateSpinner(self.current_spinner.clone()));
}
fn cancel_selection(&mut self) {
// Restore original theme on cancel (no history event)
// Restore original selections on cancel (no history event)
if self.current_theme != self.original_theme {
self.app_event_tx
.send(AppEvent::PreviewTheme(self.original_theme));
}
if self.current_spinner != self.original_spinner {
self.app_event_tx
.send(AppEvent::PreviewSpinner(self.original_spinner.clone()));
}
}
}
#[derive(Copy, Clone, Eq, PartialEq)]
enum Tab { Themes, Spinner }
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
fn desired_height(&self, _width: u16) -> u16 {
// Use most of the available screen for better scrolling
// But cap it at the number of themes + header/footer
let theme_count = Self::get_theme_options().len() as u16;
(theme_count + 4).min(20)
// Leave room for header/tabs/footer
(theme_count.max(crate::spinner::spinner_names().len() as u16) + 6).min(22)
}
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
@@ -184,6 +238,17 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
} => {
self.move_selection_down();
}
KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. } => {
self.active_tab = Tab::Themes;
// Schedule a near-future redraw so spinner previews continue animating
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(120)));
}
KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => {
self.active_tab = Tab::Spinner;
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(120)));
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -219,15 +284,15 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
let scroll_offset = if available_height >= options.len() {
// All items fit, no scrolling needed
0
} else if self.selected_index < available_height / 2 {
} else if self.selected_theme_index < available_height / 2 {
// Near the top
0
} else if self.selected_index >= options.len() - available_height / 2 {
} else if self.selected_theme_index >= options.len() - available_height / 2 {
// Near the bottom
options.len().saturating_sub(available_height)
} else {
// Center the selected item
self.selected_index.saturating_sub(available_height / 2)
self.selected_theme_index.saturating_sub(available_height / 2)
};
// Create content
@@ -235,58 +300,111 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
Line::from(vec![
Span::raw(" "),
Span::styled(
"Theme Selection",
"Appearance",
Style::default()
.fg(theme.text_bright)
.add_modifier(Modifier::BOLD),
),
]),
// Tabs header
{
let tab_themes = if matches!(self.active_tab, Tab::Themes) { "[Themes]" } else { " Themes " };
let tab_spinner = if matches!(self.active_tab, Tab::Spinner) { "[Spinner]" } else { " Spinner " };
Line::from(vec![
Span::raw(" "),
Span::styled(tab_themes, Style::default().fg(theme.keyword)),
Span::raw(" "),
Span::styled(tab_spinner, Style::default().fg(theme.keyword)),
])
},
Line::from(" "),
];
// Add visible themes based on scroll offset
let visible_end = (scroll_offset + available_height).min(options.len());
for i in scroll_offset..visible_end {
let (theme_enum, name, description) = &options[i];
let is_selected = i == self.selected_index;
let is_original = *theme_enum == self.original_theme;
if matches!(self.active_tab, Tab::Themes) {
// Add visible themes based on scroll offset
let visible_end = (scroll_offset + available_height).min(options.len());
for i in scroll_offset..visible_end {
let (theme_enum, name, description) = &options[i];
let is_selected = i == self.selected_theme_index;
let is_original = *theme_enum == self.original_theme;
let prefix = if is_selected { "› " } else { " " };
let suffix = if is_original { " (original)" } else { "" };
let prefix = if is_selected { "› " } else { " " };
let suffix = if is_original { " (original)" } else { "" };
let mut spans = vec![Span::raw(" "), Span::raw(prefix)];
let mut spans = vec![Span::raw(" "), Span::raw(prefix)];
if is_selected {
spans.push(Span::styled(
*name,
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(*name, Style::default().fg(theme.text)));
}
spans.push(Span::styled(suffix, Style::default().fg(theme.text_dim)));
if !suffix.is_empty() {
spans.push(Span::raw(" "));
} else {
spans.push(Span::raw(" "));
}
if is_selected {
spans.push(Span::styled(
*name,
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::BOLD),
*description,
Style::default().fg(theme.text_dim),
));
} else {
spans.push(Span::styled(*name, Style::default().fg(theme.text)));
lines.push(Line::from(spans));
}
} else {
// Spinner tab: list spinner names with a live frame preview
use std::time::{SystemTime, UNIX_EPOCH};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let names = crate::spinner::spinner_names();
let visible_end = (scroll_offset + available_height).min(names.len());
for i in scroll_offset..visible_end {
let name = names[i];
let is_selected = i == self.selected_spinner_index;
let is_original = name == self.original_spinner;
let def = crate::spinner::find_spinner_by_name(name).unwrap_or(crate::spinner::current_spinner());
let frame = crate::spinner::frame_at_time(def, now_ms);
spans.push(Span::styled(suffix, Style::default().fg(theme.text_dim)));
let prefix = if is_selected { "› " } else { " " };
let suffix = if is_original { " (original)" } else { "" };
if !suffix.is_empty() {
spans.push(Span::raw(" "));
} else {
spans.push(Span::raw(" "));
let mut spans = vec![Span::raw(" "), Span::raw(prefix)];
// Show preview frame and name
let preview = format!("{} ", frame);
spans.push(Span::styled(preview, Style::default().fg(theme.info)));
if is_selected {
spans.push(Span::styled(
name,
Style::default().fg(theme.primary).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(name, Style::default().fg(theme.text)));
}
spans.push(Span::styled(suffix, Style::default().fg(theme.text_dim)));
lines.push(Line::from(spans));
}
spans.push(Span::styled(
*description,
Style::default().fg(theme.text_dim),
));
lines.push(Line::from(spans));
// Keep preview animating while the tab is active
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(100)));
}
// Add scroll indicators if needed
if scroll_offset > 0 || visible_end < options.len() {
if matches!(self.active_tab, Tab::Themes) && (scroll_offset > 0 || (scroll_offset + available_height).min(options.len()) < options.len()) {
lines.push(Line::from(" "));
let scroll_info = format!("[{}/{}]", self.selected_index + 1, options.len());
let scroll_info = format!("[{}/{}]", self.selected_theme_index + 1, options.len());
lines.push(Line::from(vec![Span::raw(" "), Span::styled(
scroll_info,
Style::default().fg(theme.text_dim),
@@ -296,13 +414,10 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
lines.push(Line::from(vec![
Span::styled(
"↑↓",
Style::default()
.fg(theme.keyword)
.add_modifier(Modifier::BOLD),
),
Span::styled(" preview • ", Style::default().fg(theme.text_dim)),
Span::styled("←→", Style::default().fg(theme.keyword).add_modifier(Modifier::BOLD)),
Span::styled(" tabs • ", Style::default().fg(theme.text_dim)),
Span::styled("↑↓", Style::default().fg(theme.keyword).add_modifier(Modifier::BOLD)),
Span::styled(" select • ", Style::default().fg(theme.text_dim)),
Span::styled(
"Enter",
Style::default()
+19
View File
@@ -4511,6 +4511,25 @@ impl ChatWidget<'_> {
self.history_push(history_cell::new_background_event(message));
}
pub(crate) fn set_spinner(&mut self, spinner_name: String) {
// Update the config
self.config.tui.spinner.name = spinner_name.clone();
// Persist selection to config file
if let Ok(home) = codex_core::config::find_codex_home() {
if let Err(e) = codex_core::config::set_tui_spinner_name(&home, &spinner_name) {
tracing::warn!("Failed to persist spinner to config.toml: {}", e);
} else {
tracing::info!("Persisted TUI spinner selection to config.toml");
}
} else {
tracing::warn!("Could not locate Codex home to persist spinner selection");
}
// Confirmation message
let message = format!("✓ Spinner changed to {}", spinner_name);
self.history_push(history_cell::new_background_event(message));
}
fn apply_access_mode_indicator_from_config(&mut self) {
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
+1
View File
@@ -64,6 +64,7 @@ mod terminal_info;
mod text_formatting;
mod text_processing;
mod theme;
mod spinner;
mod tui;
mod user_approval_widget;
mod height_manager;
+77
View File
@@ -0,0 +1,77 @@
use lazy_static::lazy_static;
use std::sync::RwLock;
/// Definition of a spinner: human name, frame interval (ms), and frames.
pub struct SpinnerDef {
pub name: &'static str,
pub interval_ms: u64,
pub frames: &'static [&'static str],
}
// A curated set of spinners. Names align with sindresorhus/cli-spinners where possible.
// Default is "diamond" to preserve the current look-and-feel.
const SPINNER_DIAMOND: SpinnerDef = SpinnerDef { name: "diamond", interval_ms: 120, frames: &["◇", "◆"] };
const SPINNER_DOTS: SpinnerDef = SpinnerDef { name: "dots", interval_ms: 80, frames: &["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"] };
const SPINNER_LINE: SpinnerDef = SpinnerDef { name: "line", interval_ms: 130, frames: &["-","\\","|","/"] };
const SPINNER_PIPE: SpinnerDef = SpinnerDef { name: "pipe", interval_ms: 100, frames: &["┤","┘","┴","└","├","┌","┬","┐"] };
const SPINNER_STAR: SpinnerDef = SpinnerDef { name: "star", interval_ms: 100, frames: &["✶","✵","✹","✺","✹","✷"] };
const SPINNER_ARROW: SpinnerDef = SpinnerDef { name: "arrow", interval_ms: 100, frames: &["←","↖","↑","↗","→","↘","↓","↙"] };
const SPINNER_GROW_VERTICAL: SpinnerDef = SpinnerDef { name: "grow-vertical", interval_ms: 120, frames: &["▁","▃","▄","▅","▆","▇","▆","▅","▄","▃"] };
const SPINNER_BOUNCING_BAR: SpinnerDef = SpinnerDef { name: "bouncing-bar", interval_ms: 80, frames: &["[ ]","[ =]","[ ==]","[ ===]","[====]","[=== ]","[ == ]","[ = ]","[ ]","[= ]","[== ]","[=== ]","[====]","[ ===]","[ ==]","[ =]"] };
const SPINNER_FLIP: SpinnerDef = SpinnerDef { name: "flip", interval_ms: 70, frames: &["_","-","‾","-" ] };
const SPINNER_TRIANGLE: SpinnerDef = SpinnerDef { name: "triangle", interval_ms: 80, frames: &["◢","◣","◤","◥"] };
const SPINNER_MONKEY: SpinnerDef = SpinnerDef { name: "monkey", interval_ms: 300, frames: &["🙈","🙉","🙊"] };
const ALL_SPINNERS: &[&SpinnerDef] = &[
&SPINNER_DIAMOND,
&SPINNER_DOTS,
&SPINNER_LINE,
&SPINNER_PIPE,
&SPINNER_STAR,
&SPINNER_ARROW,
&SPINNER_GROW_VERTICAL,
&SPINNER_BOUNCING_BAR,
&SPINNER_FLIP,
&SPINNER_TRIANGLE,
&SPINNER_MONKEY,
];
lazy_static! {
static ref CURRENT_SPINNER: RwLock<&'static SpinnerDef> = RwLock::new(&SPINNER_DIAMOND);
}
/// Initialize the global spinner by name (kebab-case). Unknown names fall back to "diamond".
pub fn init_spinner(name: &str) {
let def = find_spinner_by_name(name).unwrap_or(&SPINNER_DIAMOND);
let mut cur = CURRENT_SPINNER.write().unwrap();
*cur = def;
}
/// Switch to a different spinner by name.
pub fn switch_spinner(name: &str) {
init_spinner(name);
}
/// Return the current spinner definition.
pub fn current_spinner() -> &'static SpinnerDef {
*CURRENT_SPINNER.read().unwrap()
}
/// Get a spinner definition by name.
pub fn find_spinner_by_name(name: &str) -> Option<&'static SpinnerDef> {
let needle = name.trim().to_ascii_lowercase();
ALL_SPINNERS.iter().copied().find(|s| s.name == needle)
}
/// Enumerate available spinner names in display order.
pub fn spinner_names() -> Vec<&'static str> {
ALL_SPINNERS.iter().map(|s| s.name).collect()
}
/// Compute the current frame index given `now` in milliseconds.
pub fn frame_at_time(def: &SpinnerDef, now_ms: u128) -> &'static str {
if def.frames.is_empty() { return ""; }
let idx = ((now_ms as u64 / def.interval_ms) as usize) % def.frames.len();
def.frames[idx]
}
+2
View File
@@ -50,6 +50,8 @@ impl std::fmt::Debug for TerminalInfo {
pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
// Initialize the theme based on config
crate::theme::init_theme(&config.tui.theme);
// Initialize spinner selection based on config
crate::spinner::init_spinner(&config.tui.spinner.name);
// Initialize syntax highlighting preference from config
crate::syntax_highlight::init_highlight_from_config(&config.tui.highlight);