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