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, pub running: bool,
/// True while the Worker is in `WorkerStatus::Paused`. /// True while the Worker is in `WorkerStatus::Paused`.
pub paused: bool, 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, pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the /// Sum of `input_tokens - cache_read_input_tokens` across the
/// current turn's LLM requests — i.e. the net tokens this turn /// current turn's LLM requests — i.e. the net tokens this turn
@@ -352,6 +355,7 @@ impl App {
worker_status: WorkerStatus::Idle, worker_status: WorkerStatus::Idle,
running: false, running: false,
paused: false, paused: false,
run_started_at: None,
run_requests: 0, run_requests: 0,
run_upload_tokens: 0, run_upload_tokens: 0,
run_output_tokens: 0, run_output_tokens: 0,
@@ -553,11 +557,17 @@ impl App {
} }
pub fn set_worker_status(&mut self, status: WorkerStatus) { pub fn set_worker_status(&mut self, status: WorkerStatus) {
let was_running = self.running;
self.worker_status = status; self.worker_status = status;
self.running = status == WorkerStatus::Running; self.running = status == WorkerStatus::Running;
self.paused = status == WorkerStatus::Paused; self.paused = status == WorkerStatus::Paused;
if self.running { if self.running {
if !was_running {
self.run_started_at = Some(Instant::now());
}
self.quit_confirm = None; self.quit_confirm = None;
} else {
self.run_started_at = None;
} }
} }
@@ -1121,11 +1131,13 @@ impl App {
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
self.assistant_streaming = false; self.assistant_streaming = false;
} }
// UI consumers of Invoke / LlmCall semantics are out of scope Event::InvokeStart { .. } => {
// for `tickets/invoke-turn-llmcall-semantics.md`; events flow self.set_worker_status(WorkerStatus::Running);
// through to subscribers but the TUI currently derives its }
// turn header from `UserMessage` / `SystemItem` arrivals. // UI consumers of per-attempt LlmCall semantics remain out of scope;
Event::InvokeStart { .. } | Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => { // 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; self.latest_llm_wait_event = None;
} }
Event::LlmRetry { 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] #[test]
fn running_submit_is_queued_locally_and_clears_composer() { fn running_submit_is_queued_locally_and_clears_composer() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
+62 -5
View File
@@ -531,15 +531,19 @@ enum E2eRewindInput {
enum LoopInput<P> { enum LoopInput<P> {
Terminal(TerminalEventResult), Terminal(TerminalEventResult),
Worker(P), Worker(P),
Tick,
} }
async fn next_loop_input<P, F>( async fn next_loop_input<P, F, T>(
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>, term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
connected: bool, connected: bool,
pod_next: F, pod_next: F,
animate: bool,
animation_tick: T,
) -> LoopInput<P> ) -> LoopInput<P>
where where
F: Future<Output = P>, F: Future<Output = P>,
T: Future,
{ {
tokio::select! { tokio::select! {
biased; biased;
@@ -553,6 +557,7 @@ where
})) }))
} }
event = pod_next, if connected => LoopInput::Worker(event), 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>, client: &mut ConsoleConnection<T>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; 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))?; terminal.draw(|f| ui::draw(f, app))?;
@@ -626,7 +633,15 @@ async fn run_loop<T: Socket>(
continue; 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) => { LoopInput::Terminal(term_event) => {
handle_terminal_event(app, client, term_event?).await?; handle_terminal_event(app, client, term_event?).await?;
} }
@@ -642,6 +657,7 @@ async fn run_loop<T: Socket>(
app.push_error("Connection lost"); app.push_error("Connection lost");
} }
}, },
LoopInput::Tick => {}
} }
terminal.draw(|f| ui::draw(f, app))?; 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] #[tokio::test]
async fn terminal_event_is_selected_before_ready_worker_event() { async fn terminal_event_is_selected_before_ready_worker_event() {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
@@ -1225,7 +1258,15 @@ mod tests {
)))) ))))
.unwrap(); .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))) => { LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('x')); assert_eq!(key.code, KeyCode::Char('x'));
} }
@@ -1237,7 +1278,15 @@ mod tests {
async fn terminal_event_is_preserved_after_worker_event_wins() { async fn terminal_event_is_preserved_after_worker_event_wins() {
let (tx, mut rx) = mpsc::unbounded_channel(); 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)) => {} LoopInput::Worker(Some(1)) => {}
_ => panic!("expected the first ready Worker event to win before any terminal input"), _ => panic!("expected the first ready Worker event to win before any terminal input"),
} }
@@ -1248,7 +1297,15 @@ mod tests {
)))) ))))
.unwrap(); .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))) => { LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('y')); assert_eq!(key.code, KeyCode::Char('y'));
} }
+115 -45
View File
@@ -36,6 +36,9 @@ use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::text_selection::{HistoryViewport, SelectionRow};
use crate::view_mode::Mode; 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) { pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area(); let area = frame.area();
// Input content starts after the prompt (`> ` or `: `), so the width // Input content starts after the prompt (`> ` or `: `), so the width
@@ -57,19 +60,27 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
let tabs = app.worker_view_tabs(); let tabs = app.worker_view_tabs();
let show_tabs = tabs.len() > 1; let show_tabs = tabs.len() > 1;
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs); 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 let run_status_h = u16::from(app.running);
// the latest message doesn't visually crash into the task summary. let run_status_gap = run_status_h;
// Folds away with the mini-view when there are no tasks. // One blank row separates the history tail from the run/task mini-view so
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 }; // 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([ let chunks = Layout::vertical([
Constraint::Min(0), // history view 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(mini_view_h), // task mini-view (0 when empty) Constraint::Length(run_status_h), // active run status
Constraint::Length(1), // separator Constraint::Length(run_status_gap), // gap below active run status
Constraint::Length(1), // status Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(input_height), // input area Constraint::Length(1), // separator
Constraint::Length(1), // actionbar Constraint::Length(1), // status
Constraint::Length(input_height), // input area
Constraint::Length(1), // actionbar
]) ])
.split(area); .split(area);
@@ -82,24 +93,27 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
} else { } else {
draw_history(frame, app, chunks[0]); draw_history(frame, app, chunks[0]);
} }
if run_status_h > 0 {
draw_run_status(frame, app, chunks[2]);
}
if mini_view_h > 0 { if mini_view_h > 0 {
draw_task_mini_view( draw_task_mini_view(
frame, frame,
&app.selected_worker_view().task_store, &app.selected_worker_view().task_store,
&tabs, &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 // Status/composer/control surfaces remain parent-owned. View selection changes
// only transcript/task presentation and never implies SubWorker control. // only transcript/task presentation and never implies SubWorker control.
draw_status(frame, app, chunks[4]); draw_status(frame, app, chunks[6]);
draw_input(frame, app, &input_render, chunks[5]); draw_input(frame, app, &input_render, chunks[7]);
draw_actionbar(frame, app, chunks[6]); draw_actionbar(frame, app, chunks[8]);
if app.is_command_mode() { 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()) { } 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) (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) { fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
if area.height == 0 || area.width == 0 { if area.height == 0 || area.width == 0 {
return; return;
@@ -1726,32 +1799,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
), ),
]; ];
if app.running { if app.paused {
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 {
spans.push(Span::raw(" | ")); spans.push(Span::raw(" | "));
spans.push(Span::styled( spans.push(Span::styled(
"paused", "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", " — Enter to resume, Ctrl-X to cancel, type to start new turn",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} else { } else if !app.running {
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
} }
@@ -2053,6 +2101,28 @@ mod tests {
use protocol::WorkerStatus; use protocol::WorkerStatus;
use std::time::{Duration, Instant}; 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] #[test]
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() { fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
let tabs = vec![ let tabs = vec![