Skip to main content

lean_ctx/tui/
app.rs

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}