use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::live_model_name;
use crate::tui::app::App;
use crate::tui::{logo, theme};
pub(crate) fn status_pill(app: &App) -> Span<'static> {
let (label, bg) = if app.awaiting_model {
(" ○ NO MODEL ", theme::TEXT_DIM)
} else if app.progress.ready {
(" ● SERVING ", theme::GREEN)
} else {
(" ● LOADING ", theme::WARN)
};
Span::styled(
label,
Style::default()
.bg(bg.color())
.fg(theme::BG_BASE.color())
.add_modifier(Modifier::BOLD),
)
}
pub(crate) fn download_chip(app: &App, width: u16) -> Option<Vec<Span<'static>>> {
let job = app.download.job.as_ref()?;
let pulse = if (app.tick / 4).is_multiple_of(2) {
Modifier::BOLD
} else {
Modifier::DIM
};
let (glyph_style, steady) = if job.cancelling {
(theme::warn(), true)
} else {
(theme::brand_cyan(), false)
};
let glyph = Span::styled(
"\u{2193} ",
if steady {
glyph_style
} else {
glyph_style.add_modifier(pulse)
},
);
if width < 48 {
return Some(vec![glyph, Span::raw(" ")]);
}
let measure = match job.fraction() {
Some(f) => crate::tui::format::percent(f),
None if job.done > 0 => crate::tui::format::bytes(job.done),
None => "resolving\u{2026}".to_string(),
};
let mut out = vec![glyph];
if job.cancelling {
out.push(Span::styled("stopping", theme::warn()));
out.push(Span::raw(" "));
return Some(out);
}
if width >= 60 {
let tail = job.repo.rsplit('/').next().unwrap_or(&job.repo);
let cap = if width >= 96 { 24 } else { 16 };
let name: String = if tail.chars().count() > cap {
tail.chars().take(cap - 1).collect::<String>() + "\u{2026}"
} else {
tail.to_string()
};
out.push(Span::styled(format!("{name} \u{b7} "), theme::text2()));
}
out.push(Span::styled(measure, theme::text2()));
if width >= 96 && job.rate_bps > 0.0 {
out.push(Span::styled(
format!(" \u{b7} {}", crate::tui::format::rate(job.rate_bps)),
theme::text2(),
));
}
out.push(Span::raw(" "));
Some(out)
}
fn thermal_alert_span(app: &App) -> Option<Span<'static>> {
use crate::tui::data::thermal::ThermalAlert;
match app.thermal.snapshot().alert() {
ThermalAlert::Unknown | ThermalAlert::Ok => None,
ThermalAlert::Throttling => Some(Span::styled(
" \u{26a0} THROTTLING ",
theme::warn().add_modifier(ratatui::style::Modifier::BOLD),
)),
ThermalAlert::Thrashing => Some(Span::styled(
" \u{26a0} THERMAL THRASH ",
theme::error().add_modifier(ratatui::style::Modifier::BOLD),
)),
}
}
pub(crate) fn draw_header(f: &mut Frame, app: &App, area: Rect, tall: bool) {
let wave = if app.progress.ready && !app.awaiting_model {
None
} else {
Some((app.tick / 3) as usize % 3)
};
let up = app.started.elapsed().as_secs();
let uptime = fmt_uptime(up);
let chip = download_chip(app, area.width);
let mut right_spans = Vec::new();
if !tall && let Some(c) = chip.as_ref() {
right_spans.extend(c.iter().cloned());
right_spans.push(Span::raw(" "));
}
if let Some(alert) = thermal_alert_span(app) {
right_spans.push(alert);
right_spans.push(Span::raw(" "));
}
right_spans.push(status_pill(app));
right_spans.push(Span::styled(format!(" {uptime} "), theme::text2()));
let right = Line::from(right_spans);
if tall {
let lines = logo::three_line(wave);
for (i, line) in lines.into_iter().enumerate() {
let row = Rect {
y: area.y + i as u16,
height: 1,
..area
};
f.render_widget(Paragraph::new(line), row);
}
f.render_widget(
Paragraph::new(right).alignment(ratatui::layout::Alignment::Right),
Rect {
y: area.y,
height: 1,
..area
},
);
let sub = Line::from(Span::styled(header_line(app), theme::text2()));
f.render_widget(
Paragraph::new(sub).alignment(ratatui::layout::Alignment::Right),
Rect {
y: area.y + 1,
height: 1,
..area
},
);
if let Some(c) = chip {
f.render_widget(
Paragraph::new(Line::from(c)).alignment(ratatui::layout::Alignment::Right),
Rect {
y: area.y + 2,
height: 1,
..area
},
);
}
} else {
f.render_widget(Paragraph::new(logo::one_line(wave)), area);
f.render_widget(
Paragraph::new(right).alignment(ratatui::layout::Alignment::Right),
area,
);
}
}
pub(crate) fn header_line(app: &App) -> String {
if app.awaiting_model {
return format!("press 4 for Library · :{} ", app.args.port);
}
let live = app.host.as_ref().and_then(|h| h.args());
let a = live.as_ref().unwrap_or(&app.args);
format!(
"{} · kv {} · :{} ",
live_model_name(app),
a.kv_cache_dtype.as_deref().unwrap_or("auto"),
a.port
)
}
pub(super) fn fmt_uptime(secs: u64) -> String {
let (d, h, m, s) = (secs / 86_400, secs / 3_600 % 24, secs / 60 % 60, secs % 60);
if d > 0 {
format!("up {d}d {h:02}:{m:02}")
} else {
format!("up {h}:{m:02}:{s:02}")
}
}
#[cfg(test)]
#[path = "header_tests.rs"]
mod tests;