1use 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
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 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
210pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
212 gain_dashboard(t, tick, true)
213}
214
215pub fn format_gain_body() -> String {
219 gain_dashboard(&active_theme(), None, false)
220}
221
222pub 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)] fn gain_dashboard(t: &Theme, tick: Option<u64>, with_footer: bool) -> String {
232 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 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 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 {
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 {
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 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 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 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 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 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 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
597fn 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 let daemon_running = crate::daemon::is_daemon_running();
698
699 if daemon_running {
700 ctx_items.push(format!(" Daemon: {c}running{rst}", c = t.success.fg()));
701 } else {
702 ctx_items.push(format!(
703 " {w}Daemon: offline{rst} {m}(lean-ctx serve -d for persistent tracking){rst}",
704 w = t.warning.fg()
705 ));
706 }
707
708 if !ctx_items.is_empty() {
709 out.push(format!(" {a}⚡ Context OS{rst}"));
710 for item in &ctx_items {
711 out.push(format!(" {item}"));
712 }
713 out.push(String::new());
714 }
715 }
716
717 {
718 let a = t.accent.fg();
723 let m = t.muted.fg();
724 let overhead = crate::core::context_overhead::ContextOverhead::cached();
725 out.push(format!(" {a}📐 Methodology{rst}"));
726 out.push(format!(
727 " {m} Savings = compression on lean-ctx-touched traffic (reads + shell),{rst}"
728 ));
729 out.push(format!(
730 " {m} not your full provider bill. lean-ctx adds ~{} tok/turn of context{rst}",
731 overhead.total_tokens(),
732 ));
733 out.push(format!(
734 " {m} ({} tool schemas + instructions + rules); without provider prompt{rst}",
735 overhead.tool_count,
736 ));
737 out.push(format!(
738 " {m} caching that rides every turn → net = savings − overhead × turns.{rst}"
739 ));
740 out.push(String::new());
741 }
742
743 let m = t.muted.fg();
744 out.push(format!(
745 " {m}🐛 Found a bug? Run: lean-ctx report-issue{rst}"
746 ));
747 out.push(format!(
748 " {m}📊 Help improve lean-ctx: lean-ctx contribute{rst}"
749 ));
750 out.push(format!(" {m}🧠 View bug memory: lean-ctx gotchas{rst}"));
751
752 out.push(String::new());
753 out.push(String::new());
754}
755
756fn trend_string(store: &StatsStore, up_color: &str, down_color: &str, rst: &str) -> String {
757 if store.daily.len() < 14 {
758 return String::new();
759 }
760 let recent_7: u64 = store
761 .daily
762 .iter()
763 .rev()
764 .take(7)
765 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
766 .sum();
767 let prev_7: u64 = store
768 .daily
769 .iter()
770 .rev()
771 .skip(7)
772 .take(7)
773 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
774 .sum();
775 if prev_7 == 0 {
776 return String::new();
777 }
778 let change = ((recent_7 as f64 / prev_7 as f64) - 1.0) * 100.0;
779 if change >= 0.0 {
780 format!("{up_color}+{change:.0}%{rst} vs last week")
781 } else {
782 format!("{down_color}{change:.0}%{rst} vs last week")
783 }
784}
785
786fn contextual_tip(store: &StatsStore) -> Option<String> {
787 let tips = build_tips(store);
788 if tips.is_empty() {
789 return None;
790 }
791 let seed = std::time::SystemTime::now()
792 .duration_since(std::time::UNIX_EPOCH)
793 .unwrap_or_default()
794 .as_secs()
795 / 86400;
796 Some(tips[(seed as usize) % tips.len()].clone())
797}
798
799fn build_tips(store: &StatsStore) -> Vec<String> {
800 let mut tips = Vec::new();
801
802 if store.cep.modes.get("map").copied().unwrap_or(0) == 0 {
803 tips.push("Try mode=\"map\" for files you only need as context — shows deps + exports, skips implementation.".into());
804 }
805
806 if store.cep.modes.get("signatures").copied().unwrap_or(0) == 0 {
807 tips.push("Try mode=\"signatures\" for large files — returns only the API surface.".into());
808 }
809
810 if store.cep.total_cache_reads > 0
811 && store.cep.total_cache_hits as f64 / store.cep.total_cache_reads as f64 > 0.8
812 {
813 tips.push(
814 "High cache hit rate! Use ctx_compress periodically to keep context compact.".into(),
815 );
816 }
817
818 if store.total_commands > 50 && store.cep.sessions == 0 {
819 tips.push("Use ctx_session to track your task — enables cross-session memory.".into());
820 }
821
822 if store.cep.modes.get("entropy").copied().unwrap_or(0) == 0 && store.total_commands > 20 {
823 tips.push("Try mode=\"entropy\" for maximum compression on large files.".into());
824 }
825
826 if store.daily.len() >= 7 {
827 tips.push("Run lean-ctx gain --graph for a 30-day sparkline chart.".into());
828 }
829
830 tips.push("Run ctx_overview(task) at session start for a task-aware project map.".into());
831 tips.push("Run lean-ctx dashboard for a live web UI with all your stats.".into());
832
833 let cfg = crate::core::config::Config::load();
834 if cfg.theme == "default" {
835 tips.push(
836 "Customize your dashboard! Try: lean-ctx theme set cyberpunk (or neon, ocean, sunset, monochrome)".into(),
837 );
838 tips.push(
839 "Want a unique look? Run lean-ctx theme list to see all available themes.".into(),
840 );
841 } else {
842 tips.push(format!(
843 "Current theme: {}. Run lean-ctx theme list to explore others.",
844 cfg.theme
845 ));
846 }
847
848 tips.push(
849 "Create a custom theme: write a TOML file and import it with lean-ctx theme import <file>"
850 .into(),
851 );
852
853 tips
854}
855
856pub fn gain_live() {
858 use std::io::Write;
859
860 let interval = std::time::Duration::from_secs(1);
861 let mut line_count = 0usize;
862 let dim = theme::dim();
863 let rst = theme::rst();
864
865 tracing::info!("Live mode (1s refresh) · Ctrl+C to exit");
866
867 loop {
868 if line_count > 0 {
869 print!("\x1B[{line_count}A\x1B[J");
870 }
871
872 let tick = std::time::SystemTime::now()
873 .duration_since(std::time::UNIX_EPOCH)
874 .ok()
875 .map(|d| d.as_millis() as u64);
876 let output = format_gain_themed_at(&active_theme(), tick);
877 let footer = format!("\n {dim}▸ Live · updates every 1s · Ctrl+C to exit{rst}\n");
878 let full = format!("{output}{footer}");
879 line_count = full.lines().count();
880
881 print!("{full}");
882 let _ = std::io::stdout().flush();
883
884 std::thread::sleep(interval);
885 }
886}