Add spinner animation to TUI status indicator (#1917)

## Summary
- add a pulsing dot loader before the shimmering `Working` label in the
status indicator widget and include a small test asserting the spinner
character is rendered
- also fix a small bug in the ran command header by adding a space
between the  and `Ran command`


https://github.com/user-attachments/assets/6768c9d2-e094-49cb-ad51-44bcac10aa6f

## Testing
- `just fmt`
- `just fix` *(failed: E0658 `let` expressions in core/src/client.rs)*
- `cargo test --all-features` *(failed: E0658 `let` expressions in
core/src/client.rs)*

------
https://chatgpt.com/codex/tasks/task_i_68941bffdb948322b0f4190bc9dbe7f6

---------

Co-authored-by: aibrahim-oai <aibrahim@openai.com>
This commit is contained in:
Ed Bayes
2025-08-07 01:45:04 -07:00
committed by GitHub
parent 13982d6b4e
commit 20084facfe
2 changed files with 33 additions and 3 deletions

View File

@@ -262,7 +262,7 @@ impl HistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
let command_escaped = strip_bash_lc_and_escape(&command);
lines.push(Line::from(vec![
"⚡Ran command ".magenta(),
" Ran command ".magenta(),
command_escaped.into(),
]));
@@ -556,7 +556,7 @@ impl HistoryCell {
let mut header: Vec<Span> = Vec::new();
header.push(Span::raw("📋"));
header.push(Span::styled(
"Updated",
" Updated",
Style::default().add_modifier(Modifier::BOLD).magenta(),
));
header.push(Span::raw(" to do list ["));

View File

@@ -213,9 +213,20 @@ impl WidgetRef for StatusIndicatorWidget {
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let inner_width = area.width as usize;
// Compose a single status line like: "▌ Working (Xs • Ctrl c to interrupt) <logs>"
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("", Style::default().fg(Color::Cyan)));
// Simple dim spinner to the left of the header.
let spinner_frames = ['·', '•', '●', '•'];
const SPINNER_SLOWDOWN: usize = 2;
let spinner_ch = spinner_frames[(idx / SPINNER_SLOWDOWN) % spinner_frames.len()];
spans.push(Span::styled(
spinner_ch.to_string(),
Style::default().fg(Color::DarkGray),
));
spans.push(Span::raw(" "));
// Space after header
// Animated header after the left bar
spans.extend(animated_spans);
// Space between header and bracket block
@@ -324,4 +335,23 @@ mod tests {
}
assert!(row.contains("Working"), "expected Working header: {row:?}");
}
#[test]
fn spinner_is_rendered() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx);
w.restart_with_text("Hello".to_string());
std::thread::sleep(std::time::Duration::from_millis(120));
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
w.render_ref(area, &mut buf);
let ch = buf[(2, 0)].symbol().chars().next().unwrap_or(' ');
assert!(
matches!(ch, '·' | '•' | '●'),
"expected spinner char at col 2: {ch:?}"
);
}
}