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