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    crate::core::data_dir::lean_ctx_data_dir().ok()
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 write_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
103fn merge_and_save(current: &StatsStore, baseline: &StatsStore) -> StatsStore {
104    let dir = match stats_dir() {
105        Some(d) => d,
106        None => {
107            let disk = load_from_disk();
108            return apply_deltas(&disk, current, baseline);
109        }
110    };
111
112    let lock_path = dir.join(".stats.lock");
113    let _lock = acquire_file_lock(&lock_path);
114
115    let disk = load_from_disk();
116    let merged = apply_deltas(&disk, current, baseline);
117    write_to_disk(&merged);
118    merged
119}
120
121struct FileLockGuard(PathBuf);
122
123impl Drop for FileLockGuard {
124    fn drop(&mut self) {
125        let _ = std::fs::remove_file(&self.0);
126    }
127}
128
129fn acquire_file_lock(lock_path: &std::path::Path) -> Option<FileLockGuard> {
130    for _ in 0..20 {
131        match std::fs::OpenOptions::new()
132            .create_new(true)
133            .write(true)
134            .open(lock_path)
135        {
136            Ok(_) => return Some(FileLockGuard(lock_path.to_path_buf())),
137            Err(_) => {
138                if let Ok(meta) = std::fs::metadata(lock_path) {
139                    if let Ok(modified) = meta.modified() {
140                        if modified.elapsed().unwrap_or_default().as_secs() > 5 {
141                            let _ = std::fs::remove_file(lock_path);
142                            continue;
143                        }
144                    }
145                }
146                std::thread::sleep(std::time::Duration::from_millis(5));
147            }
148        }
149    }
150    None
151}
152
153fn apply_deltas(disk: &StatsStore, current: &StatsStore, baseline: &StatsStore) -> StatsStore {
154    let mut merged = disk.clone();
155
156    let delta_commands = current
157        .total_commands
158        .saturating_sub(baseline.total_commands);
159    let delta_input = current
160        .total_input_tokens
161        .saturating_sub(baseline.total_input_tokens);
162    let delta_output = current
163        .total_output_tokens
164        .saturating_sub(baseline.total_output_tokens);
165
166    merged.total_commands += delta_commands;
167    merged.total_input_tokens += delta_input;
168    merged.total_output_tokens += delta_output;
169
170    for (cmd, stats) in &current.commands {
171        let base = baseline.commands.get(cmd);
172        let dc = stats.count.saturating_sub(base.map_or(0, |b| b.count));
173        let di = stats
174            .input_tokens
175            .saturating_sub(base.map_or(0, |b| b.input_tokens));
176        let do_ = stats
177            .output_tokens
178            .saturating_sub(base.map_or(0, |b| b.output_tokens));
179        if dc > 0 || di > 0 || do_ > 0 {
180            let entry = merged.commands.entry(cmd.clone()).or_default();
181            entry.count += dc;
182            entry.input_tokens += di;
183            entry.output_tokens += do_;
184        }
185    }
186
187    merge_daily(&mut merged.daily, &current.daily, &baseline.daily);
188
189    if let Some(ref ts) = current.last_use {
190        match merged.last_use {
191            Some(ref existing) if existing >= ts => {}
192            _ => merged.last_use = Some(ts.clone()),
193        }
194    }
195    if merged.first_use.is_none() {
196        merged.first_use = current.first_use.clone();
197    } else if let Some(ref cur_first) = current.first_use {
198        if let Some(ref merged_first) = merged.first_use {
199            if cur_first < merged_first {
200                merged.first_use = Some(cur_first.clone());
201            }
202        }
203    }
204
205    merge_cep(&mut merged.cep, &current.cep, &baseline.cep);
206
207    merged
208}
209
210fn merge_daily(merged: &mut Vec<DayStats>, current: &[DayStats], baseline: &[DayStats]) {
211    let base_map: HashMap<String, &DayStats> =
212        baseline.iter().map(|d| (d.date.clone(), d)).collect();
213
214    for day in current {
215        let base = base_map.get(&day.date);
216        let dc = day.commands.saturating_sub(base.map_or(0, |b| b.commands));
217        let di = day
218            .input_tokens
219            .saturating_sub(base.map_or(0, |b| b.input_tokens));
220        let do_ = day
221            .output_tokens
222            .saturating_sub(base.map_or(0, |b| b.output_tokens));
223        if dc == 0 && di == 0 && do_ == 0 {
224            continue;
225        }
226        if let Some(existing) = merged.iter_mut().find(|d| d.date == day.date) {
227            existing.commands += dc;
228            existing.input_tokens += di;
229            existing.output_tokens += do_;
230        } else {
231            merged.push(DayStats {
232                date: day.date.clone(),
233                commands: dc,
234                input_tokens: di,
235                output_tokens: do_,
236            });
237        }
238    }
239
240    if merged.len() > 90 {
241        merged.sort_by(|a, b| a.date.cmp(&b.date));
242        merged.drain(..merged.len() - 90);
243    }
244}
245
246fn merge_cep(merged: &mut CepStats, current: &CepStats, baseline: &CepStats) {
247    merged.sessions += current.sessions.saturating_sub(baseline.sessions);
248    merged.total_cache_hits += current
249        .total_cache_hits
250        .saturating_sub(baseline.total_cache_hits);
251    merged.total_cache_reads += current
252        .total_cache_reads
253        .saturating_sub(baseline.total_cache_reads);
254    merged.total_tokens_original += current
255        .total_tokens_original
256        .saturating_sub(baseline.total_tokens_original);
257    merged.total_tokens_compressed += current
258        .total_tokens_compressed
259        .saturating_sub(baseline.total_tokens_compressed);
260
261    for (mode, count) in &current.modes {
262        let base_count = baseline.modes.get(mode).copied().unwrap_or(0);
263        let delta = count.saturating_sub(base_count);
264        if delta > 0 {
265            *merged.modes.entry(mode.clone()).or_insert(0) += delta;
266        }
267    }
268
269    let base_scores_len = baseline.scores.len();
270    if current.scores.len() > base_scores_len {
271        for snapshot in &current.scores[base_scores_len..] {
272            merged.scores.push(snapshot.clone());
273        }
274    }
275    if merged.scores.len() > 100 {
276        merged.scores.drain(..merged.scores.len() - 100);
277    }
278
279    if current.last_session_pid.is_some() {
280        merged.last_session_pid = current.last_session_pid;
281        merged.last_session_original = current.last_session_original;
282        merged.last_session_compressed = current.last_session_compressed;
283    }
284}
285
286pub fn load() -> StatsStore {
287    let guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
288    if let Some((ref current, ref baseline, _)) = *guard {
289        let disk = load_from_disk();
290        return apply_deltas(&disk, current, baseline);
291    }
292    drop(guard);
293    load_from_disk()
294}
295
296pub fn save(store: &StatsStore) {
297    write_to_disk(store);
298}
299
300const FLUSH_INTERVAL_SECS: u64 = 30;
301
302/// (current_state, baseline_from_disk, last_flush_time)
303static STATS_BUFFER: Mutex<Option<(StatsStore, StatsStore, Instant)>> = Mutex::new(None);
304
305fn maybe_flush(store: &mut StatsStore, baseline: &mut StatsStore, last_flush: &mut Instant) {
306    if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS {
307        let merged = merge_and_save(store, baseline);
308        *store = merged.clone();
309        *baseline = merged;
310        *last_flush = Instant::now();
311    }
312}
313
314pub fn flush() {
315    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
316    if let Some((ref mut store, ref mut baseline, ref mut last_flush)) = *guard {
317        let merged = merge_and_save(store, baseline);
318        *store = merged.clone();
319        *baseline = merged;
320        *last_flush = Instant::now();
321    }
322}
323
324pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
325    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
326    if guard.is_none() {
327        let disk = load_from_disk();
328        *guard = Some((disk.clone(), disk, Instant::now()));
329    }
330    let (store, baseline, last_flush) = guard.as_mut().unwrap();
331
332    let is_first_command = store.total_commands == baseline.total_commands;
333    let now = chrono::Local::now();
334    let today = now.format("%Y-%m-%d").to_string();
335    let timestamp = now.to_rfc3339();
336
337    store.total_commands += 1;
338    store.total_input_tokens += input_tokens as u64;
339    store.total_output_tokens += output_tokens as u64;
340
341    if store.first_use.is_none() {
342        store.first_use = Some(timestamp.clone());
343    }
344    store.last_use = Some(timestamp);
345
346    let cmd_key = normalize_command(command);
347    let entry = store.commands.entry(cmd_key).or_default();
348    entry.count += 1;
349    entry.input_tokens += input_tokens as u64;
350    entry.output_tokens += output_tokens as u64;
351
352    if let Some(day) = store.daily.last_mut() {
353        if day.date == today {
354            day.commands += 1;
355            day.input_tokens += input_tokens as u64;
356            day.output_tokens += output_tokens as u64;
357        } else {
358            store.daily.push(DayStats {
359                date: today,
360                commands: 1,
361                input_tokens: input_tokens as u64,
362                output_tokens: output_tokens as u64,
363            });
364        }
365    } else {
366        store.daily.push(DayStats {
367            date: today,
368            commands: 1,
369            input_tokens: input_tokens as u64,
370            output_tokens: output_tokens as u64,
371        });
372    }
373
374    if store.daily.len() > 90 {
375        store.daily.drain(..store.daily.len() - 90);
376    }
377
378    if is_first_command {
379        let merged = merge_and_save(store, baseline);
380        *store = merged.clone();
381        *baseline = merged;
382        *last_flush = Instant::now();
383    } else {
384        maybe_flush(store, baseline, last_flush);
385    }
386}
387
388fn normalize_command(command: &str) -> String {
389    let parts: Vec<&str> = command.split_whitespace().collect();
390    if parts.is_empty() {
391        return command.to_string();
392    }
393
394    let base = std::path::Path::new(parts[0])
395        .file_name()
396        .and_then(|n| n.to_str())
397        .unwrap_or(parts[0]);
398
399    match base {
400        "git" => {
401            if parts.len() > 1 {
402                format!("git {}", parts[1])
403            } else {
404                "git".to_string()
405            }
406        }
407        "cargo" => {
408            if parts.len() > 1 {
409                format!("cargo {}", parts[1])
410            } else {
411                "cargo".to_string()
412            }
413        }
414        "npm" | "yarn" | "pnpm" => {
415            if parts.len() > 1 {
416                format!("{} {}", base, parts[1])
417            } else {
418                base.to_string()
419            }
420        }
421        "docker" => {
422            if parts.len() > 1 {
423                format!("docker {}", parts[1])
424            } else {
425                "docker".to_string()
426            }
427        }
428        _ => base.to_string(),
429    }
430}
431
432pub fn reset_cep() {
433    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
434    let mut store = load_from_disk();
435    store.cep = CepStats::default();
436    write_to_disk(&store);
437    *guard = Some((store.clone(), store, Instant::now()));
438}
439
440pub fn reset_all() {
441    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
442    let store = StatsStore::default();
443    write_to_disk(&store);
444    *guard = Some((store.clone(), store, Instant::now()));
445}
446
447pub struct GainSummary {
448    pub total_saved: u64,
449    pub total_calls: u64,
450}
451
452pub fn load_stats() -> GainSummary {
453    let store = load();
454    let input_saved = store
455        .total_input_tokens
456        .saturating_sub(store.total_output_tokens);
457    GainSummary {
458        total_saved: input_saved,
459        total_calls: store.total_commands,
460    }
461}
462
463fn cmd_total_saved(s: &CommandStats, _cm: &CostModel) -> u64 {
464    s.input_tokens.saturating_sub(s.output_tokens)
465}
466
467fn day_total_saved(d: &DayStats, _cm: &CostModel) -> u64 {
468    d.input_tokens.saturating_sub(d.output_tokens)
469}
470
471#[allow(clippy::too_many_arguments)]
472pub fn record_cep_session(
473    score: u32,
474    cache_hits: u64,
475    cache_reads: u64,
476    tokens_original: u64,
477    tokens_compressed: u64,
478    modes: &HashMap<String, u64>,
479    tool_calls: u64,
480    complexity: &str,
481) {
482    let mut guard = STATS_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
483    if guard.is_none() {
484        let disk = load_from_disk();
485        *guard = Some((disk.clone(), disk, Instant::now()));
486    }
487    let (store, baseline, last_flush) = guard.as_mut().unwrap();
488
489    let cep = &mut store.cep;
490
491    let pid = std::process::id();
492    let prev_original = cep.last_session_original.unwrap_or(0);
493    let prev_compressed = cep.last_session_compressed.unwrap_or(0);
494    let is_same_session = cep.last_session_pid == Some(pid);
495
496    if is_same_session {
497        let delta_original = tokens_original.saturating_sub(prev_original);
498        let delta_compressed = tokens_compressed.saturating_sub(prev_compressed);
499        cep.total_tokens_original += delta_original;
500        cep.total_tokens_compressed += delta_compressed;
501    } else {
502        cep.sessions += 1;
503        cep.total_cache_hits += cache_hits;
504        cep.total_cache_reads += cache_reads;
505        cep.total_tokens_original += tokens_original;
506        cep.total_tokens_compressed += tokens_compressed;
507
508        for (mode, count) in modes {
509            *cep.modes.entry(mode.clone()).or_insert(0) += count;
510        }
511    }
512
513    cep.last_session_pid = Some(pid);
514    cep.last_session_original = Some(tokens_original);
515    cep.last_session_compressed = Some(tokens_compressed);
516
517    let cache_hit_rate = if cache_reads > 0 {
518        (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
519    } else {
520        0
521    };
522
523    let compression_rate = if tokens_original > 0 {
524        ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
525            as u32
526    } else {
527        0
528    };
529
530    let total_modes = 6u32;
531    let mode_diversity =
532        ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
533
534    let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
535
536    cep.scores.push(CepSessionSnapshot {
537        timestamp: chrono::Local::now().to_rfc3339(),
538        score,
539        cache_hit_rate,
540        mode_diversity,
541        compression_rate,
542        tool_calls,
543        tokens_saved,
544        complexity: complexity.to_string(),
545    });
546
547    if cep.scores.len() > 100 {
548        cep.scores.drain(..cep.scores.len() - 100);
549    }
550
551    maybe_flush(store, baseline, last_flush);
552}
553
554use super::theme::{self, Theme};
555
556fn active_theme() -> Theme {
557    let cfg = super::config::Config::load();
558    theme::load_theme(&cfg.theme)
559}
560
561/// Average LLM pricing per 1M tokens (blended across Claude, GPT, Gemini).
562pub const DEFAULT_INPUT_PRICE_PER_M: f64 = 2.50;
563pub const DEFAULT_OUTPUT_PRICE_PER_M: f64 = 10.0;
564
565pub struct CostModel {
566    pub input_price_per_m: f64,
567    pub output_price_per_m: f64,
568    pub avg_verbose_output_per_call: u64,
569    pub avg_concise_output_per_call: u64,
570}
571
572impl Default for CostModel {
573    fn default() -> Self {
574        let env_model = std::env::var("LEAN_CTX_MODEL")
575            .or_else(|_| std::env::var("LCTX_MODEL"))
576            .ok();
577        let pricing = crate::core::gain::model_pricing::ModelPricing::load();
578        let quote = pricing.quote(env_model.as_deref());
579        Self {
580            input_price_per_m: quote.cost.input_per_m,
581            output_price_per_m: quote.cost.output_per_m,
582            avg_verbose_output_per_call: 180,
583            avg_concise_output_per_call: 120,
584        }
585    }
586}
587
588pub struct CostBreakdown {
589    pub input_cost_without: f64,
590    pub input_cost_with: f64,
591    pub output_cost_without: f64,
592    pub output_cost_with: f64,
593    pub total_cost_without: f64,
594    pub total_cost_with: f64,
595    pub total_saved: f64,
596    pub estimated_output_tokens_without: u64,
597    pub estimated_output_tokens_with: u64,
598    pub output_tokens_saved: u64,
599}
600
601impl CostModel {
602    pub fn calculate(&self, store: &StatsStore) -> CostBreakdown {
603        let input_cost_without =
604            store.total_input_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
605        let input_cost_with =
606            store.total_output_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
607
608        let input_saved = store
609            .total_input_tokens
610            .saturating_sub(store.total_output_tokens);
611        let compression_rate = if store.total_input_tokens > 0 {
612            input_saved as f64 / store.total_input_tokens as f64
613        } else {
614            0.0
615        };
616        let est_output_without = store.total_commands * self.avg_verbose_output_per_call;
617        let est_output_with = if compression_rate > 0.01 {
618            store.total_commands * self.avg_concise_output_per_call
619        } else {
620            est_output_without
621        };
622        let output_saved = est_output_without.saturating_sub(est_output_with);
623
624        let output_cost_without = est_output_without as f64 / 1_000_000.0 * self.output_price_per_m;
625        let output_cost_with = est_output_with as f64 / 1_000_000.0 * self.output_price_per_m;
626
627        let total_without = input_cost_without + output_cost_without;
628        let total_with = input_cost_with + output_cost_with;
629
630        CostBreakdown {
631            input_cost_without,
632            input_cost_with,
633            output_cost_without,
634            output_cost_with,
635            total_cost_without: total_without,
636            total_cost_with: total_with,
637            total_saved: total_without - total_with,
638            estimated_output_tokens_without: est_output_without,
639            estimated_output_tokens_with: est_output_with,
640            output_tokens_saved: output_saved,
641        }
642    }
643}
644
645fn format_usd(amount: f64) -> String {
646    if amount >= 0.01 {
647        format!("${amount:.2}")
648    } else {
649        format!("${amount:.3}")
650    }
651}
652
653fn usd_estimate(tokens: u64) -> String {
654    let env_model = std::env::var("LEAN_CTX_MODEL")
655        .or_else(|_| std::env::var("LCTX_MODEL"))
656        .ok();
657    let pricing = crate::core::gain::model_pricing::ModelPricing::load();
658    let quote = pricing.quote(env_model.as_deref());
659    let cost = tokens as f64 * quote.cost.input_per_m / 1_000_000.0;
660    format_usd(cost)
661}
662
663fn format_pct_1dp(val: f64) -> String {
664    if val == 0.0 {
665        "0.0%".to_string()
666    } else if val > 0.0 && val < 0.1 {
667        "<0.1%".to_string()
668    } else {
669        format!("{val:.1}%")
670    }
671}
672
673fn format_savings_pct(saved: u64, input: u64) -> String {
674    if input == 0 {
675        if saved > 0 {
676            return "n/a".to_string();
677        }
678        return "0.0%".to_string();
679    }
680    let rate = saved as f64 / input as f64 * 100.0;
681    format_pct_1dp(rate)
682}
683
684fn format_big(n: u64) -> String {
685    if n >= 1_000_000 {
686        format!("{:.1}M", n as f64 / 1_000_000.0)
687    } else if n >= 1_000 {
688        format!("{:.1}K", n as f64 / 1_000.0)
689    } else {
690        format!("{n}")
691    }
692}
693
694fn format_num(n: u64) -> String {
695    if n >= 1_000_000 {
696        format!("{:.1}M", n as f64 / 1_000_000.0)
697    } else if n >= 1_000 {
698        format!("{},{:03}", n / 1_000, n % 1_000)
699    } else {
700        format!("{n}")
701    }
702}
703
704fn truncate_cmd(cmd: &str, max: usize) -> String {
705    if cmd.len() <= max {
706        cmd.to_string()
707    } else {
708        format!("{}…", &cmd[..max - 1])
709    }
710}
711
712fn format_cep_live(lv: &serde_json::Value, t: &Theme) -> String {
713    let mut o = Vec::new();
714    let r = theme::rst();
715    let b = theme::bold();
716    let d = theme::dim();
717
718    let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
719    let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
720    let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
721    let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
722    let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
723    let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
724    let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
725    let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
726    let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
727    let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
728
729    o.push(String::new());
730    o.push(format!(
731        "  {icon} {brand} {cep}  {d}Live Session (no historical data yet){r}",
732        icon = t.header_icon(),
733        brand = t.brand_title(),
734        cep = t.section_title("CEP"),
735    ));
736    o.push(format!("  {ln}", ln = t.border_line(56)));
737    o.push(String::new());
738
739    let txt = t.text.fg();
740    let sc = t.success.fg();
741    let sec = t.secondary.fg();
742
743    o.push(format!(
744        "  {b}{txt}CEP Score{r}         {b}{pc}{score:>3}/100{r}",
745        pc = t.pct_color(score as f64),
746    ));
747    o.push(format!(
748        "  {b}{txt}Cache Hit Rate{r}    {b}{pc}{cache_util}%{r}  {d}({cache_hits} hits / {total_reads} reads){r}",
749        pc = t.pct_color(cache_util as f64),
750    ));
751    o.push(format!(
752        "  {b}{txt}Mode Diversity{r}    {b}{pc}{mode_div}%{r}",
753        pc = t.pct_color(mode_div as f64),
754    ));
755    o.push(format!(
756        "  {b}{txt}Compression{r}       {b}{pc}{comp_rate}%{r}  {d}({} → {}){r}",
757        format_big(tok_orig),
758        format_big(tok_orig.saturating_sub(tok_saved)),
759        pc = t.pct_color(comp_rate as f64),
760    ));
761    o.push(format!(
762        "  {b}{txt}Tokens Saved{r}      {b}{sc}{}{r}  {d}(≈ {}){r}",
763        format_big(tok_saved),
764        usd_estimate(tok_saved),
765    ));
766    o.push(format!(
767        "  {b}{txt}Tool Calls{r}        {b}{sec}{tool_calls}{r}"
768    ));
769    o.push(format!("  {b}{txt}Complexity{r}        {d}{complexity}{r}"));
770    o.push(String::new());
771    o.push(format!("  {ln}", ln = t.border_line(56)));
772    o.push(format!(
773        "  {d}This is live data from the current MCP session.{r}"
774    ));
775    o.push(format!(
776        "  {d}Historical CEP trends appear after more sessions.{r}"
777    ));
778    o.push(String::new());
779
780    o.join("\n")
781}
782
783fn load_mcp_live() -> Option<serde_json::Value> {
784    let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
785    let content = std::fs::read_to_string(path).ok()?;
786    serde_json::from_str(&content).ok()
787}
788
789pub fn format_cep_report() -> String {
790    let t = active_theme();
791    let store = load();
792    let cep = &store.cep;
793    let live = load_mcp_live();
794    let mut o = Vec::new();
795    let r = theme::rst();
796    let b = theme::bold();
797    let d = theme::dim();
798
799    if cep.sessions == 0 && live.is_none() {
800        return format!(
801            "{d}No CEP sessions recorded yet.{r}\n\
802             Use lean-ctx as an MCP server in your editor to start tracking.\n\
803             CEP metrics are recorded automatically during MCP sessions."
804        );
805    }
806
807    if cep.sessions == 0 {
808        if let Some(ref lv) = live {
809            return format_cep_live(lv, &t);
810        }
811    }
812
813    let total_saved = cep
814        .total_tokens_original
815        .saturating_sub(cep.total_tokens_compressed);
816    let overall_compression = if cep.total_tokens_original > 0 {
817        total_saved as f64 / cep.total_tokens_original as f64 * 100.0
818    } else {
819        0.0
820    };
821    let cache_hit_rate = if cep.total_cache_reads > 0 {
822        cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
823    } else {
824        0.0
825    };
826    let avg_score = if !cep.scores.is_empty() {
827        cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
828    } else {
829        0.0
830    };
831    let latest_score = cep.scores.last().map(|s| s.score).unwrap_or(0);
832
833    let shell_saved = store
834        .total_input_tokens
835        .saturating_sub(store.total_output_tokens)
836        .saturating_sub(total_saved);
837    let total_all_saved = store
838        .total_input_tokens
839        .saturating_sub(store.total_output_tokens);
840    let cep_share = if total_all_saved > 0 {
841        total_saved as f64 / total_all_saved as f64 * 100.0
842    } else {
843        0.0
844    };
845
846    let txt = t.text.fg();
847    let sc = t.success.fg();
848    let sec = t.secondary.fg();
849    let wrn = t.warning.fg();
850
851    o.push(String::new());
852    o.push(format!(
853        "  {icon} {brand} {cep}  {d}Cognitive Efficiency Protocol Report{r}",
854        icon = t.header_icon(),
855        brand = t.brand_title(),
856        cep = t.section_title("CEP"),
857    ));
858    o.push(format!("  {ln}", ln = t.border_line(56)));
859    o.push(String::new());
860
861    o.push(format!(
862        "  {b}{txt}CEP Score{r}         {b}{pc}{:>3}/100{r}  {d}(avg: {avg_score:.0}, latest: {latest_score}){r}",
863        latest_score,
864        pc = t.pct_color(latest_score as f64),
865    ));
866    o.push(format!(
867        "  {b}{txt}Sessions{r}          {b}{sec}{}{r}",
868        cep.sessions
869    ));
870    o.push(format!(
871        "  {b}{txt}Cache Hit Rate{r}    {b}{pc}{:.1}%{r}  {d}({} hits / {} reads){r}",
872        cache_hit_rate,
873        cep.total_cache_hits,
874        cep.total_cache_reads,
875        pc = t.pct_color(cache_hit_rate),
876    ));
877    o.push(format!(
878        "  {b}{txt}MCP Compression{r}   {b}{pc}{:.1}%{r}  {d}({} → {}){r}",
879        overall_compression,
880        format_big(cep.total_tokens_original),
881        format_big(cep.total_tokens_compressed),
882        pc = t.pct_color(overall_compression),
883    ));
884    o.push(format!(
885        "  {b}{txt}Tokens Saved{r}      {b}{sc}{}{r}  {d}(≈ {}){r}",
886        format_big(total_saved),
887        usd_estimate(total_saved),
888    ));
889    o.push(String::new());
890
891    o.push(format!("  {}", t.section_title("Savings Breakdown")));
892    o.push(format!("  {ln}", ln = t.border_line(56)));
893
894    let bar_w = 30;
895    let shell_ratio = if total_all_saved > 0 {
896        shell_saved as f64 / total_all_saved as f64
897    } else {
898        0.0
899    };
900    let cep_ratio = if total_all_saved > 0 {
901        total_saved as f64 / total_all_saved as f64
902    } else {
903        0.0
904    };
905    let m = t.muted.fg();
906    let shell_bar = theme::pad_right(&t.gradient_bar(shell_ratio, bar_w), bar_w);
907    let shell_pct_val = (1.0 - cep_share) * 100.0;
908    let shell_pct_display = format_pct_1dp(shell_pct_val);
909    o.push(format!(
910        "  {m}Shell Hook{r}   {shell_bar} {b}{:>6}{r} {d}({shell_pct_display}){r}",
911        format_big(shell_saved),
912    ));
913    let cep_bar = theme::pad_right(&t.gradient_bar(cep_ratio, bar_w), bar_w);
914    let cep_pct_display = format_pct_1dp(cep_share * 100.0);
915    o.push(format!(
916        "  {m}MCP/CEP{r}      {cep_bar} {b}{:>6}{r} {d}({cep_pct_display}){r}",
917        format_big(total_saved),
918    ));
919    o.push(String::new());
920
921    if total_saved == 0 && cep.modes.is_empty() {
922        if store.total_commands > 20 {
923            o.push(format!(
924                "  {wrn}⚠  MCP tools configured but not being used by your AI client.{r}"
925            ));
926            o.push(
927                "     Your AI client may be using native Read/Shell instead of ctx_read/ctx_shell."
928                    .to_string(),
929            );
930            o.push(format!(
931                "     Run {sec}lean-ctx init{r} to update rules, then restart your AI session."
932            ));
933            o.push(format!(
934                "     Run {sec}lean-ctx doctor{r} for detailed adoption diagnostics."
935            ));
936        } else {
937            o.push(format!(
938                "  {wrn}⚠  MCP server not configured.{r} Shell hook compresses output, but"
939            ));
940            o.push(
941                "     full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
942                    .to_string(),
943            );
944            o.push(format!(
945                "     Run {sec}lean-ctx setup{r} to auto-configure your editors."
946            ));
947        }
948        o.push(String::new());
949    }
950
951    if !cep.modes.is_empty() {
952        o.push(format!("  {}", t.section_title("Read Modes Used")));
953        o.push(format!("  {ln}", ln = t.border_line(56)));
954
955        let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
956        sorted_modes.sort_by(|a, b2| b2.1.cmp(a.1));
957        let max_mode = *sorted_modes.first().map(|(_, c)| *c).unwrap_or(&1);
958        let max_mode = max_mode.max(1);
959
960        for (mode, count) in &sorted_modes {
961            let ratio = **count as f64 / max_mode as f64;
962            let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
963            o.push(format!("  {sec}{:<14}{r} {:>4}x  {bar}", mode, count,));
964        }
965
966        let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
967        let full_count = cep.modes.get("full").copied().unwrap_or(0);
968        let optimized = total_mode_calls.saturating_sub(full_count);
969        let opt_pct = if total_mode_calls > 0 {
970            optimized as f64 / total_mode_calls as f64 * 100.0
971        } else {
972            0.0
973        };
974        o.push(format!(
975            "  {d}{optimized}/{total_mode_calls} reads used optimized modes ({opt_pct:.0}% non-full){r}"
976        ));
977    }
978
979    if cep.scores.len() >= 2 {
980        o.push(String::new());
981        o.push(format!("  {}", t.section_title("CEP Score Trend")));
982        o.push(format!("  {ln}", ln = t.border_line(56)));
983
984        let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
985        let spark = t.gradient_sparkline(&score_values);
986        o.push(format!("  {spark}"));
987
988        let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
989        for snap in recent.iter().rev() {
990            let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
991            let pc = t.pct_color(snap.score as f64);
992            o.push(format!(
993                "  {m}{ts}{r}  {pc}{b}{:>3}{r}/100  cache:{:>3}%  modes:{:>3}%  {d}{}{r}",
994                snap.score, snap.cache_hit_rate, snap.mode_diversity, snap.complexity,
995            ));
996        }
997    }
998
999    o.push(String::new());
1000    o.push(format!("  {ln}", ln = t.border_line(56)));
1001    o.push(format!("  {d}Improve your CEP score:{r}"));
1002    if cache_hit_rate < 50.0 {
1003        o.push(format!(
1004            "    {wrn}↑{r} Re-read files with ctx_read to leverage caching"
1005        ));
1006    }
1007    let modes_count = cep.modes.len();
1008    if modes_count < 3 {
1009        o.push(format!(
1010            "    {wrn}↑{r} Use map/signatures modes for context-only files"
1011        ));
1012    }
1013    if avg_score >= 70.0 {
1014        o.push(format!(
1015            "    {sc}✓{r} Great score! You're using lean-ctx effectively"
1016        ));
1017    }
1018    o.push(String::new());
1019
1020    o.join("\n")
1021}
1022
1023pub fn format_gain() -> String {
1024    format_gain_themed(&active_theme())
1025}
1026
1027pub fn format_gain_themed(t: &Theme) -> String {
1028    format_gain_themed_at(t, None)
1029}
1030
1031pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
1032    let store = load();
1033    let mut o = Vec::new();
1034    let r = theme::rst();
1035    let b = theme::bold();
1036    let d = theme::dim();
1037
1038    if store.total_commands == 0 {
1039        return format!(
1040            "{d}No commands recorded yet.{r} Use {cmd}lean-ctx -c \"command\"{r} to start tracking.",
1041            cmd = t.secondary.fg(),
1042        );
1043    }
1044
1045    let input_saved = store
1046        .total_input_tokens
1047        .saturating_sub(store.total_output_tokens);
1048    let pct = if store.total_input_tokens > 0 {
1049        input_saved as f64 / store.total_input_tokens as f64 * 100.0
1050    } else {
1051        0.0
1052    };
1053    let cost_model = CostModel::default();
1054    let cost = cost_model.calculate(&store);
1055    let total_saved = input_saved;
1056    let days_active = store.daily.len();
1057
1058    let w = 62;
1059    let side = t.box_side();
1060
1061    let box_line = |content: &str| -> String {
1062        let padded = theme::pad_right(content, w);
1063        format!("  {side}{padded}{side}")
1064    };
1065
1066    o.push(String::new());
1067    o.push(format!("  {}", t.box_top(w)));
1068    o.push(box_line(""));
1069
1070    let header = format!(
1071        "    {icon}  {b}{title}{r}   {d}Token Savings Dashboard{r}",
1072        icon = t.header_icon(),
1073        title = t.brand_title(),
1074    );
1075    o.push(box_line(&header));
1076    o.push(box_line(""));
1077    o.push(format!("  {}", t.box_mid(w)));
1078    o.push(box_line(""));
1079
1080    let tok_val = format_big(total_saved);
1081    let pct_val = format!("{pct:.1}%");
1082    let cmd_val = format_num(store.total_commands);
1083    let usd_val = format_usd(cost.total_saved);
1084
1085    let c1 = t.success.fg();
1086    let c2 = t.secondary.fg();
1087    let c3 = t.warning.fg();
1088    let c4 = t.accent.fg();
1089
1090    let kw = 14;
1091    let v1 = theme::pad_right(&format!("{c1}{b}{tok_val}{r}"), kw);
1092    let v2 = theme::pad_right(&format!("{c2}{b}{pct_val}{r}"), kw);
1093    let v3 = theme::pad_right(&format!("{c3}{b}{cmd_val}{r}"), kw);
1094    let v4 = theme::pad_right(&format!("{c4}{b}{usd_val}{r}"), kw);
1095    o.push(box_line(&format!("    {v1}{v2}{v3}{v4}")));
1096
1097    let l1 = theme::pad_right(&format!("{d}tokens saved{r}"), kw);
1098    let l2 = theme::pad_right(&format!("{d}compression{r}"), kw);
1099    let l3 = theme::pad_right(&format!("{d}commands{r}"), kw);
1100    let l4 = theme::pad_right(&format!("{d}USD saved{r}"), kw);
1101    o.push(box_line(&format!("    {l1}{l2}{l3}{l4}")));
1102    o.push(box_line(""));
1103    o.push(format!("  {}", t.box_bottom(w)));
1104
1105    // Token Guardian Buddy
1106    {
1107        let cfg = crate::core::config::Config::load();
1108        if cfg.buddy_enabled {
1109            let buddy = crate::core::buddy::BuddyState::compute();
1110            o.push(crate::core::buddy::format_buddy_block_at(&buddy, t, tick));
1111        }
1112    }
1113
1114    o.push(String::new());
1115
1116    let cost_title = t.section_title("Cost Breakdown");
1117    o.push(format!(
1118        "  {cost_title}  {d}@ ${:.2}/M input · ${:.2}/M output{r}",
1119        cost_model.input_price_per_m, cost_model.output_price_per_m,
1120    ));
1121    o.push(format!("  {ln}", ln = t.border_line(w)));
1122    o.push(String::new());
1123    let lbl_w = 20;
1124    let lbl_without = theme::pad_right(&format!("{m}Without lean-ctx{r}", m = t.muted.fg()), lbl_w);
1125    let lbl_with = theme::pad_right(&format!("{m}With lean-ctx{r}", m = t.muted.fg()), lbl_w);
1126    let lbl_saved = theme::pad_right(&format!("{c}{b}You saved{r}", c = t.success.fg()), lbl_w);
1127
1128    o.push(format!(
1129        "    {lbl_without} {:>8}   {d}{} input + {} output{r}",
1130        format_usd(cost.total_cost_without),
1131        format_usd(cost.input_cost_without),
1132        format_usd(cost.output_cost_without),
1133    ));
1134    o.push(format!(
1135        "    {lbl_with} {:>8}   {d}{} input + {} output{r}",
1136        format_usd(cost.total_cost_with),
1137        format_usd(cost.input_cost_with),
1138        format_usd(cost.output_cost_with),
1139    ));
1140    o.push(String::new());
1141    o.push(format!(
1142        "    {lbl_saved} {c}{b}{:>8}{r}   {d}input {} + output {}{r}",
1143        format_usd(cost.total_saved),
1144        format_usd(cost.input_cost_without - cost.input_cost_with),
1145        format_usd(cost.output_cost_without - cost.output_cost_with),
1146        c = t.success.fg(),
1147    ));
1148
1149    // Savings by Source (MCP Tools vs Shell Hooks)
1150    {
1151        let mut mcp_saved = 0u64;
1152        let mut mcp_input = 0u64;
1153        let mut mcp_calls = 0u64;
1154        let mut hook_saved = 0u64;
1155        let mut hook_input = 0u64;
1156        let mut hook_calls = 0u64;
1157        for (cmd, s) in &store.commands {
1158            let sv = s.input_tokens.saturating_sub(s.output_tokens);
1159            if cmd.starts_with("ctx_") {
1160                mcp_saved += sv;
1161                mcp_input += s.input_tokens;
1162                mcp_calls += s.count;
1163            } else {
1164                hook_saved += sv;
1165                hook_input += s.input_tokens;
1166                hook_calls += s.count;
1167            }
1168        }
1169        if mcp_calls > 0 || hook_calls > 0 {
1170            o.push(String::new());
1171            o.push(format!("  {}", t.section_title("Savings by Source")));
1172            o.push(format!("  {ln}", ln = t.border_line(w)));
1173            o.push(String::new());
1174
1175            let total = (mcp_saved + hook_saved).max(1) as f64;
1176            let mcp_pct = mcp_saved as f64 / total * 100.0;
1177            let hook_pct = hook_saved as f64 / total * 100.0;
1178            let mcp_rate_str = format_savings_pct(mcp_saved, mcp_input);
1179            let hook_rate_str = format_savings_pct(hook_saved, hook_input);
1180            let mcp_pct_str = format_pct_1dp(mcp_pct);
1181            let hook_pct_str = format_pct_1dp(hook_pct);
1182
1183            let mcp_bar = t.gradient_bar(mcp_saved as f64 / total, 18);
1184            let hook_bar = t.gradient_bar(hook_saved as f64 / total, 18);
1185
1186            let mc = t.success.fg();
1187            let hc = t.secondary.fg();
1188            o.push(format!(
1189                "    {mc}{b}MCP Tools{r}      {:>5}x  {mcp_bar}  {b}{:>6}{r}  {d}{mcp_rate_str:>6} rate · {mcp_pct_str:>6} of total{r}",
1190                mcp_calls,
1191                format_big(mcp_saved),
1192            ));
1193            o.push(format!(
1194                "    {hc}{b}Shell Hooks{r}     {:>5}x  {hook_bar}  {b}{:>6}{r}  {d}{hook_rate_str:>6} rate · {hook_pct_str:>6} of total{r}",
1195                hook_calls,
1196                format_big(hook_saved),
1197            ));
1198        }
1199    }
1200
1201    o.push(String::new());
1202
1203    if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
1204        let first_short = first.get(..10).unwrap_or(first);
1205        let daily_savings: Vec<u64> = store
1206            .daily
1207            .iter()
1208            .map(|d2| day_total_saved(d2, &cost_model))
1209            .collect();
1210        let spark = t.gradient_sparkline(&daily_savings);
1211        o.push(format!(
1212            "    {d}Since {first_short} · {days_active} day{plural}{r}   {spark}",
1213            plural = if days_active != 1 { "s" } else { "" }
1214        ));
1215        o.push(String::new());
1216    }
1217
1218    o.push(String::new());
1219
1220    if !store.commands.is_empty() {
1221        o.push(format!("  {}", t.section_title("Top Commands")));
1222        o.push(format!("  {ln}", ln = t.border_line(w)));
1223        o.push(String::new());
1224
1225        let mut sorted: Vec<_> = store
1226            .commands
1227            .iter()
1228            .filter(|(_, s)| s.input_tokens > s.output_tokens)
1229            .collect();
1230        sorted.sort_by(|a, b2| {
1231            let sa = cmd_total_saved(a.1, &cost_model);
1232            let sb = cmd_total_saved(b2.1, &cost_model);
1233            sb.cmp(&sa)
1234        });
1235
1236        let max_cmd_saved = sorted
1237            .first()
1238            .map(|(_, s)| cmd_total_saved(s, &cost_model))
1239            .unwrap_or(1)
1240            .max(1);
1241
1242        for (cmd, stats) in sorted.iter().take(10) {
1243            let cmd_saved = cmd_total_saved(stats, &cost_model);
1244            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
1245            let cmd_pct = if stats.input_tokens > 0 {
1246                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
1247            } else {
1248                0.0
1249            };
1250            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
1251            let bar = theme::pad_right(&t.gradient_bar(ratio, 22), 22);
1252            let pc = t.pct_color(cmd_pct);
1253            let cmd_col = theme::pad_right(
1254                &format!("{m}{}{r}", truncate_cmd(cmd, 16), m = t.muted.fg()),
1255                18,
1256            );
1257            let saved_col = theme::pad_right(&format!("{b}{pc}{}{r}", format_big(cmd_saved)), 8);
1258            o.push(format!(
1259                "    {cmd_col} {:>5}x   {bar}  {saved_col} {d}{cmd_pct:>3.0}%{r}",
1260                stats.count,
1261            ));
1262        }
1263
1264        if sorted.len() > 10 {
1265            o.push(format!(
1266                "    {d}... +{} more commands{r}",
1267                sorted.len() - 10
1268            ));
1269        }
1270    }
1271
1272    if store.daily.len() >= 2 {
1273        o.push(String::new());
1274        o.push(String::new());
1275        o.push(format!("  {}", t.section_title("Recent Days")));
1276        o.push(format!("  {ln}", ln = t.border_line(w)));
1277        o.push(String::new());
1278
1279        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
1280        for day in recent.iter().rev() {
1281            let day_saved = day_total_saved(day, &cost_model);
1282            let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1283            let day_pct = if day.input_tokens > 0 {
1284                day_input_saved as f64 / day.input_tokens as f64 * 100.0
1285            } else {
1286                0.0
1287            };
1288            let pc = t.pct_color(day_pct);
1289            let date_short = day.date.get(5..).unwrap_or(&day.date);
1290            let date_col = theme::pad_right(&format!("{m}{date_short}{r}", m = t.muted.fg()), 7);
1291            let saved_col = theme::pad_right(&format!("{pc}{b}{}{r}", format_big(day_saved)), 9);
1292            o.push(format!(
1293                "    {date_col}  {:>5} cmds   {saved_col} saved   {pc}{day_pct:>5.1}%{r}",
1294                day.commands,
1295            ));
1296        }
1297    }
1298
1299    o.push(String::new());
1300    o.push(String::new());
1301
1302    if let Some(tip) = contextual_tip(&store) {
1303        o.push(format!("    {w}💡 {tip}{r}", w = t.warning.fg()));
1304        o.push(String::new());
1305    }
1306
1307    // Bug Memory stats
1308    {
1309        let project_root = std::env::current_dir()
1310            .map(|p| p.to_string_lossy().to_string())
1311            .unwrap_or_default();
1312        if !project_root.is_empty() {
1313            let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
1314            if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
1315                let a = t.accent.fg();
1316                o.push(format!("    {a}🧠 Bug Memory{r}"));
1317                o.push(format!(
1318                    "    {m}   Active gotchas: {}{r}   Bugs prevented: {}{r}",
1319                    gotcha_store.gotchas.len(),
1320                    gotcha_store.stats.total_prevented,
1321                    m = t.muted.fg(),
1322                ));
1323                o.push(String::new());
1324            }
1325        }
1326    }
1327
1328    let m = t.muted.fg();
1329    o.push(format!(
1330        "    {m}🐛 Found a bug? Run: lean-ctx report-issue{r}"
1331    ));
1332    o.push(format!(
1333        "    {m}📊 Help improve lean-ctx: lean-ctx contribute{r}"
1334    ));
1335    o.push(format!("    {m}🧠 View bug memory: lean-ctx gotchas{r}"));
1336
1337    o.push(String::new());
1338    o.push(String::new());
1339
1340    o.join("\n")
1341}
1342
1343fn contextual_tip(store: &StatsStore) -> Option<String> {
1344    let tips = build_tips(store);
1345    if tips.is_empty() {
1346        return None;
1347    }
1348    let seed = std::time::SystemTime::now()
1349        .duration_since(std::time::UNIX_EPOCH)
1350        .unwrap_or_default()
1351        .as_secs()
1352        / 86400;
1353    Some(tips[(seed as usize) % tips.len()].clone())
1354}
1355
1356fn build_tips(store: &StatsStore) -> Vec<String> {
1357    let mut tips = Vec::new();
1358
1359    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
1360        tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
1361    }
1362
1363    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
1364        tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
1365    }
1366
1367    if store.cep.total_cache_reads > 0
1368        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
1369    {
1370        tips.push(
1371            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
1372        );
1373    }
1374
1375    if store.total_commands > 50 && store.cep.sessions == 0 {
1376        tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
1377    }
1378
1379    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
1380        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
1381    }
1382
1383    if store.daily.len() >= 7 {
1384        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
1385    }
1386
1387    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
1388    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
1389
1390    let cfg = crate::core::config::Config::load();
1391    if cfg.theme == "default" {
1392        tips.push(
1393            "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
1394        );
1395        tips.push(
1396            "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
1397        );
1398    } else {
1399        tips.push(format!(
1400            "Current theme: {}. Run lean-ctx theme list to explore others.",
1401            cfg.theme
1402        ));
1403    }
1404
1405    tips.push(
1406        "Create your own theme with lean-ctx theme create <name> and set custom colors!".into(),
1407    );
1408
1409    tips
1410}
1411
1412pub fn gain_live() {
1413    use std::io::Write;
1414
1415    let interval = std::time::Duration::from_secs(1);
1416    let mut line_count = 0usize;
1417    let d = theme::dim();
1418    let r = theme::rst();
1419
1420    eprintln!("  {d}▸ Live mode (1s refresh) · Ctrl+C to exit{r}");
1421
1422    loop {
1423        if line_count > 0 {
1424            print!("\x1B[{line_count}A\x1B[J");
1425        }
1426
1427        let tick = std::time::SystemTime::now()
1428            .duration_since(std::time::UNIX_EPOCH)
1429            .ok()
1430            .map(|d| d.as_millis() as u64);
1431        let output = format_gain_themed_at(&active_theme(), tick);
1432        let footer = format!("\n  {d}▸ Live · updates every 1s · Ctrl+C to exit{r}\n");
1433        let full = format!("{output}{footer}");
1434        line_count = full.lines().count();
1435
1436        print!("{full}");
1437        let _ = std::io::stdout().flush();
1438
1439        std::thread::sleep(interval);
1440    }
1441}
1442
1443pub fn format_gain_graph() -> String {
1444    let t = active_theme();
1445    let store = load();
1446    let r = theme::rst();
1447    let b = theme::bold();
1448    let d = theme::dim();
1449
1450    if store.daily.is_empty() {
1451        return format!("{d}No daily data yet.{r} Use lean-ctx for a few days to see the graph.");
1452    }
1453
1454    let cm = CostModel::default();
1455    let days: Vec<_> = store
1456        .daily
1457        .iter()
1458        .rev()
1459        .take(30)
1460        .collect::<Vec<_>>()
1461        .into_iter()
1462        .rev()
1463        .collect();
1464
1465    let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1466
1467    let max_saved = *savings.iter().max().unwrap_or(&1);
1468    let max_saved = max_saved.max(1);
1469
1470    let bar_width = 36;
1471    let mut o = Vec::new();
1472
1473    o.push(String::new());
1474    o.push(format!(
1475        "  {icon} {title}  {d}Token Savings Graph (last 30 days){r}",
1476        icon = t.header_icon(),
1477        title = t.brand_title(),
1478    ));
1479    o.push(format!("  {ln}", ln = t.border_line(58)));
1480    o.push(format!(
1481        "  {d}{:>58}{r}",
1482        format!("peak: {}", format_big(max_saved))
1483    ));
1484    o.push(String::new());
1485
1486    for (i, day) in days.iter().enumerate() {
1487        let saved = savings[i];
1488        let ratio = saved as f64 / max_saved as f64;
1489        let bar = theme::pad_right(&t.gradient_bar(ratio, bar_width), bar_width);
1490
1491        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1492        let pct = if day.input_tokens > 0 {
1493            input_saved as f64 / day.input_tokens as f64 * 100.0
1494        } else {
1495            0.0
1496        };
1497        let date_short = day.date.get(5..).unwrap_or(&day.date);
1498
1499        o.push(format!(
1500            "  {m}{date_short}{r} {brd}│{r} {bar} {b}{:>6}{r} {d}{pct:.0}%{r}",
1501            format_big(saved),
1502            m = t.muted.fg(),
1503            brd = t.border.fg(),
1504        ));
1505    }
1506
1507    let total_saved: u64 = savings.iter().sum();
1508    let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
1509    let spark = t.gradient_sparkline(&savings);
1510
1511    o.push(String::new());
1512    o.push(format!("  {ln}", ln = t.border_line(58)));
1513    o.push(format!(
1514        "  {spark}  {b}{txt}{}{r} saved across {b}{}{r} commands",
1515        format_big(total_saved),
1516        format_num(total_cmds),
1517        txt = t.text.fg(),
1518    ));
1519    o.push(String::new());
1520
1521    o.join("\n")
1522}
1523
1524pub fn format_gain_daily() -> String {
1525    let t = active_theme();
1526    let store = load();
1527    let r = theme::rst();
1528    let b = theme::bold();
1529    let d = theme::dim();
1530
1531    if store.daily.is_empty() {
1532        return format!("{d}No daily data yet.{r}");
1533    }
1534
1535    let mut o = Vec::new();
1536    let w = 64;
1537
1538    let side = t.box_side();
1539    let daily_box = |content: &str| -> String {
1540        let padded = theme::pad_right(content, w);
1541        format!("  {side}{padded}{side}")
1542    };
1543
1544    o.push(String::new());
1545    o.push(format!(
1546        "  {icon} {title}  {d}Daily Breakdown{r}",
1547        icon = t.header_icon(),
1548        title = t.brand_title(),
1549    ));
1550    o.push(format!("  {}", t.box_top(w)));
1551    let hdr = format!(
1552        " {b}{txt}{:<12} {:>6}  {:>10}  {:>10}  {:>7}  {:>6}{r}",
1553        "Date",
1554        "Cmds",
1555        "Input",
1556        "Saved",
1557        "Rate",
1558        "USD",
1559        txt = t.text.fg(),
1560    );
1561    o.push(daily_box(&hdr));
1562    o.push(format!("  {}", t.box_mid(w)));
1563
1564    let days: Vec<_> = store
1565        .daily
1566        .iter()
1567        .rev()
1568        .take(30)
1569        .collect::<Vec<_>>()
1570        .into_iter()
1571        .rev()
1572        .cloned()
1573        .collect();
1574
1575    let cm = CostModel::default();
1576    for day in &days {
1577        let saved = day_total_saved(day, &cm);
1578        let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1579        let pct = if day.input_tokens > 0 {
1580            input_saved as f64 / day.input_tokens as f64 * 100.0
1581        } else {
1582            0.0
1583        };
1584        let pc = t.pct_color(pct);
1585        let usd = usd_estimate(saved);
1586        let row = format!(
1587            " {m}{:<12}{r} {:>6}  {:>10}  {pc}{b}{:>10}{r}  {pc}{:>6.1}%{r}  {d}{:>6}{r}",
1588            &day.date,
1589            day.commands,
1590            format_big(day.input_tokens),
1591            format_big(saved),
1592            pct,
1593            usd,
1594            m = t.muted.fg(),
1595        );
1596        o.push(daily_box(&row));
1597    }
1598
1599    let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
1600    let total_saved: u64 = store
1601        .daily
1602        .iter()
1603        .map(|day| day_total_saved(day, &cm))
1604        .sum();
1605    let total_pct = if total_input > 0 {
1606        let input_saved: u64 = store
1607            .daily
1608            .iter()
1609            .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
1610            .sum();
1611        input_saved as f64 / total_input as f64 * 100.0
1612    } else {
1613        0.0
1614    };
1615    let total_usd = usd_estimate(total_saved);
1616    let sc = t.success.fg();
1617
1618    o.push(format!("  {}", t.box_mid(w)));
1619    let total_row = format!(
1620        " {b}{txt}{:<12}{r} {:>6}  {:>10}  {sc}{b}{:>10}{r}  {sc}{b}{:>6.1}%{r}  {b}{:>6}{r}",
1621        "TOTAL",
1622        format_num(store.total_commands),
1623        format_big(total_input),
1624        format_big(total_saved),
1625        total_pct,
1626        total_usd,
1627        txt = t.text.fg(),
1628    );
1629    o.push(daily_box(&total_row));
1630    o.push(format!("  {}", t.box_bottom(w)));
1631
1632    let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1633    let spark = t.gradient_sparkline(&daily_savings);
1634    o.push(format!("  {d}Trend:{r} {spark}"));
1635    o.push(String::new());
1636
1637    o.join("\n")
1638}
1639
1640pub fn format_gain_json() -> String {
1641    let store = load();
1642    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647    use super::*;
1648
1649    fn make_store(commands: u64, input: u64, output: u64) -> StatsStore {
1650        StatsStore {
1651            total_commands: commands,
1652            total_input_tokens: input,
1653            total_output_tokens: output,
1654            ..Default::default()
1655        }
1656    }
1657
1658    #[test]
1659    fn apply_deltas_merges_mcp_and_shell() {
1660        let baseline = make_store(0, 0, 0);
1661        let mut current = make_store(0, 0, 0);
1662        current.total_commands = 5;
1663        current.total_input_tokens = 1000;
1664        current.total_output_tokens = 200;
1665        current.commands.insert(
1666            "ctx_read".to_string(),
1667            CommandStats {
1668                count: 5,
1669                input_tokens: 1000,
1670                output_tokens: 200,
1671            },
1672        );
1673
1674        let mut disk = make_store(20, 500, 490);
1675        disk.commands.insert(
1676            "echo".to_string(),
1677            CommandStats {
1678                count: 20,
1679                input_tokens: 500,
1680                output_tokens: 490,
1681            },
1682        );
1683
1684        let merged = apply_deltas(&disk, &current, &baseline);
1685
1686        assert_eq!(merged.total_commands, 25);
1687        assert_eq!(merged.total_input_tokens, 1500);
1688        assert_eq!(merged.total_output_tokens, 690);
1689        assert_eq!(merged.commands["ctx_read"].count, 5);
1690        assert_eq!(merged.commands["echo"].count, 20);
1691    }
1692
1693    #[test]
1694    fn apply_deltas_incremental_flush() {
1695        let baseline = make_store(10, 200, 100);
1696        let current = make_store(15, 700, 300);
1697
1698        let disk = make_store(30, 600, 500);
1699
1700        let merged = apply_deltas(&disk, &current, &baseline);
1701
1702        assert_eq!(merged.total_commands, 35);
1703        assert_eq!(merged.total_input_tokens, 1100);
1704        assert_eq!(merged.total_output_tokens, 700);
1705    }
1706
1707    #[test]
1708    fn apply_deltas_preserves_disk_commands() {
1709        let baseline = make_store(0, 0, 0);
1710        let mut current = make_store(2, 100, 50);
1711        current.commands.insert(
1712            "ctx_read".to_string(),
1713            CommandStats {
1714                count: 2,
1715                input_tokens: 100,
1716                output_tokens: 50,
1717            },
1718        );
1719
1720        let mut disk = make_store(10, 300, 280);
1721        disk.commands.insert(
1722            "echo".to_string(),
1723            CommandStats {
1724                count: 8,
1725                input_tokens: 200,
1726                output_tokens: 200,
1727            },
1728        );
1729        disk.commands.insert(
1730            "ctx_read".to_string(),
1731            CommandStats {
1732                count: 3,
1733                input_tokens: 150,
1734                output_tokens: 80,
1735            },
1736        );
1737
1738        let merged = apply_deltas(&disk, &current, &baseline);
1739
1740        assert_eq!(merged.commands["echo"].count, 8);
1741        assert_eq!(merged.commands["ctx_read"].count, 5);
1742        assert_eq!(merged.commands["ctx_read"].input_tokens, 250);
1743    }
1744
1745    #[test]
1746    fn merge_daily_combines_same_date() {
1747        let baseline_daily = vec![];
1748        let current_daily = vec![DayStats {
1749            date: "2026-04-18".to_string(),
1750            commands: 5,
1751            input_tokens: 1000,
1752            output_tokens: 200,
1753        }];
1754        let mut merged_daily = vec![DayStats {
1755            date: "2026-04-18".to_string(),
1756            commands: 20,
1757            input_tokens: 500,
1758            output_tokens: 490,
1759        }];
1760
1761        merge_daily(&mut merged_daily, &current_daily, &baseline_daily);
1762
1763        assert_eq!(merged_daily.len(), 1);
1764        assert_eq!(merged_daily[0].commands, 25);
1765        assert_eq!(merged_daily[0].input_tokens, 1500);
1766    }
1767
1768    #[test]
1769    fn format_pct_1dp_normal() {
1770        assert_eq!(format_pct_1dp(50.0), "50.0%");
1771        assert_eq!(format_pct_1dp(100.0), "100.0%");
1772        assert_eq!(format_pct_1dp(33.333), "33.3%");
1773    }
1774
1775    #[test]
1776    fn format_pct_1dp_small_values() {
1777        assert_eq!(format_pct_1dp(0.0), "0.0%");
1778        assert_eq!(format_pct_1dp(0.05), "<0.1%");
1779        assert_eq!(format_pct_1dp(0.09), "<0.1%");
1780        assert_eq!(format_pct_1dp(0.1), "0.1%");
1781        assert_eq!(format_pct_1dp(0.5), "0.5%");
1782    }
1783
1784    #[test]
1785    fn format_savings_pct_zero_input() {
1786        assert_eq!(format_savings_pct(0, 0), "0.0%");
1787        assert_eq!(format_savings_pct(100, 0), "n/a");
1788    }
1789
1790    #[test]
1791    fn format_savings_pct_normal() {
1792        assert_eq!(format_savings_pct(50, 100), "50.0%");
1793        assert_eq!(format_savings_pct(1, 10000), "<0.1%");
1794    }
1795}