ocd/src/render/pretty.rs
ocd 3b1d2a4389 Scaffold ocd: config, HTTP fetch, cache, paced pipeline, render
Rust CLI with clap subcommands (pretty/raw json/raw plain/fetch/cache/config).
Per-workspace dual HTTP (Zen balance + Go limits) run with tokio::join,
500 ms workspace pacing, TTL-180s file cache, retry/backoff for transient
failures. scrape.rs holds the only OpenCode-coupled HTML selectors (placeholders
until the real Zen/Go pages are captured). Unicode blitter-block bars in
pretty mode; stable JSON/plain raw output for widgets and agent plugins.
2026-07-30 01:07:30 +03:00

169 lines
5.5 KiB
Rust

//! Pretty terminal renderer. Unicode blitter-block bars, colored by usage.
//! Degrades to ASCII with `--ascii`, and to no color with `--no-color` or when
//! stdout is not a terminal.
use anstyle::{AnsiColor, Style};
use crate::model::{AccountReport, ReportStatus, UsageFrame};
use crate::timefmt;
const EIGHTHS: [&str; 7] = ["", "", "", "", "", "", ""];
/// One report block, then the next. Reports are separated by a blank line.
pub fn render_pretty(reports: &[AccountReport], ascii: bool, no_color: bool) -> String {
let width = bar_width();
let mut out = String::new();
for (i, r) in reports.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&render_one(r, width, ascii, no_color));
}
out
}
fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> String {
let mut out = String::new();
// Header line.
let status = paint(&status_text(&r.status), status_style(&r.status), no_color);
let fetched = r
.fetched_at
.as_deref()
.map(timefmt::relative)
.unwrap_or_else(|| "unknown".to_string());
out.push_str(&format!("{} / {} {} fetched {}\n", r.account, r.workspace, status, fetched));
if let Some(l) = &r.limits {
out.push_str(&frame_row("5h", &l.usage_5h, width, ascii, no_color, "resets"));
out.push_str(&frame_row("week", &l.usage_week, width, ascii, no_color, "resets"));
out.push_str(&frame_row("month", &l.usage_month, width, ascii, no_color, "resets"));
if let Some(nu) = &l.next_update {
out.push_str(&format!(
" next {}\n",
timefmt::absolute(nu),
));
out.push_str(&format!(
" ({})\n",
timefmt::relative(nu),
));
}
} else {
out.push_str(" limits unavailable\n");
}
if let Some(b) = &r.balance {
out.push_str(&format!(
" balance {} {} remaining\n",
fmt_num(b.remaining),
b.unit,
));
} else {
out.push_str(" balance unavailable\n");
}
// Print the failure detail on an error status.
if let ReportStatus::Error(msg) = &r.status {
out.push_str(&format!(" error {msg}\n"));
}
out
}
fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String {
let pct = frame.pct();
let bar = colored_bar(pct, width, ascii, no_color);
let usage = if frame.total > 0.0 {
format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit)
} else {
format!("{} {}", fmt_num(frame.used), frame.unit)
};
let resets = frame
.resets_at
.as_deref()
.map(|t| format!("resets in {}", timefmt::relative(t)))
.unwrap_or_default();
format!(
" {:<7} {} {:<22} {:>5.1}% {}\n",
label, bar, usage, pct, resets
)
}
fn colored_bar(pct: f64, width: usize, ascii: bool, no_color: bool) -> String {
let total_eighths = ((pct / 100.0) * (width as f64 * 8.0)).round() as i64;
let total_eighths = total_eighths.clamp(0, (width as i64) * 8);
let whole = (total_eighths / 8) as usize;
let rem = (total_eighths % 8) as usize;
let mid = if rem > 0 { 1 } else { 0 };
let total_filled = whole + mid;
let empty = width.saturating_sub(total_filled);
let filled_str = if ascii {
"#".repeat(whole) + if rem > 0 { "#" } else { "" }
} else {
"".repeat(whole) + if rem > 0 { EIGHTHS[rem - 1] } else { "" }
};
let empty_str = if ascii { "-".repeat(empty) } else { "".repeat(empty) };
let style = zone_style(pct);
format!("{}{}{}", paint(&filled_str, style, no_color), empty_str, "" )
}
fn zone_style(pct: f64) -> Style {
if pct >= 90.0 {
Style::new().fg_color(Some(AnsiColor::BrightRed.into()))
} else if pct >= 70.0 {
Style::new().fg_color(Some(AnsiColor::Yellow.into()))
} else {
Style::new().fg_color(Some(AnsiColor::Green.into()))
}
}
fn status_text(s: &ReportStatus) -> String {
match s {
ReportStatus::Ok => "[ok]".to_string(),
ReportStatus::Cached => "[cached]".to_string(),
ReportStatus::RateLimited => "[rate limited]".to_string(),
ReportStatus::Timeout => "[timeout]".to_string(),
ReportStatus::Error(_) => "[error]".to_string(),
}
}
fn status_style(s: &ReportStatus) -> Style {
match s {
ReportStatus::Ok => Style::new().fg_color(Some(AnsiColor::Green.into())),
ReportStatus::Cached => Style::new().fg_color(Some(AnsiColor::Cyan.into())),
ReportStatus::RateLimited => Style::new().fg_color(Some(AnsiColor::Yellow.into())),
ReportStatus::Timeout => Style::new().fg_color(Some(AnsiColor::Magenta.into())),
ReportStatus::Error(_) => Style::new().fg_color(Some(AnsiColor::Red.into())),
}
}
fn paint(text: &str, style: Style, no_color: bool) -> String {
if no_color {
text.to_string()
} else {
format!("{}{}{}", style.render(), text, style.render_reset())
}
}
fn bar_width() -> usize {
let fallback = 20usize;
if let Some(terminal_size::Width(w)) = terminal_size::terminal_size().map(|(w, _)| w) {
(w as usize / 3).clamp(10, 44)
} else {
fallback
}
}
fn fmt_num(n: f64) -> String {
let n = n.abs();
if n >= 1000.0 {
format!("{:.1}k", n / 1000.0)
} else if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{:.2}", n)
}
}