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
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);
|
|
}
|
|
}
|