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