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