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)] pub 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
212const PROGRESS_BAR_WIDTH: usize = 20;
216
217#[derive(Debug)]
225pub struct ProgressIndicator {
226 label: String,
227 done: u64,
228 total: u64,
230 frame: usize,
231 tty: bool,
232 finished: bool,
233}
234
235impl ProgressIndicator {
236 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 pub fn set_label(&mut self, label: impl Into<String>) {
250 self.label = label.into();
251 }
252
253 pub fn set(&mut self, done: u64, total: u64) {
255 self.done = done;
256 self.total = total;
257 }
258
259 pub fn indeterminate(&mut self) {
261 self.done = 0;
262 self.total = 0;
263 }
264
265 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 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 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}