Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/rocm-dash-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 30 additions & 1 deletion crates/rocm-dash-tui/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StateEvent>, 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(""), "");
}
}
61 changes: 43 additions & 18 deletions crates/rocm-dash-tui/src/ui/job_console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<Line> = job
Expand Down
2 changes: 2 additions & 0 deletions crates/rocm-dash-tui/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions crates/rocm-dash-tui/src/ui/spinner.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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);
}
}
1 change: 1 addition & 0 deletions crates/rocm-dash-tui/src/ui/tabs/instances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion crates/rocm-dash-tui/tests/wave0_job_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading