1use 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
15pub fn format_gain_themed(t: &Theme) -> String {
17 format_gain_themed_at(t, None)
18}
19
20pub fn format_gain_hero() -> String {
22 format_gain_hero_themed(&active_theme())
23}
24
25pub fn format_gain_hero_themed(t: &Theme) -> String {
27 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 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 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 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 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 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
214pub 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
220pub 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
228pub 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)] fn 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 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 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 {
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 {
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 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 if !store.commands.is_empty() {
491 out.push(format!(" {}", t.box_top_labeled(w, "TOP COMMANDS")));
492 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 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 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 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 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
625fn 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 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 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
894pub 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}