mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/theme): improve Create Custom spinner UX\n\n- Add visible caret and field focus; Up/Down navigates Interval→Frames→Save/Cancel\n- Save/Cancel buttons at bottom; Enter confirms action\n- Save writes a single "Custom" spinner (upsert) and activates it, then returns to Overview\n- Titles: Overview, Overview » Change Theme, Overview » Change Spinner, Overview » Change Spinner » Create Custom\n- Ensure pane heights so Frames + Save/Cancel are visible\n- Fix Instant scheduling (use checked_add)\n- Upsert custom spinner in registry
This commit is contained in:
@@ -1435,6 +1435,9 @@ impl State {
|
||||
Self {
|
||||
approved_commands: self.approved_commands.clone(),
|
||||
history: self.history.clone(),
|
||||
// Preserve request_ordinal so reconfigurations (e.g., /reasoning)
|
||||
// do not reset provider ordering mid-session.
|
||||
request_ordinal: self.request_ordinal,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,36 @@ 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.
|
||||
pub fn set_custom_spinner(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
interval: u64,
|
||||
frames: &[String],
|
||||
) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(&config_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// Write custom spinner
|
||||
let node = &mut doc["tui"]["spinner"]["custom"][name];
|
||||
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);
|
||||
|
||||
// Set as active
|
||||
doc["tui"]["spinner"]["name"] = toml_edit::value(name);
|
||||
|
||||
std::fs::create_dir_all(codex_home)?;
|
||||
let tmp = NamedTempFile::new_in(codex_home)?;
|
||||
std::fs::write(tmp.path(), doc.to_string())?;
|
||||
tmp.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);
|
||||
|
||||
@@ -229,16 +229,26 @@ pub struct SpinnerSelection {
|
||||
/// by Codex. Defaults to "diamond".
|
||||
#[serde(default = "default_spinner_name")]
|
||||
pub name: String,
|
||||
/// Custom spinner definitions saved by the user
|
||||
#[serde(default)]
|
||||
pub custom: std::collections::HashMap<String, CustomSpinner>,
|
||||
}
|
||||
|
||||
fn default_spinner_name() -> String { "diamond".to_string() }
|
||||
|
||||
impl Default for SpinnerSelection {
|
||||
fn default() -> Self {
|
||||
Self { name: default_spinner_name() }
|
||||
Self { name: default_spinner_name(), custom: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// User-defined custom spinner
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct CustomSpinner {
|
||||
pub interval: u64,
|
||||
pub frames: Vec<String>,
|
||||
}
|
||||
|
||||
/// Configuration for syntax highlighting in Markdown code blocks.
|
||||
///
|
||||
/// `theme` accepts the following values:
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"barScroll": {
|
||||
"interval": 80,
|
||||
"frames": ["▉▊▋▌▍▎▏","▊▋▌▍▎▏▉","▋▌▍▎▏▉▊","▌▍▎▏▉▊▋","▍▎▏▉▊▋▌","▎▏▉▊▋▌▍","▏▉▊▋▌▍▎","▉▊▋▌▍▎▏"],
|
||||
"frames": ["▉▊▋▌▍▎▏","▏▉▊▋▌▍▎","▎▏▉▊▋▌▍","▍▎▏▉▊▋▌","▌▍▎▏▉▊▋","▋▌▍▎▏▉▊","▊▋▌▍▎▏▉","▉▊▋▌▍▎▏"],
|
||||
"label": "Bar Scroll"
|
||||
}
|
||||
},
|
||||
@@ -244,22 +244,5 @@
|
||||
"frames": ["+","x","*"],
|
||||
"label": "Cross Sparkle"
|
||||
}
|
||||
},
|
||||
"Emoji": {
|
||||
"earthRotation": {
|
||||
"interval": 180,
|
||||
"frames": ["🌍","🌎","🌏"],
|
||||
"label": "Earth Rotation"
|
||||
},
|
||||
"moonPhases": {
|
||||
"interval": 80,
|
||||
"frames": ["🌑","🌒","🌓","🌔","🌕","🌖","🌗","🌘"],
|
||||
"label": "Moon Phases"
|
||||
},
|
||||
"weatherCycle": {
|
||||
"interval": 100,
|
||||
"frames": ["☀️","☀️","☀️","🌤","⛅️","🌥","☁️","🌧","🌨","🌧","🌨","🌧","🌨","⛈","🌨","🌧","🌨","☁️","🌥","⛅️","🌤","☀️","☀️"],
|
||||
"label": "Weather Cycle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,10 +193,48 @@ impl ChatComposer {
|
||||
let animation_flag_clone = Arc::clone(&animation_flag);
|
||||
let app_event_tx_clone = self.app_event_tx.clone();
|
||||
|
||||
// Drive redraws at the spinner's native cadence with a
|
||||
// phase‑aligned, monotonic scheduler to minimize drift and
|
||||
// reduce perceived frame skipping under load. We purposely
|
||||
// avoid very small intervals to keep CPU impact low.
|
||||
thread::spawn(move || {
|
||||
use std::time::Instant;
|
||||
// Default to ~120ms if spinner state is not yet initialized
|
||||
let default_ms: u64 = 120;
|
||||
// Clamp to a sane floor so we never busy loop if a custom spinner
|
||||
// has an extremely small interval configured.
|
||||
let min_ms: u64 = 60; // ~16 FPS upper bound for this thread
|
||||
|
||||
// Determine the target period. If the user changes the spinner
|
||||
// while running, we'll still get correct visual output because
|
||||
// frames are time‑based at render; this cadence simply requests
|
||||
// redraws.
|
||||
let period_ms = crate::spinner::current_spinner()
|
||||
.interval_ms
|
||||
.max(min_ms)
|
||||
.max(1);
|
||||
let period = Duration::from_millis(period_ms); // fallback uses default below if needed
|
||||
|
||||
let mut next = Instant::now()
|
||||
.checked_add(if period_ms == 0 { Duration::from_millis(default_ms) } else { period })
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
while animation_flag_clone.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(200)); // Slower animation
|
||||
app_event_tx_clone.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
let now = Instant::now();
|
||||
if now < next {
|
||||
let sleep_dur = next - now;
|
||||
thread::sleep(sleep_dur);
|
||||
} else {
|
||||
// If we're late (system busy), request a redraw immediately.
|
||||
app_event_tx_clone.send(crate::app_event::AppEvent::RequestRedraw);
|
||||
// Step the schedule forward by whole periods to avoid
|
||||
// bursty catch‑up redraws.
|
||||
let mut target = next;
|
||||
while target <= now {
|
||||
if let Some(t) = target.checked_add(period) { target = t; } else { break; }
|
||||
}
|
||||
next = target;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -187,12 +187,17 @@ impl ThemeSelectionView {
|
||||
}
|
||||
} else {
|
||||
let names = crate::spinner::spinner_names();
|
||||
if self.selected_spinner_index + 1 < names.len() {
|
||||
// Allow moving onto the extra pseudo-row (Create your own…)
|
||||
if self.selected_spinner_index + 1 <= names.len() {
|
||||
self.selected_spinner_index += 1;
|
||||
if let Some(name) = names.get(self.selected_spinner_index) {
|
||||
self.current_spinner = name.clone();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
|
||||
if self.selected_spinner_index < names.len() {
|
||||
if let Some(name) = names.get(self.selected_spinner_index) {
|
||||
self.current_spinner = name.clone();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::PreviewSpinner(self.current_spinner.clone()));
|
||||
}
|
||||
} else {
|
||||
// On the pseudo-row: do not change current spinner preview
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,28 +233,46 @@ impl ThemeSelectionView {
|
||||
}
|
||||
}
|
||||
Mode::Overview => {}
|
||||
Mode::CreateSpinner(_) => {}
|
||||
}
|
||||
self.mode = Mode::Overview;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
enum Mode { Overview, Themes, Spinner }
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Mode { Overview, Themes, Spinner, CreateSpinner(CreateState) }
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct CreateState {
|
||||
step: CreateStep,
|
||||
interval: String,
|
||||
frames: String,
|
||||
action_idx: usize, // 0 = Save, 1 = Cancel
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum CreateStep { Interval, Frames, Action }
|
||||
|
||||
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
match self.mode {
|
||||
match &self.mode {
|
||||
// Border (2) + inner padding (2) + 2 content rows = 6
|
||||
Mode::Overview => 6,
|
||||
// Detail lists: fixed 9 visible rows (max), shrink if fewer
|
||||
Mode::Themes => {
|
||||
let n = Self::get_theme_options().len() as u16;
|
||||
4 + n.min(9)
|
||||
// Border(2) + padding(2) + title(1)+space(1) + list
|
||||
6 + n.min(9)
|
||||
}
|
||||
Mode::Spinner => {
|
||||
let n = crate::spinner::spinner_names().len() as u16;
|
||||
4 + n.min(9)
|
||||
// +1 for the "Create your own…" pseudo-row
|
||||
let n = (crate::spinner::spinner_names().len() as u16) + 1;
|
||||
// Border(2) + padding(2) + title(1)+space(1) + list
|
||||
6 + n.min(9)
|
||||
}
|
||||
// Title + spacer + 2 fields + buttons + help = 6 content rows
|
||||
// plus border(2) + padding(2) = 10; add 2 rows headroom for small terminals
|
||||
Mode::CreateSpinner(_) => 12,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,11 +283,20 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
match self.mode {
|
||||
Mode::Overview => {
|
||||
self.overview_selected_index = self.overview_selected_index.saturating_sub(1) % 2;
|
||||
// In create form, Up navigates fields/buttons
|
||||
if let Mode::CreateSpinner(ref mut s) = self.mode {
|
||||
s.step = match s.step {
|
||||
CreateStep::Interval => CreateStep::Action,
|
||||
CreateStep::Frames => CreateStep::Interval,
|
||||
CreateStep::Action => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Action }
|
||||
};
|
||||
} else {
|
||||
match self.mode.clone() {
|
||||
Mode::Overview => {
|
||||
self.overview_selected_index = self.overview_selected_index.saturating_sub(1) % 2;
|
||||
}
|
||||
_ => self.move_selection_up(),
|
||||
}
|
||||
_ => self.move_selection_up(),
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -272,11 +304,20 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
match self.mode {
|
||||
Mode::Overview => {
|
||||
self.overview_selected_index = (self.overview_selected_index + 1) % 2;
|
||||
// In create form, Down navigates fields/buttons
|
||||
if let Mode::CreateSpinner(ref mut s) = self.mode {
|
||||
s.step = match s.step {
|
||||
CreateStep::Interval => CreateStep::Frames,
|
||||
CreateStep::Frames => CreateStep::Action,
|
||||
CreateStep::Action => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Action }
|
||||
};
|
||||
} else {
|
||||
match &self.mode.clone() {
|
||||
Mode::Overview => {
|
||||
self.overview_selected_index = (self.overview_selected_index + 1) % 2;
|
||||
}
|
||||
_ => self.move_selection_down(),
|
||||
}
|
||||
_ => self.move_selection_down(),
|
||||
}
|
||||
}
|
||||
KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. } => {}
|
||||
@@ -286,7 +327,9 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
match self.mode {
|
||||
// Take ownership of mode to avoid borrowing self while we may assign to self.mode
|
||||
let current_mode = std::mem::replace(&mut self.mode, Mode::Overview);
|
||||
match current_mode {
|
||||
Mode::Overview => {
|
||||
if self.overview_selected_index == 0 {
|
||||
self.revert_theme_on_back = self.current_theme;
|
||||
@@ -300,8 +343,44 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
self.just_entered_spinner = true;
|
||||
}
|
||||
}
|
||||
Mode::Themes => self.confirm_theme(),
|
||||
Mode::Spinner => self.confirm_spinner(),
|
||||
Mode::Themes => {
|
||||
// confirm_theme sets self.mode back to Overview
|
||||
self.confirm_theme()
|
||||
}
|
||||
Mode::Spinner => {
|
||||
// If tail row selected (Create your own…), open create form
|
||||
let names = crate::spinner::spinner_names();
|
||||
if self.selected_spinner_index >= names.len() {
|
||||
self.mode = Mode::CreateSpinner(CreateState { step: CreateStep::Interval, interval: String::from("120"), frames: String::new(), action_idx: 0 });
|
||||
} else {
|
||||
self.confirm_spinner()
|
||||
}
|
||||
}
|
||||
Mode::CreateSpinner(mut s) => {
|
||||
let mut go_overview = false;
|
||||
match s.step {
|
||||
CreateStep::Interval => { s.step = CreateStep::Frames; }
|
||||
CreateStep::Frames => { s.step = CreateStep::Action; }
|
||||
CreateStep::Action => {
|
||||
if s.action_idx == 0 {
|
||||
let interval = s.interval.trim().parse::<u64>().unwrap_or(120);
|
||||
let frames: Vec<String> = s.frames.split(',').map(|f| f.trim().to_string()).filter(|f| !f.is_empty()).collect();
|
||||
if !frames.is_empty() {
|
||||
if let Ok(home) = codex_core::config::find_codex_home() { let _ = codex_core::config::set_custom_spinner(&home, "custom", interval, &frames); }
|
||||
crate::spinner::add_custom_spinner("custom".to_string(), "Custom".to_string(), interval, frames);
|
||||
crate::spinner::switch_spinner("custom");
|
||||
self.current_spinner = "custom".to_string();
|
||||
self.revert_spinner_on_back = self.current_spinner.clone();
|
||||
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("✓ Custom spinner saved".to_string()));
|
||||
go_overview = true;
|
||||
} else {
|
||||
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("Provide at least one frame (comma-separated)".to_string()));
|
||||
}
|
||||
} else { /* Cancel → return to overview */ go_overview = true; }
|
||||
}
|
||||
}
|
||||
if go_overview { self.mode = Mode::Overview; } else { self.mode = Mode::CreateSpinner(s); }
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -311,9 +390,21 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
} => {
|
||||
match self.mode {
|
||||
Mode::Overview => self.is_complete = true,
|
||||
Mode::CreateSpinner(_) => { self.mode = Mode::Spinner; }
|
||||
_ => self.cancel_detail(),
|
||||
}
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, .. } => {
|
||||
if let Mode::CreateSpinner(ref mut s) = self.mode {
|
||||
match s.step { CreateStep::Interval => s.interval.push(c), CreateStep::Frames => s.frames.push(c), CreateStep::Action => {} }
|
||||
}
|
||||
}
|
||||
KeyEvent { code: KeyCode::Backspace, .. } => {
|
||||
if let Mode::CreateSpinner(ref mut s) = self.mode {
|
||||
let tgt = match s.step { CreateStep::Interval => &mut s.interval, CreateStep::Frames => &mut s.frames, CreateStep::Action => { return; } };
|
||||
tgt.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -410,15 +501,20 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
} else if matches!(self.mode, Mode::Themes) {
|
||||
// Header: Choose Theme
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Choose Theme",
|
||||
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
// Compute anchored window: top until middle, then center; bottom shows end
|
||||
let count = options.len();
|
||||
let visible = available_height.min(9).max(1);
|
||||
let visible = available_height.saturating_sub(1).min(9).max(1);
|
||||
let (start, _vis, _mid) = crate::util::list_window::anchored_window(
|
||||
self.selected_theme_index,
|
||||
count,
|
||||
visible,
|
||||
);
|
||||
let end = (start + visible).min(count);
|
||||
let end = (start + visible).min(count + 1); // +1 for "Create your own…"
|
||||
for i in start..end {
|
||||
let (theme_enum, name, description) = &options[i];
|
||||
let is_selected = i == self.selected_theme_index;
|
||||
@@ -460,6 +556,58 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
} else if matches!(self.mode, Mode::CreateSpinner(_)) {
|
||||
// Inline form for custom spinner with visible selection & caret
|
||||
let theme = crate::theme::current_theme();
|
||||
if let Mode::CreateSpinner(s) = &self.mode {
|
||||
let mut form_lines = Vec::new();
|
||||
form_lines.push(Line::from(Span::styled(
|
||||
"Overview » Change Spinner » Create Custom",
|
||||
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
form_lines.push(Line::from(" "));
|
||||
|
||||
let caret = Span::styled("▏", Style::default().fg(theme.info));
|
||||
// Interval line
|
||||
{
|
||||
let active = matches!(s.step, CreateStep::Interval);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
spans.push(Span::styled(if active { "› " } else { " " }, Style::default().fg(theme.keyword)));
|
||||
spans.push(Span::styled("Interval (ms): ", Style::default().fg(theme.keyword)));
|
||||
if s.interval.is_empty() && active { spans.push(Span::styled("(e.g., 120)", Style::default().fg(theme.text_dim))); spans.push(caret.clone()); }
|
||||
else if active { spans.push(Span::raw(s.interval.clone())); spans.push(caret.clone()); }
|
||||
else { spans.push(Span::raw(s.interval.clone())); }
|
||||
form_lines.push(Line::from(spans));
|
||||
}
|
||||
// Frames line
|
||||
{
|
||||
let active = matches!(s.step, CreateStep::Frames);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
spans.push(Span::styled(if active { "› " } else { " " }, Style::default().fg(theme.keyword)));
|
||||
spans.push(Span::styled("Frames (comma-separated): ", Style::default().fg(theme.keyword)));
|
||||
if s.frames.is_empty() && active { spans.push(Span::styled("(e.g., ., .., …)", Style::default().fg(theme.text_dim))); spans.push(caret.clone()); }
|
||||
else if active { spans.push(Span::raw(s.frames.clone())); spans.push(caret.clone()); }
|
||||
else { spans.push(Span::raw(s.frames.clone())); }
|
||||
form_lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
// Action buttons (Save / Cancel)
|
||||
{
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
let active = matches!(s.step, CreateStep::Action);
|
||||
let save_selected = active && s.action_idx == 0;
|
||||
let cancel_selected = active && s.action_idx == 1;
|
||||
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("[ Save ]", sel(save_selected)));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("[ Cancel ]", sel(cancel_selected)));
|
||||
form_lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
Paragraph::new(form_lines).alignment(Alignment::Left).render(body_area, buf);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Spinner: render one centered preview row per spinner, matching the composer title
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -468,8 +616,10 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let names = crate::spinner::spinner_names();
|
||||
let count = names.len();
|
||||
let visible = available_height.min(9).max(1);
|
||||
// Include an extra pseudo-row for "Create your own…"
|
||||
let count = names.len() + 1;
|
||||
// Reserve two rows (header + spacer)
|
||||
let visible = available_height.saturating_sub(2).min(9).max(1);
|
||||
let (start, _vis, _mid) = crate::util::list_window::anchored_window(
|
||||
self.selected_spinner_index,
|
||||
count,
|
||||
@@ -485,12 +635,33 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
|
||||
max_label_len = max_label_len.max(label.chars().count() as u16);
|
||||
}
|
||||
|
||||
// Render header (left-aligned) and spacer row
|
||||
let header_rect = Rect { x: body_area.x, y: body_area.y, width: body_area.width, height: 1 };
|
||||
let header = Line::from(Span::styled(
|
||||
"Overview » Change Spinner",
|
||||
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
Paragraph::new(header).alignment(Alignment::Left).render(header_rect, buf);
|
||||
if header_rect.y + 1 < body_area.y + body_area.height {
|
||||
let spacer = Rect { x: body_area.x, y: body_area.y + 1, width: body_area.width, height: 1 };
|
||||
Paragraph::new(Line::from(" ")).render(spacer, buf);
|
||||
}
|
||||
|
||||
for row_idx in 0..(end - start) {
|
||||
let i = start + row_idx;
|
||||
let y = body_area.y + row_idx as u16;
|
||||
// rows start two below (header + spacer)
|
||||
let y = body_area.y + 2 + row_idx as u16;
|
||||
if y >= body_area.y + body_area.height { break; }
|
||||
|
||||
let row_rect = Rect { x: body_area.x, y, width: body_area.width, height: 1 };
|
||||
if i >= names.len() {
|
||||
let mut spans = Vec::new();
|
||||
let is_selected = i == self.selected_spinner_index;
|
||||
spans.push(Span::styled(if is_selected { "› " } else { " " }.to_string(), Style::default().fg(if is_selected { theme.keyword } else { theme.text } )));
|
||||
spans.push(Span::styled("Create your own…", Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)));
|
||||
Paragraph::new(Line::from(spans)).render(row_rect, buf);
|
||||
continue;
|
||||
}
|
||||
let name = &names[i];
|
||||
let is_selected = i == self.selected_spinner_index;
|
||||
let def = crate::spinner::find_spinner_by_name(name).unwrap_or(crate::spinner::current_spinner());
|
||||
|
||||
@@ -451,7 +451,6 @@ enum SystemPlacement {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -483,9 +482,21 @@ impl ChatWidget<'_> {
|
||||
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +653,11 @@ impl ChatWidget<'_> {
|
||||
if k.req == req {
|
||||
last_in_req = Some(match last_in_req {
|
||||
Some(prev) => {
|
||||
if *k > prev { *k } else { prev }
|
||||
if *k > prev {
|
||||
*k
|
||||
} else {
|
||||
prev
|
||||
}
|
||||
}
|
||||
None => *k,
|
||||
});
|
||||
@@ -650,8 +665,16 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
self.internal_seq = self.internal_seq.saturating_add(1);
|
||||
match last_in_req {
|
||||
Some(last) => OrderKey { req, out: last.out, seq: last.seq.saturating_add(1) },
|
||||
None => OrderKey { req, out: i32::MIN + 2, seq: self.internal_seq },
|
||||
Some(last) => OrderKey {
|
||||
req,
|
||||
out: last.out,
|
||||
seq: last.seq.saturating_add(1),
|
||||
},
|
||||
None => OrderKey {
|
||||
req,
|
||||
out: i32::MIN + 2,
|
||||
seq: self.internal_seq,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4652,7 +4675,7 @@ impl ChatWidget<'_> {
|
||||
codex_core::config_types::ThemeName::DarkPaperLightPro => "Dark - Paper Light Pro",
|
||||
codex_core::config_types::ThemeName::Custom => "Custom",
|
||||
};
|
||||
let message = format!("✓ Theme changed to {}", theme_name);
|
||||
let message = format!("Theme changed to {}", theme_name);
|
||||
let placement = self.ui_placement_for_now();
|
||||
self.push_system_cell(
|
||||
history_cell::new_background_event(message),
|
||||
@@ -4677,7 +4700,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
// Confirmation message (replaceable system notice)
|
||||
let message = format!("✓ Spinner changed to {}", spinner_name);
|
||||
let message = format!("Spinner changed to {}", spinner_name);
|
||||
let placement = self.ui_placement_for_now();
|
||||
self.push_system_cell(
|
||||
history_cell::new_background_event(message),
|
||||
@@ -8536,7 +8559,14 @@ impl ChatWidget<'_> {
|
||||
// No conflicts; create a merge commit and continue
|
||||
let _ = Command::new("git")
|
||||
.current_dir(&work_cwd)
|
||||
.args(["commit", "-m", &format!("merge {} into {} before finalize", default_branch, branch_name)])
|
||||
.args([
|
||||
"commit",
|
||||
"-m",
|
||||
&format!(
|
||||
"merge {} into {} before finalize",
|
||||
default_branch, branch_name
|
||||
),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
} else {
|
||||
@@ -8552,7 +8582,9 @@ impl ChatWidget<'_> {
|
||||
.map(|s| s.lines().any(|l| l.starts_with('U') || l.contains("UU ")))
|
||||
.unwrap_or(false);
|
||||
if has_conflicts {
|
||||
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
let _ = tx.send(AppEvent::CodexEvent(Event {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
event_seq: 0,
|
||||
|
||||
+35
-13
@@ -47,7 +47,11 @@ lazy_static! {
|
||||
Value::Object(map) => {
|
||||
// Mixed-mode tolerant parse: for each top-level entry
|
||||
for (k, v) in map.into_iter() {
|
||||
if k == "Default" { continue; }
|
||||
// If this is the pointer entry (Default: "name"), skip it;
|
||||
// but allow a group actually named "Default" (object).
|
||||
if k == "Default" {
|
||||
if !v.is_string() { /* fall through to parse group */ } else { continue; }
|
||||
}
|
||||
match v {
|
||||
Value::Object(inner) => {
|
||||
if inner.get("interval").is_some() {
|
||||
@@ -86,16 +90,7 @@ lazy_static! {
|
||||
idx
|
||||
};
|
||||
static ref CURRENT_INDEX: RwLock<usize> = RwLock::new(*DEFAULT_INDEX);
|
||||
static ref GLOBAL_MAX_FRAME_LEN: usize = {
|
||||
let mut maxlen = 0usize;
|
||||
for s in ALL_SPINNERS.iter() {
|
||||
for f in &s.frames {
|
||||
let l = f.chars().count();
|
||||
if l > maxlen { maxlen = l; }
|
||||
}
|
||||
}
|
||||
maxlen
|
||||
};
|
||||
static ref CUSTOM_SPINNERS: RwLock<Vec<Spinner>> = RwLock::new(Vec::new());
|
||||
}
|
||||
|
||||
pub fn init_spinner(name: &str) { switch_spinner(name); }
|
||||
@@ -125,6 +120,13 @@ pub fn current_spinner() -> &'static Spinner {
|
||||
|
||||
pub fn find_spinner_by_name(name: &str) -> Option<&'static Spinner> {
|
||||
let raw = name.trim();
|
||||
// custom first
|
||||
if let Some(pos) = CUSTOM_SPINNERS.read().unwrap().iter().position(|s| s.name == raw) {
|
||||
// Leak to 'static for shared ref (only for custom preview; safe for session lifetime)
|
||||
let s = CUSTOM_SPINNERS.read().unwrap()[pos].clone();
|
||||
let b = Box::leak(Box::new(s));
|
||||
return Some(b);
|
||||
}
|
||||
ALL_SPINNERS
|
||||
.iter()
|
||||
.find(|s| s.name == raw)
|
||||
@@ -134,7 +136,11 @@ 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_names() -> Vec<String> {
|
||||
let mut v: Vec<String> = ALL_SPINNERS.iter().map(|s| s.name.clone()).collect();
|
||||
v.extend(CUSTOM_SPINNERS.read().unwrap().iter().map(|s| s.name.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn spinner_label_for(name: &str) -> String {
|
||||
find_spinner_by_name(name)
|
||||
@@ -209,4 +215,20 @@ fn vpush(out: &mut Vec<Spinner>, name: &str, sj: SpinnerJson, group_override: Op
|
||||
out.push(Spinner { name: name.to_string(), label, group, interval_ms: sj.interval, frames: sj.frames });
|
||||
}
|
||||
|
||||
pub fn global_max_frame_len() -> usize { *GLOBAL_MAX_FRAME_LEN }
|
||||
pub fn global_max_frame_len() -> usize {
|
||||
let mut maxlen = 0usize;
|
||||
for s in ALL_SPINNERS.iter() { for f in &s.frames { maxlen = maxlen.max(f.chars().count()); } }
|
||||
for s in CUSTOM_SPINNERS.read().unwrap().iter() { for f in &s.frames { maxlen = maxlen.max(f.chars().count()); } }
|
||||
maxlen
|
||||
}
|
||||
|
||||
pub fn set_custom_spinners(custom: Vec<Spinner>) { *CUSTOM_SPINNERS.write().unwrap() = custom; }
|
||||
|
||||
pub fn add_custom_spinner(name: String, label: String, interval_ms: u64, frames: Vec<String>) {
|
||||
let mut v = CUSTOM_SPINNERS.write().unwrap();
|
||||
if let Some(pos) = v.iter().position(|s| s.name == name) {
|
||||
v[pos] = Spinner { name, label, group: "Custom".to_string(), interval_ms, frames };
|
||||
} else {
|
||||
v.push(Spinner { name, label, group: "Custom".to_string(), interval_ms, frames });
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -50,8 +50,21 @@ 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
|
||||
// Initialize spinner selection and register custom spinners from config
|
||||
crate::spinner::init_spinner(&config.tui.spinner.name);
|
||||
if !config.tui.spinner.custom.is_empty() {
|
||||
let mut custom = Vec::new();
|
||||
for (name, cs) in &config.tui.spinner.custom {
|
||||
custom.push(crate::spinner::Spinner {
|
||||
name: name.clone(),
|
||||
label: crate::spinner::spinner_label_for(name),
|
||||
group: "Custom".to_string(),
|
||||
interval_ms: cs.interval,
|
||||
frames: cs.frames.clone(),
|
||||
});
|
||||
}
|
||||
crate::spinner::set_custom_spinners(custom);
|
||||
}
|
||||
// Initialize syntax highlighting preference from config
|
||||
crate::syntax_highlight::init_highlight_from_config(&config.tui.highlight);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user