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