use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use super::format::format_message_plain;
use super::theme::Theme;
use crate::session::SessionMessage;
const MAX_SUGGESTIONS: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterScope {
Local(usize),
Global,
}
pub struct FilterState {
pub input: String,
pub locked: Option<String>,
pub scope: FilterScope,
pub history: Vec<String>,
pub history_cursor: Option<usize>,
pub suggestion_index: Option<usize>,
pub saved_input: Option<String>,
}
impl FilterState {
#[must_use]
pub const fn new(scope: FilterScope) -> Self {
Self {
input: String::new(),
locked: None,
scope,
history: Vec::new(),
history_cursor: None,
suggestion_index: None,
saved_input: None,
}
}
pub fn push_char(&mut self, c: char) {
self.input.push(c);
self.suggestion_index = None;
self.history_cursor = None;
self.saved_input = None;
}
pub fn pop_char(&mut self) {
self.input.pop();
self.suggestion_index = None;
}
pub fn submit(&mut self) {
if let Some(idx) = self.suggestion_index {
let matches = self.matching_entries_owned();
if let Some(suggestion) = matches.get(idx) {
self.input.clone_from(suggestion);
}
}
if self.input.is_empty() {
self.locked = None;
} else {
self.locked = Some(self.input.clone());
self.history.retain(|h| h != &self.input);
self.history.push(self.input.clone());
}
self.input.clear();
self.suggestion_index = None;
self.history_cursor = None;
self.saved_input = None;
}
pub fn cancel(&mut self) {
self.input.clear();
self.suggestion_index = None;
self.history_cursor = None;
self.saved_input = None;
}
pub fn clear_locked(&mut self) {
self.locked = None;
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "history indices never overflow isize"
)]
pub fn navigate_history(&mut self, delta: isize) {
if self.history.is_empty() {
return;
}
if self.history_cursor.is_none() {
self.saved_input = Some(self.input.clone());
}
let len = self.history.len() as isize;
let new_pos = self
.history_cursor
.map_or_else(|| len + delta, |pos| pos as isize + delta);
if new_pos >= len {
self.history_cursor = None;
if let Some(saved) = self.saved_input.take() {
self.input = saved;
}
} else if new_pos < 0 {
self.history_cursor = Some(0);
self.input = self.history[0].clone();
} else {
let idx = new_pos as usize;
self.history_cursor = Some(idx);
self.input = self.history[idx].clone();
}
self.suggestion_index = None;
}
pub fn cycle_suggestion(&mut self, delta: isize) {
let matches = self.matching_entries_owned();
if matches.is_empty() {
self.suggestion_index = None;
return;
}
let count = matches.len();
match (self.suggestion_index, delta.signum()) {
(None, -1) => self.suggestion_index = Some(0),
(None, 1) => self.suggestion_index = Some(count - 1),
(Some(idx), -1) if idx + 1 >= count => self.suggestion_index = None,
(Some(idx), -1) => self.suggestion_index = Some(idx + 1),
(Some(0), 1) => self.suggestion_index = None,
(Some(idx), 1) => self.suggestion_index = Some(idx - 1),
_ => {}
}
}
#[must_use]
pub fn matching_entries(&self) -> Vec<&str> {
if self.input.is_empty() {
return self
.history
.iter()
.rev()
.map(String::as_str)
.take(MAX_SUGGESTIONS)
.collect();
}
let lower_input = self.input.to_lowercase();
let mut seen = std::collections::HashSet::new();
let mut result: Vec<&str> = Vec::new();
for entry in self.history.iter().rev() {
let lower_entry = entry.to_lowercase();
if lower_entry.contains(&lower_input) && seen.insert(entry.as_str()) {
result.push(entry.as_str());
if result.len() >= MAX_SUGGESTIONS {
break;
}
}
}
result
}
#[must_use]
pub fn effective_input(&self) -> &str {
if let Some(idx) = self.suggestion_index {
let matches = self.matching_entries();
if let Some(entry) = matches.get(idx) {
return entry;
}
}
&self.input
}
#[must_use]
pub fn virtual_text(&self) -> Option<&str> {
let idx = self.suggestion_index?;
let matches = self.matching_entries();
let entry = matches.get(idx)?;
if entry.len() > self.input.len() && entry.starts_with(&self.input) {
Some(&entry[self.input.len()..])
} else {
None
}
}
fn matching_entries_owned(&self) -> Vec<String> {
self.matching_entries()
.into_iter()
.map(String::from)
.collect()
}
}
#[must_use]
pub fn filter_messages(messages: &[SessionMessage], pattern: &str) -> Vec<usize> {
let lower_pattern = pattern.to_lowercase();
messages
.iter()
.enumerate()
.filter(|(_, msg)| {
format_message_plain(msg)
.to_lowercase()
.contains(&lower_pattern)
})
.map(|(i, _)| i)
.collect()
}
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub fn render_filter_bar(state: &FilterState, area: Rect, buf: &mut Buffer, theme: &Theme) {
if area.width < 10 || area.height < 1 {
return;
}
let width = area.width as usize;
let y = area.y;
let prefix = match state.scope {
FilterScope::Local(_) => "Filter: ",
FilterScope::Global => "Global: ",
};
let hint = "Esc cancel";
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(prefix.to_string(), theme.accent));
spans.push(Span::raw(state.input.clone()));
spans.push(Span::styled("\u{258F}", theme.text));
if let Some(vtext) = state.virtual_text() {
spans.push(Span::styled(vtext.to_string(), theme.muted));
}
let left_line = Line::from(spans);
let left_width = UnicodeWidthStr::width(
left_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.as_str(),
);
buf.set_line(area.x, y, &left_line, area.width);
let hint_width = UnicodeWidthStr::width(hint);
let gap = 1; if left_width + gap + hint_width <= width {
let hint_x = area.x + area.width - hint_width as u16;
let hint_line = Line::from(Span::styled(hint.to_string(), theme.muted));
buf.set_line(hint_x, y, &hint_line, hint_width as u16);
}
}
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub fn render_filter_liftup(state: &FilterState, area: Rect, buf: &mut Buffer, theme: &Theme) {
let matches = state.matching_entries();
if matches.is_empty() || area.height < 1 {
return;
}
let visible_count = matches.len().min(area.height as usize);
for (i, entry) in matches.iter().take(visible_count).enumerate() {
let row = area.y + (visible_count - 1 - i) as u16;
if row < area.y {
break;
}
let is_selected = state.suggestion_index == Some(i);
let prefix = if is_selected { "\u{2590} " } else { " " }; let style = if is_selected { theme.text } else { theme.muted };
let line = Line::from(vec![
Span::styled(prefix.to_string(), style),
Span::styled((*entry).to_string(), style),
]);
buf.set_line(area.x, row, &line, area.width);
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::session::SessionMessage;
fn test_theme() -> Theme {
Theme::new()
}
fn make_message(method: &str) -> SessionMessage {
SessionMessage {
id: 0,
r#type: "mcp".to_string(),
method: method.to_string(),
server: "catenary".to_string(),
client: "claude-code".to_string(),
request_id: None,
parent_id: None,
timestamp: chrono::Utc::now(),
payload: serde_json::json!({"params": {"name": method}}),
}
}
fn buffer_to_string(buf: &Buffer) -> String {
let mut s = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
s.push_str(cell.symbol());
}
s.push('\n');
}
s
}
#[test]
fn test_filter_push_and_submit() {
let mut f = FilterState::new(FilterScope::Local(0));
f.push_char('h');
f.push_char('o');
f.push_char('v');
f.push_char('e');
f.push_char('r');
f.submit();
assert_eq!(f.locked, Some("hover".to_string()));
assert!(f.history.contains(&"hover".to_string()));
}
#[test]
fn test_filter_cancel() {
let mut f = FilterState::new(FilterScope::Local(0));
f.push_char('t');
f.push_char('e');
f.push_char('s');
f.push_char('t');
f.cancel();
assert_eq!(f.locked, None);
assert!(f.input.is_empty());
assert!(f.history.is_empty());
}
#[test]
fn test_filter_clear_locked() {
let mut f = FilterState::new(FilterScope::Local(0));
for c in "hover".chars() {
f.push_char(c);
}
f.submit();
assert_eq!(f.locked, Some("hover".to_string()));
f.clear_locked();
assert_eq!(f.locked, None);
assert!(f.history.contains(&"hover".to_string()));
}
#[test]
fn test_filter_history_navigation() {
let mut f = FilterState::new(FilterScope::Local(0));
for s in &["aaa", "bbb", "ccc"] {
for c in s.chars() {
f.push_char(c);
}
f.submit();
}
f.push_char('x');
f.navigate_history(-1); assert_eq!(f.input, "ccc");
f.navigate_history(-1); assert_eq!(f.input, "bbb");
f.navigate_history(1); assert_eq!(f.input, "ccc");
f.navigate_history(1); assert_eq!(f.input, "x");
}
#[test]
fn test_filter_history_dedup() {
let mut f = FilterState::new(FilterScope::Local(0));
for _ in 0..2 {
for c in "hover".chars() {
f.push_char(c);
}
f.submit();
}
assert_eq!(
f.history.iter().filter(|h| *h == "hover").count(),
1,
"history should contain only one 'hover' entry"
);
}
#[test]
fn test_filter_suggestion_cycle() {
let mut f = FilterState::new(FilterScope::Local(0));
for s in &["hover ok", "hover error", "search"] {
for c in s.chars() {
f.push_char(c);
}
f.submit();
}
for c in "hov".chars() {
f.push_char(c);
}
let matches = f.matching_entries();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0], "hover error");
assert_eq!(matches[1], "hover ok");
f.cycle_suggestion(-1);
assert_eq!(f.effective_input(), "hover error");
f.cycle_suggestion(-1);
assert_eq!(f.effective_input(), "hover ok");
f.cycle_suggestion(-1);
assert_eq!(f.suggestion_index, None);
assert_eq!(f.effective_input(), "hov");
}
#[test]
fn test_filter_virtual_text() {
let mut f = FilterState::new(FilterScope::Local(0));
for c in "hover".chars() {
f.push_char(c);
}
f.submit();
for c in "ho".chars() {
f.push_char(c);
}
f.cycle_suggestion(-1);
assert_eq!(f.virtual_text(), Some("ver"));
}
#[test]
fn test_filter_effective_input() {
let mut f = FilterState::new(FilterScope::Local(0));
for c in "hover error".chars() {
f.push_char(c);
}
f.submit();
for c in "hov".chars() {
f.push_char(c);
}
assert_eq!(f.effective_input(), "hov");
f.cycle_suggestion(-1);
assert_eq!(f.effective_input(), "hover error");
}
#[test]
fn test_filter_matching_entries_max_5() {
let mut f = FilterState::new(FilterScope::Local(0));
for i in 0..10 {
let s = format!("test {i}");
for c in s.chars() {
f.push_char(c);
}
f.submit();
}
for c in "test".chars() {
f.push_char(c);
}
let matches = f.matching_entries();
assert!(
matches.len() <= 5,
"matching_entries should return at most 5, got {}",
matches.len()
);
}
#[test]
fn test_filter_messages_case_insensitive() {
let messages = vec![make_message("Hover"), make_message("hover")];
let result = filter_messages(&messages, "HOVER");
assert_eq!(result.len(), 2);
assert_eq!(result[0], 0);
assert_eq!(result[1], 1);
}
#[test]
fn test_filter_messages_no_match() {
let messages = vec![make_message("tools/call"), make_message("initialize")];
let result = filter_messages(&messages, "zzzzz");
assert!(result.is_empty());
}
#[test]
fn test_render_filter_bar_typing() {
let theme = test_theme();
let mut f = FilterState::new(FilterScope::Local(0));
for c in "hov".chars() {
f.push_char(c);
}
let backend = TestBackend::new(60, 1);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|frame| {
let area = frame.area();
render_filter_bar(&f, area, frame.buffer_mut(), &theme);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(
content.contains("Filter: hov"),
"expected 'Filter: hov' in bar, got: {content}"
);
assert!(
content.contains("Esc cancel"),
"expected 'Esc cancel' hint, got: {content}"
);
}
#[test]
fn test_render_filter_bar_with_suggestion() {
let theme = test_theme();
let mut f = FilterState::new(FilterScope::Local(0));
for c in "hover error".chars() {
f.push_char(c);
}
f.submit();
for c in "hov".chars() {
f.push_char(c);
}
f.cycle_suggestion(-1);
let backend = TestBackend::new(60, 1);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|frame| {
let area = frame.area();
render_filter_bar(&f, area, frame.buffer_mut(), &theme);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(
content.contains("hov"),
"expected typed text 'hov', got: {content}"
);
assert!(
content.contains("er error"),
"expected virtual text 'er error', got: {content}"
);
}
#[test]
fn test_render_filter_liftup() {
let theme = test_theme();
let mut f = FilterState::new(FilterScope::Local(0));
for s in &["hover ok", "hover error", "hover timeout"] {
for c in s.chars() {
f.push_char(c);
}
f.submit();
}
for c in "hover".chars() {
f.push_char(c);
}
let backend = TestBackend::new(40, 5);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|frame| {
let area = frame.area();
render_filter_liftup(&f, area, frame.buffer_mut(), &theme);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(
content.contains("hover ok"),
"expected 'hover ok' in liftup, got: {content}"
);
assert!(
content.contains("hover error"),
"expected 'hover error' in liftup, got: {content}"
);
assert!(
content.contains("hover timeout"),
"expected 'hover timeout' in liftup, got: {content}"
);
}
}