1use crate::core::events::{EventKind, LeanCtxEvent};
2use crate::core::gain::gain_score::GainScore;
3use crate::core::gain::model_pricing::ModelPricing;
4use crate::core::gain::task_classifier::{TaskCategory, TaskClassifier};
5use crate::tui::event_reader::EventTail;
6use crossterm::event::{self, Event, KeyCode, KeyEventKind};
7use crossterm::terminal::{
8 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
9};
10use crossterm::ExecutableCommand;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Row, Table};
15use ratatui::Terminal;
16use std::io::stdout;
17use std::time::{Duration, Instant};
18
19fn tui_colors() -> TuiTheme {
20 let t = crate::core::theme::load_theme(&crate::core::config::Config::load().theme);
21 let to_ratatui = |c: &crate::core::theme::Color| {
22 let (r, g, b) = c.rgb();
23 Color::Rgb(r, g, b)
24 };
25 TuiTheme {
26 green: to_ratatui(&t.success),
27 muted: to_ratatui(&t.muted),
28 surface: to_ratatui(&t.surface),
29 bg: to_ratatui(&t.background),
30 }
31}
32
33struct TuiTheme {
34 green: Color,
35 muted: Color,
36 surface: Color,
37 bg: Color,
38}
39
40const GREEN: Color = Color::Rgb(52, 211, 153);
41const PURPLE: Color = Color::Rgb(129, 140, 248);
42const BLUE: Color = Color::Rgb(56, 189, 248);
43const YELLOW: Color = Color::Rgb(251, 191, 36);
44const RED: Color = Color::Rgb(248, 113, 113);
45const MUTED: Color = Color::Rgb(107, 107, 136);
46const SURFACE: Color = Color::Rgb(10, 10, 18);
47const BG: Color = Color::Rgb(6, 6, 10);
48
49struct AppState {
50 events: Vec<LeanCtxEvent>,
51 total_saved: u64,
52 total_original: u64,
53 cache_hits: u64,
54 cache_reads: u64,
55 total_calls: u64,
56 files: std::collections::HashMap<String, FileHeat>,
57 gain_score: Option<GainScore>,
58 last_gain_refresh: Instant,
59 quit: bool,
60 focus: usize,
61 filter: EventFilter,
62 search_query: String,
63 search_active: bool,
64}
65
66#[derive(Clone, Copy, PartialEq)]
67enum EventFilter {
68 All,
69 Reads,
70 Shell,
71 Cache,
72 Errors,
73}
74
75impl EventFilter {
76 fn label(self) -> &'static str {
77 match self {
78 Self::All => "all",
79 Self::Reads => "reads",
80 Self::Shell => "shell",
81 Self::Cache => "cache",
82 Self::Errors => "errors",
83 }
84 }
85
86 fn next(self) -> Self {
87 match self {
88 Self::All => Self::Reads,
89 Self::Reads => Self::Shell,
90 Self::Shell => Self::Cache,
91 Self::Cache => Self::Errors,
92 Self::Errors => Self::All,
93 }
94 }
95
96 fn matches(self, ev: &EventKind) -> bool {
97 match self {
98 Self::All => true,
99 Self::Reads => matches!(ev, EventKind::ToolCall { tool, .. } if tool.contains("read")),
100 Self::Shell => matches!(ev, EventKind::ToolCall { tool, .. } if tool.contains("shell")),
101 Self::Cache => matches!(ev, EventKind::CacheHit { .. }),
102 Self::Errors => matches!(
103 ev,
104 EventKind::BudgetExhausted { .. }
105 | EventKind::PolicyViolation { .. }
106 | EventKind::SloViolation { .. }
107 | EventKind::BudgetWarning { .. }
108 | EventKind::VerificationWarning { .. }
109 ),
110 }
111 }
112}
113
114struct FileHeat {
115 access_count: u32,
116 tokens_saved: u64,
117}
118
119impl AppState {
120 fn new() -> Self {
121 let store = crate::core::stats::load();
122 let heatmap = crate::core::heatmap::HeatMap::load();
123 let files = heatmap
124 .entries
125 .values()
126 .map(|e| {
127 (
128 e.path.clone(),
129 FileHeat {
130 access_count: e.access_count,
131 tokens_saved: e.total_tokens_saved,
132 },
133 )
134 })
135 .collect();
136 Self {
137 events: Vec::new(),
138 total_saved: store
139 .total_input_tokens
140 .saturating_sub(store.total_output_tokens),
141 total_original: store.total_input_tokens,
142 cache_hits: store.cep.total_cache_hits,
143 cache_reads: store.cep.total_cache_reads,
144 total_calls: store.total_commands,
145 files,
146 gain_score: None,
147 last_gain_refresh: Instant::now(),
148 quit: false,
149 focus: 0,
150 filter: EventFilter::All,
151 search_query: String::new(),
152 search_active: false,
153 }
154 }
155
156 fn ingest(&mut self, new_events: Vec<LeanCtxEvent>) {
157 for ev in &new_events {
158 match &ev.kind {
159 EventKind::ToolCall {
160 tool: _,
161 tokens_original,
162 tokens_saved,
163 path,
164 ..
165 } => {
166 self.total_saved += tokens_saved;
167 self.total_original += tokens_original;
168 self.total_calls += 1;
169 if let Some(p) = path {
170 let entry = self.files.entry(p.clone()).or_insert(FileHeat {
171 access_count: 0,
172 tokens_saved: 0,
173 });
174 entry.access_count += 1;
175 entry.tokens_saved += tokens_saved;
176 }
177 }
178 EventKind::CacheHit { path, saved_tokens } => {
179 self.cache_hits += 1;
180 self.total_saved += saved_tokens;
181 let entry = self.files.entry(path.clone()).or_insert(FileHeat {
182 access_count: 0,
183 tokens_saved: 0,
184 });
185 entry.access_count += 1;
186 entry.tokens_saved += saved_tokens;
187 }
188 EventKind::Compression { path, .. } => {
189 let entry = self.files.entry(path.clone()).or_insert(FileHeat {
190 access_count: 0,
191 tokens_saved: 0,
192 });
193 entry.access_count += 1;
194 }
195 _ => {}
196 }
197 }
198 self.events.extend(new_events);
199 if self.events.len() > 200 {
200 let drain = self.events.len() - 200;
201 self.events.drain(..drain);
202 }
203 }
204
205 fn savings_pct(&self) -> f64 {
206 if self.total_original == 0 {
207 return 0.0;
208 }
209 self.total_saved as f64 / self.total_original as f64 * 100.0
210 }
211
212 fn cache_rate(&self) -> f64 {
213 if self.cache_reads == 0 {
214 return 0.0;
215 }
216 self.cache_hits as f64 / self.cache_reads as f64 * 100.0
217 }
218
219 fn refresh_gain_score(&mut self) {
220 if self.last_gain_refresh.elapsed() < Duration::from_secs(2) {
221 return;
222 }
223 let engine = crate::core::gain::GainEngine::load();
224 self.gain_score = Some(engine.gain_score(None));
225 self.last_gain_refresh = Instant::now();
226 }
227}
228
229pub fn run() -> anyhow::Result<()> {
230 enable_raw_mode()?;
231 stdout().execute(EnterAlternateScreen)?;
232 let backend = ratatui::backend::CrosstermBackend::new(stdout());
233 let mut terminal = Terminal::new(backend)?;
234
235 let mut state = AppState::new();
236 let mut tail = EventTail::new();
237 let backfill = tail.backfill(20);
240 if !backfill.is_empty() {
241 state.ingest(backfill);
242 }
243 let tick_rate = Duration::from_millis(200);
244 let mut last_tick = Instant::now();
245
246 loop {
247 terminal.draw(|f| draw(f, &state))?;
248
249 let timeout = tick_rate.saturating_sub(last_tick.elapsed());
250 if event::poll(timeout)? {
251 if let Event::Key(key) = event::read()? {
252 if key.kind == KeyEventKind::Press {
253 if state.search_active {
254 match key.code {
255 KeyCode::Esc | KeyCode::Enter => state.search_active = false,
256 KeyCode::Backspace => {
257 state.search_query.pop();
258 }
259 KeyCode::Char(c) => state.search_query.push(c),
260 _ => {}
261 }
262 } else {
263 match key.code {
264 KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
265 KeyCode::Tab => state.focus = (state.focus + 1) % 5,
266 KeyCode::Char('1') => state.focus = 0,
267 KeyCode::Char('2') => state.focus = 1,
268 KeyCode::Char('3') => state.focus = 2,
269 KeyCode::Char('4') => state.focus = 3,
270 KeyCode::Char('5') => state.focus = 4,
271 KeyCode::Char('f') => state.filter = state.filter.next(),
272 KeyCode::Char('/') => {
273 state.search_active = true;
274 state.search_query.clear();
275 }
276 _ => {}
277 }
278 }
279 }
280 }
281 }
282
283 if last_tick.elapsed() >= tick_rate {
284 let new = tail.poll();
285 if !new.is_empty() {
286 state.ingest(new);
287 }
288 state.refresh_gain_score();
289 last_tick = Instant::now();
290 }
291
292 if state.quit {
293 break;
294 }
295 }
296
297 disable_raw_mode()?;
298 stdout().execute(LeaveAlternateScreen)?;
299 Ok(())
300}
301
302fn draw(f: &mut ratatui::Frame, state: &AppState) {
303 let tc = tui_colors();
304 let size = f.area();
305
306 let header_body = Layout::default()
307 .direction(Direction::Vertical)
308 .constraints([Constraint::Length(3), Constraint::Min(0)])
309 .split(size);
310
311 draw_header(f, header_body[0], state);
312
313 let columns = Layout::default()
314 .direction(Direction::Horizontal)
315 .constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
316 .split(header_body[1]);
317
318 let left = Layout::default()
319 .direction(Direction::Vertical)
320 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
321 .split(columns[0]);
322
323 let right = Layout::default()
324 .direction(Direction::Vertical)
325 .constraints([
326 Constraint::Length(5),
327 Constraint::Percentage(35),
328 Constraint::Percentage(35),
329 Constraint::Min(0),
330 ])
331 .split(columns[1]);
332
333 draw_live_feed(f, left[0], state);
334 draw_heatmap(f, left[1], state);
335 draw_gain_score_widget(f, right[0], state, &tc);
336 draw_savings(f, right[1], state);
337 draw_session(f, right[2], state);
338 draw_task_activity(f, right[3], state);
339}
340
341fn draw_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
342 let saved = format_tokens(state.total_saved);
343 let pct = format!("{:.0}%", state.savings_pct());
344 let env_model = std::env::var("LEAN_CTX_MODEL")
345 .or_else(|_| std::env::var("LCTX_MODEL"))
346 .ok();
347 let pricing = ModelPricing::load();
348 let quote = pricing.quote(env_model.as_deref());
349 let cost = format!(
350 "${:.2}",
351 state.total_saved as f64 * quote.cost.input_per_m / 1_000_000.0
352 );
353 let gain_score = state.gain_score.as_ref().map_or(0, |s| s.total);
354 let trend_icon = state.gain_score.as_ref().map_or("─", |s| match s.trend {
355 crate::core::gain::gain_score::Trend::Rising => "▲",
356 crate::core::gain::gain_score::Trend::Stable => "─",
357 crate::core::gain::gain_score::Trend::Declining => "▼",
358 });
359 let trend_color = state.gain_score.as_ref().map_or(MUTED, |s| match s.trend {
360 crate::core::gain::gain_score::Trend::Rising => GREEN,
361 crate::core::gain::gain_score::Trend::Stable => MUTED,
362 crate::core::gain::gain_score::Trend::Declining => YELLOW,
363 });
364
365 let spans = vec![
366 Span::styled(
367 " LeanCTX ",
368 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
369 ),
370 Span::styled("Observatory ", Style::default().fg(MUTED)),
371 Span::raw(" "),
372 Span::styled(format!("{saved} saved"), Style::default().fg(GREEN)),
373 Span::raw(" "),
374 Span::styled(format!("{pct} compression"), Style::default().fg(PURPLE)),
375 Span::raw(" "),
376 Span::styled(format!("{cost} avoided"), Style::default().fg(BLUE)),
377 Span::raw(" "),
378 Span::styled(format!("{gain_score}/100 gain"), Style::default().fg(GREEN)),
379 Span::styled(format!(" {trend_icon}"), Style::default().fg(trend_color)),
380 Span::raw(" "),
381 Span::styled(
382 format!("{} events", state.events.len()),
383 Style::default().fg(MUTED),
384 ),
385 ];
386
387 let header = Paragraph::new(Line::from(spans)).block(
388 Block::default()
389 .borders(Borders::BOTTOM)
390 .border_style(Style::default().fg(Color::Rgb(30, 30, 50))),
391 );
392 f.render_widget(header, area);
393}
394
395fn draw_gain_score_widget(f: &mut ratatui::Frame, area: Rect, state: &AppState, tc: &TuiTheme) {
396 let gain_score = state.gain_score.as_ref().map_or(0, |s| s.total);
397 let default_lvl = crate::core::gain::gain_score::GainLevel {
398 level: 0,
399 title: "Novice",
400 min_score: 0,
401 };
402 let lvl = state
403 .gain_score
404 .as_ref()
405 .map_or(default_lvl, crate::core::gain::gain_score::GainScore::level);
406
407 let block = Block::default()
408 .title(Span::styled(
409 " Gain Score ",
410 Style::default().fg(tc.green).add_modifier(Modifier::BOLD),
411 ))
412 .borders(Borders::ALL)
413 .border_style(Style::default().fg(Color::Rgb(30, 30, 50)))
414 .style(Style::default().bg(tc.surface));
415
416 let inner = block.inner(area);
417 f.render_widget(block, area);
418
419 let chunks = Layout::default()
420 .direction(Direction::Vertical)
421 .constraints([Constraint::Length(1), Constraint::Length(2)])
422 .split(inner);
423
424 let score_line = Line::from(vec![
425 Span::styled(
426 format!(" {gain_score}/100 "),
427 Style::default().fg(tc.green).add_modifier(Modifier::BOLD),
428 ),
429 Span::styled(
430 format!("Lv{} {}", lvl.level, lvl.title),
431 Style::default().fg(tc.muted),
432 ),
433 ]);
434 f.render_widget(Paragraph::new(score_line), chunks[0]);
435
436 let ratio = (gain_score as f64 / 100.0).min(1.0);
437 f.render_widget(
438 Gauge::default()
439 .ratio(ratio)
440 .gauge_style(Style::default().fg(tc.green).bg(tc.bg))
441 .label(format!("{gain_score}%")),
442 chunks[1],
443 );
444}
445
446fn draw_task_activity(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
447 let block = Block::default()
448 .title(Span::styled(
449 " Task Activity ",
450 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
451 ))
452 .borders(Borders::ALL)
453 .border_style(Style::default().fg(if state.focus == 4 {
454 GREEN
455 } else {
456 Color::Rgb(30, 30, 50)
457 }))
458 .style(Style::default().bg(SURFACE));
459
460 let mut counts: std::collections::HashMap<TaskCategory, u64> = std::collections::HashMap::new();
461 for ev in state.events.iter().rev().take(120) {
462 if let EventKind::ToolCall { tool, .. } = &ev.kind {
463 let cat = TaskClassifier::classify_tool(tool);
464 *counts.entry(cat).or_insert(0) += 1;
465 }
466 }
467
468 let mut rows: Vec<(TaskCategory, u64)> = counts.into_iter().collect();
469 rows.sort_by_key(|x| std::cmp::Reverse(x.1));
470
471 let max_items = area.height.saturating_sub(2) as usize;
472 let items: Vec<ListItem> = if rows.is_empty() {
473 vec![ListItem::new(Line::from(vec![Span::styled(
474 "No tool calls yet.",
475 Style::default().fg(MUTED),
476 )]))]
477 } else {
478 rows.into_iter()
479 .take(max_items)
480 .map(|(cat, n)| {
481 ListItem::new(Line::from(vec![
482 Span::styled(
483 format!("{:<14}", cat.label()),
484 Style::default().fg(Color::Rgb(220, 220, 240)),
485 ),
486 Span::styled(format!("{n:>4}"), Style::default().fg(MUTED)),
487 ]))
488 })
489 .collect()
490 };
491
492 let list = List::new(items).block(block);
493 f.render_widget(list, area);
494}
495
496fn draw_live_feed(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
497 let filter_label = if state.filter == EventFilter::All {
498 " Live Feed ".to_string()
499 } else {
500 format!(" Live Feed [{}] ", state.filter.label())
501 };
502 let title_spans = if state.search_active {
503 vec![
504 Span::styled(
505 filter_label,
506 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
507 ),
508 Span::styled(
509 format!(" /{}", state.search_query),
510 Style::default().fg(YELLOW),
511 ),
512 ]
513 } else {
514 vec![Span::styled(
515 filter_label,
516 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
517 )]
518 };
519 let block = Block::default()
520 .title(Line::from(title_spans))
521 .borders(Borders::ALL)
522 .border_style(Style::default().fg(if state.focus == 0 {
523 GREEN
524 } else {
525 Color::Rgb(30, 30, 50)
526 }))
527 .style(Style::default().bg(SURFACE));
528
529 if state.events.is_empty() {
530 let msg = Paragraph::new(vec![
531 Line::from(""),
532 Line::from(Span::styled(
533 " Waiting for events...",
534 Style::default().fg(MUTED),
535 )),
536 Line::from(""),
537 Line::from(Span::styled(
538 " Use lean-ctx in your editor or run:",
539 Style::default().fg(MUTED),
540 )),
541 Line::from(Span::styled(
542 " lean-ctx -c \"git status\"",
543 Style::default().fg(BLUE),
544 )),
545 ])
546 .block(block);
547 f.render_widget(msg, area);
548 return;
549 }
550
551 let visible = area.height.saturating_sub(2) as usize;
552 let filtered_events: Vec<&LeanCtxEvent> = state
553 .events
554 .iter()
555 .filter(|ev| state.filter.matches(&ev.kind))
556 .filter(|ev| {
557 if state.search_query.is_empty() {
558 return true;
559 }
560 let q = &state.search_query;
561 match &ev.kind {
562 EventKind::ToolCall { tool, path, .. } => {
563 tool.contains(q.as_str())
564 || path.as_ref().is_some_and(|p| p.contains(q.as_str()))
565 }
566 EventKind::CacheHit { path, .. } | EventKind::Compression { path, .. } => {
567 path.contains(q.as_str())
568 }
569 _ => false,
570 }
571 })
572 .collect();
573 let start = filtered_events.len().saturating_sub(visible);
574 let items: Vec<ListItem> = filtered_events[start..]
575 .iter()
576 .rev()
577 .map(|ev| {
578 let (icon, tool, detail, color) = match &ev.kind {
579 EventKind::ToolCall {
580 tool,
581 tokens_original,
582 tokens_saved,
583 mode,
584 ..
585 } => {
586 let pct = if *tokens_original > 0 {
587 format!("-{}%", tokens_saved * 100 / tokens_original)
588 } else {
589 String::new()
590 };
591 let m = mode.as_deref().unwrap_or("");
592 (
593 ">>",
594 tool.as_str(),
595 format!(
596 "{} {}t->{}t {}",
597 m,
598 tokens_original,
599 tokens_original - tokens_saved,
600 pct
601 ),
602 GREEN,
603 )
604 }
605 EventKind::CacheHit { path, saved_tokens } => {
606 let short = path.rsplit('/').next().unwrap_or(path);
607 (
608 "**",
609 "cache",
610 format!("{short} {saved_tokens}t saved"),
611 PURPLE,
612 )
613 }
614 EventKind::Compression {
615 path,
616 strategy,
617 before_lines,
618 after_lines,
619 ..
620 } => {
621 let short = path.rsplit('/').next().unwrap_or(path);
622 (
623 "~~",
624 "compress",
625 format!("{short} {strategy} {before_lines}L->{after_lines}L"),
626 BLUE,
627 )
628 }
629 EventKind::AgentAction {
630 agent_id, action, ..
631 } => ("@@", "agent", format!("{agent_id} {action}"), YELLOW),
632 EventKind::KnowledgeUpdate {
633 category,
634 key,
635 action,
636 } => (
637 "!!",
638 "knowledge",
639 format!("{action} {category}/{key}"),
640 PURPLE,
641 ),
642 EventKind::ThresholdShift {
643 language,
644 new_entropy,
645 new_jaccard,
646 ..
647 } => (
648 "~~",
649 "threshold",
650 format!("{language} e={new_entropy:.2} j={new_jaccard:.2}"),
651 MUTED,
652 ),
653 EventKind::BudgetWarning {
654 role,
655 dimension,
656 percent,
657 ..
658 } => (
659 "$$",
660 "budget",
661 format!("{role} {dimension} {percent}% WARNING"),
662 YELLOW,
663 ),
664 EventKind::BudgetExhausted {
665 role, dimension, ..
666 } => ("!!", "budget", format!("{role} {dimension} EXHAUSTED"), RED),
667 EventKind::PolicyViolation { role, tool, reason } => (
668 "XX",
669 "policy",
670 format!("{role} blocked {tool}: {reason}"),
671 RED,
672 ),
673 EventKind::RoleChanged { from, to } => {
674 ("->", "role", format!("{from} -> {to}"), BLUE)
675 }
676 EventKind::ProfileChanged { from, to } => {
677 ("->", "profile", format!("{from} -> {to}"), BLUE)
678 }
679 EventKind::SloViolation {
680 slo_name, action, ..
681 } => ("!!", "slo", format!("{slo_name} violated → {action}"), RED),
682 EventKind::Anomaly {
683 metric,
684 deviation_factor,
685 ..
686 } => (
687 "??",
688 "anomaly",
689 format!("{metric} {deviation_factor:.1}x StdDev"),
690 YELLOW,
691 ),
692 EventKind::VerificationWarning {
693 warning_kind,
694 detail,
695 ..
696 } => (
697 "!?",
698 "verify",
699 format!(
700 "{warning_kind}: {}",
701 detail.chars().take(40).collect::<String>()
702 ),
703 YELLOW,
704 ),
705 EventKind::ThresholdAdapted { language, arm, .. } => (
706 "~>",
707 "adapt",
708 format!("{language}/{arm} threshold adapted"),
709 BLUE,
710 ),
711 };
712 let ts = &ev.timestamp[11..19.min(ev.timestamp.len())];
713 ListItem::new(Line::from(vec![
714 Span::styled(format!("{ts} "), Style::default().fg(MUTED)),
715 Span::styled(format!("{icon} "), Style::default().fg(color)),
716 Span::styled(
717 format!("{tool:14}"),
718 Style::default().fg(color).add_modifier(Modifier::BOLD),
719 ),
720 Span::styled(detail, Style::default().fg(MUTED)),
721 ]))
722 })
723 .collect();
724
725 let list = List::new(items).block(block);
726 f.render_widget(list, area);
727}
728
729fn draw_heatmap(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
730 let block = Block::default()
731 .title(Span::styled(
732 " File Heatmap ",
733 Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
734 ))
735 .borders(Borders::ALL)
736 .border_style(Style::default().fg(if state.focus == 2 {
737 GREEN
738 } else {
739 Color::Rgb(30, 30, 50)
740 }))
741 .style(Style::default().bg(SURFACE));
742
743 let mut files: Vec<_> = state.files.iter().collect();
744 files.sort_by_key(|x| std::cmp::Reverse(x.1.access_count));
745 if files.is_empty() {
746 let msg = Paragraph::new("Waiting for file activity...")
747 .style(Style::default().fg(MUTED))
748 .block(block);
749 f.render_widget(msg, area);
750 return;
751 }
752 let max_access = files.first().map_or(1, |f| f.1.access_count).max(1);
753
754 let visible = (area.height.saturating_sub(2)) as usize;
755 let rows: Vec<Row> = files
756 .iter()
757 .take(visible)
758 .map(|(path, heat)| {
759 let short = path.rsplit('/').next().unwrap_or(path);
760 let bar_len = (heat.access_count as f64 / max_access as f64 * 12.0) as usize;
761 let bar: String = "█".repeat(bar_len) + &"░".repeat(12 - bar_len);
762 Row::new(vec![
763 ratatui::widgets::Cell::from(Span::styled(
764 format!("{short:20}"),
765 Style::default().fg(Color::White),
766 )),
767 ratatui::widgets::Cell::from(Span::styled(bar, Style::default().fg(YELLOW))),
768 ratatui::widgets::Cell::from(Span::styled(
769 format!("{}x", heat.access_count),
770 Style::default().fg(MUTED),
771 )),
772 ratatui::widgets::Cell::from(Span::styled(
773 format!("{}t", format_tokens(heat.tokens_saved)),
774 Style::default().fg(GREEN),
775 )),
776 ])
777 })
778 .collect();
779
780 let table = Table::new(
781 rows,
782 [
783 Constraint::Length(22),
784 Constraint::Length(14),
785 Constraint::Length(6),
786 Constraint::Length(10),
787 ],
788 )
789 .block(block);
790 f.render_widget(table, area);
791}
792
793fn draw_savings(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
794 let block = Block::default()
795 .title(Span::styled(
796 " Token Savings ",
797 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
798 ))
799 .borders(Borders::ALL)
800 .border_style(Style::default().fg(if state.focus == 1 {
801 GREEN
802 } else {
803 Color::Rgb(30, 30, 50)
804 }))
805 .style(Style::default().bg(SURFACE));
806
807 let inner = block.inner(area);
808 f.render_widget(block, area);
809
810 let chunks = Layout::default()
811 .direction(Direction::Vertical)
812 .constraints([
813 Constraint::Length(2),
814 Constraint::Length(3),
815 Constraint::Length(1),
816 Constraint::Length(2),
817 Constraint::Length(3),
818 Constraint::Min(0),
819 ])
820 .split(inner);
821
822 let pct = state.savings_pct();
823 f.render_widget(
824 Paragraph::new(Line::from(vec![
825 Span::styled(
826 format!(" {} saved ", format_tokens(state.total_saved)),
827 Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
828 ),
829 Span::styled(format!("({pct:.0}%)"), Style::default().fg(MUTED)),
830 ])),
831 chunks[0],
832 );
833
834 let ratio = (pct / 100.0).min(1.0);
835 f.render_widget(
836 Gauge::default()
837 .ratio(ratio)
838 .gauge_style(Style::default().fg(GREEN).bg(BG))
839 .label(format!("{pct:.0}%")),
840 chunks[1],
841 );
842
843 f.render_widget(Paragraph::new(""), chunks[2]);
844
845 let cache_pct = state.cache_rate();
846 f.render_widget(
847 Paragraph::new(Line::from(vec![
848 Span::styled(" Cache Hit Rate ", Style::default().fg(PURPLE)),
849 Span::styled(format!("{cache_pct:.0}%"), Style::default().fg(MUTED)),
850 Span::styled(
851 format!(" ({}/{})", state.cache_hits, state.cache_reads),
852 Style::default().fg(MUTED),
853 ),
854 ])),
855 chunks[3],
856 );
857
858 let cache_ratio = (cache_pct / 100.0).min(1.0);
859 f.render_widget(
860 Gauge::default()
861 .ratio(cache_ratio)
862 .gauge_style(Style::default().fg(PURPLE).bg(BG))
863 .label(format!("{cache_pct:.0}%")),
864 chunks[4],
865 );
866}
867
868fn draw_session(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
869 let block = Block::default()
870 .title(Span::styled(
871 " Session ",
872 Style::default().fg(BLUE).add_modifier(Modifier::BOLD),
873 ))
874 .borders(Borders::ALL)
875 .border_style(Style::default().fg(if state.focus == 3 {
876 GREEN
877 } else {
878 Color::Rgb(30, 30, 50)
879 }))
880 .style(Style::default().bg(SURFACE));
881
882 let cost = state.total_saved as f64 * 2.5 / 1_000_000.0;
883
884 let lines = vec![
885 Line::from(vec![
886 Span::styled(" Calls ", Style::default().fg(MUTED)),
887 Span::styled(
888 format!("{}", state.total_calls),
889 Style::default().fg(Color::White),
890 ),
891 ]),
892 Line::from(vec![
893 Span::styled(" Files ", Style::default().fg(MUTED)),
894 Span::styled(
895 format!("{}", state.files.len()),
896 Style::default().fg(Color::White),
897 ),
898 ]),
899 Line::from(vec![
900 Span::styled(" Original ", Style::default().fg(MUTED)),
901 Span::styled(
902 format_tokens(state.total_original),
903 Style::default().fg(Color::White),
904 ),
905 ]),
906 Line::from(vec![
907 Span::styled(" Sent ", Style::default().fg(MUTED)),
908 Span::styled(
909 format_tokens(state.total_original.saturating_sub(state.total_saved)),
910 Style::default().fg(Color::White),
911 ),
912 ]),
913 Line::from(vec![
914 Span::styled(" Saved ", Style::default().fg(MUTED)),
915 Span::styled(format!("${cost:.3}"), Style::default().fg(GREEN)),
916 ]),
917 Line::from(""),
918 Line::from(Span::styled(
919 " q=quit Tab=focus 1-5=panel f=filter /=search",
920 Style::default().fg(Color::Rgb(50, 50, 70)),
921 )),
922 ];
923
924 let paragraph = Paragraph::new(lines).block(block);
925 f.render_widget(paragraph, area);
926}
927
928fn format_tokens(n: u64) -> String {
929 if n >= 1_000_000 {
930 format!("{:.1}M", n as f64 / 1_000_000.0)
931 } else if n >= 1_000 {
932 format!("{:.1}K", n as f64 / 1_000.0)
933 } else {
934 format!("{n}")
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 fn mk_state() -> AppState {
943 AppState {
944 events: Vec::new(),
945 total_saved: 0,
946 total_original: 0,
947 cache_hits: 0,
948 cache_reads: 0,
949 total_calls: 0,
950 files: std::collections::HashMap::new(),
951 gain_score: None,
952 last_gain_refresh: Instant::now(),
953 quit: false,
954 focus: 0,
955 filter: EventFilter::All,
956 search_query: String::new(),
957 search_active: false,
958 }
959 }
960
961 #[test]
962 fn ingest_toolcall_with_path_populates_heatmap() {
963 let mut s = mk_state();
964 s.ingest(vec![LeanCtxEvent {
965 id: 1,
966 timestamp: "t".to_string(),
967 kind: EventKind::ToolCall {
968 tool: "ctx_read".to_string(),
969 tokens_original: 100,
970 tokens_saved: 80,
971 mode: Some("full".to_string()),
972 duration_ms: 1,
973 path: Some("src/main.rs".to_string()),
974 },
975 }]);
976
977 let entry = s.files.get("src/main.rs").expect("file entry missing");
978 assert_eq!(entry.access_count, 1);
979 assert_eq!(entry.tokens_saved, 80);
980 }
981
982 #[test]
983 fn ingest_compression_counts_access_without_fake_tokens() {
984 let mut s = mk_state();
985 s.ingest(vec![LeanCtxEvent {
986 id: 1,
987 timestamp: "t".to_string(),
988 kind: EventKind::Compression {
989 path: "src/lib.rs".to_string(),
990 before_lines: 100,
991 after_lines: 10,
992 strategy: "entropy".to_string(),
993 kept_line_count: 10,
994 removed_line_count: 90,
995 },
996 }]);
997
998 let entry = s.files.get("src/lib.rs").expect("file entry missing");
999 assert_eq!(entry.access_count, 1);
1000 assert_eq!(entry.tokens_saved, 0);
1001 }
1002
1003 #[test]
1006 fn dashboard_snapshot_renders_all_panels() {
1007 use ratatui::backend::TestBackend;
1008 use ratatui::Terminal;
1009
1010 let mut state = mk_state();
1011 state.total_saved = 515_300_000;
1012 state.total_original = 752_000_000;
1013 state.total_calls = 22_599;
1014 state.ingest(vec![
1015 LeanCtxEvent {
1016 id: 1,
1017 timestamp: "2026-06-03T20:00".to_string(),
1018 kind: EventKind::ToolCall {
1019 tool: "ctx_read".to_string(),
1020 tokens_original: 4200,
1021 tokens_saved: 3360,
1022 mode: Some("map".to_string()),
1023 duration_ms: 5,
1024 path: Some("src/core/stats/format.rs".to_string()),
1025 },
1026 },
1027 LeanCtxEvent {
1028 id: 2,
1029 timestamp: "2026-06-03T20:01".to_string(),
1030 kind: EventKind::CacheHit {
1031 path: "src/core/theme.rs".to_string(),
1032 saved_tokens: 1200,
1033 },
1034 },
1035 ]);
1036
1037 let backend = TestBackend::new(120, 40);
1038 let mut terminal = Terminal::new(backend).expect("terminal");
1039 terminal
1040 .draw(|f| draw(f, &state))
1041 .expect("draw must not panic");
1042
1043 let backend = terminal.backend();
1044 println!("{backend:?}");
1045
1046 let text: String = backend
1047 .buffer()
1048 .content
1049 .iter()
1050 .map(ratatui::buffer::Cell::symbol)
1051 .collect();
1052 assert!(text.contains("LeanCTX"), "header brand missing from render");
1053 assert!(text.contains("Gain Score"), "gain score panel missing");
1054 assert!(text.contains("Heatmap"), "heatmap panel missing");
1055 }
1056}