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