Skip to main content

lean_ctx/core/stats/
format.rs

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)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
119fn 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/// Renders the full CEP (Cognitive Efficiency Protocol) report with themes.
199#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
200pub 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    // `cep_share` is already a percentage (0..100), so the shell share is its
329    // complement — not `(1.0 - cep_share) * 100`, which produced absurd values.
330    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        // Cap to the most recent points so the sparkline fits inside the box.
410        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
459/// Renders the token savings dashboard using the active theme.
460pub fn format_gain() -> String {
461    format_gain_themed(&active_theme())
462}
463
464/// Renders the token savings dashboard with a specific theme.
465pub fn format_gain_themed(t: &Theme) -> String {
466    format_gain_themed_at(t, None)
467}
468
469/// Renders the concise "hero" gain output — 3 key metrics, gain score, trend, next actions.
470pub fn format_gain_hero() -> String {
471    format_gain_hero_themed(&active_theme())
472}
473
474/// Hero gain with specific theme.
475pub 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    // Weekly nudge: after 7 days of data, if user hasn't published, show a prominent card
578    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
626/// Renders the token savings dashboard at a specific animation tick (with footer).
627pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
628    gain_dashboard(t, tick, true)
629}
630
631/// The dashboard body without the trailing footer (tips / Context OS / hints).
632/// Used to compose `gain --deep`, where the extra themed sections must appear
633/// before the footer instead of in the middle of the output.
634pub fn format_gain_body() -> String {
635    gain_dashboard(&active_theme(), None, false)
636}
637
638/// The standalone gain dashboard footer (contextual tip, Context OS, hints).
639pub 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)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
647fn 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    // -- GAIN SCORE section (labeled box) --
780    {
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    // -- COMPANION section --
820    {
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    // -- COST BREAKDOWN section --
837    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    // -- TOP COMMANDS section --
873    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    // -- RECENT DAYS section --
926    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
971/// Appends the dashboard footer (contextual tip, Bug Memory, Context OS panel,
972/// help hints). Kept separate so `gain --deep` can render it *after* the extra
973/// themed sections instead of in the middle of the output.
974fn 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
1203/// Runs the live-updating gain dashboard (1s refresh loop, Ctrl+C to exit).
1204pub 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/// Renders a 30-day token savings bar chart with sparkline.
1236#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
1237pub 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/// Renders a daily breakdown table of token savings with totals.
1321#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
1322pub 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
1446/// Returns the full stats store as pretty-printed JSON.
1447pub fn format_gain_json() -> String {
1448    let store = super::load();
1449    serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
1450}