1use 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 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 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, §ions_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}