mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:23:33 +00:00
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:
@@ -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; } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1757
-1825
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user