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