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