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        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}