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}