diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index d73af377..8d4427e9 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -463,6 +463,10 @@ pub struct AppState { /// Horizontal scroll offset (columns) of the active job console — log lines /// drawn wider than the console wrap off-screen, so the wheel/H-wheel pans. pub console_hscroll: u16, + /// Monotonic UI repaint counter, incremented once per tick (~250ms). Drives + /// frame-based animation (e.g. the job-console braille progress spinner) + /// without threading a clock through the render path. + pub tick_count: u64, /// Scroll offset of the wide-layout right LOGS dock, counted in lines UP from /// the newest line (0 = pinned to the tail). Clamped against the buffer. pub dock_logs_scroll: u16, @@ -616,6 +620,7 @@ impl AppState { bench_detail_scroll: 0, console_scroll: 0, console_hscroll: 0, + tick_count: 0, dock_logs_scroll: 0, last_dock_area: None, chat: Vec::new(), @@ -1610,7 +1615,11 @@ async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Resu terminal.draw(|f| ui::draw(f, &mut state))?; } tokio::select! { - _ = tick.tick() => { /* repaint */ } + _ = tick.tick() => { + // Advance the animation clock so spinners cycle even while a + // job produces no new output. + state.tick_count = state.tick_count.wrapping_add(1); + } maybe_msg = rx.recv() => { match maybe_msg { Some(ClientMsg::Connecting) => state.conn = ConnState::Connecting, diff --git a/crates/rocm-dash-tui/src/jobs.rs b/crates/rocm-dash-tui/src/jobs.rs index 2d1112f6..8f964bc7 100644 --- a/crates/rocm-dash-tui/src/jobs.rs +++ b/crates/rocm-dash-tui/src/jobs.rs @@ -160,9 +160,38 @@ where } } +/// Keep only the final visible segment of a `\r`-redrawn progress line. +/// +/// Progress tools (pip, tqdm, huggingface) redraw a line in place with a bare +/// carriage return and no newline, so Tokio's `\n`-only line reader hands us +/// the whole `\r`-joined redraw sequence as one very long string. The segment +/// after the last `\r` is the final visible state of that line; keeping only it +/// gives the console clean progress instead of one long horizontal smear. Lines +/// without `\r` pass through unchanged. +fn last_cr_segment(line: &str) -> &str { + line.rsplit('\r').next().unwrap_or(line) +} + fn emit(tx: &UnboundedSender, id: &str, line: String) { let _ = tx.send(StateEvent::JobLine { id: id.to_string(), - line, + line: last_cr_segment(&line).to_string(), }); } + +#[cfg(test)] +mod tests { + use super::last_cr_segment; + + #[test] + fn collapses_carriage_return_redraws() { + // A tqdm-style redraw sequence collapses to its final state. + assert_eq!(last_cr_segment("dl: 10%\rdl: 50%\rdl: 100%"), "dl: 100%"); + } + + #[test] + fn passes_plain_lines_through() { + assert_eq!(last_cr_segment("Installing ROCm…"), "Installing ROCm…"); + assert_eq!(last_cr_segment(""), ""); + } +} diff --git a/crates/rocm-dash-tui/src/ui/job_console.rs b/crates/rocm-dash-tui/src/ui/job_console.rs index 753acf58..2396bd8f 100644 --- a/crates/rocm-dash-tui/src/ui/job_console.rs +++ b/crates/rocm-dash-tui/src/ui/job_console.rs @@ -79,13 +79,16 @@ pub fn status_label(job: &JobState, theme: &Theme) -> (String, ratatui::style::C } } -/// Render the job console centered over `area`. `scroll` is `(vertical_line, -/// horizontal_col)` of the first visible cell (the caller clamps it). +/// Render the job console centered over `area`. +/// +/// `scroll` is `(vertical_line, horizontal_col)` of the first visible cell (the +/// caller clamps it). `tick_count` drives the running-job progress spinner. pub fn draw_job_console( f: &mut Frame, area: Rect, job: &JobState, scroll: (u16, u16), + tick_count: u64, theme: &Theme, ) { let popup = centered_rect(90, 84, 140, 40, area); @@ -104,25 +107,47 @@ pub fn draw_job_console( ]) .split(inner); - // Header: status badge. + // Header: status badge, plus an animated braille spinner and parsed + // percentage while the job runs so progress reads as live motion. let (label, color) = status_label(job, theme); - f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled( - " status ", + let mut header = vec![ + Span::styled( + " status ", + Style::default() + .fg(theme.bg) + .bg(color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]; + if matches!(job.status, JobStatus::Running) { + header.push(Span::styled( + format!("{} ", crate::ui::spinner::spinner_frame(tick_count)), + Style::default().fg(theme.accent), + )); + } + header.push(Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + if matches!(job.status, JobStatus::Running) { + // The most recent line carrying a percentage wins (later output is more + // current than earlier); silently omit the figure when none is present. + if let Some(pct) = job + .output + .iter() + .rev() + .find_map(|l| crate::ui::spinner::parse_progress_pct(l)) + { + header.push(Span::styled( + format!(" {pct}%"), Style::default() - .fg(theme.bg) - .bg(color) + .fg(theme.accent) .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ), - ])), - rows[0], - ); + )); + } + } + f.render_widget(Paragraph::new(Line::from(header)), rows[0]); // Body: streamed output lines. let lines: Vec = job diff --git a/crates/rocm-dash-tui/src/ui/mod.rs b/crates/rocm-dash-tui/src/ui/mod.rs index 71c782f2..41b3f797 100644 --- a/crates/rocm-dash-tui/src/ui/mod.rs +++ b/crates/rocm-dash-tui/src/ui/mod.rs @@ -29,6 +29,7 @@ pub mod runtime_manager; pub mod serve_wizard; pub mod services_manager; pub mod sparkline; +pub mod spinner; pub mod tabs; pub mod theme; pub mod update_manager; @@ -229,6 +230,7 @@ fn draw_active_manager(f: &mut Frame, rect: Rect, state: &AppState, theme: &Them rect, job, (state.console_scroll, state.console_hscroll), + state.tick_count, theme, ); return; diff --git a/crates/rocm-dash-tui/src/ui/spinner.rs b/crates/rocm-dash-tui/src/ui/spinner.rs new file mode 100644 index 00000000..45450732 --- /dev/null +++ b/crates/rocm-dash-tui/src/ui/spinner.rs @@ -0,0 +1,77 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Animated braille spinner and progress-percentage parsing. +//! +//! Long jobs stream progress via carriage-return redraws (pip, tqdm, +//! huggingface). The console pairs an animated braille glyph with the +//! percentage parsed from the latest output line, so a running job reads as +//! live progress instead of a wall of text. Pure helpers — no I/O, no state. + +/// Braille spinner frames, advanced one step per UI repaint tick (~250ms). +const BRAILLE_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// The spinner glyph for a given monotonic tick count. +pub const fn spinner_frame(tick: u64) -> char { + BRAILLE_FRAMES[(tick % BRAILLE_FRAMES.len() as u64) as usize] +} + +/// Parse a progress percentage (0–100) from a raw output line, if present. +/// +/// Matches the last `NN%` token — the form pip, tqdm, and huggingface all +/// print. Returns `None` when no percent token is found or the value falls +/// outside 0–100 (e.g. a literal `%` with no leading number, or a bogus value). +pub fn parse_progress_pct(line: &str) -> Option { + let pct_idx = line.rfind('%')?; + // Walk back over the numeric run (digits and one-or-more dots) before '%'. + let rev: String = line[..pct_idx] + .chars() + .rev() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if rev.is_empty() { + return None; + } + let num: String = rev.chars().rev().collect(); + let val: f32 = num.parse().ok()?; + if (0.0..=100.0).contains(&val) { + Some(val.round() as u8) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spinner_cycles_through_all_frames() { + assert_eq!(spinner_frame(0), '⠋'); + assert_eq!(spinner_frame(1), '⠙'); + // Wraps after the last frame. + assert_eq!(spinner_frame(10), spinner_frame(0)); + assert_eq!(spinner_frame(21), spinner_frame(1)); + } + + #[test] + fn parses_percent_from_progress_lines() { + assert_eq!( + parse_progress_pct("Downloading model.safetensors: 45%|████▌ | 2.3G/5.2G"), + Some(45) + ); + assert_eq!(parse_progress_pct("100%|██████████| done"), Some(100)); + assert_eq!(parse_progress_pct("progress: 0%"), Some(0)); + // Decimal rounds to nearest whole percent. + assert_eq!(parse_progress_pct("45.6% complete"), Some(46)); + } + + #[test] + fn rejects_non_progress_lines() { + assert_eq!(parse_progress_pct("no percent here"), None); + assert_eq!(parse_progress_pct("just a % sign"), None); + // Out of range is not a percentage. + assert_eq!(parse_progress_pct("cpu at 250% load"), None); + } +} diff --git a/crates/rocm-dash-tui/src/ui/tabs/instances.rs b/crates/rocm-dash-tui/src/ui/tabs/instances.rs index cd472fba..ca2a9d52 100644 --- a/crates/rocm-dash-tui/src/ui/tabs/instances.rs +++ b/crates/rocm-dash-tui/src/ui/tabs/instances.rs @@ -878,6 +878,7 @@ mod tests { bench_detail_scroll: 0, console_scroll: 0, console_hscroll: 0, + tick_count: 0, dock_logs_scroll: 0, last_dock_area: None, chat: Vec::new(), diff --git a/crates/rocm-dash-tui/tests/wave0_job_bridge.rs b/crates/rocm-dash-tui/tests/wave0_job_bridge.rs index 087ea626..7faa5603 100644 --- a/crates/rocm-dash-tui/tests/wave0_job_bridge.rs +++ b/crates/rocm-dash-tui/tests/wave0_job_bridge.rs @@ -159,7 +159,7 @@ fn job_console_snapshot_renders_status_and_output() { } let job = state.job("serve").unwrap(); let out = render(140, 32, |f| { - draw_job_console(f, f.area(), job, (0, 0), &theme); + draw_job_console(f, f.area(), job, (0, 0), 0, &theme); }); assert!(out.contains("rocm serve llama3"), "title shows the command");