Skip to main content

lean_ctx/core/stats/format/
dashboard.rs

1//! The `gain` hero dashboard: themed savings panels, footer, tips, live mode.
2
3use super::util::{
4    active_theme, cmd_total_saved, day_total_saved, format_big, format_num, format_usd,
5    measured_compression_rate, truncate_cmd,
6};
7use crate::core::theme::{self, Theme};
8
9use super::super::model::{CostModel, StatsStore};
10
11pub fn format_gain() -> String {
12    format_gain_themed(&active_theme())
13}
14
15/// Renders the token savings dashboard with a specific theme.
16pub fn format_gain_themed(t: &Theme) -> String {
17    format_gain_themed_at(t, None)
18}
19
20/// Renders the concise "hero" gain output — 3 key metrics, gain score, trend, next actions.
21pub fn format_gain_hero() -> String {
22    format_gain_hero_themed(&active_theme())
23}
24
25/// Hero gain with specific theme.
26pub fn format_gain_hero_themed(t: &Theme) -> String {
27    // Aggregate across split data dirs so an MCP-server/CLI XDG split does not
28    // hide savings behind a false `0` (#500).
29    let store = crate::core::stats::load_for_display();
30    let rst = theme::rst();
31    let bold = theme::bold();
32    let dim = theme::dim();
33
34    if store.total_commands == 0 {
35        return format_gain_themed_at(t, None);
36    }
37
38    let input_saved = store
39        .total_input_tokens
40        .saturating_sub(store.total_output_tokens);
41    let pct = if store.total_input_tokens > 0 {
42        input_saved as f64 / store.total_input_tokens as f64 * 100.0
43    } else {
44        0.0
45    };
46    let cost_model = CostModel::default();
47    let cost = cost_model.calculate(&store);
48
49    let engine = crate::core::gain::GainEngine::load();
50    // One summary load powers both the score panel and the net-of-injection
51    // reconciliation below (#361) — no double compute.
52    let summary = engine.summary(None);
53    let score = &summary.score;
54
55    let w = 57;
56    let side = t.box_side();
57    let box_line = |content: &str| -> String {
58        let padded = theme::pad_right(content, w);
59        format!("  {side}{padded}{side}")
60    };
61
62    let mut out = Vec::new();
63    out.push(String::new());
64    out.push(format!("  {}", t.box_top(w)));
65    out.push(box_line(&format!(
66        "  {icon}  {title}",
67        icon = t.header_icon(),
68        title = t.brand_title(),
69    )));
70    out.push(box_line(""));
71
72    let c1 = t.success.fg();
73    let c2 = t.secondary.fg();
74    let c4 = t.accent.fg();
75    let tok_val = format_big(input_saved);
76    let pct_val = format!("{pct:.0}%");
77    let usd_val = format_usd(cost.total_saved);
78
79    let kw = 18;
80    let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
81    let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
82    let v3 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
83    out.push(box_line(&format!("  {v1}{v2}{v3}")));
84
85    let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
86    let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
87    let ul3 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
88    out.push(box_line(&format!("  {ul1}{ul2}{ul3}")));
89
90    let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
91    let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
92    let l3 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
93    out.push(box_line(&format!("  {l1}{l2}{l3}")));
94    out.push(box_line(""));
95
96    let score_bar_w = 30;
97    let score_ratio = (score.total as f64 / 100.0).min(1.0);
98    let bar = t.gradient_bar(score_ratio, score_bar_w);
99    let sc_color = t.pct_color(score.total as f64);
100    let lvl = score.level();
101    out.push(box_line(&format!(
102        "  {bar}  {sc_color}{bold}{}/100{rst}  Lv{} {dim}{}{rst}",
103        score.total, lvl.level, lvl.title,
104    )));
105    out.push(box_line(""));
106
107    if store.daily.len() >= 2 {
108        let daily_savings: Vec<u64> = store
109            .daily
110            .iter()
111            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
112            .collect();
113        let spark = t.gradient_sparkline(&daily_savings);
114        let trend_str = trend_string(&store, &c1, &t.warning.fg(), rst);
115        out.push(box_line(&format!(
116            "  {dim}trend:{rst} {spark}  {trend_str}"
117        )));
118    }
119
120    if input_saved > 0 {
121        let energy_str = crate::core::energy::format_for_tokens(input_saved);
122        let charges = crate::core::energy::phone_charges_hint(input_saved)
123            .map(|h| format!(" ({h})"))
124            .unwrap_or_default();
125        out.push(box_line(&format!(
126            "  {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
127        )));
128    }
129
130    // Net-of-injection honesty (#361): the headline above is gross savings on
131    // lean-ctx-touched traffic. lean-ctx also injects a fixed per-turn prefix
132    // that, without provider prompt caching, is re-billed every turn — so the
133    // honest bill impact is gross minus that tax. Show it in the default view,
134    // not just in `--json` / `--deep`, so the hero never overstates the effect.
135    if summary.turns > 0 {
136        let net = summary.net_tokens_saved;
137        let net_str = format_big(net.unsigned_abs());
138        let sign = if net < 0 { "-" } else { "" };
139        let net_col = if net < 0 { t.warning.fg() } else { c1.clone() };
140        out.push(box_line(""));
141        out.push(box_line(&format!(
142            "  {dim}net of injection:{rst} {net_col}{bold}{sign}{net_str}{rst} {dim}(− {tax} tax · {turns} turns){rst}",
143            tax = format_big(summary.injected_overhead_total_tokens),
144            turns = summary.turns,
145        )));
146    } else if summary.injected_overhead_tokens_per_turn > 0 {
147        out.push(box_line(""));
148        out.push(box_line(&format!(
149            "  {dim}injection:{rst} {dim}+{op}/turn fixed (net = gross; proxy not in path){rst}",
150            op = format_big(summary.injected_overhead_tokens_per_turn),
151        )));
152    }
153
154    out.push(format!("  {}", t.box_bottom(w)));
155    // One-line methodology so the headline is never read as the whole bill.
156    out.push(format!(
157        "  {dim}savings = compression on lean-ctx-touched traffic, not your full provider bill · details: lean-ctx gain --deep{rst}"
158    ));
159    out.push(String::new());
160
161    // Weekly nudge: after 7 days of data, if user hasn't published, show a prominent card
162    if store.daily.len() >= 7 && !crate::cli::wrapped_publish::has_published() {
163        let week_saved: u64 = store
164            .daily
165            .iter()
166            .rev()
167            .take(7)
168            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
169            .sum();
170        if week_saved > 0 {
171            let accent = t.accent.fg();
172            let nw = 42;
173            let nside = t.box_side();
174            // Pad to visual width: the token count is variable-length and the
175            // labels carry ANSI colour, so hardcoded spacing skews the border.
176            let nudge_line = |content: &str| -> String {
177                format!("  {nside}{}{nside}", theme::pad_right(content, nw))
178            };
179            out.push(format!("  {}", t.box_top(nw)));
180            out.push(nudge_line(&format!(" {accent}{bold}Your first week!{rst}")));
181            out.push(nudge_line(&format!(
182                " You saved {c1}{bold}{}{rst} tokens this week.",
183                crate::core::wrapped::format_tokens(week_saved),
184            )));
185            out.push(nudge_line(&format!(
186                " Share your card? {sec}lean-ctx gain --wrapped{rst}",
187                sec = t.secondary.fg(),
188            )));
189            out.push(format!("  {}", t.box_bottom(nw)));
190            out.push(String::new());
191        }
192    }
193
194    let sec = t.secondary.fg();
195    out.push(format!(
196        "  {sec}lean-ctx gain --deep{rst}     {dim}Full breakdown{rst}"
197    ));
198    out.push(format!(
199        "  {sec}lean-ctx gain --wrapped{rst}  {dim}Shareable card{rst}"
200    ));
201    out.push(format!(
202        "  {sec}lean-ctx watch{rst}           {dim}Live observatory{rst}"
203    ));
204    out.push(String::new());
205
206    if let Some(tip) = contextual_tip(&store) {
207        out.push(format!("  {dim}💡 {tip}{rst}"));
208        out.push(String::new());
209    }
210
211    out.join("\n")
212}
213
214/// Renders the token savings dashboard at a specific animation tick (with footer).
215pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
216    let store = crate::core::stats::load_for_display();
217    render_gain_dashboard_for_store(t, tick, true, &store)
218}
219
220/// The dashboard body without the trailing footer (tips / Context OS / hints).
221/// Used to compose `gain --deep`, where the extra themed sections must appear
222/// before the footer instead of in the middle of the output.
223pub fn format_gain_body() -> String {
224    let store = crate::core::stats::load_for_display();
225    render_gain_dashboard_for_store(&active_theme(), None, false, &store)
226}
227
228/// The standalone gain dashboard footer (contextual tip, Context OS, hints).
229pub fn format_gain_footer() -> String {
230    let store = crate::core::stats::load_for_display();
231    let mut out = Vec::new();
232    append_gain_footer(&mut out, &active_theme(), &store);
233    out.join("\n")
234}
235
236#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
237fn render_gain_dashboard_for_store(
238    t: &Theme,
239    tick: Option<u64>,
240    with_footer: bool,
241    store: &StatsStore,
242) -> String {
243    let mut out = Vec::new();
244    let rst = theme::rst();
245    let bold = theme::bold();
246    let dim = theme::dim();
247
248    if store.total_commands == 0 {
249        let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
250            Ok(p) => p.display().to_string(),
251            Err(_) => "~/.config/lean-ctx".into(),
252        };
253        // `mcp-live.json` is STATE (GH #408); read it from the state dir.
254        let mcp_live = crate::core::paths::state_dir().map_or_else(
255            |_| std::path::Path::new(&data_dir).join("mcp-live.json"),
256            |d| d.join("mcp-live.json"),
257        );
258        let mcp_hint = if let Ok(live) = std::fs::read_to_string(&mcp_live) {
259            if live.contains("\"total_calls\"") {
260                format!(
261                    "\n{dim}MCP calls are tracked in mcp-live.json but stats.json is empty.{rst}\
262                     \n{dim}This may indicate a data directory split. Run: lean-ctx doctor{rst}"
263                )
264            } else {
265                String::new()
266            }
267        } else {
268            String::new()
269        };
270        let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
271        let split_hint = if split_dirs.len() >= 2 {
272            format!(
273                "\n{dim}⚠ Stats found in multiple locations:{rst}\
274                 \n{dim}  {}{rst}\
275                 \n{dim}Run: lean-ctx doctor{rst}",
276                split_dirs
277                    .iter()
278                    .map(|d| d.display().to_string())
279                    .collect::<Vec<_>>()
280                    .join(", ")
281            )
282        } else {
283            String::new()
284        };
285        // Cross-check the tamper-evident savings ledger (#500): if it recorded
286        // events while stats.json stayed empty, the MCP server and the CLI are
287        // almost certainly resolving different data dirs — name that explicitly
288        // instead of the bare "expected" message so the user can act.
289        let ledger = crate::core::savings_ledger::summary();
290        let ledger_hint = if ledger.total_events > 0 {
291            format!(
292                "\n{dim}⚠ {} savings events (~{} tokens) ARE in the ledger but not in stats.json —{rst}\
293                 \n{dim}  the MCP server likely writes to a different data dir than this CLI.{rst}\
294                 \n{dim}  Inspect: lean-ctx savings   ·   Diagnose: lean-ctx doctor{rst}",
295                ledger.total_events,
296                crate::core::wrapped::format_tokens(ledger.saved_tokens),
297            )
298        } else {
299            String::new()
300        };
301        return format!(
302            "{bold}No savings recorded yet — and that's expected.{rst}\
303             \n\n  {dim}Savings appear after your AI tool uses lean-ctx for the first time.{rst}\
304             \n\n  Next:\
305             \n    1. Make sure your AI tool is connected:  {cmd}lean-ctx doctor{rst}\
306             \n    2. Fully restart your AI tool so it reconnects to lean-ctx.\
307             \n    3. Ask it to read a file or run a command — then check back here.\
308             \n\n  {dim}Tip: track a shell command yourself with {rst}{cmd}lean-ctx -c \"git status\"{rst}\
309             \n\n  {dim}Stats path: {data_dir}{rst}{mcp_hint}{split_hint}{ledger_hint}",
310            cmd = t.secondary.fg(),
311        );
312    }
313
314    let input_saved = store
315        .total_input_tokens
316        .saturating_sub(store.total_output_tokens);
317    let pct = if store.total_input_tokens > 0 {
318        input_saved as f64 / store.total_input_tokens as f64 * 100.0
319    } else {
320        0.0
321    };
322    let cost_model = CostModel::default();
323    let cost = cost_model.calculate(store);
324    let total_saved = input_saved;
325    let _days_active = store.daily.len();
326
327    let w = 70;
328    let side = t.box_side();
329    let ss = t.box_side_square();
330
331    let box_line = |content: &str| -> String {
332        let padded = theme::pad_right(content, w);
333        format!("  {side}{padded}{side}")
334    };
335    let sec_line = |content: &str| -> String {
336        let padded = theme::pad_right(content, w);
337        format!("  {ss}{padded}{ss}")
338    };
339
340    out.push(String::new());
341    out.push(format!("  {}", t.box_top(w)));
342    out.push(box_line(""));
343
344    let ver = env!("CARGO_PKG_VERSION");
345    let header = format!(
346        "     {icon}  {bold}{title}{rst}",
347        icon = t.header_icon(),
348        title = t.brand_title(),
349    );
350    let ver_part = format!("{dim}v{ver}{rst}");
351    let header_padded = theme::pad_right(&header, w - ver.len() - 2);
352    out.push(format!("  {side}{header_padded}{ver_part} {side}"));
353
354    let subtitle = format!("     {dim}Token Savings Dashboard{rst}");
355    out.push(box_line(&subtitle));
356    out.push(box_line(""));
357    out.push(format!("  {}", t.box_mid(w)));
358    out.push(box_line(""));
359
360    let tok_val = format_big(total_saved);
361    let pct_val = format!("{pct:.1}%");
362    let cmd_val = format_num(store.total_commands);
363    let usd_val = format_usd(cost.total_saved);
364
365    let c1 = t.success.fg();
366    let c2 = t.secondary.fg();
367    let c3 = t.warning.fg();
368    let c4 = t.accent.fg();
369
370    let kw = 16;
371    let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
372    let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
373    let v3 = theme::pad_right(&format!("{c3}{bold}{cmd_val}{rst}"), kw);
374    let v4 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
375    out.push(box_line(&format!("     {v1}{v2}{v3}{v4}")));
376
377    let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
378    let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
379    let ul3 = theme::pad_right(&t.kpi_underline(cmd_val.len(), &t.warning), kw);
380    let ul4 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
381    out.push(box_line(&format!("     {ul1}{ul2}{ul3}{ul4}")));
382
383    let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
384    let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
385    let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
386    let l4 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
387    out.push(box_line(&format!("     {l1}{l2}{l3}{l4}")));
388    out.push(box_line(""));
389    out.push(format!("  {}", t.box_bottom(w)));
390    out.push(String::new());
391
392    // -- GAIN SCORE section (labeled box) --
393    {
394        let engine = crate::core::gain::GainEngine::load();
395        let score = engine.gain_score(None);
396        let lvl = score.level();
397        let score_ratio = (score.total as f64 / 100.0).min(1.0);
398        let bar = t.gradient_bar(score_ratio, 30);
399        let sc_color = t.pct_color(score.total as f64);
400
401        out.push(format!("  {}", t.box_top_labeled(w, "GAIN SCORE")));
402        out.push(sec_line(&format!(
403            "  {bar}  {sc_color}{bold}{}/100{rst}  Lv{} {dim}{}{rst}",
404            score.total, lvl.level, lvl.title,
405        )));
406        out.push(sec_line(""));
407
408        if store.daily.len() >= 2 {
409            let daily_savings: Vec<u64> = store
410                .daily
411                .iter()
412                .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
413                .collect();
414            let spark = t.gradient_sparkline(&daily_savings);
415            let trend_str = trend_string(store, &c1, &t.warning.fg(), rst);
416            out.push(sec_line(&format!(
417                "  {dim}trend:{rst} {spark}  {trend_str}"
418            )));
419        }
420
421        if total_saved > 0 {
422            let energy_str = crate::core::energy::format_for_tokens(total_saved);
423            let charges = crate::core::energy::phone_charges_hint(total_saved)
424                .map(|h| format!(" ({h})"))
425                .unwrap_or_default();
426            out.push(sec_line(&format!(
427                "  {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
428            )));
429        }
430        out.push(format!("  {}", t.box_bottom_square(w)));
431    }
432
433    // -- COMPANION section --
434    {
435        let cfg = crate::core::config::Config::load();
436        if cfg.buddy_enabled {
437            out.push(String::new());
438            out.push(format!("  {}", t.box_top_labeled(w, "YOUR COMPANION")));
439            let buddy = crate::core::buddy::BuddyState::compute();
440            let block = crate::core::buddy::format_buddy_block_at(&buddy, t, tick);
441            for line in block.lines() {
442                out.push(sec_line(line));
443            }
444            out.push(format!("  {}", t.box_bottom_square(w)));
445        }
446    }
447
448    out.push(String::new());
449
450    // -- COST BREAKDOWN section --
451    let price_label = format!(
452        "{} · @ ${:.2}/M input · ${:.2}/M output · {}",
453        cost_model.model_key,
454        cost_model.input_price_per_m,
455        cost_model.output_price_per_m,
456        pricing_match_label(cost_model.pricing_match_kind),
457    );
458    let cost_label = format!("COST BREAKDOWN ──── {price_label}");
459    out.push(format!("  {}", t.box_top_labeled(w, &cost_label)));
460    out.push(sec_line(""));
461    let without_bar = t.gradient_bar(1.0, 26);
462    let with_ratio = cost.total_cost_with / cost.total_cost_without.max(0.01);
463    let with_bar = t.gradient_bar(with_ratio, 26);
464    let saved_pct = if cost.total_cost_without > 0.0 {
465        (1.0 - with_ratio) * 100.0
466    } else {
467        0.0
468    };
469
470    out.push(sec_line(&format!(
471        "  {m}Without lean-ctx{rst}   {:>10}  {without_bar}",
472        format_usd(cost.total_cost_without),
473        m = t.muted.fg(),
474    )));
475    out.push(sec_line(&format!(
476        "  {m}With lean-ctx{rst}      {:>10}  {with_bar}",
477        format_usd(cost.total_cost_with),
478        m = t.muted.fg(),
479    )));
480    out.push(sec_line(&format!(
481        "  {c}{bold}You saved{rst}          {c}{bold}{:>10}{rst}  {dim}── {saved_pct:.1}% reduction ──{rst}",
482        format_usd(cost.total_saved),
483        c = t.success.fg(),
484    )));
485    out.push(format!("  {}", t.box_bottom_square(w)));
486
487    out.push(String::new());
488
489    // -- TOP COMMANDS section --
490    if !store.commands.is_empty() {
491        out.push(format!("  {}", t.box_top_labeled(w, "TOP COMMANDS")));
492        // Build the header from the same column widths as the data rows below,
493        // so labels sit over their columns (1-space lead, runs 6-wide, etc.).
494        let hdr = format!(
495            " {} {:>6}  {} {}{:>4}",
496            theme::pad_right("Command", 16),
497            "Runs",
498            theme::pad_right("Compression", 20),
499            theme::pad_right("Saved", 7),
500            "Rate",
501        );
502        out.push(sec_line(&format!("{dim}{hdr}{rst}", dim = t.muted.fg())));
503
504        let mut sorted: Vec<_> = store
505            .commands
506            .iter()
507            .filter(|(_, s)| s.input_tokens > s.output_tokens)
508            .collect();
509        sorted.sort_by(|a, b2| {
510            let sa = cmd_total_saved(a.1, &cost_model);
511            let sb = cmd_total_saved(b2.1, &cost_model);
512            sb.cmp(&sa)
513        });
514
515        let max_cmd_saved = sorted
516            .first()
517            .map_or(1, |(_, s)| cmd_total_saved(s, &cost_model))
518            .max(1);
519
520        for (cmd, stats) in sorted.iter().take(10) {
521            let cmd_saved = cmd_total_saved(stats, &cost_model);
522            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
523            let cmd_pct = if stats.input_tokens > 0 {
524                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
525            } else {
526                0.0
527            };
528            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
529            let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
530            let pc = t.pct_color(cmd_pct);
531            let cmd_col = theme::pad_right(
532                &format!("{m}{}{rst}", truncate_cmd(cmd, 14), m = t.muted.fg()),
533                16,
534            );
535            let saved_col =
536                theme::pad_right(&format!("{bold}{pc}{}{rst}", format_big(cmd_saved)), 7);
537            let row = format!(
538                " {cmd_col} {:>6}x {bar} {saved_col}{dim}{cmd_pct:>3.0}%{rst}",
539                stats.count,
540            );
541            out.push(sec_line(&row));
542        }
543
544        if sorted.len() > 10 {
545            out.push(sec_line(&format!(
546                "  {dim}... +{} more commands{rst}",
547                sorted.len() - 10
548            )));
549        }
550        out.push(format!("  {}", t.box_bottom_square(w)));
551    }
552
553    // -- RECENT DAYS section --
554    if store.daily.len() >= 2 {
555        out.push(String::new());
556        out.push(format!("  {}", t.box_top_labeled(w, "RECENT DAYS")));
557        out.push(sec_line(&format!(
558            "  {dim}{}{rst}",
559            "Date     Cmds  Observed    Saved     Rate    Trend     Version",
560            dim = t.muted.fg()
561        )));
562
563        let max_day_saved = store
564            .daily
565            .iter()
566            .rev()
567            .take(7)
568            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
569            .max()
570            .unwrap_or(1)
571            .max(1);
572
573        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
574        for day in recent.iter().rev() {
575            let day_saved = day_total_saved(day, &cost_model);
576            let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
577            let day_pct = measured_compression_rate(day.input_tokens, day.output_tokens);
578            let pc = t.pct_color(day_pct.unwrap_or_default());
579            let rate_col = day_pct.map_or_else(
580                || format!("{dim}  n/a {rst}"),
581                |pct| format!("{pc}{pct:>5.1}%{rst}"),
582            );
583            let ratio = day_input_saved as f64 / max_day_saved as f64;
584            // Pad the bar to a fixed width so the trailing version column lines up
585            // (matches the BY COMMAND bar above; gradient_bar can return < width).
586            let day_bar = theme::pad_right(&t.gradient_bar(ratio, 8), 8);
587            let date_short = day.date.get(5..).unwrap_or(&day.date);
588            let date_col = theme::pad_right(&format!("{m}{date_short}{rst}", m = t.muted.fg()), 7);
589            // Per-day observed baseline makes the volume-weighted percentage explicit:
590            // a low rate can reflect tiny or non-reducing calls rather than a regression.
591            let observed_col = theme::pad_right(
592                &format!("{m}{}{rst}", format_big(day.input_tokens), m = t.muted.fg()),
593                11,
594            );
595            let saved_col =
596                theme::pad_right(&format!("{pc}{bold}{}{rst}", format_big(day_saved)), 9);
597            // Per-day version attributes a compression change to a specific
598            // release (#307); pre-tracking days carry no version and show "—".
599            let ver = if day.version.is_empty() {
600                "—".to_string()
601            } else {
602                format!("v{}", day.version)
603            };
604            out.push(sec_line(&format!(
605                "  {date_col} {:>4}  {observed_col} {saved_col} {rate_col}  {day_bar}  {dim}{ver}{rst}",
606                day.commands,
607            )));
608        }
609        out.push(sec_line(&format!(
610            "  {dim}Rate = metered ctx_* baseline → returned tokens; commands include writes.{rst}"
611        )));
612        out.push(sec_line(&format!(
613            "  {dim}Native sed/cat/Bash bypasses are unseen; n/a = no measured baseline.{rst}"
614        )));
615        out.push(format!("  {}", t.box_bottom_square(w)));
616    }
617
618    if with_footer {
619        append_gain_footer(&mut out, t, store);
620    }
621
622    out.join("\n")
623}
624
625/// Appends the dashboard footer (contextual tip, Bug Memory, Context OS panel,
626/// help hints). Kept separate so `gain --deep` can render it *after* the extra
627/// themed sections instead of in the middle of the output.
628fn append_gain_footer(out: &mut Vec<String>, t: &Theme, store: &StatsStore) {
629    let rst = theme::rst();
630    let bold = theme::bold();
631
632    out.push(String::new());
633    out.push(String::new());
634
635    if let Some(tip) = contextual_tip(store) {
636        out.push(format!("    {w}💡 {tip}{rst}", w = t.warning.fg()));
637        out.push(String::new());
638    }
639
640    {
641        let project_root = std::env::current_dir()
642            .map(|p| p.to_string_lossy().to_string())
643            .unwrap_or_default();
644        if !project_root.is_empty() {
645            let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
646            if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
647                let a = t.accent.fg();
648                out.push(format!("    {a}🧠 Bug Memory{rst}"));
649                out.push(format!(
650                    "    {m}   Active gotchas: {}{rst}   Bugs prevented: {}{rst}",
651                    gotcha_store.gotchas.len(),
652                    gotcha_store.stats.total_prevented,
653                    m = t.muted.fg(),
654                ));
655                out.push(String::new());
656            }
657        }
658    }
659
660    {
661        let project_root = std::env::current_dir()
662            .map(|p| p.to_string_lossy().to_string())
663            .unwrap_or_default();
664        let a = t.accent.fg();
665        let m = t.muted.fg();
666
667        let mut ctx_items: Vec<String> = Vec::new();
668
669        if let Some(session) =
670            crate::core::session::SessionState::load_latest_for_project_root(&project_root)
671        {
672            let task_str = session
673                .task
674                .as_ref()
675                .map_or("—", |tk| tk.description.as_str());
676            let task_disp = if task_str.len() > 35 {
677                format!("{}…", &task_str[..task_str.floor_char_boundary(32)])
678            } else {
679                task_str.to_string()
680            };
681            ctx_items.push(format!(
682                "   Session: {bold}{task_disp}{rst}  {m}files={} findings={} terse={}{rst}",
683                session.files_touched.len(),
684                session.findings.len(),
685                if session.terse_mode { "on" } else { "off" },
686            ));
687        }
688
689        let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
690        let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
691        if active_facts > 0 {
692            ctx_items.push(format!(
693                "   Knowledge: {bold}{active_facts}{rst} active facts  {m}{} total{rst}",
694                knowledge.facts.len(),
695            ));
696        }
697
698        if let Some(open) = crate::core::graph_provider::open_best_effort(&project_root) {
699            let nc = open.provider.node_count().unwrap_or(0);
700            let ec = open.provider.edge_count().unwrap_or(0);
701            if nc > 0 {
702                let (unit, suffix) = match open.source {
703                    crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
704                        ("nodes", "")
705                    }
706                    crate::core::graph_provider::GraphProviderSource::GraphIndex => {
707                        let max_cfg = crate::core::config::Config::load().graph_index_max_files;
708                        if max_cfg > 0 && nc >= max_cfg as usize {
709                            ("files", " (limit reached)")
710                        } else {
711                            ("files", "")
712                        }
713                    }
714                };
715                ctx_items.push(format!(
716                    "   Graph: {bold}{nc}{rst} {unit}  {bold}{ec}{rst} edges{suffix}",
717                ));
718            }
719        }
720
721        // is_daemon_running() is cross-platform (reads daemon.pid + ipc::process::is_alive,
722        // which has a Windows OpenProcess impl). The old #[cfg(not(unix))] = false branch
723        // hardcoded "offline" on Windows even when `serve --status` reported the daemon
724        // running — gating away a working check. See #576.
725        let daemon_running = crate::daemon::is_daemon_running();
726
727        if daemon_running {
728            ctx_items.push(format!("   Daemon: {c}running{rst}", c = t.success.fg()));
729        } else {
730            ctx_items.push(format!(
731                "   {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
732                w = t.warning.fg()
733            ));
734        }
735
736        if !ctx_items.is_empty() {
737            out.push(format!("    {a}⚡ Context OS{rst}"));
738            for item in &ctx_items {
739                out.push(format!("    {item}"));
740            }
741            out.push(String::new());
742        }
743    }
744
745    {
746        // Methodology disclosure (#361): the headline measures compression on
747        // lean-ctx-touched traffic, not the full provider bill — and lean-ctx
748        // itself injects a fixed per-turn prefix that, without provider prompt
749        // caching, is re-billed every turn. State both so the number stays honest.
750        let a = t.accent.fg();
751        let m = t.muted.fg();
752        let overhead = crate::core::context_overhead::ContextOverhead::cached();
753        out.push(format!("    {a}📐 Methodology{rst}"));
754        out.push(format!(
755            "    {m}   Savings = compression on lean-ctx-touched traffic (reads + shell),{rst}"
756        ));
757        out.push(format!(
758            "    {m}   not your full provider bill. lean-ctx adds ~{} tok/turn of context{rst}",
759            overhead.total_tokens(),
760        ));
761        out.push(format!(
762            "    {m}   ({} tool schemas + instructions + rules); without provider prompt{rst}",
763            overhead.tool_count,
764        ));
765        out.push(format!(
766            "    {m}   caching that rides every turn → net = savings − overhead × turns.{rst}"
767        ));
768        out.push(String::new());
769    }
770
771    let m = t.muted.fg();
772    out.push(format!(
773        "    {m}🐛 Found a bug? Run: lean-ctx report-issue{rst}"
774    ));
775    out.push(format!(
776        "    {m}📊 Help improve lean-ctx: lean-ctx contribute{rst}"
777    ));
778    out.push(format!("    {m}🧠 View bug memory: lean-ctx gotchas{rst}"));
779
780    out.push(String::new());
781    out.push(String::new());
782}
783
784fn trend_string(store: &StatsStore, up_color: &str, down_color: &str, rst: &str) -> String {
785    if store.daily.len() < 14 {
786        return String::new();
787    }
788    let recent_7: u64 = store
789        .daily
790        .iter()
791        .rev()
792        .take(7)
793        .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
794        .sum();
795    let prev_7: u64 = store
796        .daily
797        .iter()
798        .rev()
799        .skip(7)
800        .take(7)
801        .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
802        .sum();
803    if prev_7 == 0 {
804        return String::new();
805    }
806    let change = ((recent_7 as f64 / prev_7 as f64) - 1.0) * 100.0;
807    if change >= 0.0 {
808        format!("{up_color}+{change:.0}%{rst} vs last week")
809    } else {
810        format!("{down_color}{change:.0}%{rst} vs last week")
811    }
812}
813
814fn pricing_match_label(kind: crate::core::gain::model_pricing::PricingMatchKind) -> &'static str {
815    match kind {
816        crate::core::gain::model_pricing::PricingMatchKind::Exact => "exact price",
817        crate::core::gain::model_pricing::PricingMatchKind::Live => "live price",
818        crate::core::gain::model_pricing::PricingMatchKind::Alias => "alias price",
819        crate::core::gain::model_pricing::PricingMatchKind::Heuristic => "estimated price",
820        crate::core::gain::model_pricing::PricingMatchKind::Fallback => "fallback estimate",
821    }
822}
823
824fn contextual_tip(store: &StatsStore) -> Option<String> {
825    let tips = build_tips(store);
826    if tips.is_empty() {
827        return None;
828    }
829    let seed = std::time::SystemTime::now()
830        .duration_since(std::time::UNIX_EPOCH)
831        .unwrap_or_default()
832        .as_secs()
833        / 86400;
834    Some(tips[(seed as usize) % tips.len()].clone())
835}
836
837fn build_tips(store: &StatsStore) -> Vec<String> {
838    let mut tips = Vec::new();
839
840    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
841        tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
842    }
843
844    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
845        tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
846    }
847
848    if store.cep.total_cache_reads > 0
849        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
850    {
851        tips.push(
852            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
853        );
854    }
855
856    if store.total_commands > 50 && store.cep.sessions == 0 {
857        tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
858    }
859
860    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
861        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
862    }
863
864    if store.daily.len() >= 7 {
865        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
866    }
867
868    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
869    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
870
871    let cfg = crate::core::config::Config::load();
872    if cfg.theme == "default" {
873        tips.push(
874            "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
875        );
876        tips.push(
877            "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
878        );
879    } else {
880        tips.push(format!(
881            "Current theme: {}. Run lean-ctx theme list to explore others.",
882            cfg.theme
883        ));
884    }
885
886    tips.push(
887        "Create a custom theme: write a TOML file and import it with lean-ctx theme import <file>"
888            .into(),
889    );
890
891    tips
892}
893
894/// Runs the live-updating gain dashboard (1s refresh loop, Ctrl+C to exit).
895pub fn gain_live() {
896    use std::io::Write;
897
898    let interval = std::time::Duration::from_secs(1);
899    let mut line_count = 0usize;
900    let dim = theme::dim();
901    let rst = theme::rst();
902
903    tracing::info!("Live mode (1s refresh) · Ctrl+C to exit");
904
905    loop {
906        if line_count > 0 {
907            print!("\x1B[{line_count}A\x1B[J");
908        }
909
910        let tick = std::time::SystemTime::now()
911            .duration_since(std::time::UNIX_EPOCH)
912            .ok()
913            .map(|d| d.as_millis() as u64);
914        let output = format_gain_themed_at(&active_theme(), tick);
915        let footer = format!("\n  {dim}▸ Live · updates every 1s · Ctrl+C to exit{rst}\n");
916        let full = format!("{output}{footer}");
917        line_count = full.lines().count();
918
919        print!("{full}");
920        let _ = std::io::stdout().flush();
921
922        std::thread::sleep(interval);
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use crate::core::gain::model_pricing::PricingMatchKind;
930    use crate::core::stats::{CommandStats, DayStats};
931
932    #[test]
933    fn pricing_match_labels_surface_estimates() {
934        assert_eq!(pricing_match_label(PricingMatchKind::Exact), "exact price");
935        assert_eq!(
936            pricing_match_label(PricingMatchKind::Fallback),
937            "fallback estimate"
938        );
939    }
940    use std::collections::HashMap;
941
942    fn sample_store() -> StatsStore {
943        let mut commands = HashMap::new();
944        commands.insert(
945            "ctx_read".to_string(),
946            CommandStats {
947                count: 42,
948                input_tokens: 140_000,
949                output_tokens: 70_000,
950            },
951        );
952
953        let daily = (1..=14)
954            .map(|day| DayStats {
955                date: format!("2026-07-{day:02}"),
956                commands: day as u64,
957                input_tokens: 10_000 + day as u64 * 1_000,
958                output_tokens: 5_000,
959                version: "3.9.12".to_string(),
960            })
961            .collect();
962
963        StatsStore {
964            total_commands: 42,
965            total_input_tokens: 140_000,
966            total_output_tokens: 70_000,
967            commands,
968            daily,
969            first_inject_tokens_saved: 70_000,
970            active_tool_result_tokens_saved: 70_000,
971            last_tool_result_turn: 1,
972            stream_tracked_results: 1,
973            ..StatsStore::default()
974        }
975    }
976
977    #[test]
978    fn deep_dashboard_separates_score_bar_and_omits_recent_day_cmd_suffix() {
979        let theme = theme::load_theme("default");
980        let output = render_gain_dashboard_for_store(&theme, None, false, &sample_store());
981        let lines: Vec<&str> = output.lines().collect();
982        let score_line = lines
983            .iter()
984            .position(|line| line.contains("/100") && line.contains("Lv"))
985            .expect("score line");
986        assert!(!lines[score_line + 1].contains("trend:"));
987        assert!(lines[score_line + 2].contains("trend:"));
988
989        let recent_header = lines
990            .iter()
991            .position(|line| line.contains("RECENT DAYS"))
992            .expect("recent days header");
993        let recent_rows = lines
994            .iter()
995            .skip(recent_header + 2)
996            .take(7)
997            .filter(|line| line.contains("2026-07-"));
998        for row in recent_rows {
999            assert!(
1000                !row.contains(" cmds"),
1001                "row still contains cmds suffix: {row}"
1002            );
1003        }
1004    }
1005}