use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
Frame, Terminal,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap},
};
use tokio::sync::mpsc;
use crate::monitor::dashboard::{MemoryData, PalaceRow, format_count};
use crate::monitor::memory_client::{
DrawerInfo, MemoryClient, MemoryDetail, MemoryEvent, RecallHit, resolve_memory_url,
};
use crate::monitor::tui_common::{
self, ThreeWaySortKey, enter_tui, leave_tui, left_panel_width, panel_block, truncate,
};
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 DREAM_BACKOFF_INITIAL: Duration = Duration::from_secs(5);
const DREAM_BACKOFF_MAX: Duration = Duration::from_secs(300);
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const KEY_HINT: &str = "[Tab] focus [↑↓] select [Enter] open/recall [d] dream [/] filter [s] sort [g] group [←→] page [q] quit [?] help";
pub const DRAWER_PAGE_SIZE: usize = 20;
const DRAWER_CREATOR_WIDTH: usize = 24;
const SORT_LABELS: &[&str; 3] = &["Activity", "Name", "Vectors"];
pub type PalaceSortKey = ThreeWaySortKey;
pub fn sort_label(key: ThreeWaySortKey) -> &'static str {
key.label(SORT_LABELS)
}
pub const ALL_LABEL: &str = "All palaces";
const INDEXING_SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const DREAMING_SPINNER: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PalaceActivity {
Idle,
Indexing,
Active,
Dreaming,
Error,
}
impl PalaceActivity {
pub fn prefix(self, tick: usize) -> char {
match self {
PalaceActivity::Idle => ' ',
PalaceActivity::Indexing => INDEXING_SPINNER[tick % INDEXING_SPINNER.len()],
PalaceActivity::Active => '⠿',
PalaceActivity::Dreaming => DREAMING_SPINNER[tick % DREAMING_SPINNER.len()],
PalaceActivity::Error => '✗',
}
}
pub fn color(self) -> Option<Color> {
match self {
PalaceActivity::Idle => None,
PalaceActivity::Indexing => Some(Color::Yellow),
PalaceActivity::Active => Some(Color::Cyan),
PalaceActivity::Dreaming => Some(Color::Magenta),
PalaceActivity::Error => Some(Color::Red),
}
}
}
pub fn spinner_tick() -> usize {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| (d.as_millis() / 100) as usize)
.unwrap_or(0)
}
pub fn palace_activity_state(
palace: &PalaceRow,
now: chrono::DateTime<chrono::Utc>,
) -> PalaceActivity {
if palace.is_compacting {
return PalaceActivity::Dreaming;
}
match palace.last_write_at {
Some(ts) => {
let delta = now.signed_duration_since(ts);
let secs = delta.num_seconds();
if secs < 10 {
PalaceActivity::Indexing
} else if secs < 60 {
PalaceActivity::Active
} else {
PalaceActivity::Idle
}
}
None => PalaceActivity::Idle,
}
}
pub fn palace_has_content(palace: &PalaceRow) -> bool {
palace.vector_count > 0 || palace.kg_triple_count > 0 || palace.drawer_count > 0
}
pub fn format_relative_time(
now: chrono::DateTime<chrono::Utc>,
ts: chrono::DateTime<chrono::Utc>,
) -> String {
let secs = now.signed_duration_since(ts).num_seconds();
if secs < 5 {
return "just now".to_string();
}
if secs < 60 {
return format!("{secs}s ago");
}
let mins = secs / 60;
if mins < 60 {
return format!("{mins}m ago");
}
let hours = mins / 60;
if hours < 24 {
return format!("{hours}h ago");
}
let days = hours / 24;
format!("{days}d ago")
}
pub fn activity_label(activity: PalaceActivity) -> &'static str {
match activity {
PalaceActivity::Idle => "Idle",
PalaceActivity::Indexing => "Indexing",
PalaceActivity::Active => "Active",
PalaceActivity::Dreaming => "Dreaming",
PalaceActivity::Error => "Error",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MemoryFocus {
#[default]
List,
DrawerPane,
Input,
}
impl MemoryFocus {
pub fn next(self) -> Self {
match self {
Self::List => Self::DrawerPane,
Self::DrawerPane => Self::Input,
Self::Input => Self::List,
}
}
pub fn toggled(self) -> Self {
match self {
Self::List => Self::Input,
Self::Input => Self::List,
Self::DrawerPane => Self::List,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DreamBackoff {
next_allowed_at: Option<Instant>,
consecutive_failures: u32,
first_failure_logged: bool,
}
impl DreamBackoff {
pub fn new() -> Self {
Self::default()
}
pub fn ready(&self, now: Instant) -> bool {
match self.next_allowed_at {
Some(deadline) => now >= deadline,
None => true,
}
}
pub fn remaining(&self, now: Instant) -> Duration {
self.next_allowed_at
.and_then(|d| d.checked_duration_since(now))
.unwrap_or(Duration::ZERO)
}
pub fn record_success(&mut self) {
self.next_allowed_at = None;
self.consecutive_failures = 0;
self.first_failure_logged = false;
}
pub fn record_failure(&mut self, now: Instant) -> bool {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
let delay = backoff_delay(self.consecutive_failures);
self.next_allowed_at = Some(now + delay);
let should_log = !self.first_failure_logged;
self.first_failure_logged = true;
should_log
}
pub fn consecutive_failures(&self) -> u32 {
self.consecutive_failures
}
}
fn backoff_delay(n: u32) -> Duration {
let shift = n.saturating_sub(1).min(20); let multiplier: u64 = 1u64 << shift;
let secs = DREAM_BACKOFF_INITIAL
.as_secs()
.saturating_mul(multiplier)
.min(DREAM_BACKOFF_MAX.as_secs());
Duration::from_secs(secs)
}
#[derive(Debug, Clone, Default)]
pub struct DrawerListState {
pub palace_id: Option<String>,
pub drawers: Vec<DrawerInfo>,
pub offset: usize,
pub loading: bool,
pub last_error: Option<String>,
}
impl DrawerListState {
pub fn new() -> Self {
Self::default()
}
pub fn reset_for(&mut self, scope: Option<String>) {
self.palace_id = scope;
self.drawers.clear();
self.offset = 0;
self.loading = true;
self.last_error = None;
}
pub fn next_page(&mut self) {
if self.drawers.len() >= DRAWER_PAGE_SIZE {
self.offset = self.offset.saturating_add(DRAWER_PAGE_SIZE);
self.loading = true;
self.last_error = None;
}
}
pub fn prev_page(&mut self) {
if self.offset == 0 {
return;
}
self.offset = self.offset.saturating_sub(DRAWER_PAGE_SIZE);
self.loading = true;
self.last_error = None;
}
pub fn page(&self) -> usize {
self.offset / DRAWER_PAGE_SIZE.max(1)
}
}
#[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,
pub filter: String,
pub filter_active: bool,
pub sort_key: ThreeWaySortKey,
pub group_by_project: bool,
pub dream_backoff: DreamBackoff,
pub drawer_list: DrawerListState,
pub drawer_cursor: usize,
pub drawer_detail_open: bool,
pub drawer_detail_idx: usize,
pub drawer_detail_memories: Vec<MemoryDetail>,
pub drawer_detail_scroll: usize,
pub drawer_detail_loading: 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,
filter: String::new(),
filter_active: false,
sort_key: ThreeWaySortKey::default(),
group_by_project: false,
dream_backoff: DreamBackoff::new(),
drawer_list: DrawerListState::new(),
drawer_cursor: 0,
drawer_detail_open: false,
drawer_detail_idx: 0,
drawer_detail_memories: Vec::new(),
drawer_detail_scroll: 0,
drawer_detail_loading: false,
}
}
pub fn toggle_focus(&mut self) {
self.focus = self.focus.toggled();
}
pub fn cycle_focus(&mut self) {
self.focus = self.focus.next();
if self.focus == MemoryFocus::List {
self.drawer_cursor = 0;
}
}
pub fn drawer_cursor_up(&mut self) {
self.drawer_cursor = self.drawer_cursor.saturating_sub(1);
}
pub fn drawer_cursor_down(&mut self) {
let len = self.drawer_list.drawers.len();
if len == 0 {
self.drawer_cursor = 0;
return;
}
if self.drawer_cursor + 1 < len {
self.drawer_cursor += 1;
}
}
pub fn clamp_drawer_cursor(&mut self) {
let len = self.drawer_list.drawers.len();
if len == 0 {
self.drawer_cursor = 0;
} else if self.drawer_cursor >= len {
self.drawer_cursor = len - 1;
}
}
pub fn close_drawer_detail(&mut self) {
self.drawer_detail_open = false;
self.drawer_detail_memories.clear();
self.drawer_detail_scroll = 0;
self.drawer_detail_loading = false;
}
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 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.palaces.get(self.selected - 1).map(|p| p.id.as_str())
}
pub fn clamp_to_visible(&mut self) {
if self.selected == 0 {
return;
}
let Some(current_id) = self.palaces.get(self.selected - 1).map(|p| p.id.clone()) else {
self.selected = 0;
return;
};
let ids = visible_palace_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_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);
let mut terminal = enter_tui()?;
let result = run_loop(&mut terminal, &mut state, &mut client).await;
leave_tui(&mut terminal)?;
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,
content_preview,
} => {
let line = if content_preview.is_empty() {
format!("SSE: drawer added → {palace_id} ({drawer_count})")
} else {
format!("SSE: drawer added → {palace_id} ({drawer_count}): \"{content_preview}\"")
};
state.log.push_scoped(&palace_id, line);
}
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 fetch_drawer_page(state: &mut MemoryTuiState, client: &MemoryClient) {
let Some(palace_id) = state.selected_id().map(str::to_string) else {
state.drawer_list.palace_id = None;
state.drawer_list.drawers.clear();
state.drawer_list.offset = 0;
state.drawer_list.loading = false;
state.drawer_list.last_error = None;
return;
};
state.drawer_list.palace_id = Some(palace_id.clone());
state.drawer_list.loading = true;
match client
.list_drawers(&palace_id, DRAWER_PAGE_SIZE, state.drawer_list.offset)
.await
{
Ok(rows) => {
state.drawer_list.drawers = rows;
state.drawer_list.last_error = None;
}
Err(e) => {
state.drawer_list.last_error = Some(e.to_string());
state.drawer_list.drawers.clear();
}
}
state.drawer_list.loading = false;
}
async fn fetch_drawer_detail(state: &mut MemoryTuiState, client: &MemoryClient) {
let Some(palace_id) = state.selected_id().map(str::to_string) else {
state.close_drawer_detail();
return;
};
state.drawer_detail_loading = true;
match client.fetch_drawer_detail(&palace_id, 50).await {
Ok(memories) => {
state.drawer_detail_memories = memories;
if state.drawer_detail_idx >= state.drawer_detail_memories.len() {
state.drawer_detail_idx = state.drawer_detail_memories.len().saturating_sub(1);
}
}
Err(e) => {
state
.log
.push_scoped(&palace_id, format!("drawer detail fetch failed: {e}"));
state.drawer_detail_memories.clear();
}
}
state.drawer_detail_loading = false;
}
pub fn drawer_panel_lines(state: &MemoryTuiState, total_drawer_count: u64) -> Vec<String> {
let dl = &state.drawer_list;
if dl.palace_id.is_none() {
return vec![];
}
let mut lines: Vec<String> = Vec::with_capacity(dl.drawers.len() + 2);
let from = dl.offset + 1;
let to = dl.offset + dl.drawers.len();
let header = if dl.drawers.is_empty() {
if dl.loading {
"loading drawers…".to_string()
} else if let Some(err) = &dl.last_error {
format!("drawers unavailable: {err}")
} else {
"(no drawers yet)".to_string()
}
} else {
format!(
"drawers {}–{} of {} (page {})",
from,
to,
format_count(total_drawer_count),
dl.page() + 1,
)
};
lines.push(header);
for d in &dl.drawers {
lines.push(format_drawer_row(d));
}
lines
}
const DRAWER_SNIPPET_WIDTH: usize = 60;
pub fn format_drawer_row(drawer: &DrawerInfo) -> String {
let id = truncate(&drawer.id, 8);
let ts = match drawer.created_at {
Some(t) => t.format("%m-%d %H:%M").to_string(),
None => "-- ".to_string(),
};
let creator = truncate(&drawer.creator, DRAWER_CREATOR_WIDTH);
let base = format!("{id} {ts} {creator}");
match drawer
.snippet
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(snippet) => format!("{base} {}", truncate(snippet, DRAWER_SNIPPET_WIDTH)),
None => base,
}
}
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;
});
let mut last_drawer_scope: Option<String> = None;
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;
}
if state.drawer_detail_open {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => state.close_drawer_detail(),
KeyCode::Up => {
state.drawer_detail_scroll = state.drawer_detail_scroll.saturating_sub(1);
}
KeyCode::Down => {
state.drawer_detail_scroll = state.drawer_detail_scroll.saturating_add(1);
}
_ => {}
}
continue;
}
match (state.focus, key.code) {
(MemoryFocus::List, KeyCode::Esc) if state.filter_active => {
state.filter_active = false;
}
(MemoryFocus::List, KeyCode::Enter) if state.filter_active => {
state.filter_active = false;
}
(MemoryFocus::List, KeyCode::Backspace) if state.filter_active => {
state.filter.pop();
state.clamp_to_visible();
}
(MemoryFocus::List, KeyCode::Char(c)) if state.filter_active => {
state.filter.push(c);
state.clamp_to_visible();
}
(MemoryFocus::List, KeyCode::Tab) if state.filter_active => {}
(_, KeyCode::Char('?')) => state.show_help = true,
(_, KeyCode::Tab) => state.cycle_focus(),
(MemoryFocus::DrawerPane, KeyCode::Esc) => {
state.focus = MemoryFocus::List;
state.drawer_cursor = 0;
}
(_, KeyCode::Esc) => return Ok(()),
(MemoryFocus::List, KeyCode::Char('q')) => return Ok(()),
(MemoryFocus::List, KeyCode::Up) => navigate_up_visible(state),
(MemoryFocus::List, KeyCode::Down) => navigate_down_visible(state),
(MemoryFocus::List, KeyCode::Left) if state.selected_id().is_some() => {
state.drawer_list.prev_page();
fetch_drawer_page(state, client).await;
state.clamp_drawer_cursor();
}
(MemoryFocus::List, KeyCode::Right) if state.selected_id().is_some() => {
state.drawer_list.next_page();
fetch_drawer_page(state, client).await;
state.clamp_drawer_cursor();
}
(MemoryFocus::List, KeyCode::Char('/')) => {
state.filter_active = true;
state.filter.clear();
}
(MemoryFocus::List, KeyCode::Char('s')) => {
state.sort_key = state.sort_key.next();
}
(MemoryFocus::List, KeyCode::Char('g')) => {
state.group_by_project = !state.group_by_project;
}
(MemoryFocus::List, KeyCode::Char('d')) => {
let now = Instant::now();
if !state.dream_backoff.ready(now) {
let remaining = state.dream_backoff.remaining(now);
tracing::debug!(
"dream cycle suppressed by backoff: {}s remaining",
remaining.as_secs()
);
} else {
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
));
state.dream_backoff.record_success();
}
Err(e) => {
let should_log = state.dream_backoff.record_failure(Instant::now());
if should_log {
let next = state.dream_backoff.remaining(Instant::now());
state.log.push(format!(
"dream failed: {e} (next attempt in {}s)",
next.as_secs()
));
} else {
tracing::debug!(
"dream failed (suppressed, {} consecutive failures): {e}",
state.dream_backoff.consecutive_failures()
);
}
}
}
poll_daemon(state, client).await;
last_poll = Instant::now();
}
}
(MemoryFocus::DrawerPane, KeyCode::Up) => {
state.drawer_cursor_up();
}
(MemoryFocus::DrawerPane, KeyCode::Down) => {
state.drawer_cursor_down();
}
(MemoryFocus::DrawerPane, KeyCode::Left) if state.selected_id().is_some() => {
state.drawer_list.prev_page();
fetch_drawer_page(state, client).await;
state.clamp_drawer_cursor();
}
(MemoryFocus::DrawerPane, KeyCode::Right) if state.selected_id().is_some() => {
state.drawer_list.next_page();
fetch_drawer_page(state, client).await;
state.clamp_drawer_cursor();
}
(MemoryFocus::DrawerPane, KeyCode::Enter)
if !state.drawer_list.drawers.is_empty()
&& state.drawer_cursor < state.drawer_list.drawers.len() =>
{
state.drawer_detail_open = true;
state.drawer_detail_idx = state.drawer_cursor;
state.drawer_detail_scroll = 0;
state.drawer_detail_memories.clear();
fetch_drawer_detail(state, client).await;
}
(MemoryFocus::DrawerPane, KeyCode::Char('q')) => return Ok(()),
(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;
if state.selected_id().is_some() {
fetch_drawer_page(state, client).await;
state.clamp_drawer_cursor();
}
last_poll = Instant::now();
}
let current_scope = state.selected_id().map(str::to_string);
if current_scope != last_drawer_scope {
state.drawer_list.reset_for(current_scope.clone());
fetch_drawer_page(state, client).await;
state.drawer_cursor = 0;
state.close_drawer_detail();
last_drawer_scope = current_scope;
}
}
}
pub fn help_text() -> String {
[
" Tab cycle focus: palace list → drawer pane → recall bar",
" ↑ / ↓ move the active selection (list, drawers, or modal scroll)",
" ← / → page through drawers in the ACTIVITY panel",
" Enter in DrawerPane: open the selected drawer's detail modal",
" in Input: run a recall query",
" All the top list row fans recalls / stats across every palace",
" / activate the inline palace filter (Esc / Enter close)",
" s cycle palace sort: Activity → Name → Vectors",
" g toggle grouping by inferred project",
" d run a dream cycle across every palace",
" ? toggle this help overlay",
" q / Esc close modal / quit",
]
.join("\n")
}
pub fn palace_row(palace: &PalaceRow, _selected: bool) -> String {
palace_row_with_activity(palace, PalaceActivity::Idle, 0)
}
pub fn palace_row_with_activity(
palace: &PalaceRow,
activity: PalaceActivity,
tick: usize,
) -> String {
let prefix = activity.prefix(tick);
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
format!(
"{prefix} {:<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 is_header: bool,
pub activity: Option<PalaceActivity>,
}
fn palace_row_indented_with_activity(
palace: &PalaceRow,
activity: PalaceActivity,
tick: usize,
) -> String {
let prefix = activity.prefix(tick);
let label = if palace.name.is_empty() {
&palace.id
} else {
&palace.name
};
format!(
" {prefix} {:<9} {:>7}v",
truncate(label, 9),
format_count(palace.vector_count),
)
}
pub fn filtered_sorted_palaces(state: &MemoryTuiState) -> Vec<PalaceRow> {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
tui_common::filtered_sorted(&nonempty, &state.filter, state.sort_key)
}
pub fn visible_palace_ids(state: &MemoryTuiState) -> Vec<String> {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
tui_common::visible_ids(
&nonempty,
&state.filter,
state.sort_key,
state.group_by_project,
)
}
pub fn navigate_up_visible(state: &mut MemoryTuiState) {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
let current_id = state
.selected_id()
.map(str::to_string)
.unwrap_or_else(|| tui_common::ALL_SENTINEL.to_string());
let local_cursor = tui_common::id_to_cursor(&nonempty, ¤t_id).unwrap_or(0);
let new_local = tui_common::navigate_up(
&nonempty,
local_cursor,
&state.filter,
state.sort_key,
state.group_by_project,
);
let new_id = tui_common::current_visible_id(&nonempty, new_local);
state.selected = tui_common::id_to_cursor(&state.palaces, &new_id).unwrap_or(0);
}
pub fn navigate_down_visible(state: &mut MemoryTuiState) {
let nonempty: Vec<PalaceRow> = state
.palaces
.iter()
.filter(|p| palace_has_content(p))
.cloned()
.collect();
let current_id = state
.selected_id()
.map(str::to_string)
.unwrap_or_else(|| tui_common::ALL_SENTINEL.to_string());
let local_cursor = tui_common::id_to_cursor(&nonempty, ¤t_id).unwrap_or(0);
let new_local = tui_common::navigate_down(
&nonempty,
local_cursor,
&state.filter,
state.sort_key,
state.group_by_project,
);
let new_id = tui_common::current_visible_id(&nonempty, new_local);
state.selected = tui_common::id_to_cursor(&state.palaces, &new_id).unwrap_or(0);
}
pub fn visible_selected_row(state: &MemoryTuiState) -> usize {
if state.selected == 0 {
return 0;
}
palace_lines(state)
.iter()
.position(|row| row.selected)
.unwrap_or(0)
}
pub fn palace_lines(state: &MemoryTuiState) -> Vec<PalaceListRow> {
palace_lines_at(state, chrono::Utc::now(), 0)
}
pub fn palace_lines_at(
state: &MemoryTuiState,
now: chrono::DateTime<chrono::Utc>,
tick: usize,
) -> 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;
rows.push(PalaceListRow {
text: format!(" {ALL_LABEL} {}v", format_count(total_vectors)),
selected: all_selected,
is_all: true,
is_header: false,
activity: None,
});
if state.palaces.is_empty() {
let text = if state.daemon_status == DaemonStatus::Connecting {
" Loading…".to_string()
} else {
" (no palaces)".to_string()
};
rows.push(PalaceListRow {
text,
selected: false,
is_all: false,
is_header: false,
activity: None,
});
return rows;
}
let visible = filtered_sorted_palaces(state);
if visible.is_empty() {
rows.push(PalaceListRow {
text: " (no matches)".to_string(),
selected: false,
is_all: false,
is_header: false,
activity: None,
});
return rows;
}
let cursor_for = |p: &PalaceRow| -> usize {
state
.palaces
.iter()
.position(|orig| orig.id == p.id)
.map(|i| i + 1)
.unwrap_or(0)
};
if state.group_by_project {
let mut seen: Vec<String> = Vec::new();
for p in &visible {
let proj = p.project().to_string();
if !seen.iter().any(|s| s == &proj) {
seen.push(proj);
}
}
for project in &seen {
rows.push(PalaceListRow {
text: format!("── {project} ─────"),
selected: false,
is_all: false,
is_header: true,
activity: None,
});
for palace in visible.iter().filter(|p| p.project() == project) {
let cursor = cursor_for(palace);
let selected = cursor == state.selected;
let activity = palace_activity_state(palace, now);
rows.push(PalaceListRow {
text: palace_row_indented_with_activity(palace, activity, tick),
selected,
is_all: false,
is_header: false,
activity: Some(activity),
});
}
}
} else {
for palace in &visible {
let cursor = cursor_for(palace);
let selected = cursor == state.selected;
let activity = palace_activity_state(palace, now);
rows.push(PalaceListRow {
text: palace_row_with_activity(palace, activity, tick),
selected,
is_all: false,
is_header: false,
activity: Some(activity),
});
}
}
rows
}
pub fn stats_lines(state: &MemoryTuiState) -> Vec<String> {
if state.daemon_status == DaemonStatus::Connecting {
return vec!["Loading…".to_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()
};
let now = chrono::Utc::now();
let activity = palace_activity_state(palace, now);
let mut lines = vec![
format!("Palace: {label}"),
format!("Vectors: {}", format_count(palace.vector_count)),
format!("Id: {}", palace.id),
String::new(),
"Knowledge Graph".to_string(),
format!(" Nodes: {}", format_count(palace.node_count)),
format!(" Edges: {}", format_count(palace.edge_count)),
format!(" Triples: {}", format_count(palace.kg_triple_count)),
String::new(),
];
match palace.last_write_at {
Some(ts) => {
lines.push(format!(
"Last write: {} ({})",
format_relative_time(now, ts),
ts.format("%Y-%m-%d %H:%M:%S UTC"),
));
}
None => lines.push("Last write: never".to_string()),
}
lines.push(format!("State: {}", activity_label(activity)));
lines
}
None => vec!["(no palace selected)".to_string()],
}
}
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
),
}
}
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 now = chrono::Utc::now();
let tick = spinner_tick();
let rendered_rows = palace_lines_at(state, now, tick);
let palace_items: Vec<ListItem> = rendered_rows
.iter()
.map(|row| {
let style = if row.is_header || row.is_all {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if let Some(color) = row.activity.and_then(|a| a.color()) {
Style::default().fg(color)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(row.text.clone(), 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 palace_visible = list_area.height.saturating_sub(2) as usize;
let visible_row = rendered_rows
.iter()
.position(|row| row.selected && !row.is_header)
.unwrap_or(0);
state.sync_scroll_to(visible_row, palace_visible);
let palace_title = format!("PALACES [{}]", sort_label(state.sort_key));
let highlight_style = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let mut palace_state = ListState::default()
.with_offset(state.scroll_offset)
.with_selected(Some(visible_row));
frame.render_stateful_widget(
List::new(palace_items)
.block(panel_block(&palace_title, list_focused))
.highlight_style(highlight_style)
.highlight_symbol("> ")
.highlight_spacing(HighlightSpacing::Always),
list_area,
&mut palace_state,
);
if state.drawer_detail_open {
let right_split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(split[1]);
render_activity_and_stats(frame, state, right_split[0]);
render_detail_pane(frame, state, right_split[1]);
} else {
render_activity_and_stats(frame, state, split[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 {
tui_common::render_help_overlay(frame, &help_text());
}
}
fn render_activity_and_stats(frame: &mut Frame, state: &MemoryTuiState, area: Rect) {
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(tui_common::ACTIVITY_PERCENT),
Constraint::Percentage(100 - tui_common::ACTIVITY_PERCENT),
])
.split(area);
let scope = state.scope_filter();
let drawer_pane_focused = state.focus == MemoryFocus::DrawerPane;
let activity_title = match scope {
Some(id) if drawer_pane_focused => format!("DRAWER ▶ {id}"),
Some(id) => format!("ACTIVITY — {id}"),
None if drawer_pane_focused => format!("DRAWER ▶ {ALL_LABEL}"),
None => format!("ACTIVITY — {ALL_LABEL}"),
};
let activity_height = right[0].height.saturating_sub(2) as usize;
let drawer_total = state
.selected
.checked_sub(1)
.and_then(|i| state.palaces.get(i))
.map(|p| p.drawer_count)
.unwrap_or(0);
let drawer_lines = drawer_panel_lines(state, drawer_total);
if !drawer_lines.is_empty() {
let take = activity_height.max(1);
let visible_lines: Vec<String> = drawer_lines.into_iter().take(take).collect();
let items: Vec<ListItem> = visible_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect();
let selected_row = if drawer_pane_focused && !state.drawer_list.drawers.is_empty() {
Some((state.drawer_cursor + 1).min(visible_lines.len().saturating_sub(1)))
} else {
None
};
let highlight_style = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let mut list_state = ListState::default().with_selected(selected_row);
frame.render_stateful_widget(
List::new(items)
.block(panel_block(&activity_title, drawer_pane_focused))
.highlight_style(highlight_style)
.highlight_symbol("> ")
.highlight_spacing(HighlightSpacing::Always),
right[0],
&mut list_state,
);
} else {
let fallback_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 if state.daemon_status == DaemonStatus::Connecting {
vec![ListItem::new("Loading…")]
} else {
vec![ListItem::new("(no activity yet)")]
};
frame.render_widget(
List::new(fallback_items).block(panel_block(&activity_title, drawer_pane_focused)),
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],
);
}
fn render_detail_pane(frame: &mut Frame, state: &MemoryTuiState, area: Rect) {
let id_prefix = state
.drawer_list
.drawers
.get(state.drawer_detail_idx)
.map(|d| {
let n = d.id.len().min(8);
d.id[..n].to_string()
})
.unwrap_or_default();
let title = if id_prefix.is_empty() {
" DETAIL ".to_string()
} else {
format!(" DETAIL — {id_prefix} ")
};
let border_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
let body = drawer_detail_body(state);
let para = Paragraph::new(body)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: false })
.scroll((state.drawer_detail_scroll as u16, 0))
.block(block);
frame.render_widget(para, area);
}
pub fn drawer_detail_body(state: &MemoryTuiState) -> String {
if state.drawer_detail_loading {
return "Loading…".to_string();
}
if state.drawer_detail_memories.is_empty() {
return "(no memories returned)".to_string();
}
let mut out = String::new();
for (i, memory) in state.drawer_detail_memories.iter().enumerate() {
if i > 0 {
out.push_str("\n\n──────────────────────────────────────\n\n");
}
let ts = memory
.created_at
.map(|t| t.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| "(no timestamp)".to_string());
let creator = crate::monitor::memory_client::creator_label(&memory.tags);
let tag_join = if memory.tags.is_empty() {
"(none)".to_string()
} else {
memory.tags.join(", ")
};
let header_id = if memory.id.is_empty() {
"(no id)".to_string()
} else {
memory.id.clone()
};
out.push_str(&format!("Drawer: {header_id}\n"));
out.push_str(&format!("Time: {ts}\n"));
out.push_str(&format!("By: {creator}\n"));
out.push_str(&format!("Tags: {tag_join}\n"));
out.push('\n');
if memory.content.is_empty() {
out.push_str("(empty content)");
} else {
out.push_str(&memory.content);
}
}
out
}
#[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,
..Default::default()
},
PalaceRow {
id: "work".into(),
name: "work".into(),
vector_count: 0,
kg_triple_count: 42,
..Default::default()
},
];
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_stats_lines_connecting_shows_loading() {
let state = MemoryTuiState::new("http://x");
assert!(matches!(state.daemon_status, DaemonStatus::Connecting));
let lines = stats_lines(&state);
assert_eq!(lines, vec!["Loading…".to_string()]);
}
#[test]
fn test_palace_row_display() {
let palace = PalaceRow {
id: "default".into(),
name: "default".into(),
vector_count: 8_400,
..Default::default()
};
let row = palace_row(&palace, true);
assert!(row.starts_with(" "), "leading spinner+space: {row}");
assert!(row.contains("default"));
assert!(row.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,
..Default::default()
};
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,
..Default::default()
};
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[1].is_all && !rows[1].selected);
assert!(rows[1].text.contains("default"));
assert!(rows[2].text.contains("work"));
let mut empty = MemoryTuiState::new("http://x");
empty.daemon_status = DaemonStatus::Online {
version: "0.1.54".into(),
uptime_secs: 0,
};
let rows = palace_lines(&empty);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_all);
assert!(rows[1].text.contains("no palaces"));
let connecting = MemoryTuiState::new("http://x");
assert!(matches!(connecting.daemon_status, DaemonStatus::Connecting));
let rows = palace_lines(&connecting);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_all);
assert!(
rows[1].text.contains("Loading…"),
"connecting state must show Loading…, got: {:?}",
rows[1].text
);
}
#[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,
content_preview: "How the migration system handles…".into(),
},
);
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[0].contains("\"How the migration system handles…\""));
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), tui_common::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_palace_sort_key_cycle() {
assert_eq!(PalaceSortKey::default(), PalaceSortKey::Activity);
assert_eq!(PalaceSortKey::Activity.next(), PalaceSortKey::Name);
assert_eq!(PalaceSortKey::Name.next(), PalaceSortKey::Count);
assert_eq!(PalaceSortKey::Count.next(), PalaceSortKey::Activity);
assert_eq!(sort_label(PalaceSortKey::Activity), "Activity");
assert_eq!(sort_label(PalaceSortKey::Name), "Name");
assert_eq!(sort_label(PalaceSortKey::Count), "Vectors");
}
fn diverse_state() -> MemoryTuiState {
use chrono::{TimeZone, Utc};
let mut state = MemoryTuiState::new("http://127.0.0.1:7070");
state.palaces = vec![
PalaceRow {
id: "trusty-search".into(),
name: "trusty-search".into(),
vector_count: 12,
last_write_at: Some(Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()),
description: Some(
"Auto-registered from /Users/masa/Projects/trusty-tools/trusty-search".into(),
),
..Default::default()
},
PalaceRow {
id: "trusty-memory".into(),
name: "trusty-memory".into(),
vector_count: 3_775,
last_write_at: Some(Utc.with_ymd_and_hms(2026, 5, 18, 22, 29, 50).unwrap()),
description: Some(
"Auto-registered from /Users/masa/Projects/trusty-tools/trusty-memory".into(),
),
..Default::default()
},
PalaceRow {
id: "claude-mpm".into(),
name: "claude-mpm".into(),
vector_count: 6_163,
last_write_at: Some(Utc.with_ymd_and_hms(2026, 5, 10, 0, 0, 0).unwrap()),
description: Some("Auto-registered from /Users/masa/Projects/claude-mpm".into()),
..Default::default()
},
PalaceRow {
id: "notes".into(),
name: "notes".into(),
vector_count: 100,
last_write_at: None,
description: None,
..Default::default()
},
];
state
}
#[test]
fn test_apply_sort_activity() {
let mut state = diverse_state();
state.sort_key = PalaceSortKey::Activity;
let rows = filtered_sorted_palaces(&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 = PalaceSortKey::Name;
let rows = filtered_sorted_palaces(&state);
let names: Vec<&str> = rows.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
names,
vec!["claude-mpm", "notes", "trusty-memory", "trusty-search"]
);
}
#[test]
fn test_apply_sort_vectors() {
let mut state = diverse_state();
state.sort_key = PalaceSortKey::Count;
let rows = filtered_sorted_palaces(&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_palaces(&state);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|p| p.name.contains("trusty")));
state.filter = "claude-mpm".into();
let rows = filtered_sorted_palaces(&state);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "claude-mpm");
state.filter = "nothing-here".into();
assert!(filtered_sorted_palaces(&state).is_empty());
state.filter.clear();
assert_eq!(filtered_sorted_palaces(&state).len(), 4);
}
#[test]
fn test_palace_lines_grouped() {
let mut state = diverse_state();
state.group_by_project = true;
state.sort_key = PalaceSortKey::Name;
let rows = palace_lines(&state);
assert!(rows[0].is_all);
let headers: Vec<&PalaceListRow> = 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 = palace_lines(&state);
let headers: Vec<&PalaceListRow> = rows.iter().filter(|r| r.is_header).collect();
assert_eq!(headers.len(), 1);
assert!(headers[0].text.contains("claude-mpm"));
}
#[test]
fn test_help_text_lists_bindings() {
let text = help_text();
for token in ["Tab", "d ", "Enter", "?", "q ", "/", "s ", "g "] {
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,
..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_palace_ids() {
let mut state = diverse_state();
state.sort_key = PalaceSortKey::Name;
let ids = visible_palace_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_palace_ids(&state);
assert_eq!(ids[0], tui_common::ALL_SENTINEL);
assert_eq!(ids.len(), 3, "All + 2 trusty-* palaces");
}
#[test]
fn test_navigate_visible() {
let mut state = diverse_state();
state.sort_key = PalaceSortKey::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 = PalaceSortKey::Name;
let pos = state
.palaces
.iter()
.position(|p| p.id == "claude-mpm")
.expect("palace");
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 = PalaceSortKey::Count;
let pos = state
.palaces
.iter()
.position(|p| p.id == "notes")
.expect("palace");
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 = PalaceSortKey::Name;
state.group_by_project = true;
let pos = state
.palaces
.iter()
.position(|p| p.id == "trusty-memory")
.expect("palace");
state.selected = pos + 1;
let expected = palace_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 = PalaceSortKey::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 = PalaceSortKey::Name;
let pos = state
.palaces
.iter()
.position(|p| p.id == "claude-mpm")
.expect("palace");
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
.palaces
.iter()
.position(|p| p.id == "trusty-memory")
.expect("palace");
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("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,
..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_palace_activity_state() {
use chrono::{TimeZone, Utc};
let now = Utc.with_ymd_and_hms(2026, 5, 22, 12, 0, 0).unwrap();
let mut p = PalaceRow {
id: "a".into(),
name: "a".into(),
vector_count: 1,
is_compacting: true,
..Default::default()
};
assert_eq!(palace_activity_state(&p, now), PalaceActivity::Dreaming);
p.is_compacting = false;
p.last_write_at = Some(now - chrono::Duration::seconds(3));
assert_eq!(palace_activity_state(&p, now), PalaceActivity::Indexing);
p.last_write_at = Some(now - chrono::Duration::seconds(30));
assert_eq!(palace_activity_state(&p, now), PalaceActivity::Active);
p.last_write_at = Some(now - chrono::Duration::seconds(120));
assert_eq!(palace_activity_state(&p, now), PalaceActivity::Idle);
p.last_write_at = None;
assert_eq!(palace_activity_state(&p, now), PalaceActivity::Idle);
assert_eq!(PalaceActivity::Idle.prefix(0), ' ');
assert_eq!(PalaceActivity::Active.prefix(0), '⠿');
assert_eq!(PalaceActivity::Error.prefix(0), '✗');
let i0 = PalaceActivity::Indexing.prefix(0);
let i1 = PalaceActivity::Indexing.prefix(1);
assert_ne!(i0, i1, "indexing spinner advances per tick");
let d0 = PalaceActivity::Dreaming.prefix(0);
let d1 = PalaceActivity::Dreaming.prefix(1);
assert_ne!(d0, d1, "dreaming spinner advances per tick");
assert_eq!(PalaceActivity::Idle.color(), None);
assert_eq!(PalaceActivity::Indexing.color(), Some(Color::Yellow));
assert_eq!(PalaceActivity::Active.color(), Some(Color::Cyan));
assert_eq!(PalaceActivity::Dreaming.color(), Some(Color::Magenta));
assert_eq!(PalaceActivity::Error.color(), Some(Color::Red));
}
#[test]
fn test_filter_empty_palaces() {
let mut state = MemoryTuiState::new("http://x");
state.palaces = vec![
PalaceRow {
id: "vec-only".into(),
name: "vec-only".into(),
vector_count: 10,
..Default::default()
},
PalaceRow {
id: "kg-only".into(),
name: "kg-only".into(),
kg_triple_count: 5,
..Default::default()
},
PalaceRow {
id: "drawer-only".into(),
name: "drawer-only".into(),
drawer_count: 18,
..Default::default()
},
PalaceRow {
id: "empty".into(),
name: "empty".into(),
..Default::default()
},
];
let visible = filtered_sorted_palaces(&state);
assert_eq!(visible.len(), 3, "only truly empty palace dropped");
assert!(visible.iter().any(|p| p.id == "vec-only"));
assert!(visible.iter().any(|p| p.id == "kg-only"));
assert!(
visible.iter().any(|p| p.id == "drawer-only"),
"drawer-only palace must be visible (has stored memories, not yet embedded)"
);
assert!(!visible.iter().any(|p| p.id == "empty"));
let rows = palace_lines(&state);
assert!(!rows.iter().any(|r| r.text.contains("empty")));
assert!(rows.iter().any(|r| r.text.contains("drawer-o")));
}
#[test]
fn test_palace_row_with_activity() {
let p = PalaceRow {
id: "default".into(),
name: "default".into(),
vector_count: 8_400,
..Default::default()
};
let row = palace_row_with_activity(&p, PalaceActivity::Indexing, 0);
assert_eq!(row.chars().next(), Some(INDEXING_SPINNER[0]));
assert!(row.contains("default"));
assert!(row.contains("8,400v"));
let ind = palace_row_indented_with_activity(&p, PalaceActivity::Active, 0);
assert!(ind.starts_with(' '));
assert!(ind.contains('⠿'));
assert!(ind.contains("default"));
}
#[test]
fn test_palace_lines_activity() {
use chrono::{TimeZone, Utc};
let now = Utc.with_ymd_and_hms(2026, 5, 22, 12, 0, 0).unwrap();
let mut state = MemoryTuiState::new("http://x");
state.palaces = vec![
PalaceRow {
id: "indexing".into(),
name: "indexing".into(),
vector_count: 1,
last_write_at: Some(now - chrono::Duration::seconds(2)),
..Default::default()
},
PalaceRow {
id: "dreaming".into(),
name: "dreaming".into(),
vector_count: 1,
is_compacting: true,
..Default::default()
},
];
let rows = palace_lines_at(&state, now, 0);
assert_eq!(rows[0].activity, None);
assert_eq!(rows[1].activity, Some(PalaceActivity::Indexing));
assert_eq!(rows[2].activity, Some(PalaceActivity::Dreaming));
}
#[test]
fn test_stats_graph_section() {
use chrono::{TimeZone, Utc};
let mut state = MemoryTuiState::new("http://x");
state.daemon_status = DaemonStatus::Online {
version: "0.1.54".into(),
uptime_secs: 0,
};
state.palaces = vec![PalaceRow {
id: "p1".into(),
name: "p1".into(),
vector_count: 1_234,
kg_triple_count: 567,
node_count: 4_321,
edge_count: 12_345,
community_count: 7,
last_write_at: Some(Utc.with_ymd_and_hms(2026, 5, 22, 11, 59, 50).unwrap()),
..Default::default()
}];
state.selected = 1; let lines = stats_lines(&state);
let joined = lines.join("\n");
assert!(joined.contains("Knowledge Graph"));
assert!(joined.contains("Nodes:"));
assert!(joined.contains("4,321"));
assert!(joined.contains("Edges:"));
assert!(joined.contains("12.3k"));
assert!(joined.contains("Triples:"));
assert!(joined.contains("567"));
assert!(joined.contains("Last write:"));
assert!(joined.contains("State:"));
}
#[test]
fn test_format_relative_time() {
use chrono::{TimeZone, Utc};
let now = Utc.with_ymd_and_hms(2026, 5, 22, 12, 0, 0).unwrap();
assert_eq!(
format_relative_time(now, now - chrono::Duration::seconds(1)),
"just now"
);
assert_eq!(
format_relative_time(now, now - chrono::Duration::seconds(30)),
"30s ago"
);
assert_eq!(
format_relative_time(now, now - chrono::Duration::minutes(2)),
"2m ago"
);
assert_eq!(
format_relative_time(now, now - chrono::Duration::hours(5)),
"5h ago"
);
assert_eq!(
format_relative_time(now, now - chrono::Duration::days(3)),
"3d ago"
);
assert_eq!(
format_relative_time(now, now + chrono::Duration::seconds(10)),
"just now"
);
}
#[test]
fn test_spinner_tick_returns_value() {
let _t = spinner_tick();
}
#[test]
fn dream_backoff_allows_first_attempt() {
let backoff = DreamBackoff::new();
assert!(backoff.ready(Instant::now()));
assert_eq!(backoff.consecutive_failures(), 0);
assert_eq!(backoff.remaining(Instant::now()), Duration::ZERO);
}
#[test]
fn dream_backoff_blocks_within_window() {
let mut backoff = DreamBackoff::new();
let t0 = Instant::now();
let logged = backoff.record_failure(t0);
assert!(logged, "first failure must be loud");
assert!(!backoff.ready(t0 + Duration::from_secs(1)));
assert!(backoff.ready(t0 + DREAM_BACKOFF_INITIAL));
}
#[test]
fn dream_backoff_remaining_reports_window() {
let mut backoff = DreamBackoff::new();
let t0 = Instant::now();
backoff.record_failure(t0);
let r = backoff.remaining(t0);
assert!(r <= DREAM_BACKOFF_INITIAL && r > Duration::from_secs(0));
}
#[test]
fn dream_backoff_resets_on_success() {
let mut backoff = DreamBackoff::new();
let t0 = Instant::now();
backoff.record_failure(t0);
backoff.record_failure(t0);
assert_eq!(backoff.consecutive_failures(), 2);
backoff.record_success();
assert_eq!(backoff.consecutive_failures(), 0);
assert!(backoff.ready(t0));
assert!(backoff.record_failure(t0));
}
#[test]
fn dream_backoff_logs_only_first() {
let mut backoff = DreamBackoff::new();
let t0 = Instant::now();
assert!(backoff.record_failure(t0), "first failure is loud");
assert!(
!backoff.record_failure(t0),
"subsequent failures are suppressed"
);
assert!(
!backoff.record_failure(t0),
"still suppressed after several failures"
);
}
#[test]
fn dream_backoff_delay_doubles_and_caps() {
assert_eq!(backoff_delay(1), DREAM_BACKOFF_INITIAL);
assert_eq!(backoff_delay(2), DREAM_BACKOFF_INITIAL * 2);
assert_eq!(backoff_delay(3), DREAM_BACKOFF_INITIAL * 4);
assert_eq!(backoff_delay(30), DREAM_BACKOFF_MAX);
assert_eq!(backoff_delay(0), DREAM_BACKOFF_INITIAL);
}
#[test]
fn dream_backoff_doubles_then_caps() {
let mut backoff = DreamBackoff::new();
let t0 = Instant::now();
let mut last = Duration::ZERO;
for _ in 0..10 {
backoff.record_failure(t0);
let r = backoff.remaining(t0);
assert!(r >= last || r == DREAM_BACKOFF_MAX);
last = r;
}
assert!(last <= DREAM_BACKOFF_MAX);
}
fn sample_drawer(idx: usize, tags: &[&str]) -> DrawerInfo {
use chrono::{TimeZone, Utc};
DrawerInfo {
id: format!("{idx:08x}-aaaa-bbbb-cccc-dddddddddddd"),
created_at: Some(
Utc.with_ymd_and_hms(2026, 5, 1, 12, idx as u32 % 60, 0)
.unwrap(),
),
creator: crate::monitor::memory_client::creator_label(
&tags.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(),
),
tags: tags.iter().map(|s| (*s).to_string()).collect(),
snippet: None,
}
}
fn sample_drawer_with_snippet(idx: usize, tags: &[&str], snippet: &str) -> DrawerInfo {
let mut d = sample_drawer(idx, tags);
d.snippet = Some(snippet.to_string());
d
}
#[test]
fn drawer_state_default_page_size() {
let state = DrawerListState::new();
assert!(state.palace_id.is_none());
assert!(state.drawers.is_empty());
assert_eq!(state.offset, 0);
assert!(!state.loading);
assert!(state.last_error.is_none());
assert_eq!(state.page(), 0);
assert_eq!(DRAWER_PAGE_SIZE, 20);
}
#[test]
fn drawer_state_reset_on_palace_change() {
let mut state = DrawerListState {
palace_id: Some("old".into()),
drawers: vec![sample_drawer(1, &[])],
offset: 40,
loading: false,
last_error: Some("stale".into()),
};
state.reset_for(Some("new".into()));
assert_eq!(state.palace_id.as_deref(), Some("new"));
assert!(state.drawers.is_empty());
assert_eq!(state.offset, 0);
assert!(state.loading, "should mark loading after reset");
assert!(state.last_error.is_none());
state.reset_for(None);
assert!(state.palace_id.is_none());
}
#[test]
fn drawer_state_pagination() {
let mut state = DrawerListState::new();
state.drawers = (0..DRAWER_PAGE_SIZE)
.map(|i| sample_drawer(i, &[]))
.collect();
state.next_page();
assert_eq!(state.offset, DRAWER_PAGE_SIZE);
assert_eq!(state.page(), 1);
assert!(state.loading);
state.loading = false;
state.prev_page();
assert_eq!(state.offset, 0);
assert_eq!(state.page(), 0);
assert!(state.loading);
state.loading = false;
state.prev_page();
assert_eq!(state.offset, 0);
assert!(!state.loading);
state.drawers = vec![sample_drawer(0, &[])];
state.next_page();
assert_eq!(
state.offset, 0,
"end-of-list page should not advance past last",
);
}
#[test]
fn drawer_row_layout() {
let drawer = sample_drawer(0xab, &["msg:from=cto"]);
let row = format_drawer_row(&drawer);
assert!(
row.starts_with("000000a…") || row.starts_with("000000ab"),
"row should start with truncated id, got: {row}",
);
assert!(row.contains("05-01"), "row should carry MM-DD: {row}");
assert!(row.contains("msg:from=cto"), "creator missing: {row}");
let bare = sample_drawer(1, &[]);
let row = format_drawer_row(&bare);
assert!(
row.contains("—"),
"missing em-dash for no-creator row: {row}"
);
let mut undated = sample_drawer(2, &[]);
undated.created_at = None;
let row = format_drawer_row(&undated);
assert!(row.contains("--"), "missing `--` for undated row: {row}");
}
#[test]
fn drawer_row_includes_snippet() {
let with_snippet =
sample_drawer_with_snippet(3, &["msg:from=cto"], "JWT middleware added to auth flow");
let row = format_drawer_row(&with_snippet);
assert!(
row.contains("msg:from=cto"),
"creator must still appear before snippet: {row}",
);
assert!(
row.contains("JWT middleware added to auth flow"),
"snippet must be appended: {row}",
);
let bare = sample_drawer(4, &["msg:from=cto"]);
let row = format_drawer_row(&bare);
assert!(
!row.ends_with(" "),
"no-snippet row must not have trailing whitespace: {row:?}",
);
let empty = sample_drawer_with_snippet(5, &["msg:from=cto"], " ");
let row = format_drawer_row(&empty);
assert!(
!row.ends_with(" "),
"whitespace-only snippet must be elided: {row:?}",
);
let long = "x".repeat(200);
let big = sample_drawer_with_snippet(6, &["msg:from=cto"], &long);
let row = format_drawer_row(&big);
assert!(
row.contains('…'),
"long snippet must be truncated with `…`: {row}",
);
}
#[test]
fn drawer_panel_lines_renders_no_palace() {
let state = sample_state();
assert!(state.drawer_list.palace_id.is_none());
let lines = drawer_panel_lines(&state, 0);
assert!(lines.is_empty(), "no-scope path should render no lines");
}
#[test]
fn drawer_panel_lines_renders_loading_then_rows() {
let mut state = sample_state();
state.drawer_list.palace_id = Some("default".into());
state.drawer_list.loading = true;
let lines = drawer_panel_lines(&state, 0);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("loading"));
state.drawer_list.loading = false;
state.drawer_list.drawers = vec![
sample_drawer(1, &["msg:from=cto"]),
sample_drawer(2, &["creator:client=mpm"]),
];
let lines = drawer_panel_lines(&state, 14);
assert_eq!(lines.len(), 3, "header + 2 rows");
assert!(lines[0].contains("drawers 1–2"));
assert!(lines[0].contains("page 1"));
assert!(lines[1].contains("msg:from=cto"));
assert!(lines[2].contains("creator:client=mpm"));
}
#[test]
fn drawer_panel_lines_renders_error() {
let mut state = sample_state();
state.drawer_list.palace_id = Some("default".into());
state.drawer_list.loading = false;
state.drawer_list.last_error = Some("connection refused".into());
let lines = drawer_panel_lines(&state, 0);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("drawers unavailable"));
assert!(lines[0].contains("connection refused"));
}
#[test]
fn test_focus_tab_cycle() {
assert_eq!(MemoryFocus::default(), MemoryFocus::List);
let mut focus = MemoryFocus::List;
focus = focus.next();
assert_eq!(focus, MemoryFocus::DrawerPane);
focus = focus.next();
assert_eq!(focus, MemoryFocus::Input);
focus = focus.next();
assert_eq!(focus, MemoryFocus::List);
}
#[test]
fn test_state_cycle_focus_resets_drawer_cursor() {
let mut state = sample_state();
state.drawer_cursor = 5;
state.cycle_focus(); assert_eq!(state.focus, MemoryFocus::DrawerPane);
assert_eq!(state.drawer_cursor, 5, "cursor preserved while in pane");
state.cycle_focus(); assert_eq!(state.focus, MemoryFocus::Input);
assert_eq!(state.drawer_cursor, 5, "cursor preserved while away");
state.cycle_focus(); assert_eq!(state.focus, MemoryFocus::List);
assert_eq!(state.drawer_cursor, 0, "cursor resets on return to list");
}
#[test]
fn test_drawer_cursor_clamp() {
let mut state = sample_state();
state.drawer_list.drawers = (0..3).map(|i| sample_drawer(i, &[])).collect();
state.drawer_cursor_up();
assert_eq!(state.drawer_cursor, 0);
state.drawer_cursor_down();
state.drawer_cursor_down();
state.drawer_cursor_down();
state.drawer_cursor_down();
assert_eq!(state.drawer_cursor, 2, "clamped at last index");
state.drawer_list.drawers.truncate(1);
state.clamp_drawer_cursor();
assert_eq!(state.drawer_cursor, 0, "clamped to new last index");
state.drawer_list.drawers.clear();
state.drawer_cursor = 5;
state.clamp_drawer_cursor();
assert_eq!(state.drawer_cursor, 0);
state.drawer_cursor_down();
assert_eq!(state.drawer_cursor, 0);
}
#[test]
fn test_drawer_detail_modal_lifecycle() {
let mut state = sample_state();
state.drawer_detail_open = true;
state.drawer_detail_idx = 3;
state.drawer_detail_scroll = 17;
state.drawer_detail_loading = true;
state.drawer_detail_memories = vec![MemoryDetail {
id: "x".into(),
content: "y".into(),
tags: vec![],
created_at: None,
}];
state.close_drawer_detail();
assert!(!state.drawer_detail_open);
assert!(state.drawer_detail_memories.is_empty());
assert_eq!(state.drawer_detail_scroll, 0);
assert!(!state.drawer_detail_loading);
}
#[test]
fn test_drawer_detail_body_layout() {
use chrono::{TimeZone, Utc};
let mut state = sample_state();
state.drawer_detail_memories = vec![
MemoryDetail {
id: "abc-123".into(),
content: "First memory body".into(),
tags: vec!["msg:from=cto".into(), "tag:type=note".into()],
created_at: Some(Utc.with_ymd_and_hms(2026, 5, 20, 12, 34, 56).unwrap()),
},
MemoryDetail {
id: "def-456".into(),
content: "Second memory body".into(),
tags: vec![],
created_at: None,
},
];
let body = drawer_detail_body(&state);
assert!(
body.contains("Drawer: abc-123"),
"missing id header: {body}"
);
assert!(body.contains("2026-05-20 12:34:56 UTC"));
assert!(body.contains("msg:from=cto"));
assert!(body.contains("tag:type=note"));
assert!(body.contains("First memory body"));
assert!(
body.contains("──────────────────────────────────────"),
"missing memory separator: {body}",
);
assert!(body.contains("Drawer: def-456"));
assert!(body.contains("(no timestamp)"));
assert!(body.contains("(none)"));
assert!(body.contains("Second memory body"));
}
#[test]
fn test_drawer_detail_body_loading() {
let mut state = sample_state();
state.drawer_detail_loading = true;
assert_eq!(drawer_detail_body(&state), "Loading…");
state.drawer_detail_loading = false;
assert_eq!(drawer_detail_body(&state), "(no memories returned)");
}
#[test]
fn test_render_drawer_pane_focused_title() {
let mut state = sample_state();
state.selected = 1; state.focus = MemoryFocus::DrawerPane;
state.drawer_list.palace_id = Some("default".into());
state.drawer_list.drawers = vec![sample_drawer(0, &["msg:from=cto"])];
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &mut state))
.expect("render with drawer focus must not panic");
let buffer = terminal.backend().buffer();
let content: String = buffer
.content()
.iter()
.map(|cell| cell.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
content.contains("DRAWER ▶"),
"expected DRAWER ▶ marker in rendered output",
);
}
#[test]
fn test_render_with_drawer_detail_open() {
use chrono::{TimeZone, Utc};
let mut state = sample_state();
state.selected = 1;
state.drawer_list.palace_id = Some("default".into());
state.drawer_list.drawers = vec![DrawerInfo {
id: "abc12345-rest-of-uuid".into(),
..Default::default()
}];
state.drawer_detail_open = true;
state.drawer_detail_idx = 0;
state.drawer_detail_memories = vec![MemoryDetail {
id: "abc12345-rest-of-uuid".into(),
content: "Verbatim memory body for the detail pane".into(),
tags: vec!["msg:from=cto".into()],
created_at: Some(Utc.with_ymd_and_hms(2026, 5, 20, 12, 34, 56).unwrap()),
}];
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| render(f, &mut state))
.expect("render with detail pane open must not panic");
let buffer = terminal.backend().buffer();
let content: String = buffer
.content()
.iter()
.map(|cell| cell.symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
content.contains("DETAIL"),
"expected DETAIL pane title in rendered output: {content}",
);
assert!(
content.contains("abc12345"),
"expected drawer-id prefix in DETAIL title: {content}",
);
assert!(
content.contains("STATISTICS"),
"expected STATISTICS panel to remain visible in split layout",
);
}
#[test]
fn drawer_panel_lines_renders_empty_palace() {
let mut state = sample_state();
state.drawer_list.palace_id = Some("default".into());
state.drawer_list.loading = false;
let lines = drawer_panel_lines(&state, 0);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("no drawers yet"));
}
}