Skip to main content

lean_ctx/
terminal_ui.rs

1use std::io::{self, IsTerminal, Write};
2
3const LOGO: [&str; 6] = [
4    r"  ██╗     ███████╗ █████╗ ███╗   ██╗     ██████╗████████╗██╗  ██╗",
5    r"  ██║     ██╔════╝██╔══██╗████╗  ██║    ██╔════╝╚══██╔══╝╚██╗██╔╝",
6    r"  ██║     █████╗  ███████║██╔██╗ ██║    ██║        ██║    ╚███╔╝ ",
7    r"  ██║     ██╔══╝  ██╔══██║██║╚██╗██║    ██║        ██║    ██╔██╗ ",
8    r"  ███████╗███████╗██║  ██║██║ ╚████║    ╚██████╗   ██║   ██╔╝ ██╗",
9    r"  ╚══════╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═══╝     ╚═════╝   ╚═╝   ╚═╝  ╚═╝",
10];
11
12const TAGLINE: &str = "Context Runtime for AI Agents";
13
14pub fn print_logo_animated() {
15    let cfg = crate::core::config::Config::load();
16    let t = crate::core::theme::load_theme(&cfg.theme);
17    print_logo_animated_themed(&t);
18}
19
20pub fn print_logo_animated_themed(t: &crate::core::theme::Theme) {
21    if crate::core::theme::no_color() {
22        print_logo_plain();
23        return;
24    }
25    if !io::stdout().is_terminal() {
26        print_logo_themed_static(t);
27        return;
28    }
29
30    let mut stdout = io::stdout();
31    let frames = 28;
32    let frame_ms = 45;
33    let top_padding = 2;
34
35    let _ = writeln!(stdout);
36    let _ = writeln!(stdout);
37
38    for frame in 0..frames {
39        if frame > 0 {
40            print!("\x1b[{}A", LOGO.len() + 2 + top_padding);
41            for _ in 0..top_padding {
42                let _ = writeln!(stdout);
43            }
44        }
45
46        let wave_offset = frame as f64 / frames as f64;
47
48        for (i, line) in LOGO.iter().enumerate() {
49            let chars: Vec<char> = line.chars().collect();
50            let max_j = chars.len().max(1) as f64;
51            let mut buf = String::with_capacity(chars.len() * 20);
52
53            for (j, ch) in chars.iter().enumerate() {
54                if *ch == ' ' {
55                    buf.push(' ');
56                    continue;
57                }
58                let pos = j as f64 / max_j + i as f64 * 0.15;
59                let blend = ((pos + wave_offset * 2.0) * std::f64::consts::PI)
60                    .sin()
61                    .mul_add(0.5, 0.5);
62                let c = t.primary.lerp(&t.secondary, blend);
63                buf.push_str(&c.fg());
64                buf.push(*ch);
65            }
66            buf.push_str("\x1b[0m");
67            let _ = writeln!(stdout, "{buf}");
68        }
69
70        let tag_blend = ((wave_offset * 2.0 + 1.0) * std::f64::consts::PI)
71            .sin()
72            .mul_add(0.5, 0.5);
73        let tag_color = t.muted.lerp(&t.accent, tag_blend * 0.5);
74        let _ = writeln!(stdout, "{}             {TAGLINE}\x1b[0m", tag_color.fg());
75        let _ = writeln!(stdout);
76
77        let _ = stdout.flush();
78        std::thread::sleep(std::time::Duration::from_millis(frame_ms));
79    }
80
81    print!("\x1b[{}A", LOGO.len() + 2 + top_padding);
82    print_logo_themed_static(t);
83}
84
85pub fn print_logo_static() {
86    let cfg = crate::core::config::Config::load();
87    let t = crate::core::theme::load_theme(&cfg.theme);
88    print_logo_themed_static(&t);
89}
90
91fn print_logo_themed_static(t: &crate::core::theme::Theme) {
92    if crate::core::theme::no_color() {
93        print_logo_plain();
94        return;
95    }
96    let mut stdout = io::stdout();
97
98    let _ = writeln!(stdout);
99    let _ = writeln!(stdout);
100
101    for (i, line) in LOGO.iter().enumerate() {
102        let chars: Vec<char> = line.chars().collect();
103        let mut buf = String::with_capacity(chars.len() * 20);
104
105        for (j, ch) in chars.iter().enumerate() {
106            if *ch == ' ' {
107                buf.push(' ');
108                continue;
109            }
110            let progress = if chars.len() > 1 {
111                j as f64 / (chars.len() - 1) as f64
112            } else {
113                0.5
114            };
115            let row_t = i as f64 / (LOGO.len() - 1).max(1) as f64;
116            let blend = (progress + row_t * 0.3).min(1.0);
117            let c = t.primary.lerp(&t.secondary, blend);
118            buf.push_str(&c.fg());
119            buf.push(*ch);
120        }
121        buf.push_str("\x1b[0m");
122        let _ = writeln!(stdout, "{buf}");
123    }
124
125    let _ = writeln!(stdout, "{}             {TAGLINE}\x1b[0m", t.muted.fg());
126    let _ = writeln!(stdout);
127    let _ = stdout.flush();
128}
129
130fn print_logo_plain() {
131    println!();
132    println!();
133    for line in &LOGO {
134        println!("{line}");
135    }
136    println!("             {TAGLINE}");
137    println!();
138}
139
140#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
141pub fn print_command_box() {
142    use crate::core::theme;
143    let cfg = crate::core::config::Config::load();
144    let theme = theme::load_theme(&cfg.theme);
145    let dim = theme::dim();
146    let bold = theme::bold();
147    let rst = theme::rst();
148    let cmd = theme.accent.fg();
149    let ok = theme.success.fg();
150    let m = theme.muted.fg();
151
152    println!("  {dim}┌─────────────────────────────────────────────────────────┐{rst}");
153    println!(
154        "  {dim}│{rst}  {cmd}{bold}lean-ctx gain{rst}        {m}Token savings dashboard{rst}         {dim}│{rst}"
155    );
156    println!(
157        "  {dim}│{rst}  {cmd}{bold}lean-ctx dashboard{rst}   {m}Web analytics (browser){rst}        {dim}│{rst}"
158    );
159    println!(
160        "  {dim}│{rst}  {cmd}{bold}lean-ctx heatmap{rst}     {m}Project context heat map{rst}        {dim}│{rst}"
161    );
162    println!(
163        "  {dim}│{rst}  {cmd}{bold}lean-ctx benchmark{rst}   {m}Test compression quality{rst}        {dim}│{rst}"
164    );
165    println!(
166        "  {dim}│{rst}  {cmd}{bold}lean-ctx config{rst}      {m}Edit settings{rst}                   {dim}│{rst}"
167    );
168    println!(
169        "  {dim}│{rst}  {cmd}{bold}lean-ctx doctor{rst}      {m}Verify installation{rst}             {dim}│{rst}"
170    );
171    println!(
172        "  {dim}│{rst}  {cmd}{bold}lean-ctx update{rst}      {m}Self-update to latest{rst}           {dim}│{rst}"
173    );
174    println!(
175        "  {dim}│{rst}  {cmd}{bold}LEAN_CTX_DISABLED=1{rst}  {m}Disable compression{rst}             {dim}│{rst}"
176    );
177    println!(
178        "  {dim}│{rst}  {cmd}{bold}lean-ctx report-issue{rst} {m}Report a bug (auto-diagnostics){rst} {dim}│{rst}"
179    );
180    println!(
181        "  {dim}│{rst}  {cmd}{bold}lean-ctx contribute{rst}  {m}Share anonymized compression stats{rst}{dim}│{rst}"
182    );
183    println!(
184        "  {dim}│{rst}  {cmd}{bold}lean-ctx uninstall{rst}   {m}Clean removal{rst}                   {dim}│{rst}"
185    );
186    println!("  {dim}└─────────────────────────────────────────────────────────┘{rst}");
187    println!("  {ok}Ready!{rst} Your next AI command will be automatically optimized.");
188    println!("  {dim}Docs: https://leanctx.com/docs{rst}");
189    println!();
190}
191
192pub fn print_step_header(step: u8, total: u8, title: &str) {
193    let dim = "\x1b[2m";
194    let bold = "\x1b[1m";
195    let cyan = "\x1b[36m";
196    let rst = "\x1b[0m";
197    println!();
198    println!("  {cyan}{bold}[{step}/{total}]{rst} {bold}{title}{rst}");
199    println!("  {dim}─────────────────────────────────────────────────────{rst}");
200}
201
202pub fn print_status_ok(msg: &str) {
203    println!("  \x1b[32m✓\x1b[0m {msg}");
204}
205
206pub fn print_status_skip(msg: &str) {
207    println!("  \x1b[2m○\x1b[0m \x1b[2m{msg}\x1b[0m");
208}
209
210pub fn print_status_new(msg: &str) {
211    println!("  \x1b[1;32m✓\x1b[0m \x1b[1m{msg}\x1b[0m");
212}
213
214pub fn print_status_warn(msg: &str) {
215    println!("  \x1b[33m⚠\x1b[0m {msg}");
216}
217
218pub fn spinner_tick(msg: &str, frame: usize) {
219    let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
220    let ch = frames[frame % frames.len()];
221    print!("\r  \x1b[36m{ch}\x1b[0m {msg}");
222    let _ = io::stdout().flush();
223}
224
225pub fn spinner_done(msg: &str) {
226    print!("\r  \x1b[32m✓\x1b[0m {msg}\x1b[K\n");
227    let _ = io::stdout().flush();
228}
229
230// ── Unified progress indicator (index builds, long CLI work) ──────────
231
232/// Interior width of the progress bar (characters between `[` and `]`).
233const PROGRESS_BAR_WIDTH: usize = 20;
234
235/// Shared CLI progress renderer for measurable and indeterminate work.
236///
237/// * **Determinate** (`total > 0`): `label [=======>        ]  42%`
238/// * **Indeterminate** (`total == 0`): bouncing arrow L↔R inside the bar
239///
240/// Renders on stderr so stdout stays free for machine-readable output.
241/// Non-TTY: silent ticks; only \[`finish`\] prints a line.
242#[derive(Debug)]
243pub struct ProgressIndicator {
244    label: String,
245    done: u64,
246    /// `0` → indeterminate (bouncing arrow).
247    total: u64,
248    frame: usize,
249    tty: bool,
250    finished: bool,
251}
252
253impl ProgressIndicator {
254    /// Start a progress indicator with `label` (e.g. `"BM25"`, `"semantic"`).
255    pub fn new(label: impl Into<String>) -> Self {
256        Self {
257            label: label.into(),
258            done: 0,
259            total: 0,
260            frame: 0,
261            tty: io::stderr().is_terminal(),
262            finished: false,
263        }
264    }
265
266    /// Change the stage label without finishing (shared indicator across phases).
267    pub fn set_label(&mut self, label: impl Into<String>) {
268        self.label = label.into();
269    }
270
271    /// Set determinate progress. `total == 0` switches to indeterminate.
272    pub fn set(&mut self, done: u64, total: u64) {
273        self.done = done;
274        self.total = total;
275    }
276
277    /// Switch to indeterminate (infinite bouncing arrow).
278    pub fn indeterminate(&mut self) {
279        self.done = 0;
280        self.total = 0;
281    }
282
283    /// Advance animation / redraw. Call ~10–20×/s while work runs.
284    pub fn tick(&mut self) {
285        if self.finished {
286            return;
287        }
288        self.frame = self.frame.wrapping_add(1);
289        if !self.tty {
290            return;
291        }
292        let line = self.render_line();
293        eprint!("\r{line}\x1b[K");
294        let _ = io::stderr().flush();
295    }
296
297    /// Clear the bar and print a final success/status line.
298    pub fn finish(&mut self, msg: &str) {
299        if self.finished {
300            return;
301        }
302        self.finished = true;
303        if self.tty {
304            eprint!("\r\x1b[K");
305            let _ = io::stderr().flush();
306        }
307        eprintln!("{msg}");
308    }
309
310    /// Render the current bar line (no `\r`). Public for tests.
311    pub fn render_line(&self) -> String {
312        if self.total > 0 {
313            Self::render_determinate(&self.label, self.done, self.total)
314        } else {
315            Self::render_indeterminate(&self.label, self.frame)
316        }
317    }
318
319    fn render_determinate(label: &str, done: u64, total: u64) -> String {
320        let total = total.max(1);
321        let pct = ((done as f64 / total as f64) * 100.0).min(100.0).round() as u32;
322        let filled = (((done as f64 / total as f64) * PROGRESS_BAR_WIDTH as f64).round() as usize)
323            .min(PROGRESS_BAR_WIDTH);
324
325        let mut bar = String::with_capacity(PROGRESS_BAR_WIDTH);
326        for i in 0..PROGRESS_BAR_WIDTH {
327            if filled == 0 {
328                bar.push(' ');
329            } else if i + 1 < filled {
330                bar.push('=');
331            } else if i + 1 == filled {
332                bar.push('→');
333            } else {
334                bar.push(' ');
335            }
336        }
337        format!("  {label} [{bar}] {pct:>3}%")
338    }
339
340    fn render_indeterminate(label: &str, frame: usize) -> String {
341        let max = PROGRESS_BAR_WIDTH.saturating_sub(1).max(1);
342        let cycle = max * 2;
343        let t = frame % cycle;
344        let (pos, arrow) = if t <= max {
345            (t, '→')
346        } else {
347            (cycle - t, '←')
348        };
349        let mut bar: Vec<char> = vec![' '; PROGRESS_BAR_WIDTH];
350        let idx = pos.min(PROGRESS_BAR_WIDTH.saturating_sub(1));
351        bar[idx] = arrow;
352        let bar: String = bar.into_iter().collect();
353        format!("  {label} [{bar}]")
354    }
355}
356
357/// Animated dashboard intro: logo wave, then KPI count-up, then section-by-section reveal.
358/// `header_box` is the pre-rendered KPI box (with placeholder values for frame 0).
359/// `kpi_values` are (final_value, width) for the 4 KPI counters.
360/// `sections` are the remaining dashboard sections to reveal sequentially.
361pub fn animate_dashboard_intro(
362    t: &crate::core::theme::Theme,
363    kpi_box_builder: &dyn Fn(&[String]) -> String,
364    kpi_final: &[(u64, f64, u64, f64)], // (tokens, pct, commands, usd)
365    sections: &[String],
366) {
367    use std::io::Write;
368    let is_tty = std::io::stdout().is_terminal();
369    if crate::core::theme::no_color() || !is_tty {
370        if let &[(tokens, pct, commands, usd)] = kpi_final {
371            let kw = 14;
372            let vals = [
373                crate::core::theme::animate_countup(tokens, kw)
374                    .pop()
375                    .unwrap_or_default(),
376                crate::core::theme::animate_countup_pct(pct, kw)
377                    .pop()
378                    .unwrap_or_default(),
379                crate::core::theme::animate_countup(commands, kw)
380                    .pop()
381                    .unwrap_or_default(),
382                crate::core::theme::animate_countup_usd(usd, kw)
383                    .pop()
384                    .unwrap_or_default(),
385            ];
386            println!("{}", kpi_box_builder(&vals));
387        }
388        for s in sections {
389            println!("{s}");
390        }
391        return;
392    }
393
394    print_logo_animated_themed(t);
395
396    let mut stdout = std::io::stdout();
397    let frames = 11;
398    let frame_ms = 70;
399
400    if let &[(tokens, pct, commands, usd)] = kpi_final {
401        let kw = 14;
402        let tok_frames = crate::core::theme::animate_countup(tokens, kw);
403        let pct_frames = crate::core::theme::animate_countup_pct(pct, kw);
404        let cmd_frames = crate::core::theme::animate_countup(commands, kw);
405        let usd_frames = crate::core::theme::animate_countup_usd(usd, kw);
406
407        let mut last_line_count = 0usize;
408        for f in 0..frames {
409            if last_line_count > 0 {
410                print!("\x1b[{last_line_count}A\x1b[J");
411            }
412            let vals = [
413                tok_frames[f].clone(),
414                pct_frames[f].clone(),
415                cmd_frames[f].clone(),
416                usd_frames[f].clone(),
417            ];
418            let box_str = kpi_box_builder(&vals);
419            last_line_count = box_str.lines().count();
420            print!("{box_str}");
421            let _ = stdout.flush();
422            std::thread::sleep(std::time::Duration::from_millis(frame_ms));
423        }
424    }
425
426    for s in sections {
427        let _ = writeln!(stdout, "{s}");
428        let _ = stdout.flush();
429        std::thread::sleep(std::time::Duration::from_millis(60));
430    }
431}
432
433pub fn print_setup_header() {
434    let dim = "\x1b[2m";
435    let bold = "\x1b[1m";
436    let green = "\x1b[32m";
437    let rst = "\x1b[0m";
438    println!();
439    println!("  {dim}╭──────────────────────────────────────────╮{rst}");
440    println!(
441        "  {dim}│{rst}  {green}{bold}◆ lean-ctx setup{rst}                         {dim}│{rst}"
442    );
443    println!("  {dim}│{rst}  {dim}Configuring your development environment{rst} {dim}│{rst}");
444    println!("  {dim}╰──────────────────────────────────────────╯{rst}");
445    println!();
446}
447
448#[cfg(test)]
449mod progress_tests {
450    use super::*;
451
452    #[test]
453    fn determinate_includes_arrow_and_percent() {
454        let line = ProgressIndicator::render_determinate("BM25", 50, 100);
455        assert!(line.contains("BM25"), "{line}");
456        assert!(line.contains('→'), "{line}");
457        assert!(line.contains("50%"), "{line}");
458        assert!(line.contains('['), "{line}");
459    }
460
461    #[test]
462    fn determinate_full_is_100() {
463        let line = ProgressIndicator::render_determinate("semantic", 10, 10);
464        assert!(line.contains("100%"), "{line}");
465        assert!(line.contains('→'), "{line}");
466    }
467
468    #[test]
469    fn indeterminate_bounces_left_and_right() {
470        let right = ProgressIndicator::render_indeterminate("graph", 0);
471        assert!(right.contains('→'), "{right}");
472        assert!(!right.contains('%'), "{right}");
473
474        let max = PROGRESS_BAR_WIDTH.saturating_sub(1).max(1);
475        let left = ProgressIndicator::render_indeterminate("graph", max + 1);
476        assert!(left.contains('←'), "{left}");
477    }
478
479    #[test]
480    fn set_and_indeterminate_toggle() {
481        let mut p = ProgressIndicator::new("BM25");
482        p.tty = false;
483        p.set(2, 8);
484        assert!(p.render_line().contains("25%"));
485        p.indeterminate();
486        assert!(!p.render_line().contains('%'));
487    }
488}