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, ListState, Paragraph},
};
use tokio::sync::mpsc;
use crate::monitor::dashboard::{MemoryData, PalaceRow, format_count};
use crate::monitor::memory_client::{MemoryClient, MemoryEvent, RecallHit, resolve_memory_url};
use crate::monitor::utils::{ActivityLog, DaemonStatus};
const REFRESH_INTERVAL: Duration = Duration::from_millis(2000);
const INPUT_POLL: Duration = Duration::from_millis(50);
const RECALL_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 [d] dream [↑↓] select [Enter] recall [q] quit [?] help";
pub const ALL_LABEL: &str = "All palaces";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MemoryFocus {
#[default]
List,
Input,
}
#[derive(Debug, Clone)]
pub struct MemoryTuiState {
pub base_url: String,
pub daemon_status: DaemonStatus,
pub status: Option<MemoryData>,
pub palaces: Vec<PalaceRow>,
pub selected: usize,
pub scroll_offset: usize,
pub log: ActivityLog,
pub input: String,
pub focus: MemoryFocus,
pub show_help: bool,
}
impl MemoryTuiState {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
daemon_status: DaemonStatus::Connecting,
status: None,
palaces: Vec::new(),
selected: 0,
scroll_offset: 0,
log: ActivityLog::new(),
input: String::new(),
focus: MemoryFocus::List,
show_help: false,
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
MemoryFocus::List => MemoryFocus::Input,
MemoryFocus::Input => MemoryFocus::List,
};
}
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.palaces.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 window = visible.max(1);
if self.selected >= self.scroll_offset + window {
self.scroll_offset = self.selected + 1 - window;
} else if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
pub fn is_all_selected(&self) -> bool {
self.selected == 0
}
pub fn selected_id(&self) -> Option<&str> {
if self.selected == 0 {
return None;
}
self.palaces.get(self.selected - 1).map(|p| p.id.as_str())
}
pub fn scope_filter(&self) -> Option<&str> {
self.selected_id()
}
}
pub async fn run() -> anyhow::Result<()> {
run_with_url(resolve_memory_url()).await
}
pub async fn run_with_url(base_url: String) -> anyhow::Result<()> {
let mut client = MemoryClient::new(base_url.clone());
let mut state = MemoryTuiState::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 MemoryTuiState, client: &mut MemoryClient) {
if !state.daemon_status.is_online() {
let resolved = resolve_memory_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.clone(),
uptime_secs: 0,
};
state.palaces = data.palaces.clone();
state.status = Some(data);
state.clamp_selection();
}
Err(e) => {
state.daemon_status = DaemonStatus::Offline {
last_error: e.to_string(),
};
}
}
}
async fn run_recall(state: &mut MemoryTuiState, client: &MemoryClient) {
let query = state.input.trim().to_string();
if query.is_empty() {
return;
}
let scope = state.selected_id().map(str::to_string);
match client.recall(&query, RECALL_TOP_K).await {
Ok(hits) => match &scope {
None => {
state
.log
.push(format!("recall \"{query}\" (all) → {} results", hits.len()));
for hit in &hits {
let palace = if hit.palace_id.is_empty() {
"?"
} else {
hit.palace_id.as_str()
};
state
.log
.push_raw_scoped(palace, format!(" · [{palace}] {}", hit.snippet));
}
}
Some(id) => {
let kept: Vec<&RecallHit> = hits.iter().filter(|h| h.palace_id == *id).collect();
state
.log
.push_scoped(id, format!("recall \"{query}\" → {} results", kept.len()));
for hit in kept {
state
.log
.push_raw_scoped(id, format!(" · {}", hit.snippet));
}
}
},
Err(e) => match &scope {
None => state
.log
.push(format!("recall \"{query}\" (all) failed: {e}")),
Some(id) => state
.log
.push_scoped(id, format!("recall \"{query}\" failed: {e}")),
},
}
state.input.clear();
}
pub fn apply_memory_event(state: &mut MemoryTuiState, event: MemoryEvent) {
match event {
MemoryEvent::DreamCompleted {
merged,
pruned,
compacted,
} => {
state.log.push("SSE: dream_completed");
state.log.push_raw(format!(
" merged: {merged} pruned: {pruned} compacted: {compacted}"
));
}
MemoryEvent::DrawerAdded {
palace_id,
drawer_count,
} => {
state.log.push_scoped(
&palace_id,
format!("SSE: drawer added → {palace_id} ({drawer_count})"),
);
}
MemoryEvent::DrawerDeleted {
palace_id,
drawer_count,
} => {
state.log.push_scoped(
&palace_id,
format!("SSE: drawer deleted → {palace_id} ({drawer_count})"),
);
}
MemoryEvent::PalaceCreated { name } => {
state.log.push(format!("SSE: palace created → {name}"));
}
}
}
async fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut MemoryTuiState,
client: &mut MemoryClient,
) -> anyhow::Result<()> {
poll_daemon(state, client).await;
let mut last_poll = Instant::now();
let (sse_tx, mut sse_rx) = mpsc::channel::<MemoryEvent>(64);
let sse_client = client.clone();
tokio::spawn(async move {
sse_client.sse_stream(sse_tx).await;
});
loop {
terminal.draw(|f| render(f, state))?;
while let Ok(event) = sse_rx.try_recv() {
apply_memory_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(()),
(MemoryFocus::List, KeyCode::Char('q')) => return Ok(()),
(MemoryFocus::List, KeyCode::Up) => state.select_up(),
(MemoryFocus::List, KeyCode::Down) => state.select_down(),
(MemoryFocus::List, KeyCode::Char('d')) => {
state.log.push("dream cycle triggered");
match client.dream_run().await {
Ok(stats) => state.log.push_raw(format!(
" merged: {} pruned: {} compacted: {}",
stats.merged, stats.pruned, stats.compacted
)),
Err(e) => state.log.push(format!("dream failed: {e}")),
}
poll_daemon(state, client).await;
last_poll = Instant::now();
}
(MemoryFocus::Input, KeyCode::Enter) => {
run_recall(state, client).await;
}
(MemoryFocus::Input, KeyCode::Backspace) => {
state.input.pop();
}
(MemoryFocus::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 palace list and the recall bar",
" ↑ / ↓ move the palace selection (when the list has focus)",
" All the top list row fans recalls / stats across every palace",
" d run a dream cycle across every palace",
" Enter run a recall query — all palaces, or the selected one",
" ? 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 palace_row(palace: &PalaceRow, selected: bool) -> String {
let marker = if selected { ">" } else { " " };
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
format!(
"{marker} {:<10} {:>7}v",
truncate(label, 10),
format_count(palace.vector_count),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PalaceListRow {
pub text: String,
pub selected: bool,
pub is_all: bool,
}
pub fn palace_lines(state: &MemoryTuiState) -> Vec<PalaceListRow> {
let mut rows: Vec<PalaceListRow> = Vec::with_capacity(state.palaces.len() + 1);
let total_vectors: u64 = state.palaces.iter().map(|p| p.vector_count).sum();
let all_selected = state.selected == 0;
let all_marker = if all_selected { ">" } else { " " };
rows.push(PalaceListRow {
text: format!("{all_marker} {ALL_LABEL} {}v", format_count(total_vectors),),
selected: all_selected,
is_all: true,
});
if state.palaces.is_empty() {
rows.push(PalaceListRow {
text: " (no palaces)".to_string(),
selected: false,
is_all: false,
});
return rows;
}
for (i, palace) in state.palaces.iter().enumerate() {
let row = i + 1;
let selected = row == state.selected;
rows.push(PalaceListRow {
text: palace_row(palace, selected),
selected,
is_all: false,
});
}
rows
}
pub fn stats_lines(state: &MemoryTuiState) -> Vec<String> {
if state.is_all_selected() {
let stats = state.status.clone().unwrap_or_default();
let mut lines = vec![
format!("Scope: {ALL_LABEL}"),
format!("Palaces: {}", state.palaces.len()),
format!("Vectors: {}", format_count(stats.total_vectors)),
format!("Drawers: {}", format_count(stats.total_drawers)),
format!("KG triples: {}", format_count(stats.total_kg_triples)),
];
if state.palaces.is_empty() {
lines.push("(no palaces)".to_string());
} else {
lines.push(String::new());
for palace in &state.palaces {
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
lines.push(format!(
" · {:<12} {:>7}v",
truncate(label, 12),
format_count(palace.vector_count),
));
}
}
return lines;
}
match state.palaces.get(state.selected.saturating_sub(1)) {
Some(palace) => {
let label = if palace.name.is_empty() {
"(unnamed)"
} else {
palace.name.as_str()
};
vec![
format!("Palace: {label}"),
format!("Vectors: {}", format_count(palace.vector_count)),
format!("Id: {}", palace.id),
]
}
None => vec!["(no palace selected)".to_string()],
}
}
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: &MemoryTuiState) -> String {
let (glyph, label) = state.daemon_status.badge();
match &state.daemon_status {
DaemonStatus::Online { version, .. } => {
format!("trusty-memory v{version} [{glyph}] {label}")
}
_ => format!(
"trusty-memory v{VERSION} [{glyph}] {label} {}",
state.base_url
),
}
}
const ACTIVITY_PERCENT: u16 = 60;
pub fn render(frame: &mut Frame, state: &mut MemoryTuiState) {
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 == MemoryFocus::List;
let palace_items: Vec<ListItem> = palace_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_all {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(row.text, style)))
})
.collect();
let palace_visible = split[0].height.saturating_sub(2) as usize;
state.sync_scroll(palace_visible);
let mut palace_state = ListState::default()
.with_offset(state.scroll_offset)
.with_selected(Some(state.selected));
frame.render_stateful_widget(
List::new(palace_items).block(panel_block("PALACES", list_focused)),
split[0],
&mut palace_state,
);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(ACTIVITY_PERCENT),
Constraint::Percentage(100 - 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 == MemoryFocus::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("RECALL ▶ ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}{cursor}", state.input), input_style),
]))
.block(panel_block("RECALL", 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() -> MemoryTuiState {
let mut state = MemoryTuiState::new("http://127.0.0.1:7070");
state.daemon_status = DaemonStatus::Online {
version: "0.1.54".into(),
uptime_secs: 0,
};
state.palaces = vec![
PalaceRow {
id: "default".into(),
name: "default".into(),
vector_count: 8_400,
},
PalaceRow {
id: "work".into(),
name: "work".into(),
vector_count: 0,
},
];
state.status = Some(MemoryData {
version: "0.1.54".into(),
palace_count: 2,
total_drawers: 14,
total_vectors: 8_400,
total_kg_triples: 1_200,
palaces: state.palaces.clone(),
});
state
}
#[test]
fn test_new_state_defaults() {
let state = MemoryTuiState::new("http://127.0.0.1:7070");
assert_eq!(state.base_url, "http://127.0.0.1:7070");
assert!(matches!(state.daemon_status, DaemonStatus::Connecting));
assert!(state.status.is_none());
assert!(state.palaces.is_empty());
assert_eq!(state.selected, 0);
assert!(state.log.is_empty());
assert_eq!(state.focus, MemoryFocus::List);
assert!(!state.show_help);
}
#[test]
fn test_toggle_focus() {
let mut state = MemoryTuiState::new("http://x");
assert_eq!(state.focus, MemoryFocus::List);
state.toggle_focus();
assert_eq!(state.focus, MemoryFocus::Input);
state.toggle_focus();
assert_eq!(state.focus, MemoryFocus::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 palaces.len()");
for _ in 0..10 {
state.select_up();
}
assert_eq!(state.selected, 0);
state.selected = 2;
state.palaces.truncate(1);
state.clamp_selection();
assert_eq!(state.selected, 1);
state.palaces.clear();
state.selected = 9;
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("default"));
state.select_down();
assert_eq!(state.selected_id(), Some("work"));
state.palaces.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("default"));
state.select_up();
assert!(state.is_all_selected());
let rows = palace_lines(&state);
assert_eq!(rows.len(), 3, "1 'All' row + 2 palaces");
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("default"));
}
#[test]
fn test_stats_lines() {
let mut state = sample_state();
let all = stats_lines(&state);
assert!(
all.iter()
.any(|l| l.contains("Palaces:") && l.contains('2'))
);
assert!(
all.iter()
.any(|l| l.contains("Vectors:") && l.contains("8,400"))
);
assert!(
all.iter()
.any(|l| l.contains("KG triples:") && l.contains("1,200"))
);
assert!(all.iter().any(|l| l.contains("default")));
state.select_down(); let one = stats_lines(&state);
assert!(
one.iter()
.any(|l| l.contains("Palace:") && l.contains("default"))
);
assert!(
one.iter()
.any(|l| l.contains("Vectors:") && l.contains("8,400"))
);
assert!(one.iter().any(|l| l.contains("Id:")));
}
#[test]
fn test_palace_row_display() {
let palace = PalaceRow {
id: "default".into(),
name: "default".into(),
vector_count: 8_400,
};
let selected = palace_row(&palace, true);
assert!(selected.starts_with('>'), "selected marker: {selected}");
assert!(selected.contains("default"));
assert!(selected.contains("8,400v"));
let unselected = palace_row(&palace, false);
assert!(unselected.starts_with(' '), "unselected: {unselected}");
let nameless = PalaceRow {
id: "p-xyz".into(),
name: String::new(),
vector_count: 0,
};
let row = palace_row(&nameless, false);
assert!(row.contains("p-xyz"));
assert!(row.contains("0v"));
let long = PalaceRow {
id: "x".into(),
name: "a-very-long-palace-name".into(),
vector_count: 1,
};
assert!(palace_row(&long, false).contains('…'));
}
#[test]
fn test_palace_lines() {
let state = sample_state();
let rows = palace_lines(&state);
assert_eq!(rows.len(), 3);
assert!(rows[0].is_all);
assert!(rows[0].selected);
assert!(rows[0].text.contains(ALL_LABEL));
assert!(rows[0].text.starts_with('>'));
assert!(!rows[1].is_all && !rows[1].selected);
assert!(rows[1].text.contains("default"));
assert!(rows[2].text.contains("work"));
let empty = MemoryTuiState::new("http://x");
let rows = palace_lines(&empty);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_all);
assert!(rows[1].text.contains("no palaces"));
}
#[test]
fn test_log_append_dream() {
let mut state = MemoryTuiState::new("http://x");
apply_memory_event(
&mut state,
MemoryEvent::DreamCompleted {
merged: 3,
pruned: 1,
compacted: 0,
},
);
let lines: Vec<&String> = state.log.iter().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("SSE: dream_completed"));
assert!(lines[0].starts_with('['), "header is timestamped");
assert!(lines[1].contains("merged: 3"));
assert!(lines[1].contains("pruned: 1"));
assert!(lines[1].contains("compacted: 0"));
assert!(lines[1].starts_with(" "));
}
#[test]
fn test_apply_memory_event() {
let mut state = MemoryTuiState::new("http://x");
apply_memory_event(
&mut state,
MemoryEvent::DrawerAdded {
palace_id: "default".into(),
drawer_count: 14,
},
);
apply_memory_event(
&mut state,
MemoryEvent::DrawerDeleted {
palace_id: "work".into(),
drawer_count: 2,
},
);
apply_memory_event(
&mut state,
MemoryEvent::PalaceCreated {
name: "notes".into(),
},
);
let lines: Vec<&String> = state.log.iter().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("drawer added → default (14)"));
assert!(lines[1].contains("drawer deleted → work (2)"));
assert!(lines[2].contains("palace created → notes"));
let default_feed: Vec<&String> = state.log.tail_scoped(Some("default"), 100).collect();
assert_eq!(default_feed.len(), 2);
assert!(
default_feed
.iter()
.any(|l| l.contains("drawer added → default"))
);
assert!(
default_feed
.iter()
.any(|l| l.contains("palace created → notes"))
);
assert!(
!default_feed
.iter()
.any(|l| l.contains("drawer deleted → work"))
);
}
#[test]
fn test_log_capacity() {
let mut state = MemoryTuiState::new("http://x");
for i in 0..(ActivityLog::MAX_ENTRIES + 30) {
state.log.push(format!("event {i}"));
}
assert_eq!(state.log.len(), ActivityLog::MAX_ENTRIES);
}
#[test]
fn test_timestamped_format() {
let line = timestamped("recall complete");
assert!(line.starts_with('['));
assert!(line.ends_with(" recall complete"));
assert_eq!(line.as_bytes()[9], b']');
}
#[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_truncate() {
assert_eq!(truncate("work", 10), "work");
assert_eq!(truncate("a-very-long-palace", 8), "a-very-…");
}
#[test]
fn test_title_line() {
let state = sample_state();
let title = title_line(&state);
assert!(title.contains("trusty-memory v0.1.54"));
assert!(title.contains("online"));
let mut offline = MemoryTuiState::new("http://127.0.0.1:7070");
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:7070"));
}
#[test]
fn test_help_text_lists_bindings() {
let text = help_text();
for token in ["Tab", "d ", "Enter", "?", "q "] {
assert!(text.contains(token), "help text missing {token}");
}
}
#[test]
fn test_scroll_offset() {
let mut state = sample_state();
for row in 0..=state.last_row() {
state.selected = row;
state.sync_scroll(6);
assert_eq!(state.scroll_offset, 0, "no scroll while the list fits");
}
state.palaces = (0..40)
.map(|n| PalaceRow {
id: format!("p-{n}"),
name: format!("palace-{n}"),
vector_count: 1,
})
.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_render_smoke() {
let mut state = sample_state();
state.log.push("SSE: dream_completed");
state
.log
.push_scoped("default", "recall \"auth flow\" → 3 results");
state.input = "auth flow".into();
state.focus = MemoryFocus::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 palace) must not panic");
state.palaces = (0..60)
.map(|n| PalaceRow {
id: format!("p-{n}"),
name: format!("palace-{n}"),
vector_count: 100,
})
.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");
}
}