Skip to main content

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