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