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