Skip to main content

ai_usagebar/tui/
context.rs

1//! TUI overlay for local Claude Code context-window sessions.
2
3use ratatui::Frame;
4use ratatui::layout::{Constraint, Direction, Layout, Rect};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Clear, Paragraph};
7use ratatui_bubbletea_components::{Help, KeyBinding, ListItem, SelectList};
8
9use crate::context::{ContextScan, ContextSession, ContextUsage};
10use crate::format::local_time_hms;
11use crate::pango::severity_for;
12use crate::theme::Theme;
13use crate::tui::panels::{self, Section};
14use crate::tui::style::bubble_theme;
15
16#[derive(Debug)]
17pub struct ContextState {
18    pub selected: usize,
19    pub detail: bool,
20    pub generation: u64,
21    pub load: ContextLoad,
22    selection_id: Option<String>,
23}
24
25#[derive(Debug)]
26pub enum ContextLoad {
27    Loading,
28    Ready(ContextScan),
29    Error(String),
30}
31
32impl Default for ContextState {
33    fn default() -> Self {
34        Self {
35            selected: 0,
36            detail: false,
37            generation: 0,
38            load: ContextLoad::Loading,
39            selection_id: None,
40        }
41    }
42}
43
44impl ContextState {
45    /// Begin a scan with an identity allocated by the long-lived host app.
46    /// The host, rather than this disposable overlay, owns monotonicity across
47    /// close/reopen cycles.
48    pub fn begin_refresh(&mut self, generation: u64) {
49        if let Some(selection_id) = self
50            .selected_session()
51            .map(|session| session.session_id.clone())
52        {
53            self.selection_id = Some(selection_id);
54        }
55        self.generation = generation;
56        self.load = ContextLoad::Loading;
57    }
58
59    /// Ignore a result from an earlier `r` scan. The host also drops results
60    /// after the overlay closes by having no state to apply them to.
61    pub fn apply_scan(
62        &mut self,
63        generation: u64,
64        result: std::result::Result<ContextScan, String>,
65    ) -> bool {
66        if generation != self.generation {
67            return false;
68        }
69        self.load = match result {
70            Ok(scan) => {
71                self.selected = self
72                    .selection_id
73                    .take()
74                    .and_then(|id| {
75                        scan.sessions
76                            .iter()
77                            .position(|session| session.session_id == id)
78                    })
79                    .unwrap_or_else(|| self.selected.min(scan.sessions.len().saturating_sub(1)));
80                if scan.sessions.is_empty() {
81                    self.detail = false;
82                }
83                ContextLoad::Ready(scan)
84            }
85            Err(error) => {
86                self.selection_id = None;
87                self.detail = false;
88                ContextLoad::Error(error)
89            }
90        };
91        true
92    }
93
94    pub fn selected_session(&self) -> Option<&ContextSession> {
95        match &self.load {
96            ContextLoad::Ready(scan) => scan.sessions.get(self.selected),
97            ContextLoad::Loading | ContextLoad::Error(_) => None,
98        }
99    }
100
101    fn session_count(&self) -> usize {
102        match &self.load {
103            ContextLoad::Ready(scan) => scan.sessions.len(),
104            ContextLoad::Loading | ContextLoad::Error(_) => 0,
105        }
106    }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Action {
111    Continue,
112    Close,
113    Refresh,
114    Quit,
115}
116
117pub fn handle_key(state: &mut ContextState, code: KeyCode, mods: KeyModifiers) -> Action {
118    if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
119        return Action::Quit;
120    }
121    match code {
122        KeyCode::Esc if state.detail => {
123            state.detail = false;
124            Action::Continue
125        }
126        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('c') => Action::Close,
127        KeyCode::Char('r') => Action::Refresh,
128        KeyCode::Enter if state.selected_session().is_some() => {
129            state.detail = true;
130            Action::Continue
131        }
132        KeyCode::Up | KeyCode::Char('k') => {
133            let count = state.session_count();
134            if count > 0 {
135                state.selected = (state.selected + count - 1) % count;
136            }
137            Action::Continue
138        }
139        KeyCode::Down | KeyCode::Char('j') => {
140            let count = state.session_count();
141            if count > 0 {
142                state.selected = (state.selected + 1) % count;
143            }
144            Action::Continue
145        }
146        KeyCode::Home => {
147            state.selected = 0;
148            Action::Continue
149        }
150        KeyCode::End => {
151            let count = state.session_count();
152            state.selected = count.saturating_sub(1);
153            Action::Continue
154        }
155        _ => Action::Continue,
156    }
157}
158
159pub fn render(f: &mut Frame, area: Rect, state: &ContextState, theme: &Theme) {
160    let modal = centered_rect(90, 90, area);
161    f.render_widget(Clear, modal);
162
163    let bubble = bubble_theme(theme);
164    let block = bubble.titled_modal_block(" Claude context ");
165    let inner = block.inner(modal);
166    f.render_widget(block, modal);
167
168    let chunks = Layout::default()
169        .direction(Direction::Vertical)
170        .constraints([Constraint::Min(1), Constraint::Length(1)])
171        .split(inner);
172    match &state.load {
173        ContextLoad::Loading => panels::render(
174            f,
175            chunks[0],
176            theme,
177            &[
178                Section::Spacer,
179                Section::Text {
180                    label: String::new(),
181                    value: "  Scanning recent Claude Code sessions…".into(),
182                },
183            ],
184        ),
185        ContextLoad::Error(error) => panels::render(
186            f,
187            chunks[0],
188            theme,
189            &[
190                Section::Spacer,
191                Section::Text {
192                    label: "Error".into(),
193                    value: error.clone(),
194                },
195                Section::Spacer,
196                Section::Text {
197                    label: String::new(),
198                    value: "Press `r` to retry or `esc` to close.".into(),
199                },
200            ],
201        ),
202        ContextLoad::Ready(scan) if state.detail => {
203            if let Some(session) = scan.sessions.get(state.selected) {
204                panels::render(f, chunks[0], theme, &sections_for(session));
205            }
206        }
207        ContextLoad::Ready(scan) => render_list(f, chunks[0], state, scan, theme),
208    }
209
210    let help = if state.detail {
211        Help::new([
212            KeyBinding::with_keys(["↑/↓", "j/k"], "session"),
213            KeyBinding::new("r", "rescan"),
214            KeyBinding::new("esc", "back"),
215            KeyBinding::new("q", "close"),
216        ])
217    } else {
218        Help::new([
219            KeyBinding::with_keys(["↑/↓", "j/k"], "select"),
220            KeyBinding::new("enter", "details"),
221            KeyBinding::new("r", "rescan"),
222            KeyBinding::with_keys(["q", "esc"], "close"),
223        ])
224    }
225    .theme(bubble);
226    f.render_widget(&help, chunks[1]);
227}
228
229fn render_list(f: &mut Frame, area: Rect, state: &ContextState, scan: &ContextScan, theme: &Theme) {
230    let bubble = bubble_theme(theme);
231    let chunks = Layout::default()
232        .direction(Direction::Vertical)
233        .constraints([Constraint::Length(2), Constraint::Min(1)])
234        .split(area);
235
236    let showing = scan.sessions.len();
237    let mut status = format!("  {showing} recent session");
238    if showing != 1 {
239        status.push('s');
240    }
241    if scan.discovered > showing {
242        status.push_str(&format!(" · {} discovered", scan.discovered));
243    }
244    if scan.skipped > 0 {
245        status.push_str(&format!(
246            " · {} unreadable/invalid records skipped",
247            scan.skipped
248        ));
249    }
250    if scan.walk_capped {
251        status.push_str(" · directory scan capped");
252    }
253    f.render_widget(
254        Paragraph::new(Line::from(vec![
255            Span::styled(status, bubble.muted),
256            Span::styled(
257                "\n  Input-only usage from bounded local transcript tails",
258                bubble.muted,
259            ),
260        ])),
261        chunks[0],
262    );
263
264    if scan.sessions.is_empty() {
265        f.render_widget(
266            Paragraph::new(Line::from(Span::styled(
267                "  No Claude Code session transcripts found.",
268                bubble.muted,
269            ))),
270            chunks[1],
271        );
272        return;
273    }
274
275    let items = scan
276        .sessions
277        .iter()
278        .map(|session| {
279            ListItem::new(session.display_name()).description(session_description(session))
280        })
281        .collect::<Vec<_>>();
282    let mut list = SelectList::new(items).theme(bubble);
283    list.select(Some(state.selected));
284    f.render_widget(&list, chunks[1]);
285}
286
287fn session_description(session: &ContextSession) -> String {
288    let usage = match session.usage {
289        ContextUsage::Available {
290            input_tokens,
291            percent: Some(percent),
292            ..
293        } => format!("{percent}% · {} input tokens", format_tokens(input_tokens)),
294        ContextUsage::Available { input_tokens, .. } => {
295            format!(
296                "{} input tokens · window unknown",
297                format_tokens(input_tokens)
298            )
299        }
300        ContextUsage::Compacted => "compacted · waiting for next response".into(),
301        ContextUsage::Unknown => "context usage unavailable".into(),
302    };
303    let model = session.model.as_deref().unwrap_or("unknown model");
304    format!(
305        "{} · {model} · {usage} · {}",
306        session.project,
307        local_time_hms(session.modified_at)
308    )
309}
310
311pub fn sections_for(session: &ContextSession) -> Vec<Section> {
312    let mut sections = vec![Section::Title {
313        left: session.display_name(),
314        right: Some(format!("Updated {}", local_time_hms(session.modified_at))),
315    }];
316    match session.usage {
317        ContextUsage::Available {
318            input_tokens,
319            window_tokens: Some(window_tokens),
320            percent: Some(percent),
321        } => {
322            sections.push(Section::Spacer);
323            sections.push(Section::Metric {
324                label: "Input context".into(),
325                pct: percent.min(100),
326                severity: severity_for(i32::from(percent)),
327                value_label: format!(
328                    "{percent}% · {} / {} tokens",
329                    format_tokens(input_tokens),
330                    format_tokens(window_tokens)
331                ),
332                footnote: "Latest API input; output tokens are intentionally excluded".into(),
333            });
334        }
335        ContextUsage::Available { input_tokens, .. } => {
336            sections.push(Section::Spacer);
337            sections.push(Section::Text {
338                label: "Input context".into(),
339                value: format!(
340                    "{} tokens · window size is not configured",
341                    format_tokens(input_tokens)
342                ),
343            });
344        }
345        ContextUsage::Compacted => {
346            sections.push(Section::Spacer);
347            sections.push(Section::Text {
348                label: "Input context".into(),
349                value: "Compacted · waiting for the next assistant response".into(),
350            });
351        }
352        ContextUsage::Unknown => {
353            sections.push(Section::Spacer);
354            sections.push(Section::Text {
355                label: "Input context".into(),
356                value: "Unavailable in the bounded transcript tail".into(),
357            });
358        }
359    }
360    sections.push(Section::Spacer);
361    sections.push(Section::Text {
362        label: "Project".into(),
363        value: session.project.clone(),
364    });
365    sections.push(Section::Text {
366        label: "Model".into(),
367        value: session.model.clone().unwrap_or_else(|| "unknown".into()),
368    });
369    sections.push(Section::Text {
370        label: "Session".into(),
371        value: session.session_id.clone(),
372    });
373    sections
374}
375
376fn format_tokens(value: u64) -> String {
377    let digits = value.to_string();
378    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
379    for (index, ch) in digits.chars().enumerate() {
380        if index > 0 && (digits.len() - index).is_multiple_of(3) {
381            out.push(',');
382        }
383        out.push(ch);
384    }
385    out
386}
387
388fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
389    let height = (area.height * percent_y) / 100;
390    let width = (area.width * percent_x) / 100;
391    Rect {
392        x: area.x + (area.width - width) / 2,
393        y: area.y + (area.height - height) / 2,
394        width,
395        height,
396    }
397}
398
399pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
400
401#[cfg(test)]
402mod tests {
403    use chrono::{TimeZone, Utc};
404
405    use super::*;
406
407    fn session(id: &str, usage: ContextUsage) -> ContextSession {
408        ContextSession {
409            session_id: id.into(),
410            title: None,
411            project: "project".into(),
412            model: Some("claude-test".into()),
413            modified_at: Utc.with_ymd_and_hms(2026, 7, 20, 12, 0, 0).unwrap(),
414            usage,
415        }
416    }
417
418    fn scan(ids: &[&str]) -> ContextScan {
419        ContextScan {
420            sessions: ids
421                .iter()
422                .map(|id| session(id, ContextUsage::Unknown))
423                .collect(),
424            discovered: ids.len(),
425            skipped: 0,
426            walk_capped: false,
427        }
428    }
429
430    #[test]
431    fn stale_scan_result_is_discarded() {
432        let mut state = ContextState::default();
433        state.begin_refresh(1);
434        let first = 1;
435        state.begin_refresh(2);
436        let current = 2;
437        assert!(!state.apply_scan(first, Ok(scan(&["old"]))));
438        assert!(state.apply_scan(current, Ok(scan(&["new"]))));
439        assert_eq!(state.selected_session().unwrap().session_id, "new");
440    }
441
442    #[test]
443    fn a_result_from_a_closed_overlay_cannot_land_after_reopen() {
444        let mut reopened = ContextState::default();
445        reopened.begin_refresh(2);
446        assert!(!reopened.apply_scan(1, Ok(scan(&["closed-overlay"]))));
447        assert!(matches!(reopened.load, ContextLoad::Loading));
448    }
449
450    #[test]
451    fn rescan_preserves_the_selected_session_across_reordering() {
452        let mut state = ContextState::default();
453        let generation = 1;
454        state.begin_refresh(generation);
455        state.apply_scan(generation, Ok(scan(&["one", "two"])));
456        state.selected = 1;
457        state.detail = true;
458
459        let generation = 2;
460        state.begin_refresh(generation);
461        state.apply_scan(generation, Ok(scan(&["two", "one"])));
462        assert_eq!(state.selected, 0);
463        assert_eq!(state.selected_session().unwrap().session_id, "two");
464        assert!(state.detail);
465    }
466
467    #[test]
468    fn list_and_detail_navigation_are_bounded_and_wrap() {
469        let mut state = ContextState::default();
470        let generation = 1;
471        state.begin_refresh(generation);
472        state.apply_scan(generation, Ok(scan(&["one", "two"])));
473
474        assert_eq!(
475            handle_key(&mut state, KeyCode::Up, KeyModifiers::NONE),
476            Action::Continue
477        );
478        assert_eq!(state.selected, 1);
479        handle_key(&mut state, KeyCode::Enter, KeyModifiers::NONE);
480        assert!(state.detail);
481        assert_eq!(
482            handle_key(&mut state, KeyCode::Esc, KeyModifiers::NONE),
483            Action::Continue
484        );
485        assert!(!state.detail);
486        assert_eq!(
487            handle_key(&mut state, KeyCode::Esc, KeyModifiers::NONE),
488            Action::Close
489        );
490    }
491
492    #[test]
493    fn refresh_and_global_quit_are_explicit_actions() {
494        let mut state = ContextState::default();
495        assert_eq!(
496            handle_key(&mut state, KeyCode::Char('r'), KeyModifiers::NONE),
497            Action::Refresh
498        );
499        assert_eq!(
500            handle_key(&mut state, KeyCode::Char('c'), KeyModifiers::CONTROL),
501            Action::Quit
502        );
503    }
504
505    #[test]
506    fn detail_sections_use_existing_severity_and_preserve_unknown_states() {
507        let available = session(
508            "available",
509            ContextUsage::Available {
510                input_tokens: 180_000,
511                window_tokens: Some(200_000),
512                percent: Some(90),
513            },
514        );
515        let sections = sections_for(&available);
516        assert!(sections.iter().any(|section| matches!(
517            section,
518            Section::Metric {
519                severity: crate::pacing::PaceSeverity::Critical,
520                value_label,
521                ..
522            } if value_label.contains("180,000 / 200,000")
523        )));
524
525        let compacted = sections_for(&session("compacted", ContextUsage::Compacted));
526        assert!(compacted.iter().any(|section| matches!(
527            section,
528            Section::Text { value, .. } if value.contains("waiting for the next assistant")
529        )));
530    }
531
532    #[test]
533    fn token_formatting_is_grouped_without_locale_state() {
534        assert_eq!(format_tokens(0), "0");
535        assert_eq!(format_tokens(999), "999");
536        assert_eq!(format_tokens(1_234_567), "1,234,567");
537    }
538
539    #[test]
540    fn list_and_detail_render_at_a_common_24_row_terminal() {
541        use ratatui::Terminal;
542        use ratatui::backend::TestBackend;
543
544        let mut state = ContextState::default();
545        let generation = 1;
546        state.begin_refresh(generation);
547        state.apply_scan(generation, Ok(scan(&["one", "two"])));
548        let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
549        terminal
550            .draw(|frame| render(frame, frame.area(), &state, &Theme::default()))
551            .unwrap();
552
553        state.detail = true;
554        terminal
555            .draw(|frame| render(frame, frame.area(), &state, &Theme::default()))
556            .unwrap();
557    }
558}