use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
Frame, Terminal,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, 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::tui_common::{
self, ListFocus, ThreeWaySortKey, enter_tui, leave_tui, left_panel_width, panel_block, truncate,
};
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;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const KEY_HINT: &str = "[Tab] focus [r] reindex [↑↓] select [Enter] search [/] filter [s] sort [g] group [q] quit [?] help";
const SORT_LABELS: &[&str; 3] = &["Activity", "Name", "Chunks"];
pub type IndexSortKey = ThreeWaySortKey;
pub fn sort_label(key: ThreeWaySortKey) -> &'static str {
key.label(SORT_LABELS)
}
pub const ALL_LABEL: &str = "All indexes";
pub type SearchFocus = ListFocus;
#[derive(Debug, Clone)]
pub struct SearchTuiState {
pub base_url: String,
pub daemon_status: DaemonStatus,
pub indexes: Vec<IndexRow>,
pub selected: usize,
pub scroll_offset: usize,
pub log: ActivityLog,
pub input: String,
pub focus: SearchFocus,
pub show_help: bool,
pub filter: String,
pub filter_active: bool,
pub sort_key: ThreeWaySortKey,
pub group_by_project: bool,
pub log_watermark: Option<String>,
pub log_first_poll: 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,
scroll_offset: 0,
log: ActivityLog::new(),
input: String::new(),
focus: ListFocus::List,
show_help: false,
filter: String::new(),
filter_active: false,
sort_key: ThreeWaySortKey::default(),
group_by_project: false,
log_watermark: None,
log_first_poll: true,
}
}
pub fn toggle_focus(&mut self) {
self.focus = self.focus.toggled();
}
pub fn select_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn select_down(&mut self) {
if self.selected < self.last_row() {
self.selected += 1;
}
}
fn last_row(&self) -> usize {
self.indexes.len()
}
pub fn clamp_selection(&mut self) {
if self.selected > self.last_row() {
self.selected = self.last_row();
}
}
pub fn sync_scroll(&mut self, visible: usize) {
let cursor = self.selected;
self.sync_scroll_to(cursor, visible);
}
pub fn sync_scroll_to(&mut self, cursor_row: usize, visible: usize) {
let window = visible.max(1);
if cursor_row >= self.scroll_offset + window {
self.scroll_offset = cursor_row + 1 - window;
} else if cursor_row < self.scroll_offset {
self.scroll_offset = cursor_row;
}
}
pub fn is_all_selected(&self) -> bool {
self.selected == 0
}
pub fn selected_id(&self) -> Option<&str> {
if self.selected == 0 {
return None;
}
self.indexes.get(self.selected - 1).map(|i| i.id.as_str())
}
pub fn clamp_to_visible(&mut self) {
if self.selected == 0 {
return;
}
let Some(current_id) = self.indexes.get(self.selected - 1).map(|i| i.id.clone()) else {
self.selected = 0;
return;
};
let ids = visible_index_ids(self);
if !ids.iter().any(|id| id == ¤t_id) {
self.selected = 0;
}
}
pub fn scope_filter(&self) -> Option<&str> {
self.selected_id()
}
}
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);
let mut terminal = enter_tui()?;
let result = run_loop(&mut terminal, &mut state, &mut client).await;
leave_tui(&mut terminal)?;
result
}
pub fn new_log_lines_since<'a>(new_lines: &'a [String], watermark: Option<&str>) -> &'a [String] {
let Some(mark) = watermark else {
return new_lines;
};
match new_lines.iter().rposition(|line| line == mark) {
Some(idx) => &new_lines[idx + 1..],
None => new_lines,
}
}
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(),
};
}
}
let tail = client.logs_tail(50).await;
if state.log_first_poll {
state.log_watermark = tail.last().cloned();
state.log_first_poll = false;
} else {
let new = new_log_lines_since(&tail, state.log_watermark.as_deref());
for line in new {
state.log.push(line.clone());
}
if let Some(last) = tail.last() {
state.log_watermark = Some(last.clone());
}
}
}
async fn run_search(state: &mut SearchTuiState, client: &SearchClient) {
let query = state.input.trim().to_string();
if query.is_empty() {
return;
}
if state.is_all_selected() {
run_search_all(state, client, &query).await;
} else if let Some(id) = state.selected_id().map(str::to_string) {
run_search_one(state, client, &id, &query).await;
} else {
state.log.push("search: no index selected");
}
state.input.clear();
}
async fn run_search_one(state: &mut SearchTuiState, client: &SearchClient, id: &str, query: &str) {
match client.search(id, query, SEARCH_TOP_K).await {
Ok(hits) => {
state
.log
.push_scoped(id, format!("search \"{query}\" → {} results", hits.len()));
for hit in &hits {
state
.log
.push_raw_scoped(id, format!(" {}:{} {}", hit.file, hit.line, hit.snippet));
}
}
Err(e) => state
.log
.push_scoped(id, format!("search \"{query}\" failed: {e}")),
}
}
async fn run_search_all(state: &mut SearchTuiState, client: &SearchClient, query: &str) {
let ids: Vec<String> = state.indexes.iter().map(|i| i.id.clone()).collect();
if ids.is_empty() {
state.log.push("search (all): no indexes registered");
return;
}
state
.log
.push(format!("search \"{query}\" (all) → {} indexes", ids.len()));
let mut total = 0usize;
for id in &ids {
match client.search(id, query, SEARCH_TOP_K).await {
Ok(hits) => {
total += hits.len();
state
.log
.push_raw_scoped(id, format!(" · {id}: {} results", hits.len()));
}
Err(e) => state
.log
.push_raw_scoped(id, format!(" · {id}: failed: {e}")),
}
}
state.log.push(format!(
"search \"{query}\" (all) → {total} results across {} indexes",
ids.len()
));
}
#[derive(Debug, Clone)]
pub struct ScopedReindexEvent {
pub index_id: String,
pub event: ReindexEvent,
}
pub fn apply_reindex_event(state: &mut SearchTuiState, scoped: ScopedReindexEvent) {
let id = scoped.index_id;
match scoped.event {
ReindexEvent::Started { total_files } => {
state
.log
.push_scoped(&id, 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_scoped(&id, format!("indexing: {indexed}/{total_files} ({pct}%)"));
}
ReindexEvent::Complete {
total_chunks,
status,
} => {
state.log.push_scoped(
&id,
format!("reindex {status}: {} chunks", format_count(total_chunks)),
);
}
ReindexEvent::Failed(message) => {
state
.log
.push_scoped(&id, 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::<ScopedReindexEvent>(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) {
(SearchFocus::List, KeyCode::Esc) if state.filter_active => {
state.filter_active = false;
}
(SearchFocus::List, KeyCode::Enter) if state.filter_active => {
state.filter_active = false;
}
(SearchFocus::List, KeyCode::Backspace) if state.filter_active => {
state.filter.pop();
state.clamp_to_visible();
}
(SearchFocus::List, KeyCode::Char(c)) if state.filter_active => {
state.filter.push(c);
state.clamp_to_visible();
}
(SearchFocus::List, KeyCode::Tab) if state.filter_active => {}
(_, 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) => navigate_up_visible(state),
(SearchFocus::List, KeyCode::Down) => navigate_down_visible(state),
(SearchFocus::List, KeyCode::Char('/')) => {
state.filter_active = true;
state.filter.clear();
}
(SearchFocus::List, KeyCode::Char('s')) => {
state.sort_key = state.sort_key.next();
}
(SearchFocus::List, KeyCode::Char('g')) => {
state.group_by_project = !state.group_by_project;
}
(SearchFocus::List, KeyCode::Char('r')) => {
let targets: Vec<String> = if state.is_all_selected() {
state.indexes.iter().map(|i| i.id.clone()).collect()
} else {
state
.selected_id()
.map(str::to_string)
.into_iter()
.collect()
};
if targets.is_empty() {
if state.is_all_selected() {
state.log.push("reindex (all): no indexes registered");
} else {
state.log.push("reindex: no index selected");
}
} else {
if state.is_all_selected() {
state
.log
.push(format!("reindex triggered: all {} indexes", targets.len()));
}
for id in targets {
state
.log
.push_scoped(&id, format!("reindex triggered: {id}"));
spawn_reindex(client.clone(), reindex_tx.clone(), id);
}
}
}
(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();
}
}
}
fn spawn_reindex(client: SearchClient, out: mpsc::Sender<ScopedReindexEvent>, index_id: String) {
let (inner_tx, mut inner_rx) = mpsc::channel::<ReindexEvent>(64);
let stream_id = index_id.clone();
tokio::spawn(async move {
client.reindex_stream(&stream_id, inner_tx).await;
});
tokio::spawn(async move {
while let Some(event) = inner_rx.recv().await {
let scoped = ScopedReindexEvent {
index_id: index_id.clone(),
event,
};
if out.send(scoped).await.is_err() {
break; }
}
});
}
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)",
" All the top list row fans queries / stats across every index",
" / activate the inline index filter (Esc / Enter close)",
" s cycle index sort: Activity → Name → Chunks",
" g toggle grouping by inferred project",
" r reindex the selected index — or all, when 'All' is selected",
" Enter run a search against the selected index — or all of them",
" ? toggle this help overlay",
" q / Esc quit",
]
.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexListRow {
pub text: String,
pub selected: bool,
pub is_all: bool,
pub is_header: bool,
}
fn index_row_flat(idx: &IndexRow, selected: bool) -> String {
let marker = if selected { ">" } else { " " };
format!(
"{marker} {:<12} {:>8} ✓",
truncate(&idx.id, 12),
format_count(idx.chunk_count),
)
}
fn index_row_indented(idx: &IndexRow, selected: bool) -> String {
let marker = if selected { ">" } else { " " };
format!(
"{marker} {:<11} {:>8} ✓",
truncate(&idx.id, 11),
format_count(idx.chunk_count),
)
}
pub fn filtered_sorted_indexes(state: &SearchTuiState) -> Vec<IndexRow> {
tui_common::filtered_sorted(&state.indexes, &state.filter, state.sort_key)
}
pub fn visible_index_ids(state: &SearchTuiState) -> Vec<String> {
tui_common::visible_ids(
&state.indexes,
&state.filter,
state.sort_key,
state.group_by_project,
)
}
pub fn navigate_up_visible(state: &mut SearchTuiState) {
state.selected = tui_common::navigate_up(
&state.indexes,
state.selected,
&state.filter,
state.sort_key,
state.group_by_project,
);
}
pub fn navigate_down_visible(state: &mut SearchTuiState) {
state.selected = tui_common::navigate_down(
&state.indexes,
state.selected,
&state.filter,
state.sort_key,
state.group_by_project,
);
}
pub fn visible_selected_row(state: &SearchTuiState) -> usize {
if state.selected == 0 {
return 0;
}
index_lines(state)
.iter()
.position(|row| row.selected)
.unwrap_or(0)
}
pub fn index_lines(state: &SearchTuiState) -> Vec<IndexListRow> {
let mut rows: Vec<IndexListRow> = Vec::with_capacity(state.indexes.len() + 1);
let total_chunks: u64 = state.indexes.iter().map(|i| i.chunk_count).sum();
let all_selected = state.selected == 0;
let all_marker = if all_selected { ">" } else { " " };
rows.push(IndexListRow {
text: format!(
"{all_marker} {:<12} {:>8} ∗",
truncate(ALL_LABEL, 12),
format_count(total_chunks),
),
selected: all_selected,
is_all: true,
is_header: false,
});
if state.indexes.is_empty() {
rows.push(IndexListRow {
text: " (no indexes registered)".to_string(),
selected: false,
is_all: false,
is_header: false,
});
return rows;
}
let visible = filtered_sorted_indexes(state);
if visible.is_empty() {
rows.push(IndexListRow {
text: " (no matches)".to_string(),
selected: false,
is_all: false,
is_header: false,
});
return rows;
}
let cursor_for = |idx: &IndexRow| -> usize {
state
.indexes
.iter()
.position(|orig| orig.id == idx.id)
.map(|i| i + 1)
.unwrap_or(0)
};
if state.group_by_project {
let mut seen: Vec<String> = Vec::new();
for i in &visible {
let proj = i.project().to_string();
if !seen.iter().any(|s| s == &proj) {
seen.push(proj);
}
}
for project in &seen {
rows.push(IndexListRow {
text: format!("── {project} ─────"),
selected: false,
is_all: false,
is_header: true,
});
for idx in visible.iter().filter(|i| i.project() == project) {
let cursor = cursor_for(idx);
let selected = cursor == state.selected;
rows.push(IndexListRow {
text: index_row_indented(idx, selected),
selected,
is_all: false,
is_header: false,
});
}
}
} else {
for idx in &visible {
let cursor = cursor_for(idx);
let selected = cursor == state.selected;
rows.push(IndexListRow {
text: index_row_flat(idx, selected),
selected,
is_all: false,
is_header: false,
});
}
}
rows
}
pub fn stats_lines(state: &SearchTuiState) -> Vec<String> {
if state.is_all_selected() {
let total: u64 = state.indexes.iter().map(|i| i.chunk_count).sum();
let total_nodes: u64 = state.indexes.iter().map(|i| i.node_count).sum();
let mut lines = vec![
format!("Scope: {ALL_LABEL}"),
format!("Indexes: {}", state.indexes.len()),
format!("Total chunks: {}", format_count(total)),
];
if total_nodes > 0 {
lines.push(format!("Graph nodes: {}", format_count(total_nodes)));
} else {
lines.push("Graph nodes: (none — reindex to build)".to_string());
}
if state.indexes.is_empty() {
lines.push("(no indexes registered)".to_string());
} else {
lines.push(String::new());
for idx in &state.indexes {
lines.push(format!(
" · {:<14} {:>8}",
truncate(&idx.id, 14),
format_count(idx.chunk_count),
));
}
}
return lines;
}
match state.indexes.get(state.selected.saturating_sub(1)) {
Some(idx) => {
let mut lines = vec![
format!("Index: {}", idx.id),
format!("Chunks: {}", format_count(idx.chunk_count)),
format!(
"Root path: {}",
if idx.root_path.is_empty() {
"(unknown)"
} else {
idx.root_path.as_str()
}
),
];
if let Some(bytes) = idx.disk_bytes {
lines.push(format!("Disk size: {}", format_bytes(bytes)));
}
if let Some(when) = idx.last_indexed {
lines.push(format!(
"Last indexed: {}",
when.format("%Y-%m-%d %H:%M UTC")
));
}
lines.push(String::new());
lines.push("Graph:".to_string());
if idx.node_count == 0 {
lines.push(" (no graph — press [r] to reindex)".to_string());
} else {
lines.push(format!(
" Nodes: {:>8} Edges: {:>8}",
format_count(idx.node_count),
format_count(idx.edge_count),
));
if let Some(max) = idx.edge_kinds.iter().map(|(_, n)| *n).max()
&& max > 0
{
const BAR_WIDTH: usize = 14;
for (kind, count) in &idx.edge_kinds {
let bar_len =
((*count as f64 / max as f64) * BAR_WIDTH as f64).round() as usize;
let bar_len = bar_len.min(BAR_WIDTH);
let bar: String = "█".repeat(bar_len);
lines.push(format!(
" {:<18} {:>7} {}",
truncate(kind, 18),
format_count(*count),
bar,
));
}
}
if idx.community_count > 0 {
lines.push(String::new());
lines.push("Communities:".to_string());
lines.push(format!(
" Count: {} Modularity: {:.3}",
idx.community_count, idx.modularity,
));
}
}
lines
}
None => vec!["(no index selected)".to_string()],
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
const TB: f64 = GB * 1024.0;
let n = bytes as f64;
if n < KB {
format!("{bytes} B")
} else if n < MB {
format!("{:.1} KB", n / KB)
} else if n < GB {
format!("{:.1} MB", n / MB)
} else if n < TB {
format!("{:.1} GB", n / GB)
} else {
format!("{:.1} TB", n / TB)
}
}
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: &mut 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(|row| {
let style = if row.selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if row.is_header {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if row.is_all {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(row.text, style)))
})
.collect();
let show_filter_bar = state.filter_active || !state.filter.is_empty();
let (filter_area, list_area) = if show_filter_bar {
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(3)])
.split(split[0]);
(Some(inner[0]), inner[1])
} else {
(None, split[0])
};
if let Some(area) = filter_area {
let border_color = if state.filter_active {
Color::Yellow
} else {
Color::DarkGray
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(
state.filter.as_str().to_string(),
Style::default().fg(Color::White),
),
Span::styled(
if state.filter_active { "_" } else { "" },
Style::default().fg(Color::Cyan),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
)
.title(Span::styled(
" FILTER ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
),
area,
);
}
let index_visible = list_area.height.saturating_sub(2) as usize;
let visible_row = visible_selected_row(state);
state.sync_scroll_to(visible_row, index_visible);
let mut index_state = ListState::default()
.with_offset(state.scroll_offset)
.with_selected(Some(visible_row));
let index_title = format!("INDEXES [{}]", sort_label(state.sort_key));
frame.render_stateful_widget(
List::new(index_items).block(panel_block(&index_title, list_focused)),
list_area,
&mut index_state,
);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(tui_common::ACTIVITY_PERCENT),
Constraint::Percentage(100 - tui_common::ACTIVITY_PERCENT),
])
.split(split[1]);
let scope = state.scope_filter();
let activity_title = match scope {
Some(id) => format!("ACTIVITY — {id}"),
None => format!("ACTIVITY — {ALL_LABEL}"),
};
let activity_height = right[0].height.saturating_sub(2) as usize;
let activity_items: Vec<ListItem> = if state.log.has_scoped(scope) {
state
.log
.tail_scoped(scope, activity_height.max(1))
.map(|line| ListItem::new(line.as_str()))
.collect()
} else {
vec![ListItem::new("(no activity yet)")]
};
frame.render_widget(
List::new(activity_items).block(panel_block(&activity_title, false)),
right[0],
);
let stats_items: Vec<ListItem> = stats_lines(state).into_iter().map(ListItem::new).collect();
frame.render_widget(
List::new(stats_items).block(panel_block("STATISTICS", false)),
right[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 {
tui_common::render_help_overlay(frame, &help_text());
}
}
#[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(),
..Default::default()
},
IndexRow {
id: "trusty".into(),
chunk_count: 18_994,
root_path: "/tmp/trusty".into(),
..Default::default()
},
IndexRow {
id: "duetto".into(),
chunk_count: 900,
root_path: "/tmp/duetto".into(),
..Default::default()
},
];
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, 3, "clamped to indexes.len()");
for _ in 0..10 {
state.select_up();
}
assert_eq!(state.selected, 0);
state.selected = 3;
state.indexes.truncate(1);
state.clamp_selection();
assert_eq!(state.selected, 1);
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!(state.is_all_selected());
assert_eq!(state.selected_id(), None);
state.select_down();
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_all_selector() {
let mut state = sample_state();
assert!(state.is_all_selected());
assert_eq!(state.scope_filter(), None);
state.select_down();
assert!(!state.is_all_selected());
assert_eq!(state.scope_filter(), Some("cto"));
state.select_up();
assert!(state.is_all_selected());
assert_eq!(state.scope_filter(), None);
state.sort_key = IndexSortKey::Name;
let rows = index_lines(&state);
assert_eq!(rows.len(), 4, "1 'All' row + 3 indexes");
assert!(rows[0].is_all);
assert!(rows[0].text.contains(ALL_LABEL));
assert!(rows[0].selected, "'All' is selected by default");
assert!(!rows[1].is_all);
assert!(rows[1].text.contains("cto"));
}
#[test]
fn test_stats_lines() {
let mut state = sample_state();
let all = stats_lines(&state);
assert!(
all.iter()
.any(|l| l.contains("Indexes:") && l.contains('3'))
);
assert!(all.iter().any(|l| l.contains("Total chunks:")));
assert!(all.iter().any(|l| l.contains("cto")));
assert!(all.iter().any(|l| l.contains("trusty")));
state.select_down(); let one = stats_lines(&state);
assert!(
one.iter()
.any(|l| l.contains("Index:") && l.contains("cto"))
);
assert!(
one.iter()
.any(|l| l.contains("Chunks:") && l.contains("1,200"))
);
assert!(one.iter().any(|l| l.contains("/tmp/cto")));
}
#[test]
fn test_stats_lines_graph_section() {
let mut state = sample_state();
state.indexes[0].node_count = 4_821;
state.indexes[0].edge_count = 12_034;
state.indexes[0].edge_kinds = vec![
("CallsFunction".into(), 8_201),
("UsesType".into(), 2_411),
("Implements".into(), 1_422),
];
state.indexes[0].community_count = 47;
state.indexes[0].modularity = 0.712;
state.select_down(); let lines = stats_lines(&state);
assert!(lines.iter().any(|l| l == "Graph:"));
assert!(
lines
.iter()
.any(|l| l.contains("Nodes:") && l.contains("4,821") && l.contains("Edges:"))
);
assert!(lines.iter().any(|l| l.contains("CallsFunction")));
assert!(lines.iter().any(|l| l == "Communities:"));
assert!(
lines
.iter()
.any(|l| l.contains("Count: 47") && l.contains("Modularity: 0.712"))
);
}
#[test]
fn test_stats_lines_no_graph_section() {
let mut state = sample_state();
state.indexes[0].node_count = 0;
state.indexes[0].edge_count = 100; state.indexes[0].community_count = 5; state.select_down();
let lines = stats_lines(&state);
assert!(
lines.iter().any(|l| l == "Graph:"),
"Graph header should always appear"
);
assert!(
lines
.iter()
.any(|l| l.contains("(no graph — press [r] to reindex)")),
"empty-graph hint should appear when node_count == 0"
);
assert!(
!lines.iter().any(|l| l == "Communities:"),
"Communities section must stay hidden without nodes"
);
assert!(
!lines.iter().any(|l| l.contains("Nodes:")),
"Nodes/Edges breakdown must stay hidden without nodes"
);
}
#[test]
fn test_stats_lines_edge_kind_bars() {
let mut state = sample_state();
state.indexes[0].node_count = 100;
state.indexes[0].edge_count = 200;
state.indexes[0].edge_kinds = vec![
("Big".into(), 100), ("Half".into(), 50), ("Tiny".into(), 10), ];
state.select_down();
let lines = stats_lines(&state);
let bar_lines: Vec<&String> = lines.iter().filter(|l| l.contains('█')).collect();
assert_eq!(bar_lines.len(), 3, "expected one bar line per edge kind");
let big_bars = bar_lines[0].matches('█').count();
let half_bars = bar_lines[1].matches('█').count();
let tiny_bars = bar_lines[2].matches('█').count();
assert_eq!(big_bars, 14, "largest kind gets 14 bars");
assert!(
half_bars < big_bars && half_bars > tiny_bars,
"half-sized kind ({half_bars}) sits between big ({big_bars}) and tiny ({tiny_bars})"
);
assert!(
tiny_bars >= 1,
"tiny kind should still get at least one bar"
);
}
#[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']');
}
fn scoped(event: ReindexEvent) -> ScopedReindexEvent {
ScopedReindexEvent {
index_id: "cto".into(),
event,
}
}
#[test]
fn test_apply_reindex_event() {
let mut state = SearchTuiState::new("http://x");
apply_reindex_event(
&mut state,
scoped(ReindexEvent::Started { total_files: 1200 }),
);
apply_reindex_event(
&mut state,
scoped(ReindexEvent::Progress {
indexed: 600,
total_files: 1200,
}),
);
apply_reindex_event(
&mut state,
scoped(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"));
assert_eq!(state.log.tail_scoped(Some("cto"), 100).count(), 3);
assert_eq!(state.log.tail_scoped(Some("trusty"), 100).count(), 0);
apply_reindex_event(&mut state, scoped(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), tui_common::LEFT_PANEL_MAX);
assert_eq!(left_panel_width(60), 20);
}
#[test]
fn test_index_lines() {
let mut state = sample_state();
state.sort_key = IndexSortKey::Name;
let rows = index_lines(&state);
assert_eq!(rows.len(), 4);
assert!(rows[0].is_all);
assert!(rows[0].selected);
assert!(rows[0].text.starts_with('>'));
assert!(rows[0].text.contains(ALL_LABEL));
assert!(!rows[1].is_all && !rows[1].selected);
assert!(rows[1].text.contains("cto"));
assert!(rows[3].text.contains("trusty"));
assert!(rows[3].text.contains("19.0k"));
let empty = SearchTuiState::new("http://x");
let rows = index_lines(&empty);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_all);
assert!(rows[1].text.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 ", "/", "s ", "g "] {
assert!(text.contains(token), "help text missing {token}");
}
}
#[test]
fn test_index_sort_key_cycle() {
assert_eq!(IndexSortKey::default(), IndexSortKey::Activity);
assert_eq!(IndexSortKey::Activity.next(), IndexSortKey::Name);
assert_eq!(IndexSortKey::Name.next(), IndexSortKey::Count);
assert_eq!(IndexSortKey::Count.next(), IndexSortKey::Activity);
assert_eq!(sort_label(IndexSortKey::Activity), "Activity");
assert_eq!(sort_label(IndexSortKey::Name), "Name");
assert_eq!(sort_label(IndexSortKey::Count), "Chunks");
}
fn diverse_state() -> SearchTuiState {
use chrono::{TimeZone, Utc};
let mut state = SearchTuiState::new("http://127.0.0.1:7878");
state.indexes = vec![
IndexRow {
id: "trusty-search".into(),
chunk_count: 12,
root_path: "/Users/masa/Projects/trusty-tools/trusty-search".into(),
last_indexed: Some(Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()),
..Default::default()
},
IndexRow {
id: "trusty-memory".into(),
chunk_count: 3_775,
root_path: "/Users/masa/Projects/trusty-tools/trusty-memory".into(),
last_indexed: Some(Utc.with_ymd_and_hms(2026, 5, 18, 22, 29, 50).unwrap()),
..Default::default()
},
IndexRow {
id: "claude-mpm".into(),
chunk_count: 6_163,
root_path: "/Users/masa/Projects/claude-mpm".into(),
last_indexed: Some(Utc.with_ymd_and_hms(2026, 5, 10, 0, 0, 0).unwrap()),
..Default::default()
},
IndexRow {
id: "notes".into(),
chunk_count: 100,
root_path: String::new(),
last_indexed: None,
..Default::default()
},
];
state
}
#[test]
fn test_apply_sort_activity() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Activity;
let rows = filtered_sorted_indexes(&state);
assert_eq!(rows[0].id, "trusty-memory");
assert_eq!(rows[1].id, "claude-mpm");
assert_eq!(rows[2].id, "trusty-search");
assert_eq!(rows[3].id, "notes");
}
#[test]
fn test_apply_sort_name() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
let rows = filtered_sorted_indexes(&state);
let ids: Vec<&str> = rows.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
ids,
vec!["claude-mpm", "notes", "trusty-memory", "trusty-search"]
);
}
#[test]
fn test_apply_sort_chunks() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Count;
let rows = filtered_sorted_indexes(&state);
assert_eq!(rows[0].id, "claude-mpm");
assert_eq!(rows[1].id, "trusty-memory");
assert_eq!(rows[2].id, "notes");
assert_eq!(rows[3].id, "trusty-search");
}
#[test]
fn test_apply_filter() {
let mut state = diverse_state();
state.filter = "TRUSTY".into();
let rows = filtered_sorted_indexes(&state);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|i| i.id.contains("trusty")));
state.filter = "claude-mpm".into();
let rows = filtered_sorted_indexes(&state);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "claude-mpm");
state.filter = "nothing-here".into();
assert!(filtered_sorted_indexes(&state).is_empty());
state.filter.clear();
assert_eq!(filtered_sorted_indexes(&state).len(), 4);
}
#[test]
fn test_index_lines_grouped() {
let mut state = diverse_state();
state.group_by_project = true;
state.sort_key = IndexSortKey::Name;
let rows = index_lines(&state);
assert!(rows[0].is_all);
let headers: Vec<&IndexListRow> = rows.iter().filter(|r| r.is_header).collect();
assert!(
!headers.is_empty(),
"grouping must emit at least one header"
);
for h in &headers {
assert!(h.text.contains("──"));
assert!(!h.selected);
}
let header_text: String = headers
.iter()
.map(|h| h.text.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(header_text.contains("trusty-memory") || header_text.contains("trusty-search"));
assert!(header_text.contains("claude-mpm"));
state.filter = "claude".into();
let rows = index_lines(&state);
let headers: Vec<&IndexListRow> = rows.iter().filter(|r| r.is_header).collect();
assert_eq!(headers.len(), 1);
assert!(headers[0].text.contains("claude-mpm"));
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(2_048), "2.0 KB");
assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
assert!(format_bytes(2 * 1024 * 1024 * 1024).ends_with("GB"));
}
#[test]
fn test_scroll_offset() {
let mut state = sample_state();
for row in 0..=state.last_row() {
state.selected = row;
state.sync_scroll(8);
assert_eq!(state.scroll_offset, 0, "no scroll while the list fits");
}
state.indexes = (0..40)
.map(|n| IndexRow {
id: format!("idx-{n}"),
chunk_count: 1,
root_path: String::new(),
..Default::default()
})
.collect();
let window = 5;
for row in 0..=state.last_row() {
state.selected = row;
state.sync_scroll(window);
assert!(
row >= state.scroll_offset && row < state.scroll_offset + window,
"row {row} must be inside [{}, {})",
state.scroll_offset,
state.scroll_offset + window,
);
}
assert_eq!(state.scroll_offset, state.last_row() + 1 - window);
for row in (0..=state.last_row()).rev() {
state.selected = row;
state.sync_scroll(window);
assert!(
row >= state.scroll_offset && row < state.scroll_offset + window,
"row {row} must stay visible while scrolling up",
);
}
assert_eq!(state.scroll_offset, 0, "back at the top");
}
#[test]
fn test_visible_index_ids() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
let ids = visible_index_ids(&state);
assert_eq!(ids[0], tui_common::ALL_SENTINEL);
assert_eq!(
&ids[1..],
&[
"claude-mpm".to_string(),
"notes".to_string(),
"trusty-memory".to_string(),
"trusty-search".to_string(),
]
);
state.filter = "trusty".into();
let ids = visible_index_ids(&state);
assert_eq!(ids[0], tui_common::ALL_SENTINEL);
assert_eq!(ids.len(), 3, "All + 2 trusty-* indexes");
}
#[test]
fn test_navigate_visible() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
assert_eq!(state.selected, 0);
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("claude-mpm"));
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("notes"));
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-memory"));
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-search"));
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-search"));
navigate_up_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-memory"));
navigate_up_visible(&mut state);
navigate_up_visible(&mut state);
navigate_up_visible(&mut state);
assert!(state.is_all_selected());
navigate_up_visible(&mut state);
assert!(state.is_all_selected());
state.filter = "trusty".into();
state.selected = 0;
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-memory"));
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-search"));
navigate_down_visible(&mut state);
assert_eq!(state.selected_id(), Some("trusty-search"));
}
#[test]
fn test_visible_selected_row_follows_sort() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
let pos = state
.indexes
.iter()
.position(|i| i.id == "claude-mpm")
.expect("index");
state.selected = pos + 1;
assert_eq!(state.selected, 3, "original index puts claude-mpm at 3");
assert_eq!(
visible_selected_row(&state),
1,
"claude-mpm is the first non-All row after Name sort",
);
state.selected = 0;
assert_eq!(visible_selected_row(&state), 0);
state.sort_key = IndexSortKey::Count;
let pos = state
.indexes
.iter()
.position(|i| i.id == "notes")
.expect("index");
state.selected = pos + 1;
assert_eq!(visible_selected_row(&state), 3);
}
#[test]
fn test_visible_selected_row_follows_group() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
state.group_by_project = true;
let pos = state
.indexes
.iter()
.position(|i| i.id == "trusty-memory")
.expect("index");
state.selected = pos + 1;
let expected = index_lines(&state)
.iter()
.position(|row| row.selected)
.expect("trusty-memory must appear in the grouped layout");
assert_eq!(visible_selected_row(&state), expected);
assert!(expected > 0, "highlight is not on the All row");
}
#[test]
fn test_sync_scroll_to_follows_sorted_order() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
state.selected = 1;
let visible_row = visible_selected_row(&state);
assert_eq!(visible_row, 4, "trusty-search is the last visible row");
state.sync_scroll_to(visible_row, 3);
assert_eq!(state.scroll_offset, 2);
}
#[test]
fn test_clamp_to_visible() {
let mut state = diverse_state();
state.sort_key = IndexSortKey::Name;
let pos = state
.indexes
.iter()
.position(|i| i.id == "claude-mpm")
.expect("index");
state.selected = pos + 1;
state.filter = "trusty".into();
state.clamp_to_visible();
assert_eq!(state.selected, 0, "selection dropped to All");
state.filter = "trusty".into();
let pos = state
.indexes
.iter()
.position(|i| i.id == "trusty-memory")
.expect("index");
state.selected = pos + 1;
state.clamp_to_visible();
assert_eq!(state.selected_id(), Some("trusty-memory"));
}
#[test]
fn test_render_smoke() {
let mut state = sample_state();
state.log.push("daemon started");
state.log.push_scoped("cto", "reindex started: 1200 files");
state
.log
.push_scoped("trusty", "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, &mut state))
.expect("render (All) must not panic");
}
state.selected = 1;
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &mut state))
.expect("render (single index) must not panic");
state.indexes = (0..60)
.map(|n| IndexRow {
id: format!("idx-{n}"),
chunk_count: 100,
root_path: String::new(),
..Default::default()
})
.collect();
state.selected = state.last_row();
let backend = TestBackend::new(120, 20);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &mut state))
.expect("overflowing list render must not panic");
assert!(state.scroll_offset > 0, "long list scrolled to the cursor");
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, &mut state))
.expect("help render must not panic");
}
#[test]
fn test_new_log_lines_since_watermark() {
let lines: Vec<String> = ["a", "b", "c", "d", "e"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(new_log_lines_since(&lines, Some("c")), &["d", "e"]);
assert_eq!(new_log_lines_since(&lines, Some("e")), &[] as &[String]);
assert_eq!(new_log_lines_since(&lines, Some("z")), lines.as_slice());
assert_eq!(new_log_lines_since(&lines, None), lines.as_slice());
assert!(new_log_lines_since(&[], Some("a")).is_empty());
}
#[test]
fn test_push_new_log_lines_skips_first_poll() {
let mut state = SearchTuiState::new("http://x");
assert!(state.log_first_poll);
let lines: Vec<String> = ["info: daemon started", "info: index loaded"]
.iter()
.map(|s| s.to_string())
.collect();
if state.log_first_poll {
state.log_watermark = lines.last().cloned();
state.log_first_poll = false;
}
assert!(!state.log_first_poll);
assert!(state.log.is_empty());
let lines2: Vec<String> = [
"info: daemon started",
"info: index loaded",
"info: watch triggered",
]
.iter()
.map(|s| s.to_string())
.collect();
let new = new_log_lines_since(&lines2, state.log_watermark.as_deref());
for line in new {
state.log.push(line.clone());
}
state.log_watermark = lines2.last().cloned();
assert_eq!(state.log.len(), 1);
assert!(
state
.log
.iter()
.next()
.map(|l| l.contains("watch triggered"))
.unwrap_or(false)
);
}
}