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