Skip to main content

lean_ctx/tui/
app.rs

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}