You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

732 lines
26 KiB
Rust

//! 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::<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) {
// 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<Row> = 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<usize> {
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);
}
}