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