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