@ -5,7 +5,7 @@
//! a scrollable chat log below.
//! a scrollable chat log below.
use ratatui ::Frame ;
use ratatui ::Frame ;
use ratatui ::crossterm ::event ::{ Event , KeyCode , KeyEventKind , KeyModifiers };
use ratatui ::crossterm ::event ::{ Event , KeyCode , KeyEventKind };
use ratatui ::layout ::{ Constraint , Direction , Layout , Rect } ;
use ratatui ::layout ::{ Constraint , Direction , Layout , Rect } ;
use ratatui ::style ::{ Color , Modifier , Style } ;
use ratatui ::style ::{ Color , Modifier , Style } ;
use ratatui ::text ::{ Line , Span , Text } ;
use ratatui ::text ::{ Line , Span , Text } ;
@ -238,102 +238,6 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
lines
lines
}
}
// ---------------------------------------------------------------------------
// Search helpers
// ---------------------------------------------------------------------------
/// Style applied to matched substrings in search mode.
const SEARCH_HIGHLIGHT : Style = Style ::new ( ) . bg ( Color ::Yellow ) . fg ( Color ::Black ) ;
/// Split a single [`Span`] around all case-insensitive occurrences of `query_lower`,
/// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`].
///
/// Only operates on ASCII-safe byte positions — if `text` and its lowercase
/// counterpart have different byte lengths (multi-byte Unicode edge case) the
/// original span is returned unchanged.
fn split_span_with_search ( span : Span < ' static > , query_lower : & str ) -> Vec < Span < ' static > > {
let original_style = span . style ;
let text = span . content . into_owned ( ) ;
let text_lower = text . to_lowercase ( ) ;
// Guard: only byte-level slice when lengths match (safe for ASCII / common Unicode).
if text . len ( ) ! = text_lower . len ( ) | | ! text_lower . contains ( query_lower ) {
return vec! [ Span ::styled ( text , original_style ) ] ;
}
let mut result : Vec < Span < ' static > > = Vec ::new ( ) ;
let mut pos = 0 usize ;
while pos < text . len ( ) {
match text_lower [ pos .. ] . find ( query_lower ) {
None = > {
result . push ( Span ::styled ( text [ pos .. ] . to_string ( ) , original_style ) ) ;
break ;
}
Some ( rel ) = > {
let abs = pos + rel ;
if abs > pos {
result . push ( Span ::styled ( text [ pos .. abs ] . to_string ( ) , original_style ) ) ;
}
result . push ( Span ::styled (
text [ abs .. abs + query_lower . len ( ) ] . to_string ( ) ,
SEARCH_HIGHLIGHT ,
) ) ;
pos = abs + query_lower . len ( ) ;
}
}
}
if result . is_empty ( ) {
result . push ( Span ::styled ( text , original_style ) ) ;
}
result
}
/// Post-process rendered chat lines to highlight all case-insensitive matches
/// of `query`. Returns lines unchanged when `query` is empty.
fn apply_search_highlights ( lines : Vec < Line < ' static > > , query : & str ) -> Vec < Line < ' static > > {
if query . is_empty ( ) {
return lines ;
}
let query_lower = query . to_lowercase ( ) ;
lines
. into_iter ( )
. map ( | line | {
// Quick check: does the plain text of this line contain the query?
let plain : String = line . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
if ! plain . to_lowercase ( ) . contains ( & * query_lower ) {
return line ;
}
// Rebuild spans, splitting around matches.
let new_spans : Vec < Span < ' static > > = line
. spans
. into_iter ( )
. flat_map ( | sp | split_span_with_search ( sp , & query_lower ) )
. collect ( ) ;
Line ::from ( new_spans )
} )
. collect ( )
}
/// Return the indices of chat-log lines that contain `query` (case-insensitive).
///
/// Calls [`build_chat_lines`] to get the rendered lines, then filters by text content.
/// Returns an empty vec when `query` is empty.
pub fn find_match_lines ( entries : & [ RawEntry ] , query : & str , color_enabled : bool ) -> Vec < usize > {
if query . is_empty ( ) {
return Vec ::new ( ) ;
}
let query_lower = query . to_lowercase ( ) ;
build_chat_lines ( entries , color_enabled )
. iter ( )
. enumerate ( )
. filter ( | ( _ , line ) | {
let text : String = line . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
text . to_lowercase ( ) . contains ( & * query_lower )
} )
. map ( | ( i , _ ) | i )
. collect ( )
}
/// Truncate a string to at most `max_chars` chars (Unicode-safe).
/// Truncate a string to at most `max_chars` chars (Unicode-safe).
fn truncate_str ( s : & str , max_chars : usize ) -> String {
fn truncate_str ( s : & str , max_chars : usize ) -> String {
let char_count = s . chars ( ) . count ( ) ;
let char_count = s . chars ( ) . count ( ) ;
@ -348,60 +252,11 @@ fn truncate_str(s: &str, max_chars: usize) -> String {
// Rendering
// Rendering
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
/// Draw the search bar at the bottom of the transcript area.
fn render_search_bar ( f : & mut Frame , area : Rect , state : & AppState ) {
let focused = state . focus = = Focus ::SearchInput ;
let border_style = if focused {
Style ::default ( ) . fg ( Color ::Yellow )
} else {
Style ::default ( )
} ;
let match_info = if ! state . search_active . is_empty ( ) {
let total = state . search_match_lines . len ( ) ;
if total = = 0 {
" no matches" . to_string ( )
} else {
let cur = state . search_current_match + 1 ;
format! ( " {cur}/{total}" )
}
} else {
String ::new ( )
} ;
let block = Block ::default ( )
. title ( format! ( " Search{match_info} " ) )
. borders ( Borders ::ALL )
. border_style ( border_style ) ;
let label = Span ::styled ( "Search: " , Style ::default ( ) . add_modifier ( Modifier ::BOLD ) ) ;
let input_line = if focused {
let mut spans = vec! [ label , Span ::raw ( state . search_input . clone ( ) ) ] ;
spans . push ( Span ::styled ( "█" , Style ::default ( ) . fg ( Color ::Yellow ) ) ) ;
Line ::from ( spans )
} else if state . search_input . is_empty ( ) & & state . search_active . is_empty ( ) {
Line ::from ( vec! [
label ,
Span ::styled (
"Press 't' to search — type and press Enter to highlight matches" ,
Style ::default ( ) . fg ( Color ::DarkGray ) ,
) ,
] )
} else {
Line ::from ( vec! [ label , Span ::raw ( state . search_input . clone ( ) ) ] )
} ;
let paragraph = Paragraph ::new ( input_line ) . block ( block ) ;
f . render_widget ( paragraph , area ) ;
}
/// Draw the transcript screen onto `area` of the given frame.
/// Draw the transcript screen onto `area` of the given frame.
///
///
/// Layout:
/// Layout:
/// - Top 4 lines: stats header (inside a bordered block).
/// - Top 4 lines: stats header (inside a bordered block).
/// - Middle: scrollable chat log (left) + sub-agents panel (right).
/// - Remaining lines: scrollable chat log.
/// - Bottom 3 lines: search bar.
pub fn render_transcript ( f : & mut Frame , area : Rect , state : & AppState ) {
pub fn render_transcript ( f : & mut Frame , area : Rect , state : & AppState ) {
// Determine the session_id for the header.
// Determine the session_id for the header.
let session_id = match & state . screen {
let session_id = match & state . screen {
@ -410,11 +265,12 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
_ = > String ::new ( ) ,
_ = > String ::new ( ) ,
} ;
} ;
// Split area: header (fixed) | body (min) | search bar (fixed).
// Split area: header (fixed) | chat log (min).
// Header block: 2 content lines + 2 border lines = 4 rows total.
let header_height : u16 = 4 ;
let chunks = Layout ::vertical ( [
let chunks = Layout ::vertical ( [
Constraint ::Length ( 4 ) , // stats header
Constraint ::Length ( header_height ) ,
Constraint ::Min ( 1 ) , // chat log + sub-agents
Constraint ::Min ( 1 ) ,
Constraint ::Length ( 3 ) , // search bar
] )
] )
. split ( area ) ;
. split ( area ) ;
@ -436,10 +292,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
. split ( chunks [ 1 ] ) ;
. split ( chunks [ 1 ] ) ;
// ── Chat log ─────────────────────────────────────────────────────────────
// ── Chat log ─────────────────────────────────────────────────────────────
let mut chat_lines = build_chat_lines ( & state . transcript_entries , state . color_enabled ) ;
let chat_lines = build_chat_lines ( & state . transcript_entries , state . color_enabled ) ;
if ! state . search_active . is_empty ( ) {
chat_lines = apply_search_highlights ( chat_lines , & state . search_active ) ;
}
let chat_border_style = if state . focus = = Focus ::ChatLog {
let chat_border_style = if state . focus = = Focus ::ChatLog {
Style ::default ( ) . fg ( Color ::Yellow )
Style ::default ( ) . fg ( Color ::Yellow )
@ -447,15 +300,8 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
Style ::default ( )
Style ::default ( )
} ;
} ;
let search_hint = if ! state . search_active . is_empty ( ) {
" · n/N match"
} else {
""
} ;
let chat_block = Block ::default ( )
let chat_block = Block ::default ( )
. title ( format! (
. title ( " Transcript [↑/↓ scroll · ←/→ h-scroll · Tab focus · Esc back · ? help] " )
" Transcript [↑/↓ scroll · Spc/PgDn page · t search{search_hint} · Tab · Esc · ?] "
) )
. borders ( Borders ::ALL )
. borders ( Borders ::ALL )
. border_style ( chat_border_style ) ;
. border_style ( chat_border_style ) ;
@ -511,9 +357,6 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
. block ( subagents_block ) ;
. block ( subagents_block ) ;
f . render_widget ( subagents_paragraph , body_chunks [ 1 ] ) ;
f . render_widget ( subagents_paragraph , body_chunks [ 1 ] ) ;
// ── Search bar ───────────────────────────────────────────────────────────
render_search_bar ( f , chunks [ 2 ] , state ) ;
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
@ -531,18 +374,12 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
return false ;
return false ;
}
}
// When the search bar is focused, route all input there.
if state . focus = = Focus ::SearchInput {
return handle_search_input_event ( key . code , state ) ;
}
match key . code {
match key . code {
// Toggle focus : ChatLog → SubagentsPanel → SearchInput → ChatLog .
// Toggle focus between ChatLog and SubagentsPanel.
KeyCode ::Tab = > {
KeyCode ::Tab = > {
state . focus = match state . focus {
state . focus = match state . focus {
Focus ::ChatLog = > Focus ::SubagentsPanel ,
Focus ::ChatLog = > Focus ::SubagentsPanel ,
Focus ::SubagentsPanel = > Focus ::SearchInput ,
Focus ::SubagentsPanel | Focus ::FilterInput = > Focus ::ChatLog ,
Focus ::SearchInput | Focus ::FilterInput = > Focus ::ChatLog ,
} ;
} ;
true
true
}
}
@ -561,40 +398,13 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state . show_help = true ;
state . show_help = true ;
true
true
}
}
// Jump directly to the search input.
KeyCode ::Char ( 't' ) = > {
state . focus = Focus ::SearchInput ;
true
}
// Next match.
KeyCode ::Char ( 'n' ) = > {
if ! state . search_match_lines . is_empty ( ) {
state . search_current_match =
( state . search_current_match + 1 ) % state . search_match_lines . len ( ) ;
state . transcript_scroll =
state . search_match_lines [ state . search_current_match ] ;
}
true
}
// Previous match (Shift+N).
KeyCode ::Char ( 'N' ) = > {
if ! state . search_match_lines . is_empty ( ) {
state . search_current_match = state
. search_current_match
. checked_sub ( 1 )
. unwrap_or ( state . search_match_lines . len ( ) - 1 ) ;
state . transcript_scroll =
state . search_match_lines [ state . search_current_match ] ;
}
true
}
// Directional keys are routed based on the focused panel.
// Directional keys are routed based on the focused panel.
KeyCode ::Up | KeyCode ::Char ( 'k' ) = > match state . focus {
KeyCode ::Up | KeyCode ::Char ( 'k' ) = > match state . focus {
Focus ::SubagentsPanel = > {
Focus ::SubagentsPanel = > {
state . subagent_selected = state . subagent_selected . saturating_sub ( 1 ) ;
state . subagent_selected = state . subagent_selected . saturating_sub ( 1 ) ;
true
true
}
}
Focus ::ChatLog | Focus ::FilterInput | Focus ::SearchInput => {
Focus ::ChatLog | Focus ::FilterInput = > {
state . transcript_scroll = state . transcript_scroll . saturating_sub ( 1 ) ;
state . transcript_scroll = state . transcript_scroll . saturating_sub ( 1 ) ;
true
true
}
}
@ -608,28 +418,11 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
}
true
true
}
}
Focus ::ChatLog | Focus ::FilterInput | Focus ::SearchInput => {
Focus ::ChatLog | Focus ::FilterInput => {
state . transcript_scroll = state . transcript_scroll . saturating_add ( 1 ) ;
state . transcript_scroll = state . transcript_scroll . saturating_add ( 1 ) ;
true
true
}
}
} ,
} ,
// Page scrolling — always acts on the chat log, matching less/more.
// Shift+Space must come before the plain-Space arm so the guard fires first.
KeyCode ::Char ( ' ' ) if key . modifiers . contains ( KeyModifiers ::SHIFT ) = > {
let page = ( state . transcript_page_height as usize ) . max ( 1 ) ;
state . transcript_scroll = state . transcript_scroll . saturating_sub ( page ) ;
true
}
KeyCode ::PageUp = > {
let page = ( state . transcript_page_height as usize ) . max ( 1 ) ;
state . transcript_scroll = state . transcript_scroll . saturating_sub ( page ) ;
true
}
KeyCode ::PageDown | KeyCode ::Char ( ' ' ) = > {
let page = ( state . transcript_page_height as usize ) . max ( 1 ) ;
state . transcript_scroll = state . transcript_scroll . saturating_add ( page ) ;
true
}
// Horizontal scroll — only meaningful in ChatLog.
// Horizontal scroll — only meaningful in ChatLog.
KeyCode ::Left | KeyCode ::Char ( 'h' ) = > {
KeyCode ::Left | KeyCode ::Char ( 'h' ) = > {
state . transcript_h_scroll = state . transcript_h_scroll . saturating_sub ( 1 ) ;
state . transcript_h_scroll = state . transcript_h_scroll . saturating_sub ( 1 ) ;
@ -659,53 +452,6 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
}
}
}
/// Handle a key event while the search input bar is focused.
///
/// Returns `true` when the event is consumed.
fn handle_search_input_event ( code : KeyCode , state : & mut AppState ) -> bool {
match code {
// Character input: append to search_input.
KeyCode ::Char ( c ) = > {
state . search_input . push ( c ) ;
true
}
// Backspace: remove last char.
KeyCode ::Backspace = > {
state . search_input . pop ( ) ;
true
}
// Enter: apply the search, compute match lines, return focus to ChatLog.
KeyCode ::Enter = > {
let query = state . search_input . trim ( ) . to_string ( ) ;
state . search_active = query . clone ( ) ;
state . search_match_lines =
find_match_lines ( & state . transcript_entries , & query , state . color_enabled ) ;
state . search_current_match = 0 ;
// Scroll to first match if any.
if let Some ( & first ) = state . search_match_lines . first ( ) {
state . transcript_scroll = first ;
}
state . focus = Focus ::ChatLog ;
true
}
// Escape: clear search and return focus to ChatLog.
KeyCode ::Esc = > {
state . search_input . clear ( ) ;
state . search_active . clear ( ) ;
state . search_match_lines . clear ( ) ;
state . search_current_match = 0 ;
state . focus = Focus ::ChatLog ;
true
}
// Tab: cycle focus back to ChatLog.
KeyCode ::Tab = > {
state . focus = Focus ::ChatLog ;
true
}
_ = > true , // consume all other keys while search is focused
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Data loading
// Data loading
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
@ -1160,14 +906,12 @@ mod tests {
}
}
#[ test ]
#[ test ]
fn tab_ cyc les_focus( ) {
fn tab_ togg les_focus( ) {
let mut state = transcript_state ( ) ;
let mut state = transcript_state ( ) ;
assert_eq! ( state . focus , Focus ::ChatLog ) ;
assert_eq! ( state . focus , Focus ::ChatLog ) ;
handle_transcript_event ( press ( KeyCode ::Tab ) , & mut state ) ;
handle_transcript_event ( press ( KeyCode ::Tab ) , & mut state ) ;
assert_eq! ( state . focus , Focus ::SubagentsPanel ) ;
assert_eq! ( state . focus , Focus ::SubagentsPanel ) ;
handle_transcript_event ( press ( KeyCode ::Tab ) , & mut state ) ;
handle_transcript_event ( press ( KeyCode ::Tab ) , & mut state ) ;
assert_eq! ( state . focus , Focus ::SearchInput ) ;
handle_transcript_event ( press ( KeyCode ::Tab ) , & mut state ) ;
assert_eq! ( state . focus , Focus ::ChatLog ) ;
assert_eq! ( state . focus , Focus ::ChatLog ) ;
}
}
@ -1203,218 +947,6 @@ mod tests {
assert! ( state . show_help ) ;
assert! ( state . show_help ) ;
}
}
fn press_with_mod ( code : KeyCode , mods : KeyModifiers ) -> Event {
Event ::Key ( KeyEvent {
code ,
modifiers : mods ,
kind : KeyEventKind ::Press ,
state : KeyEventState ::NONE ,
} )
}
#[ test ]
fn space_scrolls_down_by_page ( ) {
let mut state = transcript_state ( ) ;
state . transcript_page_height = 20 ;
handle_transcript_event ( press ( KeyCode ::Char ( ' ' ) ) , & mut state ) ;
assert_eq! ( state . transcript_scroll , 20 ) ;
}
#[ test ]
fn pagedown_scrolls_down_by_page ( ) {
let mut state = transcript_state ( ) ;
state . transcript_page_height = 15 ;
handle_transcript_event ( press ( KeyCode ::PageDown ) , & mut state ) ;
assert_eq! ( state . transcript_scroll , 15 ) ;
}
#[ test ]
fn shift_space_scrolls_up_by_page ( ) {
let mut state = transcript_state ( ) ;
state . transcript_page_height = 10 ;
state . transcript_scroll = 25 ;
handle_transcript_event (
press_with_mod ( KeyCode ::Char ( ' ' ) , KeyModifiers ::SHIFT ) ,
& mut state ,
) ;
assert_eq! ( state . transcript_scroll , 15 ) ;
}
#[ test ]
fn pageup_scrolls_up_by_page ( ) {
let mut state = transcript_state ( ) ;
state . transcript_page_height = 10 ;
state . transcript_scroll = 25 ;
handle_transcript_event ( press ( KeyCode ::PageUp ) , & mut state ) ;
assert_eq! ( state . transcript_scroll , 15 ) ;
}
#[ test ]
fn pageup_clamps_at_zero ( ) {
let mut state = transcript_state ( ) ;
state . transcript_page_height = 20 ;
state . transcript_scroll = 5 ;
handle_transcript_event ( press ( KeyCode ::PageUp ) , & mut state ) ;
assert_eq! ( state . transcript_scroll , 0 ) ;
}
// ── Search input focus ───────────────────────────────────────────────────
#[ test ]
fn t_key_focuses_search_input ( ) {
let mut state = transcript_state ( ) ;
let consumed = handle_transcript_event ( press ( KeyCode ::Char ( 't' ) ) , & mut state ) ;
assert! ( consumed ) ;
assert_eq! ( state . focus , Focus ::SearchInput ) ;
}
#[ test ]
fn search_char_input_appends ( ) {
let mut state = transcript_state ( ) ;
state . focus = Focus ::SearchInput ;
handle_transcript_event ( press ( KeyCode ::Char ( 'h' ) ) , & mut state ) ;
handle_transcript_event ( press ( KeyCode ::Char ( 'i' ) ) , & mut state ) ;
assert_eq! ( state . search_input , "hi" ) ;
}
#[ test ]
fn search_backspace_removes_char ( ) {
let mut state = transcript_state ( ) ;
state . focus = Focus ::SearchInput ;
state . search_input = "hi" . to_string ( ) ;
handle_transcript_event ( press ( KeyCode ::Backspace ) , & mut state ) ;
assert_eq! ( state . search_input , "h" ) ;
}
#[ test ]
fn search_enter_applies_query_and_returns_focus ( ) {
let mut state = transcript_state ( ) ;
state . focus = Focus ::SearchInput ;
state . search_input = "user" . to_string ( ) ;
handle_transcript_event ( press ( KeyCode ::Enter ) , & mut state ) ;
assert_eq! ( state . search_active , "user" ) ;
assert_eq! ( state . focus , Focus ::ChatLog ) ;
}
#[ test ]
fn search_esc_clears_and_returns_focus ( ) {
let mut state = transcript_state ( ) ;
state . focus = Focus ::SearchInput ;
state . search_input = "hi" . to_string ( ) ;
state . search_active = "hi" . to_string ( ) ;
state . search_match_lines = vec! [ 1 , 3 ] ;
handle_transcript_event ( press ( KeyCode ::Esc ) , & mut state ) ;
assert_eq! ( state . search_input , "" ) ;
assert_eq! ( state . search_active , "" ) ;
assert! ( state . search_match_lines . is_empty ( ) ) ;
assert_eq! ( state . focus , Focus ::ChatLog ) ;
}
// ── n/N match navigation ─────────────────────────────────────────────────
#[ test ]
fn n_advances_to_next_match ( ) {
let mut state = transcript_state ( ) ;
state . search_match_lines = vec! [ 2 , 5 , 9 ] ;
state . search_current_match = 0 ;
handle_transcript_event ( press ( KeyCode ::Char ( 'n' ) ) , & mut state ) ;
assert_eq! ( state . search_current_match , 1 ) ;
assert_eq! ( state . transcript_scroll , 5 ) ;
}
#[ test ]
fn n_wraps_to_first_match ( ) {
let mut state = transcript_state ( ) ;
state . search_match_lines = vec! [ 2 , 5 , 9 ] ;
state . search_current_match = 2 ; // last
handle_transcript_event ( press ( KeyCode ::Char ( 'n' ) ) , & mut state ) ;
assert_eq! ( state . search_current_match , 0 ) ;
assert_eq! ( state . transcript_scroll , 2 ) ;
}
#[ test ]
fn big_n_goes_to_previous_match ( ) {
let mut state = transcript_state ( ) ;
state . search_match_lines = vec! [ 2 , 5 , 9 ] ;
state . search_current_match = 2 ;
handle_transcript_event ( press ( KeyCode ::Char ( 'N' ) ) , & mut state ) ;
assert_eq! ( state . search_current_match , 1 ) ;
assert_eq! ( state . transcript_scroll , 5 ) ;
}
#[ test ]
fn big_n_wraps_to_last_match ( ) {
let mut state = transcript_state ( ) ;
state . search_match_lines = vec! [ 2 , 5 , 9 ] ;
state . search_current_match = 0 ; // first
handle_transcript_event ( press ( KeyCode ::Char ( 'N' ) ) , & mut state ) ;
assert_eq! ( state . search_current_match , 2 ) ;
assert_eq! ( state . transcript_scroll , 9 ) ;
}
#[ test ]
fn n_with_no_matches_does_nothing ( ) {
let mut state = transcript_state ( ) ;
state . search_match_lines = vec! [ ] ;
state . transcript_scroll = 3 ;
handle_transcript_event ( press ( KeyCode ::Char ( 'n' ) ) , & mut state ) ;
assert_eq! ( state . transcript_scroll , 3 ) ; // unchanged
}
// ── find_match_lines + highlight helpers ─────────────────────────────────
#[ test ]
fn find_match_lines_empty_query_returns_empty ( ) {
let result = find_match_lines ( & [ ] , "" , false ) ;
assert! ( result . is_empty ( ) ) ;
}
#[ test ]
fn find_match_lines_finds_case_insensitive ( ) {
let entries = vec! [
user_text_entry ( "Hello Claude" ) ,
user_text_entry ( "world" ) ,
user_text_entry ( "hello again" ) ,
] ;
let lines = find_match_lines ( & entries , "hello" , false ) ;
// Lines 0 and 2 (from entries 0 and 2) should match.
assert_eq! ( lines . len ( ) , 2 ) ;
}
#[ test ]
fn apply_search_highlights_empty_query_unchanged ( ) {
let original = vec! [ Line ::from ( "some text" ) ] ;
let result = apply_search_highlights ( original . clone ( ) , "" ) ;
assert_eq! ( result . len ( ) , original . len ( ) ) ;
}
#[ test ]
fn split_span_with_search_no_match_unchanged ( ) {
let span = Span ::raw ( "hello world" ) ;
let result = split_span_with_search ( span , "xyz" ) ;
assert_eq! ( result . len ( ) , 1 ) ;
assert_eq! ( result [ 0 ] . content , "hello world" ) ;
}
#[ test ]
fn split_span_with_search_highlights_match ( ) {
let span = Span ::raw ( "hello world" ) ;
let result = split_span_with_search ( span , "world" ) ;
// Expect: "hello " + highlighted "world"
assert_eq! ( result . len ( ) , 2 ) ;
assert_eq! ( result [ 0 ] . content , "hello " ) ;
assert_eq! ( result [ 1 ] . content , "world" ) ;
assert_eq! ( result [ 1 ] . style , SEARCH_HIGHLIGHT ) ;
}
#[ test ]
fn page_scroll_uses_one_when_height_is_zero ( ) {
let mut state = transcript_state ( ) ;
state . transcript_page_height = 0 ;
handle_transcript_event ( press ( KeyCode ::Char ( ' ' ) ) , & mut state ) ;
assert_eq! ( state . transcript_scroll , 1 ) ;
}
#[ test ]
#[ test ]
fn unhandled_key_not_consumed ( ) {
fn unhandled_key_not_consumed ( ) {
let mut state = transcript_state ( ) ;
let mut state = transcript_state ( ) ;