Skip to main content

lean_ctx/core/stats/
format.rs

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)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
130fn 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/// Renders the full CEP (Cognitive Efficiency Protocol) report with themes.
210#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
211pub 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
445/// Renders the token savings dashboard using the active theme.
446pub fn format_gain() -> String {
447    format_gain_themed(&active_theme())
448}
449
450/// Renders the token savings dashboard with a specific theme.
451pub fn format_gain_themed(t: &Theme) -> String {
452    format_gain_themed_at(t, None)
453}
454
455/// Renders the token savings dashboard at a specific animation tick.
456#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
457pub 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            "{dim}No commands recorded yet.{rst}\
500             \n\n  MCP tools are tracked automatically when agents use lean-ctx.\
501             \n  Shell commands: use {cmd}lean-ctx -c \"command\"{rst} to track.\
502             \n\n  {dim}Stats path: {data_dir}{rst}{mcp_hint}{split_hint}",
503            cmd = t.secondary.fg(),
504        );
505    }
506
507    let input_saved = store
508        .total_input_tokens
509        .saturating_sub(store.total_output_tokens);
510    let pct = if store.total_input_tokens > 0 {
511        input_saved as f64 / store.total_input_tokens as f64 * 100.0
512    } else {
513        0.0
514    };
515    let cost_model = CostModel::default();
516    let cost = cost_model.calculate(&store);
517    let total_saved = input_saved;
518    let days_active = store.daily.len();
519
520    let w = 62;
521    let side = t.box_side();
522
523    let box_line = |content: &str| -> String {
524        let padded = theme::pad_right(content, w);
525        format!("  {side}{padded}{side}")
526    };
527
528    out.push(String::new());
529    out.push(format!("  {}", t.box_top(w)));
530    out.push(box_line(""));
531
532    let header = format!(
533        "    {icon}  {bold}{title}{rst}   {dim}Token Savings Dashboard{rst}",
534        icon = t.header_icon(),
535        title = t.brand_title(),
536    );
537    out.push(box_line(&header));
538    out.push(box_line(""));
539    out.push(format!("  {}", t.box_mid(w)));
540    out.push(box_line(""));
541
542    let tok_val = format_big(total_saved);
543    let pct_val = format!("{pct:.1}%");
544    let cmd_val = format_num(store.total_commands);
545    let usd_val = format_usd(cost.total_saved);
546
547    let c1 = t.success.fg();
548    let c2 = t.secondary.fg();
549    let c3 = t.warning.fg();
550    let c4 = t.accent.fg();
551
552    let kw = 14;
553    let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
554    let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
555    let v3 = theme::pad_right(&format!("{c3}{bold}{cmd_val}{rst}"), kw);
556    let v4 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
557    out.push(box_line(&format!("    {v1}{v2}{v3}{v4}")));
558
559    let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
560    let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
561    let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
562    let l4 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
563    out.push(box_line(&format!("    {l1}{l2}{l3}{l4}")));
564    out.push(box_line(""));
565    out.push(format!("  {}", t.box_bottom(w)));
566
567    {
568        let cfg = crate::core::config::Config::load();
569        if cfg.buddy_enabled {
570            let buddy = crate::core::buddy::BuddyState::compute();
571            out.push(crate::core::buddy::format_buddy_block_at(&buddy, t, tick));
572        }
573    }
574
575    out.push(String::new());
576
577    let cost_title = t.section_title("Cost Breakdown");
578    out.push(format!(
579        "  {cost_title}  {dim}@ ${:.2}/M input · ${:.2}/M output{rst}",
580        cost_model.input_price_per_m, cost_model.output_price_per_m,
581    ));
582    out.push(format!("  {ln}", ln = t.border_line(w)));
583    out.push(String::new());
584    let lbl_w = 20;
585    let lbl_without = theme::pad_right(
586        &format!("{m}Without lean-ctx{rst}", m = t.muted.fg()),
587        lbl_w,
588    );
589    let lbl_with = theme::pad_right(&format!("{m}With lean-ctx{rst}", m = t.muted.fg()), lbl_w);
590    let lbl_saved = theme::pad_right(
591        &format!("{c}{bold}You saved{rst}", c = t.success.fg()),
592        lbl_w,
593    );
594
595    out.push(format!(
596        "    {lbl_without} {:>8}   {dim}{} input + {} output{rst}",
597        format_usd(cost.total_cost_without),
598        format_usd(cost.input_cost_without),
599        format_usd(cost.output_cost_without),
600    ));
601    out.push(format!(
602        "    {lbl_with} {:>8}   {dim}{} input + {} output{rst}",
603        format_usd(cost.total_cost_with),
604        format_usd(cost.input_cost_with),
605        format_usd(cost.output_cost_with),
606    ));
607    out.push(String::new());
608    out.push(format!(
609        "    {lbl_saved} {c}{bold}{:>8}{rst}   {dim}input {} + output {}{rst}",
610        format_usd(cost.total_saved),
611        format_usd(cost.input_cost_without - cost.input_cost_with),
612        format_usd(cost.output_cost_without - cost.output_cost_with),
613        c = t.success.fg(),
614    ));
615
616    {
617        let mut mcp_saved = 0u64;
618        let mut mcp_input = 0u64;
619        let mut mcp_calls = 0u64;
620        let mut hook_saved = 0u64;
621        let mut hook_input = 0u64;
622        let mut hook_calls = 0u64;
623        for (cmd, s) in &store.commands {
624            let sv = s.input_tokens.saturating_sub(s.output_tokens);
625            if cmd.starts_with("ctx_") {
626                mcp_saved += sv;
627                mcp_input += s.input_tokens;
628                mcp_calls += s.count;
629            } else {
630                hook_saved += sv;
631                hook_input += s.input_tokens;
632                hook_calls += s.count;
633            }
634        }
635        if mcp_calls > 0 || hook_calls > 0 {
636            out.push(String::new());
637            out.push(format!("  {}", t.section_title("Savings by Source")));
638            out.push(format!("  {ln}", ln = t.border_line(w)));
639            out.push(String::new());
640
641            let total = (mcp_saved + hook_saved).max(1) as f64;
642            let mcp_pct = mcp_saved as f64 / total * 100.0;
643            let hook_pct = hook_saved as f64 / total * 100.0;
644            let mcp_rate_str = format_savings_pct(mcp_saved, mcp_input);
645            let hook_rate_str = format_savings_pct(hook_saved, hook_input);
646            let mcp_pct_str = format_pct_1dp(mcp_pct);
647            let hook_pct_str = format_pct_1dp(hook_pct);
648
649            let mcp_bar = t.gradient_bar(mcp_saved as f64 / total, 18);
650            let hook_bar = t.gradient_bar(hook_saved as f64 / total, 18);
651
652            let mc = t.success.fg();
653            let hc = t.secondary.fg();
654            out.push(format!(
655                "    {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}",
656                mcp_calls,
657                format_big(mcp_saved),
658            ));
659            out.push(format!(
660                "    {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}",
661                hook_calls,
662                format_big(hook_saved),
663            ));
664        }
665    }
666
667    out.push(String::new());
668
669    if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
670        let first_short = first.get(..10).unwrap_or(first);
671        let daily_savings: Vec<u64> = store
672            .daily
673            .iter()
674            .map(|d2| day_total_saved(d2, &cost_model))
675            .collect();
676        let spark = t.gradient_sparkline(&daily_savings);
677        out.push(format!(
678            "    {dim}Since {first_short} · {days_active} day{plural}{rst}   {spark}",
679            plural = if days_active == 1 { "" } else { "s" }
680        ));
681        out.push(String::new());
682    }
683
684    out.push(String::new());
685
686    if !store.commands.is_empty() {
687        out.push(format!("  {}", t.section_title("Top Commands")));
688        out.push(format!("  {ln}", ln = t.border_line(w)));
689        out.push(String::new());
690
691        let mut sorted: Vec<_> = store
692            .commands
693            .iter()
694            .filter(|(_, s)| s.input_tokens > s.output_tokens)
695            .collect();
696        sorted.sort_by(|a, b2| {
697            let sa = cmd_total_saved(a.1, &cost_model);
698            let sb = cmd_total_saved(b2.1, &cost_model);
699            sb.cmp(&sa)
700        });
701
702        let max_cmd_saved = sorted
703            .first()
704            .map_or(1, |(_, s)| cmd_total_saved(s, &cost_model))
705            .max(1);
706
707        for (cmd, stats) in sorted.iter().take(10) {
708            let cmd_saved = cmd_total_saved(stats, &cost_model);
709            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
710            let cmd_pct = if stats.input_tokens > 0 {
711                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
712            } else {
713                0.0
714            };
715            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
716            let bar = theme::pad_right(&t.gradient_bar(ratio, 22), 22);
717            let pc = t.pct_color(cmd_pct);
718            let cmd_col = theme::pad_right(
719                &format!("{m}{}{rst}", truncate_cmd(cmd, 16), m = t.muted.fg()),
720                18,
721            );
722            let saved_col =
723                theme::pad_right(&format!("{bold}{pc}{}{rst}", format_big(cmd_saved)), 8);
724            out.push(format!(
725                "    {cmd_col} {:>5}x   {bar}  {saved_col} {dim}{cmd_pct:>3.0}%{rst}",
726                stats.count,
727            ));
728        }
729
730        if sorted.len() > 10 {
731            out.push(format!(
732                "    {dim}... +{} more commands{rst}",
733                sorted.len() - 10
734            ));
735        }
736    }
737
738    if store.daily.len() >= 2 {
739        out.push(String::new());
740        out.push(String::new());
741        out.push(format!("  {}", t.section_title("Recent Days")));
742        out.push(format!("  {ln}", ln = t.border_line(w)));
743        out.push(String::new());
744
745        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
746        for day in recent.iter().rev() {
747            let day_saved = day_total_saved(day, &cost_model);
748            let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
749            let day_pct = if day.input_tokens > 0 {
750                day_input_saved as f64 / day.input_tokens as f64 * 100.0
751            } else {
752                0.0
753            };
754            let pc = t.pct_color(day_pct);
755            let date_short = day.date.get(5..).unwrap_or(&day.date);
756            let date_col = theme::pad_right(&format!("{m}{date_short}{rst}", m = t.muted.fg()), 7);
757            let saved_col =
758                theme::pad_right(&format!("{pc}{bold}{}{rst}", format_big(day_saved)), 9);
759            out.push(format!(
760                "    {date_col}  {:>5} cmds   {saved_col} saved   {pc}{day_pct:>5.1}%{rst}",
761                day.commands,
762            ));
763        }
764    }
765
766    out.push(String::new());
767    out.push(String::new());
768
769    if let Some(tip) = contextual_tip(&store) {
770        out.push(format!("    {w}💡 {tip}{rst}", w = t.warning.fg()));
771        out.push(String::new());
772    }
773
774    {
775        let project_root = std::env::current_dir()
776            .map(|p| p.to_string_lossy().to_string())
777            .unwrap_or_default();
778        if !project_root.is_empty() {
779            let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
780            if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
781                let a = t.accent.fg();
782                out.push(format!("    {a}🧠 Bug Memory{rst}"));
783                out.push(format!(
784                    "    {m}   Active gotchas: {}{rst}   Bugs prevented: {}{rst}",
785                    gotcha_store.gotchas.len(),
786                    gotcha_store.stats.total_prevented,
787                    m = t.muted.fg(),
788                ));
789                out.push(String::new());
790            }
791        }
792    }
793
794    {
795        let project_root = std::env::current_dir()
796            .map(|p| p.to_string_lossy().to_string())
797            .unwrap_or_default();
798        let a = t.accent.fg();
799        let m = t.muted.fg();
800
801        let mut ctx_items: Vec<String> = Vec::new();
802
803        if let Some(session) =
804            crate::core::session::SessionState::load_latest_for_project_root(&project_root)
805        {
806            let task_str = session
807                .task
808                .as_ref()
809                .map_or("—", |tk| tk.description.as_str());
810            let task_disp = if task_str.len() > 35 {
811                format!("{}…", &task_str[..32])
812            } else {
813                task_str.to_string()
814            };
815            ctx_items.push(format!(
816                "   Session: {bold}{task_disp}{rst}  {m}files={} findings={} terse={}{rst}",
817                session.files_touched.len(),
818                session.findings.len(),
819                if session.terse_mode { "on" } else { "off" },
820            ));
821        }
822
823        let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
824        let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
825        if active_facts > 0 {
826            ctx_items.push(format!(
827                "   Knowledge: {bold}{active_facts}{rst} active facts  {m}{} total{rst}",
828                knowledge.facts.len(),
829            ));
830        }
831
832        if let Some(open) = crate::core::graph_provider::open_best_effort(&project_root) {
833            let nc = open.provider.node_count().unwrap_or(0);
834            let ec = open.provider.edge_count().unwrap_or(0);
835            if nc > 0 {
836                let (unit, suffix) = match open.source {
837                    crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
838                        ("nodes", "")
839                    }
840                    crate::core::graph_provider::GraphProviderSource::GraphIndex => {
841                        let max_cfg = crate::core::config::Config::load().graph_index_max_files;
842                        if max_cfg > 0 && nc >= max_cfg as usize {
843                            ("files", " (limit reached)")
844                        } else {
845                            ("files", "")
846                        }
847                    }
848                };
849                ctx_items.push(format!(
850                    "   Graph: {bold}{nc}{rst} {unit}  {bold}{ec}{rst} edges{suffix}",
851                ));
852            }
853        }
854
855        #[cfg(unix)]
856        let daemon_running = crate::daemon::is_daemon_running();
857        #[cfg(not(unix))]
858        let daemon_running = false;
859
860        if daemon_running {
861            ctx_items.push(format!("   Daemon: {c}running{rst}", c = t.success.fg()));
862        } else {
863            ctx_items.push(format!(
864                "   {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
865                w = t.warning.fg()
866            ));
867        }
868
869        if !ctx_items.is_empty() {
870            out.push(format!("    {a}⚡ Context OS{rst}"));
871            for item in &ctx_items {
872                out.push(format!("    {item}"));
873            }
874            out.push(String::new());
875        }
876    }
877
878    let m = t.muted.fg();
879    out.push(format!(
880        "    {m}🐛 Found a bug? Run: lean-ctx report-issue{rst}"
881    ));
882    out.push(format!(
883        "    {m}📊 Help improve lean-ctx: lean-ctx contribute{rst}"
884    ));
885    out.push(format!("    {m}🧠 View bug memory: lean-ctx gotchas{rst}"));
886
887    out.push(String::new());
888    out.push(String::new());
889
890    out.join("\n")
891}
892
893fn contextual_tip(store: &StatsStore) -> Option<String> {
894    let tips = build_tips(store);
895    if tips.is_empty() {
896        return None;
897    }
898    let seed = std::time::SystemTime::now()
899        .duration_since(std::time::UNIX_EPOCH)
900        .unwrap_or_default()
901        .as_secs()
902        / 86400;
903    Some(tips[(seed as usize) % tips.len()].clone())
904}
905
906fn build_tips(store: &StatsStore) -> Vec<String> {
907    let mut tips = Vec::new();
908
909    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
910        tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
911    }
912
913    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
914        tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
915    }
916
917    if store.cep.total_cache_reads > 0
918        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
919    {
920        tips.push(
921            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
922        );
923    }
924
925    if store.total_commands > 50 && store.cep.sessions == 0 {
926        tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
927    }
928
929    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
930        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
931    }
932
933    if store.daily.len() >= 7 {
934        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
935    }
936
937    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
938    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
939
940    let cfg = crate::core::config::Config::load();
941    if cfg.theme == "default" {
942        tips.push(
943            "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
944        );
945        tips.push(
946            "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
947        );
948    } else {
949        tips.push(format!(
950            "Current theme: {}. Run lean-ctx theme list to explore others.",
951            cfg.theme
952        ));
953    }
954
955    tips.push(
956        "Create your own theme with lean-ctx theme create <name> and set custom colors!".into(),
957    );
958
959    tips
960}
961
962/// Runs the live-updating gain dashboard (1s refresh loop, Ctrl+C to exit).
963pub fn gain_live() {
964    use std::io::Write;
965
966    let interval = std::time::Duration::from_secs(1);
967    let mut line_count = 0usize;
968    let dim = theme::dim();
969    let rst = theme::rst();
970
971    tracing::info!("Live mode (1s refresh) · Ctrl+C to exit");
972
973    loop {
974        if line_count > 0 {
975            print!("\x1B[{line_count}A\x1B[J");
976        }
977
978        let tick = std::time::SystemTime::now()
979            .duration_since(std::time::UNIX_EPOCH)
980            .ok()
981            .map(|d| d.as_millis() as u64);
982        let output = format_gain_themed_at(&active_theme(), tick);
983        let footer = format!("\n  {dim}▸ Live · updates every 1s · Ctrl+C to exit{rst}\n");
984        let full = format!("{output}{footer}");
985        line_count = full.lines().count();
986
987        print!("{full}");
988        let _ = std::io::stdout().flush();
989
990        std::thread::sleep(interval);
991    }
992}
993
994/// Renders a 30-day token savings bar chart with sparkline.
995#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
996pub fn format_gain_graph() -> String {
997    let theme = active_theme();
998    let store = super::load();
999    let rst = theme::rst();
1000    let bold = theme::bold();
1001    let dim = theme::dim();
1002
1003    if store.daily.is_empty() {
1004        return format!(
1005            "{dim}No daily data yet.{rst} Use lean-ctx for a few days to see the graph."
1006        );
1007    }
1008
1009    let cm = CostModel::default();
1010    let days: Vec<_> = store
1011        .daily
1012        .iter()
1013        .rev()
1014        .take(30)
1015        .collect::<Vec<_>>()
1016        .into_iter()
1017        .rev()
1018        .collect();
1019
1020    let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1021
1022    let max_saved = *savings.iter().max().unwrap_or(&1);
1023    let max_saved = max_saved.max(1);
1024
1025    let bar_width = 36;
1026    let mut out = Vec::new();
1027
1028    out.push(String::new());
1029    out.push(format!(
1030        "  {icon} {title}  {dim}Token Savings Graph (last 30 days){rst}",
1031        icon = theme.header_icon(),
1032        title = theme.brand_title(),
1033    ));
1034    out.push(format!("  {ln}", ln = theme.border_line(58)));
1035    out.push(format!(
1036        "  {dim}{:>58}{rst}",
1037        format!("peak: {}", format_big(max_saved))
1038    ));
1039    out.push(String::new());
1040
1041    for (i, day) in days.iter().enumerate() {
1042        let saved = savings[i];
1043        let ratio = saved as f64 / max_saved as f64;
1044        let bar = theme::pad_right(&theme.gradient_bar(ratio, bar_width), bar_width);
1045
1046        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1047        let pct = if day.input_tokens > 0 {
1048            input_saved as f64 / day.input_tokens as f64 * 100.0
1049        } else {
1050            0.0
1051        };
1052        let date_short = day.date.get(5..).unwrap_or(&day.date);
1053
1054        out.push(format!(
1055            "  {m}{date_short}{rst} {brd}│{rst} {bar} {bold}{:>6}{rst} {dim}{pct:.0}%{rst}",
1056            format_big(saved),
1057            m = theme.muted.fg(),
1058            brd = theme.border.fg(),
1059        ));
1060    }
1061
1062    let total_saved: u64 = savings.iter().sum();
1063    let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
1064    let spark = theme.gradient_sparkline(&savings);
1065
1066    out.push(String::new());
1067    out.push(format!("  {ln}", ln = theme.border_line(58)));
1068    out.push(format!(
1069        "  {spark}  {bold}{txt}{}{rst} saved across {bold}{}{rst} commands",
1070        format_big(total_saved),
1071        format_num(total_cmds),
1072        txt = theme.text.fg(),
1073    ));
1074    out.push(String::new());
1075
1076    out.join("\n")
1077}
1078
1079/// Renders a daily breakdown table of token savings with totals.
1080#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
1081pub fn format_gain_daily() -> String {
1082    let theme = active_theme();
1083    let store = super::load();
1084    let rst = theme::rst();
1085    let bold = theme::bold();
1086    let dim = theme::dim();
1087
1088    if store.daily.is_empty() {
1089        return format!("{dim}No daily data yet.{rst}");
1090    }
1091
1092    let mut out = Vec::new();
1093    let w = 64;
1094
1095    let side = theme.box_side();
1096    let daily_box = |content: &str| -> String {
1097        let padded = theme::pad_right(content, w);
1098        format!("  {side}{padded}{side}")
1099    };
1100
1101    out.push(String::new());
1102    out.push(format!(
1103        "  {icon} {title}  {dim}Daily Breakdown{rst}",
1104        icon = theme.header_icon(),
1105        title = theme.brand_title(),
1106    ));
1107    out.push(format!("  {}", theme.box_top(w)));
1108    let hdr = format!(
1109        " {bold}{txt}{:<12} {:>6}  {:>10}  {:>10}  {:>7}  {:>6}{rst}",
1110        "Date",
1111        "Cmds",
1112        "Input",
1113        "Saved",
1114        "Rate",
1115        "USD",
1116        txt = theme.text.fg(),
1117    );
1118    out.push(daily_box(&hdr));
1119    out.push(format!("  {}", theme.box_mid(w)));
1120
1121    let days: Vec<_> = store
1122        .daily
1123        .iter()
1124        .rev()
1125        .take(30)
1126        .collect::<Vec<_>>()
1127        .into_iter()
1128        .rev()
1129        .cloned()
1130        .collect();
1131
1132    let cm = CostModel::default();
1133    for day in &days {
1134        let saved = day_total_saved(day, &cm);
1135        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1136        let pct = if day.input_tokens > 0 {
1137            input_saved as f64 / day.input_tokens as f64 * 100.0
1138        } else {
1139            0.0
1140        };
1141        let pc = theme.pct_color(pct);
1142        let usd = usd_estimate(saved);
1143        let row = format!(
1144            " {m}{:<12}{rst} {:>6}  {:>10}  {pc}{bold}{:>10}{rst}  {pc}{:>6.1}%{rst}  {dim}{:>6}{rst}",
1145            &day.date,
1146            day.commands,
1147            format_big(day.input_tokens),
1148            format_big(saved),
1149            pct,
1150            usd,
1151            m = theme.muted.fg(),
1152        );
1153        out.push(daily_box(&row));
1154    }
1155
1156    let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
1157    let total_saved: u64 = store
1158        .daily
1159        .iter()
1160        .map(|day| day_total_saved(day, &cm))
1161        .sum();
1162    let total_pct = if total_input > 0 {
1163        let input_saved: u64 = store
1164            .daily
1165            .iter()
1166            .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
1167            .sum();
1168        input_saved as f64 / total_input as f64 * 100.0
1169    } else {
1170        0.0
1171    };
1172    let total_usd = usd_estimate(total_saved);
1173    let sc = theme.success.fg();
1174
1175    out.push(format!("  {}", theme.box_mid(w)));
1176    let total_row = format!(
1177        " {bold}{txt}{:<12}{rst} {:>6}  {:>10}  {sc}{bold}{:>10}{rst}  {sc}{bold}{:>6.1}%{rst}  {bold}{:>6}{rst}",
1178        "TOTAL",
1179        format_num(store.total_commands),
1180        format_big(total_input),
1181        format_big(total_saved),
1182        total_pct,
1183        total_usd,
1184        txt = theme.text.fg(),
1185    );
1186    out.push(daily_box(&total_row));
1187    out.push(format!("  {}", theme.box_bottom(w)));
1188
1189    let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1190    let spark = theme.gradient_sparkline(&daily_savings);
1191    out.push(format!("  {dim}Trend:{rst} {spark}"));
1192    out.push(String::new());
1193
1194    out.join("\n")
1195}
1196
1197/// Returns the full stats store as pretty-printed JSON.
1198pub fn format_gain_json() -> String {
1199    let store = super::load();
1200    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1201}