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            "{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    // 30-day savings subtitle (only shown when enough history exists)
572    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
986/// Runs the live-updating gain dashboard (1s refresh loop, Ctrl+C to exit).
987pub 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/// Renders a 30-day token savings bar chart with sparkline.
1019#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
1020pub 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/// Renders a daily breakdown table of token savings with totals.
1104#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
1105pub 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
1221/// Returns the full stats store as pretty-printed JSON.
1222pub fn format_gain_json() -> String {
1223    let store = super::load();
1224    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1225}