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