//! 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, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState}; use crate::filter::Filter; use crate::tui::state::{AppState, Focus}; // --------------------------------------------------------------------------- // 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) { // Split the screen: top portion for the sessions table, bottom 3 rows for the // filter bar (border top + content + border bottom). let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(area); let table_area = chunks[0]; let filter_area = chunks[1]; // ── Sessions table ─────────────────────────────────────────────────────── // 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 = table_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 // Apply active filter to the session rows. let active_filter = Filter::parse(&state.filter_active).ok(); let filtered_sessions: Vec<_> = state .sessions .iter() .filter(|s| { active_filter .as_ref() .map(|f| f.matches(*s)) .unwrap_or(true) }) .collect(); let rows: Vec = filtered_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 table_title = if state.filter_active.is_empty() { " Sessions ".to_string() } else { format!(" Sessions [filter: {}] ", state.filter_active) }; let block = Block::default().title(table_title).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 !filtered_sessions.is_empty() { let clamped = state.list_selected.min(filtered_sessions.len() - 1); table_state.select(Some(clamped)); } f.render_stateful_widget(table, table_area, &mut table_state); // Render an "empty" hint when there are no sessions. if filtered_sessions.is_empty() { let hint = Paragraph::new(Text::raw(if state.filter_active.is_empty() { "No sessions found." } else { "No sessions match the current filter." })) .alignment(ratatui::layout::Alignment::Center); // Place the hint in the inner area (inside the block border). let inner = Rect { x: table_area.x + 1, y: table_area.y + table_area.height / 2, width: table_area.width.saturating_sub(2), height: 1, }; f.render_widget(hint, inner); } // ── Filter bar ─────────────────────────────────────────────────────────── render_filter_bar(f, filter_area, state); } /// Draw the filter input bar at the bottom of the screen. fn render_filter_bar(f: &mut Frame, area: Rect, state: &AppState) { let focused = state.focus == Focus::FilterInput; let border_style = if focused { Style::default().fg(Color::Yellow) } else { Style::default() }; let block = Block::default() .title(" Filter ") .borders(Borders::ALL) .border_style(border_style); // Build the content line: label + input text (+ cursor when focused). let label = Span::styled("Filter: ", Style::default().add_modifier(Modifier::BOLD)); let input_text = if focused { // Show a block cursor at end of input. let mut spans = vec![label, Span::raw(state.filter_input.clone())]; spans.push(Span::styled("█", Style::default().fg(Color::Yellow))); Line::from(spans) } else if state.filter_input.is_empty() && state.filter_active.is_empty() { Line::from(vec![ label, Span::styled( "Press '/', 't', or Tab to focus — type a query and press Enter", Style::default().fg(Color::DarkGray), ), ]) } else { Line::from(vec![label, Span::raw(state.filter_input.clone())]) }; let paragraph = Paragraph::new(input_text).block(block); f.render_widget(paragraph, area); } // --------------------------------------------------------------------------- // Event handling // --------------------------------------------------------------------------- /// Return the sessions that match the current active filter. /// /// If no filter is active, all sessions are returned. fn filtered_session_indices(state: &AppState) -> Vec { let active_filter = Filter::parse(&state.filter_active).ok(); state .sessions .iter() .enumerate() .filter(|(_, s)| { active_filter .as_ref() .map(|f| f.matches(*s)) .unwrap_or(true) }) .map(|(i, _)| i) .collect() } /// 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; } // When the filter input is focused, handle filter-specific keys first. if state.focus == Focus::FilterInput { return handle_filter_input_event(key.code, state); } 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') => { let visible = filtered_session_indices(state); if !visible.is_empty() { state.list_selected = (state.list_selected + 1).min(visible.len() - 1); } true } // Enter session transcript. KeyCode::Enter => { let visible = filtered_session_indices(state); let clamped = state.list_selected.min(visible.len().saturating_sub(1)); if let Some(&real_idx) = visible.get(clamped) { let full_id = state.sessions[real_idx].full_id.clone(); state.enter_transcript(full_id); } true } // Focus the filter input directly. KeyCode::Char('t') | KeyCode::Char('/') => { state.focus = Focus::FilterInput; true } // Tab cycles focus: list → filter → list. KeyCode::Tab => { state.focus = Focus::FilterInput; true } // Quit — show confirmation dialog rather than exiting immediately. KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { state.show_quit_dialog = true; true } // Help overlay. KeyCode::Char('?') => { state.show_help = true; true } _ => false, } } /// Handle a key event while the filter input bar is focused. /// /// Returns `true` when the event is consumed. fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool { match code { // Character input: append to filter_input and reset history browsing. KeyCode::Char(c) => { state.filter_input.push(c); state.filter_history_pos = None; true } // Backspace: remove last char. KeyCode::Backspace => { state.filter_input.pop(); state.filter_history_pos = None; true } // Enter: apply the current input as the active filter and return focus to the list. KeyCode::Enter => { let query = state.filter_input.trim().to_string(); // Record in history (skip duplicate consecutive entries). if !query.is_empty() { let is_dup = state .filter_history .last() .map(|l| l == &query) .unwrap_or(false); if !is_dup { state.filter_history.push(query.clone()); AppState::append_history_to_disk(&query); } } state.filter_active = query; state.filter_history_pos = None; // Reset list selection when filter changes. state.list_selected = 0; // Return focus to the main list. state.focus = Focus::ChatLog; true } // Escape: clear the text input (but keep the panel visible). KeyCode::Esc => { state.filter_input.clear(); state.filter_history_pos = None; true } // Up arrow: browse back through history. KeyCode::Up => { if state.filter_history.is_empty() { return true; } let new_pos = match state.filter_history_pos { None => state.filter_history.len() - 1, Some(p) => p.saturating_sub(1), }; state.filter_history_pos = Some(new_pos); state.filter_input = state.filter_history[new_pos].clone(); true } // Down arrow: browse forward through history (or clear when past the end). KeyCode::Down => { let Some(pos) = state.filter_history_pos else { return true; }; if pos + 1 < state.filter_history.len() { let new_pos = pos + 1; state.filter_history_pos = Some(new_pos); state.filter_input = state.filter_history[new_pos].clone(); } else { // Past the end: clear input and stop browsing history. state.filter_history_pos = None; state.filter_input.clear(); } true } // Tab: cycle focus back to the list. KeyCode::Tab => { state.focus = Focus::ChatLog; true } _ => true, // consume all other keys while filter is focused } } // --------------------------------------------------------------------------- // 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, input_tokens: 0, output_tokens: 0, } } 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_shows_quit_dialog() { let mut state = AppState::new(); let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state); assert!(consumed); assert!(state.show_quit_dialog); assert!(!state.should_quit); } #[test] fn big_q_shows_quit_dialog() { let mut state = AppState::new(); handle_session_list_event(press(KeyCode::Char('Q')), &mut state); assert!(state.show_quit_dialog); assert!(!state.should_quit); } #[test] fn esc_shows_quit_dialog() { let mut state = AppState::new(); handle_session_list_event(press(KeyCode::Esc), &mut state); assert!(state.show_quit_dialog); 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); } // ── Filter input focus ─────────────────────────────────────────────────── #[test] fn t_key_focuses_filter_input() { let mut state = AppState::new(); assert_eq!(state.focus, Focus::ChatLog); let consumed = handle_session_list_event(press(KeyCode::Char('t')), &mut state); assert!(consumed); assert_eq!(state.focus, Focus::FilterInput); } #[test] fn tab_focuses_filter_input() { let mut state = AppState::new(); let consumed = handle_session_list_event(press(KeyCode::Tab), &mut state); assert!(consumed); assert_eq!(state.focus, Focus::FilterInput); } #[test] fn tab_from_filter_cycles_back_to_list() { let mut state = AppState::new(); state.focus = Focus::FilterInput; handle_session_list_event(press(KeyCode::Tab), &mut state); assert_eq!(state.focus, Focus::ChatLog); } // ── Filter character input ─────────────────────────────────────────────── #[test] fn char_input_appends_to_filter_input() { let mut state = AppState::new(); state.focus = Focus::FilterInput; handle_session_list_event(press(KeyCode::Char('m')), &mut state); handle_session_list_event(press(KeyCode::Char('o')), &mut state); assert_eq!(state.filter_input, "mo"); } #[test] fn backspace_removes_last_char() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_input = "mo".to_string(); handle_session_list_event(press(KeyCode::Backspace), &mut state); assert_eq!(state.filter_input, "m"); } #[test] fn esc_clears_filter_input_stays_focused() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_input = "foo".to_string(); handle_session_list_event(press(KeyCode::Esc), &mut state); assert_eq!(state.filter_input, ""); assert_eq!(state.focus, Focus::FilterInput); } #[test] fn enter_applies_filter_and_returns_focus_to_list() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_input = "model:haiku".to_string(); handle_session_list_event(press(KeyCode::Enter), &mut state); assert_eq!(state.filter_active, "model:haiku"); assert_eq!(state.focus, Focus::ChatLog); // Should be in history now. assert!(state.filter_history.contains(&"model:haiku".to_string())); } #[test] fn enter_empty_clears_active_filter() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_active = "model:haiku".to_string(); state.filter_input = "".to_string(); handle_session_list_event(press(KeyCode::Enter), &mut state); assert_eq!(state.filter_active, ""); } #[test] fn duplicate_consecutive_entry_not_added_to_history() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_input = "model:haiku".to_string(); handle_session_list_event(press(KeyCode::Enter), &mut state); let count_before = state.filter_history.len(); state.focus = Focus::FilterInput; state.filter_input = "model:haiku".to_string(); handle_session_list_event(press(KeyCode::Enter), &mut state); assert_eq!(state.filter_history.len(), count_before); } // ── Filter history navigation ──────────────────────────────────────────── #[test] fn up_cycles_through_history() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_history = vec!["first".to_string(), "second".to_string()]; handle_session_list_event(press(KeyCode::Up), &mut state); assert_eq!(state.filter_input, "second"); assert_eq!(state.filter_history_pos, Some(1)); handle_session_list_event(press(KeyCode::Up), &mut state); assert_eq!(state.filter_input, "first"); assert_eq!(state.filter_history_pos, Some(0)); } #[test] fn down_goes_forward_in_history() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_history = vec!["first".to_string(), "second".to_string()]; state.filter_history_pos = Some(0); state.filter_input = "first".to_string(); handle_session_list_event(press(KeyCode::Down), &mut state); assert_eq!(state.filter_input, "second"); assert_eq!(state.filter_history_pos, Some(1)); } #[test] fn down_past_end_clears_input() { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_history = vec!["first".to_string()]; state.filter_history_pos = Some(0); state.filter_input = "first".to_string(); handle_session_list_event(press(KeyCode::Down), &mut state); assert_eq!(state.filter_input, ""); assert_eq!(state.filter_history_pos, None); } }