Skip to main content

shunt/
term.rs

1/// Terminal formatting helpers — ANSI colors, alignment, and status symbols.
2///
3/// All color output is suppressed when stdout is not a TTY (e.g. piped to a file).
4use std::sync::OnceLock;
5
6// ---------------------------------------------------------------------------
7// TTY detection
8// ---------------------------------------------------------------------------
9
10fn is_tty() -> bool {
11    static TTY: OnceLock<bool> = OnceLock::new();
12    *TTY.get_or_init(|| {
13        // SAFETY: just calling libc isatty
14        #[cfg(unix)]
15        unsafe { libc::isatty(1) != 0 }
16        #[cfg(not(unix))]
17        false
18    })
19}
20
21// ---------------------------------------------------------------------------
22// ANSI codes
23// ---------------------------------------------------------------------------
24
25pub 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
53/// 256-colour dark forest green — used for borders and decorative chrome.
54pub fn dark_green(s: &str) -> String {
55    if is_tty() { format!("\x1b[38;5;28m{s}\x1b[0m") } else { s.to_owned() }
56}
57
58/// Bold bright green — used for account names in the routing diagram.
59pub fn green_bold(s: &str) -> String {
60    if is_tty() { format!("\x1b[1;32m{s}\x1b[0m") } else { s.to_owned() }
61}
62
63/// Bold medium green — the primary brand colour for the "shunt" wordmark.
64pub fn brand_green(s: &str) -> String {
65    if is_tty() { format!("\x1b[1;38;5;34m{s}\x1b[0m") } else { s.to_owned() }
66}
67
68// ---------------------------------------------------------------------------
69// Symbols
70// ---------------------------------------------------------------------------
71
72pub 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
80// ---------------------------------------------------------------------------
81// Layout helpers
82// ---------------------------------------------------------------------------
83
84/// Horizontal rule, dimmed
85/// Simple [y/N] confirmation prompt. Returns true if the user confirms.
86/// Defaults to NO on empty input.
87pub 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
100/// Print a section header like:  ◆ Accounts  ─────────────────
101pub fn section(label: &str) {
102    let dashes = "─".repeat(44usize.saturating_sub(label.len() + 4));
103    println!("  {}  {}  {}", bold_white("◆"), bold(label), dim(&dashes));
104}
105
106/// Format a duration in ms dynamically:
107///   >= 24h  → "Xd Yh" / "Xd"
108///   >= 1h   → "Xh Ym" / "Xh"
109///   >= 1m   → "Xm"
110///   < 1m    → "Xs"
111pub 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
136// ---------------------------------------------------------------------------
137// Interactive select menu
138// ---------------------------------------------------------------------------
139
140/// An item in the interactive select menu.
141pub struct SelectItem {
142    /// What the user sees (may contain ANSI codes)
143    pub label: String,
144    /// Value returned on selection
145    pub value: String,
146}
147
148/// Show an interactive, arrow-key-navigable menu and return the chosen value.
149///
150/// Controls:
151///   ↑ / k      — move up
152///   ↓ / j      — move down
153///   1–9        — jump to item N
154///   Enter      — confirm selection
155///   Esc / q    — cancel (returns None)
156pub 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    // Enter raw mode so keystrokes are read immediately without Enter
173    terminal::enable_raw_mode().ok()?;
174    execute!(stdout, cursor::Hide).ok();
175
176    let render = |sel: usize, out: &mut dyn Write| {
177        // Clear all lines we're about to draw
178        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    // Initial render
194    let lines_drawn = items.len() + 5; // header + blank + items + blank + hint
195    render(selected, &mut stdout);
196
197    let result = loop {
198        match event::read() {
199            Ok(Event::Key(key)) => {
200                // Move cursor back to top of our block
201                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                        // Clear the menu block and leave a one-line confirmation
228                        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");   // 1m 30s → "1m"
262        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
274/// Format a large token count as "1.2k" / "34k" / "1.1M" / raw
275pub 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}