use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::widgets::{Block, Borders, Clear};
use crate::theme::Theme;
use crate::types::ChatMessage;
pub struct TranscriptSearchState {
pub query: String,
query_lower: String,
pub matches: Vec<usize>,
pub selected: usize,
pub pre_search_scroll_offset: usize,
}
impl TranscriptSearchState {
#[must_use]
pub fn new(current_scroll: usize) -> Self {
Self {
query: String::new(),
query_lower: String::new(),
matches: Vec::new(),
selected: 0,
pre_search_scroll_offset: current_scroll,
}
}
pub fn push_char(&mut self, c: char, messages: &[ChatMessage]) {
self.query.push(c);
self.query_lower = self.query.to_lowercase();
self.refilter(messages);
}
pub fn pop_char(&mut self, messages: &[ChatMessage]) {
self.query.pop();
self.query_lower = self.query.to_lowercase();
self.refilter(messages);
}
pub fn select_next(&mut self) {
if self.matches.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.matches.len();
}
pub fn select_previous(&mut self) {
if self.matches.is_empty() {
return;
}
self.selected = self
.selected
.checked_sub(1)
.unwrap_or(self.matches.len() - 1);
}
#[must_use]
pub fn selected_message_index(&self) -> Option<usize> {
self.matches.get(self.selected).copied()
}
#[must_use]
pub fn query_lower(&self) -> &str {
&self.query_lower
}
fn refilter(&mut self, messages: &[ChatMessage]) {
self.matches = Self::compute_matches(&self.query_lower, messages);
self.selected = self.selected.min(self.matches.len().saturating_sub(1));
}
fn compute_matches(query_lower: &str, messages: &[ChatMessage]) -> Vec<usize> {
if query_lower.is_empty() {
return Vec::new();
}
messages
.iter()
.enumerate()
.filter(|(_, msg)| message_matches(msg, query_lower))
.map(|(i, _)| i)
.collect()
}
}
fn message_matches(msg: &ChatMessage, query_lower: &str) -> bool {
if msg.content.to_lowercase().contains(query_lower) {
return true;
}
msg.tool_name
.as_ref()
.is_some_and(|name| name.as_str().to_lowercase().contains(query_lower))
}
pub fn render(state: &TranscriptSearchState, frame: &mut Frame, input_area: Rect, theme: &Theme) {
let width: u16 = 60;
let height: u16 = 3;
let x = if input_area.width > width {
input_area.x + (input_area.width - width) / 2
} else {
input_area.x
};
let actual_width = width.min(input_area.width);
let y = input_area.y.saturating_sub(height);
let popup = Rect {
x,
y,
width: actual_width,
height,
};
frame.render_widget(Clear, popup);
let match_info = if state.query.is_empty() {
String::new()
} else if state.matches.is_empty() {
" no matches".to_owned()
} else {
format!(" {}/{}", state.selected + 1, state.matches.len())
};
let title = format!(" Find: {}{} ", state.query, match_info);
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme.panel_border)
.title(title)
.title_alignment(Alignment::Center);
frame.render_widget(block, popup);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::render_to_string;
use crate::types::MessageRole;
fn messages(items: &[&str]) -> Vec<ChatMessage> {
items
.iter()
.map(|s| ChatMessage::new(MessageRole::Assistant, (*s).to_owned()))
.collect()
}
#[test]
fn new_state_has_no_matches_before_any_query() {
let state = TranscriptSearchState::new(5);
assert!(state.matches.is_empty());
assert_eq!(state.selected, 0);
assert_eq!(state.pre_search_scroll_offset, 5);
}
#[test]
fn push_char_filters_case_insensitively() {
let msgs = messages(&["Hello World", "goodbye", "HELLO there"]);
let mut state = TranscriptSearchState::new(0);
state.push_char('h', &msgs);
state.push_char('e', &msgs);
state.push_char('l', &msgs);
state.push_char('l', &msgs);
assert_eq!(state.matches, vec![0, 2]);
}
#[test]
fn pop_char_recomputes_wider_matches() {
let msgs = messages(&["hello", "help", "world"]);
let mut state = TranscriptSearchState::new(0);
state.push_char('h', &msgs);
state.push_char('e', &msgs);
state.push_char('l', &msgs);
state.push_char('p', &msgs);
assert_eq!(state.matches, vec![1]);
state.pop_char(&msgs);
assert_eq!(state.matches, vec![0, 1]);
}
#[test]
fn empty_query_has_zero_matches_not_all_messages() {
let msgs = messages(&["a", "b", "c"]);
let state = TranscriptSearchState::new(0);
assert!(state.matches.is_empty());
let mut state2 = TranscriptSearchState::new(0);
state2.push_char('a', &msgs);
state2.pop_char(&msgs);
assert!(state2.matches.is_empty());
}
#[test]
fn matches_tool_name_not_just_content() {
let mut msgs = messages(&["some output"]);
msgs[0].tool_name = Some(zeph_common::ToolName::new("shell"));
let mut state = TranscriptSearchState::new(0);
for c in "shell".chars() {
state.push_char(c, &msgs);
}
assert_eq!(state.matches, vec![0]);
}
#[test]
fn select_next_wraps() {
let msgs = messages(&["x", "x", "x"]);
let mut state = TranscriptSearchState::new(0);
state.push_char('x', &msgs);
assert_eq!(state.matches.len(), 3);
state.select_next();
state.select_next();
state.select_next();
assert_eq!(state.selected, 0, "must wrap back to the first match");
}
#[test]
fn select_previous_wraps() {
let msgs = messages(&["x", "x"]);
let mut state = TranscriptSearchState::new(0);
state.push_char('x', &msgs);
state.select_previous();
assert_eq!(state.selected, 1, "must wrap to the last match");
}
#[test]
fn select_next_noop_on_empty_matches() {
let mut state = TranscriptSearchState::new(0);
state.select_next();
assert_eq!(state.selected, 0);
}
#[test]
fn selected_message_index_reflects_current_selection() {
let msgs = messages(&["needle here", "no match", "needle again"]);
let mut state = TranscriptSearchState::new(0);
for c in "needle".chars() {
state.push_char(c, &msgs);
}
assert_eq!(state.selected_message_index(), Some(0));
state.select_next();
assert_eq!(state.selected_message_index(), Some(2));
}
#[test]
fn refilter_clamps_selected_when_matches_shrink() {
let msgs = messages(&["cat", "cats", "dog"]);
let mut state = TranscriptSearchState::new(0);
state.push_char('c', &msgs);
state.select_next(); assert_eq!(state.selected, 1);
for c in "atxyz".chars() {
state.push_char(c, &msgs);
}
assert!(state.matches.is_empty());
assert_eq!(state.selected, 0);
}
#[test]
fn render_search_bar_snapshot_shows_query_and_count() {
let msgs = messages(&["needle", "needle"]);
let mut state = TranscriptSearchState::new(0);
for c in "needle".chars() {
state.push_char(c, &msgs);
}
let output = render_to_string(80, 24, |frame, area| {
let theme = Theme::default();
render(&state, frame, area, &theme);
});
assert!(output.contains("needle"));
assert!(output.contains("1/2"));
}
#[test]
fn render_no_matches_snapshot() {
let msgs = messages(&["hello"]);
let mut state = TranscriptSearchState::new(0);
for c in "zzz".chars() {
state.push_char(c, &msgs);
}
let output = render_to_string(80, 24, |frame, area| {
let theme = Theme::default();
render(&state, frame, area, &theme);
});
assert!(output.contains("no matches"));
}
}