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    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            out.push(format!("  {}", t.box_top(42)));
173            let nside = t.box_side();
174            out.push(format!(
175                "  {nside} {accent}{bold}Your first week!{rst}                          {nside}"
176            ));
177            out.push(format!(
178                "  {nside} You saved {c1}{bold}{}{rst} tokens this week.      {nside}",
179                crate::core::wrapped::format_tokens(week_saved),
180            ));
181            out.push(format!(
182                "  {nside} Share your card? {sec}lean-ctx gain --wrapped{rst} {nside}",
183                sec = t.secondary.fg(),
184            ));
185            out.push(format!("  {}", t.box_bottom(42)));
186            out.push(String::new());
187        }
188    }
189
190    let sec = t.secondary.fg();
191    out.push(format!(
192        "  {sec}lean-ctx gain --deep{rst}     {dim}Full breakdown{rst}"
193    ));
194    out.push(format!(
195        "  {sec}lean-ctx gain --wrapped{rst}  {dim}Shareable card{rst}"
196    ));
197    out.push(format!(
198        "  {sec}lean-ctx watch{rst}           {dim}Live observatory{rst}"
199    ));
200    out.push(String::new());
201
202    if let Some(tip) = contextual_tip(&store) {
203        out.push(format!("  {dim}💡 {tip}{rst}"));
204        out.push(String::new());
205    }
206
207    out.join("\n")
208}
209
210/// Renders the token savings dashboard at a specific animation tick (with footer).
211pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
212    gain_dashboard(t, tick, true)
213}
214
215/// The dashboard body without the trailing footer (tips / Context OS / hints).
216/// Used to compose `gain --deep`, where the extra themed sections must appear
217/// before the footer instead of in the middle of the output.
218pub fn format_gain_body() -> String {
219    gain_dashboard(&active_theme(), None, false)
220}
221
222/// The standalone gain dashboard footer (contextual tip, Context OS, hints).
223pub fn format_gain_footer() -> String {
224    let store = crate::core::stats::load();
225    let mut out = Vec::new();
226    append_gain_footer(&mut out, &active_theme(), &store);
227    out.join("\n")
228}
229
230#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
231fn gain_dashboard(t: &Theme, tick: Option<u64>, with_footer: bool) -> String {
232    // Aggregate across split data dirs (#500) — see `format_gain_hero_themed`.
233    let store = crate::core::stats::load_for_display();
234    let mut out = Vec::new();
235    let rst = theme::rst();
236    let bold = theme::bold();
237    let dim = theme::dim();
238
239    if store.total_commands == 0 {
240        let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
241            Ok(p) => p.display().to_string(),
242            Err(_) => "~/.config/lean-ctx".into(),
243        };
244        // `mcp-live.json` is STATE (GH #408); read it from the state dir.
245        let mcp_live = crate::core::paths::state_dir().map_or_else(
246            |_| std::path::Path::new(&data_dir).join("mcp-live.json"),
247            |d| d.join("mcp-live.json"),
248        );
249        let mcp_hint = if let Ok(live) = std::fs::read_to_string(&mcp_live) {
250            if live.contains("\"total_calls\"") {
251                format!(
252                    "\n{dim}MCP calls are tracked in mcp-live.json but stats.json is empty.{rst}\
253                     \n{dim}This may indicate a data directory split. Run: lean-ctx doctor{rst}"
254                )
255            } else {
256                String::new()
257            }
258        } else {
259            String::new()
260        };
261        let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
262        let split_hint = if split_dirs.len() >= 2 {
263            format!(
264                "\n{dim}⚠ Stats found in multiple locations:{rst}\
265                 \n{dim}  {}{rst}\
266                 \n{dim}Run: lean-ctx doctor{rst}",
267                split_dirs
268                    .iter()
269                    .map(|d| d.display().to_string())
270                    .collect::<Vec<_>>()
271                    .join(", ")
272            )
273        } else {
274            String::new()
275        };
276        // Cross-check the tamper-evident savings ledger (#500): if it recorded
277        // events while stats.json stayed empty, the MCP server and the CLI are
278        // almost certainly resolving different data dirs — name that explicitly
279        // instead of the bare "expected" message so the user can act.
280        let ledger = crate::core::savings_ledger::summary();
281        let ledger_hint = if ledger.total_events > 0 {
282            format!(
283                "\n{dim}⚠ {} savings events (~{} tokens) ARE in the ledger but not in stats.json —{rst}\
284                 \n{dim}  the MCP server likely writes to a different data dir than this CLI.{rst}\
285                 \n{dim}  Inspect: lean-ctx savings   ·   Diagnose: lean-ctx doctor{rst}",
286                ledger.total_events,
287                crate::core::wrapped::format_tokens(ledger.saved_tokens),
288            )
289        } else {
290            String::new()
291        };
292        return format!(
293            "{bold}No savings recorded yet — and that's expected.{rst}\
294             \n\n  {dim}Savings appear after your AI tool uses lean-ctx for the first time.{rst}\
295             \n\n  Next:\
296             \n    1. Make sure your AI tool is connected:  {cmd}lean-ctx doctor{rst}\
297             \n    2. Fully restart your AI tool so it reconnects to lean-ctx.\
298             \n    3. Ask it to read a file or run a command — then check back here.\
299             \n\n  {dim}Tip: track a shell command yourself with {rst}{cmd}lean-ctx -c \"git status\"{rst}\
300             \n\n  {dim}Stats path: {data_dir}{rst}{mcp_hint}{split_hint}{ledger_hint}",
301            cmd = t.secondary.fg(),
302        );
303    }
304
305    let input_saved = store
306        .total_input_tokens
307        .saturating_sub(store.total_output_tokens);
308    let pct = if store.total_input_tokens > 0 {
309        input_saved as f64 / store.total_input_tokens as f64 * 100.0
310    } else {
311        0.0
312    };
313    let cost_model = CostModel::default();
314    let cost = cost_model.calculate(&store);
315    let total_saved = input_saved;
316    let _days_active = store.daily.len();
317
318    let w = 70;
319    let side = t.box_side();
320    let ss = t.box_side_square();
321
322    let box_line = |content: &str| -> String {
323        let padded = theme::pad_right(content, w);
324        format!("  {side}{padded}{side}")
325    };
326    let sec_line = |content: &str| -> String {
327        let padded = theme::pad_right(content, w);
328        format!("  {ss}{padded}{ss}")
329    };
330
331    out.push(String::new());
332    out.push(format!("  {}", t.box_top(w)));
333    out.push(box_line(""));
334
335    let ver = env!("CARGO_PKG_VERSION");
336    let header = format!(
337        "     {icon}  {bold}{title}{rst}",
338        icon = t.header_icon(),
339        title = t.brand_title(),
340    );
341    let ver_part = format!("{dim}v{ver}{rst}");
342    let header_padded = theme::pad_right(&header, w - ver.len() - 2);
343    out.push(format!("  {side}{header_padded}{ver_part} {side}"));
344
345    let subtitle = format!("     {dim}Token Savings Dashboard{rst}");
346    out.push(box_line(&subtitle));
347    out.push(box_line(""));
348    out.push(format!("  {}", t.box_mid(w)));
349    out.push(box_line(""));
350
351    let tok_val = format_big(total_saved);
352    let pct_val = format!("{pct:.1}%");
353    let cmd_val = format_num(store.total_commands);
354    let usd_val = format_usd(cost.total_saved);
355
356    let c1 = t.success.fg();
357    let c2 = t.secondary.fg();
358    let c3 = t.warning.fg();
359    let c4 = t.accent.fg();
360
361    let kw = 16;
362    let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
363    let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
364    let v3 = theme::pad_right(&format!("{c3}{bold}{cmd_val}{rst}"), kw);
365    let v4 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
366    out.push(box_line(&format!("     {v1}{v2}{v3}{v4}")));
367
368    let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
369    let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
370    let ul3 = theme::pad_right(&t.kpi_underline(cmd_val.len(), &t.warning), kw);
371    let ul4 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
372    out.push(box_line(&format!("     {ul1}{ul2}{ul3}{ul4}")));
373
374    let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
375    let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
376    let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
377    let l4 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
378    out.push(box_line(&format!("     {l1}{l2}{l3}{l4}")));
379    out.push(box_line(""));
380    out.push(format!("  {}", t.box_bottom(w)));
381    out.push(String::new());
382
383    // -- GAIN SCORE section (labeled box) --
384    {
385        let engine = crate::core::gain::GainEngine::load();
386        let score = engine.gain_score(None);
387        let lvl = score.level();
388        let score_ratio = (score.total as f64 / 100.0).min(1.0);
389        let bar = t.gradient_bar(score_ratio, 30);
390        let sc_color = t.pct_color(score.total as f64);
391
392        out.push(format!("  {}", t.box_top_labeled(w, "GAIN SCORE")));
393        out.push(sec_line(&format!(
394            "  {bar}  {sc_color}{bold}{}/100{rst}  Lv{} {dim}{}{rst}",
395            score.total, lvl.level, lvl.title,
396        )));
397
398        if store.daily.len() >= 2 {
399            let daily_savings: Vec<u64> = store
400                .daily
401                .iter()
402                .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
403                .collect();
404            let spark = t.gradient_sparkline(&daily_savings);
405            let trend_str = trend_string(&store, &c1, &t.warning.fg(), rst);
406            out.push(sec_line(&format!(
407                "  {dim}trend:{rst} {spark}  {trend_str}"
408            )));
409        }
410
411        if total_saved > 0 {
412            let energy_str = crate::core::energy::format_for_tokens(total_saved);
413            let charges = crate::core::energy::phone_charges_hint(total_saved)
414                .map(|h| format!(" ({h})"))
415                .unwrap_or_default();
416            out.push(sec_line(&format!(
417                "  {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
418            )));
419        }
420        out.push(format!("  {}", t.box_bottom_square(w)));
421    }
422
423    // -- COMPANION section --
424    {
425        let cfg = crate::core::config::Config::load();
426        if cfg.buddy_enabled {
427            out.push(String::new());
428            out.push(format!("  {}", t.box_top_labeled(w, "YOUR COMPANION")));
429            let buddy = crate::core::buddy::BuddyState::compute();
430            let block = crate::core::buddy::format_buddy_block_at(&buddy, t, tick);
431            for line in block.lines() {
432                out.push(sec_line(line));
433            }
434            out.push(format!("  {}", t.box_bottom_square(w)));
435        }
436    }
437
438    out.push(String::new());
439
440    // -- COST BREAKDOWN section --
441    let price_label = format!(
442        "@ ${:.2}/M input · ${:.2}/M output",
443        cost_model.input_price_per_m, cost_model.output_price_per_m,
444    );
445    let cost_label = format!("COST BREAKDOWN ──── {price_label}");
446    out.push(format!("  {}", t.box_top_labeled(w, &cost_label)));
447    out.push(sec_line(""));
448    let without_bar = t.gradient_bar(1.0, 26);
449    let with_ratio = cost.total_cost_with / cost.total_cost_without.max(0.01);
450    let with_bar = t.gradient_bar(with_ratio, 26);
451    let saved_pct = if cost.total_cost_without > 0.0 {
452        (1.0 - with_ratio) * 100.0
453    } else {
454        0.0
455    };
456
457    out.push(sec_line(&format!(
458        "  {m}Without lean-ctx{rst}  {:>10}  {without_bar}",
459        format_usd(cost.total_cost_without),
460        m = t.muted.fg(),
461    )));
462    out.push(sec_line(&format!(
463        "  {m}With lean-ctx{rst}      {:>10}  {with_bar}",
464        format_usd(cost.total_cost_with),
465        m = t.muted.fg(),
466    )));
467    out.push(sec_line(&format!(
468        "  {c}{bold}You saved{rst}          {c}{bold}{:>10}{rst}  {dim}── {saved_pct:.1}% reduction ──{rst}",
469        format_usd(cost.total_saved),
470        c = t.success.fg(),
471    )));
472    out.push(format!("  {}", t.box_bottom_square(w)));
473
474    out.push(String::new());
475
476    // -- TOP COMMANDS section --
477    if !store.commands.is_empty() {
478        out.push(format!("  {}", t.box_top_labeled(w, "TOP COMMANDS")));
479
480        let mut sorted: Vec<_> = store
481            .commands
482            .iter()
483            .filter(|(_, s)| s.input_tokens > s.output_tokens)
484            .collect();
485        sorted.sort_by(|a, b2| {
486            let sa = cmd_total_saved(a.1, &cost_model);
487            let sb = cmd_total_saved(b2.1, &cost_model);
488            sb.cmp(&sa)
489        });
490
491        let max_cmd_saved = sorted
492            .first()
493            .map_or(1, |(_, s)| cmd_total_saved(s, &cost_model))
494            .max(1);
495
496        for (cmd, stats) in sorted.iter().take(10) {
497            let cmd_saved = cmd_total_saved(stats, &cost_model);
498            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
499            let cmd_pct = if stats.input_tokens > 0 {
500                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
501            } else {
502                0.0
503            };
504            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
505            let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
506            let pc = t.pct_color(cmd_pct);
507            let cmd_col = theme::pad_right(
508                &format!("{m}{}{rst}", truncate_cmd(cmd, 14), m = t.muted.fg()),
509                16,
510            );
511            let saved_col =
512                theme::pad_right(&format!("{bold}{pc}{}{rst}", format_big(cmd_saved)), 7);
513            let row = format!(
514                " {cmd_col} {:>4}x {bar} {saved_col}{dim}{cmd_pct:>3.0}%{rst}",
515                stats.count,
516            );
517            out.push(sec_line(&row));
518        }
519
520        if sorted.len() > 10 {
521            out.push(sec_line(&format!(
522                "  {dim}... +{} more commands{rst}",
523                sorted.len() - 10
524            )));
525        }
526        out.push(format!("  {}", t.box_bottom_square(w)));
527    }
528
529    // -- RECENT DAYS section --
530    if store.daily.len() >= 2 {
531        out.push(String::new());
532        out.push(format!("  {}", t.box_top_labeled(w, "RECENT DAYS")));
533
534        let max_day_saved = store
535            .daily
536            .iter()
537            .rev()
538            .take(7)
539            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
540            .max()
541            .unwrap_or(1)
542            .max(1);
543
544        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
545        for day in recent.iter().rev() {
546            let day_saved = day_total_saved(day, &cost_model);
547            let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
548            let day_pct = if day.input_tokens > 0 {
549                day_input_saved as f64 / day.input_tokens as f64 * 100.0
550            } else {
551                0.0
552            };
553            let pc = t.pct_color(day_pct);
554            let ratio = day_input_saved as f64 / max_day_saved as f64;
555            // Pad the bar to a fixed width so the trailing version column lines up
556            // (matches the BY COMMAND bar above; gradient_bar can return < width).
557            let day_bar = theme::pad_right(&t.gradient_bar(ratio, 8), 8);
558            let date_short = day.date.get(5..).unwrap_or(&day.date);
559            let date_col = theme::pad_right(&format!("{m}{date_short}{rst}", m = t.muted.fg()), 7);
560            // Per-day input volume (self-labeled "… in") makes the volume-weighted
561            // nature of the % explicit: a lower day-% usually reflects a smaller /
562            // less-compressible workload (e.g. fewer high-ratio grep/search calls),
563            // not worse compression. Without it the % drop reads as a regression
564            // when it is really composition (GL #622).
565            let in_col = theme::pad_right(
566                &format!(
567                    "{m}{} in{rst}",
568                    format_big(day.input_tokens),
569                    m = t.muted.fg()
570                ),
571                11,
572            );
573            let saved_col =
574                theme::pad_right(&format!("{pc}{bold}{}{rst}", format_big(day_saved)), 9);
575            // Per-day version attributes a compression change to a specific
576            // release (#307); pre-tracking days carry no version and show "—".
577            let ver = if day.version.is_empty() {
578                "—".to_string()
579            } else {
580                format!("v{}", day.version)
581            };
582            out.push(sec_line(&format!(
583                "  {date_col} {:>4} cmds  {in_col} {saved_col} {pc}{day_pct:>5.1}%{rst}  {day_bar}  {dim}{ver}{rst}",
584                day.commands,
585            )));
586        }
587        out.push(format!("  {}", t.box_bottom_square(w)));
588    }
589
590    if with_footer {
591        append_gain_footer(&mut out, t, &store);
592    }
593
594    out.join("\n")
595}
596
597/// Appends the dashboard footer (contextual tip, Bug Memory, Context OS panel,
598/// help hints). Kept separate so `gain --deep` can render it *after* the extra
599/// themed sections instead of in the middle of the output.
600fn append_gain_footer(out: &mut Vec<String>, t: &Theme, store: &StatsStore) {
601    let rst = theme::rst();
602    let bold = theme::bold();
603
604    out.push(String::new());
605    out.push(String::new());
606
607    if let Some(tip) = contextual_tip(store) {
608        out.push(format!("    {w}💡 {tip}{rst}", w = t.warning.fg()));
609        out.push(String::new());
610    }
611
612    {
613        let project_root = std::env::current_dir()
614            .map(|p| p.to_string_lossy().to_string())
615            .unwrap_or_default();
616        if !project_root.is_empty() {
617            let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
618            if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
619                let a = t.accent.fg();
620                out.push(format!("    {a}🧠 Bug Memory{rst}"));
621                out.push(format!(
622                    "    {m}   Active gotchas: {}{rst}   Bugs prevented: {}{rst}",
623                    gotcha_store.gotchas.len(),
624                    gotcha_store.stats.total_prevented,
625                    m = t.muted.fg(),
626                ));
627                out.push(String::new());
628            }
629        }
630    }
631
632    {
633        let project_root = std::env::current_dir()
634            .map(|p| p.to_string_lossy().to_string())
635            .unwrap_or_default();
636        let a = t.accent.fg();
637        let m = t.muted.fg();
638
639        let mut ctx_items: Vec<String> = Vec::new();
640
641        if let Some(session) =
642            crate::core::session::SessionState::load_latest_for_project_root(&project_root)
643        {
644            let task_str = session
645                .task
646                .as_ref()
647                .map_or("—", |tk| tk.description.as_str());
648            let task_disp = if task_str.len() > 35 {
649                format!("{}…", &task_str[..task_str.floor_char_boundary(32)])
650            } else {
651                task_str.to_string()
652            };
653            ctx_items.push(format!(
654                "   Session: {bold}{task_disp}{rst}  {m}files={} findings={} terse={}{rst}",
655                session.files_touched.len(),
656                session.findings.len(),
657                if session.terse_mode { "on" } else { "off" },
658            ));
659        }
660
661        let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
662        let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
663        if active_facts > 0 {
664            ctx_items.push(format!(
665                "   Knowledge: {bold}{active_facts}{rst} active facts  {m}{} total{rst}",
666                knowledge.facts.len(),
667            ));
668        }
669
670        if let Some(open) = crate::core::graph_provider::open_best_effort(&project_root) {
671            let nc = open.provider.node_count().unwrap_or(0);
672            let ec = open.provider.edge_count().unwrap_or(0);
673            if nc > 0 {
674                let (unit, suffix) = match open.source {
675                    crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
676                        ("nodes", "")
677                    }
678                    crate::core::graph_provider::GraphProviderSource::GraphIndex => {
679                        let max_cfg = crate::core::config::Config::load().graph_index_max_files;
680                        if max_cfg > 0 && nc >= max_cfg as usize {
681                            ("files", " (limit reached)")
682                        } else {
683                            ("files", "")
684                        }
685                    }
686                };
687                ctx_items.push(format!(
688                    "   Graph: {bold}{nc}{rst} {unit}  {bold}{ec}{rst} edges{suffix}",
689                ));
690            }
691        }
692
693        #[cfg(unix)]
694        let daemon_running = crate::daemon::is_daemon_running();
695        #[cfg(not(unix))]
696        let daemon_running = false;
697
698        if daemon_running {
699            ctx_items.push(format!("   Daemon: {c}running{rst}", c = t.success.fg()));
700        } else {
701            ctx_items.push(format!(
702                "   {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
703                w = t.warning.fg()
704            ));
705        }
706
707        if !ctx_items.is_empty() {
708            out.push(format!("    {a}⚡ Context OS{rst}"));
709            for item in &ctx_items {
710                out.push(format!("    {item}"));
711            }
712            out.push(String::new());
713        }
714    }
715
716    {
717        // Methodology disclosure (#361): the headline measures compression on
718        // lean-ctx-touched traffic, not the full provider bill — and lean-ctx
719        // itself injects a fixed per-turn prefix that, without provider prompt
720        // caching, is re-billed every turn. State both so the number stays honest.
721        let a = t.accent.fg();
722        let m = t.muted.fg();
723        let overhead = crate::core::context_overhead::ContextOverhead::cached();
724        out.push(format!("    {a}📐 Methodology{rst}"));
725        out.push(format!(
726            "    {m}   Savings = compression on lean-ctx-touched traffic (reads + shell),{rst}"
727        ));
728        out.push(format!(
729            "    {m}   not your full provider bill. lean-ctx adds ~{} tok/turn of context{rst}",
730            overhead.total_tokens(),
731        ));
732        out.push(format!(
733            "    {m}   ({} tool schemas + instructions + rules); without provider prompt{rst}",
734            overhead.tool_count,
735        ));
736        out.push(format!(
737            "    {m}   caching that rides every turn → net = savings − overhead × turns.{rst}"
738        ));
739        out.push(String::new());
740    }
741
742    let m = t.muted.fg();
743    out.push(format!(
744        "    {m}🐛 Found a bug? Run: lean-ctx report-issue{rst}"
745    ));
746    out.push(format!(
747        "    {m}📊 Help improve lean-ctx: lean-ctx contribute{rst}"
748    ));
749    out.push(format!("    {m}🧠 View bug memory: lean-ctx gotchas{rst}"));
750
751    out.push(String::new());
752    out.push(String::new());
753}
754
755fn trend_string(store: &StatsStore, up_color: &str, down_color: &str, rst: &str) -> String {
756    if store.daily.len() < 14 {
757        return String::new();
758    }
759    let recent_7: u64 = store
760        .daily
761        .iter()
762        .rev()
763        .take(7)
764        .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
765        .sum();
766    let prev_7: u64 = store
767        .daily
768        .iter()
769        .rev()
770        .skip(7)
771        .take(7)
772        .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
773        .sum();
774    if prev_7 == 0 {
775        return String::new();
776    }
777    let change = ((recent_7 as f64 / prev_7 as f64) - 1.0) * 100.0;
778    if change >= 0.0 {
779        format!("{up_color}+{change:.0}%{rst} vs last week")
780    } else {
781        format!("{down_color}{change:.0}%{rst} vs last week")
782    }
783}
784
785fn contextual_tip(store: &StatsStore) -> Option<String> {
786    let tips = build_tips(store);
787    if tips.is_empty() {
788        return None;
789    }
790    let seed = std::time::SystemTime::now()
791        .duration_since(std::time::UNIX_EPOCH)
792        .unwrap_or_default()
793        .as_secs()
794        / 86400;
795    Some(tips[(seed as usize) % tips.len()].clone())
796}
797
798fn build_tips(store: &StatsStore) -> Vec<String> {
799    let mut tips = Vec::new();
800
801    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
802        tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
803    }
804
805    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
806        tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
807    }
808
809    if store.cep.total_cache_reads > 0
810        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
811    {
812        tips.push(
813            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
814        );
815    }
816
817    if store.total_commands > 50 && store.cep.sessions == 0 {
818        tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
819    }
820
821    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
822        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
823    }
824
825    if store.daily.len() >= 7 {
826        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
827    }
828
829    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
830    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
831
832    let cfg = crate::core::config::Config::load();
833    if cfg.theme == "default" {
834        tips.push(
835            "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
836        );
837        tips.push(
838            "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
839        );
840    } else {
841        tips.push(format!(
842            "Current theme: {}. Run lean-ctx theme list to explore others.",
843            cfg.theme
844        ));
845    }
846
847    tips.push(
848        "Create a custom theme: write a TOML file and import it with lean-ctx theme import <file>"
849            .into(),
850    );
851
852    tips
853}
854
855/// Runs the live-updating gain dashboard (1s refresh loop, Ctrl+C to exit).
856pub fn gain_live() {
857    use std::io::Write;
858
859    let interval = std::time::Duration::from_secs(1);
860    let mut line_count = 0usize;
861    let dim = theme::dim();
862    let rst = theme::rst();
863
864    tracing::info!("Live mode (1s refresh) · Ctrl+C to exit");
865
866    loop {
867        if line_count > 0 {
868            print!("\x1B[{line_count}A\x1B[J");
869        }
870
871        let tick = std::time::SystemTime::now()
872            .duration_since(std::time::UNIX_EPOCH)
873            .ok()
874            .map(|d| d.as_millis() as u64);
875        let output = format_gain_themed_at(&active_theme(), tick);
876        let footer = format!("\n  {dim}▸ Live · updates every 1s · Ctrl+C to exit{rst}\n");
877        let full = format!("{output}{footer}");
878        line_count = full.lines().count();
879
880        print!("{full}");
881        let _ = std::io::stdout().flush();
882
883        std::thread::sleep(interval);
884    }
885}