use std::time::Instant;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crate::monitor::memory_client::{
ActivityFeed, MemoryClient, MemoryEvent, RecallHit, resolve_memory_socket,
};
use crate::monitor::memory_tui::MemoryFocus;
use crate::monitor::memory_tui::MemoryTuiState;
use crate::monitor::memory_tui::render::render;
use crate::monitor::memory_tui::state::{DRAWER_PAGE_SIZE, RECALL_TOP_K};
use crate::monitor::memory_tui::view::navigate_down_visible;
use crate::monitor::memory_tui::view::navigate_up_visible;
use crate::monitor::utils::DaemonStatus;
const REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000);
const INPUT_POLL: std::time::Duration = std::time::Duration::from_millis(50);
pub(crate) async fn poll_daemon(state: &mut MemoryTuiState, client: &mut MemoryClient) {
if !state.daemon_status.is_online()
&& let Ok(resolved) = resolve_memory_socket()
&& resolved != client.socket()
{
state.daemon_addr = resolved.display().to_string();
client.set_socket(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}"));
}
}
}
pub(crate) 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;
}
async fn rotate_feed(
state: &mut MemoryTuiState,
client: &MemoryClient,
feed: &mut Option<ActivityFeed>,
) -> bool {
let was_healthy = feed.as_ref().is_some_and(ActivityFeed::is_live);
if was_healthy && let Some(hole) = feed.as_ref().and_then(ActivityFeed::take_last_error) {
state.log.push_raw(format!("activity stream: {hole}"));
}
if feed.as_ref().is_some_and(|f| !f.is_live()) {
let reason = feed
.as_ref()
.and_then(ActivityFeed::take_last_error)
.unwrap_or_else(|| "the activity stream ended".to_string());
state
.log
.push_raw(format!("activity stream lost ({reason}); polling"));
*feed = None;
}
if feed.is_none() {
*feed = open_activity_feed(state, client).await;
}
was_healthy
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PollDisposition {
Seed,
CursorOnly,
Render,
}
fn poll_disposition(seeded_events: bool, feed_was_healthy: bool) -> PollDisposition {
if !seeded_events {
PollDisposition::Seed
} else if feed_was_healthy {
PollDisposition::CursorOnly
} else {
PollDisposition::Render
}
}
async fn open_activity_feed(
state: &mut MemoryTuiState,
client: &MemoryClient,
) -> Option<ActivityFeed> {
match ActivityFeed::open(client.socket()).await {
Ok(feed) => {
state.log.push_raw("activity stream attached".to_string());
Some(feed)
}
Err(e) => {
state
.log
.push_raw(format!("activity stream unavailable ({e}); polling"));
None
}
}
}
pub(crate) async fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut ratatui::Terminal<B>,
state: &mut MemoryTuiState,
client: &mut MemoryClient,
) -> anyhow::Result<()> {
poll_daemon(state, client).await;
let mut last_poll = Instant::now();
let mut feed = open_activity_feed(state, client).await;
let mut event_cursor: u64 = 0;
let mut seeded_events = false;
let mut last_drawer_scope: Option<String> = None;
loop {
terminal.draw(|f| render(f, state))?;
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 let Some(live) = feed.as_mut() {
for event in live.drain() {
apply_memory_event(state, event);
}
}
if last_poll.elapsed() >= REFRESH_INTERVAL {
poll_daemon(state, client).await;
let feed_was_healthy = rotate_feed(state, client, &mut feed).await;
if let Ok((cursor, events)) = client.recent_events(event_cursor).await {
if poll_disposition(seeded_events, feed_was_healthy) == PollDisposition::Render {
for event in events {
apply_memory_event(state, event);
}
}
seeded_events = true;
event_cursor = cursor;
}
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;
}
}
}
#[cfg(test)]
mod tests {
use super::{PollDisposition, poll_disposition};
#[test]
fn poll_seeds_the_cursor_on_the_first_read() {
assert_eq!(poll_disposition(false, true), PollDisposition::Seed);
assert_eq!(poll_disposition(false, false), PollDisposition::Seed);
}
#[test]
fn poll_advances_only_while_the_feed_carried_the_interval() {
assert_eq!(poll_disposition(true, true), PollDisposition::CursorOnly);
}
#[test]
fn poll_renders_the_death_window_even_though_the_feed_reopened() {
assert_eq!(poll_disposition(true, false), PollDisposition::Render);
}
#[test]
fn poll_renders_every_tick_when_no_stream_ever_attached() {
let mut seeded = false;
let mut rendered = 0;
for _ in 0..3 {
if poll_disposition(seeded, false) == PollDisposition::Render {
rendered += 1;
}
seeded = true;
}
assert_eq!(rendered, 2, "the first tick seeds, every one after renders");
}
}