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
parent
1ddf7fb4fe
commit
06b0c7cc57
@ -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…
Reference in New Issue