ux(spinners): keep grouped order and render group headers as dim title rows; add selector arrow back\n\n- Sort spinners by (group,name) from JSON (grouped or flat)\n- Spinner list shows dim group header rows (non-selectable) between groups\n- Selected row gets '› ' arrow and info-colored preview; others dim\n- Add Node script to upgrade spinners.json to grouped format with labels

This commit is contained in:
James Peter
2025-09-10 12:11:41 +10:00
parent 6a28917b7c
commit 23366b26c8
11 changed files with 2232 additions and 1905 deletions
+8
View File
@@ -73,6 +73,14 @@ jobs:
// Prefer repository_dispatch payload
let slug = (context.eventName === 'repository_dispatch' && context.payload.client_payload && context.payload.client_payload.slug) ? String(context.payload.client_payload.slug).toLowerCase() : '';
const markerRe = /<!--\s*codex-id:\s*([a-z0-9-]{3,})\s*-->/i;
if (!slug) {
// Try issue label id/<slug>
try {
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
const idLabel = (issue.labels || []).map(l => (typeof l === 'string' ? l : l.name)).find(n => typeof n === 'string' && n.startsWith('id/'));
if (idLabel) slug = idLabel.slice(3).toLowerCase();
} catch {}
}
if (!slug) {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
for (const c of comments) { const m = (c.body || '').match(markerRe); if (m) { slug = m[1].toLowerCase(); break; } }
-7
View File
@@ -279,18 +279,11 @@ jobs:
Then run /theme in the terminal to see the new option. [Explain how to try it once launched.]
Can’t wait for you to try changing your title! Please comment here if it’s working well or needs tweaks. Maintainers will review and, if merged into a release, you’ll get an update here. Thanks again! [Positive, personable close.]
Changes made:
- Added title customization [Summarize notable change(s).]
Commits:
- [Link to any commits]
---
REQUIREMENTS:
- Replace @user with the actual GitHub handle(s) of the person you are replying to (either the issue author and/or last commenter).
- Replace <slug here> with the exact preview command from the context below.
- In the changes made section, include links to the commit if possible.
- Keep it brief, clear, and kind.
</task>
<context>
+32 -1
View File
@@ -460,7 +460,8 @@ jobs:
gate_reason=$(printf "Rate: %s\nSafety: %s" "${reason_rate:-none}" "${reason_safety:-none}")
printf '%s\n\n%s\n' \
"Thanks @${ACTOR}! This issue was auto-reviewed." \
"For safety, the bot did not auto-apply changes. Details:\n${gate_reason}" \
"For safety, the bot did not auto-apply changes." \
"${gate_reason}" \
> .github/auto/ISSUE_COMMENT.md
fi
echo "allow_agent=${allow_agent}" >> "$GITHUB_OUTPUT"
@@ -509,6 +510,36 @@ jobs:
env:
SUGGESTED_ID: ${{ steps.safety.outputs.suggested_id }}
- name: Apply id/<slug> label and remove ID marker comments
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner; const repo = context.repo.repo; const issue_number = Number(process.env.ISSUE_NUMBER);
const slug = `${{ steps.slug.outputs.slug }}`;
if (slug) {
try { await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [`id/${slug}`] }); } catch {}
}
// Remove any previous codex-id marker comments to keep issues tidy
try {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const markerRe = /<!--\s*codex-id:/i;
for (const c of comments) {
if ((c.body || '').match(markerRe) && c.user?.type?.toLowerCase().includes('bot')) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id });
}
}
} catch {}
- name: Apply triage state label (allow/block)
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner; const repo = context.repo.repo; const issue_number = Number(process.env.ISSUE_NUMBER);
const allow = '${{ steps.gate.outputs.allow_agent }}' === 'true';
const mode = '${{ steps.gate.outputs.agent_mode }}';
let label = allow && mode === 'full' ? 'triage/allow' : (allow ? 'triage/allow' : 'triage/block');
try { await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] }); } catch {}
- name: If no changes, post a helpful comment
timeout-minutes: 5
if: steps.gate.outputs.allow_agent != 'true' || steps.gate.outputs.agent_mode != 'full'
+3
View File
@@ -102,3 +102,6 @@ rand = "0.9"
# which conflicts with ratatui 0.29.x pinning 0.2.0. This crate is
# only used in tests.
vt100 = "0.15.0"
[build-dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
fn main() {
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct Src { interval: u64, frames: Vec<String> }
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct Dest { interval: u64, frames: Vec<String>, label: String, group: String }
fn humanize(name: &str) -> String {
let mut out = String::new();
let mut prev_is_lower = false;
let mut prev_is_alpha = false;
for ch in name.chars() {
if ch == '-' || ch == '_' { out.push(' '); prev_is_lower=false; prev_is_alpha=false; continue; }
if ch.is_ascii_uppercase() && prev_is_lower { out.push(' '); }
else if ch.is_ascii_digit() && prev_is_alpha { out.push(' '); }
out.push(ch);
prev_is_lower = ch.is_ascii_lowercase();
prev_is_alpha = ch.is_ascii_alphabetic();
}
out.split_whitespace().map(|w| {
let mut cs = w.chars();
match cs.next() { Some(f) => format!("{}{}", f.to_uppercase(), cs.as_str().to_lowercase()), None => String::new() }
}).collect::<Vec<_>>().join(" ")
}
fn group_for(name: &str) -> String {
let n = name.to_ascii_lowercase();
let key = n.as_str();
let g = if key.contains("dot") { "Dots" }
else if key.contains("circle") || key.contains("round") || key.contains("arc") { "Circles" }
else if key.contains("line") || key.contains("pipe") || key.contains("bar") || key.contains("pulse") { "Lines" }
else if key.contains("bounce") || key.contains("ball") || key.contains("pong") { "Bouncing" }
else if key.contains("star") || key.contains("asterisk") { "Stars" }
else if key.contains("arrow") || key.contains("triangle") { "Arrows" }
else if key.contains("box") || key.contains("square") { "Boxes" }
else if key.contains("toggle") { "Toggles" }
else if key.contains("monkey") || key.contains("earth") || key.contains("moon") || key.contains("weather") || key.contains("smiley") || key.contains("emoji") { "Emoji" }
else { "Other" };
g.to_string()
}
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let path = PathBuf::from(manifest_dir).join("src").join("assets").join("spinners.json");
if let Ok(text) = fs::read_to_string(&path) {
if let Ok(src) = serde_json::from_str::<BTreeMap<String, serde_json::Value>>(&text) {
let mut changed = false;
let mut out: BTreeMap<String, Dest> = BTreeMap::new();
for (name, v) in src.into_iter() {
if let Ok(d) = serde_json::from_value::<Dest>(v.clone()) {
out.insert(name, d); continue;
}
if let Ok(s) = serde_json::from_value::<Src>(v.clone()) {
out.insert(name.clone(), Dest { interval: s.interval, frames: s.frames, label: humanize(&name), group: group_for(&name) });
changed = true;
}
}
if changed {
let pretty = serde_json::to_string_pretty(&out).unwrap();
let _ = fs::write(&path, pretty);
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
}
@@ -28,7 +28,7 @@ pub(crate) struct ThemeSelectionView {
current_theme: ThemeName, // Currently displayed theme
selected_theme_index: usize,
// Spinner tab state
original_spinner: String,
_original_spinner: String,
current_spinner: String,
selected_spinner_index: usize,
// UI mode/state
@@ -64,7 +64,7 @@ impl ThemeSelectionView {
original_theme: current_theme,
current_theme,
selected_theme_index,
original_spinner: current_spinner_name.clone(),
_original_spinner: current_spinner_name.clone(),
current_spinner: current_spinner_name.clone(),
selected_spinner_index,
mode: Mode::Overview,
@@ -461,8 +461,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
lines.push(Line::from(spans));
}
} else {
// Spinner: render a two-column view [Name | Preview] with the
// preview styled like the composer title (centered line with spinner and text).
// Spinner: render one centered preview row per spinner, matching the composer title
use std::time::{SystemTime, UNIX_EPOCH};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -478,51 +477,52 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
);
let end = (start + visible).min(count);
// Compute column rects once
let left_w = (body_area.width as f32 * 0.35) as u16;
let right_w = body_area.width.saturating_sub(left_w);
for (row_idx, i) in (start..end).enumerate() {
let y = body_area.y + row_idx as u16;
let mut prev_group: Option<String> = None;
let mut y = body_area.y;
for i in start..end {
if y >= body_area.y + body_area.height { break; }
// Left column rect
let left_rect = Rect { x: body_area.x, y, width: left_w, height: 1 };
// Right column rect
let right_rect = Rect { x: body_area.x + left_w, y, width: right_w, height: 1 };
let name = names[i].clone();
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);
// Render left cell (selector + name + optional tag)
let mut left_spans = vec![Span::raw(" ")];
if is_selected { left_spans.push(Span::styled("› ", Style::default().fg(theme.keyword))); } else { left_spans.push(Span::raw(" ")); }
if is_selected {
left_spans.push(Span::styled(name.clone(), Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)));
} else {
left_spans.push(Span::styled(name.clone(), Style::default().fg(theme.text)));
}
if is_original { left_spans.push(Span::styled(" (original)", Style::default().fg(theme.text_dim))); }
Paragraph::new(Line::from(left_spans)).alignment(Alignment::Left).render(left_rect, buf);
// Render right cell: centered preview with border-colored rules
let info = Style::default().fg(crate::colors::info());
// Compose centered preview like composer title: rules + spinner + name + ...
let border = Style::default().fg(crate::colors::border());
let content = format!("{} Thinking...", frame);
let fg = if is_selected { Style::default().fg(crate::colors::info()) } else { Style::default().fg(theme.text_dim) };
let label = crate::spinner::spinner_label_for(&name);
let content = format!("{} {}...", frame, label);
let content_len = content.chars().count() as u16 + 2; // spaces around
let total = right_rect.width;
// Optional group header row
let group = crate::spinner::spinner_group_for(&name).to_string();
if prev_group.as_deref() != Some(group.as_str()) {
if y < body_area.y + body_area.height {
let header_rect = Rect { x: body_area.x, y, width: body_area.width, height: 1 };
let header = Line::from(Span::styled(group.clone(), Style::default().fg(crate::colors::text_dim()))).centered();
Paragraph::new(header).render(header_rect, buf);
y += 1;
}
prev_group = Some(group);
if y >= body_area.y + body_area.height { break; }
}
let row_rect = Rect { x: body_area.x, y, width: body_area.width, height: 1 };
// Reserve 2 cols for selector arrow on the far left
let total = row_rect.width.saturating_sub(2);
let rule_total = total.saturating_sub(content_len);
let left_rule = rule_total / 2;
let right_rule = rule_total.saturating_sub(left_rule);
let mut right_spans: Vec<Span> = Vec::new();
right_spans.push(Span::styled("─".repeat(left_rule as usize), border));
right_spans.push(Span::raw(" "));
right_spans.push(Span::styled(content, info));
right_spans.push(Span::raw(" "));
right_spans.push(Span::styled("─".repeat(right_rule as usize), border));
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Left).render(right_rect, buf);
let mut spans: Vec<Span> = Vec::new();
// Selector arrow area
if is_selected { spans.push(Span::styled("› ", Style::default().fg(theme.keyword))); } else { spans.push(Span::raw(" ")); }
spans.push(Span::styled("─".repeat(left_rule as usize), border));
spans.push(Span::raw(" "));
spans.push(Span::styled(content, fg));
spans.push(Span::raw(" "));
spans.push(Span::styled("─".repeat(right_rule as usize), border));
Paragraph::new(Line::from(spans)).alignment(Alignment::Left).render(row_rect, buf);
y += 1;
}
// Animate spinner previews while open
+129 -13
View File
@@ -295,6 +295,11 @@ pub(crate) struct ChatWidget<'a> {
// Pending one-shot note about the latest access-mode change to inject into
// the agent's conversation history on the next user message.
pending_access_note: Option<String>,
// Stable synthetic request bucket for pre‑turn system notices (set on first use)
synthetic_system_req: Option<u64>,
// Map of system notice ids to their history index for in-place replacement
system_cell_by_id: std::collections::HashMap<String, usize>,
}
struct PendingJumpBack {
@@ -433,7 +438,87 @@ impl AsRef<str> for StreamId {
}
}
// ---- System notice ordering helpers ----
#[derive(Copy, Clone)]
enum SystemPlacement {
/// Place near the top of the current request (before most provider output)
EarlyInCurrent,
/// Place at the end of the current request window (after provider output)
EndOfCurrent,
/// Place before the first user prompt of the very first request
/// (used for pre‑turn UI confirmations like theme/spinner changes)
PrePromptInCurrent,
}
impl ChatWidget<'_> {
/// Compute an OrderKey for system (non‑LLM) notices in a way that avoids
/// creating multiple synthetic request buckets before the first provider turn.
fn system_order_key(
&mut self,
placement: SystemPlacement,
order: Option<&codex_core::protocol::OrderMeta>,
) -> OrderKey {
// If the provider supplied OrderMeta, honor it strictly.
if let Some(om) = order {
return Self::order_key_from_order_meta(om);
}
// Derive a stable request bucket for system notices:
// - If we've seen provider traffic, use that request.
// - Otherwise, use a sticky synthetic req = 1 so multiple UI
// notices remain grouped together until first turn arrives.
let req = if self.last_seen_request_index > 0 {
self.last_seen_request_index
} else {
// Sticky synthetic request (1) until first OrderMeta arrives.
// Keep a cached value to avoid accidental drift if logic changes.
if self.synthetic_system_req.is_none() {
self.synthetic_system_req = Some(1);
}
self.synthetic_system_req.unwrap_or(1)
};
self.internal_seq = self.internal_seq.saturating_add(1);
match placement {
SystemPlacement::EarlyInCurrent => OrderKey { req, out: i32::MIN + 2, seq: self.internal_seq },
SystemPlacement::EndOfCurrent => OrderKey { req, out: i32::MAX, seq: self.internal_seq },
SystemPlacement::PrePromptInCurrent => OrderKey { req, out: i32::MIN, seq: self.internal_seq },
}
}
/// Insert or replace a system notice cell with consistent ordering.
/// If `id_for_replace` is provided and we have a prior index for it, replace in place.
fn push_system_cell(
&mut self,
cell: impl HistoryCell + 'static,
placement: SystemPlacement,
id_for_replace: Option<String>,
order: Option<&codex_core::protocol::OrderMeta>,
) {
if let Some(id) = id_for_replace.as_ref() {
if let Some(&idx) = self.system_cell_by_id.get(id) {
self.history_replace_at(idx, Box::new(cell));
return;
}
}
let key = self.system_order_key(placement, order);
let pos = self.history_insert_with_key_global_tagged(Box::new(cell), key, "system");
if let Some(id) = id_for_replace {
self.system_cell_by_id.insert(id, pos);
}
}
/// Decide where to place a UI confirmation right now.
/// If we're truly pre‑turn (no provider traffic yet, and no queued prompt),
/// place before the first user prompt. Otherwise, append to end of current.
fn ui_placement_for_now(&self) -> SystemPlacement {
if self.last_seen_request_index == 0 && self.pending_user_prompts_for_next_turn == 0 {
SystemPlacement::PrePromptInCurrent
} else {
SystemPlacement::EndOfCurrent
}
}
pub(crate) fn enable_perf(&mut self, enable: bool) {
self.perf_state.enabled = enable;
}
@@ -1548,6 +1633,8 @@ impl ChatWidget<'_> {
scroll_history_hint_shown: false,
access_status_idx: None,
pending_access_note: None,
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
};
// Seed footer access indicator based on current config
new_widget.apply_access_mode_indicator_from_config();
@@ -1721,6 +1808,8 @@ impl ChatWidget<'_> {
scroll_history_hint_shown: false,
access_status_idx: None,
pending_access_note: None,
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
};
// Welcome at top of first request for forked session too
w.history_push_top_next_req(history_cell::new_animated_welcome());
@@ -3647,13 +3736,20 @@ impl ChatWidget<'_> {
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
info!("BackgroundEvent: {message}");
// Insert background events at near‑time so they appear where the
// user saw them, instead of sinking to the end of the turn.
let key = self.near_time_key(event.order.as_ref());
let _ = self.history_insert_with_key_global_tagged(
Box::new(history_cell::new_background_event(message.clone())),
key,
"bg-now",
// Route through unified system notice helper. If the core ties the
// event to a turn (order present), prefer EarlyInCurrent; else append
// at EndOfCurrent. Use the event.id for in-place replacement.
let placement = if event.order.as_ref().is_some() {
SystemPlacement::EarlyInCurrent
} else {
SystemPlacement::EndOfCurrent
};
let id_for_replace = Some(id.clone());
self.push_system_cell(
history_cell::new_background_event(message.clone()),
placement,
id_for_replace,
event.order.as_ref(),
);
// If we inserted during streaming, keep the reasoning ellipsis visible.
self.restore_reasoning_in_progress_if_streaming();
@@ -4426,8 +4522,14 @@ impl ChatWidget<'_> {
self.submit_op(op);
// Add status message to history
self.history_push(history_cell::new_reasoning_output(&new_effort));
// Add status message to history (replaceable system notice)
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_reasoning_output(&new_effort),
placement,
Some("ui:reasoning".to_string()),
None,
);
}
pub(crate) fn set_text_verbosity(&mut self, new_verbosity: TextVerbosity) {
@@ -4487,7 +4589,7 @@ impl ChatWidget<'_> {
// Retint pre-rendered history cell lines to the new palette
self.restyle_history_after_theme_change();
// Add confirmation message to history
// Add confirmation message to history (replaceable system notice)
let theme_name = match new_theme {
// Light themes
codex_core::config_types::ThemeName::LightPhoton => "Light - Photon",
@@ -4508,7 +4610,13 @@ impl ChatWidget<'_> {
codex_core::config_types::ThemeName::Custom => "Custom",
};
let message = format!("✓ Theme changed to {}", theme_name);
self.history_push(history_cell::new_background_event(message));
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_background_event(message),
placement,
Some("ui:theme".to_string()),
None,
);
}
pub(crate) fn set_spinner(&mut self, spinner_name: String) {
@@ -4525,9 +4633,15 @@ impl ChatWidget<'_> {
tracing::warn!("Could not locate Codex home to persist spinner selection");
}
// Confirmation message
// Confirmation message (replaceable system notice)
let message = format!("✓ Spinner changed to {}", spinner_name);
self.history_push(history_cell::new_background_event(message));
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_background_event(message),
placement,
Some("ui:spinner".to_string()),
None,
);
}
fn apply_access_mode_indicator_from_config(&mut self) {
@@ -7608,6 +7722,8 @@ impl ChatWidget<'_> {
self.history_push_top_next_req(history_cell::new_animated_welcome());
self.reasoning_index.clear();
self.stream_order_seq.clear();
self.synthetic_system_req = None;
self.system_cell_by_id.clear();
// Reset the bottom pane with a new composer
// (This effectively clears the text input)
+112 -12
View File
@@ -1,11 +1,17 @@
use lazy_static::lazy_static;
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::sync::RwLock;
#[derive(Debug, Clone)]
pub struct Spinner {
/// Machine name (from JSON key)
pub name: String,
/// Human‑readable label (Title Case)
pub label: String,
/// Logical group for browsing
pub group: String,
pub interval_ms: u64,
pub frames: Vec<String>,
}
@@ -14,26 +20,54 @@ pub struct Spinner {
struct SpinnerJson {
interval: u64,
frames: Vec<String>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
group: Option<String>,
}
// The full upstream set (commit pinned) with our classic "diamond" added.
// Stored as JSON text and parsed on startup; avoids massive generated code.
// Stored as JSON text and parsed on startup; supports two formats:
// 1) Flat map: { name: {interval, frames, label?, group?}, ... }
// 2) Grouped map: { Group: { name: {interval, frames, label?}, ... }, ... }
const SPINNERS_JSON: &str = include_str!("../assets/spinners.json");
lazy_static! {
static ref ALL_SPINNERS: Vec<Spinner> = {
let map: BTreeMap<String, SpinnerJson> = serde_json::from_str(SPINNERS_JSON).unwrap_or_default();
let mut v: Vec<Spinner> = map
.into_iter()
.map(|(name, sj)| Spinner { name, interval_ms: sj.interval, frames: sj.frames })
.collect();
// Ensure our default "diamond" exists
if !v.iter().any(|s| s.name == "diamond") {
v.push(Spinner { name: "diamond".to_string(), interval_ms: 120, frames: vec!["◇".into(), "◆".into()] });
let mut list: Vec<Spinner> = Vec::new();
let val: Value = serde_json::from_str(SPINNERS_JSON).unwrap_or(Value::Object(Default::default()));
match val {
Value::Object(map) => {
// Detect grouped vs flat by peeking at first value
let is_grouped = map.values().next().map(|v| !v.get("interval").is_some()).unwrap_or(false);
if is_grouped {
// Grouped: outer key = group
for (group, inner_val) in map.into_iter() {
if let Value::Object(inner_map) = inner_val {
for (name, val_entry) in inner_map.into_iter() {
if let Ok(sj) = serde_json::from_value::<SpinnerJson>(val_entry) {
vpush(&mut list, &name, sj, Some(group.clone()));
}
}
}
}
} else {
// Flat: each entry includes optional label/group
let flat: BTreeMap<String, SpinnerJson> = serde_json::from_value(Value::Object(map)).unwrap_or_default();
for (name, sj) in flat.into_iter() {
vpush(&mut list, &name, sj, None);
}
}
}
_ => {}
}
// Stable sort by name for predictable order
v.sort_by(|a, b| a.name.cmp(&b.name));
v
// Ensure our default "diamond" exists
if !list.iter().any(|s| s.name == "diamond") {
list.push(Spinner { name: "diamond".to_string(), label: humanize("diamond"), group: derive_group("diamond").to_string(), interval_ms: 120, frames: vec!["◇".into(), "◆".into()] });
}
// Stable sort by (group, name) for grouped browsing
list.sort_by(|a, b| match a.group.cmp(&b.group) { core::cmp::Ordering::Equal => a.name.cmp(&b.name), ord => ord });
list
};
static ref CURRENT_INDEX: RwLock<usize> = RwLock::new(
ALL_SPINNERS.iter().position(|s| s.name == "diamond").unwrap_or(0)
@@ -72,8 +106,74 @@ pub fn find_spinner_by_name(name: &str) -> Option<&'static Spinner> {
pub fn spinner_names() -> Vec<String> { ALL_SPINNERS.iter().map(|s| s.name.clone()).collect() }
pub fn spinner_label_for(name: &str) -> String {
find_spinner_by_name(name)
.map(|s| s.label.clone())
.unwrap_or_else(|| humanize(name))
}
pub fn spinner_group_for(name: &str) -> &'static str {
if let Some(s) = find_spinner_by_name(name) { return &s.group; }
derive_group(name)
}
pub fn frame_at_time(def: &Spinner, now_ms: u128) -> String {
if def.frames.is_empty() { return String::new(); }
let idx = ((now_ms as u64 / def.interval_ms) as usize) % def.frames.len();
def.frames[idx].clone()
}
fn humanize(name: &str) -> String {
// Convert kebab or camelCase to Title Case with spaces, keep digits grouped
let mut out = String::new();
let mut prev_is_lower = false;
let mut prev_is_alpha = false;
for ch in name.chars() {
if ch == '-' || ch == '_' {
out.push(' ');
prev_is_lower = false;
prev_is_alpha = false;
continue;
}
if ch.is_ascii_uppercase() && prev_is_lower {
out.push(' ');
} else if ch.is_ascii_digit() && prev_is_alpha {
out.push(' ');
}
out.push(ch);
prev_is_lower = ch.is_ascii_lowercase();
prev_is_alpha = ch.is_ascii_alphabetic();
}
// Title case each word
out.split_whitespace()
.map(|w| {
let mut cs = w.chars();
match cs.next() {
Some(first) => format!("{}{}", first.to_uppercase(), cs.as_str().to_lowercase()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn derive_group(name: &str) -> &'static str {
let n = name.to_ascii_lowercase();
let key = n.as_str();
if key.contains("dot") { return "Dots"; }
if key.contains("circle") || key.contains("round") || key.contains("arc") { return "Circles"; }
if key.contains("line") || key.contains("pipe") || key.contains("bar") || key.contains("pulse") { return "Lines"; }
if key.contains("bounce") || key.contains("ball") || key.contains("pong") { return "Bouncing"; }
if key.contains("star") || key.contains("asterisk") { return "Stars"; }
if key.contains("arrow") || key.contains("triangle") { return "Arrows"; }
if key.contains("box") || key.contains("square") { return "Boxes"; }
if key.contains("toggle") { return "Toggles"; }
if key.contains("monkey") || key.contains("earth") || key.contains("moon") || key.contains("weather") || key.contains("smiley") || key.contains("emoji") { return "Emoji"; }
"Other"
}
fn vpush(out: &mut Vec<Spinner>, name: &str, sj: SpinnerJson, group_override: Option<String>) {
let label = sj.label.clone().unwrap_or_else(|| humanize(name));
let group = group_override.unwrap_or_else(|| sj.group.clone().unwrap_or_else(|| derive_group(name).to_string()));
out.push(Spinner { name: name.to_string(), label, group, interval_ms: sj.interval, frames: sj.frames });
}
+24 -10
View File
@@ -43,18 +43,33 @@ function groupFor(name) {
function main() {
const dryRun = process.argv.includes('--dry-run');
const text = fs.readFileSync(file, 'utf8');
/** @type {Record<string, any>} */
const data = JSON.parse(text);
let updated = 0, total = 0;
// If already grouped (outer values lack `interval`), normalize labels and return
const firstVal = Object.values(data)[0];
const alreadyGrouped = firstVal && typeof firstVal === 'object' && !('interval' in firstVal);
const out = {};
for (const [name, v] of Object.entries(data)) {
total++;
const hasLabel = v && typeof v === 'object' && 'label' in v;
const hasGroup = v && typeof v === 'object' && 'group' in v;
const label = hasLabel ? v.label : humanize(name);
const group = hasGroup ? v.group : groupFor(name);
if (!hasLabel || !hasGroup) updated++;
out[name] = { interval: v.interval, frames: v.frames, label, group };
if (alreadyGrouped) {
for (const [group, inner] of Object.entries(data)) {
out[group] = {};
for (const [name, v] of Object.entries(inner)) {
total++;
const hasLabel = v && typeof v === 'object' && 'label' in v;
const label = hasLabel ? v.label : humanize(name);
if (!hasLabel) updated++;
out[group][name] = { interval: v.interval, frames: v.frames, label };
}
}
} else {
// Flat → Grouped
for (const [name, v] of Object.entries(data)) {
total++;
const label = v && typeof v === 'object' && 'label' in v ? v.label : humanize(name);
const group = v && typeof v === 'object' && 'group' in v ? v.group : groupFor(name);
if (!('label' in v) || !('group' in v)) updated++;
if (!out[group]) out[group] = {};
out[group][name] = { interval: v.interval, frames: v.frames, label };
}
}
if (dryRun) {
console.log(`Would update ${updated}/${total} entries (labels/groups).`);
@@ -65,4 +80,3 @@ function main() {
}
if (require.main === module) main();
+63
View File
@@ -0,0 +1,63 @@
use std::collections::BTreeMap;
use std::fs;
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct Src { interval: u64, frames: Vec<String> }
#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct Dest { interval: u64, frames: Vec<String>, label: String, group: String }
fn humanize(name: &str) -> String {
let mut out = String::new();
let mut prev_is_lower = false;
let mut prev_is_alpha = false;
for ch in name.chars() {
if ch == '-' || ch == '_' { out.push(' '); prev_is_lower=false; prev_is_alpha=false; continue; }
if ch.is_ascii_uppercase() && prev_is_lower { out.push(' '); }
else if ch.is_ascii_digit() && prev_is_alpha { out.push(' '); }
out.push(ch);
prev_is_lower = ch.is_ascii_lowercase();
prev_is_alpha = ch.is_ascii_alphabetic();
}
out.split_whitespace().map(|w| {
let mut cs = w.chars();
match cs.next() { Some(f) => format!("{}{}", f.to_uppercase(), cs.as_str().to_lowercase()), None => String::new() }
}).collect::<Vec<_>>().join(" ")
}
fn group_for(name: &str) -> String {
let n = name.to_ascii_lowercase();
let key = n.as_str();
let g = if key.contains("dot") { "Dots" }
else if key.contains("circle") || key.contains("round") || key.contains("arc") { "Circles" }
else if key.contains("line") || key.contains("pipe") || key.contains("bar") || key.contains("pulse") { "Lines" }
else if key.contains("bounce") || key.contains("ball") || key.contains("pong") { "Bouncing" }
else if key.contains("star") || key.contains("asterisk") { "Stars" }
else if key.contains("arrow") || key.contains("triangle") { "Arrows" }
else if key.contains("box") || key.contains("square") { "Boxes" }
else if key.contains("toggle") { "Toggles" }
else if key.contains("monkey") || key.contains("earth") || key.contains("moon") || key.contains("weather") || key.contains("smiley") || key.contains("emoji") { "Emoji" }
else { "Other" };
g.to_string()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = "codex-rs/tui/assets/spinners.json";
let text = fs::read_to_string(path)?;
let src: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text)?;
let mut out: BTreeMap<String, Dest> = BTreeMap::new();
for (name, v) in src.into_iter() {
// Support already-upgraded entries too
if let Ok(d) = serde_json::from_value::<Dest>(v.clone()) {
out.insert(name, d);
continue;
}
let s: Src = serde_json::from_value(v)?;
out.insert(name.clone(), Dest { interval: s.interval, frames: s.frames, label: humanize(&name), group: group_for(&name) });
}
let pretty = serde_json::to_string_pretty(&out)?;
fs::write(path, pretty)?;
println!("Upgraded spinners.json with labels/groups");
Ok(())
}