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
282use super::theme::{self, Theme};
283
284fn active_theme() -> Theme {
285 let cfg = super::config::Config::load();
286 theme::load_theme(&cfg.theme)
287}
288
289pub const DEFAULT_INPUT_PRICE_PER_M: f64 = 2.50;
291pub const DEFAULT_OUTPUT_PRICE_PER_M: f64 = 10.0;
292
293pub struct CostModel {
294 pub input_price_per_m: f64,
295 pub output_price_per_m: f64,
296 pub avg_verbose_output_per_call: u64,
297 pub avg_concise_output_per_call: u64,
298}
299
300impl Default for CostModel {
301 fn default() -> Self {
302 Self {
303 input_price_per_m: DEFAULT_INPUT_PRICE_PER_M,
304 output_price_per_m: DEFAULT_OUTPUT_PRICE_PER_M,
305 avg_verbose_output_per_call: 450,
306 avg_concise_output_per_call: 120,
307 }
308 }
309}
310
311pub struct CostBreakdown {
312 pub input_cost_without: f64,
313 pub input_cost_with: f64,
314 pub output_cost_without: f64,
315 pub output_cost_with: f64,
316 pub total_cost_without: f64,
317 pub total_cost_with: f64,
318 pub total_saved: f64,
319 pub estimated_output_tokens_without: u64,
320 pub estimated_output_tokens_with: u64,
321 pub output_tokens_saved: u64,
322}
323
324impl CostModel {
325 pub fn calculate(&self, store: &StatsStore) -> CostBreakdown {
326 let input_cost_without =
327 store.total_input_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
328 let input_cost_with =
329 store.total_output_tokens as f64 / 1_000_000.0 * self.input_price_per_m;
330
331 let est_output_without = store.total_commands * self.avg_verbose_output_per_call;
332 let est_output_with = store.total_commands * self.avg_concise_output_per_call;
333 let output_saved = est_output_without.saturating_sub(est_output_with);
334
335 let output_cost_without = est_output_without as f64 / 1_000_000.0 * self.output_price_per_m;
336 let output_cost_with = est_output_with as f64 / 1_000_000.0 * self.output_price_per_m;
337
338 let total_without = input_cost_without + output_cost_without;
339 let total_with = input_cost_with + output_cost_with;
340
341 CostBreakdown {
342 input_cost_without,
343 input_cost_with,
344 output_cost_without,
345 output_cost_with,
346 total_cost_without: total_without,
347 total_cost_with: total_with,
348 total_saved: total_without - total_with,
349 estimated_output_tokens_without: est_output_without,
350 estimated_output_tokens_with: est_output_with,
351 output_tokens_saved: output_saved,
352 }
353 }
354}
355
356fn format_usd(amount: f64) -> String {
357 if amount >= 0.01 {
358 format!("${amount:.2}")
359 } else {
360 format!("${amount:.3}")
361 }
362}
363
364fn usd_estimate(tokens: u64) -> String {
365 let cost = tokens as f64 * DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
366 format_usd(cost)
367}
368
369fn format_big(n: u64) -> String {
370 if n >= 1_000_000 {
371 format!("{:.1}M", n as f64 / 1_000_000.0)
372 } else if n >= 1_000 {
373 format!("{:.1}K", n as f64 / 1_000.0)
374 } else {
375 format!("{n}")
376 }
377}
378
379fn format_num(n: u64) -> String {
380 if n >= 1_000_000 {
381 format!("{:.1}M", n as f64 / 1_000_000.0)
382 } else if n >= 1_000 {
383 format!("{},{:03}", n / 1_000, n % 1_000)
384 } else {
385 format!("{n}")
386 }
387}
388
389fn truncate_cmd(cmd: &str, max: usize) -> String {
390 if cmd.len() <= max {
391 cmd.to_string()
392 } else {
393 format!("{}…", &cmd[..max - 1])
394 }
395}
396
397fn format_cep_live(lv: &serde_json::Value, t: &Theme) -> String {
398 let mut o = Vec::new();
399 let r = theme::rst();
400 let b = theme::bold();
401 let d = theme::dim();
402
403 let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
404 let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
405 let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
406 let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
407 let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
408 let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
409 let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
410 let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
411 let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
412 let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
413
414 o.push(String::new());
415 o.push(format!(
416 " {icon} {brand} {cep} {d}Live Session (no historical data yet){r}",
417 icon = t.header_icon(),
418 brand = t.brand_title(),
419 cep = t.section_title("CEP"),
420 ));
421 o.push(format!(" {ln}", ln = t.border_line(56)));
422 o.push(String::new());
423
424 let txt = t.text.fg();
425 let sc = t.success.fg();
426 let sec = t.secondary.fg();
427
428 o.push(format!(
429 " {b}{txt}CEP Score{r} {b}{pc}{score:>3}/100{r}",
430 pc = t.pct_color(score as f64),
431 ));
432 o.push(format!(
433 " {b}{txt}Cache Hit Rate{r} {b}{pc}{cache_util}%{r} {d}({cache_hits} hits / {total_reads} reads){r}",
434 pc = t.pct_color(cache_util as f64),
435 ));
436 o.push(format!(
437 " {b}{txt}Mode Diversity{r} {b}{pc}{mode_div}%{r}",
438 pc = t.pct_color(mode_div as f64),
439 ));
440 o.push(format!(
441 " {b}{txt}Compression{r} {b}{pc}{comp_rate}%{r} {d}({} → {}){r}",
442 format_big(tok_orig),
443 format_big(tok_orig.saturating_sub(tok_saved)),
444 pc = t.pct_color(comp_rate as f64),
445 ));
446 o.push(format!(
447 " {b}{txt}Tokens Saved{r} {b}{sc}{}{r} {d}(≈ {}){r}",
448 format_big(tok_saved),
449 usd_estimate(tok_saved),
450 ));
451 o.push(format!(
452 " {b}{txt}Tool Calls{r} {b}{sec}{tool_calls}{r}"
453 ));
454 o.push(format!(" {b}{txt}Complexity{r} {d}{complexity}{r}"));
455 o.push(String::new());
456 o.push(format!(" {ln}", ln = t.border_line(56)));
457 o.push(format!(
458 " {d}This is live data from the current MCP session.{r}"
459 ));
460 o.push(format!(
461 " {d}Historical CEP trends appear after more sessions.{r}"
462 ));
463 o.push(String::new());
464
465 o.join("\n")
466}
467
468fn load_mcp_live() -> Option<serde_json::Value> {
469 let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
470 let content = std::fs::read_to_string(path).ok()?;
471 serde_json::from_str(&content).ok()
472}
473
474pub fn format_cep_report() -> String {
475 let t = active_theme();
476 let store = load();
477 let cep = &store.cep;
478 let live = load_mcp_live();
479 let mut o = Vec::new();
480 let r = theme::rst();
481 let b = theme::bold();
482 let d = theme::dim();
483
484 if cep.sessions == 0 && live.is_none() {
485 return format!(
486 "{d}No CEP sessions recorded yet.{r}\n\
487 Use lean-ctx as an MCP server in your editor to start tracking.\n\
488 CEP metrics are recorded automatically during MCP sessions."
489 );
490 }
491
492 if cep.sessions == 0 {
493 if let Some(ref lv) = live {
494 return format_cep_live(lv, &t);
495 }
496 }
497
498 let total_saved = cep
499 .total_tokens_original
500 .saturating_sub(cep.total_tokens_compressed);
501 let overall_compression = if cep.total_tokens_original > 0 {
502 total_saved as f64 / cep.total_tokens_original as f64 * 100.0
503 } else {
504 0.0
505 };
506 let cache_hit_rate = if cep.total_cache_reads > 0 {
507 cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
508 } else {
509 0.0
510 };
511 let avg_score = if !cep.scores.is_empty() {
512 cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
513 } else {
514 0.0
515 };
516 let latest_score = cep.scores.last().map(|s| s.score).unwrap_or(0);
517
518 let shell_saved = store
519 .total_input_tokens
520 .saturating_sub(store.total_output_tokens)
521 .saturating_sub(total_saved);
522 let total_all_saved = store
523 .total_input_tokens
524 .saturating_sub(store.total_output_tokens);
525 let cep_share = if total_all_saved > 0 {
526 total_saved as f64 / total_all_saved as f64 * 100.0
527 } else {
528 0.0
529 };
530
531 let txt = t.text.fg();
532 let sc = t.success.fg();
533 let sec = t.secondary.fg();
534 let wrn = t.warning.fg();
535
536 o.push(String::new());
537 o.push(format!(
538 " {icon} {brand} {cep} {d}Cognitive Efficiency Protocol Report{r}",
539 icon = t.header_icon(),
540 brand = t.brand_title(),
541 cep = t.section_title("CEP"),
542 ));
543 o.push(format!(" {ln}", ln = t.border_line(56)));
544 o.push(String::new());
545
546 o.push(format!(
547 " {b}{txt}CEP Score{r} {b}{pc}{:>3}/100{r} {d}(avg: {avg_score:.0}, latest: {latest_score}){r}",
548 latest_score,
549 pc = t.pct_color(latest_score as f64),
550 ));
551 o.push(format!(
552 " {b}{txt}Sessions{r} {b}{sec}{}{r}",
553 cep.sessions
554 ));
555 o.push(format!(
556 " {b}{txt}Cache Hit Rate{r} {b}{pc}{:.1}%{r} {d}({} hits / {} reads){r}",
557 cache_hit_rate,
558 cep.total_cache_hits,
559 cep.total_cache_reads,
560 pc = t.pct_color(cache_hit_rate),
561 ));
562 o.push(format!(
563 " {b}{txt}MCP Compression{r} {b}{pc}{:.1}%{r} {d}({} → {}){r}",
564 overall_compression,
565 format_big(cep.total_tokens_original),
566 format_big(cep.total_tokens_compressed),
567 pc = t.pct_color(overall_compression),
568 ));
569 o.push(format!(
570 " {b}{txt}Tokens Saved{r} {b}{sc}{}{r} {d}(≈ {}){r}",
571 format_big(total_saved),
572 usd_estimate(total_saved),
573 ));
574 o.push(String::new());
575
576 o.push(format!(" {}", t.section_title("Savings Breakdown")));
577 o.push(format!(" {ln}", ln = t.border_line(56)));
578
579 let bar_w = 30;
580 let shell_ratio = if total_all_saved > 0 {
581 shell_saved as f64 / total_all_saved as f64
582 } else {
583 0.0
584 };
585 let cep_ratio = if total_all_saved > 0 {
586 total_saved as f64 / total_all_saved as f64
587 } else {
588 0.0
589 };
590 let m = t.muted.fg();
591 let shell_bar = theme::pad_right(&t.gradient_bar(shell_ratio, bar_w), bar_w);
592 o.push(format!(
593 " {m}Shell Hook{r} {shell_bar} {b}{:>6}{r} {d}({:.0}%){r}",
594 format_big(shell_saved),
595 (1.0 - cep_share) * 100.0 / 100.0 * 100.0,
596 ));
597 let cep_bar = theme::pad_right(&t.gradient_bar(cep_ratio, bar_w), bar_w);
598 o.push(format!(
599 " {m}MCP/CEP{r} {cep_bar} {b}{:>6}{r} {d}({cep_share:.0}%){r}",
600 format_big(total_saved),
601 ));
602 o.push(String::new());
603
604 if total_saved == 0 && cep.modes.is_empty() {
605 o.push(format!(
606 " {wrn}⚠ MCP server not configured.{r} Shell hook compresses output, but"
607 ));
608 o.push(
609 " full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
610 .to_string(),
611 );
612 o.push(format!(
613 " Run {sec}lean-ctx setup{r} to auto-configure your editors."
614 ));
615 o.push(String::new());
616 }
617
618 if !cep.modes.is_empty() {
619 o.push(format!(" {}", t.section_title("Read Modes Used")));
620 o.push(format!(" {ln}", ln = t.border_line(56)));
621
622 let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
623 sorted_modes.sort_by(|a, b2| b2.1.cmp(a.1));
624 let max_mode = *sorted_modes.first().map(|(_, c)| *c).unwrap_or(&1);
625 let max_mode = max_mode.max(1);
626
627 for (mode, count) in &sorted_modes {
628 let ratio = **count as f64 / max_mode as f64;
629 let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
630 o.push(format!(" {sec}{:<14}{r} {:>4}x {bar}", mode, count,));
631 }
632
633 let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
634 let full_count = cep.modes.get("full").copied().unwrap_or(0);
635 let optimized = total_mode_calls.saturating_sub(full_count);
636 let opt_pct = if total_mode_calls > 0 {
637 optimized as f64 / total_mode_calls as f64 * 100.0
638 } else {
639 0.0
640 };
641 o.push(format!(
642 " {d}{optimized}/{total_mode_calls} reads used optimized modes ({opt_pct:.0}% non-full){r}"
643 ));
644 }
645
646 if cep.scores.len() >= 2 {
647 o.push(String::new());
648 o.push(format!(" {}", t.section_title("CEP Score Trend")));
649 o.push(format!(" {ln}", ln = t.border_line(56)));
650
651 let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
652 let spark = t.gradient_sparkline(&score_values);
653 o.push(format!(" {spark}"));
654
655 let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
656 for snap in recent.iter().rev() {
657 let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
658 let pc = t.pct_color(snap.score as f64);
659 o.push(format!(
660 " {m}{ts}{r} {pc}{b}{:>3}{r}/100 cache:{:>3}% modes:{:>3}% {d}{}{r}",
661 snap.score, snap.cache_hit_rate, snap.mode_diversity, snap.complexity,
662 ));
663 }
664 }
665
666 o.push(String::new());
667 o.push(format!(" {ln}", ln = t.border_line(56)));
668 o.push(format!(" {d}Improve your CEP score:{r}"));
669 if cache_hit_rate < 50.0 {
670 o.push(format!(
671 " {wrn}↑{r} Re-read files with ctx_read to leverage caching"
672 ));
673 }
674 let modes_count = cep.modes.len();
675 if modes_count < 3 {
676 o.push(format!(
677 " {wrn}↑{r} Use map/signatures modes for context-only files"
678 ));
679 }
680 if avg_score >= 70.0 {
681 o.push(format!(
682 " {sc}✓{r} Great score! You're using lean-ctx effectively"
683 ));
684 }
685 o.push(String::new());
686
687 o.join("\n")
688}
689
690pub fn format_gain() -> String {
691 format_gain_themed(&active_theme())
692}
693
694pub fn format_gain_themed(t: &Theme) -> String {
695 let store = load();
696 let mut o = Vec::new();
697 let r = theme::rst();
698 let b = theme::bold();
699 let d = theme::dim();
700
701 if store.total_commands == 0 {
702 return format!(
703 "{d}No commands recorded yet.{r} Use {cmd}lean-ctx -c \"command\"{r} to start tracking.",
704 cmd = t.secondary.fg(),
705 );
706 }
707
708 let input_saved = store
709 .total_input_tokens
710 .saturating_sub(store.total_output_tokens);
711 let pct = if store.total_input_tokens > 0 {
712 input_saved as f64 / store.total_input_tokens as f64 * 100.0
713 } else {
714 0.0
715 };
716 let cost_model = CostModel::default();
717 let cost = cost_model.calculate(&store);
718 let total_saved = input_saved + cost.output_tokens_saved;
719 let days_active = store.daily.len();
720
721 let w = 62;
722 let side = t.box_side();
723
724 let box_line = |content: &str| -> String {
725 let padded = theme::pad_right(content, w);
726 format!(" {side}{padded}{side}")
727 };
728
729 o.push(String::new());
730 o.push(format!(" {}", t.box_top(w)));
731 o.push(box_line(""));
732
733 let header = format!(
734 " {icon} {b}{title}{r} {d}Token Savings Dashboard{r}",
735 icon = t.header_icon(),
736 title = t.brand_title(),
737 );
738 o.push(box_line(&header));
739 o.push(box_line(""));
740 o.push(format!(" {}", t.box_mid(w)));
741 o.push(box_line(""));
742
743 let tok_val = format_big(total_saved);
744 let pct_val = format!("{pct:.1}%");
745 let cmd_val = format_num(store.total_commands);
746 let usd_val = format_usd(cost.total_saved);
747
748 let c1 = t.success.fg();
749 let c2 = t.secondary.fg();
750 let c3 = t.warning.fg();
751 let c4 = t.accent.fg();
752
753 let kw = 14;
754 let v1 = theme::pad_right(&format!("{c1}{b}{tok_val}{r}"), kw);
755 let v2 = theme::pad_right(&format!("{c2}{b}{pct_val}{r}"), kw);
756 let v3 = theme::pad_right(&format!("{c3}{b}{cmd_val}{r}"), kw);
757 let v4 = theme::pad_right(&format!("{c4}{b}{usd_val}{r}"), kw);
758 o.push(box_line(&format!(" {v1}{v2}{v3}{v4}")));
759
760 let l1 = theme::pad_right(&format!("{d}tokens saved{r}"), kw);
761 let l2 = theme::pad_right(&format!("{d}compression{r}"), kw);
762 let l3 = theme::pad_right(&format!("{d}commands{r}"), kw);
763 let l4 = theme::pad_right(&format!("{d}USD saved{r}"), kw);
764 o.push(box_line(&format!(" {l1}{l2}{l3}{l4}")));
765 o.push(box_line(""));
766 o.push(format!(" {}", t.box_bottom(w)));
767
768 o.push(String::new());
769 o.push(String::new());
770
771 let cost_title = t.section_title("Cost Breakdown");
772 o.push(format!(
773 " {cost_title} {d}@ ${}/M input · ${}/M output{r}",
774 DEFAULT_INPUT_PRICE_PER_M, DEFAULT_OUTPUT_PRICE_PER_M,
775 ));
776 o.push(format!(" {ln}", ln = t.border_line(w)));
777 o.push(String::new());
778 let lbl_w = 20;
779 let lbl_without = theme::pad_right(&format!("{m}Without lean-ctx{r}", m = t.muted.fg()), lbl_w);
780 let lbl_with = theme::pad_right(&format!("{m}With lean-ctx{r}", m = t.muted.fg()), lbl_w);
781 let lbl_saved = theme::pad_right(&format!("{c}{b}You saved{r}", c = t.success.fg()), lbl_w);
782
783 o.push(format!(
784 " {lbl_without} {:>8} {d}{} input + {} output{r}",
785 format_usd(cost.total_cost_without),
786 format_usd(cost.input_cost_without),
787 format_usd(cost.output_cost_without),
788 ));
789 o.push(format!(
790 " {lbl_with} {:>8} {d}{} input + {} output{r}",
791 format_usd(cost.total_cost_with),
792 format_usd(cost.input_cost_with),
793 format_usd(cost.output_cost_with),
794 ));
795 o.push(String::new());
796 o.push(format!(
797 " {lbl_saved} {c}{b}{:>8}{r} {d}input {} + output {}{r}",
798 format_usd(cost.total_saved),
799 format_usd(cost.input_cost_without - cost.input_cost_with),
800 format_usd(cost.output_cost_without - cost.output_cost_with),
801 c = t.success.fg(),
802 ));
803
804 o.push(String::new());
805
806 if let (Some(first), Some(_last)) = (&store.first_use, &store.last_use) {
807 let first_short = first.get(..10).unwrap_or(first);
808 let daily_savings: Vec<u64> = store
809 .daily
810 .iter()
811 .map(|d2| day_total_saved(d2, &cost_model))
812 .collect();
813 let spark = t.gradient_sparkline(&daily_savings);
814 o.push(format!(
815 " {d}Since {first_short} · {days_active} day{plural}{r} {spark}",
816 plural = if days_active != 1 { "s" } else { "" }
817 ));
818 o.push(String::new());
819 }
820
821 o.push(String::new());
822
823 if !store.commands.is_empty() {
824 o.push(format!(" {}", t.section_title("Top Commands")));
825 o.push(format!(" {ln}", ln = t.border_line(w)));
826 o.push(String::new());
827
828 let mut sorted: Vec<_> = store.commands.iter().collect();
829 sorted.sort_by(|a, b2| {
830 let sa = cmd_total_saved(a.1, &cost_model);
831 let sb = cmd_total_saved(b2.1, &cost_model);
832 sb.cmp(&sa)
833 });
834
835 let max_cmd_saved = sorted
836 .first()
837 .map(|(_, s)| cmd_total_saved(s, &cost_model))
838 .unwrap_or(1)
839 .max(1);
840
841 for (cmd, stats) in sorted.iter().take(10) {
842 let cmd_saved = cmd_total_saved(stats, &cost_model);
843 let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
844 let cmd_pct = if stats.input_tokens > 0 {
845 cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
846 } else {
847 0.0
848 };
849 let ratio = cmd_saved as f64 / max_cmd_saved as f64;
850 let bar = theme::pad_right(&t.gradient_bar(ratio, 22), 22);
851 let pc = t.pct_color(cmd_pct);
852 let cmd_col = theme::pad_right(
853 &format!("{m}{}{r}", truncate_cmd(cmd, 16), m = t.muted.fg()),
854 18,
855 );
856 let saved_col = theme::pad_right(&format!("{b}{pc}{}{r}", format_big(cmd_saved)), 8);
857 o.push(format!(
858 " {cmd_col} {:>5}x {bar} {saved_col} {d}{cmd_pct:>3.0}%{r}",
859 stats.count,
860 ));
861 }
862
863 if sorted.len() > 10 {
864 o.push(format!(
865 " {d}... +{} more commands{r}",
866 sorted.len() - 10
867 ));
868 }
869 }
870
871 if store.daily.len() >= 2 {
872 o.push(String::new());
873 o.push(String::new());
874 o.push(format!(" {}", t.section_title("Recent Days")));
875 o.push(format!(" {ln}", ln = t.border_line(w)));
876 o.push(String::new());
877
878 let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
879 for day in recent.iter().rev() {
880 let day_saved = day_total_saved(day, &cost_model);
881 let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
882 let day_pct = if day.input_tokens > 0 {
883 day_input_saved as f64 / day.input_tokens as f64 * 100.0
884 } else {
885 0.0
886 };
887 let pc = t.pct_color(day_pct);
888 let date_short = day.date.get(5..).unwrap_or(&day.date);
889 let date_col = theme::pad_right(&format!("{m}{date_short}{r}", m = t.muted.fg()), 7);
890 let saved_col = theme::pad_right(&format!("{pc}{b}{}{r}", format_big(day_saved)), 9);
891 o.push(format!(
892 " {date_col} {:>5} cmds {saved_col} saved {pc}{day_pct:>5.1}%{r}",
893 day.commands,
894 ));
895 }
896 }
897
898 o.push(String::new());
899 o.push(String::new());
900
901 if let Some(tip) = contextual_tip(&store) {
902 o.push(format!(" {w}💡 {tip}{r}", w = t.warning.fg()));
903 o.push(String::new());
904 }
905
906 o.push(String::new());
907 o.push(String::new());
908
909 o.join("\n")
910}
911
912fn contextual_tip(store: &StatsStore) -> Option<String> {
913 let tips = build_tips(store);
914 if tips.is_empty() {
915 return None;
916 }
917 let seed = std::time::SystemTime::now()
918 .duration_since(std::time::UNIX_EPOCH)
919 .unwrap_or_default()
920 .as_secs()
921 / 86400;
922 Some(tips[(seed as usize) % tips.len()].clone())
923}
924
925fn build_tips(store: &StatsStore) -> Vec<String> {
926 let mut tips = Vec::new();
927
928 if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
929 tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
930 }
931
932 if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
933 tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
934 }
935
936 if store.cep.total_cache_reads > 0
937 && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
938 {
939 tips.push(
940 "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
941 );
942 }
943
944 if store.total_commands > 50 && store.cep.sessions == 0 {
945 tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
946 }
947
948 if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
949 tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
950 }
951
952 if store.daily.len() >= 7 {
953 tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
954 }
955
956 tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
957 tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
958
959 let cfg = crate::core::config::Config::load();
960 if cfg.theme == "default" {
961 tips.push(
962 "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
963 );
964 tips.push(
965 "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
966 );
967 } else {
968 tips.push(format!(
969 "Current theme: {}. Run lean-ctx theme list to explore others.",
970 cfg.theme
971 ));
972 }
973
974 tips.push(
975 "Create your own theme with lean-ctx theme create <name> and set custom colors!".into(),
976 );
977
978 tips
979}
980
981pub fn gain_live() {
982 use std::io::Write;
983
984 let interval = std::time::Duration::from_secs(2);
985 let mut line_count = 0usize;
986 let d = theme::dim();
987 let r = theme::rst();
988
989 eprintln!(" {d}▸ Live mode (2s refresh) · Ctrl+C to exit{r}");
990
991 loop {
992 if line_count > 0 {
993 print!("\x1B[{line_count}A\x1B[J");
994 }
995
996 let output = format_gain();
997 let footer = format!("\n {d}▸ Live · updates every 2s · Ctrl+C to exit{r}\n");
998 let full = format!("{output}{footer}");
999 line_count = full.lines().count();
1000
1001 print!("{full}");
1002 let _ = std::io::stdout().flush();
1003
1004 std::thread::sleep(interval);
1005 }
1006}
1007
1008pub fn format_gain_graph() -> String {
1009 let t = active_theme();
1010 let store = load();
1011 let r = theme::rst();
1012 let b = theme::bold();
1013 let d = theme::dim();
1014
1015 if store.daily.is_empty() {
1016 return format!("{d}No daily data yet.{r} Use lean-ctx for a few days to see the graph.");
1017 }
1018
1019 let cm = CostModel::default();
1020 let days: Vec<_> = store
1021 .daily
1022 .iter()
1023 .rev()
1024 .take(30)
1025 .collect::<Vec<_>>()
1026 .into_iter()
1027 .rev()
1028 .collect();
1029
1030 let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1031
1032 let max_saved = *savings.iter().max().unwrap_or(&1);
1033 let max_saved = max_saved.max(1);
1034
1035 let bar_width = 36;
1036 let mut o = Vec::new();
1037
1038 o.push(String::new());
1039 o.push(format!(
1040 " {icon} {title} {d}Token Savings Graph (last 30 days){r}",
1041 icon = t.header_icon(),
1042 title = t.brand_title(),
1043 ));
1044 o.push(format!(" {ln}", ln = t.border_line(58)));
1045 o.push(format!(
1046 " {d}{:>58}{r}",
1047 format!("peak: {}", format_big(max_saved))
1048 ));
1049 o.push(String::new());
1050
1051 for (i, day) in days.iter().enumerate() {
1052 let saved = savings[i];
1053 let ratio = saved as f64 / max_saved as f64;
1054 let bar = theme::pad_right(&t.gradient_bar(ratio, bar_width), bar_width);
1055
1056 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1057 let pct = if day.input_tokens > 0 {
1058 input_saved as f64 / day.input_tokens as f64 * 100.0
1059 } else {
1060 0.0
1061 };
1062 let date_short = day.date.get(5..).unwrap_or(&day.date);
1063
1064 o.push(format!(
1065 " {m}{date_short}{r} {brd}│{r} {bar} {b}{:>6}{r} {d}{pct:.0}%{r}",
1066 format_big(saved),
1067 m = t.muted.fg(),
1068 brd = t.border.fg(),
1069 ));
1070 }
1071
1072 let total_saved: u64 = savings.iter().sum();
1073 let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
1074 let spark = t.gradient_sparkline(&savings);
1075
1076 o.push(String::new());
1077 o.push(format!(" {ln}", ln = t.border_line(58)));
1078 o.push(format!(
1079 " {spark} {b}{txt}{}{r} saved across {b}{}{r} commands",
1080 format_big(total_saved),
1081 format_num(total_cmds),
1082 txt = t.text.fg(),
1083 ));
1084 o.push(String::new());
1085
1086 o.join("\n")
1087}
1088
1089pub fn format_gain_daily() -> String {
1090 let t = active_theme();
1091 let store = load();
1092 let r = theme::rst();
1093 let b = theme::bold();
1094 let d = theme::dim();
1095
1096 if store.daily.is_empty() {
1097 return format!("{d}No daily data yet.{r}");
1098 }
1099
1100 let mut o = Vec::new();
1101 let w = 64;
1102
1103 let side = t.box_side();
1104 let daily_box = |content: &str| -> String {
1105 let padded = theme::pad_right(content, w);
1106 format!(" {side}{padded}{side}")
1107 };
1108
1109 o.push(String::new());
1110 o.push(format!(
1111 " {icon} {title} {d}Daily Breakdown{r}",
1112 icon = t.header_icon(),
1113 title = t.brand_title(),
1114 ));
1115 o.push(format!(" {}", t.box_top(w)));
1116 let hdr = format!(
1117 " {b}{txt}{:<12} {:>6} {:>10} {:>10} {:>7} {:>6}{r}",
1118 "Date",
1119 "Cmds",
1120 "Input",
1121 "Saved",
1122 "Rate",
1123 "USD",
1124 txt = t.text.fg(),
1125 );
1126 o.push(daily_box(&hdr));
1127 o.push(format!(" {}", t.box_mid(w)));
1128
1129 let days: Vec<_> = store
1130 .daily
1131 .iter()
1132 .rev()
1133 .take(30)
1134 .collect::<Vec<_>>()
1135 .into_iter()
1136 .rev()
1137 .cloned()
1138 .collect();
1139
1140 let cm = CostModel::default();
1141 for day in &days {
1142 let saved = day_total_saved(day, &cm);
1143 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1144 let pct = if day.input_tokens > 0 {
1145 input_saved as f64 / day.input_tokens as f64 * 100.0
1146 } else {
1147 0.0
1148 };
1149 let pc = t.pct_color(pct);
1150 let usd = usd_estimate(saved);
1151 let row = format!(
1152 " {m}{:<12}{r} {:>6} {:>10} {pc}{b}{:>10}{r} {pc}{:>6.1}%{r} {d}{:>6}{r}",
1153 &day.date,
1154 day.commands,
1155 format_big(day.input_tokens),
1156 format_big(saved),
1157 pct,
1158 usd,
1159 m = t.muted.fg(),
1160 );
1161 o.push(daily_box(&row));
1162 }
1163
1164 let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
1165 let total_saved: u64 = store
1166 .daily
1167 .iter()
1168 .map(|day| day_total_saved(day, &cm))
1169 .sum();
1170 let total_pct = if total_input > 0 {
1171 let input_saved: u64 = store
1172 .daily
1173 .iter()
1174 .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
1175 .sum();
1176 input_saved as f64 / total_input as f64 * 100.0
1177 } else {
1178 0.0
1179 };
1180 let total_usd = usd_estimate(total_saved);
1181 let sc = t.success.fg();
1182
1183 o.push(format!(" {}", t.box_mid(w)));
1184 let total_row = format!(
1185 " {b}{txt}{:<12}{r} {:>6} {:>10} {sc}{b}{:>10}{r} {sc}{b}{:>6.1}%{r} {b}{:>6}{r}",
1186 "TOTAL",
1187 format_num(store.total_commands),
1188 format_big(total_input),
1189 format_big(total_saved),
1190 total_pct,
1191 total_usd,
1192 txt = t.text.fg(),
1193 );
1194 o.push(daily_box(&total_row));
1195 o.push(format!(" {}", t.box_bottom(w)));
1196
1197 let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1198 let spark = t.gradient_sparkline(&daily_savings);
1199 o.push(format!(" {d}Trend:{r} {spark}"));
1200 o.push(String::new());
1201
1202 o.join("\n")
1203}
1204
1205pub fn format_gain_json() -> String {
1206 let store = load();
1207 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1208}