Skip to main content

lean_ctx/core/
stats.rs

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