use std::time::{Duration, Instant};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
};
use tokio::sync::mpsc;
use crate::monitor::dashboard::{IndexRow, format_count};
use crate::monitor::search_client::{ReindexEvent, SearchClient, resolve_search_url};
use crate::monitor::utils::{ActivityLog, DaemonStatus, fmt_uptime};
const REFRESH_INTERVAL: Duration = Duration::from_millis(2000);
const INPUT_POLL: Duration = Duration::from_millis(50);
const SEARCH_TOP_K: usize = 5;
const LEFT_PANEL_MAX: u16 = 28;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const KEY_HINT: &str =
"[Tab] focus [r] reindex [↑↓] select [Enter] search [q] quit [?] help";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SearchFocus {
#[default]
List,
Input,
}
#[derive(Debug, Clone)]
pub struct SearchTuiState {
pub base_url: String,
pub daemon_status: DaemonStatus,
pub indexes: Vec<IndexRow>,
pub selected: usize,
pub log: ActivityLog,
pub input: String,
pub focus: SearchFocus,
pub show_help: bool,
}
impl SearchTuiState {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
daemon_status: DaemonStatus::Connecting,
indexes: Vec::new(),
selected: 0,
log: ActivityLog::new(),
input: String::new(),
focus: SearchFocus::List,
show_help: false,
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
SearchFocus::List => SearchFocus::Input,
SearchFocus::Input => SearchFocus::List,
};
}
pub fn select_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn select_down(&mut self) {
let last = self.indexes.len().saturating_sub(1);
if self.selected < last {
self.selected += 1;
}
}
pub fn clamp_selection(&mut self) {
let last = self.indexes.len().saturating_sub(1);
if self.selected > last {
self.selected = last;
}
}
pub fn selected_id(&self) -> Option<&str> {
self.indexes.get(self.selected).map(|i| i.id.as_str())
}
}
pub async fn run() -> anyhow::Result<()> {
run_with_url(resolve_search_url()).await
}
pub async fn run_with_url(base_url: String) -> anyhow::Result<()> {
let mut client = SearchClient::new(base_url.clone());
let mut state = SearchTuiState::new(base_url);
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(&mut terminal, &mut state, &mut client).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
async fn poll_daemon(state: &mut SearchTuiState, client: &mut SearchClient) {
if !state.daemon_status.is_online() {
let resolved = resolve_search_url();
if resolved != client.base_url() {
client.set_base_url(resolved.clone());
state.base_url = resolved;
}
}
match client.fetch_all().await {
Ok(data) => {
state.daemon_status = DaemonStatus::Online {
version: data.version,
uptime_secs: data.uptime_secs,
};
state.indexes = data.indexes;
state.clamp_selection();
}
Err(e) => {
state.daemon_status = DaemonStatus::Offline {
last_error: e.to_string(),
};
}
}
}
async fn run_search(state: &mut SearchTuiState, client: &SearchClient) {
let query = state.input.trim().to_string();
if query.is_empty() {
return;
}
let Some(id) = state.selected_id().map(str::to_string) else {
state.log.push("search: no index selected");
state.input.clear();
return;
};
match client.search(&id, &query, SEARCH_TOP_K).await {
Ok(hits) => {
state
.log
.push(format!("search \"{query}\" → {} results", hits.len()));
for hit in &hits {
state
.log
.push_raw(format!(" {}:{} {}", hit.file, hit.line, hit.snippet));
}
}
Err(e) => state.log.push(format!("search \"{query}\" failed: {e}")),
}
state.input.clear();
}
pub fn apply_reindex_event(state: &mut SearchTuiState, event: ReindexEvent) {
match event {
ReindexEvent::Started { total_files } => {
state
.log
.push(format!("reindex started: {total_files} files"));
}
ReindexEvent::Progress {
indexed,
total_files,
} => {
let pct = if total_files > 0 {
indexed.saturating_mul(100) / total_files
} else {
0
};
state
.log
.push(format!("indexing: {indexed}/{total_files} ({pct}%)"));
}
ReindexEvent::Complete {
total_chunks,
status,
} => {
state.log.push(format!(
"reindex {status}: {} chunks",
format_count(total_chunks)
));
}
ReindexEvent::Failed(message) => {
state.log.push(format!("reindex error: {message}"));
}
}
}
async fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut SearchTuiState,
client: &mut SearchClient,
) -> anyhow::Result<()> {
poll_daemon(state, client).await;
let mut last_poll = Instant::now();
let (reindex_tx, mut reindex_rx) = mpsc::channel::<ReindexEvent>(64);
loop {
terminal.draw(|f| render(f, state))?;
while let Ok(event) = reindex_rx.try_recv() {
apply_reindex_event(state, event);
}
let key = if event::poll(INPUT_POLL)? {
match event::read()? {
Event::Key(key) => Some(key),
_ => None,
}
} else {
None
};
if let Some(key) = key
&& key.kind != KeyEventKind::Release
{
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(());
}
if state.show_help {
if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
state.show_help = false;
} else if key.code == KeyCode::Char('q') {
return Ok(());
}
continue;
}
match (state.focus, key.code) {
(_, KeyCode::Char('?')) => state.show_help = true,
(_, KeyCode::Tab) => state.toggle_focus(),
(_, KeyCode::Esc) => return Ok(()),
(SearchFocus::List, KeyCode::Char('q')) => return Ok(()),
(SearchFocus::List, KeyCode::Up) => state.select_up(),
(SearchFocus::List, KeyCode::Down) => state.select_down(),
(SearchFocus::List, KeyCode::Char('r')) => {
if let Some(id) = state.selected_id().map(str::to_string) {
state.log.push(format!("reindex triggered: {id}"));
let stream_client = client.clone();
let tx = reindex_tx.clone();
tokio::spawn(async move {
stream_client.reindex_stream(&id, tx).await;
});
} else {
state.log.push("reindex: no index selected");
}
}
(SearchFocus::Input, KeyCode::Enter) => {
run_search(state, client).await;
poll_daemon(state, client).await;
last_poll = Instant::now();
}
(SearchFocus::Input, KeyCode::Backspace) => {
state.input.pop();
}
(SearchFocus::Input, KeyCode::Char(c)) => state.input.push(c),
_ => {}
}
}
if last_poll.elapsed() >= REFRESH_INTERVAL {
poll_daemon(state, client).await;
last_poll = Instant::now();
}
}
}
pub fn help_text() -> String {
[
" Tab switch focus between the index list and the search bar",
" ↑ / ↓ move the index selection (when the list has focus)",
" r reindex the selected index (streams live progress)",
" Enter run a search against the selected index (search bar)",
" ? toggle this help overlay",
" q / Esc quit",
]
.join("\n")
}
pub fn left_panel_width(width: u16) -> u16 {
LEFT_PANEL_MAX.min(width / 3)
}
pub fn index_lines(state: &SearchTuiState) -> Vec<(String, bool)> {
if state.indexes.is_empty() {
return vec![("(no indexes registered)".to_string(), false)];
}
state
.indexes
.iter()
.enumerate()
.map(|(i, idx)| {
let marker = if i == state.selected { ">" } else { " " };
let text = format!(
"{marker} {:<12} {:>8} ✓",
truncate(&idx.id, 12),
format_count(idx.chunk_count),
);
(text, i == state.selected)
})
.collect()
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
}
pub fn title_line(state: &SearchTuiState) -> String {
let (glyph, label) = state.daemon_status.badge();
match &state.daemon_status {
DaemonStatus::Online { uptime_secs, .. } => format!(
"trusty-search v{VERSION} [{glyph}] {label} uptime: {}",
fmt_uptime(*uptime_secs)
),
_ => format!(
"trusty-search v{VERSION} [{glyph}] {label} {}",
state.base_url
),
}
}
pub fn render(frame: &mut Frame, state: &SearchTuiState) {
let area = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(4), Constraint::Length(3), Constraint::Length(1), ])
.split(area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
title_line(state),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))),
rows[0],
);
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(left_panel_width(area.width)),
Constraint::Min(10),
])
.split(rows[1]);
let list_focused = state.focus == SearchFocus::List;
let index_items: Vec<ListItem> = index_lines(state)
.into_iter()
.map(|(text, selected)| {
let style = if selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect();
frame.render_widget(
List::new(index_items).block(panel_block("INDEXES", list_focused)),
split[0],
);
let activity_height = split[1].height.saturating_sub(2) as usize;
let activity_items: Vec<ListItem> = if state.log.is_empty() {
vec![ListItem::new("(no activity yet)")]
} else {
state
.log
.tail(activity_height.max(1))
.map(|line| ListItem::new(line.as_str()))
.collect()
};
frame.render_widget(
List::new(activity_items).block(panel_block("ACTIVITY", false)),
split[1],
);
let input_focused = state.focus == SearchFocus::Input;
let cursor = if input_focused { "_" } else { "" };
let input_style = if input_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("SEARCH ▶ ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}{cursor}", state.input), input_style),
]))
.block(panel_block("SEARCH", input_focused)),
rows[2],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
KEY_HINT,
Style::default().fg(Color::DarkGray),
))),
rows[3],
);
if state.show_help {
render_help_overlay(frame);
}
}
fn panel_block(name: &str, focused: bool) -> Block<'static> {
let border_style = if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
format!(" {name} "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
}
fn render_help_overlay(frame: &mut Frame) {
let area = frame.area();
let w = 60.min(area.width);
let h = 9.min(area.height);
let rect = ratatui::layout::Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w,
height: h,
};
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(help_text())
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Help — press ? or Esc to close "),
),
rect,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monitor::utils::timestamped;
use ratatui::{Terminal, backend::TestBackend};
fn sample_state() -> SearchTuiState {
let mut state = SearchTuiState::new("http://127.0.0.1:7878");
state.daemon_status = DaemonStatus::Online {
version: "0.3.65".into(),
uptime_secs: 7440,
};
state.indexes = vec![
IndexRow {
id: "cto".into(),
chunk_count: 1_200,
root_path: "/tmp/cto".into(),
},
IndexRow {
id: "trusty".into(),
chunk_count: 18_994,
root_path: "/tmp/trusty".into(),
},
IndexRow {
id: "duetto".into(),
chunk_count: 900,
root_path: "/tmp/duetto".into(),
},
];
state
}
#[test]
fn test_new_state_defaults() {
let state = SearchTuiState::new("http://127.0.0.1:7878");
assert_eq!(state.base_url, "http://127.0.0.1:7878");
assert!(matches!(state.daemon_status, DaemonStatus::Connecting));
assert!(state.indexes.is_empty());
assert_eq!(state.selected, 0);
assert!(state.log.is_empty());
assert!(state.input.is_empty());
assert_eq!(state.focus, SearchFocus::List);
assert!(!state.show_help);
}
#[test]
fn test_toggle_focus() {
let mut state = SearchTuiState::new("http://x");
assert_eq!(state.focus, SearchFocus::List);
state.toggle_focus();
assert_eq!(state.focus, SearchFocus::Input);
state.toggle_focus();
assert_eq!(state.focus, SearchFocus::List);
}
#[test]
fn test_selected_clamp() {
let mut state = sample_state();
for _ in 0..10 {
state.select_down();
}
assert_eq!(state.selected, 2, "clamped to indexes.len() - 1");
for _ in 0..10 {
state.select_up();
}
assert_eq!(state.selected, 0);
state.selected = 2;
state.indexes.truncate(1);
state.clamp_selection();
assert_eq!(state.selected, 0);
state.indexes.clear();
state.selected = 5;
state.clamp_selection();
assert_eq!(state.selected, 0);
}
#[test]
fn test_selected_id() {
let mut state = sample_state();
assert_eq!(state.selected_id(), Some("cto"));
state.select_down();
assert_eq!(state.selected_id(), Some("trusty"));
state.indexes.clear();
state.clamp_selection();
assert_eq!(state.selected_id(), None);
}
#[test]
fn test_log_append() {
let mut state = SearchTuiState::new("http://x");
for i in 0..(ActivityLog::MAX_ENTRIES + 50) {
state.log.push(format!("event {i}"));
}
assert_eq!(state.log.len(), ActivityLog::MAX_ENTRIES);
}
#[test]
fn test_timestamped_format() {
let line = timestamped("reindex started");
assert!(line.starts_with('['));
assert!(line.ends_with(" reindex started"));
assert_eq!(line.as_bytes()[9], b']');
}
#[test]
fn test_apply_reindex_event() {
let mut state = SearchTuiState::new("http://x");
apply_reindex_event(&mut state, ReindexEvent::Started { total_files: 1200 });
apply_reindex_event(
&mut state,
ReindexEvent::Progress {
indexed: 600,
total_files: 1200,
},
);
apply_reindex_event(
&mut state,
ReindexEvent::Complete {
total_chunks: 19_012,
status: "complete".into(),
},
);
let lines: Vec<&String> = state.log.iter().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("reindex started: 1200 files"));
assert!(lines[1].contains("600/1200 (50%)"));
assert!(lines[2].contains("reindex complete: 19.0k chunks"));
apply_reindex_event(&mut state, ReindexEvent::Failed("disk full".into()));
assert!(
state
.log
.iter()
.last()
.expect("entry")
.contains("reindex error: disk full")
);
}
#[test]
fn test_left_panel_width() {
assert_eq!(left_panel_width(200), LEFT_PANEL_MAX);
assert_eq!(left_panel_width(60), 20);
}
#[test]
fn test_index_lines() {
let state = sample_state();
let lines = index_lines(&state);
assert_eq!(lines.len(), 3);
assert!(lines[0].0.contains("cto") && lines[0].1);
assert!(lines[0].0.starts_with('>'));
assert!(lines[1].0.contains("trusty") && !lines[1].1);
assert!(lines[1].0.contains("19.0k"));
let empty = SearchTuiState::new("http://x");
let lines = index_lines(&empty);
assert_eq!(lines.len(), 1);
assert!(lines[0].0.contains("no indexes"));
}
#[test]
fn test_truncate() {
assert_eq!(truncate("short", 12), "short");
assert_eq!(truncate("a-very-long-index-id", 8), "a-very-…");
}
#[test]
fn test_title_line() {
let state = sample_state();
let title = title_line(&state);
assert!(title.contains("trusty-search v"));
assert!(title.contains("online"));
assert!(title.contains("uptime: 2h 4m"));
let mut offline = SearchTuiState::new("http://127.0.0.1:7878");
offline.daemon_status = DaemonStatus::Offline {
last_error: "refused".into(),
};
let title = title_line(&offline);
assert!(title.contains("offline"));
assert!(title.contains("http://127.0.0.1:7878"));
}
#[test]
fn test_help_text_lists_bindings() {
let text = help_text();
for token in ["Tab", "r ", "Enter", "?", "q "] {
assert!(text.contains(token), "help text missing {token}");
}
}
#[test]
fn test_render_smoke() {
let mut state = sample_state();
state.log.push("reindex started: 1200 files");
state.log.push("search \"fn embed\" → 5 results");
state.input = "fn authenticate".into();
state.focus = SearchFocus::Input;
for (w, h) in [(120u16, 30u16), (80, 24)] {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &state))
.expect("render must not panic");
}
state.show_help = true;
state.daemon_status = DaemonStatus::Connecting;
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &state))
.expect("help render must not panic");
}
}