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)] pub 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
230const PROGRESS_BAR_WIDTH: usize = 20;
234
235#[derive(Debug)]
243pub struct ProgressIndicator {
244 label: String,
245 done: u64,
246 total: u64,
248 frame: usize,
249 tty: bool,
250 finished: bool,
251}
252
253impl ProgressIndicator {
254 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 pub fn set_label(&mut self, label: impl Into<String>) {
268 self.label = label.into();
269 }
270
271 pub fn set(&mut self, done: u64, total: u64) {
273 self.done = done;
274 self.total = total;
275 }
276
277 pub fn indeterminate(&mut self) {
279 self.done = 0;
280 self.total = 0;
281 }
282
283 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 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 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
357pub 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)], 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}