diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 0c620570..d9a8e09d 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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, 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()); diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 541ff446..cc8316ab 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -531,15 +531,19 @@ enum E2eRewindInput { enum LoopInput

{ Terminal(TerminalEventResult), Worker(P), + Tick, } -async fn next_loop_input( +async fn next_loop_input( term_rx: &mut mpsc::UnboundedReceiver, connected: bool, pod_next: F, + animate: bool, + animation_tick: T, ) -> LoopInput

where F: Future, + 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( client: &mut ConsoleConnection, ) -> Result<(), Box> { 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( 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( 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::(); + + assert!(matches!( + next_loop_input( + &mut rx, + true, + std::future::pending::>(), + 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')); } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 63706fe2..bd5ae01d 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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,19 +60,27 @@ 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_h), // task mini-view (0 when empty) - Constraint::Length(1), // separator - Constraint::Length(1), // status - Constraint::Length(input_height), // input area - Constraint::Length(1), // actionbar + Constraint::Min(0), // history 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 + Constraint::Length(input_height), // input area + Constraint::Length(1), // actionbar ]) .split(area); @@ -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![