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