1use std::sync::OnceLock;
5
6fn is_tty() -> bool {
11 static TTY: OnceLock<bool> = OnceLock::new();
12 *TTY.get_or_init(|| {
13 #[cfg(unix)]
15 unsafe { libc::isatty(1) != 0 }
16 #[cfg(not(unix))]
17 false
18 })
19}
20
21pub fn bold(s: &str) -> String {
26 if is_tty() { format!("\x1b[1m{s}\x1b[0m") } else { s.to_owned() }
27}
28
29pub fn dim(s: &str) -> String {
30 if is_tty() { format!("\x1b[2m{s}\x1b[0m") } else { s.to_owned() }
31}
32
33pub fn green(s: &str) -> String {
34 if is_tty() { format!("\x1b[32m{s}\x1b[0m") } else { s.to_owned() }
35}
36
37pub fn yellow(s: &str) -> String {
38 if is_tty() { format!("\x1b[33m{s}\x1b[0m") } else { s.to_owned() }
39}
40
41pub fn red(s: &str) -> String {
42 if is_tty() { format!("\x1b[31m{s}\x1b[0m") } else { s.to_owned() }
43}
44
45pub fn cyan(s: &str) -> String {
46 if is_tty() { format!("\x1b[36m{s}\x1b[0m") } else { s.to_owned() }
47}
48
49pub fn bold_white(s: &str) -> String {
50 if is_tty() { format!("\x1b[1;97m{s}\x1b[0m") } else { s.to_owned() }
51}
52
53pub fn dark_green(s: &str) -> String {
55 if is_tty() { format!("\x1b[38;5;28m{s}\x1b[0m") } else { s.to_owned() }
56}
57
58pub fn green_bold(s: &str) -> String {
60 if is_tty() { format!("\x1b[1;32m{s}\x1b[0m") } else { s.to_owned() }
61}
62
63pub fn brand_green(s: &str) -> String {
65 if is_tty() { format!("\x1b[1;38;5;154m{s}\x1b[0m") } else { s.to_owned() }
66}
67
68pub const CHECK: &str = "✓";
73pub const CROSS: &str = "✗";
74pub const DOT: &str = "●";
75pub const EMPTY: &str = "○";
76pub const DASH: &str = "—";
77pub const ARROW: &str = "→";
78pub const DIAMOND: &str = "◆";
79
80pub fn confirm(prompt: &str) -> bool {
88 use std::io::Write;
89 print!(" {} {} [y/N]: ", crate::term::dim("·"), prompt);
90 std::io::stdout().flush().ok();
91 let mut buf = String::new();
92 std::io::stdin().read_line(&mut buf).ok();
93 matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
94}
95
96pub fn rule(width: usize) -> String {
97 dim(&"─".repeat(width))
98}
99
100pub fn section(label: &str) {
102 let dashes = "─".repeat(44usize.saturating_sub(label.len() + 4));
103 println!(" {} {} {}", bold_white("◆"), bold(label), dim(&dashes));
104}
105
106pub fn fmt_duration_ms(ms: u64) -> String {
112 let secs = ms / 1000;
113 if secs == 0 {
114 return "0s".into();
115 }
116 let mins = secs / 60;
117 if mins == 0 {
118 return format!("{}s", secs);
119 }
120 let hours = mins / 60;
121 let rem_mins = mins % 60;
122 if hours == 0 {
123 return format!("{mins}m");
124 }
125 let days = hours / 24;
126 let rem_hours = hours % 24;
127 if days == 0 {
128 if rem_mins == 0 { format!("{hours}h") } else { format!("{hours}h {rem_mins}m") }
129 } else if rem_hours == 0 {
130 format!("{days}d")
131 } else {
132 format!("{days}d {rem_hours}h")
133 }
134}
135
136pub struct SelectItem {
142 pub label: String,
144 pub value: String,
146}
147
148pub fn select(prompt: &str, items: &[SelectItem], initial: usize) -> Option<String> {
157 use crossterm::{
158 cursor,
159 event::{self, Event, KeyCode, KeyModifiers},
160 execute,
161 terminal::{self, ClearType},
162 };
163 use std::io::{stdout, Write};
164
165 if items.is_empty() {
166 return None;
167 }
168
169 let mut selected = initial.min(items.len() - 1);
170 let mut stdout = stdout();
171
172 terminal::enable_raw_mode().ok()?;
174 execute!(stdout, cursor::Hide).ok();
175
176 let render = |sel: usize, out: &mut dyn Write| {
177 let _ = write!(out, "\r\n {prompt}\r\n\r\n");
179 for (i, item) in items.iter().enumerate() {
180 if i == sel {
181 let _ = write!(out, " \x1b[1;32m◆\x1b[0m \x1b[1m{}\x1b[0m\r\n", item.label);
182 } else {
183 let _ = write!(out, " {}\r\n", item.label);
184 }
185 }
186 let _ = write!(
187 out,
188 "\r\n \x1b[2m↑ ↓ navigate · enter select · esc cancel\x1b[0m\r\n",
189 );
190 let _ = out.flush();
191 };
192
193 let lines_drawn = items.len() + 5; render(selected, &mut stdout);
196
197 let result = loop {
198 match event::read() {
199 Ok(Event::Key(key)) => {
200 execute!(
202 stdout,
203 cursor::MoveUp(lines_drawn as u16),
204 cursor::MoveToColumn(0),
205 terminal::Clear(ClearType::FromCursorDown),
206 ).ok();
207
208 match (key.code, key.modifiers) {
209 (KeyCode::Char('c'), KeyModifiers::CONTROL) => break None,
210 (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => break None,
211 (KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
212 selected = if selected == 0 { items.len() - 1 } else { selected - 1 };
213 render(selected, &mut stdout);
214 }
215 (KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
216 selected = (selected + 1) % items.len();
217 render(selected, &mut stdout);
218 }
219 (KeyCode::Char(c), _) if c.is_ascii_digit() => {
220 let n = c as usize - '0' as usize;
221 if n >= 1 && n <= items.len() {
222 selected = n - 1;
223 render(selected, &mut stdout);
224 }
225 }
226 (KeyCode::Enter, _) => {
227 execute!(
229 stdout,
230 cursor::MoveUp(lines_drawn as u16),
231 cursor::MoveToColumn(0),
232 terminal::Clear(ClearType::FromCursorDown),
233 ).ok();
234 break Some(items[selected].value.clone());
235 }
236 _ => { render(selected, &mut stdout); }
237 }
238 }
239 _ => {}
240 }
241 };
242
243 execute!(stdout, cursor::Show).ok();
244 terminal::disable_raw_mode().ok();
245 println!();
246 result
247}
248
249#[cfg(test)]
250mod tests {
251 use super::fmt_duration_ms;
252
253 #[test]
254 fn test_fmt_duration_ms() {
255 assert_eq!(fmt_duration_ms(0), "0s");
256 assert_eq!(fmt_duration_ms(500), "0s");
257 assert_eq!(fmt_duration_ms(1_000), "1s");
258 assert_eq!(fmt_duration_ms(45_000), "45s");
259 assert_eq!(fmt_duration_ms(59_000), "59s");
260 assert_eq!(fmt_duration_ms(60_000), "1m");
261 assert_eq!(fmt_duration_ms(90_000), "1m"); assert_eq!(fmt_duration_ms(30 * 60_000), "30m");
263 assert_eq!(fmt_duration_ms(60 * 60_000), "1h");
264 assert_eq!(fmt_duration_ms(90 * 60_000), "1h 30m");
265 assert_eq!(fmt_duration_ms(5 * 3600_000), "5h");
266 assert_eq!(fmt_duration_ms(5 * 3600_000 + 30 * 60_000), "5h 30m");
267 assert_eq!(fmt_duration_ms(24 * 3600_000), "1d");
268 assert_eq!(fmt_duration_ms(48 * 3600_000), "2d");
269 assert_eq!(fmt_duration_ms(25 * 3600_000), "1d 1h");
270 assert_eq!(fmt_duration_ms(7 * 24 * 3600_000), "7d");
271 }
272}
273
274pub fn fmt_tokens(n: u64) -> String {
276 if n >= 1_000_000 {
277 format!("{:.1}M", n as f64 / 1_000_000.0)
278 } else if n >= 10_000 {
279 format!("{}k", n / 1_000)
280 } else if n >= 1_000 {
281 format!("{:.1}k", n as f64 / 1_000.0)
282 } else {
283 format!("{n}")
284 }
285}