feat: add TUI run status spinner
This commit is contained in:
+28
-5
@@ -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());
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-39
@@ -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,14 +60,22 @@ 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(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(mini_view_h), // task mini-view (0 when empty)
|
||||||
Constraint::Length(1), // separator
|
Constraint::Length(1), // separator
|
||||||
Constraint::Length(1), // status
|
Constraint::Length(1), // status
|
||||||
@@ -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![
|
||||||
|
|||||||
Reference in New Issue
Block a user