use std::sync::OnceLock;
fn is_tty() -> bool {
static TTY: OnceLock<bool> = OnceLock::new();
*TTY.get_or_init(|| {
#[cfg(unix)]
unsafe { libc::isatty(1) != 0 }
#[cfg(not(unix))]
false
})
}
pub fn bold(s: &str) -> String {
if is_tty() { format!("\x1b[1m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn dim(s: &str) -> String {
if is_tty() { format!("\x1b[2m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn green(s: &str) -> String {
if is_tty() { format!("\x1b[32m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn yellow(s: &str) -> String {
if is_tty() { format!("\x1b[33m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn red(s: &str) -> String {
if is_tty() { format!("\x1b[31m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn cyan(s: &str) -> String {
if is_tty() { format!("\x1b[36m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn bold_white(s: &str) -> String {
if is_tty() { format!("\x1b[1;97m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn dark_green(s: &str) -> String {
if is_tty() { format!("\x1b[38;5;28m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn green_bold(s: &str) -> String {
if is_tty() { format!("\x1b[1;32m{s}\x1b[0m") } else { s.to_owned() }
}
pub fn brand_green(s: &str) -> String {
if is_tty() { format!("\x1b[1;38;5;154m{s}\x1b[0m") } else { s.to_owned() }
}
pub const CHECK: &str = "✓";
pub const CROSS: &str = "✗";
pub const DOT: &str = "●";
pub const EMPTY: &str = "○";
pub const DASH: &str = "—";
pub const ARROW: &str = "→";
pub const DIAMOND: &str = "◆";
pub fn confirm(prompt: &str) -> bool {
use std::io::Write;
print!(" {} {} [y/N]: ", crate::term::dim("·"), prompt);
std::io::stdout().flush().ok();
let mut buf = String::new();
std::io::stdin().read_line(&mut buf).ok();
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
}
pub fn rule(width: usize) -> String {
dim(&"─".repeat(width))
}
pub fn section(label: &str) {
let dashes = "─".repeat(44usize.saturating_sub(label.len() + 4));
println!(" {} {} {}", bold_white("◆"), bold(label), dim(&dashes));
}
pub fn fmt_duration_ms(ms: u64) -> String {
let secs = ms / 1000;
if secs == 0 {
return "0s".into();
}
let mins = secs / 60;
if mins == 0 {
return format!("{}s", secs);
}
let hours = mins / 60;
let rem_mins = mins % 60;
if hours == 0 {
return format!("{mins}m");
}
let days = hours / 24;
let rem_hours = hours % 24;
if days == 0 {
if rem_mins == 0 { format!("{hours}h") } else { format!("{hours}h {rem_mins}m") }
} else if rem_hours == 0 {
format!("{days}d")
} else {
format!("{days}d {rem_hours}h")
}
}
pub struct SelectItem {
pub label: String,
pub value: String,
}
pub fn select(prompt: &str, items: &[SelectItem], initial: usize) -> Option<String> {
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, ClearType},
};
use std::io::{stdout, Write};
if items.is_empty() {
return None;
}
let mut selected = initial.min(items.len() - 1);
let mut stdout = stdout();
terminal::enable_raw_mode().ok()?;
execute!(stdout, cursor::Hide).ok();
let render = |sel: usize, out: &mut dyn Write| {
let _ = write!(out, "\r\n {prompt}\r\n\r\n");
for (i, item) in items.iter().enumerate() {
if i == sel {
let _ = write!(out, " \x1b[1;32m◆\x1b[0m \x1b[1m{}\x1b[0m\r\n", item.label);
} else {
let _ = write!(out, " {}\r\n", item.label);
}
}
let _ = write!(
out,
"\r\n \x1b[2m↑ ↓ navigate · enter select · esc cancel\x1b[0m\r\n",
);
let _ = out.flush();
};
let lines_drawn = items.len() + 5; render(selected, &mut stdout);
let result = loop {
match event::read() {
Ok(Event::Key(key)) => {
execute!(
stdout,
cursor::MoveUp(lines_drawn as u16),
cursor::MoveToColumn(0),
terminal::Clear(ClearType::FromCursorDown),
).ok();
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => break None,
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => break None,
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
selected = if selected == 0 { items.len() - 1 } else { selected - 1 };
render(selected, &mut stdout);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
selected = (selected + 1) % items.len();
render(selected, &mut stdout);
}
(KeyCode::Char(c), _) if c.is_ascii_digit() => {
let n = c as usize - '0' as usize;
if n >= 1 && n <= items.len() {
selected = n - 1;
render(selected, &mut stdout);
}
}
(KeyCode::Enter, _) => {
execute!(
stdout,
cursor::MoveUp(lines_drawn as u16),
cursor::MoveToColumn(0),
terminal::Clear(ClearType::FromCursorDown),
).ok();
break Some(items[selected].value.clone());
}
_ => { render(selected, &mut stdout); }
}
}
_ => {}
}
};
execute!(stdout, cursor::Show).ok();
terminal::disable_raw_mode().ok();
println!();
result
}
#[cfg(test)]
mod tests {
use super::fmt_duration_ms;
#[test]
fn test_fmt_duration_ms() {
assert_eq!(fmt_duration_ms(0), "0s");
assert_eq!(fmt_duration_ms(500), "0s");
assert_eq!(fmt_duration_ms(1_000), "1s");
assert_eq!(fmt_duration_ms(45_000), "45s");
assert_eq!(fmt_duration_ms(59_000), "59s");
assert_eq!(fmt_duration_ms(60_000), "1m");
assert_eq!(fmt_duration_ms(90_000), "1m"); assert_eq!(fmt_duration_ms(30 * 60_000), "30m");
assert_eq!(fmt_duration_ms(60 * 60_000), "1h");
assert_eq!(fmt_duration_ms(90 * 60_000), "1h 30m");
assert_eq!(fmt_duration_ms(5 * 3600_000), "5h");
assert_eq!(fmt_duration_ms(5 * 3600_000 + 30 * 60_000), "5h 30m");
assert_eq!(fmt_duration_ms(24 * 3600_000), "1d");
assert_eq!(fmt_duration_ms(48 * 3600_000), "2d");
assert_eq!(fmt_duration_ms(25 * 3600_000), "1d 1h");
assert_eq!(fmt_duration_ms(7 * 24 * 3600_000), "7d");
}
}
pub fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 10_000 {
format!("{}k", n / 1_000)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
format!("{n}")
}
}