feat: add TUI run status spinner
This commit is contained in:
+28
-5
@@ -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());
|
||||
|
||||
@@ -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
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user