Skip to main content

lean_ctx/core/
stats.rs

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