ux(spinners): equalize right border to x - text_len; dim non-selected rows (border + spinner + text) to 50%\n\n- Right rule = left rule logic; remove trailing filler\n- Non-selected rows use text_dim + DIM for all parts\n- Preserve JSON order and global width reference\n\nBuild: ./build-fast.sh clean

This commit is contained in:
James Peter
2025-09-10 15:31:40 +10:00
parent 87de031e63
commit 7e865ac9f2
4 changed files with 282 additions and 1740 deletions
File diff suppressed because it is too large Load Diff
@@ -477,52 +477,51 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
);
let end = (start + visible).min(count);
let mut prev_group: Option<String> = None;
let mut y = body_area.y;
for i in start..end {
// Compute fixed column widths globally so rows never jump when scrolling
let max_frame_len: u16 = crate::spinner::global_max_frame_len() as u16;
let mut max_label_len: u16 = 0;
for name in names.iter() {
let label = crate::spinner::spinner_label_for(name);
max_label_len = max_label_len.max(label.chars().count() as u16);
}
for row_idx in 0..(end - start) {
let i = start + row_idx;
let y = body_area.y + row_idx as u16;
if y >= body_area.y + body_area.height { break; }
let name = names[i].clone();
let row_rect = Rect { x: body_area.x, y, width: body_area.width, height: 1 };
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());
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);
// Compose centered preview like composer title: rules + spinner + name + ...
let border = Style::default().fg(crate::colors::border());
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
// Aligned columns (centered block):
// selector (2) | left_rule | space | spinner (right‑aligned to max) | space | label (padded to max) | space | right_rule
let border = if is_selected { Style::default().fg(crate::colors::border()) } else { Style::default().fg(theme.text_dim).add_modifier(Modifier::DIM) };
let fg = if is_selected { Style::default().fg(crate::colors::info()) } else { Style::default().fg(theme.text_dim).add_modifier(Modifier::DIM) };
let label = crate::spinner::spinner_label_for(name);
// 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; }
}
// Use border-based alignment per spec
let spinner_len = frame.chars().count() as u16;
let text_len = (label.chars().count() as u16).saturating_add(3); // label + "..."
let x: u16 = max_frame_len.saturating_add(5);
let left_rule = x.saturating_sub(spinner_len);
let right_rule = x.saturating_sub(text_len);
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 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(" ")); }
// selector
spans.push(Span::styled(if is_selected { "› " } else { " " }.to_string(), Style::default().fg(if is_selected { theme.keyword } else { theme.text } )));
// left rule
spans.push(Span::styled("─".repeat(left_rule as usize), border));
// spinner
spans.push(Span::styled(frame, fg));
spans.push(Span::raw(" "));
spans.push(Span::styled(content, fg));
spans.push(Span::raw(" "));
// label with dots
spans.push(Span::styled(format!("{}...", label), fg));
// right rule (match left border logic: x - text_len)
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
+52 -9
View File
@@ -464,20 +464,22 @@ impl ChatWidget<'_> {
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 {
// Derive a stable request bucket for system notices when OrderMeta is absent.
// Default to the current provider request if known; else use a sticky
// pre‑turn synthetic req=1 to group UI confirmations before the first turn.
// If a user prompt for the next turn is already queued, attach new
// system notices to the upcoming request to avoid retroactive inserts.
let mut 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)
};
if order.is_none() && self.pending_user_prompts_for_next_turn > 0 {
req = req.saturating_add(1);
}
self.internal_seq = self.internal_seq.saturating_add(1);
match placement {
@@ -615,6 +617,44 @@ impl ChatWidget<'_> {
}
}
/// Like near_time_key but never advances to the next request when a prompt is queued.
/// Use this for late, provider-origin items that lack OrderMeta (e.g., PlanUpdate)
/// so they remain attached to the current/last request instead of jumping forward.
fn near_time_key_current_req(
&mut self,
order: Option<&codex_core::protocol::OrderMeta>,
) -> OrderKey {
if let Some(om) = order {
return Self::order_key_from_order_meta(om);
}
let req = if self.last_seen_request_index > 0 {
self.last_seen_request_index
} else {
if self.current_request_index < self.last_seen_request_index {
self.current_request_index = self.last_seen_request_index;
}
self.current_request_index = self.current_request_index.saturating_add(1);
self.current_request_index
};
let mut last_in_req: Option<OrderKey> = None;
for k in &self.cell_order_seq {
if k.req == req {
last_in_req = Some(match last_in_req {
Some(prev) => {
if *k > prev { *k } else { prev }
}
None => *k,
});
}
}
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 },
}
}
// After inserting a non‑reasoning cell during streaming, restore the
// in‑progress indicator on the latest reasoning cell so the ellipsis
// remains visible while the model continues.
@@ -3342,8 +3382,11 @@ impl ChatWidget<'_> {
}
EventMsg::PlanUpdate(update) => {
// Insert plan updates at the time they occur. If the provider
// supplied OrderMeta, honor it. Otherwise, derive a near‑time key.
let key = self.near_time_key(event.order.as_ref());
// supplied OrderMeta, honor it. Otherwise, derive a key within
// the current (last-seen) request — do NOT advance to the next
// request when a prompt is already queued, since these belong
// to the in-flight turn.
let key = self.near_time_key_current_req(event.order.as_ref());
let _ = self.history_insert_with_key_global(
Box::new(history_cell::new_plan_update(update)),
key,
+20 -7
View File
@@ -1,7 +1,7 @@
use lazy_static::lazy_static;
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;
// Keep JSON insertion order; no need for BTreeMap
use std::sync::RwLock;
#[derive(Debug, Clone)]
@@ -52,10 +52,11 @@ lazy_static! {
}
}
} 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);
// Flat: each entry includes optional label/group; preserve JSON order
for (name, val_entry) in map.into_iter() {
if let Ok(sj) = serde_json::from_value::<SpinnerJson>(val_entry) {
vpush(&mut list, &name, sj, None);
}
}
}
}
@@ -65,13 +66,22 @@ lazy_static! {
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 });
// Preserve JSON order: no reordering here
list
};
static ref CURRENT_INDEX: RwLock<usize> = RwLock::new(
ALL_SPINNERS.iter().position(|s| s.name == "diamond").unwrap_or(0)
);
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
};
}
pub fn init_spinner(name: &str) { switch_spinner(name); }
@@ -112,6 +122,7 @@ pub fn spinner_label_for(name: &str) -> String {
.unwrap_or_else(|| humanize(name))
}
#[allow(dead_code)]
pub fn spinner_group_for(name: &str) -> &'static str {
if let Some(s) = find_spinner_by_name(name) { return &s.group; }
derive_group(name)
@@ -177,3 +188,5 @@ fn vpush(out: &mut Vec<Spinner>, name: &str, sj: SpinnerJson, group_override: Op
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 });
}
pub fn global_max_frame_len() -> usize { *GLOBAL_MAX_FRAME_LEN }