mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
+177
-1690
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user