1use crate::core::theme::{self, Theme};
2
3use super::model::{CommandStats, CostModel, DayStats, StatsStore};
4
5fn active_theme() -> Theme {
6 let cfg = crate::core::config::Config::load();
7 theme::load_theme(&cfg.theme)
8}
9
10fn format_usd(amount: f64) -> String {
11 if amount >= 0.01 {
12 format!("${amount:.2}")
13 } else {
14 format!("${amount:.3}")
15 }
16}
17
18fn usd_estimate(tokens: u64) -> String {
19 let env_model = std::env::var("LEAN_CTX_MODEL")
20 .or_else(|_| std::env::var("LCTX_MODEL"))
21 .ok();
22 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
23 let quote = pricing.quote(env_model.as_deref());
24 let cost = tokens as f64 * quote.cost.input_per_m / 1_000_000.0;
25 format_usd(cost)
26}
27
28pub(super) fn format_pct_1dp(val: f64) -> String {
29 if val == 0.0 {
30 "0.0%".to_string()
31 } else if val > 0.0 && val < 0.1 {
32 "<0.1%".to_string()
33 } else {
34 format!("{val:.1}%")
35 }
36}
37
38pub(super) fn format_savings_pct(saved: u64, input: u64) -> String {
39 if input == 0 {
40 if saved > 0 {
41 return "n/a".to_string();
42 }
43 return "0.0%".to_string();
44 }
45 let rate = saved as f64 / input as f64 * 100.0;
46 format_pct_1dp(rate)
47}
48
49fn format_big(n: u64) -> String {
50 if n >= 1_000_000 {
51 format!("{:.1}M", n as f64 / 1_000_000.0)
52 } else if n >= 1_000 {
53 format!("{:.1}K", n as f64 / 1_000.0)
54 } else {
55 format!("{n}")
56 }
57}
58
59fn format_num(n: u64) -> String {
60 if n >= 1_000_000 {
61 format!("{:.1}M", n as f64 / 1_000_000.0)
62 } else if n >= 1_000 {
63 format!("{},{:03}", n / 1_000, n % 1_000)
64 } else {
65 format!("{n}")
66 }
67}
68
69fn truncate_cmd(cmd: &str, max: usize) -> String {
70 if cmd.len() <= max {
71 cmd.to_string()
72 } else {
73 format!("{}…", &cmd[..max - 1])
74 }
75}
76
77fn cmd_total_saved(s: &CommandStats, _cm: &CostModel) -> u64 {
78 s.input_tokens.saturating_sub(s.output_tokens)
79}
80
81fn day_total_saved(d: &DayStats, _cm: &CostModel) -> u64 {
82 d.input_tokens.saturating_sub(d.output_tokens)
83}
84
85pub(super) fn normalize_command(command: &str) -> String {
86 let parts: Vec<&str> = command.split_whitespace().collect();
87 if parts.is_empty() {
88 return command.to_string();
89 }
90
91 let base = std::path::Path::new(parts[0])
92 .file_name()
93 .and_then(|n| n.to_str())
94 .unwrap_or(parts[0]);
95
96 match base {
97 "git" => {
98 if parts.len() > 1 {
99 format!("git {}", parts[1])
100 } else {
101 "git".to_string()
102 }
103 }
104 "cargo" => {
105 if parts.len() > 1 {
106 format!("cargo {}", parts[1])
107 } else {
108 "cargo".to_string()
109 }
110 }
111 "npm" | "yarn" | "pnpm" => {
112 if parts.len() > 1 {
113 format!("{} {}", base, parts[1])
114 } else {
115 base.to_string()
116 }
117 }
118 "docker" => {
119 if parts.len() > 1 {
120 format!("docker {}", parts[1])
121 } else {
122 "docker".to_string()
123 }
124 }
125 _ => base.to_string(),
126 }
127}
128
129#[allow(clippy::many_single_char_names)] fn format_cep_live(lv: &serde_json::Value, t: &Theme) -> String {
131 let mut out = Vec::new();
132 let rst = theme::rst();
133 let bold = theme::bold();
134 let dim = theme::dim();
135
136 let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
137 let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
138 let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
139 let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
140 let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
141 let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
142 let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
143 let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
144 let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
145 let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
146
147 out.push(String::new());
148 out.push(format!(
149 " {icon} {brand} {cep} {dim}Live Session (no historical data yet){rst}",
150 icon = t.header_icon(),
151 brand = t.brand_title(),
152 cep = t.section_title("CEP"),
153 ));
154 out.push(format!(" {ln}", ln = t.border_line(56)));
155 out.push(String::new());
156
157 let txt = t.text.fg();
158 let sc = t.success.fg();
159 let sec = t.secondary.fg();
160
161 out.push(format!(
162 " {bold}{txt}CEP Score{rst} {bold}{pc}{score:>3}/100{rst}",
163 pc = t.pct_color(score as f64),
164 ));
165 out.push(format!(
166 " {bold}{txt}Cache Hit Rate{rst} {bold}{pc}{cache_util}%{rst} {dim}({cache_hits} hits / {total_reads} reads){rst}",
167 pc = t.pct_color(cache_util as f64),
168 ));
169 out.push(format!(
170 " {bold}{txt}Mode Diversity{rst} {bold}{pc}{mode_div}%{rst}",
171 pc = t.pct_color(mode_div as f64),
172 ));
173 out.push(format!(
174 " {bold}{txt}Compression{rst} {bold}{pc}{comp_rate}%{rst} {dim}({} → {}){rst}",
175 format_big(tok_orig),
176 format_big(tok_orig.saturating_sub(tok_saved)),
177 pc = t.pct_color(comp_rate as f64),
178 ));
179 out.push(format!(
180 " {bold}{txt}Tokens Saved{rst} {bold}{sc}{}{rst} {dim}(≈ {}){rst}",
181 format_big(tok_saved),
182 usd_estimate(tok_saved),
183 ));
184 out.push(format!(
185 " {bold}{txt}Tool Calls{rst} {bold}{sec}{tool_calls}{rst}"
186 ));
187 out.push(format!(
188 " {bold}{txt}Complexity{rst} {dim}{complexity}{rst}"
189 ));
190 out.push(String::new());
191 out.push(format!(" {ln}", ln = t.border_line(56)));
192 out.push(format!(
193 " {dim}This is live data from the current MCP session.{rst}"
194 ));
195 out.push(format!(
196 " {dim}Historical CEP trends appear after more sessions.{rst}"
197 ));
198 out.push(String::new());
199
200 out.join("\n")
201}
202
203fn load_mcp_live() -> Option<serde_json::Value> {
204 let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
205 let content = std::fs::read_to_string(path).ok()?;
206 serde_json::from_str(&content).ok()
207}
208
209#[allow(clippy::many_single_char_names)] pub fn format_cep_report() -> String {
212 let theme = active_theme();
213 let store = super::load();
214 let cep = &store.cep;
215 let live = load_mcp_live();
216 let mut out = Vec::new();
217 let rst = theme::rst();
218 let bold = theme::bold();
219 let dim = theme::dim();
220
221 if cep.sessions == 0 && live.is_none() {
222 return format!(
223 "{dim}No CEP sessions recorded yet.{rst}\n\
224 Use lean-ctx as an MCP server in your editor to start tracking.\n\
225 CEP metrics are recorded automatically during MCP sessions."
226 );
227 }
228
229 if cep.sessions == 0 {
230 if let Some(ref lv) = live {
231 return format_cep_live(lv, &theme);
232 }
233 }
234
235 let total_saved = cep
236 .total_tokens_original
237 .saturating_sub(cep.total_tokens_compressed);
238 let overall_compression = if cep.total_tokens_original > 0 {
239 total_saved as f64 / cep.total_tokens_original as f64 * 100.0
240 } else {
241 0.0
242 };
243 let cache_hit_rate = if cep.total_cache_reads > 0 {
244 cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
245 } else {
246 0.0
247 };
248 let avg_score = if cep.scores.is_empty() {
249 0.0
250 } else {
251 cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
252 };
253 let latest_score = cep.scores.last().map_or(0, |s| s.score);
254
255 let shell_saved = store
256 .total_input_tokens
257 .saturating_sub(store.total_output_tokens)
258 .saturating_sub(total_saved);
259 let total_all_saved = store
260 .total_input_tokens
261 .saturating_sub(store.total_output_tokens);
262 let cep_share = if total_all_saved > 0 {
263 total_saved as f64 / total_all_saved as f64 * 100.0
264 } else {
265 0.0
266 };
267
268 let txt = theme.text.fg();
269 let sc = theme.success.fg();
270 let sec = theme.secondary.fg();
271 let wrn = theme.warning.fg();
272
273 out.push(String::new());
274 out.push(format!(
275 " {icon} {brand} {cep} {dim}Cognitive Efficiency Protocol Report{rst}",
276 icon = theme.header_icon(),
277 brand = theme.brand_title(),
278 cep = theme.section_title("CEP"),
279 ));
280 out.push(format!(" {ln}", ln = theme.border_line(56)));
281 out.push(String::new());
282
283 out.push(format!(
284 " {bold}{txt}CEP Score{rst} {bold}{pc}{:>3}/100{rst} {dim}(avg: {avg_score:.0}, latest: {latest_score}){rst}",
285 latest_score,
286 pc = theme.pct_color(latest_score as f64),
287 ));
288 out.push(format!(
289 " {bold}{txt}Sessions{rst} {bold}{sec}{}{rst}",
290 cep.sessions
291 ));
292 out.push(format!(
293 " {bold}{txt}Cache Hit Rate{rst} {bold}{pc}{:.1}%{rst} {dim}({} hits / {} reads){rst}",
294 cache_hit_rate,
295 cep.total_cache_hits,
296 cep.total_cache_reads,
297 pc = theme.pct_color(cache_hit_rate),
298 ));
299 out.push(format!(
300 " {bold}{txt}MCP Compression{rst} {bold}{pc}{:.1}%{rst} {dim}({} → {}){rst}",
301 overall_compression,
302 format_big(cep.total_tokens_original),
303 format_big(cep.total_tokens_compressed),
304 pc = theme.pct_color(overall_compression),
305 ));
306 out.push(format!(
307 " {bold}{txt}Tokens Saved{rst} {bold}{sc}{}{rst} {dim}(≈ {}){rst}",
308 format_big(total_saved),
309 usd_estimate(total_saved),
310 ));
311 out.push(String::new());
312
313 out.push(format!(" {}", theme.section_title("Savings Breakdown")));
314 out.push(format!(" {ln}", ln = theme.border_line(56)));
315
316 let bar_w = 30;
317 let shell_ratio = if total_all_saved > 0 {
318 shell_saved as f64 / total_all_saved as f64
319 } else {
320 0.0
321 };
322 let cep_ratio = if total_all_saved > 0 {
323 total_saved as f64 / total_all_saved as f64
324 } else {
325 0.0
326 };
327 let m = theme.muted.fg();
328 let shell_bar = theme::pad_right(&theme.gradient_bar(shell_ratio, bar_w), bar_w);
329 let shell_pct_val = (1.0 - cep_share) * 100.0;
330 let shell_pct_display = format_pct_1dp(shell_pct_val);
331 out.push(format!(
332 " {m}Shell Hook{rst} {shell_bar} {bold}{:>6}{rst} {dim}({shell_pct_display}){rst}",
333 format_big(shell_saved),
334 ));
335 let cep_bar = theme::pad_right(&theme.gradient_bar(cep_ratio, bar_w), bar_w);
336 let cep_pct_display = format_pct_1dp(cep_share * 100.0);
337 out.push(format!(
338 " {m}MCP/CEP{rst} {cep_bar} {bold}{:>6}{rst} {dim}({cep_pct_display}){rst}",
339 format_big(total_saved),
340 ));
341 out.push(String::new());
342
343 if total_saved == 0 && cep.modes.is_empty() {
344 if store.total_commands > 20 {
345 out.push(format!(
346 " {wrn}⚠ MCP tools configured but not being used by your AI client.{rst}"
347 ));
348 out.push(
349 " Your AI client may be using native Read/Shell instead of ctx_read/ctx_shell."
350 .to_string(),
351 );
352 out.push(format!(
353 " Run {sec}lean-ctx init{rst} to update rules, then restart your AI session."
354 ));
355 out.push(format!(
356 " Run {sec}lean-ctx doctor{rst} for detailed adoption diagnostics."
357 ));
358 } else {
359 out.push(format!(
360 " {wrn}⚠ MCP server not configured.{rst} Shell hook compresses output, but"
361 ));
362 out.push(
363 " full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
364 .to_string(),
365 );
366 out.push(format!(
367 " Run {sec}lean-ctx setup{rst} to auto-configure your editors."
368 ));
369 }
370 out.push(String::new());
371 }
372
373 if !cep.modes.is_empty() {
374 out.push(format!(" {}", theme.section_title("Read Modes Used")));
375 out.push(format!(" {ln}", ln = theme.border_line(56)));
376
377 let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
378 sorted_modes.sort_by_key(|item| std::cmp::Reverse(*item.1));
379 let max_mode = *sorted_modes.first().map_or(&1, |(_, c)| *c);
380 let max_mode = max_mode.max(1);
381
382 for (mode, count) in &sorted_modes {
383 let ratio = **count as f64 / max_mode as f64;
384 let bar = theme::pad_right(&theme.gradient_bar(ratio, 20), 20);
385 out.push(format!(" {sec}{mode:<14}{rst} {count:>4}x {bar}"));
386 }
387
388 let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
389 let full_count = cep.modes.get("full").copied().unwrap_or(0);
390 let optimized = total_mode_calls.saturating_sub(full_count);
391 let opt_pct = if total_mode_calls > 0 {
392 optimized as f64 / total_mode_calls as f64 * 100.0
393 } else {
394 0.0
395 };
396 out.push(format!(
397 " {dim}{optimized}/{total_mode_calls} reads used optimized modes ({opt_pct:.0}% non-full){rst}"
398 ));
399 }
400
401 if cep.scores.len() >= 2 {
402 out.push(String::new());
403 out.push(format!(" {}", theme.section_title("CEP Score Trend")));
404 out.push(format!(" {ln}", ln = theme.border_line(56)));
405
406 let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
407 let spark = theme.gradient_sparkline(&score_values);
408 out.push(format!(" {spark}"));
409
410 let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
411 for snap in recent.iter().rev() {
412 let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
413 let pc = theme.pct_color(snap.score as f64);
414 out.push(format!(
415 " {m}{ts}{rst} {pc}{bold}{:>3}{rst}/100 cache:{:>3}% modes:{:>3}% {dim}{}{rst}",
416 snap.score, snap.cache_hit_rate, snap.mode_diversity, snap.complexity,
417 ));
418 }
419 }
420
421 out.push(String::new());
422 out.push(format!(" {ln}", ln = theme.border_line(56)));
423 out.push(format!(" {dim}Improve your CEP score:{rst}"));
424 if cache_hit_rate < 50.0 {
425 out.push(format!(
426 " {wrn}↑{rst} Re-read files with ctx_read to leverage caching"
427 ));
428 }
429 let modes_count = cep.modes.len();
430 if modes_count < 3 {
431 out.push(format!(
432 " {wrn}↑{rst} Use map/signatures modes for context-only files"
433 ));
434 }
435 if avg_score >= 70.0 {
436 out.push(format!(
437 " {sc}✓{rst} Great score! You're using lean-ctx effectively"
438 ));
439 }
440 out.push(String::new());
441
442 out.join("\n")
443}
444
445pub fn format_gain() -> String {
447 format_gain_themed(&active_theme())
448}
449
450pub fn format_gain_themed(t: &Theme) -> String {
452 format_gain_themed_at(t, None)
453}
454
455#[allow(clippy::many_single_char_names)] pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
458 let store = super::load();
459 let mut out = Vec::new();
460 let rst = theme::rst();
461 let bold = theme::bold();
462 let dim = theme::dim();
463
464 if store.total_commands == 0 {
465 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
466 Ok(p) => p.display().to_string(),
467 Err(_) => "~/.config/lean-ctx".into(),
468 };
469 let mcp_hint = if let Ok(live) =
470 std::fs::read_to_string(std::path::Path::new(&data_dir).join("mcp-live.json"))
471 {
472 if live.contains("\"total_calls\"") {
473 format!(
474 "\n{dim}MCP calls are tracked in mcp-live.json but stats.json is empty.{rst}\
475 \n{dim}This may indicate a data directory split. Run: lean-ctx doctor{rst}"
476 )
477 } else {
478 String::new()
479 }
480 } else {
481 String::new()
482 };
483 let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
484 let split_hint = if split_dirs.len() >= 2 {
485 format!(
486 "\n{dim}⚠ Stats found in multiple locations:{rst}\
487 \n{dim} {}{rst}\
488 \n{dim}Run: lean-ctx doctor{rst}",
489 split_dirs
490 .iter()
491 .map(|d| d.display().to_string())
492 .collect::<Vec<_>>()
493 .join(", ")
494 )
495 } else {
496 String::new()
497 };
498 return format!(
499 "{bold}No savings recorded yet — and that's expected.{rst}\
500 \n\n {dim}Savings appear after your AI tool uses lean-ctx for the first time.{rst}\
501 \n\n Next:\
502 \n 1. Make sure your AI tool is connected: {cmd}lean-ctx doctor{rst}\
503 \n 2. Fully restart your AI tool so it reconnects to lean-ctx.\
504 \n 3. Ask it to read a file or run a command — then check back here.\
505 \n\n {dim}Tip: track a shell command yourself with {rst}{cmd}lean-ctx -c \"git status\"{rst}\
506 \n\n {dim}Stats path: {data_dir}{rst}{mcp_hint}{split_hint}",
507 cmd = t.secondary.fg(),
508 );
509 }
510
511 let input_saved = store
512 .total_input_tokens
513 .saturating_sub(store.total_output_tokens);
514 let pct = if store.total_input_tokens > 0 {
515 input_saved as f64 / store.total_input_tokens as f64 * 100.0
516 } else {
517 0.0
518 };
519 let cost_model = CostModel::default();
520 let cost = cost_model.calculate(&store);
521 let total_saved = input_saved;
522 let days_active = store.daily.len();
523
524 let w = 62;
525 let side = t.box_side();
526
527 let box_line = |content: &str| -> String {
528 let padded = theme::pad_right(content, w);
529 format!(" {side}{padded}{side}")
530 };
531
532 out.push(String::new());
533 out.push(format!(" {}", t.box_top(w)));
534 out.push(box_line(""));
535
536 let header = format!(
537 " {icon} {bold}{title}{rst} {dim}Token Savings Dashboard{rst}",
538 icon = t.header_icon(),
539 title = t.brand_title(),
540 );
541 out.push(box_line(&header));
542 out.push(box_line(""));
543 out.push(format!(" {}", t.box_mid(w)));
544 out.push(box_line(""));
545
546 let tok_val = format_big(total_saved);
547 let pct_val = format!("{pct:.1}%");
548 let cmd_val = format_num(store.total_commands);
549 let usd_val = format_usd(cost.total_saved);
550
551 let c1 = t.success.fg();
552 let c2 = t.secondary.fg();
553 let c3 = t.warning.fg();
554 let c4 = t.accent.fg();
555
556 let kw = 14;
557 let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
558 let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
559 let v3 = theme::pad_right(&format!("{c3}{bold}{cmd_val}{rst}"), kw);
560 let v4 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
561 out.push(box_line(&format!(" {v1}{v2}{v3}{v4}")));
562
563 let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
564 let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
565 let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
566 let l4 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
567 out.push(box_line(&format!(" {l1}{l2}{l3}{l4}")));
568 out.push(box_line(""));
569 out.push(format!(" {}", t.box_bottom(w)));
570
571 if store.daily.len() >= 2 {
573 let thirty_day_tokens: u64 = store
574 .daily
575 .iter()
576 .rev()
577 .take(30)
578 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
579 .sum();
580 let thirty_day_usd = usd_estimate(thirty_day_tokens);
581 let accent = t.accent.fg();
582 out.push(format!(
583 " {dim}past 30 days:{rst} {accent}{bold}{thirty_day_usd}{rst} {dim}saved{rst}"
584 ));
585 }
586
587 {
588 let cfg = crate::core::config::Config::load();
589 if cfg.buddy_enabled {
590 let buddy = crate::core::buddy::BuddyState::compute();
591 out.push(crate::core::buddy::format_buddy_block_at(&buddy, t, tick));
592 }
593 }
594
595 out.push(String::new());
596
597 let cost_title = t.section_title("Cost Breakdown");
598 out.push(format!(
599 " {cost_title} {dim}@ ${:.2}/M input · ${:.2}/M output{rst}",
600 cost_model.input_price_per_m, cost_model.output_price_per_m,
601 ));
602 out.push(format!(" {ln}", ln = t.border_line(w)));
603 out.push(String::new());
604 let lbl_w = 20;
605 let lbl_without = theme::pad_right(
606 &format!("{m}Without lean-ctx{rst}", m = t.muted.fg()),
607 lbl_w,
608 );
609 let lbl_with = theme::pad_right(&format!("{m}With lean-ctx{rst}", m = t.muted.fg()), lbl_w);
610 let lbl_saved = theme::pad_right(
611 &format!("{c}{bold}You saved{rst}", c = t.success.fg()),
612 lbl_w,
613 );
614
615 out.push(format!(
616 " {lbl_without} {:>8} {dim}{} input + {} output{rst}",
617 format_usd(cost.total_cost_without),
618 format_usd(cost.input_cost_without),
619 format_usd(cost.output_cost_without),
620 ));
621 out.push(format!(
622 " {lbl_with} {:>8} {dim}{} input + {} output{rst}",
623 format_usd(cost.total_cost_with),
624 format_usd(cost.input_cost_with),
625 format_usd(cost.output_cost_with),
626 ));
627 out.push(String::new());
628 out.push(format!(
629 " {lbl_saved} {c}{bold}{:>8}{rst} {dim}input {} + output {}{rst}",
630 format_usd(cost.total_saved),
631 format_usd(cost.input_cost_without - cost.input_cost_with),
632 format_usd(cost.output_cost_without - cost.output_cost_with),
633 c = t.success.fg(),
634 ));
635
636 {
637 let mut mcp_saved = 0u64;
638 let mut mcp_input = 0u64;
639 let mut mcp_calls = 0u64;
640 let mut hook_saved = 0u64;
641 let mut hook_input = 0u64;
642 let mut hook_calls = 0u64;
643 for (cmd, s) in &store.commands {
644 let sv = s.input_tokens.saturating_sub(s.output_tokens);
645 if cmd.starts_with("ctx_") {
646 mcp_saved += sv;
647 mcp_input += s.input_tokens;
648 mcp_calls += s.count;
649 } else {
650 hook_saved += sv;
651 hook_input += s.input_tokens;
652 hook_calls += s.count;
653 }
654 }
655 if mcp_calls > 0 || hook_calls > 0 {
656 out.push(String::new());
657 out.push(format!(" {}", t.section_title("Savings by Source")));
658 out.push(format!(" {ln}", ln = t.border_line(w)));
659 out.push(String::new());
660
661 let total = (mcp_saved + hook_saved).max(1) as f64;
662 let mcp_pct = mcp_saved as f64 / total * 100.0;
663 let hook_pct = hook_saved as f64 / total * 100.0;
664 let mcp_rate_str = format_savings_pct(mcp_saved, mcp_input);
665 let hook_rate_str = format_savings_pct(hook_saved, hook_input);
666 let mcp_pct_str = format_pct_1dp(mcp_pct);
667 let hook_pct_str = format_pct_1dp(hook_pct);
668
669 let mcp_bar = t.gradient_bar(mcp_saved as f64 / total, 18);
670 let hook_bar = t.gradient_bar(hook_saved as f64 / total, 18);
671
672 let mc = t.success.fg();
673 let hc = t.secondary.fg();
674 out.push(format!(
675 " {mc}{bold}MCP Tools{rst} {:>5}x {mcp_bar} {bold}{:>6}{rst} {dim}{mcp_rate_str:>6} rate · {mcp_pct_str:>6} of total{rst}",
676 mcp_calls,
677 format_big(mcp_saved),
678 ));
679 out.push(format!(
680 " {hc}{bold}Shell Hooks{rst} {:>5}x {hook_bar} {bold}{:>6}{rst} {dim}{hook_rate_str:>6} rate · {hook_pct_str:>6} of total{rst}",
681 hook_calls,
682 format_big(hook_saved),
683 ));
684 }
685 }
686
687 out.push(String::new());
688
689 if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
690 let first_short = first.get(..10).unwrap_or(first);
691 let daily_savings: Vec<u64> = store
692 .daily
693 .iter()
694 .map(|d2| day_total_saved(d2, &cost_model))
695 .collect();
696 let spark = t.gradient_sparkline(&daily_savings);
697 out.push(format!(
698 " {dim}Since {first_short} · {days_active} day{plural}{rst} {spark}",
699 plural = if days_active == 1 { "" } else { "s" }
700 ));
701 out.push(String::new());
702 }
703
704 out.push(String::new());
705
706 if !store.commands.is_empty() {
707 out.push(format!(" {}", t.section_title("Top Commands")));
708 out.push(format!(" {ln}", ln = t.border_line(w)));
709 out.push(String::new());
710
711 let mut sorted: Vec<_> = store
712 .commands
713 .iter()
714 .filter(|(_, s)| s.input_tokens > s.output_tokens)
715 .collect();
716 sorted.sort_by(|a, b2| {
717 let sa = cmd_total_saved(a.1, &cost_model);
718 let sb = cmd_total_saved(b2.1, &cost_model);
719 sb.cmp(&sa)
720 });
721
722 let max_cmd_saved = sorted
723 .first()
724 .map_or(1, |(_, s)| cmd_total_saved(s, &cost_model))
725 .max(1);
726
727 for (cmd, stats) in sorted.iter().take(10) {
728 let cmd_saved = cmd_total_saved(stats, &cost_model);
729 let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
730 let cmd_pct = if stats.input_tokens > 0 {
731 cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
732 } else {
733 0.0
734 };
735 let ratio = cmd_saved as f64 / max_cmd_saved as f64;
736 let bar = theme::pad_right(&t.gradient_bar(ratio, 22), 22);
737 let pc = t.pct_color(cmd_pct);
738 let cmd_col = theme::pad_right(
739 &format!("{m}{}{rst}", truncate_cmd(cmd, 16), m = t.muted.fg()),
740 18,
741 );
742 let saved_col =
743 theme::pad_right(&format!("{bold}{pc}{}{rst}", format_big(cmd_saved)), 8);
744 out.push(format!(
745 " {cmd_col} {:>5}x {bar} {saved_col} {dim}{cmd_pct:>3.0}%{rst}",
746 stats.count,
747 ));
748 }
749
750 if sorted.len() > 10 {
751 out.push(format!(
752 " {dim}... +{} more commands{rst}",
753 sorted.len() - 10
754 ));
755 }
756 }
757
758 if store.daily.len() >= 2 {
759 out.push(String::new());
760 out.push(String::new());
761 out.push(format!(
762 " {} {dim}v{}{rst}",
763 t.section_title("Recent Days"),
764 env!("CARGO_PKG_VERSION"),
765 ));
766 out.push(format!(" {ln}", ln = t.border_line(w)));
767 out.push(String::new());
768
769 let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
770 for day in recent.iter().rev() {
771 let day_saved = day_total_saved(day, &cost_model);
772 let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
773 let day_pct = if day.input_tokens > 0 {
774 day_input_saved as f64 / day.input_tokens as f64 * 100.0
775 } else {
776 0.0
777 };
778 let pc = t.pct_color(day_pct);
779 let date_short = day.date.get(5..).unwrap_or(&day.date);
780 let date_col = theme::pad_right(&format!("{m}{date_short}{rst}", m = t.muted.fg()), 7);
781 let saved_col =
782 theme::pad_right(&format!("{pc}{bold}{}{rst}", format_big(day_saved)), 9);
783 out.push(format!(
784 " {date_col} {:>5} cmds {saved_col} saved {pc}{day_pct:>5.1}%{rst}",
785 day.commands,
786 ));
787 }
788 }
789
790 out.push(String::new());
791 out.push(String::new());
792
793 if let Some(tip) = contextual_tip(&store) {
794 out.push(format!(" {w}💡 {tip}{rst}", w = t.warning.fg()));
795 out.push(String::new());
796 }
797
798 {
799 let project_root = std::env::current_dir()
800 .map(|p| p.to_string_lossy().to_string())
801 .unwrap_or_default();
802 if !project_root.is_empty() {
803 let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
804 if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
805 let a = t.accent.fg();
806 out.push(format!(" {a}🧠 Bug Memory{rst}"));
807 out.push(format!(
808 " {m} Active gotchas: {}{rst} Bugs prevented: {}{rst}",
809 gotcha_store.gotchas.len(),
810 gotcha_store.stats.total_prevented,
811 m = t.muted.fg(),
812 ));
813 out.push(String::new());
814 }
815 }
816 }
817
818 {
819 let project_root = std::env::current_dir()
820 .map(|p| p.to_string_lossy().to_string())
821 .unwrap_or_default();
822 let a = t.accent.fg();
823 let m = t.muted.fg();
824
825 let mut ctx_items: Vec<String> = Vec::new();
826
827 if let Some(session) =
828 crate::core::session::SessionState::load_latest_for_project_root(&project_root)
829 {
830 let task_str = session
831 .task
832 .as_ref()
833 .map_or("—", |tk| tk.description.as_str());
834 let task_disp = if task_str.len() > 35 {
835 format!("{}…", &task_str[..task_str.floor_char_boundary(32)])
836 } else {
837 task_str.to_string()
838 };
839 ctx_items.push(format!(
840 " Session: {bold}{task_disp}{rst} {m}files={} findings={} terse={}{rst}",
841 session.files_touched.len(),
842 session.findings.len(),
843 if session.terse_mode { "on" } else { "off" },
844 ));
845 }
846
847 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
848 let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
849 if active_facts > 0 {
850 ctx_items.push(format!(
851 " Knowledge: {bold}{active_facts}{rst} active facts {m}{} total{rst}",
852 knowledge.facts.len(),
853 ));
854 }
855
856 if let Some(open) = crate::core::graph_provider::open_best_effort(&project_root) {
857 let nc = open.provider.node_count().unwrap_or(0);
858 let ec = open.provider.edge_count().unwrap_or(0);
859 if nc > 0 {
860 let (unit, suffix) = match open.source {
861 crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
862 ("nodes", "")
863 }
864 crate::core::graph_provider::GraphProviderSource::GraphIndex => {
865 let max_cfg = crate::core::config::Config::load().graph_index_max_files;
866 if max_cfg > 0 && nc >= max_cfg as usize {
867 ("files", " (limit reached)")
868 } else {
869 ("files", "")
870 }
871 }
872 };
873 ctx_items.push(format!(
874 " Graph: {bold}{nc}{rst} {unit} {bold}{ec}{rst} edges{suffix}",
875 ));
876 }
877 }
878
879 #[cfg(unix)]
880 let daemon_running = crate::daemon::is_daemon_running();
881 #[cfg(not(unix))]
882 let daemon_running = false;
883
884 if daemon_running {
885 ctx_items.push(format!(" Daemon: {c}running{rst}", c = t.success.fg()));
886 } else {
887 ctx_items.push(format!(
888 " {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
889 w = t.warning.fg()
890 ));
891 }
892
893 if !ctx_items.is_empty() {
894 out.push(format!(" {a}⚡ Context OS{rst}"));
895 for item in &ctx_items {
896 out.push(format!(" {item}"));
897 }
898 out.push(String::new());
899 }
900 }
901
902 let m = t.muted.fg();
903 out.push(format!(
904 " {m}🐛 Found a bug? Run: lean-ctx report-issue{rst}"
905 ));
906 out.push(format!(
907 " {m}📊 Help improve lean-ctx: lean-ctx contribute{rst}"
908 ));
909 out.push(format!(" {m}🧠 View bug memory: lean-ctx gotchas{rst}"));
910
911 out.push(String::new());
912 out.push(String::new());
913
914 out.join("\n")
915}
916
917fn contextual_tip(store: &StatsStore) -> Option<String> {
918 let tips = build_tips(store);
919 if tips.is_empty() {
920 return None;
921 }
922 let seed = std::time::SystemTime::now()
923 .duration_since(std::time::UNIX_EPOCH)
924 .unwrap_or_default()
925 .as_secs()
926 / 86400;
927 Some(tips[(seed as usize) % tips.len()].clone())
928}
929
930fn build_tips(store: &StatsStore) -> Vec<String> {
931 let mut tips = Vec::new();
932
933 if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
934 tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
935 }
936
937 if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
938 tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
939 }
940
941 if store.cep.total_cache_reads > 0
942 && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
943 {
944 tips.push(
945 "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
946 );
947 }
948
949 if store.total_commands > 50 && store.cep.sessions == 0 {
950 tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
951 }
952
953 if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
954 tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
955 }
956
957 if store.daily.len() >= 7 {
958 tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
959 }
960
961 tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
962 tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
963
964 let cfg = crate::core::config::Config::load();
965 if cfg.theme == "default" {
966 tips.push(
967 "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
968 );
969 tips.push(
970 "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
971 );
972 } else {
973 tips.push(format!(
974 "Current theme: {}. Run lean-ctx theme list to explore others.",
975 cfg.theme
976 ));
977 }
978
979 tips.push(
980 "Create your own theme with lean-ctx theme create <name> and set custom colors!".into(),
981 );
982
983 tips
984}
985
986pub fn gain_live() {
988 use std::io::Write;
989
990 let interval = std::time::Duration::from_secs(1);
991 let mut line_count = 0usize;
992 let dim = theme::dim();
993 let rst = theme::rst();
994
995 tracing::info!("Live mode (1s refresh) · Ctrl+C to exit");
996
997 loop {
998 if line_count > 0 {
999 print!("\x1B[{line_count}A\x1B[J");
1000 }
1001
1002 let tick = std::time::SystemTime::now()
1003 .duration_since(std::time::UNIX_EPOCH)
1004 .ok()
1005 .map(|d| d.as_millis() as u64);
1006 let output = format_gain_themed_at(&active_theme(), tick);
1007 let footer = format!("\n {dim}▸ Live · updates every 1s · Ctrl+C to exit{rst}\n");
1008 let full = format!("{output}{footer}");
1009 line_count = full.lines().count();
1010
1011 print!("{full}");
1012 let _ = std::io::stdout().flush();
1013
1014 std::thread::sleep(interval);
1015 }
1016}
1017
1018#[allow(clippy::many_single_char_names)] pub fn format_gain_graph() -> String {
1021 let theme = active_theme();
1022 let store = super::load();
1023 let rst = theme::rst();
1024 let bold = theme::bold();
1025 let dim = theme::dim();
1026
1027 if store.daily.is_empty() {
1028 return format!(
1029 "{dim}No daily data yet.{rst} Use lean-ctx for a few days to see the graph."
1030 );
1031 }
1032
1033 let cm = CostModel::default();
1034 let days: Vec<_> = store
1035 .daily
1036 .iter()
1037 .rev()
1038 .take(30)
1039 .collect::<Vec<_>>()
1040 .into_iter()
1041 .rev()
1042 .collect();
1043
1044 let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1045
1046 let max_saved = *savings.iter().max().unwrap_or(&1);
1047 let max_saved = max_saved.max(1);
1048
1049 let bar_width = 36;
1050 let mut out = Vec::new();
1051
1052 out.push(String::new());
1053 out.push(format!(
1054 " {icon} {title} {dim}Token Savings Graph (last 30 days){rst}",
1055 icon = theme.header_icon(),
1056 title = theme.brand_title(),
1057 ));
1058 out.push(format!(" {ln}", ln = theme.border_line(58)));
1059 out.push(format!(
1060 " {dim}{:>58}{rst}",
1061 format!("peak: {}", format_big(max_saved))
1062 ));
1063 out.push(String::new());
1064
1065 for (i, day) in days.iter().enumerate() {
1066 let saved = savings[i];
1067 let ratio = saved as f64 / max_saved as f64;
1068 let bar = theme::pad_right(&theme.gradient_bar(ratio, bar_width), bar_width);
1069
1070 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1071 let pct = if day.input_tokens > 0 {
1072 input_saved as f64 / day.input_tokens as f64 * 100.0
1073 } else {
1074 0.0
1075 };
1076 let date_short = day.date.get(5..).unwrap_or(&day.date);
1077
1078 out.push(format!(
1079 " {m}{date_short}{rst} {brd}│{rst} {bar} {bold}{:>6}{rst} {dim}{pct:.0}%{rst}",
1080 format_big(saved),
1081 m = theme.muted.fg(),
1082 brd = theme.border.fg(),
1083 ));
1084 }
1085
1086 let total_saved: u64 = savings.iter().sum();
1087 let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
1088 let spark = theme.gradient_sparkline(&savings);
1089
1090 out.push(String::new());
1091 out.push(format!(" {ln}", ln = theme.border_line(58)));
1092 out.push(format!(
1093 " {spark} {bold}{txt}{}{rst} saved across {bold}{}{rst} commands",
1094 format_big(total_saved),
1095 format_num(total_cmds),
1096 txt = theme.text.fg(),
1097 ));
1098 out.push(String::new());
1099
1100 out.join("\n")
1101}
1102
1103#[allow(clippy::many_single_char_names)] pub fn format_gain_daily() -> String {
1106 let theme = active_theme();
1107 let store = super::load();
1108 let rst = theme::rst();
1109 let bold = theme::bold();
1110 let dim = theme::dim();
1111
1112 if store.daily.is_empty() {
1113 return format!("{dim}No daily data yet.{rst}");
1114 }
1115
1116 let mut out = Vec::new();
1117 let w = 64;
1118
1119 let side = theme.box_side();
1120 let daily_box = |content: &str| -> String {
1121 let padded = theme::pad_right(content, w);
1122 format!(" {side}{padded}{side}")
1123 };
1124
1125 out.push(String::new());
1126 out.push(format!(
1127 " {icon} {title} {dim}Daily Breakdown{rst}",
1128 icon = theme.header_icon(),
1129 title = theme.brand_title(),
1130 ));
1131 out.push(format!(" {}", theme.box_top(w)));
1132 let hdr = format!(
1133 " {bold}{txt}{:<12} {:>6} {:>10} {:>10} {:>7} {:>6}{rst}",
1134 "Date",
1135 "Cmds",
1136 "Input",
1137 "Saved",
1138 "Rate",
1139 "USD",
1140 txt = theme.text.fg(),
1141 );
1142 out.push(daily_box(&hdr));
1143 out.push(format!(" {}", theme.box_mid(w)));
1144
1145 let days: Vec<_> = store
1146 .daily
1147 .iter()
1148 .rev()
1149 .take(30)
1150 .collect::<Vec<_>>()
1151 .into_iter()
1152 .rev()
1153 .cloned()
1154 .collect();
1155
1156 let cm = CostModel::default();
1157 for day in &days {
1158 let saved = day_total_saved(day, &cm);
1159 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1160 let pct = if day.input_tokens > 0 {
1161 input_saved as f64 / day.input_tokens as f64 * 100.0
1162 } else {
1163 0.0
1164 };
1165 let pc = theme.pct_color(pct);
1166 let usd = usd_estimate(saved);
1167 let row = format!(
1168 " {m}{:<12}{rst} {:>6} {:>10} {pc}{bold}{:>10}{rst} {pc}{:>6.1}%{rst} {dim}{:>6}{rst}",
1169 &day.date,
1170 day.commands,
1171 format_big(day.input_tokens),
1172 format_big(saved),
1173 pct,
1174 usd,
1175 m = theme.muted.fg(),
1176 );
1177 out.push(daily_box(&row));
1178 }
1179
1180 let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
1181 let total_saved: u64 = store
1182 .daily
1183 .iter()
1184 .map(|day| day_total_saved(day, &cm))
1185 .sum();
1186 let total_pct = if total_input > 0 {
1187 let input_saved: u64 = store
1188 .daily
1189 .iter()
1190 .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
1191 .sum();
1192 input_saved as f64 / total_input as f64 * 100.0
1193 } else {
1194 0.0
1195 };
1196 let total_usd = usd_estimate(total_saved);
1197 let sc = theme.success.fg();
1198
1199 out.push(format!(" {}", theme.box_mid(w)));
1200 let total_row = format!(
1201 " {bold}{txt}{:<12}{rst} {:>6} {:>10} {sc}{bold}{:>10}{rst} {sc}{bold}{:>6.1}%{rst} {bold}{:>6}{rst}",
1202 "TOTAL",
1203 format_num(store.total_commands),
1204 format_big(total_input),
1205 format_big(total_saved),
1206 total_pct,
1207 total_usd,
1208 txt = theme.text.fg(),
1209 );
1210 out.push(daily_box(&total_row));
1211 out.push(format!(" {}", theme.box_bottom(w)));
1212
1213 let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1214 let spark = theme.gradient_sparkline(&daily_savings);
1215 out.push(format!(" {dim}Trend:{rst} {spark}"));
1216 out.push(String::new());
1217
1218 out.join("\n")
1219}
1220
1221pub fn format_gain_json() -> String {
1223 let store = super::load();
1224 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1225}