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();
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 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
177pub fn format_gain_themed_at(t: &Theme, tick: Option<u64>) -> String {
179 gain_dashboard(t, tick, true)
180}
181
182pub fn format_gain_body() -> String {
186 gain_dashboard(&active_theme(), None, false)
187}
188
189pub 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)] fn 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 {
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 {
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 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 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 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
522fn 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 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
780pub 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}