1use crate::core::theme::{self, Theme};
2
3use super::model::{CommandStats, CostModel, DayStats, StatsStore};
4
5fn active_theme() -> Theme {
6 let cfg = crate::core::config::Config::load();
7 theme::load_theme(&cfg.theme)
8}
9
10fn format_usd(amount: f64) -> String {
11 if amount >= 0.01 {
12 format!("${amount:.2}")
13 } else {
14 format!("${amount:.3}")
15 }
16}
17
18fn usd_estimate(tokens: u64) -> String {
19 let env_model = std::env::var("LEAN_CTX_MODEL")
20 .or_else(|_| std::env::var("LCTX_MODEL"))
21 .ok();
22 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
23 let quote = pricing.quote(env_model.as_deref());
24 let cost = tokens as f64 * quote.cost.input_per_m / 1_000_000.0;
25 format_usd(cost)
26}
27
28pub(super) fn format_pct_1dp(val: f64) -> String {
29 if val == 0.0 {
30 "0.0%".to_string()
31 } else if val > 0.0 && val < 0.1 {
32 "<0.1%".to_string()
33 } else {
34 format!("{val:.1}%")
35 }
36}
37
38fn format_big(n: u64) -> String {
39 if n >= 1_000_000 {
40 format!("{:.1}M", n as f64 / 1_000_000.0)
41 } else if n >= 1_000 {
42 format!("{:.1}K", n as f64 / 1_000.0)
43 } else {
44 format!("{n}")
45 }
46}
47
48fn format_num(n: u64) -> String {
49 if n >= 1_000_000 {
50 format!("{:.1}M", n as f64 / 1_000_000.0)
51 } else if n >= 1_000 {
52 format!("{},{:03}", n / 1_000, n % 1_000)
53 } else {
54 format!("{n}")
55 }
56}
57
58fn truncate_cmd(cmd: &str, max: usize) -> String {
59 if cmd.len() <= max {
60 cmd.to_string()
61 } else {
62 format!("{}…", &cmd[..max - 1])
63 }
64}
65
66fn cmd_total_saved(s: &CommandStats, _cm: &CostModel) -> u64 {
67 s.input_tokens.saturating_sub(s.output_tokens)
68}
69
70fn day_total_saved(d: &DayStats, _cm: &CostModel) -> u64 {
71 d.input_tokens.saturating_sub(d.output_tokens)
72}
73
74pub(super) fn normalize_command(command: &str) -> String {
75 let parts: Vec<&str> = command.split_whitespace().collect();
76 if parts.is_empty() {
77 return command.to_string();
78 }
79
80 let base = std::path::Path::new(parts[0])
81 .file_name()
82 .and_then(|n| n.to_str())
83 .unwrap_or(parts[0]);
84
85 match base {
86 "git" => {
87 if parts.len() > 1 {
88 format!("git {}", parts[1])
89 } else {
90 "git".to_string()
91 }
92 }
93 "cargo" => {
94 if parts.len() > 1 {
95 format!("cargo {}", parts[1])
96 } else {
97 "cargo".to_string()
98 }
99 }
100 "npm" | "yarn" | "pnpm" => {
101 if parts.len() > 1 {
102 format!("{} {}", base, parts[1])
103 } else {
104 base.to_string()
105 }
106 }
107 "docker" => {
108 if parts.len() > 1 {
109 format!("docker {}", parts[1])
110 } else {
111 "docker".to_string()
112 }
113 }
114 _ => base.to_string(),
115 }
116}
117
118#[allow(clippy::many_single_char_names)] fn format_cep_live(lv: &serde_json::Value, t: &Theme) -> String {
120 let mut out = Vec::new();
121 let rst = theme::rst();
122 let bold = theme::bold();
123 let dim = theme::dim();
124
125 let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
126 let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
127 let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
128 let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
129 let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
130 let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
131 let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
132 let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
133 let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
134 let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
135
136 out.push(String::new());
137 out.push(format!(
138 " {icon} {brand} {cep} {dim}Live Session (no historical data yet){rst}",
139 icon = t.header_icon(),
140 brand = t.brand_title(),
141 cep = t.section_title("CEP"),
142 ));
143 out.push(format!(" {ln}", ln = t.border_line(56)));
144 out.push(String::new());
145
146 let txt = t.text.fg();
147 let sc = t.success.fg();
148 let sec = t.secondary.fg();
149
150 out.push(format!(
151 " {bold}{txt}CEP Score{rst} {bold}{pc}{score:>3}/100{rst}",
152 pc = t.pct_color(score as f64),
153 ));
154 out.push(format!(
155 " {bold}{txt}Cache Hit Rate{rst} {bold}{pc}{cache_util}%{rst} {dim}({cache_hits} hits / {total_reads} reads){rst}",
156 pc = t.pct_color(cache_util as f64),
157 ));
158 out.push(format!(
159 " {bold}{txt}Mode Diversity{rst} {bold}{pc}{mode_div}%{rst}",
160 pc = t.pct_color(mode_div as f64),
161 ));
162 out.push(format!(
163 " {bold}{txt}Compression{rst} {bold}{pc}{comp_rate}%{rst} {dim}({} → {}){rst}",
164 format_big(tok_orig),
165 format_big(tok_orig.saturating_sub(tok_saved)),
166 pc = t.pct_color(comp_rate as f64),
167 ));
168 out.push(format!(
169 " {bold}{txt}Tokens Saved{rst} {bold}{sc}{}{rst} {dim}(≈ {}){rst}",
170 format_big(tok_saved),
171 usd_estimate(tok_saved),
172 ));
173 out.push(format!(
174 " {bold}{txt}Tool Calls{rst} {bold}{sec}{tool_calls}{rst}"
175 ));
176 out.push(format!(
177 " {bold}{txt}Complexity{rst} {dim}{complexity}{rst}"
178 ));
179 out.push(String::new());
180 out.push(format!(" {ln}", ln = t.border_line(56)));
181 out.push(format!(
182 " {dim}This is live data from the current MCP session.{rst}"
183 ));
184 out.push(format!(
185 " {dim}Historical CEP trends appear after more sessions.{rst}"
186 ));
187 out.push(String::new());
188
189 out.join("\n")
190}
191
192fn load_mcp_live() -> Option<serde_json::Value> {
193 let path = dirs::home_dir()?.join(".lean-ctx/mcp-live.json");
194 let content = std::fs::read_to_string(path).ok()?;
195 serde_json::from_str(&content).ok()
196}
197
198#[allow(clippy::many_single_char_names)] pub fn format_cep_report() -> String {
201 let theme = active_theme();
202 let store = super::load();
203 let cep = &store.cep;
204 let live = load_mcp_live();
205 let mut out = Vec::new();
206 let rst = theme::rst();
207 let bold = theme::bold();
208 let dim = theme::dim();
209
210 if cep.sessions == 0 && live.is_none() {
211 return format!(
212 "{dim}No CEP sessions recorded yet.{rst}\n\
213 Use lean-ctx as an MCP server in your editor to start tracking.\n\
214 CEP metrics are recorded automatically during MCP sessions."
215 );
216 }
217
218 if cep.sessions == 0 {
219 if let Some(ref lv) = live {
220 return format_cep_live(lv, &theme);
221 }
222 }
223
224 let total_saved = cep
225 .total_tokens_original
226 .saturating_sub(cep.total_tokens_compressed);
227 let overall_compression = if cep.total_tokens_original > 0 {
228 total_saved as f64 / cep.total_tokens_original as f64 * 100.0
229 } else {
230 0.0
231 };
232 let cache_hit_rate = if cep.total_cache_reads > 0 {
233 cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
234 } else {
235 0.0
236 };
237 let avg_score = if cep.scores.is_empty() {
238 0.0
239 } else {
240 cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
241 };
242 let latest_score = cep.scores.last().map_or(0, |s| s.score);
243
244 let shell_saved = store
245 .total_input_tokens
246 .saturating_sub(store.total_output_tokens)
247 .saturating_sub(total_saved);
248 let total_all_saved = store
249 .total_input_tokens
250 .saturating_sub(store.total_output_tokens);
251 let cep_share = if total_all_saved > 0 {
252 total_saved as f64 / total_all_saved as f64 * 100.0
253 } else {
254 0.0
255 };
256
257 let txt = theme.text.fg();
258 let sc = theme.success.fg();
259 let sec = theme.secondary.fg();
260 let wrn = theme.warning.fg();
261
262 let cep_w = 60;
263 let cep_ss = theme.box_side_square();
264 let cep_line = |content: &str| -> String {
265 let padded = theme::pad_right(content, cep_w);
266 format!(" {cep_ss}{padded}{cep_ss}")
267 };
268
269 out.push(String::new());
270 out.push(format!(" {}", theme.box_top(cep_w)));
271 let cep_side = theme.box_side();
272 out.push(format!(
273 " {cep_side}{}{cep_side}",
274 theme::pad_right(
275 &format!(
276 " {icon} {brand} {dim}CEP Report{rst}",
277 icon = theme.header_icon(),
278 brand = theme.brand_title(),
279 ),
280 cep_w,
281 )
282 ));
283 out.push(format!(" {}", theme.box_bottom(cep_w)));
284 out.push(String::new());
285
286 let score_ratio = (latest_score as f64 / 100.0).min(1.0);
287 let score_bar = theme.gradient_bar(score_ratio, 20);
288 let score_pc = theme.pct_color(latest_score as f64);
289
290 out.push(format!(" {}", theme.box_top_labeled(cep_w, "CEP SCORE")));
291 out.push(cep_line(&format!(
292 " {score_bar} {score_pc}{bold}{latest_score}/100{rst} {dim}avg: {avg_score:.0}{rst}"
293 )));
294 out.push(cep_line(&format!(
295 " {bold}{txt}Sessions{rst} {sec}{}{rst} {bold}{txt}Cache{rst} {pc}{cache_hit_rate:.1}%{rst} {bold}{txt}Compression{rst} {pc2}{overall_compression:.1}%{rst}",
296 cep.sessions,
297 pc = theme.pct_color(cache_hit_rate),
298 pc2 = theme.pct_color(overall_compression),
299 )));
300 out.push(cep_line(&format!(
301 " {bold}{txt}Saved{rst} {sc}{}{rst} {dim}({} → {} · ≈ {}){rst}",
302 format_big(total_saved),
303 format_big(cep.total_tokens_original),
304 format_big(cep.total_tokens_compressed),
305 usd_estimate(total_saved),
306 )));
307 out.push(format!(" {}", theme.box_bottom_square(cep_w)));
308 out.push(String::new());
309
310 out.push(format!(
311 " {}",
312 theme.box_top_labeled(cep_w, "SAVINGS BREAKDOWN")
313 ));
314
315 let bar_w = 26;
316 let shell_ratio = if total_all_saved > 0 {
317 shell_saved as f64 / total_all_saved as f64
318 } else {
319 0.0
320 };
321 let cep_ratio = if total_all_saved > 0 {
322 total_saved as f64 / total_all_saved as f64
323 } else {
324 0.0
325 };
326 let m = theme.muted.fg();
327 let shell_bar = theme::pad_right(&theme.gradient_bar(shell_ratio, bar_w), bar_w);
328 let shell_pct_display = format_pct_1dp(100.0 - cep_share);
331 out.push(cep_line(&format!(
332 " {m}Shell Hook{rst} {shell_bar} {bold}{:>6}{rst} {dim}({shell_pct_display}){rst}",
333 format_big(shell_saved),
334 )));
335 let cep_bar = theme::pad_right(&theme.gradient_bar(cep_ratio, bar_w), bar_w);
336 let cep_pct_display = format_pct_1dp(cep_share);
337 out.push(cep_line(&format!(
338 " {m}MCP/CEP{rst} {cep_bar} {bold}{:>6}{rst} {dim}({cep_pct_display}){rst}",
339 format_big(total_saved),
340 )));
341 out.push(format!(" {}", theme.box_bottom_square(cep_w)));
342 out.push(String::new());
343
344 if total_saved == 0 && cep.modes.is_empty() {
345 if store.total_commands > 20 {
346 out.push(format!(
347 " {wrn}⚠ MCP tools configured but not being used by your AI client.{rst}"
348 ));
349 out.push(
350 " Your AI client may be using native Read/Shell instead of ctx_read/ctx_shell."
351 .to_string(),
352 );
353 out.push(format!(
354 " Run {sec}lean-ctx init{rst} to update rules, then restart your AI session."
355 ));
356 out.push(format!(
357 " Run {sec}lean-ctx doctor{rst} for detailed adoption diagnostics."
358 ));
359 } else {
360 out.push(format!(
361 " {wrn}⚠ MCP server not configured.{rst} Shell hook compresses output, but"
362 ));
363 out.push(
364 " full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
365 .to_string(),
366 );
367 out.push(format!(
368 " Run {sec}lean-ctx setup{rst} to auto-configure your editors."
369 ));
370 }
371 out.push(String::new());
372 }
373
374 if !cep.modes.is_empty() {
375 out.push(format!(" {}", theme.box_top_labeled(cep_w, "READ MODES")));
376
377 let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
378 sorted_modes.sort_by_key(|item| std::cmp::Reverse(*item.1));
379 let max_mode = (*sorted_modes.first().map_or(&1, |(_, c)| *c)).max(1);
380
381 for (mode, count) in &sorted_modes {
382 let ratio = **count as f64 / max_mode as f64;
383 let bar = theme::pad_right(&theme.gradient_bar(ratio, 20), 20);
384 let mode_disp = theme::truncate_visual(mode.as_str(), 16);
385 out.push(cep_line(&format!(
386 " {sec}{mode_disp:<16}{rst} {count:>4}x {bar}"
387 )));
388 }
389
390 let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
391 let full_count = cep.modes.get("full").copied().unwrap_or(0);
392 let optimized = total_mode_calls.saturating_sub(full_count);
393 let opt_pct = if total_mode_calls > 0 {
394 optimized as f64 / total_mode_calls as f64 * 100.0
395 } else {
396 0.0
397 };
398 out.push(cep_line(&format!(
399 " {dim}{optimized}/{total_mode_calls} reads optimized \u{00b7} {opt_pct:.0}% non-full{rst}"
400 )));
401 out.push(format!(" {}", theme.box_bottom_square(cep_w)));
402 out.push(String::new());
403 }
404
405 if cep.scores.len() >= 2 {
406 out.push(format!(" {}", theme.box_top_labeled(cep_w, "SCORE TREND")));
407
408 let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
409 let spark_vals: Vec<u64> = score_values.iter().rev().take(54).rev().copied().collect();
411 let spark = theme.gradient_sparkline(&spark_vals);
412 out.push(cep_line(&format!(" {spark}")));
413
414 let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
415 for snap in recent.iter().rev() {
416 let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
417 let pc = theme.pct_color(snap.score as f64);
418 let cplx = theme::truncate_visual(&snap.complexity, 14);
419 out.push(cep_line(&format!(
420 " {m}{ts}{rst} {pc}{bold}{:>3}{rst}/100 {dim}cache {:>3}% {cplx}{rst}",
421 snap.score, snap.cache_hit_rate,
422 )));
423 }
424 out.push(format!(" {}", theme.box_bottom_square(cep_w)));
425 out.push(String::new());
426 }
427
428 out.push(format!(" {}", theme.box_top_labeled(cep_w, "IMPROVE")));
429 let mut tips: Vec<String> = Vec::new();
430 if cache_hit_rate < 50.0 {
431 tips.push(format!(
432 " {wrn}\u{2191}{rst} Re-read files with ctx_read to leverage caching"
433 ));
434 }
435 if cep.modes.len() < 3 {
436 tips.push(format!(
437 " {wrn}\u{2191}{rst} Use map/signatures modes for context-only files"
438 ));
439 }
440 if avg_score >= 70.0 {
441 tips.push(format!(
442 " {sc}\u{2713}{rst} Great score! You're using lean-ctx effectively"
443 ));
444 }
445 if tips.is_empty() {
446 tips.push(format!(
447 " {sc}\u{2713}{rst} Solid usage \u{2014} keep leaning on cached, compressed reads"
448 ));
449 }
450 for tip in tips {
451 out.push(cep_line(&tip));
452 }
453 out.push(format!(" {}", theme.box_bottom_square(cep_w)));
454 out.push(String::new());
455
456 out.join("\n")
457}
458
459pub fn format_gain() -> String {
461 format_gain_themed(&active_theme())
462}
463
464pub fn format_gain_themed(t: &Theme) -> String {
466 format_gain_themed_at(t, None)
467}
468
469pub fn format_gain_hero() -> String {
471 format_gain_hero_themed(&active_theme())
472}
473
474pub fn format_gain_hero_themed(t: &Theme) -> String {
476 let store = super::load();
477 let rst = theme::rst();
478 let bold = theme::bold();
479 let dim = theme::dim();
480
481 if store.total_commands == 0 {
482 return format_gain_themed_at(t, None);
483 }
484
485 let input_saved = store
486 .total_input_tokens
487 .saturating_sub(store.total_output_tokens);
488 let pct = if store.total_input_tokens > 0 {
489 input_saved as f64 / store.total_input_tokens as f64 * 100.0
490 } else {
491 0.0
492 };
493 let cost_model = CostModel::default();
494 let cost = cost_model.calculate(&store);
495
496 let engine = crate::core::gain::GainEngine::load();
497 let score = engine.gain_score(None);
498
499 let w = 57;
500 let side = t.box_side();
501 let box_line = |content: &str| -> String {
502 let padded = theme::pad_right(content, w);
503 format!(" {side}{padded}{side}")
504 };
505
506 let mut out = Vec::new();
507 out.push(String::new());
508 out.push(format!(" {}", t.box_top(w)));
509 out.push(box_line(&format!(
510 " {icon} {title}",
511 icon = t.header_icon(),
512 title = t.brand_title(),
513 )));
514 out.push(box_line(""));
515
516 let c1 = t.success.fg();
517 let c2 = t.secondary.fg();
518 let c4 = t.accent.fg();
519 let tok_val = format_big(input_saved);
520 let pct_val = format!("{pct:.0}%");
521 let usd_val = format_usd(cost.total_saved);
522
523 let kw = 18;
524 let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
525 let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
526 let v3 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
527 out.push(box_line(&format!(" {v1}{v2}{v3}")));
528
529 let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
530 let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
531 let ul3 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
532 out.push(box_line(&format!(" {ul1}{ul2}{ul3}")));
533
534 let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
535 let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
536 let l3 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
537 out.push(box_line(&format!(" {l1}{l2}{l3}")));
538 out.push(box_line(""));
539
540 let score_bar_w = 30;
541 let score_ratio = (score.total as f64 / 100.0).min(1.0);
542 let bar = t.gradient_bar(score_ratio, score_bar_w);
543 let sc_color = t.pct_color(score.total as f64);
544 let lvl = score.level();
545 out.push(box_line(&format!(
546 " {bar} {sc_color}{bold}{}/100{rst} Lv{} {dim}{}{rst}",
547 score.total, lvl.level, lvl.title,
548 )));
549 out.push(box_line(""));
550
551 if store.daily.len() >= 2 {
552 let daily_savings: Vec<u64> = store
553 .daily
554 .iter()
555 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
556 .collect();
557 let spark = t.gradient_sparkline(&daily_savings);
558 let trend_str = trend_string(&store, &c1, &t.warning.fg(), rst);
559 out.push(box_line(&format!(
560 " {dim}trend:{rst} {spark} {trend_str}"
561 )));
562 }
563
564 if input_saved > 0 {
565 let energy_str = crate::core::energy::format_for_tokens(input_saved);
566 let charges = crate::core::energy::phone_charges_hint(input_saved)
567 .map(|h| format!(" ({h})"))
568 .unwrap_or_default();
569 out.push(box_line(&format!(
570 " {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
571 )));
572 }
573
574 out.push(format!(" {}", t.box_bottom(w)));
575 out.push(String::new());
576
577 if store.daily.len() >= 7 && !crate::cli::wrapped_publish::has_published() {
579 let week_saved: u64 = store
580 .daily
581 .iter()
582 .rev()
583 .take(7)
584 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
585 .sum();
586 if week_saved > 0 {
587 let accent = t.accent.fg();
588 out.push(format!(" {}", t.box_top(42)));
589 let nside = t.box_side();
590 out.push(format!(
591 " {nside} {accent}{bold}Your first week!{rst} {nside}"
592 ));
593 out.push(format!(
594 " {nside} You saved {c1}{bold}{}{rst} tokens this week. {nside}",
595 crate::core::wrapped::format_tokens(week_saved),
596 ));
597 out.push(format!(
598 " {nside} Share your card? {sec}lean-ctx gain --wrapped{rst} {nside}",
599 sec = t.secondary.fg(),
600 ));
601 out.push(format!(" {}", t.box_bottom(42)));
602 out.push(String::new());
603 }
604 }
605
606 let sec = t.secondary.fg();
607 out.push(format!(
608 " {sec}lean-ctx gain --deep{rst} {dim}Full breakdown{rst}"
609 ));
610 out.push(format!(
611 " {sec}lean-ctx gain --wrapped{rst} {dim}Shareable card{rst}"
612 ));
613 out.push(format!(
614 " {sec}lean-ctx watch{rst} {dim}Live observatory{rst}"
615 ));
616 out.push(String::new());
617
618 if let Some(tip) = contextual_tip(&store) {
619 out.push(format!(" {dim}💡 {tip}{rst}"));
620 out.push(String::new());
621 }
622
623 out.join("\n")
624}
625
626pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
628 gain_dashboard(t, tick, true)
629}
630
631pub fn format_gain_body() -> String {
635 gain_dashboard(&active_theme(), None, false)
636}
637
638pub fn format_gain_footer() -> String {
640 let store = super::load();
641 let mut out = Vec::new();
642 append_gain_footer(&mut out, &active_theme(), &store);
643 out.join("\n")
644}
645
646#[allow(clippy::many_single_char_names)] fn gain_dashboard(t: &Theme, tick: Option<u64>, with_footer: bool) -> String {
648 let store = super::load();
649 let mut out = Vec::new();
650 let rst = theme::rst();
651 let bold = theme::bold();
652 let dim = theme::dim();
653
654 if store.total_commands == 0 {
655 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
656 Ok(p) => p.display().to_string(),
657 Err(_) => "~/.config/lean-ctx".into(),
658 };
659 let mcp_hint = if let Ok(live) =
660 std::fs::read_to_string(std::path::Path::new(&data_dir).join("mcp-live.json"))
661 {
662 if live.contains("\"total_calls\"") {
663 format!(
664 "\n{dim}MCP calls are tracked in mcp-live.json but stats.json is empty.{rst}\
665 \n{dim}This may indicate a data directory split. Run: lean-ctx doctor{rst}"
666 )
667 } else {
668 String::new()
669 }
670 } else {
671 String::new()
672 };
673 let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
674 let split_hint = if split_dirs.len() >= 2 {
675 format!(
676 "\n{dim}⚠ Stats found in multiple locations:{rst}\
677 \n{dim} {}{rst}\
678 \n{dim}Run: lean-ctx doctor{rst}",
679 split_dirs
680 .iter()
681 .map(|d| d.display().to_string())
682 .collect::<Vec<_>>()
683 .join(", ")
684 )
685 } else {
686 String::new()
687 };
688 return format!(
689 "{bold}No savings recorded yet — and that's expected.{rst}\
690 \n\n {dim}Savings appear after your AI tool uses lean-ctx for the first time.{rst}\
691 \n\n Next:\
692 \n 1. Make sure your AI tool is connected: {cmd}lean-ctx doctor{rst}\
693 \n 2. Fully restart your AI tool so it reconnects to lean-ctx.\
694 \n 3. Ask it to read a file or run a command — then check back here.\
695 \n\n {dim}Tip: track a shell command yourself with {rst}{cmd}lean-ctx -c \"git status\"{rst}\
696 \n\n {dim}Stats path: {data_dir}{rst}{mcp_hint}{split_hint}",
697 cmd = t.secondary.fg(),
698 );
699 }
700
701 let input_saved = store
702 .total_input_tokens
703 .saturating_sub(store.total_output_tokens);
704 let pct = if store.total_input_tokens > 0 {
705 input_saved as f64 / store.total_input_tokens as f64 * 100.0
706 } else {
707 0.0
708 };
709 let cost_model = CostModel::default();
710 let cost = cost_model.calculate(&store);
711 let total_saved = input_saved;
712 let _days_active = store.daily.len();
713
714 let w = 70;
715 let side = t.box_side();
716 let ss = t.box_side_square();
717
718 let box_line = |content: &str| -> String {
719 let padded = theme::pad_right(content, w);
720 format!(" {side}{padded}{side}")
721 };
722 let sec_line = |content: &str| -> String {
723 let padded = theme::pad_right(content, w);
724 format!(" {ss}{padded}{ss}")
725 };
726
727 out.push(String::new());
728 out.push(format!(" {}", t.box_top(w)));
729 out.push(box_line(""));
730
731 let ver = env!("CARGO_PKG_VERSION");
732 let header = format!(
733 " {icon} {bold}{title}{rst}",
734 icon = t.header_icon(),
735 title = t.brand_title(),
736 );
737 let ver_part = format!("{dim}v{ver}{rst}");
738 let header_padded = theme::pad_right(&header, w - ver.len() - 2);
739 out.push(format!(" {side}{header_padded}{ver_part} {side}"));
740
741 let subtitle = format!(" {dim}Token Savings Dashboard{rst}");
742 out.push(box_line(&subtitle));
743 out.push(box_line(""));
744 out.push(format!(" {}", t.box_mid(w)));
745 out.push(box_line(""));
746
747 let tok_val = format_big(total_saved);
748 let pct_val = format!("{pct:.1}%");
749 let cmd_val = format_num(store.total_commands);
750 let usd_val = format_usd(cost.total_saved);
751
752 let c1 = t.success.fg();
753 let c2 = t.secondary.fg();
754 let c3 = t.warning.fg();
755 let c4 = t.accent.fg();
756
757 let kw = 16;
758 let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
759 let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
760 let v3 = theme::pad_right(&format!("{c3}{bold}{cmd_val}{rst}"), kw);
761 let v4 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
762 out.push(box_line(&format!(" {v1}{v2}{v3}{v4}")));
763
764 let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
765 let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
766 let ul3 = theme::pad_right(&t.kpi_underline(cmd_val.len(), &t.warning), kw);
767 let ul4 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
768 out.push(box_line(&format!(" {ul1}{ul2}{ul3}{ul4}")));
769
770 let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
771 let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
772 let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
773 let l4 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
774 out.push(box_line(&format!(" {l1}{l2}{l3}{l4}")));
775 out.push(box_line(""));
776 out.push(format!(" {}", t.box_bottom(w)));
777 out.push(String::new());
778
779 {
781 let engine = crate::core::gain::GainEngine::load();
782 let score = engine.gain_score(None);
783 let lvl = score.level();
784 let score_ratio = (score.total as f64 / 100.0).min(1.0);
785 let bar = t.gradient_bar(score_ratio, 30);
786 let sc_color = t.pct_color(score.total as f64);
787
788 out.push(format!(" {}", t.box_top_labeled(w, "GAIN SCORE")));
789 out.push(sec_line(&format!(
790 " {bar} {sc_color}{bold}{}/100{rst} Lv{} {dim}{}{rst}",
791 score.total, lvl.level, lvl.title,
792 )));
793
794 if store.daily.len() >= 2 {
795 let daily_savings: Vec<u64> = store
796 .daily
797 .iter()
798 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
799 .collect();
800 let spark = t.gradient_sparkline(&daily_savings);
801 let trend_str = trend_string(&store, &c1, &t.warning.fg(), rst);
802 out.push(sec_line(&format!(
803 " {dim}trend:{rst} {spark} {trend_str}"
804 )));
805 }
806
807 if total_saved > 0 {
808 let energy_str = crate::core::energy::format_for_tokens(total_saved);
809 let charges = crate::core::energy::phone_charges_hint(total_saved)
810 .map(|h| format!(" ({h})"))
811 .unwrap_or_default();
812 out.push(sec_line(&format!(
813 " {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
814 )));
815 }
816 out.push(format!(" {}", t.box_bottom_square(w)));
817 }
818
819 {
821 let cfg = crate::core::config::Config::load();
822 if cfg.buddy_enabled {
823 out.push(String::new());
824 out.push(format!(" {}", t.box_top_labeled(w, "YOUR COMPANION")));
825 let buddy = crate::core::buddy::BuddyState::compute();
826 let block = crate::core::buddy::format_buddy_block_at(&buddy, t, tick);
827 for line in block.lines() {
828 out.push(sec_line(line));
829 }
830 out.push(format!(" {}", t.box_bottom_square(w)));
831 }
832 }
833
834 out.push(String::new());
835
836 let price_label = format!(
838 "@ ${:.2}/M input · ${:.2}/M output",
839 cost_model.input_price_per_m, cost_model.output_price_per_m,
840 );
841 let cost_label = format!("COST BREAKDOWN ──── {price_label}");
842 out.push(format!(" {}", t.box_top_labeled(w, &cost_label)));
843 out.push(sec_line(""));
844 let without_bar = t.gradient_bar(1.0, 26);
845 let with_ratio = cost.total_cost_with / cost.total_cost_without.max(0.01);
846 let with_bar = t.gradient_bar(with_ratio, 26);
847 let saved_pct = if cost.total_cost_without > 0.0 {
848 (1.0 - with_ratio) * 100.0
849 } else {
850 0.0
851 };
852
853 out.push(sec_line(&format!(
854 " {m}Without lean-ctx{rst} {:>10} {without_bar}",
855 format_usd(cost.total_cost_without),
856 m = t.muted.fg(),
857 )));
858 out.push(sec_line(&format!(
859 " {m}With lean-ctx{rst} {:>10} {with_bar}",
860 format_usd(cost.total_cost_with),
861 m = t.muted.fg(),
862 )));
863 out.push(sec_line(&format!(
864 " {c}{bold}You saved{rst} {c}{bold}{:>10}{rst} {dim}── {saved_pct:.1}% reduction ──{rst}",
865 format_usd(cost.total_saved),
866 c = t.success.fg(),
867 )));
868 out.push(format!(" {}", t.box_bottom_square(w)));
869
870 out.push(String::new());
871
872 if !store.commands.is_empty() {
874 out.push(format!(" {}", t.box_top_labeled(w, "TOP COMMANDS")));
875
876 let mut sorted: Vec<_> = store
877 .commands
878 .iter()
879 .filter(|(_, s)| s.input_tokens > s.output_tokens)
880 .collect();
881 sorted.sort_by(|a, b2| {
882 let sa = cmd_total_saved(a.1, &cost_model);
883 let sb = cmd_total_saved(b2.1, &cost_model);
884 sb.cmp(&sa)
885 });
886
887 let max_cmd_saved = sorted
888 .first()
889 .map_or(1, |(_, s)| cmd_total_saved(s, &cost_model))
890 .max(1);
891
892 for (cmd, stats) in sorted.iter().take(10) {
893 let cmd_saved = cmd_total_saved(stats, &cost_model);
894 let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
895 let cmd_pct = if stats.input_tokens > 0 {
896 cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
897 } else {
898 0.0
899 };
900 let ratio = cmd_saved as f64 / max_cmd_saved as f64;
901 let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
902 let pc = t.pct_color(cmd_pct);
903 let cmd_col = theme::pad_right(
904 &format!("{m}{}{rst}", truncate_cmd(cmd, 14), m = t.muted.fg()),
905 16,
906 );
907 let saved_col =
908 theme::pad_right(&format!("{bold}{pc}{}{rst}", format_big(cmd_saved)), 7);
909 let row = format!(
910 " {cmd_col} {:>4}x {bar} {saved_col}{dim}{cmd_pct:>3.0}%{rst}",
911 stats.count,
912 );
913 out.push(sec_line(&row));
914 }
915
916 if sorted.len() > 10 {
917 out.push(sec_line(&format!(
918 " {dim}... +{} more commands{rst}",
919 sorted.len() - 10
920 )));
921 }
922 out.push(format!(" {}", t.box_bottom_square(w)));
923 }
924
925 if store.daily.len() >= 2 {
927 out.push(String::new());
928 out.push(format!(" {}", t.box_top_labeled(w, "RECENT DAYS")));
929
930 let max_day_saved = store
931 .daily
932 .iter()
933 .rev()
934 .take(7)
935 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
936 .max()
937 .unwrap_or(1)
938 .max(1);
939
940 let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
941 for day in recent.iter().rev() {
942 let day_saved = day_total_saved(day, &cost_model);
943 let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
944 let day_pct = if day.input_tokens > 0 {
945 day_input_saved as f64 / day.input_tokens as f64 * 100.0
946 } else {
947 0.0
948 };
949 let pc = t.pct_color(day_pct);
950 let ratio = day_input_saved as f64 / max_day_saved as f64;
951 let day_bar = t.gradient_bar(ratio, 20);
952 let date_short = day.date.get(5..).unwrap_or(&day.date);
953 let date_col = theme::pad_right(&format!("{m}{date_short}{rst}", m = t.muted.fg()), 7);
954 let saved_col =
955 theme::pad_right(&format!("{pc}{bold}{}{rst}", format_big(day_saved)), 9);
956 out.push(sec_line(&format!(
957 " {date_col} {:>4} cmds {saved_col} {pc}{day_pct:>5.1}%{rst} {day_bar}",
958 day.commands,
959 )));
960 }
961 out.push(format!(" {}", t.box_bottom_square(w)));
962 }
963
964 if with_footer {
965 append_gain_footer(&mut out, t, &store);
966 }
967
968 out.join("\n")
969}
970
971fn append_gain_footer(out: &mut Vec<String>, t: &Theme, store: &StatsStore) {
975 let rst = theme::rst();
976 let bold = theme::bold();
977
978 out.push(String::new());
979 out.push(String::new());
980
981 if let Some(tip) = contextual_tip(store) {
982 out.push(format!(" {w}💡 {tip}{rst}", w = t.warning.fg()));
983 out.push(String::new());
984 }
985
986 {
987 let project_root = std::env::current_dir()
988 .map(|p| p.to_string_lossy().to_string())
989 .unwrap_or_default();
990 if !project_root.is_empty() {
991 let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
992 if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
993 let a = t.accent.fg();
994 out.push(format!(" {a}🧠 Bug Memory{rst}"));
995 out.push(format!(
996 " {m} Active gotchas: {}{rst} Bugs prevented: {}{rst}",
997 gotcha_store.gotchas.len(),
998 gotcha_store.stats.total_prevented,
999 m = t.muted.fg(),
1000 ));
1001 out.push(String::new());
1002 }
1003 }
1004 }
1005
1006 {
1007 let project_root = std::env::current_dir()
1008 .map(|p| p.to_string_lossy().to_string())
1009 .unwrap_or_default();
1010 let a = t.accent.fg();
1011 let m = t.muted.fg();
1012
1013 let mut ctx_items: Vec<String> = Vec::new();
1014
1015 if let Some(session) =
1016 crate::core::session::SessionState::load_latest_for_project_root(&project_root)
1017 {
1018 let task_str = session
1019 .task
1020 .as_ref()
1021 .map_or("—", |tk| tk.description.as_str());
1022 let task_disp = if task_str.len() > 35 {
1023 format!("{}…", &task_str[..task_str.floor_char_boundary(32)])
1024 } else {
1025 task_str.to_string()
1026 };
1027 ctx_items.push(format!(
1028 " Session: {bold}{task_disp}{rst} {m}files={} findings={} terse={}{rst}",
1029 session.files_touched.len(),
1030 session.findings.len(),
1031 if session.terse_mode { "on" } else { "off" },
1032 ));
1033 }
1034
1035 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
1036 let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
1037 if active_facts > 0 {
1038 ctx_items.push(format!(
1039 " Knowledge: {bold}{active_facts}{rst} active facts {m}{} total{rst}",
1040 knowledge.facts.len(),
1041 ));
1042 }
1043
1044 if let Some(open) = crate::core::graph_provider::open_best_effort(&project_root) {
1045 let nc = open.provider.node_count().unwrap_or(0);
1046 let ec = open.provider.edge_count().unwrap_or(0);
1047 if nc > 0 {
1048 let (unit, suffix) = match open.source {
1049 crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
1050 ("nodes", "")
1051 }
1052 crate::core::graph_provider::GraphProviderSource::GraphIndex => {
1053 let max_cfg = crate::core::config::Config::load().graph_index_max_files;
1054 if max_cfg > 0 && nc >= max_cfg as usize {
1055 ("files", " (limit reached)")
1056 } else {
1057 ("files", "")
1058 }
1059 }
1060 };
1061 ctx_items.push(format!(
1062 " Graph: {bold}{nc}{rst} {unit} {bold}{ec}{rst} edges{suffix}",
1063 ));
1064 }
1065 }
1066
1067 #[cfg(unix)]
1068 let daemon_running = crate::daemon::is_daemon_running();
1069 #[cfg(not(unix))]
1070 let daemon_running = false;
1071
1072 if daemon_running {
1073 ctx_items.push(format!(" Daemon: {c}running{rst}", c = t.success.fg()));
1074 } else {
1075 ctx_items.push(format!(
1076 " {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
1077 w = t.warning.fg()
1078 ));
1079 }
1080
1081 if !ctx_items.is_empty() {
1082 out.push(format!(" {a}⚡ Context OS{rst}"));
1083 for item in &ctx_items {
1084 out.push(format!(" {item}"));
1085 }
1086 out.push(String::new());
1087 }
1088 }
1089
1090 let m = t.muted.fg();
1091 out.push(format!(
1092 " {m}🐛 Found a bug? Run: lean-ctx report-issue{rst}"
1093 ));
1094 out.push(format!(
1095 " {m}📊 Help improve lean-ctx: lean-ctx contribute{rst}"
1096 ));
1097 out.push(format!(" {m}🧠 View bug memory: lean-ctx gotchas{rst}"));
1098
1099 out.push(String::new());
1100 out.push(String::new());
1101}
1102
1103fn trend_string(store: &StatsStore, up_color: &str, down_color: &str, rst: &str) -> String {
1104 if store.daily.len() < 14 {
1105 return String::new();
1106 }
1107 let recent_7: u64 = store
1108 .daily
1109 .iter()
1110 .rev()
1111 .take(7)
1112 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
1113 .sum();
1114 let prev_7: u64 = store
1115 .daily
1116 .iter()
1117 .rev()
1118 .skip(7)
1119 .take(7)
1120 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
1121 .sum();
1122 if prev_7 == 0 {
1123 return String::new();
1124 }
1125 let change = ((recent_7 as f64 / prev_7 as f64) - 1.0) * 100.0;
1126 if change >= 0.0 {
1127 format!("{up_color}+{change:.0}%{rst} vs last week")
1128 } else {
1129 format!("{down_color}{change:.0}%{rst} vs last week")
1130 }
1131}
1132
1133fn contextual_tip(store: &StatsStore) -> Option<String> {
1134 let tips = build_tips(store);
1135 if tips.is_empty() {
1136 return None;
1137 }
1138 let seed = std::time::SystemTime::now()
1139 .duration_since(std::time::UNIX_EPOCH)
1140 .unwrap_or_default()
1141 .as_secs()
1142 / 86400;
1143 Some(tips[(seed as usize) % tips.len()].clone())
1144}
1145
1146fn build_tips(store: &StatsStore) -> Vec<String> {
1147 let mut tips = Vec::new();
1148
1149 if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
1150 tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
1151 }
1152
1153 if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
1154 tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
1155 }
1156
1157 if store.cep.total_cache_reads > 0
1158 && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
1159 {
1160 tips.push(
1161 "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
1162 );
1163 }
1164
1165 if store.total_commands > 50 && store.cep.sessions == 0 {
1166 tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
1167 }
1168
1169 if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
1170 tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
1171 }
1172
1173 if store.daily.len() >= 7 {
1174 tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
1175 }
1176
1177 tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
1178 tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
1179
1180 let cfg = crate::core::config::Config::load();
1181 if cfg.theme == "default" {
1182 tips.push(
1183 "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
1184 );
1185 tips.push(
1186 "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
1187 );
1188 } else {
1189 tips.push(format!(
1190 "Current theme: {}. Run lean-ctx theme list to explore others.",
1191 cfg.theme
1192 ));
1193 }
1194
1195 tips.push(
1196 "Create a custom theme: write a TOML file and import it with lean-ctx theme import <file>"
1197 .into(),
1198 );
1199
1200 tips
1201}
1202
1203pub fn gain_live() {
1205 use std::io::Write;
1206
1207 let interval = std::time::Duration::from_secs(1);
1208 let mut line_count = 0usize;
1209 let dim = theme::dim();
1210 let rst = theme::rst();
1211
1212 tracing::info!("Live mode (1s refresh) · Ctrl+C to exit");
1213
1214 loop {
1215 if line_count > 0 {
1216 print!("\x1B[{line_count}A\x1B[J");
1217 }
1218
1219 let tick = std::time::SystemTime::now()
1220 .duration_since(std::time::UNIX_EPOCH)
1221 .ok()
1222 .map(|d| d.as_millis() as u64);
1223 let output = format_gain_themed_at(&active_theme(), tick);
1224 let footer = format!("\n {dim}▸ Live · updates every 1s · Ctrl+C to exit{rst}\n");
1225 let full = format!("{output}{footer}");
1226 line_count = full.lines().count();
1227
1228 print!("{full}");
1229 let _ = std::io::stdout().flush();
1230
1231 std::thread::sleep(interval);
1232 }
1233}
1234
1235#[allow(clippy::many_single_char_names)] pub fn format_gain_graph() -> String {
1238 let theme = active_theme();
1239 let store = super::load();
1240 let rst = theme::rst();
1241 let bold = theme::bold();
1242 let dim = theme::dim();
1243
1244 if store.daily.is_empty() {
1245 return format!(
1246 "{dim}No daily data yet.{rst} Use lean-ctx for a few days to see the graph."
1247 );
1248 }
1249
1250 let cm = CostModel::default();
1251 let days: Vec<_> = store
1252 .daily
1253 .iter()
1254 .rev()
1255 .take(30)
1256 .collect::<Vec<_>>()
1257 .into_iter()
1258 .rev()
1259 .collect();
1260
1261 let savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1262
1263 let max_saved = *savings.iter().max().unwrap_or(&1);
1264 let max_saved = max_saved.max(1);
1265
1266 let bar_width = 36;
1267 let mut out = Vec::new();
1268
1269 out.push(String::new());
1270 out.push(format!(
1271 " {icon} {title} {dim}Token Savings Graph (last 30 days){rst}",
1272 icon = theme.header_icon(),
1273 title = theme.brand_title(),
1274 ));
1275 out.push(format!(" {ln}", ln = theme.border_line(58)));
1276 out.push(format!(
1277 " {dim}{:>58}{rst}",
1278 format!("peak: {}", format_big(max_saved))
1279 ));
1280 out.push(String::new());
1281
1282 for (i, day) in days.iter().enumerate() {
1283 let saved = savings[i];
1284 let ratio = saved as f64 / max_saved as f64;
1285 let bar = theme::pad_right(&theme.gradient_bar(ratio, bar_width), bar_width);
1286
1287 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1288 let pct = if day.input_tokens > 0 {
1289 input_saved as f64 / day.input_tokens as f64 * 100.0
1290 } else {
1291 0.0
1292 };
1293 let date_short = day.date.get(5..).unwrap_or(&day.date);
1294
1295 out.push(format!(
1296 " {m}{date_short}{rst} {brd}│{rst} {bar} {bold}{:>6}{rst} {dim}{pct:.0}%{rst}",
1297 format_big(saved),
1298 m = theme.muted.fg(),
1299 brd = theme.border.fg(),
1300 ));
1301 }
1302
1303 let total_saved: u64 = savings.iter().sum();
1304 let total_cmds: u64 = days.iter().map(|day| day.commands).sum();
1305 let spark = theme.gradient_sparkline(&savings);
1306
1307 out.push(String::new());
1308 out.push(format!(" {ln}", ln = theme.border_line(58)));
1309 out.push(format!(
1310 " {spark} {bold}{txt}{}{rst} saved across {bold}{}{rst} commands",
1311 format_big(total_saved),
1312 format_num(total_cmds),
1313 txt = theme.text.fg(),
1314 ));
1315 out.push(String::new());
1316
1317 out.join("\n")
1318}
1319
1320#[allow(clippy::many_single_char_names)] pub fn format_gain_daily() -> String {
1323 let theme = active_theme();
1324 let store = super::load();
1325 let rst = theme::rst();
1326 let bold = theme::bold();
1327 let dim = theme::dim();
1328
1329 if store.daily.is_empty() {
1330 return format!("{dim}No daily data yet.{rst}");
1331 }
1332
1333 let mut out = Vec::new();
1334 let w = 76;
1335
1336 let side = theme.box_side();
1337 let daily_box = |content: &str| -> String {
1338 let padded = theme::pad_right(content, w);
1339 format!(" {side}{padded}{side}")
1340 };
1341
1342 out.push(String::new());
1343 out.push(format!(
1344 " {icon} {title} {dim}Daily Breakdown{rst}",
1345 icon = theme.header_icon(),
1346 title = theme.brand_title(),
1347 ));
1348 out.push(format!(" {}", theme.box_top(w)));
1349 let hdr = format!(
1350 " {bold}{txt}{:<12} {:>6} {:>10} {:>10} {:>7} {:>8} {:>8}{rst}",
1351 "Date",
1352 "Cmds",
1353 "Input",
1354 "Saved",
1355 "Rate",
1356 "USD",
1357 "Ver",
1358 txt = theme.text.fg(),
1359 );
1360 out.push(daily_box(&hdr));
1361 out.push(format!(" {}", theme.box_mid(w)));
1362
1363 let days: Vec<_> = store
1364 .daily
1365 .iter()
1366 .rev()
1367 .take(30)
1368 .collect::<Vec<_>>()
1369 .into_iter()
1370 .rev()
1371 .cloned()
1372 .collect();
1373
1374 let cm = CostModel::default();
1375 for day in &days {
1376 let saved = day_total_saved(day, &cm);
1377 let input_saved = day.input_tokens.saturating_sub(day.output_tokens);
1378 let pct = if day.input_tokens > 0 {
1379 input_saved as f64 / day.input_tokens as f64 * 100.0
1380 } else {
1381 0.0
1382 };
1383 let pc = theme.pct_color(pct);
1384 let usd = usd_estimate(saved);
1385 let ver = if day.version.is_empty() {
1386 "—".to_string()
1387 } else {
1388 format!("v{}", day.version)
1389 };
1390 let row = format!(
1391 " {m}{:<12}{rst} {:>6} {:>10} {pc}{bold}{:>10}{rst} {pc}{:>6.1}%{rst} {dim}{:>8}{rst} {dim}{:>8}{rst}",
1392 &day.date,
1393 day.commands,
1394 format_big(day.input_tokens),
1395 format_big(saved),
1396 pct,
1397 usd,
1398 ver,
1399 m = theme.muted.fg(),
1400 );
1401 out.push(daily_box(&row));
1402 }
1403
1404 let total_input: u64 = store.daily.iter().map(|day| day.input_tokens).sum();
1405 let total_saved: u64 = store
1406 .daily
1407 .iter()
1408 .map(|day| day_total_saved(day, &cm))
1409 .sum();
1410 let total_pct = if total_input > 0 {
1411 let input_saved: u64 = store
1412 .daily
1413 .iter()
1414 .map(|day| day.input_tokens.saturating_sub(day.output_tokens))
1415 .sum();
1416 input_saved as f64 / total_input as f64 * 100.0
1417 } else {
1418 0.0
1419 };
1420 let total_usd = usd_estimate(total_saved);
1421 let sc = theme.success.fg();
1422
1423 out.push(format!(" {}", theme.box_mid(w)));
1424 let total_row = format!(
1425 " {bold}{txt}{:<12}{rst} {:>6} {:>10} {sc}{bold}{:>10}{rst} {sc}{bold}{:>6.1}%{rst} {bold}{:>8}{rst} {bold}{:>8}{rst}",
1426 "TOTAL",
1427 format_num(store.total_commands),
1428 format_big(total_input),
1429 format_big(total_saved),
1430 total_pct,
1431 total_usd,
1432 "",
1433 txt = theme.text.fg(),
1434 );
1435 out.push(daily_box(&total_row));
1436 out.push(format!(" {}", theme.box_bottom(w)));
1437
1438 let daily_savings: Vec<u64> = days.iter().map(|day| day_total_saved(day, &cm)).collect();
1439 let spark = theme.gradient_sparkline(&daily_savings);
1440 out.push(format!(" {dim}Trend:{rst} {spark}"));
1441 out.push(String::new());
1442
1443 out.join("\n")
1444}
1445
1446pub fn format_gain_json() -> String {
1448 let store = super::load();
1449 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1450}