use std::time::Instant;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::mpsc;
use crate::monitor::memory_client::{MemoryClient, MemoryEvent, RecallHit, resolve_memory_url};
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 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}"));
}
}
}
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;
}
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 (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;
}
}
}