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 ¤t.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, ¤t.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, ¤t.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 ¤t.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 ¤t.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
302static 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
561pub 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 {
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 {
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 {
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, ¤t, &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, ¤t, &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, ¤t, &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, ¤t_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}