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
19const GREEN: Color = Color::Rgb(52, 211, 153);
20const PURPLE: Color = Color::Rgb(129, 140, 248);
21const BLUE: Color = Color::Rgb(56, 189, 248);
22const YELLOW: Color = Color::Rgb(251, 191, 36);
23const RED: Color = Color::Rgb(248, 113, 113);
24const MUTED: Color = Color::Rgb(107, 107, 136);
25const SURFACE: Color = Color::Rgb(10, 10, 18);
26const BG: Color = Color::Rgb(6, 6, 10);
27
28struct AppState {
29    events: Vec<LeanCtxEvent>,
30    total_saved: u64,
31    total_original: u64,
32    cache_hits: u64,
33    cache_reads: u64,
34    total_calls: u64,
35    files: std::collections::HashMap<String, FileHeat>,
36    gain_score: Option<GainScore>,
37    last_gain_refresh: Instant,
38    quit: bool,
39    focus: usize,
40}
41
42struct FileHeat {
43    access_count: u32,
44    tokens_saved: u64,
45}
46
47impl AppState {
48    fn new() -> Self {
49        let store = crate::core::stats::load();
50        let heatmap = crate::core::heatmap::HeatMap::load();
51        let files = heatmap
52            .entries
53            .values()
54            .map(|e| {
55                (
56                    e.path.clone(),
57                    FileHeat {
58                        access_count: e.access_count,
59                        tokens_saved: e.total_tokens_saved,
60                    },
61                )
62            })
63            .collect();
64        Self {
65            events: Vec::new(),
66            total_saved: store
67                .total_input_tokens
68                .saturating_sub(store.total_output_tokens),
69            total_original: store.total_input_tokens,
70            cache_hits: store.cep.total_cache_hits,
71            cache_reads: store.cep.total_cache_reads,
72            total_calls: store.total_commands,
73            files,
74            gain_score: None,
75            last_gain_refresh: Instant::now(),
76            quit: false,
77            focus: 0,
78        }
79    }
80
81    fn ingest(&mut self, new_events: Vec<LeanCtxEvent>) {
82        for ev in &new_events {
83            match &ev.kind {
84                EventKind::ToolCall {
85                    tool: _,
86                    tokens_original,
87                    tokens_saved,
88                    path,
89                    ..
90                } => {
91                    self.total_saved += tokens_saved;
92                    self.total_original += tokens_original;
93                    self.total_calls += 1;
94                    if let Some(p) = path {
95                        let entry = self.files.entry(p.clone()).or_insert(FileHeat {
96                            access_count: 0,
97                            tokens_saved: 0,
98                        });
99                        entry.access_count += 1;
100                        entry.tokens_saved += tokens_saved;
101                    }
102                }
103                EventKind::CacheHit { path, saved_tokens } => {
104                    self.cache_hits += 1;
105                    self.total_saved += saved_tokens;
106                    let entry = self.files.entry(path.clone()).or_insert(FileHeat {
107                        access_count: 0,
108                        tokens_saved: 0,
109                    });
110                    entry.access_count += 1;
111                    entry.tokens_saved += saved_tokens;
112                }
113                EventKind::Compression { path, .. } => {
114                    let entry = self.files.entry(path.clone()).or_insert(FileHeat {
115                        access_count: 0,
116                        tokens_saved: 0,
117                    });
118                    entry.access_count += 1;
119                }
120                _ => {}
121            }
122        }
123        self.events.extend(new_events);
124        if self.events.len() > 200 {
125            let drain = self.events.len() - 200;
126            self.events.drain(..drain);
127        }
128    }
129
130    fn savings_pct(&self) -> f64 {
131        if self.total_original == 0 {
132            return 0.0;
133        }
134        self.total_saved as f64 / self.total_original as f64 * 100.0
135    }
136
137    fn cache_rate(&self) -> f64 {
138        if self.cache_reads == 0 {
139            return 0.0;
140        }
141        self.cache_hits as f64 / self.cache_reads as f64 * 100.0
142    }
143
144    fn refresh_gain_score(&mut self) {
145        if self.last_gain_refresh.elapsed() < Duration::from_secs(2) {
146            return;
147        }
148        let engine = crate::core::gain::GainEngine::load();
149        self.gain_score = Some(engine.gain_score(None));
150        self.last_gain_refresh = Instant::now();
151    }
152}
153
154pub fn run() -> anyhow::Result<()> {
155    enable_raw_mode()?;
156    stdout().execute(EnterAlternateScreen)?;
157    let backend = ratatui::backend::CrosstermBackend::new(stdout());
158    let mut terminal = Terminal::new(backend)?;
159
160    let mut state = AppState::new();
161    let mut tail = EventTail::new();
162    let tick_rate = Duration::from_millis(200);
163    let mut last_tick = Instant::now();
164
165    loop {
166        terminal.draw(|f| draw(f, &state))?;
167
168        let timeout = tick_rate.saturating_sub(last_tick.elapsed());
169        if event::poll(timeout)? {
170            if let Event::Key(key) = event::read()? {
171                if key.kind == KeyEventKind::Press {
172                    match key.code {
173                        KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
174                        KeyCode::Tab => state.focus = (state.focus + 1) % 5,
175                        KeyCode::Char('1') => state.focus = 0,
176                        KeyCode::Char('2') => state.focus = 1,
177                        KeyCode::Char('3') => state.focus = 2,
178                        KeyCode::Char('4') => state.focus = 3,
179                        KeyCode::Char('5') => state.focus = 4,
180                        _ => {}
181                    }
182                }
183            }
184        }
185
186        if last_tick.elapsed() >= tick_rate {
187            let new = tail.poll();
188            if !new.is_empty() {
189                state.ingest(new);
190            }
191            state.refresh_gain_score();
192            last_tick = Instant::now();
193        }
194
195        if state.quit {
196            break;
197        }
198    }
199
200    disable_raw_mode()?;
201    stdout().execute(LeaveAlternateScreen)?;
202    Ok(())
203}
204
205fn draw(f: &mut ratatui::Frame, state: &AppState) {
206    let size = f.area();
207
208    let header_body = Layout::default()
209        .direction(Direction::Vertical)
210        .constraints([Constraint::Length(3), Constraint::Min(0)])
211        .split(size);
212
213    draw_header(f, header_body[0], state);
214
215    let columns = Layout::default()
216        .direction(Direction::Horizontal)
217        .constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
218        .split(header_body[1]);
219
220    let left = Layout::default()
221        .direction(Direction::Vertical)
222        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
223        .split(columns[0]);
224
225    let right = Layout::default()
226        .direction(Direction::Vertical)
227        .constraints([
228            Constraint::Percentage(38),
229            Constraint::Percentage(37),
230            Constraint::Percentage(25),
231        ])
232        .split(columns[1]);
233
234    draw_live_feed(f, left[0], state);
235    draw_heatmap(f, left[1], state);
236    draw_savings(f, right[0], state);
237    draw_session(f, right[1], state);
238    draw_task_activity(f, right[2], state);
239}
240
241fn draw_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
242    let saved = format_tokens(state.total_saved);
243    let pct = format!("{:.0}%", state.savings_pct());
244    let env_model = std::env::var("LEAN_CTX_MODEL")
245        .or_else(|_| std::env::var("LCTX_MODEL"))
246        .ok();
247    let pricing = ModelPricing::load();
248    let quote = pricing.quote(env_model.as_deref());
249    let cost = format!(
250        "${:.3}",
251        state.total_saved as f64 * quote.cost.input_per_m / 1_000_000.0
252    );
253    let gain_score = state.gain_score.as_ref().map_or(0, |s| s.total);
254    let trend_icon = state.gain_score.as_ref().map_or("─", |s| match s.trend {
255        crate::core::gain::gain_score::Trend::Rising => "▲",
256        crate::core::gain::gain_score::Trend::Stable => "─",
257        crate::core::gain::gain_score::Trend::Declining => "▼",
258    });
259    let trend_color = state.gain_score.as_ref().map_or(MUTED, |s| match s.trend {
260        crate::core::gain::gain_score::Trend::Rising => GREEN,
261        crate::core::gain::gain_score::Trend::Stable => MUTED,
262        crate::core::gain::gain_score::Trend::Declining => YELLOW,
263    });
264
265    let spans = vec![
266        Span::styled(
267            " LeanCTX ",
268            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
269        ),
270        Span::styled("Observatory ", Style::default().fg(MUTED)),
271        Span::raw("   "),
272        Span::styled(format!("{saved} saved"), Style::default().fg(GREEN)),
273        Span::raw("  "),
274        Span::styled(format!("{pct} compression"), Style::default().fg(PURPLE)),
275        Span::raw("  "),
276        Span::styled(format!("{cost} avoided"), Style::default().fg(BLUE)),
277        Span::raw("  "),
278        Span::styled(format!("{gain_score}/100 gain"), Style::default().fg(GREEN)),
279        Span::styled(format!(" {trend_icon}"), Style::default().fg(trend_color)),
280        Span::raw("  "),
281        Span::styled(
282            format!("{} events", state.events.len()),
283            Style::default().fg(MUTED),
284        ),
285    ];
286
287    let header = Paragraph::new(Line::from(spans)).block(
288        Block::default()
289            .borders(Borders::BOTTOM)
290            .border_style(Style::default().fg(Color::Rgb(30, 30, 50))),
291    );
292    f.render_widget(header, area);
293}
294
295fn draw_task_activity(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
296    let block = Block::default()
297        .title(Span::styled(
298            " Task Activity ",
299            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
300        ))
301        .borders(Borders::ALL)
302        .border_style(Style::default().fg(if state.focus == 4 {
303            GREEN
304        } else {
305            Color::Rgb(30, 30, 50)
306        }))
307        .style(Style::default().bg(SURFACE));
308
309    let mut counts: std::collections::HashMap<TaskCategory, u64> = std::collections::HashMap::new();
310    for ev in state.events.iter().rev().take(120) {
311        if let EventKind::ToolCall { tool, .. } = &ev.kind {
312            let cat = TaskClassifier::classify_tool(tool);
313            *counts.entry(cat).or_insert(0) += 1;
314        }
315    }
316
317    let mut rows: Vec<(TaskCategory, u64)> = counts.into_iter().collect();
318    rows.sort_by_key(|x| std::cmp::Reverse(x.1));
319
320    let max_items = area.height.saturating_sub(2) as usize;
321    let items: Vec<ListItem> = if rows.is_empty() {
322        vec![ListItem::new(Line::from(vec![Span::styled(
323            "No tool calls yet.",
324            Style::default().fg(MUTED),
325        )]))]
326    } else {
327        rows.into_iter()
328            .take(max_items)
329            .map(|(cat, n)| {
330                ListItem::new(Line::from(vec![
331                    Span::styled(
332                        format!("{:<14}", cat.label()),
333                        Style::default().fg(Color::Rgb(220, 220, 240)),
334                    ),
335                    Span::styled(format!("{n:>4}"), Style::default().fg(MUTED)),
336                ]))
337            })
338            .collect()
339    };
340
341    let list = List::new(items).block(block);
342    f.render_widget(list, area);
343}
344
345fn draw_live_feed(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
346    let block = Block::default()
347        .title(Span::styled(
348            " Live Feed ",
349            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
350        ))
351        .borders(Borders::ALL)
352        .border_style(Style::default().fg(if state.focus == 0 {
353            GREEN
354        } else {
355            Color::Rgb(30, 30, 50)
356        }))
357        .style(Style::default().bg(SURFACE));
358
359    let visible = area.height.saturating_sub(2) as usize;
360    let start = state.events.len().saturating_sub(visible);
361    let items: Vec<ListItem> = state.events[start..]
362        .iter()
363        .rev()
364        .map(|ev| {
365            let (icon, tool, detail, color) = match &ev.kind {
366                EventKind::ToolCall {
367                    tool,
368                    tokens_original,
369                    tokens_saved,
370                    mode,
371                    ..
372                } => {
373                    let pct = if *tokens_original > 0 {
374                        format!("-{}%", tokens_saved * 100 / tokens_original)
375                    } else {
376                        String::new()
377                    };
378                    let m = mode.as_deref().unwrap_or("");
379                    (
380                        ">>",
381                        tool.as_str(),
382                        format!(
383                            "{} {}t->{}t {}",
384                            m,
385                            tokens_original,
386                            tokens_original - tokens_saved,
387                            pct
388                        ),
389                        GREEN,
390                    )
391                }
392                EventKind::CacheHit { path, saved_tokens } => {
393                    let short = path.rsplit('/').next().unwrap_or(path);
394                    (
395                        "**",
396                        "cache",
397                        format!("{short} {saved_tokens}t saved"),
398                        PURPLE,
399                    )
400                }
401                EventKind::Compression {
402                    path,
403                    strategy,
404                    before_lines,
405                    after_lines,
406                    ..
407                } => {
408                    let short = path.rsplit('/').next().unwrap_or(path);
409                    (
410                        "~~",
411                        "compress",
412                        format!("{short} {strategy} {before_lines}L->{after_lines}L"),
413                        BLUE,
414                    )
415                }
416                EventKind::AgentAction {
417                    agent_id, action, ..
418                } => ("@@", "agent", format!("{agent_id} {action}"), YELLOW),
419                EventKind::KnowledgeUpdate {
420                    category,
421                    key,
422                    action,
423                } => (
424                    "!!",
425                    "knowledge",
426                    format!("{action} {category}/{key}"),
427                    PURPLE,
428                ),
429                EventKind::ThresholdShift {
430                    language,
431                    new_entropy,
432                    new_jaccard,
433                    ..
434                } => (
435                    "~~",
436                    "threshold",
437                    format!("{language} e={new_entropy:.2} j={new_jaccard:.2}"),
438                    MUTED,
439                ),
440                EventKind::BudgetWarning {
441                    role,
442                    dimension,
443                    percent,
444                    ..
445                } => (
446                    "$$",
447                    "budget",
448                    format!("{role} {dimension} {percent}% WARNING"),
449                    YELLOW,
450                ),
451                EventKind::BudgetExhausted {
452                    role, dimension, ..
453                } => ("!!", "budget", format!("{role} {dimension} EXHAUSTED"), RED),
454                EventKind::PolicyViolation { role, tool, reason } => (
455                    "XX",
456                    "policy",
457                    format!("{role} blocked {tool}: {reason}"),
458                    RED,
459                ),
460                EventKind::RoleChanged { from, to } => {
461                    ("->", "role", format!("{from} -> {to}"), BLUE)
462                }
463                EventKind::ProfileChanged { from, to } => {
464                    ("->", "profile", format!("{from} -> {to}"), BLUE)
465                }
466                EventKind::SloViolation {
467                    slo_name, action, ..
468                } => ("!!", "slo", format!("{slo_name} violated → {action}"), RED),
469                EventKind::Anomaly {
470                    metric,
471                    deviation_factor,
472                    ..
473                } => (
474                    "??",
475                    "anomaly",
476                    format!("{metric} {deviation_factor:.1}x StdDev"),
477                    YELLOW,
478                ),
479                EventKind::VerificationWarning {
480                    warning_kind,
481                    detail,
482                    ..
483                } => (
484                    "!?",
485                    "verify",
486                    format!(
487                        "{warning_kind}: {}",
488                        detail.chars().take(40).collect::<String>()
489                    ),
490                    YELLOW,
491                ),
492                EventKind::ThresholdAdapted { language, arm, .. } => (
493                    "~>",
494                    "adapt",
495                    format!("{language}/{arm} threshold adapted"),
496                    BLUE,
497                ),
498            };
499            let ts = &ev.timestamp[11..19.min(ev.timestamp.len())];
500            ListItem::new(Line::from(vec![
501                Span::styled(format!("{ts} "), Style::default().fg(MUTED)),
502                Span::styled(format!("{icon} "), Style::default().fg(color)),
503                Span::styled(
504                    format!("{tool:14}"),
505                    Style::default().fg(color).add_modifier(Modifier::BOLD),
506                ),
507                Span::styled(detail, Style::default().fg(MUTED)),
508            ]))
509        })
510        .collect();
511
512    let list = List::new(items).block(block);
513    f.render_widget(list, area);
514}
515
516fn draw_heatmap(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
517    let block = Block::default()
518        .title(Span::styled(
519            " File Heatmap ",
520            Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
521        ))
522        .borders(Borders::ALL)
523        .border_style(Style::default().fg(if state.focus == 2 {
524            GREEN
525        } else {
526            Color::Rgb(30, 30, 50)
527        }))
528        .style(Style::default().bg(SURFACE));
529
530    let mut files: Vec<_> = state.files.iter().collect();
531    files.sort_by_key(|x| std::cmp::Reverse(x.1.access_count));
532    if files.is_empty() {
533        let msg = Paragraph::new("Waiting for file activity...")
534            .style(Style::default().fg(MUTED))
535            .block(block);
536        f.render_widget(msg, area);
537        return;
538    }
539    let max_access = files.first().map_or(1, |f| f.1.access_count).max(1);
540
541    let visible = (area.height.saturating_sub(2)) as usize;
542    let rows: Vec<Row> = files
543        .iter()
544        .take(visible)
545        .map(|(path, heat)| {
546            let short = path.rsplit('/').next().unwrap_or(path);
547            let bar_len = (heat.access_count as f64 / max_access as f64 * 12.0) as usize;
548            let bar: String = "█".repeat(bar_len) + &"░".repeat(12 - bar_len);
549            Row::new(vec![
550                ratatui::widgets::Cell::from(Span::styled(
551                    format!("{short:20}"),
552                    Style::default().fg(Color::White),
553                )),
554                ratatui::widgets::Cell::from(Span::styled(bar, Style::default().fg(YELLOW))),
555                ratatui::widgets::Cell::from(Span::styled(
556                    format!("{}x", heat.access_count),
557                    Style::default().fg(MUTED),
558                )),
559                ratatui::widgets::Cell::from(Span::styled(
560                    format!("{}t", format_tokens(heat.tokens_saved)),
561                    Style::default().fg(GREEN),
562                )),
563            ])
564        })
565        .collect();
566
567    let table = Table::new(
568        rows,
569        [
570            Constraint::Length(22),
571            Constraint::Length(14),
572            Constraint::Length(6),
573            Constraint::Length(10),
574        ],
575    )
576    .block(block);
577    f.render_widget(table, area);
578}
579
580fn draw_savings(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
581    let block = Block::default()
582        .title(Span::styled(
583            " Token Savings ",
584            Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
585        ))
586        .borders(Borders::ALL)
587        .border_style(Style::default().fg(if state.focus == 1 {
588            GREEN
589        } else {
590            Color::Rgb(30, 30, 50)
591        }))
592        .style(Style::default().bg(SURFACE));
593
594    let inner = block.inner(area);
595    f.render_widget(block, area);
596
597    let chunks = Layout::default()
598        .direction(Direction::Vertical)
599        .constraints([
600            Constraint::Length(2),
601            Constraint::Length(3),
602            Constraint::Length(1),
603            Constraint::Length(2),
604            Constraint::Length(3),
605            Constraint::Min(0),
606        ])
607        .split(inner);
608
609    let pct = state.savings_pct();
610    f.render_widget(
611        Paragraph::new(Line::from(vec![
612            Span::styled(
613                format!(" {} saved ", format_tokens(state.total_saved)),
614                Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
615            ),
616            Span::styled(format!("({pct:.0}%)"), Style::default().fg(MUTED)),
617        ])),
618        chunks[0],
619    );
620
621    let ratio = (pct / 100.0).min(1.0);
622    f.render_widget(
623        Gauge::default()
624            .ratio(ratio)
625            .gauge_style(Style::default().fg(GREEN).bg(BG))
626            .label(format!("{pct:.0}%")),
627        chunks[1],
628    );
629
630    f.render_widget(Paragraph::new(""), chunks[2]);
631
632    let cache_pct = state.cache_rate();
633    f.render_widget(
634        Paragraph::new(Line::from(vec![
635            Span::styled(" Cache Hit Rate ", Style::default().fg(PURPLE)),
636            Span::styled(format!("{cache_pct:.0}%"), Style::default().fg(MUTED)),
637            Span::styled(
638                format!(" ({}/{})", state.cache_hits, state.cache_reads),
639                Style::default().fg(MUTED),
640            ),
641        ])),
642        chunks[3],
643    );
644
645    let cache_ratio = (cache_pct / 100.0).min(1.0);
646    f.render_widget(
647        Gauge::default()
648            .ratio(cache_ratio)
649            .gauge_style(Style::default().fg(PURPLE).bg(BG))
650            .label(format!("{cache_pct:.0}%")),
651        chunks[4],
652    );
653}
654
655fn draw_session(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
656    let block = Block::default()
657        .title(Span::styled(
658            " Session ",
659            Style::default().fg(BLUE).add_modifier(Modifier::BOLD),
660        ))
661        .borders(Borders::ALL)
662        .border_style(Style::default().fg(if state.focus == 3 {
663            GREEN
664        } else {
665            Color::Rgb(30, 30, 50)
666        }))
667        .style(Style::default().bg(SURFACE));
668
669    let cost = state.total_saved as f64 * 2.5 / 1_000_000.0;
670
671    let lines = vec![
672        Line::from(vec![
673            Span::styled("  Calls     ", Style::default().fg(MUTED)),
674            Span::styled(
675                format!("{}", state.total_calls),
676                Style::default().fg(Color::White),
677            ),
678        ]),
679        Line::from(vec![
680            Span::styled("  Files     ", Style::default().fg(MUTED)),
681            Span::styled(
682                format!("{}", state.files.len()),
683                Style::default().fg(Color::White),
684            ),
685        ]),
686        Line::from(vec![
687            Span::styled("  Original  ", Style::default().fg(MUTED)),
688            Span::styled(
689                format_tokens(state.total_original),
690                Style::default().fg(Color::White),
691            ),
692        ]),
693        Line::from(vec![
694            Span::styled("  Sent      ", Style::default().fg(MUTED)),
695            Span::styled(
696                format_tokens(state.total_original.saturating_sub(state.total_saved)),
697                Style::default().fg(Color::White),
698            ),
699        ]),
700        Line::from(vec![
701            Span::styled("  Saved     ", Style::default().fg(MUTED)),
702            Span::styled(format!("${cost:.3}"), Style::default().fg(GREEN)),
703        ]),
704        Line::from(""),
705        Line::from(Span::styled(
706            "  q=quit Tab=focus 1-4=panel",
707            Style::default().fg(Color::Rgb(50, 50, 70)),
708        )),
709    ];
710
711    let paragraph = Paragraph::new(lines).block(block);
712    f.render_widget(paragraph, area);
713}
714
715fn format_tokens(n: u64) -> String {
716    if n >= 1_000_000 {
717        format!("{:.1}M", n as f64 / 1_000_000.0)
718    } else if n >= 1_000 {
719        format!("{:.1}K", n as f64 / 1_000.0)
720    } else {
721        format!("{n}")
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    fn mk_state() -> AppState {
730        AppState {
731            events: Vec::new(),
732            total_saved: 0,
733            total_original: 0,
734            cache_hits: 0,
735            cache_reads: 0,
736            total_calls: 0,
737            files: std::collections::HashMap::new(),
738            gain_score: None,
739            last_gain_refresh: Instant::now(),
740            quit: false,
741            focus: 0,
742        }
743    }
744
745    #[test]
746    fn ingest_toolcall_with_path_populates_heatmap() {
747        let mut s = mk_state();
748        s.ingest(vec![LeanCtxEvent {
749            id: 1,
750            timestamp: "t".to_string(),
751            kind: EventKind::ToolCall {
752                tool: "ctx_read".to_string(),
753                tokens_original: 100,
754                tokens_saved: 80,
755                mode: Some("full".to_string()),
756                duration_ms: 1,
757                path: Some("src/main.rs".to_string()),
758            },
759        }]);
760
761        let entry = s.files.get("src/main.rs").expect("file entry missing");
762        assert_eq!(entry.access_count, 1);
763        assert_eq!(entry.tokens_saved, 80);
764    }
765
766    #[test]
767    fn ingest_compression_counts_access_without_fake_tokens() {
768        let mut s = mk_state();
769        s.ingest(vec![LeanCtxEvent {
770            id: 1,
771            timestamp: "t".to_string(),
772            kind: EventKind::Compression {
773                path: "src/lib.rs".to_string(),
774                before_lines: 100,
775                after_lines: 10,
776                strategy: "entropy".to_string(),
777                kept_line_count: 10,
778                removed_line_count: 90,
779            },
780        }]);
781
782        let entry = s.files.get("src/lib.rs").expect("file entry missing");
783        assert_eq!(entry.access_count, 1);
784        assert_eq!(entry.tokens_saved, 0);
785    }
786}