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