feat: add TUI run status spinner

This commit is contained in:
2026-08-31 12:46:47 +09:00
parent a7f09fad98
commit 13a021c480
3 changed files with 205 additions and 55 deletions
+28 -5
View File
@@ -249,6 +249,9 @@ pub struct App {
pub running: bool,
/// True while the Worker is in `WorkerStatus::Paused`.
pub paused: bool,
/// Local observation time for the current run. Used only for live UI
/// elapsed time and spinner animation; it is not persisted in history.
pub run_started_at: Option<Instant>,
pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the
/// current turn's LLM requests — i.e. the net tokens this turn
@@ -352,6 +355,7 @@ impl App {
worker_status: WorkerStatus::Idle,
running: false,
paused: false,
run_started_at: None,
run_requests: 0,
run_upload_tokens: 0,
run_output_tokens: 0,
@@ -553,11 +557,17 @@ impl App {
}
pub fn set_worker_status(&mut self, status: WorkerStatus) {
let was_running = self.running;
self.worker_status = status;
self.running = status == WorkerStatus::Running;
self.paused = status == WorkerStatus::Paused;
if self.running {
if !was_running {
self.run_started_at = Some(Instant::now());
}
self.quit_confirm = None;
} else {
self.run_started_at = None;
}
}
@@ -1121,11 +1131,13 @@ impl App {
self.latest_llm_wait_event = None;
self.assistant_streaming = false;
}
// UI consumers of Invoke / LlmCall semantics are out of scope
// for `tickets/invoke-turn-llmcall-semantics.md`; events flow
// through to subscribers but the TUI currently derives its
// turn header from `UserMessage` / `SystemItem` arrivals.
Event::InvokeStart { .. } | Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
Event::InvokeStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
}
// UI consumers of per-attempt LlmCall semantics remain out of scope;
// the run-level status starts at InvokeStart and TurnStart counts each
// LLM request within that run.
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
self.latest_llm_wait_event = None;
}
Event::LlmRetry {
@@ -3377,6 +3389,17 @@ mod completion_flow_tests {
}
}
#[test]
fn running_status_starts_and_stops_live_run_clock() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
assert!(app.run_started_at.is_some());
app.set_worker_status(WorkerStatus::Idle);
assert!(app.run_started_at.is_none());
}
#[test]
fn running_submit_is_queued_locally_and_clears_composer() {
let mut app = App::new("test".into());
+62 -5
View File
@@ -531,15 +531,19 @@ enum E2eRewindInput {
enum LoopInput<P> {
Terminal(TerminalEventResult),
Worker(P),
Tick,
}
async fn next_loop_input<P, F>(
async fn next_loop_input<P, F, T>(
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
connected: bool,
pod_next: F,
animate: bool,
animation_tick: T,
) -> LoopInput<P>
where
F: Future<Output = P>,
T: Future,
{
tokio::select! {
biased;
@@ -553,6 +557,7 @@ where
}))
}
event = pod_next, if connected => LoopInput::Worker(event),
_ = animation_tick, if animate => LoopInput::Tick,
}
}
@@ -608,6 +613,8 @@ async fn run_loop<T: Socket>(
client: &mut ConsoleConnection<T>,
) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
let mut animation_tick = tokio::time::interval(Duration::from_millis(80));
animation_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
terminal.draw(|f| ui::draw(f, app))?;
@@ -626,7 +633,15 @@ async fn run_loop<T: Socket>(
continue;
}
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
match next_loop_input(
&mut term_rx,
app.connected,
client.next_event(),
app.running,
animation_tick.tick(),
)
.await
{
LoopInput::Terminal(term_event) => {
handle_terminal_event(app, client, term_event?).await?;
}
@@ -642,6 +657,7 @@ async fn run_loop<T: Socket>(
app.push_error("Connection lost");
}
},
LoopInput::Tick => {}
}
terminal.draw(|f| ui::draw(f, app))?;
@@ -1216,6 +1232,23 @@ mod tests {
);
}
#[tokio::test]
async fn animation_tick_wakes_loop_while_running() {
let (_tx, mut rx) = mpsc::unbounded_channel::<TerminalEventResult>();
assert!(matches!(
next_loop_input(
&mut rx,
true,
std::future::pending::<Option<u8>>(),
true,
std::future::ready(()),
)
.await,
LoopInput::Tick
));
}
#[tokio::test]
async fn terminal_event_is_selected_before_ready_worker_event() {
let (tx, mut rx) = mpsc::unbounded_channel();
@@ -1225,7 +1258,15 @@ mod tests {
))))
.unwrap();
match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await {
match next_loop_input(
&mut rx,
true,
std::future::ready(Some(())),
false,
std::future::pending::<()>(),
)
.await
{
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('x'));
}
@@ -1237,7 +1278,15 @@ mod tests {
async fn terminal_event_is_preserved_after_worker_event_wins() {
let (tx, mut rx) = mpsc::unbounded_channel();
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
match next_loop_input(
&mut rx,
true,
std::future::ready(Some(1_u8)),
false,
std::future::pending::<()>(),
)
.await
{
LoopInput::Worker(Some(1)) => {}
_ => panic!("expected the first ready Worker event to win before any terminal input"),
}
@@ -1248,7 +1297,15 @@ mod tests {
))))
.unwrap();
match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await {
match next_loop_input(
&mut rx,
true,
std::future::ready(Some(2_u8)),
false,
std::future::pending::<()>(),
)
.await
{
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('y'));
}
+109 -39
View File
@@ -36,6 +36,9 @@ use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
use crate::text_selection::{HistoryViewport, SelectionRow};
use crate::view_mode::Mode;
const RUN_SPINNER_FRAMES: [&str; 8] = ["", "", "", "", "", "", "", ""];
const RUN_SPINNER_FRAME_MS: u128 = 80;
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
// Input content starts after the prompt (`> ` or `: `), so the width
@@ -57,14 +60,22 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
let tabs = app.worker_view_tabs();
let show_tabs = tabs.len() > 1;
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs);
// One blank row separates the history tail from the mini-view so
// the latest message doesn't visually crash into the task summary.
// Folds away with the mini-view when there are no tasks.
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 };
let run_status_h = u16::from(app.running);
let run_status_gap = run_status_h;
// One blank row separates the history tail from the run/task mini-view so
// the latest message doesn't visually crash into operational status.
// Folds away when neither run status nor tasks are visible.
let mini_view_gap = if mini_view_h > 0 || run_status_h > 0 {
1
} else {
0
};
let chunks = Layout::vertical([
Constraint::Min(0), // history view
Constraint::Length(mini_view_gap), // gap above mini-view
Constraint::Length(mini_view_gap), // gap above run/task mini-view
Constraint::Length(run_status_h), // active run status
Constraint::Length(run_status_gap), // gap below active run status
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(1), // separator
Constraint::Length(1), // status
@@ -82,24 +93,27 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
} else {
draw_history(frame, app, chunks[0]);
}
if run_status_h > 0 {
draw_run_status(frame, app, chunks[2]);
}
if mini_view_h > 0 {
draw_task_mini_view(
frame,
&app.selected_worker_view().task_store,
&tabs,
chunks[2],
chunks[4],
);
}
draw_separator(frame, chunks[3]);
draw_separator(frame, chunks[5]);
// Status/composer/control surfaces remain parent-owned. View selection changes
// only transcript/task presentation and never implies SubWorker control.
draw_status(frame, app, chunks[4]);
draw_input(frame, app, &input_render, chunks[5]);
draw_actionbar(frame, app, chunks[6]);
draw_status(frame, app, chunks[6]);
draw_input(frame, app, &input_render, chunks[7]);
draw_actionbar(frame, app, chunks[8]);
if app.is_command_mode() {
draw_command_popup(frame, app, chunks[5]);
draw_command_popup(frame, app, chunks[7]);
} else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
draw_completion_popup(frame, state, chunks[5]);
draw_completion_popup(frame, state, chunks[7]);
}
}
@@ -120,6 +134,65 @@ fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 {
(active_shown as u16).saturating_add(1)
}
fn draw_run_status(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(run_status_line(app, Instant::now())), area);
}
fn run_status_line(app: &App, now: Instant) -> Line<'static> {
let elapsed = app
.run_started_at
.and_then(|started_at| now.checked_duration_since(started_at))
.unwrap_or_default();
let spinner_index =
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
let request_label = if app.run_requests == 1 {
"1 req".to_owned()
} else {
format!("{} reqs", app.run_requests)
};
Line::from(vec![
Span::styled(
RUN_SPINNER_FRAMES[spinner_index],
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
fmt_run_elapsed(elapsed.as_secs()),
Style::default().fg(Color::Gray),
),
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled(request_label, Style::default().fg(Color::Gray)),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled("", Style::default().fg(Color::Green)),
Span::styled(
fmt_tokens(app.run_upload_tokens),
Style::default().fg(Color::Green),
),
Span::styled("/", Style::default().fg(Color::DarkGray)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(
fmt_tokens(app.run_output_tokens),
Style::default().fg(Color::Yellow),
),
])
}
fn fmt_run_elapsed(secs: u64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{hours}h {minutes}m {seconds:02}s")
} else if minutes > 0 {
format!("{minutes}m {seconds:02}s")
} else {
format!("{seconds}s")
}
}
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
if area.height == 0 || area.width == 0 {
return;
@@ -1726,32 +1799,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
),
];
if app.running {
let status = if let Some(wait_event) = &app.latest_llm_wait_event {
format!(
"request: {} | ↑{}/↓{} | {wait_event}",
app.run_requests,
fmt_tokens(app.run_upload_tokens),
fmt_tokens(app.run_output_tokens),
)
} else if let Some(tool) = &app.current_tool {
format!(
"request: {} | ↑{}/↓{} | tool: {tool}",
app.run_requests,
fmt_tokens(app.run_upload_tokens),
fmt_tokens(app.run_output_tokens),
)
} else {
format!(
"request: {} | ↑{}/↓{}",
app.run_requests,
fmt_tokens(app.run_upload_tokens),
fmt_tokens(app.run_output_tokens),
)
};
spans.push(Span::raw(" | "));
spans.push(Span::styled(status, Style::default().fg(Color::Yellow)));
} else if app.paused {
if app.paused {
spans.push(Span::raw(" | "));
spans.push(Span::styled(
"paused",
@@ -1763,7 +1811,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
" — Enter to resume, Ctrl-X to cancel, type to start new turn",
Style::default().fg(Color::DarkGray),
));
} else {
} else if !app.running {
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
}
@@ -2053,6 +2101,28 @@ mod tests {
use protocol::WorkerStatus;
use std::time::{Duration, Instant};
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
let mut app = App::new("worker".into());
app.run_started_at = now.checked_sub(Duration::from_millis(160));
app.run_requests = 1;
app.run_upload_tokens = 1_200;
app.run_output_tokens = 45;
assert_eq!(
line_text(&run_status_line(&app, now)),
"⣟ 0s ・ 1 req | ↑1.2k/↓45"
);
}
#[test]
fn run_elapsed_uses_console_style_units() {
assert_eq!(fmt_run_elapsed(9), "9s");
assert_eq!(fmt_run_elapsed(65), "1m 05s");
assert_eq!(fmt_run_elapsed(3_726), "1h 2m 06s");
}
#[test]
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
let tabs = vec![