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    let store = crate::core::stats::load();
28    let rst = theme::rst();
29    let bold = theme::bold();
30    let dim = theme::dim();
31
32    if store.total_commands == 0 {
33        return format_gain_themed_at(t, None);
34    }
35
36    let input_saved = store
37        .total_input_tokens
38        .saturating_sub(store.total_output_tokens);
39    let pct = if store.total_input_tokens > 0 {
40        input_saved as f64 / store.total_input_tokens as f64 * 100.0
41    } else {
42        0.0
43    };
44    let cost_model = CostModel::default();
45    let cost = cost_model.calculate(&store);
46
47    let engine = crate::core::gain::GainEngine::load();
48    let score = engine.gain_score(None);
49
50    let w = 57;
51    let side = t.box_side();
52    let box_line = |content: &str| -> String {
53        let padded = theme::pad_right(content, w);
54        format!("  {side}{padded}{side}")
55    };
56
57    let mut out = Vec::new();
58    out.push(String::new());
59    out.push(format!("  {}", t.box_top(w)));
60    out.push(box_line(&format!(
61        "  {icon}  {title}",
62        icon = t.header_icon(),
63        title = t.brand_title(),
64    )));
65    out.push(box_line(""));
66
67    let c1 = t.success.fg();
68    let c2 = t.secondary.fg();
69    let c4 = t.accent.fg();
70    let tok_val = format_big(input_saved);
71    let pct_val = format!("{pct:.0}%");
72    let usd_val = format_usd(cost.total_saved);
73
74    let kw = 18;
75    let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
76    let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
77    let v3 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
78    out.push(box_line(&format!("  {v1}{v2}{v3}")));
79
80    let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
81    let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
82    let ul3 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
83    out.push(box_line(&format!("  {ul1}{ul2}{ul3}")));
84
85    let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
86    let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
87    let l3 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
88    out.push(box_line(&format!("  {l1}{l2}{l3}")));
89    out.push(box_line(""));
90
91    let score_bar_w = 30;
92    let score_ratio = (score.total as f64 / 100.0).min(1.0);
93    let bar = t.gradient_bar(score_ratio, score_bar_w);
94    let sc_color = t.pct_color(score.total as f64);
95    let lvl = score.level();
96    out.push(box_line(&format!(
97        "  {bar}  {sc_color}{bold}{}/100{rst}  Lv{} {dim}{}{rst}",
98        score.total, lvl.level, lvl.title,
99    )));
100    out.push(box_line(""));
101
102    if store.daily.len() >= 2 {
103        let daily_savings: Vec<u64> = store
104            .daily
105            .iter()
106            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
107            .collect();
108        let spark = t.gradient_sparkline(&daily_savings);
109        let trend_str = trend_string(&store, &c1, &t.warning.fg(), rst);
110        out.push(box_line(&format!(
111            "  {dim}trend:{rst} {spark}  {trend_str}"
112        )));
113    }
114
115    if input_saved > 0 {
116        let energy_str = crate::core::energy::format_for_tokens(input_saved);
117        let charges = crate::core::energy::phone_charges_hint(input_saved)
118            .map(|h| format!(" ({h})"))
119            .unwrap_or_default();
120        out.push(box_line(&format!(
121            "  {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
122        )));
123    }
124
125    out.push(format!("  {}", t.box_bottom(w)));
126    out.push(String::new());
127
128    // Weekly nudge: after 7 days of data, if user hasn't published, show a prominent card
129    if store.daily.len() >= 7 && !crate::cli::wrapped_publish::has_published() {
130        let week_saved: u64 = store
131            .daily
132            .iter()
133            .rev()
134            .take(7)
135            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
136            .sum();
137        if week_saved > 0 {
138            let accent = t.accent.fg();
139            out.push(format!("  {}", t.box_top(42)));
140            let nside = t.box_side();
141            out.push(format!(
142                "  {nside} {accent}{bold}Your first week!{rst}                          {nside}"
143            ));
144            out.push(format!(
145                "  {nside} You saved {c1}{bold}{}{rst} tokens this week.      {nside}",
146                crate::core::wrapped::format_tokens(week_saved),
147            ));
148            out.push(format!(
149                "  {nside} Share your card? {sec}lean-ctx gain --wrapped{rst} {nside}",
150                sec = t.secondary.fg(),
151            ));
152            out.push(format!("  {}", t.box_bottom(42)));
153            out.push(String::new());
154        }
155    }
156
157    let sec = t.secondary.fg();
158    out.push(format!(
159        "  {sec}lean-ctx gain --deep{rst}     {dim}Full breakdown{rst}"
160    ));
161    out.push(format!(
162        "  {sec}lean-ctx gain --wrapped{rst}  {dim}Shareable card{rst}"
163    ));
164    out.push(format!(
165        "  {sec}lean-ctx watch{rst}           {dim}Live observatory{rst}"
166    ));
167    out.push(String::new());
168
169    if let Some(tip) = contextual_tip(&store) {
170        out.push(format!("  {dim}๐Ÿ’ก {tip}{rst}"));
171        out.push(String::new());
172    }
173
174    out.join("\n")
175}
176
177/// Renders the token savings dashboard at a specific animation tick (with footer).
178pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
179    gain_dashboard(t, tick, true)
180}
181
182/// The dashboard body without the trailing footer (tips / Context OS / hints).
183/// Used to compose `gain --deep`, where the extra themed sections must appear
184/// before the footer instead of in the middle of the output.
185pub fn format_gain_body() -> String {
186    gain_dashboard(&active_theme(), None, false)
187}
188
189/// The standalone gain dashboard footer (contextual tip, Context OS, hints).
190pub fn format_gain_footer() -> String {
191    let store = crate::core::stats::load();
192    let mut out = Vec::new();
193    append_gain_footer(&mut out, &active_theme(), &store);
194    out.join("\n")
195}
196
197#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
198fn gain_dashboard(t: &Theme, tick: Option<u64>, with_footer: bool) -> String {
199    let store = crate::core::stats::load();
200    let mut out = Vec::new();
201    let rst = theme::rst();
202    let bold = theme::bold();
203    let dim = theme::dim();
204
205    if store.total_commands == 0 {
206        let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
207            Ok(p) => p.display().to_string(),
208            Err(_) => "~/.config/lean-ctx".into(),
209        };
210        let mcp_hint = if let Ok(live) =
211            std::fs::read_to_string(std::path::Path::new(&data_dir).join("mcp-live.json"))
212        {
213            if live.contains("\"total_calls\"") {
214                format!(
215                    "\n{dim}MCP calls are tracked in mcp-live.json but stats.json is empty.{rst}\
216                     \n{dim}This may indicate a data directory split. Run: lean-ctx doctor{rst}"
217                )
218            } else {
219                String::new()
220            }
221        } else {
222            String::new()
223        };
224        let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
225        let split_hint = if split_dirs.len() >= 2 {
226            format!(
227                "\n{dim}โš  Stats found in multiple locations:{rst}\
228                 \n{dim}  {}{rst}\
229                 \n{dim}Run: lean-ctx doctor{rst}",
230                split_dirs
231                    .iter()
232                    .map(|d| d.display().to_string())
233                    .collect::<Vec<_>>()
234                    .join(", ")
235            )
236        } else {
237            String::new()
238        };
239        return format!(
240            "{bold}No savings recorded yet โ€” and that's expected.{rst}\
241             \n\n  {dim}Savings appear after your AI tool uses lean-ctx for the first time.{rst}\
242             \n\n  Next:\
243             \n    1. Make sure your AI tool is connected:  {cmd}lean-ctx doctor{rst}\
244             \n    2. Fully restart your AI tool so it reconnects to lean-ctx.\
245             \n    3. Ask it to read a file or run a command โ€” then check back here.\
246             \n\n  {dim}Tip: track a shell command yourself with {rst}{cmd}lean-ctx -c \"git status\"{rst}\
247             \n\n  {dim}Stats path: {data_dir}{rst}{mcp_hint}{split_hint}",
248            cmd = t.secondary.fg(),
249        );
250    }
251
252    let input_saved = store
253        .total_input_tokens
254        .saturating_sub(store.total_output_tokens);
255    let pct = if store.total_input_tokens > 0 {
256        input_saved as f64 / store.total_input_tokens as f64 * 100.0
257    } else {
258        0.0
259    };
260    let cost_model = CostModel::default();
261    let cost = cost_model.calculate(&store);
262    let total_saved = input_saved;
263    let _days_active = store.daily.len();
264
265    let w = 70;
266    let side = t.box_side();
267    let ss = t.box_side_square();
268
269    let box_line = |content: &str| -> String {
270        let padded = theme::pad_right(content, w);
271        format!("  {side}{padded}{side}")
272    };
273    let sec_line = |content: &str| -> String {
274        let padded = theme::pad_right(content, w);
275        format!("  {ss}{padded}{ss}")
276    };
277
278    out.push(String::new());
279    out.push(format!("  {}", t.box_top(w)));
280    out.push(box_line(""));
281
282    let ver = env!("CARGO_PKG_VERSION");
283    let header = format!(
284        "     {icon}  {bold}{title}{rst}",
285        icon = t.header_icon(),
286        title = t.brand_title(),
287    );
288    let ver_part = format!("{dim}v{ver}{rst}");
289    let header_padded = theme::pad_right(&header, w - ver.len() - 2);
290    out.push(format!("  {side}{header_padded}{ver_part} {side}"));
291
292    let subtitle = format!("     {dim}Token Savings Dashboard{rst}");
293    out.push(box_line(&subtitle));
294    out.push(box_line(""));
295    out.push(format!("  {}", t.box_mid(w)));
296    out.push(box_line(""));
297
298    let tok_val = format_big(total_saved);
299    let pct_val = format!("{pct:.1}%");
300    let cmd_val = format_num(store.total_commands);
301    let usd_val = format_usd(cost.total_saved);
302
303    let c1 = t.success.fg();
304    let c2 = t.secondary.fg();
305    let c3 = t.warning.fg();
306    let c4 = t.accent.fg();
307
308    let kw = 16;
309    let v1 = theme::pad_right(&format!("{c1}{bold}{tok_val}{rst}"), kw);
310    let v2 = theme::pad_right(&format!("{c2}{bold}{pct_val}{rst}"), kw);
311    let v3 = theme::pad_right(&format!("{c3}{bold}{cmd_val}{rst}"), kw);
312    let v4 = theme::pad_right(&format!("{c4}{bold}{usd_val}{rst}"), kw);
313    out.push(box_line(&format!("     {v1}{v2}{v3}{v4}")));
314
315    let ul1 = theme::pad_right(&t.kpi_underline(tok_val.len(), &t.success), kw);
316    let ul2 = theme::pad_right(&t.kpi_underline(pct_val.len(), &t.secondary), kw);
317    let ul3 = theme::pad_right(&t.kpi_underline(cmd_val.len(), &t.warning), kw);
318    let ul4 = theme::pad_right(&t.kpi_underline(usd_val.len(), &t.accent), kw);
319    out.push(box_line(&format!("     {ul1}{ul2}{ul3}{ul4}")));
320
321    let l1 = theme::pad_right(&format!("{dim}tokens saved{rst}"), kw);
322    let l2 = theme::pad_right(&format!("{dim}compression{rst}"), kw);
323    let l3 = theme::pad_right(&format!("{dim}commands{rst}"), kw);
324    let l4 = theme::pad_right(&format!("{dim}USD saved{rst}"), kw);
325    out.push(box_line(&format!("     {l1}{l2}{l3}{l4}")));
326    out.push(box_line(""));
327    out.push(format!("  {}", t.box_bottom(w)));
328    out.push(String::new());
329
330    // -- GAIN SCORE section (labeled box) --
331    {
332        let engine = crate::core::gain::GainEngine::load();
333        let score = engine.gain_score(None);
334        let lvl = score.level();
335        let score_ratio = (score.total as f64 / 100.0).min(1.0);
336        let bar = t.gradient_bar(score_ratio, 30);
337        let sc_color = t.pct_color(score.total as f64);
338
339        out.push(format!("  {}", t.box_top_labeled(w, "GAIN SCORE")));
340        out.push(sec_line(&format!(
341            "  {bar}  {sc_color}{bold}{}/100{rst}  Lv{} {dim}{}{rst}",
342            score.total, lvl.level, lvl.title,
343        )));
344
345        if store.daily.len() >= 2 {
346            let daily_savings: Vec<u64> = store
347                .daily
348                .iter()
349                .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
350                .collect();
351            let spark = t.gradient_sparkline(&daily_savings);
352            let trend_str = trend_string(&store, &c1, &t.warning.fg(), rst);
353            out.push(sec_line(&format!(
354                "  {dim}trend:{rst} {spark}  {trend_str}"
355            )));
356        }
357
358        if total_saved > 0 {
359            let energy_str = crate::core::energy::format_for_tokens(total_saved);
360            let charges = crate::core::energy::phone_charges_hint(total_saved)
361                .map(|h| format!(" ({h})"))
362                .unwrap_or_default();
363            out.push(sec_line(&format!(
364                "  {dim}energy:{rst} {c1}{energy_str}{rst}{dim}{charges}{rst}"
365            )));
366        }
367        out.push(format!("  {}", t.box_bottom_square(w)));
368    }
369
370    // -- COMPANION section --
371    {
372        let cfg = crate::core::config::Config::load();
373        if cfg.buddy_enabled {
374            out.push(String::new());
375            out.push(format!("  {}", t.box_top_labeled(w, "YOUR COMPANION")));
376            let buddy = crate::core::buddy::BuddyState::compute();
377            let block = crate::core::buddy::format_buddy_block_at(&buddy, t, tick);
378            for line in block.lines() {
379                out.push(sec_line(line));
380            }
381            out.push(format!("  {}", t.box_bottom_square(w)));
382        }
383    }
384
385    out.push(String::new());
386
387    // -- COST BREAKDOWN section --
388    let price_label = format!(
389        "@ ${:.2}/M input ยท ${:.2}/M output",
390        cost_model.input_price_per_m, cost_model.output_price_per_m,
391    );
392    let cost_label = format!("COST BREAKDOWN โ”€โ”€โ”€โ”€ {price_label}");
393    out.push(format!("  {}", t.box_top_labeled(w, &cost_label)));
394    out.push(sec_line(""));
395    let without_bar = t.gradient_bar(1.0, 26);
396    let with_ratio = cost.total_cost_with / cost.total_cost_without.max(0.01);
397    let with_bar = t.gradient_bar(with_ratio, 26);
398    let saved_pct = if cost.total_cost_without > 0.0 {
399        (1.0 - with_ratio) * 100.0
400    } else {
401        0.0
402    };
403
404    out.push(sec_line(&format!(
405        "  {m}Without lean-ctx{rst}  {:>10}  {without_bar}",
406        format_usd(cost.total_cost_without),
407        m = t.muted.fg(),
408    )));
409    out.push(sec_line(&format!(
410        "  {m}With lean-ctx{rst}      {:>10}  {with_bar}",
411        format_usd(cost.total_cost_with),
412        m = t.muted.fg(),
413    )));
414    out.push(sec_line(&format!(
415        "  {c}{bold}You saved{rst}          {c}{bold}{:>10}{rst}  {dim}โ”€โ”€ {saved_pct:.1}% reduction โ”€โ”€{rst}",
416        format_usd(cost.total_saved),
417        c = t.success.fg(),
418    )));
419    out.push(format!("  {}", t.box_bottom_square(w)));
420
421    out.push(String::new());
422
423    // -- TOP COMMANDS section --
424    if !store.commands.is_empty() {
425        out.push(format!("  {}", t.box_top_labeled(w, "TOP COMMANDS")));
426
427        let mut sorted: Vec<_> = store
428            .commands
429            .iter()
430            .filter(|(_, s)| s.input_tokens > s.output_tokens)
431            .collect();
432        sorted.sort_by(|a, b2| {
433            let sa = cmd_total_saved(a.1, &cost_model);
434            let sb = cmd_total_saved(b2.1, &cost_model);
435            sb.cmp(&sa)
436        });
437
438        let max_cmd_saved = sorted
439            .first()
440            .map_or(1, |(_, s)| cmd_total_saved(s, &cost_model))
441            .max(1);
442
443        for (cmd, stats) in sorted.iter().take(10) {
444            let cmd_saved = cmd_total_saved(stats, &cost_model);
445            let cmd_input_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
446            let cmd_pct = if stats.input_tokens > 0 {
447                cmd_input_saved as f64 / stats.input_tokens as f64 * 100.0
448            } else {
449                0.0
450            };
451            let ratio = cmd_saved as f64 / max_cmd_saved as f64;
452            let bar = theme::pad_right(&t.gradient_bar(ratio, 20), 20);
453            let pc = t.pct_color(cmd_pct);
454            let cmd_col = theme::pad_right(
455                &format!("{m}{}{rst}", truncate_cmd(cmd, 14), m = t.muted.fg()),
456                16,
457            );
458            let saved_col =
459                theme::pad_right(&format!("{bold}{pc}{}{rst}", format_big(cmd_saved)), 7);
460            let row = format!(
461                " {cmd_col} {:>4}x {bar} {saved_col}{dim}{cmd_pct:>3.0}%{rst}",
462                stats.count,
463            );
464            out.push(sec_line(&row));
465        }
466
467        if sorted.len() > 10 {
468            out.push(sec_line(&format!(
469                "  {dim}... +{} more commands{rst}",
470                sorted.len() - 10
471            )));
472        }
473        out.push(format!("  {}", t.box_bottom_square(w)));
474    }
475
476    // -- RECENT DAYS section --
477    if store.daily.len() >= 2 {
478        out.push(String::new());
479        out.push(format!("  {}", t.box_top_labeled(w, "RECENT DAYS")));
480
481        let max_day_saved = store
482            .daily
483            .iter()
484            .rev()
485            .take(7)
486            .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
487            .max()
488            .unwrap_or(1)
489            .max(1);
490
491        let recent: Vec<_> = store.daily.iter().rev().take(7).collect();
492        for day in recent.iter().rev() {
493            let day_saved = day_total_saved(day, &cost_model);
494            let day_input_saved = day.input_tokens.saturating_sub(day.output_tokens);
495            let day_pct = if day.input_tokens > 0 {
496                day_input_saved as f64 / day.input_tokens as f64 * 100.0
497            } else {
498                0.0
499            };
500            let pc = t.pct_color(day_pct);
501            let ratio = day_input_saved as f64 / max_day_saved as f64;
502            let day_bar = t.gradient_bar(ratio, 20);
503            let date_short = day.date.get(5..).unwrap_or(&day.date);
504            let date_col = theme::pad_right(&format!("{m}{date_short}{rst}", m = t.muted.fg()), 7);
505            let saved_col =
506                theme::pad_right(&format!("{pc}{bold}{}{rst}", format_big(day_saved)), 9);
507            out.push(sec_line(&format!(
508                "  {date_col} {:>4} cmds  {saved_col} {pc}{day_pct:>5.1}%{rst}  {day_bar}",
509                day.commands,
510            )));
511        }
512        out.push(format!("  {}", t.box_bottom_square(w)));
513    }
514
515    if with_footer {
516        append_gain_footer(&mut out, t, &store);
517    }
518
519    out.join("\n")
520}
521
522/// Appends the dashboard footer (contextual tip, Bug Memory, Context OS panel,
523/// help hints). Kept separate so `gain --deep` can render it *after* the extra
524/// themed sections instead of in the middle of the output.
525fn append_gain_footer(out: &mut Vec<String>, t: &Theme, store: &StatsStore) {
526    let rst = theme::rst();
527    let bold = theme::bold();
528
529    out.push(String::new());
530    out.push(String::new());
531
532    if let Some(tip) = contextual_tip(store) {
533        out.push(format!("    {w}๐Ÿ’ก {tip}{rst}", w = t.warning.fg()));
534        out.push(String::new());
535    }
536
537    {
538        let project_root = std::env::current_dir()
539            .map(|p| p.to_string_lossy().to_string())
540            .unwrap_or_default();
541        if !project_root.is_empty() {
542            let gotcha_store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
543            if gotcha_store.stats.total_errors_detected > 0 || !gotcha_store.gotchas.is_empty() {
544                let a = t.accent.fg();
545                out.push(format!("    {a}๐Ÿง  Bug Memory{rst}"));
546                out.push(format!(
547                    "    {m}   Active gotchas: {}{rst}   Bugs prevented: {}{rst}",
548                    gotcha_store.gotchas.len(),
549                    gotcha_store.stats.total_prevented,
550                    m = t.muted.fg(),
551                ));
552                out.push(String::new());
553            }
554        }
555    }
556
557    {
558        let project_root = std::env::current_dir()
559            .map(|p| p.to_string_lossy().to_string())
560            .unwrap_or_default();
561        let a = t.accent.fg();
562        let m = t.muted.fg();
563
564        let mut ctx_items: Vec<String> = Vec::new();
565
566        if let Some(session) =
567            crate::core::session::SessionState::load_latest_for_project_root(&project_root)
568        {
569            let task_str = session
570                .task
571                .as_ref()
572                .map_or("โ€”", |tk| tk.description.as_str());
573            let task_disp = if task_str.len() > 35 {
574                format!("{}โ€ฆ", &task_str[..task_str.floor_char_boundary(32)])
575            } else {
576                task_str.to_string()
577            };
578            ctx_items.push(format!(
579                "   Session: {bold}{task_disp}{rst}  {m}files={} findings={} terse={}{rst}",
580                session.files_touched.len(),
581                session.findings.len(),
582                if session.terse_mode { "on" } else { "off" },
583            ));
584        }
585
586        let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
587        let active_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
588        if active_facts > 0 {
589            ctx_items.push(format!(
590                "   Knowledge: {bold}{active_facts}{rst} active facts  {m}{} total{rst}",
591                knowledge.facts.len(),
592            ));
593        }
594
595        if let Some(open) = crate::core::graph_provider::open_best_effort(&project_root) {
596            let nc = open.provider.node_count().unwrap_or(0);
597            let ec = open.provider.edge_count().unwrap_or(0);
598            if nc > 0 {
599                let (unit, suffix) = match open.source {
600                    crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
601                        ("nodes", "")
602                    }
603                    crate::core::graph_provider::GraphProviderSource::GraphIndex => {
604                        let max_cfg = crate::core::config::Config::load().graph_index_max_files;
605                        if max_cfg > 0 && nc >= max_cfg as usize {
606                            ("files", " (limit reached)")
607                        } else {
608                            ("files", "")
609                        }
610                    }
611                };
612                ctx_items.push(format!(
613                    "   Graph: {bold}{nc}{rst} {unit}  {bold}{ec}{rst} edges{suffix}",
614                ));
615            }
616        }
617
618        #[cfg(unix)]
619        let daemon_running = crate::daemon::is_daemon_running();
620        #[cfg(not(unix))]
621        let daemon_running = false;
622
623        if daemon_running {
624            ctx_items.push(format!("   Daemon: {c}running{rst}", c = t.success.fg()));
625        } else {
626            ctx_items.push(format!(
627                "   {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
628                w = t.warning.fg()
629            ));
630        }
631
632        if !ctx_items.is_empty() {
633            out.push(format!("    {a}โšก Context OS{rst}"));
634            for item in &ctx_items {
635                out.push(format!("    {item}"));
636            }
637            out.push(String::new());
638        }
639    }
640
641    {
642        // Methodology disclosure (#361): the headline measures compression on
643        // lean-ctx-touched traffic, not the full provider bill โ€” and lean-ctx
644        // itself injects a fixed per-turn prefix that, without provider prompt
645        // caching, is re-billed every turn. State both so the number stays honest.
646        let a = t.accent.fg();
647        let m = t.muted.fg();
648        let overhead = crate::core::context_overhead::ContextOverhead::cached();
649        out.push(format!("    {a}๐Ÿ“ Methodology{rst}"));
650        out.push(format!(
651            "    {m}   Savings = compression on lean-ctx-touched traffic (reads + shell),{rst}"
652        ));
653        out.push(format!(
654            "    {m}   not your full provider bill. lean-ctx adds ~{} tok/turn of context{rst}",
655            overhead.total_tokens(),
656        ));
657        out.push(format!(
658            "    {m}   ({} tool schemas + instructions + rules); without provider prompt{rst}",
659            overhead.tool_count,
660        ));
661        out.push(format!(
662            "    {m}   caching that rides every turn โ†’ net = savings โˆ’ overhead ร— turns.{rst}"
663        ));
664        out.push(String::new());
665    }
666
667    let m = t.muted.fg();
668    out.push(format!(
669        "    {m}๐Ÿ› Found a bug? Run: lean-ctx report-issue{rst}"
670    ));
671    out.push(format!(
672        "    {m}๐Ÿ“Š Help improve lean-ctx: lean-ctx contribute{rst}"
673    ));
674    out.push(format!("    {m}๐Ÿง  View bug memory: lean-ctx gotchas{rst}"));
675
676    out.push(String::new());
677    out.push(String::new());
678}
679
680fn trend_string(store: &StatsStore, up_color: &str, down_color: &str, rst: &str) -> String {
681    if store.daily.len() < 14 {
682        return String::new();
683    }
684    let recent_7: u64 = store
685        .daily
686        .iter()
687        .rev()
688        .take(7)
689        .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
690        .sum();
691    let prev_7: u64 = store
692        .daily
693        .iter()
694        .rev()
695        .skip(7)
696        .take(7)
697        .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
698        .sum();
699    if prev_7 == 0 {
700        return String::new();
701    }
702    let change = ((recent_7 as f64 / prev_7 as f64) - 1.0) * 100.0;
703    if change >= 0.0 {
704        format!("{up_color}+{change:.0}%{rst} vs last week")
705    } else {
706        format!("{down_color}{change:.0}%{rst} vs last week")
707    }
708}
709
710fn contextual_tip(store: &StatsStore) -> Option<String> {
711    let tips = build_tips(store);
712    if tips.is_empty() {
713        return None;
714    }
715    let seed = std::time::SystemTime::now()
716        .duration_since(std::time::UNIX_EPOCH)
717        .unwrap_or_default()
718        .as_secs()
719        / 86400;
720    Some(tips[(seed as usize) % tips.len()].clone())
721}
722
723fn build_tips(store: &StatsStore) -> Vec<String> {
724    let mut tips = Vec::new();
725
726    if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
727        tips.push("Try mode=\"map\" for files you only need as context โ€” shows deps + exports, skips implementation.".into());
728    }
729
730    if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
731        tips.push("Try mode=\"signatures\" for large files โ€” returns only the API surface.".into());
732    }
733
734    if store.cep.total_cache_reads > 0
735        && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
736    {
737        tips.push(
738            "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
739        );
740    }
741
742    if store.total_commands > 50 && store.cep.sessions == 0 {
743        tips.push("Use ctx_session to track your task โ€” enables cross-session memory.".into());
744    }
745
746    if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
747        tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
748    }
749
750    if store.daily.len() >= 7 {
751        tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
752    }
753
754    tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
755    tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
756
757    let cfg = crate::core::config::Config::load();
758    if cfg.theme == "default" {
759        tips.push(
760            "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
761        );
762        tips.push(
763            "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
764        );
765    } else {
766        tips.push(format!(
767            "Current theme: {}. Run lean-ctx theme list to explore others.",
768            cfg.theme
769        ));
770    }
771
772    tips.push(
773        "Create a custom theme: write a TOML file and import it with lean-ctx theme import <file>"
774            .into(),
775    );
776
777    tips
778}
779
780/// Runs the live-updating gain dashboard (1s refresh loop, Ctrl+C to exit).
781pub fn gain_live() {
782    use std::io::Write;
783
784    let interval = std::time::Duration::from_secs(1);
785    let mut line_count = 0usize;
786    let dim = theme::dim();
787    let rst = theme::rst();
788
789    tracing::info!("Live mode (1s refresh) ยท Ctrl+C to exit");
790
791    loop {
792        if line_count > 0 {
793            print!("\x1B[{line_count}A\x1B[J");
794        }
795
796        let tick = std::time::SystemTime::now()
797            .duration_since(std::time::UNIX_EPOCH)
798            .ok()
799            .map(|d| d.as_millis() as u64);
800        let output = format_gain_themed_at(&active_theme(), tick);
801        let footer = format!("\n  {dim}โ–ธ Live ยท updates every 1s ยท Ctrl+C to exit{rst}\n");
802        let full = format!("{output}{footer}");
803        line_count = full.lines().count();
804
805        print!("{full}");
806        let _ = std::io::stdout().flush();
807
808        std::thread::sleep(interval);
809    }
810}