feat(spinner): accept "name" in custom spinner JSON, persist as label in config, and display spinner labels (not IDs) in Overview; replace existing custom on save

This commit is contained in:
James Peter
2025-09-10 21:29:26 +10:00
parent 09685ea527
commit 704286d3da
4 changed files with 50 additions and 24 deletions
+7 -4
View File
@@ -426,10 +426,12 @@ pub fn set_tui_spinner_name(codex_home: &Path, spinner_name: &str) -> anyhow::Re
Ok(())
}
/// Save or update a custom spinner under `[tui.spinner.custom.<name>]` and set it active.
/// Save or update a custom spinner under `[tui.spinner.custom.<id>]` with a display `label`,
/// and set it active by writing `[tui.spinner].name = <id>`.
pub fn set_custom_spinner(
codex_home: &Path,
name: &str,
id: &str,
label: &str,
interval: u64,
frames: &[String],
) -> anyhow::Result<()> {
@@ -440,14 +442,15 @@ pub fn set_custom_spinner(
Err(e) => return Err(e.into()),
};
// Write custom spinner
let node = &mut doc["tui"]["spinner"]["custom"][name];
let node = &mut doc["tui"]["spinner"]["custom"][id];
node["interval"] = toml_edit::value(interval as i64);
let mut arr = toml_edit::Array::default();
for s in frames { arr.push(s.as_str()); }
node["frames"] = toml_edit::value(arr);
node["label"] = toml_edit::value(label);
// Set as active
doc["tui"]["spinner"]["name"] = toml_edit::value(name);
doc["tui"]["spinner"]["name"] = toml_edit::value(id);
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
+3
View File
@@ -247,6 +247,9 @@ impl Default for SpinnerSelection {
pub struct CustomSpinner {
pub interval: u64,
pub frames: Vec<String>,
/// Optional human-readable label to display in the UI
#[serde(default)]
pub label: Option<String>,
}
/// Configuration for syntax highlighting in Markdown code blocks.
@@ -293,6 +293,7 @@ impl ThemeSelectionView {
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 40, "description": "Display name for the spinner (shown in the UI)."},
"interval": {"type": "integer", "minimum": 50, "maximum": 300, "description": "Delay between frames in milliseconds (50–300)."},
"frames": {
"type": "array",
@@ -376,6 +377,13 @@ impl ThemeSelectionView {
}
};
let interval = v.get("interval").and_then(|x| x.as_u64()).unwrap_or(120).clamp(50, 300);
let display_name = v
.get("name")
.and_then(|x| x.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or("Custom")
.to_string();
let mut frames: Vec<String> = v
.get("frames")
.and_then(|x| x.as_array())
@@ -405,7 +413,7 @@ impl ThemeSelectionView {
.collect();
// Persist + activate
let _ = progress_tx.send(ProgressMsg::CompletedOk { interval, frames: norm_frames });
let _ = progress_tx.send(ProgressMsg::CompletedOk { name: display_name, interval, frames: norm_frames });
});
});
}
@@ -434,6 +442,7 @@ struct CreateState {
/// Parsed proposal waiting for review
proposed_interval: std::cell::Cell<Option<u64>>,
proposed_frames: std::cell::RefCell<Option<Vec<String>>>,
proposed_name: std::cell::RefCell<Option<String>>,
}
#[derive(Copy, Clone, PartialEq)]
@@ -447,7 +456,7 @@ enum ProgressMsg {
ThinkingDelta(String),
OutputDelta(String),
RawOutput(String),
CompletedOk { interval: u64, frames: Vec<String> },
CompletedOk { name: String, interval: u64, frames: Vec<String> },
// `_raw_snippet` is captured for potential future display/debugging
CompletedErr { error: String, _raw_snippet: String },
}
@@ -592,6 +601,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
thinking_current: std::cell::RefCell::new(String::new()),
proposed_interval: std::cell::Cell::new(None),
proposed_frames: std::cell::RefCell::new(None),
proposed_name: std::cell::RefCell::new(None),
});
} else {
self.confirm_spinner()
@@ -639,17 +649,18 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
s.proposed_interval.get(),
s.proposed_frames.borrow().clone(),
) {
let display_name = s
.proposed_name
.borrow()
.as_ref()
.cloned()
.unwrap_or_else(|| "Custom".to_string());
if let Ok(home) = codex_core::config::find_codex_home() {
let _ = codex_core::config::set_custom_spinner(
&home, "custom", interval, &frames,
&home, "custom", &display_name, interval, &frames,
);
}
crate::spinner::add_custom_spinner(
"custom".to_string(),
"Custom".to_string(),
interval,
frames,
);
crate::spinner::add_custom_spinner("custom".to_string(), display_name.clone(), interval, frames);
crate::spinner::switch_spinner("custom");
self.revert_spinner_on_back = "custom".to_string();
self.current_spinner = "custom".to_string();
@@ -815,12 +826,11 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
.find(|(t, _, _)| *t == self.current_theme)
.map(|(_, name, _)| *name)
.unwrap_or("Theme");
let spinner_label = self.current_spinner.as_str();
let items = vec![
("Change theme", theme_label),
("Change spinner", spinner_label),
];
for (i, (k, v)) in items.iter().enumerate() {
// Row 0: Change theme
for (i, k, v, is_spinner) in [
(0usize, "Change theme", theme_label, false),
(1usize, "Change spinner", "", true),
] {
let selected = i == self.overview_selected_index;
let mut spans = vec![Span::raw(" ")];
if selected {
@@ -830,16 +840,21 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
if selected {
spans.push(Span::styled(
*k,
k,
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(*k, Style::default().fg(theme.text)));
spans.push(Span::styled(k, Style::default().fg(theme.text)));
}
spans.push(Span::raw(" — "));
spans.push(Span::styled(*v, Style::default().fg(theme.text_dim)));
if is_spinner {
let label = crate::spinner::spinner_label_for(&self.current_spinner);
spans.push(Span::styled(label, Style::default().fg(theme.text_dim)));
} else {
spans.push(Span::styled(v, Style::default().fg(theme.text_dim)));
}
lines.push(Line::from(spans));
}
} else if matches!(self.mode, Mode::Themes) {
@@ -952,12 +967,13 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::RawOutput(_raw)) => {}
Ok(ProgressMsg::CompletedOk { interval, frames }) => {
Ok(ProgressMsg::CompletedOk { name, interval, frames }) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
sm.is_loading.set(false);
sm.step.set(CreateStep::Review);
sm.proposed_interval.set(Some(interval));
sm.proposed_frames.replace(Some(frames));
sm.proposed_name.replace(Some(name));
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
+5 -1
View File
@@ -55,9 +55,13 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
if !config.tui.spinner.custom.is_empty() {
let mut custom = Vec::new();
for (name, cs) in &config.tui.spinner.custom {
let label = cs
.label
.clone()
.unwrap_or_else(|| crate::spinner::spinner_label_for(name));
custom.push(crate::spinner::Spinner {
name: name.clone(),
label: crate::spinner::spinner_label_for(name),
label,
group: "Custom".to_string(),
interval_ms: cs.interval,
frames: cs.frames.clone(),