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
85fn print_logo_themed_static(t: &crate::core::theme::Theme) {
86    if crate::core::theme::no_color() {
87        print_logo_plain();
88        return;
89    }
90    let mut stdout = io::stdout();
91
92    let _ = writeln!(stdout);
93    let _ = writeln!(stdout);
94
95    for (i, line) in LOGO.iter().enumerate() {
96        let chars: Vec<char> = line.chars().collect();
97        let mut buf = String::with_capacity(chars.len() * 20);
98
99        for (j, ch) in chars.iter().enumerate() {
100            if *ch == ' ' {
101                buf.push(' ');
102                continue;
103            }
104            let progress = if chars.len() > 1 {
105                j as f64 / (chars.len() - 1) as f64
106            } else {
107                0.5
108            };
109            let row_t = i as f64 / (LOGO.len() - 1).max(1) as f64;
110            let blend = (progress + row_t * 0.3).min(1.0);
111            let c = t.primary.lerp(&t.secondary, blend);
112            buf.push_str(&c.fg());
113            buf.push(*ch);
114        }
115        buf.push_str("\x1b[0m");
116        let _ = writeln!(stdout, "{buf}");
117    }
118
119    let _ = writeln!(stdout, "{}             {TAGLINE}\x1b[0m", t.muted.fg());
120    let _ = writeln!(stdout);
121    let _ = stdout.flush();
122}
123
124fn print_logo_plain() {
125    println!();
126    println!();
127    for line in &LOGO {
128        println!("{line}");
129    }
130    println!("             {TAGLINE}");
131    println!();
132}
133
134#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
135pub fn print_command_box() {
136    use crate::core::theme;
137    let cfg = crate::core::config::Config::load();
138    let theme = theme::load_theme(&cfg.theme);
139    let dim = theme::dim();
140    let bold = theme::bold();
141    let rst = theme::rst();
142    let cmd = theme.accent.fg();
143    let ok = theme.success.fg();
144    let m = theme.muted.fg();
145
146    println!("  {dim}┌─────────────────────────────────────────────────────────┐{rst}");
147    println!(
148        "  {dim}│{rst}  {cmd}{bold}lean-ctx gain{rst}        {m}Token savings dashboard{rst}         {dim}│{rst}"
149    );
150    println!(
151        "  {dim}│{rst}  {cmd}{bold}lean-ctx cockpit{rst}     {m}The Cockpit (browser){rst}          {dim}│{rst}"
152    );
153    println!(
154        "  {dim}│{rst}  {cmd}{bold}lean-ctx heatmap{rst}     {m}Project context heat map{rst}        {dim}│{rst}"
155    );
156    println!(
157        "  {dim}│{rst}  {cmd}{bold}lean-ctx benchmark{rst}   {m}Test compression quality{rst}        {dim}│{rst}"
158    );
159    println!(
160        "  {dim}│{rst}  {cmd}{bold}lean-ctx config{rst}      {m}Edit settings{rst}                   {dim}│{rst}"
161    );
162    println!(
163        "  {dim}│{rst}  {cmd}{bold}lean-ctx doctor{rst}      {m}Verify installation{rst}             {dim}│{rst}"
164    );
165    println!(
166        "  {dim}│{rst}  {cmd}{bold}lean-ctx update{rst}      {m}Self-update to latest{rst}           {dim}│{rst}"
167    );
168    println!(
169        "  {dim}│{rst}  {cmd}{bold}LEAN_CTX_DISABLED=1{rst}  {m}Disable compression{rst}             {dim}│{rst}"
170    );
171    println!(
172        "  {dim}│{rst}  {cmd}{bold}lean-ctx report-issue{rst} {m}Report a bug (auto-diagnostics){rst} {dim}│{rst}"
173    );
174    println!(
175        "  {dim}│{rst}  {cmd}{bold}lean-ctx contribute{rst}  {m}Share anonymized compression stats{rst}{dim}│{rst}"
176    );
177    println!(
178        "  {dim}│{rst}  {cmd}{bold}lean-ctx uninstall{rst}   {m}Clean removal{rst}                   {dim}│{rst}"
179    );
180    println!("  {dim}└─────────────────────────────────────────────────────────┘{rst}");
181    println!("  {ok}Ready!{rst} Your next AI command will be automatically optimized.");
182    println!("  {dim}Docs: https://leanctx.com/docs{rst}");
183    println!();
184}
185
186pub fn print_step_header(step: u8, total: u8, title: &str) {
187    let dim = "\x1b[2m";
188    let bold = "\x1b[1m";
189    let cyan = "\x1b[36m";
190    let rst = "\x1b[0m";
191    println!();
192    println!("  {cyan}{bold}[{step}/{total}]{rst} {bold}{title}{rst}");
193    println!("  {dim}─────────────────────────────────────────────────────{rst}");
194}
195
196pub fn print_status_ok(msg: &str) {
197    println!("  \x1b[32m✓\x1b[0m {msg}");
198}
199
200pub fn print_status_skip(msg: &str) {
201    println!("  \x1b[2m○\x1b[0m \x1b[2m{msg}\x1b[0m");
202}
203
204pub fn print_status_new(msg: &str) {
205    println!("  \x1b[1;32m✓\x1b[0m \x1b[1m{msg}\x1b[0m");
206}
207
208pub fn print_status_warn(msg: &str) {
209    println!("  \x1b[33m⚠\x1b[0m {msg}");
210}
211
212// ── Unified progress indicator (index builds, long CLI work) ──────────
213
214/// Interior width of the progress bar (characters between `[` and `]`).
215const PROGRESS_BAR_WIDTH: usize = 20;
216
217/// Shared CLI progress renderer for measurable and indeterminate work.
218///
219/// * **Determinate** (`total > 0`): `label [=======>        ]  42%`
220/// * **Indeterminate** (`total == 0`): bouncing arrow L↔R inside the bar
221///
222/// Renders on stderr so stdout stays free for machine-readable output.
223/// Non-TTY: silent ticks; only \[`finish`\] prints a line.
224#[derive(Debug)]
225pub struct ProgressIndicator {
226    label: String,
227    done: u64,
228    /// `0` → indeterminate (bouncing arrow).
229    total: u64,
230    frame: usize,
231    tty: bool,
232    finished: bool,
233}
234
235impl ProgressIndicator {
236    /// Start a progress indicator with `label` (e.g. `"BM25"`, `"semantic"`).
237    pub fn new(label: impl Into<String>) -> Self {
238        Self {
239            label: label.into(),
240            done: 0,
241            total: 0,
242            frame: 0,
243            tty: io::stderr().is_terminal(),
244            finished: false,
245        }
246    }
247
248    /// Change the stage label without finishing (shared indicator across phases).
249    pub fn set_label(&mut self, label: impl Into<String>) {
250        self.label = label.into();
251    }
252
253    /// Set determinate progress. `total == 0` switches to indeterminate.
254    pub fn set(&mut self, done: u64, total: u64) {
255        self.done = done;
256        self.total = total;
257    }
258
259    /// Switch to indeterminate (infinite bouncing arrow).
260    pub fn indeterminate(&mut self) {
261        self.done = 0;
262        self.total = 0;
263    }
264
265    /// Advance animation / redraw. Call ~10–20×/s while work runs.
266    pub fn tick(&mut self) {
267        if self.finished {
268            return;
269        }
270        self.frame = self.frame.wrapping_add(1);
271        if !self.tty {
272            return;
273        }
274        let line = self.render_line();
275        eprint!("\r{line}\x1b[K");
276        let _ = io::stderr().flush();
277    }
278
279    /// Clear the bar and print a final success/status line.
280    pub fn finish(&mut self, msg: &str) {
281        if self.finished {
282            return;
283        }
284        self.finished = true;
285        if self.tty {
286            eprint!("\r\x1b[K");
287            let _ = io::stderr().flush();
288        }
289        eprintln!("{msg}");
290    }
291
292    /// Render the current bar line (no `\r`). Public for tests.
293    pub fn render_line(&self) -> String {
294        if self.total > 0 {
295            Self::render_determinate(&self.label, self.done, self.total)
296        } else {
297            Self::render_indeterminate(&self.label, self.frame)
298        }
299    }
300
301    fn render_determinate(label: &str, done: u64, total: u64) -> String {
302        let total = total.max(1);
303        let pct = ((done as f64 / total as f64) * 100.0).min(100.0).round() as u32;
304        let filled = (((done as f64 / total as f64) * PROGRESS_BAR_WIDTH as f64).round() as usize)
305            .min(PROGRESS_BAR_WIDTH);
306
307        let mut bar = String::with_capacity(PROGRESS_BAR_WIDTH);
308        for i in 0..PROGRESS_BAR_WIDTH {
309            if filled == 0 {
310                bar.push(' ');
311            } else if i + 1 < filled {
312                bar.push('=');
313            } else if i + 1 == filled {
314                bar.push('→');
315            } else {
316                bar.push(' ');
317            }
318        }
319        format!("  {label} [{bar}] {pct:>3}%")
320    }
321
322    fn render_indeterminate(label: &str, frame: usize) -> String {
323        let max = PROGRESS_BAR_WIDTH.saturating_sub(1).max(1);
324        let cycle = max * 2;
325        let t = frame % cycle;
326        let (pos, arrow) = if t <= max {
327            (t, '→')
328        } else {
329            (cycle - t, '←')
330        };
331        let mut bar: Vec<char> = vec![' '; PROGRESS_BAR_WIDTH];
332        let idx = pos.min(PROGRESS_BAR_WIDTH.saturating_sub(1));
333        bar[idx] = arrow;
334        let bar: String = bar.into_iter().collect();
335        format!("  {label} [{bar}]")
336    }
337}
338
339pub fn print_setup_header() {
340    let dim = "\x1b[2m";
341    let bold = "\x1b[1m";
342    let green = "\x1b[32m";
343    let rst = "\x1b[0m";
344    println!();
345    println!("  {dim}╭──────────────────────────────────────────╮{rst}");
346    println!(
347        "  {dim}│{rst}  {green}{bold}◆ lean-ctx setup{rst}                         {dim}│{rst}"
348    );
349    println!("  {dim}│{rst}  {dim}Configuring your development environment{rst} {dim}│{rst}");
350    println!("  {dim}╰──────────────────────────────────────────╯{rst}");
351    println!();
352}
353
354#[cfg(test)]
355mod progress_tests {
356    use super::*;
357
358    #[test]
359    fn determinate_includes_arrow_and_percent() {
360        let line = ProgressIndicator::render_determinate("BM25", 50, 100);
361        assert!(line.contains("BM25"), "{line}");
362        assert!(line.contains('→'), "{line}");
363        assert!(line.contains("50%"), "{line}");
364        assert!(line.contains('['), "{line}");
365    }
366
367    #[test]
368    fn determinate_full_is_100() {
369        let line = ProgressIndicator::render_determinate("semantic", 10, 10);
370        assert!(line.contains("100%"), "{line}");
371        assert!(line.contains('→'), "{line}");
372    }
373
374    #[test]
375    fn indeterminate_bounces_left_and_right() {
376        let right = ProgressIndicator::render_indeterminate("graph", 0);
377        assert!(right.contains('→'), "{right}");
378        assert!(!right.contains('%'), "{right}");
379
380        let max = PROGRESS_BAR_WIDTH.saturating_sub(1).max(1);
381        let left = ProgressIndicator::render_indeterminate("graph", max + 1);
382        assert!(left.contains('←'), "{left}");
383    }
384
385    #[test]
386    fn set_and_indeterminate_toggle() {
387        let mut p = ProgressIndicator::new("BM25");
388        p.tty = false;
389        p.set(2, 8);
390        assert!(p.render_line().contains("25%"));
391        p.indeterminate();
392        assert!(!p.render_line().contains('%'));
393    }
394}