mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Auto: address issue #110 - Let users choose their own loading spinner
Closes #110
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user