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::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 pub fn new(layout: ContextLayout) -> Self {
50 Self {
51 layout,
52 ..Self::default()
53 }
54 }
55
56 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 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
174pub 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, §ions_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
402pub(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}