1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[derive(Serialize, Deserialize, Default)]
6pub struct StatsStore {
7 pub total_commands: u64,
8 pub total_input_tokens: u64,
9 pub total_output_tokens: u64,
10 pub first_use: Option<String>,
11 pub last_use: Option<String>,
12 pub commands: HashMap<String, CommandStats>,
13 pub daily: Vec<DayStats>,
14 #[serde(default)]
15 pub cep: CepStats,
16}
17
18#[derive(Serialize, Deserialize, Clone, Default)]
19pub struct CepStats {
20 pub sessions: u64,
21 pub total_cache_hits: u64,
22 pub total_cache_reads: u64,
23 pub total_tokens_original: u64,
24 pub total_tokens_compressed: u64,
25 pub modes: HashMap<String, u64>,
26 pub scores: Vec<CepSessionSnapshot>,
27}
28
29#[derive(Serialize, Deserialize, Clone)]
30pub struct CepSessionSnapshot {
31 pub timestamp: String,
32 pub score: u32,
33 pub cache_hit_rate: u32,
34 pub mode_diversity: u32,
35 pub compression_rate: u32,
36 pub tool_calls: u64,
37 pub tokens_saved: u64,
38 pub complexity: String,
39}
40
41#[derive(Serialize, Deserialize, Clone, Default)]
42pub struct CommandStats {
43 pub count: u64,
44 pub input_tokens: u64,
45 pub output_tokens: u64,
46}
47
48#[derive(Serialize, Deserialize, Clone)]
49pub struct DayStats {
50 pub date: String,
51 pub commands: u64,
52 pub input_tokens: u64,
53 pub output_tokens: u64,
54}
55
56fn stats_dir() -> Option<PathBuf> {
57 dirs::home_dir().map(|h| h.join(".lean-ctx"))
58}
59
60fn stats_path() -> Option<PathBuf> {
61 stats_dir().map(|d| d.join("stats.json"))
62}
63
64pub fn load() -> StatsStore {
65 let path = match stats_path() {
66 Some(p) => p,
67 None => return StatsStore::default(),
68 };
69
70 match std::fs::read_to_string(&path) {
71 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
72 Err(_) => StatsStore::default(),
73 }
74}
75
76pub fn save(store: &StatsStore) {
77 let dir = match stats_dir() {
78 Some(d) => d,
79 None => return,
80 };
81
82 if !dir.exists() {
83 let _ = std::fs::create_dir_all(&dir);
84 }
85
86 let path = dir.join("stats.json");
87 if let Ok(json) = serde_json::to_string(store) {
88 let tmp = dir.join(".stats.json.tmp");
89 if std::fs::write(&tmp, &json).is_ok() {
90 let _ = std::fs::rename(&tmp, &path);
91 }
92 }
93}
94
95pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
96 let mut store = load();
97 let now = chrono::Local::now();
98 let today = now.format("%Y-%m-%d").to_string();
99 let timestamp = now.to_rfc3339();
100
101 store.total_commands += 1;
102 store.total_input_tokens += input_tokens as u64;
103 store.total_output_tokens += output_tokens as u64;
104
105 if store.first_use.is_none() {
106 store.first_use = Some(timestamp.clone());
107 }
108 store.last_use = Some(timestamp);
109
110 let cmd_key = normalize_command(command);
111 let entry = store.commands.entry(cmd_key).or_default();
112 entry.count += 1;
113 entry.input_tokens += input_tokens as u64;
114 entry.output_tokens += output_tokens as u64;
115
116 if let Some(day) = store.daily.last_mut() {
117 if day.date == today {
118 day.commands += 1;
119 day.input_tokens += input_tokens as u64;
120 day.output_tokens += output_tokens as u64;
121 } else {
122 store.daily.push(DayStats {
123 date: today,
124 commands: 1,
125 input_tokens: input_tokens as u64,
126 output_tokens: output_tokens as u64,
127 });
128 }
129 } else {
130 store.daily.push(DayStats {
131 date: today,
132 commands: 1,
133 input_tokens: input_tokens as u64,
134 output_tokens: output_tokens as u64,
135 });
136 }
137
138 if store.daily.len() > 90 {
139 store.daily.drain(..store.daily.len() - 90);
140 }
141
142 save(&store);
143}
144
145fn normalize_command(command: &str) -> String {
146 let parts: Vec<&str> = command.split_whitespace().collect();
147 if parts.is_empty() {
148 return command.to_string();
149 }
150
151 let base = std::path::Path::new(parts[0])
152 .file_name()
153 .and_then(|n| n.to_str())
154 .unwrap_or(parts[0]);
155
156 match base {
157 "git" => {
158 if parts.len() > 1 {
159 format!("git {}", parts[1])
160 } else {
161 "git".to_string()
162 }
163 }
164 "cargo" => {
165 if parts.len() > 1 {
166 format!("cargo {}", parts[1])
167 } else {
168 "cargo".to_string()
169 }
170 }
171 "npm" | "yarn" | "pnpm" => {
172 if parts.len() > 1 {
173 format!("{} {}", base, parts[1])
174 } else {
175 base.to_string()
176 }
177 }
178 "docker" => {
179 if parts.len() > 1 {
180 format!("docker {}", parts[1])
181 } else {
182 "docker".to_string()
183 }
184 }
185 _ => base.to_string(),
186 }
187}
188
189pub struct GainSummary {
190 pub total_saved: u64,
191 pub total_calls: u64,
192}
193
194pub fn load_stats() -> GainSummary {
195 let store = load();
196 let cm = CostModel::default();
197 let input_saved = store
198 .total_input_tokens
199 .saturating_sub(store.total_output_tokens);
200 let output_saved =
201 store.total_commands * (cm.avg_verbose_output_per_call - cm.avg_concise_output_per_call);
202 GainSummary {
203 total_saved: input_saved + output_saved,
204 total_calls: store.total_commands,
205 }
206}
207
208fn cmd_total_saved(s: &CommandStats, cm: &CostModel) -> u64 {
209 let input_saved = s.input_tokens.saturating_sub(s.output_tokens);
210 let output_saved = s.count * (cm.avg_verbose_output_per_call - cm.avg_concise_output_per_call);
211 input_saved + output_saved
212}
213
214fn day_total_saved(d: &DayStats, cm: &CostModel) -> u64 {
215 let input_saved = d.input_tokens.saturating_sub(d.output_tokens);
216 let output_saved =
217 d.commands * (cm.avg_verbose_output_per_call - cm.avg_concise_output_per_call);
218 input_saved + output_saved
219}
220
221#[allow(clippy::too_many_arguments)]
222pub fn record_cep_session(
223 score: u32,
224 cache_hits: u64,
225 cache_reads: u64,
226 tokens_original: u64,
227 tokens_compressed: u64,
228 modes: &HashMap<String, u64>,
229 tool_calls: u64,
230 complexity: &str,
231) {
232 let mut store = load();
233 let cep = &mut store.cep;
234
235 cep.sessions += 1;
236 cep.total_cache_hits += cache_hits;
237 cep.total_cache_reads += cache_reads;
238 cep.total_tokens_original += tokens_original;
239 cep.total_tokens_compressed += tokens_compressed;
240
241 for (mode, count) in modes {
242 *cep.modes.entry(mode.clone()).or_insert(0) += count;
243 }
244
245 let cache_hit_rate = if cache_reads > 0 {
246 (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
247 } else {
248 0
249 };
250
251 let compression_rate = if tokens_original > 0 {
252 ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
253 as u32
254 } else {
255 0
256 };
257
258 let total_modes = 6u32;
259 let mode_diversity =
260 ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
261
262 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
263
264 cep.scores.push(CepSessionSnapshot {
265 timestamp: chrono::Local::now().to_rfc3339(),
266 score,
267 cache_hit_rate,
268 mode_diversity,
269 compression_rate,
270 tool_calls,
271 tokens_saved,
272 complexity: complexity.to_string(),
273 });
274
275 if cep.scores.len() > 100 {
276 cep.scores.drain(..cep.scores.len() - 100);
277 }
278
279 save(&store);
280}
281
282const RST: &str = "\x1b[0m";
283const BOLD: &str = "\x1b[1m";
284const DIM: &str = "\x1b[2m";
285const GREEN: &str = "\x1b[32m";
286const CYAN: &str = "\x1b[36m";
287const YELLOW: &str = "\x1b[33m";
288const MAGENTA: &str = "\x1b[35m";
289const WHITE: &str = "\x1b[97m";
290const GRAY: &str = "\x1b[90m";
291fn line(ch: char, n: usize) -> String {
292 std::iter::repeat_n(ch, n).collect()
293}
294
295fn pct_color(pct: f64) -> &'static str {
296 if pct >= 90.0 {
297 "\x1b[32m"
298 } else if pct >= 70.0 {
299 "\x1b[36m"
300 } else if pct >= 50.0 {
301 "\x1b[33m"
302 } else if pct >= 30.0 {
303 "\x1b[35m"
304 } else {
305 "\x1b[37m"
306 }
307}
308
309fn bar_block(ratio: f64, width: usize) -> String {
310 let blocks = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
311 let full = (ratio * width as f64).max(0.0);
312 let whole = full as usize;
313 let frac = ((full - whole as f64) * 8.0) as usize;
314 let mut s = "█".repeat(whole);
315 if whole < width && frac > 0 {
316 s.push_str(blocks[frac]);
317 }
318 if s.is_empty() && ratio > 0.0 {
319 s.push('▏');
320 }
321 s
322}
323
324fn sparkline(values: &[u64]) -> String {
325 let ticks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
326 let max = *values.iter().max().unwrap_or(&1) as f64;
327 if max == 0.0 {
328 return " ".repeat(values.len());
329 }
330 values
331 .iter()
332 .map(|v| {
333 let idx = ((*v as f64 / max) * 7.0).round() as usize;
334 ticks[idx.min(7)]
335 })
336 .collect()
337}
338
339pub const DEFAULT_INPUT_PRICE_PER_M: f64 = 2.50;
341pub const DEFAULT_OUTPUT_PRICE_PER_M: f64 = 10.0;
342
343pub struct CostModel {
344 pub input_price_per_m: f64,
345 pub output_price_per_m: f64,
346 pub avg_verbose_output_per_call: u64,
347 pub avg_concise_output_per_call: u64,
348}
349
350impl Default for CostModel {
351 fn default() -> Self {
352 Self {
353 input_price_per_m: DEFAULT_INPUT_PRICE_PER_M,
354 output_price_per_m: DEFAULT_OUTPUT_PRICE_PER_M,
355 avg_verbose_output_per_call: 450,
356 avg_concise_output_per_call: 120,
357 }
358 }
359}
360
361pub struct CostBreakdown {
362 pub input_cost_without: f64,
363 pub input_cost_with: f64,
364 pub output_cost_without: f64,
365 pub output_cost_with: f64,
366 pub total_cost_without: f64,
367 pub total_cost_with: f64,
368 pub total_saved: f64,
369 pub estimated_output_tokens_without: u64,
370 pub estimated_output_tokens_with: u64,
371 pub output_tokens_saved: u64,
372}
373
374impl CostModel {
375 pub fn calculate(&self, store: &StatsStore) -> CostBreakdown {
376 let input_cost_without =
377 store.total_input_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
378 let input_cost_with =
379 store.total_output_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
380
381 let est_output_without = store.total_commands * self.avg_verbose_output_per_call;
382 let est_output_with = store.total_commands * self.avg_concise_output_per_call;
383 let output_saved = est_output_without.saturating_sub(est_output_with);
384
385 let output_cost_without = est_output_without as f64 / 1_000_000.0 * self.output_price_per_m;
386 let output_cost_with = est_output_with as f64 / 1_000_000.0 * self.output_price_per_m;
387
388 let total_without = input_cost_without + output_cost_without;
389 let total_with = input_cost_with + output_cost_with;
390
391 CostBreakdown {
392 input_cost_without,
393 input_cost_with,
394 output_cost_without,
395 output_cost_with,
396 total_cost_without: total_without,
397 total_cost_with: total_with,
398 total_saved: total_without - total_with,
399 estimated_output_tokens_without: est_output_without,
400 estimated_output_tokens_with: est_output_with,
401 output_tokens_saved: output_saved,
402 }
403 }
404}
405
406fn format_usd(amount: f64) -> String {
407 if amount >= 0.01 {
408 format!("${amount:.2}")
409 } else {
410 format!("${amount:.3}")
411 }
412}
413
414fn usd_estimate(tokens: u64) -> String {
415 let cost = tokens as f64 * DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
416 format_usd(cost)
417}
418
419fn format_big(n: u64) -> String {
420 if n >= 1_000_000 {
421 format!("{:.1}M", n as f64 / 1_000_000.0)
422 } else if n >= 1_000 {
423 format!("{:.1}K", n as f64 / 1_000.0)
424 } else {
425 format!("{n}")
426 }
427}
428
429fn format_num(n: u64) -> String {
430 if n >= 1_000_000 {
431 format!("{:.1}M", n as f64 / 1_000_000.0)
432 } else if n >= 1_000 {
433 format!("{},{:03}", n / 1_000, n % 1_000)
434 } else {
435 format!("{n}")
436 }
437}
438
439fn truncate_cmd(cmd: &str, max: usize) -> String {
440 if cmd.len() <= max {
441 cmd.to_string()
442 } else {
443 format!("{}…", &cmd[..max - 1])
444 }
445}
446
447fn format_cep_live(lv: &serde_json::Value) -> String {
448 let mut o = Vec::new();
449 let ln56 = line('─', 56);
450
451 let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
452 let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
453 let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
454 let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
455 let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
456 let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
457 let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
458 let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
459 let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
460 let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
461
462 o.push(String::new());
463 o.push(format!(
464 " {BOLD}{WHITE}◆ lean-ctx CEP{RST} {DIM}Live Session (no historical data yet){RST}"
465 ));
466 o.push(format!(" {DIM}{ln56}{RST}"));
467 o.push(String::new());
468
469 o.push(format!(
470 " {BOLD}{WHITE}CEP Score{RST} {BOLD}{}{score:>3}/100{RST}",
471 pct_color(score as f64),
472 ));
473 o.push(format!(
474 " {BOLD}{WHITE}Cache Hit Rate{RST} {BOLD}{}{cache_util}%{RST} {DIM}({cache_hits} hits / {total_reads} reads){RST}",
475 pct_color(cache_util as f64),
476 ));
477 o.push(format!(
478 " {BOLD}{WHITE}Mode Diversity{RST} {BOLD}{}{mode_div}%{RST}",
479 pct_color(mode_div as f64),
480 ));
481 o.push(format!(
482 " {BOLD}{WHITE}Compression{RST} {BOLD}{}{comp_rate}%{RST} {DIM}({} → {}){RST}",
483 pct_color(comp_rate as f64),
484 format_big(tok_orig),
485 format_big(tok_orig.saturating_sub(tok_saved)),
486 ));
487 o.push(format!(
488 " {BOLD}{WHITE}Tokens Saved{RST} {BOLD}{GREEN}{}{RST} {DIM}(≈ {}){RST}",
489 format_big(tok_saved),
490 usd_estimate(tok_saved),
491 ));
492 o.push(format!(
493 " {BOLD}{WHITE}Tool Calls{RST} {BOLD}{CYAN}{tool_calls}{RST}"
494 ));
495 o.push(format!(
496 " {BOLD}{WHITE}Complexity{RST} {DIM}{complexity}{RST}"
497 ));
498 o.push(String::new());
499 o.push(format!(" {DIM}{ln56}{RST}"));
500 o.push(format!(
501 " {DIM}This is live data from the current MCP session.{RST}"
502 ));
503 o.push(format!(
504 " {DIM}Historical CEP trends appear after more sessions.{RST}"
505 ));
506 o.push(String::new());
507
508 o.join("\n")
509}
510
511fn load_mcp_live() -> Option<serde_json::Value> {
512 let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
513 let content = std::fs::read_to_string(path).ok()?;
514 serde_json::from_str(&content).ok()
515}
516
517pub fn format_cep_report() -> String {
518 let store = load();
519 let cep = &store.cep;
520 let live = load_mcp_live();
521 let mut o = Vec::new();
522 let ln56 = line('─', 56);
523
524 if cep.sessions == 0 && live.is_none() {
525 return format!(
526 "{DIM}No CEP sessions recorded yet.{RST}\n\
527 Use lean-ctx as an MCP server in your editor to start tracking.\n\
528 CEP metrics are recorded automatically during MCP sessions."
529 );
530 }
531
532 if cep.sessions == 0 {
533 if let Some(ref lv) = live {
534 return format_cep_live(lv);
535 }
536 }
537
538 let total_saved = cep
539 .total_tokens_original
540 .saturating_sub(cep.total_tokens_compressed);
541 let overall_compression = if cep.total_tokens_original > 0 {
542 total_saved as f64 / cep.total_tokens_original as f64 * 100.0
543 } else {
544 0.0
545 };
546 let cache_hit_rate = if cep.total_cache_reads > 0 {
547 cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
548 } else {
549 0.0
550 };
551 let avg_score = if !cep.scores.is_empty() {
552 cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
553 } else {
554 0.0
555 };
556 let latest_score = cep.scores.last().map(|s| s.score).unwrap_or(0);
557
558 let shell_saved = store
559 .total_input_tokens
560 .saturating_sub(store.total_output_tokens)
561 .saturating_sub(total_saved);
562 let total_all_saved = store
563 .total_input_tokens
564 .saturating_sub(store.total_output_tokens);
565 let cep_share = if total_all_saved > 0 {
566 total_saved as f64 / total_all_saved as f64 * 100.0
567 } else {
568 0.0
569 };
570
571 o.push(String::new());
572 o.push(format!(
573 " {BOLD}{WHITE}◆ lean-ctx CEP{RST} {DIM}Cognitive Efficiency Protocol Report{RST}"
574 ));
575 o.push(format!(" {DIM}{ln56}{RST}"));
576 o.push(String::new());
577
578 o.push(format!(
579 " {BOLD}{WHITE}CEP Score{RST} {BOLD}{}{:>3}/100{RST} {DIM}(avg: {avg_score:.0}, latest: {latest_score}){RST}",
580 pct_color(latest_score as f64),
581 latest_score,
582 ));
583 o.push(format!(
584 " {BOLD}{WHITE}Sessions{RST} {BOLD}{CYAN}{}{RST}",
585 cep.sessions
586 ));
587 o.push(format!(
588 " {BOLD}{WHITE}Cache Hit Rate{RST} {BOLD}{}{:.1}%{RST} {DIM}({} hits / {} reads){RST}",
589 pct_color(cache_hit_rate),
590 cache_hit_rate,
591 cep.total_cache_hits,
592 cep.total_cache_reads,
593 ));
594 o.push(format!(
595 " {BOLD}{WHITE}MCP Compression{RST} {BOLD}{}{:.1}%{RST} {DIM}({} → {}){RST}",
596 pct_color(overall_compression),
597 overall_compression,
598 format_big(cep.total_tokens_original),
599 format_big(cep.total_tokens_compressed),
600 ));
601 o.push(format!(
602 " {BOLD}{WHITE}Tokens Saved{RST} {BOLD}{GREEN}{}{RST} {DIM}(≈ {}){RST}",
603 format_big(total_saved),
604 usd_estimate(total_saved),
605 ));
606 o.push(String::new());
607
608 o.push(format!(" {BOLD}{WHITE}Savings Breakdown{RST}"));
609 o.push(format!(" {DIM}{ln56}{RST}"));
610
611 let bar_w = 30;
612 let shell_ratio = if total_all_saved > 0 {
613 shell_saved as f64 / total_all_saved as f64
614 } else {
615 0.0
616 };
617 let cep_ratio = if total_all_saved > 0 {
618 total_saved as f64 / total_all_saved as f64
619 } else {
620 0.0
621 };
622 o.push(format!(
623 " {GRAY}Shell Hook{RST} {YELLOW}{:<width$}{RST} {BOLD}{:>6}{RST} {DIM}({:.0}%){RST}",
624 bar_block(shell_ratio, bar_w),
625 format_big(shell_saved),
626 (1.0 - cep_share) * 100.0 / 100.0 * 100.0,
627 width = bar_w,
628 ));
629 o.push(format!(
630 " {GRAY}MCP/CEP{RST} {GREEN}{:<width$}{RST} {BOLD}{:>6}{RST} {DIM}({cep_share:.0}%){RST}",
631 bar_block(cep_ratio, bar_w),
632 format_big(total_saved),
633 width = bar_w,
634 ));
635 o.push(String::new());
636
637 if total_saved == 0 && cep.modes.is_empty() {
638 o.push(format!(
639 " {YELLOW}⚠ MCP server not configured.{RST} Shell hook compresses output, but"
640 ));
641 o.push(
642 " full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
643 .to_string(),
644 );
645 o.push(format!(
646 " Run {CYAN}lean-ctx setup{RST} to auto-configure your editors."
647 ));
648 o.push(String::new());
649 }
650
651 if !cep.modes.is_empty() {
652 o.push(format!(" {BOLD}{WHITE}Read Modes Used{RST}"));
653 o.push(format!(" {DIM}{ln56}{RST}"));
654
655 let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
656 sorted_modes.sort_by(|a, b| b.1.cmp(a.1));
657 let max_mode = *sorted_modes.first().map(|(_, c)| *c).unwrap_or(&1);
658 let max_mode = max_mode.max(1);
659
660 for (mode, count) in &sorted_modes {
661 let ratio = **count as f64 / max_mode as f64;
662 let bar = bar_block(ratio, 20);
663 o.push(format!(
664 " {CYAN}{:<14}{RST} {:>4}x {GREEN}{bar:<20}{RST}",
665 mode, count,
666 ));
667 }
668
669 let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
670 let full_count = cep.modes.get("full").copied().unwrap_or(0);
671 let optimized = total_mode_calls.saturating_sub(full_count);
672 let opt_pct = if total_mode_calls > 0 {
673 optimized as f64 / total_mode_calls as f64 * 100.0
674 } else {
675 0.0
676 };
677 o.push(format!(
678 " {DIM}{optimized}/{total_mode_calls} reads used optimized modes ({opt_pct:.0}% non-full){RST}"
679 ));
680 }
681
682 if cep.scores.len() >= 2 {
683 o.push(String::new());
684 o.push(format!(" {BOLD}{WHITE}CEP Score Trend{RST}"));
685 o.push(format!(" {DIM}{ln56}{RST}"));
686
687 let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
688 let spark = sparkline(&score_values);
689 o.push(format!(" {GREEN}{spark}{RST}"));
690
691 let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
692 for snap in recent.iter().rev() {
693 let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
694 let pc = pct_color(snap.score as f64);
695 o.push(format!(
696 " {GRAY}{ts}{RST} {pc}{BOLD}{:>3}{RST}/100 cache:{:>3}% modes:{:>3}% {DIM}{}{RST}",
697 snap.score, snap.cache_hit_rate, snap.mode_diversity, snap.complexity,
698 ));
699 }
700 }
701
702 o.push(String::new());
703 o.push(format!(" {DIM}{ln56}{RST}"));
704 o.push(format!(" {DIM}Improve your CEP score:{RST}"));
705 if cache_hit_rate < 50.0 {
706 o.push(format!(
707 " {YELLOW}↑{RST} Re-read files with ctx_read to leverage caching"
708 ));
709 }
710 let modes_count = cep.modes.len();
711 if modes_count < 3 {
712 o.push(format!(
713 " {YELLOW}↑{RST} Use map/signatures modes for context-only files"
714 ));
715 }
716 if avg_score >= 70.0 {
717 o.push(format!(
718 " {GREEN}✓{RST} Great score! You're using lean-ctx effectively"
719 ));
720 }
721 o.push(String::new());
722
723 o.join("\n")
724}
725
726pub fn format_gain() -> String {
727 let store = load();
728 let mut o = Vec::new();
729
730 if store.total_commands == 0 {
731 return format!("{DIM}No commands recorded yet.{RST} Use {CYAN}lean-ctx -c \"command\"{RST} to start tracking.");
732 }
733
734 let input_saved = store
735 .total_input_tokens
736 .saturating_sub(store.total_output_tokens);
737 let pct = if store.total_input_tokens > 0 {
738 input_saved as f64 / store.total_input_tokens as f64 * 100.0
739 } else {
740 0.0
741 };
742 let cost_model = CostModel::default();
743 let cost = cost_model.calculate(&store);
744 let total_saved = input_saved + cost.output_tokens_saved;
745 let days_active = store.daily.len();
746
747 o.push(String::new());
748 let ln56 = line('─', 56);
749 o.push(format!(
750 " {BOLD}{WHITE}◆ lean-ctx{RST} {DIM}Token Savings Dashboard{RST}"
751 ));
752 o.push(format!(" {DIM}{ln56}{RST}"));
753 o.push(String::new());
754
755 o.push(format!(
756 " {BOLD}{GREEN} {:<12}{RST} {BOLD}{CYAN} {:<12}{RST} {BOLD}{YELLOW} {:<10}{RST} {BOLD}{MAGENTA} {:<10}{RST}",
757 format_big(total_saved),
758 format!("{pct:.1}%"),
759 format_num(store.total_commands),
760 format_usd(cost.total_saved),
761 ));
762 o.push(format!(
763 " {DIM} tokens saved compression commands USD saved{RST}"
764 ));
765 o.push(String::new());
766
767 o.push(format!(
768 " {BOLD}{WHITE}Cost Breakdown{RST} {DIM}(@ ${}/M input, ${}/M output){RST}",
769 DEFAULT_INPUT_PRICE_PER_M, DEFAULT_OUTPUT_PRICE_PER_M
770 ));
771 o.push(format!(" {DIM}{ln56}{RST}"));
772 o.push(format!(
773 " {GRAY}Without lean-ctx{RST} {:>8} {DIM}({} input + {} output){RST}",
774 format_usd(cost.total_cost_without),
775 format_usd(cost.input_cost_without),
776 format_usd(cost.output_cost_without),
777 ));
778 o.push(format!(
779 " {GRAY}With lean-ctx{RST} {:>8} {DIM}({} input + {} output){RST}",
780 format_usd(cost.total_cost_with),
781 format_usd(cost.input_cost_with),
782 format_usd(cost.output_cost_with),
783 ));
784 o.push(format!(
785 " {GREEN}{BOLD}Total Saved{RST} {GREEN}{BOLD}{:>8}{RST} {DIM}(input: {} + output: {}){RST}",
786 format_usd(cost.total_saved),
787 format_usd(cost.input_cost_without - cost.input_cost_with),
788 format_usd(cost.output_cost_without - cost.output_cost_with),
789 ));
790 o.push(format!(
791 " {DIM}Output savings: ~{} tokens saved via CEP/TDD ({} → {} per call){RST}",
792 format_big(cost.output_tokens_saved),
793 CostModel::default().avg_verbose_output_per_call,
794 CostModel::default().avg_concise_output_per_call,
795 ));
796 o.push(String::new());
797
798 o.push(format!(
799 " {DIM}{} input tokens compressed · ~{} output tokens reduced via CEP/TDD{RST}",
800 format_num(input_saved),
801 format_big(cost.output_tokens_saved),
802 ));
803
804 if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
805 let first_short = first.get(..10).unwrap_or(first);
806 let daily_savings: Vec<u64> = store
807 .daily
808 .iter()
809 .map(|d| day_total_saved(d, &cost_model))
810 .collect();
811 let spark = sparkline(&daily_savings);
812 o.push(format!(
813 " {DIM}Since {first_short} ({days_active} day{plural}){RST} {GREEN}{spark}{RST}",
814 plural = if days_active != 1 { "s" } else { "" }
815 ));
816 o.push(String::new());
817 }
818
819 if !store.commands.is_empty() {
820 o.push(format!(" {BOLD}{WHITE}Top Commands{RST}"));
821 o.push(format!(" {DIM}{ln56}{RST}"));
822
823 let mut sorted: Vec<_> = store.commands.iter().collect();
824 sorted.sort_by(|a, b| {
825 let sa = cmd_total_saved(a.1, &cost_model);
826 let sb = cmd_total_saved(b.1, &cost_model);
827 sb.cmp(&sa)
828 });
829
830 let max_cmd_saved = sorted
831 .first()
832 .map(|(_, s)| cmd_total_saved(s, &cost_model))
833 .unwrap_or(1)
834 .max(1);
835
836 for (cmd, stats) in sorted.iter().take(12) {
837 let cmd_saved = cmd_total_saved(stats, &cost_model);
838 let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
839 let cmd_pct = if stats.input_tokens > 0 {
840 cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
841 } else {
842 0.0
843 };
844 let ratio = cmd_saved as f64 / max_cmd_saved as f64;
845 let bar = bar_block(ratio, 20);
846 let pc = pct_color(cmd_pct);
847 o.push(format!(
848 " {GRAY}{:<16}{RST} {:>5}x {pc}{bar:<20}{RST} {BOLD}{pc}{:>6}{RST} {DIM}{cmd_pct:.0}%{RST}",
849 truncate_cmd(cmd, 16),
850 stats.count,
851 format_big(cmd_saved),
852 ));
853 }
854
855 if sorted.len() > 12 {
856 o.push(format!(
857 " {DIM} ... +{} more commands{RST}",
858 sorted.len() - 12
859 ));
860 }
861 }
862
863 if store.daily.len() >= 2 {
864 o.push(String::new());
865 o.push(format!(" {BOLD}{WHITE}Recent Days{RST}"));
866 o.push(format!(" {DIM}{ln56}{RST}"));
867
868 let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
869 for day in recent.iter().rev() {
870 let day_saved = day_total_saved(day, &cost_model);
871 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
872 let day_pct = if day.input_tokens > 0 {
873 input_saved as f64 / day.input_tokens as f64 * 100.0
874 } else {
875 0.0
876 };
877 let pc = pct_color(day_pct);
878 let date_short = day.date.get(5..).unwrap_or(&day.date);
879 o.push(format!(
880 " {GRAY}{date_short}{RST} {:>5} cmds {pc}{BOLD}{:>8}{RST} saved {pc}{day_pct:>5.1}%{RST}",
881 day.commands,
882 format_big(day_saved),
883 ));
884 }
885 }
886
887 if let Some(tip) = contextual_tip(&store) {
888 o.push(format!(" {YELLOW}💡 {tip}{RST}"));
889 o.push(String::new());
890 } else {
891 o.push(String::new());
892 }
893
894 o.join("\n")
895}
896
897fn contextual_tip(store: &StatsStore) -> Option<String> {
898 let tips = build_tips(store);
899 if tips.is_empty() {
900 return None;
901 }
902 let seed = std::time::SystemTime::now()
903 .duration_since(std::time::UNIX_EPOCH)
904 .unwrap_or_default()
905 .as_secs()
906 / 86400;
907 Some(tips[(seed as usize) % tips.len()].clone())
908}
909
910fn build_tips(store: &StatsStore) -> Vec<String> {
911 let mut tips = Vec::new();
912
913 if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
914 tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
915 }
916
917 if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
918 tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
919 }
920
921 if store.cep.total_cache_reads > 0
922 && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
923 {
924 tips.push(
925 "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
926 );
927 }
928
929 if store.total_commands > 50 && store.cep.sessions == 0 {
930 tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
931 }
932
933 if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
934 tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
935 }
936
937 if store.daily.len() >= 7 {
938 tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
939 }
940
941 tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
942 tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
943
944 tips
945}
946
947pub fn gain_live() {
948 use std::io::Write;
949
950 let interval = std::time::Duration::from_secs(2);
951 let mut line_count = 0usize;
952
953 eprintln!(" {DIM}▸ Live mode (2s refresh) · Ctrl+C to exit{RST}");
954
955 loop {
956 if line_count > 0 {
957 print!("\x1B[{line_count}A\x1B[J");
958 }
959
960 let output = format_gain();
961 let footer = format!("\n {DIM}▸ Live · updates every 2s · Ctrl+C to exit{RST}\n");
962 let full = format!("{output}{footer}");
963 line_count = full.lines().count();
964
965 print!("{full}");
966 let _ = std::io::stdout().flush();
967
968 std::thread::sleep(interval);
969 }
970}
971
972pub fn format_gain_graph() -> String {
973 let store = load();
974 if store.daily.is_empty() {
975 return format!(
976 "{DIM}No daily data yet.{RST} Use lean-ctx for a few days to see the graph."
977 );
978 }
979
980 let cm = CostModel::default();
981 let days: Vec<_> = store
982 .daily
983 .iter()
984 .rev()
985 .take(30)
986 .collect::<Vec<_>>()
987 .into_iter()
988 .rev()
989 .collect();
990
991 let savings: Vec<u64> = days.iter().map(|d| day_total_saved(d, &cm)).collect();
992
993 let max_saved = *savings.iter().max().unwrap_or(&1);
994 let max_saved = max_saved.max(1);
995
996 let bar_width = 36;
997 let mut o = Vec::new();
998
999 o.push(String::new());
1000 let ln58 = line('─', 58);
1001 o.push(format!(
1002 " {BOLD}{WHITE}◆ lean-ctx{RST} {DIM}Token Savings Graph (last 30 days){RST}"
1003 ));
1004 o.push(format!(" {DIM}{ln58}{RST}"));
1005 o.push(format!(
1006 " {DIM}{:>58}{RST}",
1007 format!("peak: {}", format_big(max_saved))
1008 ));
1009 o.push(String::new());
1010
1011 for (i, day) in days.iter().enumerate() {
1012 let saved = savings[i];
1013 let ratio = saved as f64 / max_saved as f64;
1014 let bar = bar_block(ratio, bar_width);
1015
1016 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1017 let pct = if day.input_tokens > 0 {
1018 input_saved as f64 / day.input_tokens as f64 * 100.0
1019 } else {
1020 0.0
1021 };
1022 let pc = pct_color(pct);
1023 let date_short = day.date.get(5..).unwrap_or(&day.date);
1024
1025 o.push(format!(
1026 " {GRAY}{date_short}{RST} {DIM}│{RST} {pc}{bar:<width$}{RST} {BOLD}{:>6}{RST} {DIM}{pct:.0}%{RST}",
1027 format_big(saved),
1028 width = bar_width,
1029 ));
1030 }
1031
1032 let total_saved: u64 = savings.iter().sum();
1033 let total_cmds: u64 = days.iter().map(|d| d.commands).sum();
1034 let spark = sparkline(&savings);
1035
1036 o.push(String::new());
1037 o.push(format!(" {DIM}{ln58}{RST}"));
1038 o.push(format!(
1039 " {GREEN}{spark}{RST} {BOLD}{WHITE}{}{RST} saved across {BOLD}{}{RST} commands",
1040 format_big(total_saved),
1041 format_num(total_cmds),
1042 ));
1043 o.push(String::new());
1044
1045 o.join("\n")
1046}
1047
1048pub fn format_gain_daily() -> String {
1049 let store = load();
1050 if store.daily.is_empty() {
1051 return format!("{DIM}No daily data yet.{RST}");
1052 }
1053
1054 let mut o = Vec::new();
1055 let w = 64;
1056
1057 o.push(String::new());
1058 let lnw = line('─', w);
1059 o.push(format!(
1060 " {BOLD}{WHITE}◆ lean-ctx{RST} {DIM}Daily Breakdown{RST}"
1061 ));
1062 o.push(format!(" {DIM}┌{lnw}┐{RST}"));
1063 o.push(format!(
1064 " {DIM}│{RST} {BOLD}{WHITE}{:<12} {:>6} {:>10} {:>10} {:>7} {:>6}{RST} {DIM}│{RST}",
1065 "Date", "Cmds", "Input", "Saved", "Rate", "USD"
1066 ));
1067 o.push(format!(" {DIM}├{lnw}┤{RST}"));
1068
1069 let days: Vec<_> = store
1070 .daily
1071 .iter()
1072 .rev()
1073 .take(30)
1074 .collect::<Vec<_>>()
1075 .into_iter()
1076 .rev()
1077 .cloned()
1078 .collect();
1079
1080 let cm = CostModel::default();
1081 for day in &days {
1082 let saved = day_total_saved(day, &cm);
1083 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1084 let pct = if day.input_tokens > 0 {
1085 input_saved as f64 / day.input_tokens as f64 * 100.0
1086 } else {
1087 0.0
1088 };
1089 let pc = pct_color(pct);
1090 let usd = usd_estimate(saved);
1091 o.push(format!(
1092 " {DIM}│{RST} {GRAY}{:<12}{RST} {:>6} {:>10} {pc}{BOLD}{:>10}{RST} {pc}{:>6.1}%{RST} {DIM}{:>6}{RST} {DIM}│{RST}",
1093 &day.date,
1094 day.commands,
1095 format_big(day.input_tokens),
1096 format_big(saved),
1097 pct,
1098 usd,
1099 ));
1100 }
1101
1102 let total_input: u64 = store.daily.iter().map(|d| d.input_tokens).sum();
1103 let total_saved: u64 = store.daily.iter().map(|d| day_total_saved(d, &cm)).sum();
1104 let total_pct = if total_input > 0 {
1105 let input_saved: u64 = store
1106 .daily
1107 .iter()
1108 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
1109 .sum();
1110 input_saved as f64 / total_input as f64 * 100.0
1111 } else {
1112 0.0
1113 };
1114 let total_usd = usd_estimate(total_saved);
1115
1116 o.push(format!(" {DIM}├{lnw}┤{RST}"));
1117 o.push(format!(
1118 " {DIM}│{RST} {BOLD}{WHITE}{:<12}{RST} {:>6} {:>10} {GREEN}{BOLD}{:>10}{RST} {GREEN}{BOLD}{:>6.1}%{RST} {BOLD}{:>6}{RST} {DIM}│{RST}",
1119 "TOTAL",
1120 format_num(store.total_commands),
1121 format_big(total_input),
1122 format_big(total_saved),
1123 total_pct,
1124 total_usd,
1125 ));
1126 o.push(format!(" {DIM}└{lnw}┘{RST}"));
1127
1128 let daily_savings: Vec<u64> = days.iter().map(|d| day_total_saved(d, &cm)).collect();
1129 let spark = sparkline(&daily_savings);
1130 o.push(format!(" {DIM}Trend:{RST} {GREEN}{spark}{RST}"));
1131 o.push(String::new());
1132
1133 o.join("\n")
1134}
1135
1136pub fn format_gain_json() -> String {
1137 let store = load();
1138 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1139}