Skip to main content

lean_ctx/tui/
app.rs

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    /// Renders the full observatory layout off-screen and verifies every panel
998    /// is laid out without panicking. Run with `--nocapture` to eyeball the grid.
999    #[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}