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