From 06b0c7cc576de242d4fee6ef41fde86ac0b3bda9 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Mon, 30 Mar 2026 09:50:13 -0700 Subject: [PATCH] feat(tui): add session list screen widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...bg-pta8--tui-session-list-screen-widget.md | 4 +- src/tui/mod.rs | 2 + src/tui/run.rs | 45 ++- src/tui/screens/mod.rs | 5 + src/tui/screens/session_list.rs | 368 ++++++++++++++++++ 5 files changed, 406 insertions(+), 18 deletions(-) create mode 100644 src/tui/screens/mod.rs create mode 100644 src/tui/screens/session_list.rs diff --git a/.beans/claudbg-pta8--tui-session-list-screen-widget.md b/.beans/claudbg-pta8--tui-session-list-screen-widget.md index df390ef..562a5fb 100644 --- a/.beans/claudbg-pta8--tui-session-list-screen-widget.md +++ b/.beans/claudbg-pta8--tui-session-list-screen-widget.md @@ -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 diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3f5fa1b..60c0911 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -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; diff --git a/src/tui/run.rs b/src/tui/run.rs index 3679b37..d0b2dbd 100644 --- a/src/tui/run.rs +++ b/src/tui/run.rs @@ -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; } diff --git a/src/tui/screens/mod.rs b/src/tui/screens/mod.rs new file mode 100644 index 0000000..fd9edb8 --- /dev/null +++ b/src/tui/screens/mod.rs @@ -0,0 +1,5 @@ +//! TUI screen modules. +//! +//! Each sub-module owns the rendering and event-handling logic for one screen. + +pub mod session_list; diff --git a/src/tui/screens/session_list.rs b/src/tui/screens/session_list.rs new file mode 100644 index 0000000..4edd7be --- /dev/null +++ b/src/tui/screens/session_list.rs @@ -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::>() + .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 = 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); + } +}