@ -4,12 +4,13 @@
use ratatui ::Frame ;
use ratatui ::Frame ;
use ratatui ::crossterm ::event ::{ Event , KeyCode , KeyEventKind } ;
use ratatui ::crossterm ::event ::{ Event , KeyCode , KeyEventKind } ;
use ratatui ::layout ::{ Constraint , Rect} ;
use ratatui ::layout ::{ Constraint , Direction, Layout , Rect} ;
use ratatui ::style ::{ Modifier, Style } ;
use ratatui ::style ::{ Color, Modifier, Style } ;
use ratatui ::text ::Text ;
use ratatui ::text ::{ Line , Span , Text } ;
use ratatui ::widgets ::{ Block , Borders , Row, Table , TableState } ;
use ratatui ::widgets ::{ Block , Borders , Paragraph, Row, Table , TableState } ;
use crate ::tui ::state ::AppState ;
use crate ::filter ::Filter ;
use crate ::tui ::state ::{ AppState , Focus } ;
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Project path truncation
// Project path truncation
@ -39,6 +40,18 @@ fn truncate_project(path: &str, max_chars: usize) -> String {
/// Draw the session-list screen onto `area` of the given frame.
/// Draw the session-list screen onto `area` of the given frame.
pub fn render_session_list ( f : & mut Frame , area : Rect , state : & AppState ) {
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:
// Column constraints:
// ID (8) | Date (20) | Project (dynamic) | Model (20) | Msgs (6) | Agents (7)
// 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
// Fixed columns total = 8 + 1 + 20 + 1 + 20 + 1 + 6 + 1 + 7 = 65 chars + borders
@ -57,14 +70,26 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Compute the max width available for the project column so we can truncate.
// 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.
// 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.
// Use 30 chars as a safe default; the Min constraint will expand it.
let project_max : usize = area
let project_max : usize = table_ area
. width
. width
. saturating_sub ( 68 ) // subtract fixed columns + separators + borders
. saturating_sub ( 68 ) // subtract fixed columns + separators + borders
. max ( 10 ) as usize ;
. max ( 10 ) as usize ;
let project_display_max = project_max + 30 ; // generous — actual render clips
let project_display_max = project_max + 30 ; // generous — actual render clips
let rows : Vec < Row > = state
// Apply active filter to the session rows.
let active_filter = Filter ::parse ( & state . filter_active ) . ok ( ) ;
let filtered_sessions : Vec < _ > = state
. sessions
. sessions
. iter ( )
. filter ( | s | {
active_filter
. as_ref ( )
. map ( | f | f . matches ( * s ) )
. unwrap_or ( true )
} )
. collect ( ) ;
let rows : Vec < Row > = filtered_sessions
. iter ( )
. iter ( )
. map ( | s | {
. map ( | s | {
let project = truncate_project ( & s . project , project_display_max ) ;
let project = truncate_project ( & s . project , project_display_max ) ;
@ -79,8 +104,14 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
} )
} )
. collect ( ) ;
. collect ( ) ;
let table_title = if state . filter_active . is_empty ( ) {
" Sessions " . to_string ( )
} else {
format! ( " Sessions [filter: {}] " , state . filter_active )
} ;
let block = Block ::default ( )
let block = Block ::default ( )
. title ( " Sessions " )
. title ( table_title )
. borders ( Borders ::ALL ) ;
. borders ( Borders ::ALL ) ;
let highlight_style = Style ::default ( ) . add_modifier ( Modifier ::REVERSED ) ;
let highlight_style = Style ::default ( ) . add_modifier ( Modifier ::REVERSED ) ;
@ -93,31 +124,97 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Sync ratatui's TableState with our AppState selection.
// Sync ratatui's TableState with our AppState selection.
let mut table_state = TableState ::default ( ) ;
let mut table_state = TableState ::default ( ) ;
if ! state . sessions . is_empty ( ) {
if ! filtered_sessions . is_empty ( ) {
table_state . select ( Some ( state . list_selected ) ) ;
let clamped = state . list_selected . min ( filtered_sessions . len ( ) - 1 ) ;
table_state . select ( Some ( clamped ) ) ;
}
}
f . render_stateful_widget ( table , area, & mut table_state ) ;
f . render_stateful_widget ( table , table_ area, & mut table_state ) ;
// Render an "empty" hint when there are no sessions.
// Render an "empty" hint when there are no sessions.
if state . sessions . is_empty ( ) {
if filtered_sessions . is_empty ( ) {
let hint = ratatui ::widgets ::Paragraph ::new ( Text ::raw ( "No sessions found." ) )
let hint = Paragraph ::new ( Text ::raw ( if state . filter_active . is_empty ( ) {
. alignment ( ratatui ::layout ::Alignment ::Center ) ;
"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).
// Place the hint in the inner area (inside the block border).
let inner = Rect {
let inner = Rect {
x : area . x + 1 ,
x : table_ area. x + 1 ,
y : area . y + area . height / 2 ,
y : table_ area. y + table_ area. height / 2 ,
width : area. width . saturating_sub ( 2 ) ,
width : table_ area. width . saturating_sub ( 2 ) ,
height : 1 ,
height : 1 ,
} ;
} ;
f . render_widget ( hint , inner ) ;
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
// 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.
/// Handle a crossterm [`Event`] for the session-list screen.
///
///
/// Returns `true` if the event was consumed (the caller should not process it
/// Returns `true` if the event was consumed (the caller should not process it
@ -130,6 +227,11 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
return false ;
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 {
match key . code {
// Navigate up.
// Navigate up.
KeyCode ::Up | KeyCode ::Char ( 'k' ) = > {
KeyCode ::Up | KeyCode ::Char ( 'k' ) = > {
@ -138,20 +240,33 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
}
}
// Navigate down.
// Navigate down.
KeyCode ::Down | KeyCode ::Char ( 'j' ) = > {
KeyCode ::Down | KeyCode ::Char ( 'j' ) = > {
if ! state . sessions . is_empty ( ) {
let visible = filtered_session_indices ( state ) ;
if ! visible . is_empty ( ) {
state . list_selected =
state . list_selected =
( state . list_selected + 1 ) . min ( state. sessions . len ( ) - 1 ) ;
( state . list_selected + 1 ) . min ( visible . len ( ) - 1 ) ;
}
}
true
true
}
}
// Enter session transcript.
// Enter session transcript.
KeyCode ::Enter = > {
KeyCode ::Enter = > {
if let Some ( item ) = state . sessions . get ( state . list_selected ) {
let visible = filtered_session_indices ( state ) ;
let full_id = item . full_id . clone ( ) ;
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 ) ;
state . enter_transcript ( full_id ) ;
}
}
true
true
}
}
// Focus the filter input directly.
KeyCode ::Char ( 't' ) = > {
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.
// Quit — show confirmation dialog rather than exiting immediately.
KeyCode ::Char ( 'q' ) | KeyCode ::Char ( 'Q' ) | KeyCode ::Esc = > {
KeyCode ::Char ( 'q' ) | KeyCode ::Char ( 'Q' ) | KeyCode ::Esc = > {
state . show_quit_dialog = true ;
state . show_quit_dialog = true ;
@ -166,6 +281,86 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
}
}
}
}
/// 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
// Tests
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
@ -368,4 +563,135 @@ mod tests {
let consumed = handle_session_list_event ( Event ::FocusGained , & mut state ) ;
let consumed = handle_session_list_event ( Event ::FocusGained , & mut state ) ;
assert! ( ! consumed ) ;
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 ) ;
}
}
}