feat(tui): add session list screen widget

Renders full-screen session list using ratatui Table with REVERSED
selection highlight. Columns: ID, Date, Project (tail-truncated), Model,
Msgs, Agents. Handles Up/k/Down/j nav, Enter→transcript, q/Esc→quit.
Wired into event loop render dispatch.

Closes claudbg-pta8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 1ddf7fb4fe
commit 06b0c7cc57

@ -1,11 +1,11 @@
---
# claudbg-pta8
title: 'TUI: session list screen widget'
status: todo
status: in-progress
type: feature
priority: normal
created_at: 2026-03-30T04:45:53Z
updated_at: 2026-03-30T04:49:03Z
updated_at: 2026-03-30T16:47:21Z
parent: claudbg-i6l2
blocked_by:
- claudbg-nq36

@ -2,6 +2,8 @@
//!
//! - [`state`] — application state model (pure data, no I/O)
//! - [`run`] — terminal setup, main event loop, and teardown
//! - [`screens`] — per-screen rendering and event-handling logic
pub mod run;
pub mod screens;
pub mod state;

@ -19,11 +19,10 @@ use ratatui::crossterm::terminal::{
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Alignment;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::error::Result;
use crate::tui::state::AppState;
use crate::tui::screens::session_list::{handle_session_list_event, render_session_list};
use crate::tui::state::{AppState, Screen};
// ---------------------------------------------------------------------------
// RAII terminal guard
@ -81,19 +80,20 @@ fn install_panic_hook() {
// Rendering
// ---------------------------------------------------------------------------
/// Draw a single frame.
///
/// Currently renders a placeholder block. Future tickets will replace this
/// with proper screen-dispatched rendering.
fn render(frame: &mut ratatui::Frame, _state: &AppState) {
/// Draw a single frame, dispatching to the appropriate screen renderer.
fn render(frame: &mut ratatui::Frame, state: &AppState) {
let area = frame.area();
let block = Block::default()
.title(" claudbg TUI ")
.borders(Borders::ALL);
let paragraph = Paragraph::new("Press q or Esc to quit.")
.block(block)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
match &state.screen {
Screen::SessionList => render_session_list(frame, area, state),
// Transcript and sub-agent screens are rendered by future tickets.
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
// Placeholder until transcript screen is implemented.
let block = ratatui::widgets::Block::default()
.title(" Transcript ")
.borders(ratatui::widgets::Borders::ALL);
frame.render_widget(block, area);
}
}
}
// ---------------------------------------------------------------------------
@ -101,9 +101,22 @@ fn render(frame: &mut ratatui::Frame, _state: &AppState) {
// ---------------------------------------------------------------------------
/// Handle a single crossterm [`Event`], mutating `state` as needed.
///
/// Dispatches to the appropriate screen handler first; falls back to global
/// keybindings for any event not consumed by the screen.
fn handle_event(event: Event, state: &mut AppState) {
let consumed = match &state.screen {
Screen::SessionList => handle_session_list_event(event.clone(), state),
// Future screens will add their own handlers here.
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => false,
};
if consumed {
return;
}
// Global fallback for screens that don't handle quit/back themselves.
if let Event::Key(key) = event {
// Only react to key-press events (ignore release/repeat on Windows).
if key.kind != KeyEventKind::Press {
return;
}

@ -0,0 +1,5 @@
//! TUI screen modules.
//!
//! Each sub-module owns the rendering and event-handling logic for one screen.
pub mod session_list;

@ -0,0 +1,368 @@
//! Session-list screen: the TUI home screen.
//!
//! Renders a full-screen table of sessions and handles navigation within it.
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Text;
use ratatui::widgets::{Block, Borders, Row, Table, TableState};
use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
// Project path truncation
// ---------------------------------------------------------------------------
/// Truncate `path` so that it fits in `max_chars`, prefixing with `…` if
/// truncated. Truncates from the *left* (keeps the tail of the path, which
/// is most informative).
fn truncate_project(path: &str, max_chars: usize) -> String {
// Count chars, not bytes.
let char_count = path.chars().count();
if char_count <= max_chars {
path.to_string()
} else {
// Keep the last (max_chars - 1) chars and prepend the ellipsis.
let keep: String = path.chars().rev().take(max_chars - 1).collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{keep}")
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Draw the session-list screen onto `area` of the given frame.
pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Column constraints:
// ID (8) | Date (20) | Project (dynamic) | Model (20) | Msgs (6) | Agents (7)
// Fixed columns total = 8 + 1 + 20 + 1 + 20 + 1 + 6 + 1 + 7 = 65 chars + borders
let widths = [
Constraint::Length(8),
Constraint::Length(20),
Constraint::Min(10), // project — gets remaining space
Constraint::Length(20),
Constraint::Length(6),
Constraint::Length(7),
];
let header = Row::new(vec!["ID", "Date", "Project", "Model", "Msgs", "Agents"])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
// Compute the max width available for the project column so we can truncate.
// Fixed widths: 8+20+20+6+7 = 61, plus 5 separators = 66, plus 2 borders = 68.
// Use 30 chars as a safe default; the Min constraint will expand it.
let project_max: usize = area
.width
.saturating_sub(68) // subtract fixed columns + separators + borders
.max(10) as usize;
let project_display_max = project_max + 30; // generous — actual render clips
let rows: Vec<Row> = state
.sessions
.iter()
.map(|s| {
let project = truncate_project(&s.project, project_display_max);
Row::new(vec![
s.short_id.clone(),
s.date.clone(),
project,
s.model.clone(),
s.msg_count.to_string(),
s.agent_count.to_string(),
])
})
.collect();
let block = Block::default()
.title(" Sessions ")
.borders(Borders::ALL);
let highlight_style = Style::default().add_modifier(Modifier::REVERSED);
let table = Table::new(rows, widths)
.header(header)
.block(block)
.row_highlight_style(highlight_style)
.highlight_symbol("► ");
// Sync ratatui's TableState with our AppState selection.
let mut table_state = TableState::default();
if !state.sessions.is_empty() {
table_state.select(Some(state.list_selected));
}
f.render_stateful_widget(table, area, &mut table_state);
// Render an "empty" hint when there are no sessions.
if state.sessions.is_empty() {
let hint = ratatui::widgets::Paragraph::new(Text::raw("No sessions found."))
.alignment(ratatui::layout::Alignment::Center);
// Place the hint in the inner area (inside the block border).
let inner = Rect {
x: area.x + 1,
y: area.y + area.height / 2,
width: area.width.saturating_sub(2),
height: 1,
};
f.render_widget(hint, inner);
}
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle a crossterm [`Event`] for the session-list screen.
///
/// Returns `true` if the event was consumed (the caller should not process it
/// further), `false` if it was not handled by this screen.
pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return false;
};
if key.kind != KeyEventKind::Press {
return false;
}
match key.code {
// Navigate up.
KeyCode::Up | KeyCode::Char('k') => {
state.list_selected = state.list_selected.saturating_sub(1);
true
}
// Navigate down.
KeyCode::Down | KeyCode::Char('j') => {
if !state.sessions.is_empty() {
state.list_selected =
(state.list_selected + 1).min(state.sessions.len() - 1);
}
true
}
// Enter session transcript.
KeyCode::Enter => {
if let Some(item) = state.sessions.get(state.list_selected) {
let full_id = item.full_id.clone();
state.enter_transcript(full_id);
}
true
}
// Quit.
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
state.should_quit = true;
true
}
// Help overlay.
KeyCode::Char('?') => {
state.show_help = true;
true
}
_ => false,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
use crate::tui::state::{Screen, SessionListItem};
fn make_item(short_id: &str, full_id: &str) -> SessionListItem {
SessionListItem {
short_id: short_id.to_string(),
full_id: full_id.to_string(),
date: "2026-03-29 14:32:01".to_string(),
project: "/home/user/project".to_string(),
model: "claude-sonnet-4-6".to_string(),
msg_count: 10,
agent_count: 2,
}
}
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
// ── truncate_project ────────────────────────────────────────────────────
#[test]
fn truncate_short_path_unchanged() {
assert_eq!(truncate_project("/home/user", 30), "/home/user");
}
#[test]
fn truncate_exact_length_unchanged() {
let path = "a".repeat(30);
assert_eq!(truncate_project(&path, 30), path);
}
#[test]
fn truncate_long_path_prefixed_with_ellipsis() {
let path = "/very/long/path/that/exceeds/the/limit/foo/bar";
let result = truncate_project(path, 20);
assert!(result.starts_with('…'));
assert_eq!(result.chars().count(), 20);
}
// ── handle_session_list_event ───────────────────────────────────────────
#[test]
fn down_increments_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 0;
let consumed = handle_session_list_event(press(KeyCode::Down), &mut state);
assert!(consumed);
assert_eq!(state.list_selected, 1);
}
#[test]
fn down_clamps_at_end() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Down), &mut state);
// Should remain at 0 (only one item).
assert_eq!(state.list_selected, 0);
}
#[test]
fn up_decrements_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 1;
let consumed = handle_session_list_event(press(KeyCode::Up), &mut state);
assert!(consumed);
assert_eq!(state.list_selected, 0);
}
#[test]
fn up_clamps_at_zero() {
let mut state = AppState::new();
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Up), &mut state);
assert_eq!(state.list_selected, 0);
}
#[test]
fn j_increments_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Char('j')), &mut state);
assert_eq!(state.list_selected, 1);
}
#[test]
fn k_decrements_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 1;
handle_session_list_event(press(KeyCode::Char('k')), &mut state);
assert_eq!(state.list_selected, 0);
}
#[test]
fn enter_transitions_to_transcript() {
let mut state = AppState::new();
let full = "aaaaaaaa-0000-0000-0000-000000000000";
state.sessions.push(make_item("aaaaaaaa", full));
state.list_selected = 0;
let consumed = handle_session_list_event(press(KeyCode::Enter), &mut state);
assert!(consumed);
assert_eq!(
state.screen,
Screen::Transcript {
session_id: full.to_string()
}
);
}
#[test]
fn enter_on_empty_sessions_does_not_crash() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Enter), &mut state);
assert!(consumed);
assert_eq!(state.screen, Screen::SessionList);
}
#[test]
fn q_sets_should_quit() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state);
assert!(consumed);
assert!(state.should_quit);
}
#[test]
fn big_q_sets_should_quit() {
let mut state = AppState::new();
handle_session_list_event(press(KeyCode::Char('Q')), &mut state);
assert!(state.should_quit);
}
#[test]
fn esc_sets_should_quit() {
let mut state = AppState::new();
handle_session_list_event(press(KeyCode::Esc), &mut state);
assert!(state.should_quit);
}
#[test]
fn question_mark_shows_help() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('?')), &mut state);
assert!(consumed);
assert!(state.show_help);
}
#[test]
fn unhandled_key_returns_false() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('x')), &mut state);
assert!(!consumed);
}
#[test]
fn key_release_not_consumed() {
let mut state = AppState::new();
let consumed = handle_session_list_event(release(KeyCode::Char('q')), &mut state);
assert!(!consumed);
assert!(!state.should_quit);
}
#[test]
fn non_key_event_not_consumed() {
let mut state = AppState::new();
let consumed = handle_session_list_event(Event::FocusGained, &mut state);
assert!(!consumed);
}
}
Loading…
Cancel
Save