use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode};
use ratatui::Terminal;
use super::client::DaemonClient;
use super::dashboard::{DashboardState, Focus};
use super::health::{self, Daemon, HealthScreen, HealthUpdate};
use super::{Screen, coordinator_send, poll_daemon, render_screen, spawn_health_pollers};
pub(super) async fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
client: &mut DaemonClient,
interval_ms: u64,
focus_id: Option<String>,
) -> anyhow::Result<()> {
let mut state = DashboardState::default();
let mut screen = Screen::default();
let mut health_screen =
HealthScreen::new(health::DEFAULT_SEARCH_URL, health::DEFAULT_MEMORY_URL);
let (health_tx, mut health_rx) = tokio::sync::mpsc::channel::<HealthUpdate>(16);
spawn_health_pollers(
health_screen.search_url.clone(),
health_screen.memory_url.clone(),
health_tx,
);
poll_daemon(&mut state, client).await;
refresh_health_data(&mut health_screen).await;
let mut last_health_refresh = Instant::now();
state.sidebar_visible = !state.sessions.is_empty();
if let Some(id) = focus_id.as_deref()
&& let Some(idx) = state.sessions.iter().position(|s| s.id.0.to_string() == id)
{
state.selected_session = idx;
state.last_action = Some(format!("Connected to {id}"));
}
let mut last_poll = Instant::now();
loop {
terminal.draw(|f| render_screen(f, screen, &state, &health_screen))?;
while let Ok(update) = health_rx.try_recv() {
health_screen.apply_update(update);
}
if event::poll(Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
{
if screen == Screen::Health {
if health_screen.tab == health::HealthTab::Search
&& health_screen.search_input_focused
{
match key.code {
KeyCode::Esc => {
health_screen.search_query.clear();
health_screen.search_input_focused = false;
}
KeyCode::Backspace => {
health_screen.search_query.pop();
}
KeyCode::Char(c) if !c.is_ascii_digit() => {
health_screen.search_query.push(c);
}
KeyCode::Char('1') => health_screen.set_tab(health::HealthTab::Health),
KeyCode::Char('2') => health_screen.set_tab(health::HealthTab::Logs),
KeyCode::Char('3') => health_screen.set_tab(health::HealthTab::Search),
KeyCode::Char('4') => health_screen.set_tab(health::HealthTab::Index),
_ => {}
}
continue;
}
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') => screen = Screen::Chat,
KeyCode::Char('1') => health_screen.set_tab(health::HealthTab::Health),
KeyCode::Char('2') => health_screen.set_tab(health::HealthTab::Logs),
KeyCode::Char('3') => health_screen.set_tab(health::HealthTab::Search),
KeyCode::Char('4') => health_screen.set_tab(health::HealthTab::Index),
KeyCode::Tab => {
health_screen.toggle_focus();
health_screen.clamp_collection_selection();
}
KeyCode::Up => match health_screen.tab {
health::HealthTab::Logs => health_screen.focused_logs_mut().scroll_up(),
_ => health_screen.select_collection_up(),
},
KeyCode::Down => match health_screen.tab {
health::HealthTab::Logs => health_screen.focused_logs_mut().scroll_down(),
_ => health_screen.select_collection_down(),
},
KeyCode::Char('r') => {
refresh_health_data(&mut health_screen).await;
}
KeyCode::Char('S') => {
health_start(&mut state, &health_screen);
}
KeyCode::Char('X') => {
health_stop(&mut state, &health_screen).await;
}
KeyCode::Char(c) if c.is_ascii_alphanumeric() => {
health_screen.set_tab(health::HealthTab::Search);
health_screen.search_query.push(c);
}
_ => {}
}
continue;
}
if state.show_help {
if matches!(
key.code,
KeyCode::Char('?') | KeyCode::Esc | KeyCode::Char('q')
) {
if key.code == KeyCode::Char('q') {
return Ok(());
}
state.show_help = false;
}
continue;
}
if state.focus != Focus::Input
&& matches!(key.code, KeyCode::Char('1') | KeyCode::Char('2'))
{
screen = match key.code {
KeyCode::Char('2') => Screen::Health,
_ => Screen::Chat,
};
continue;
}
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('?') => state.show_help = true,
KeyCode::Char('s') => state.toggle_sidebar(),
KeyCode::Tab => state.toggle_focus(),
KeyCode::Esc => state.command_bar.clear(),
KeyCode::Up => match state.focus {
Focus::Sidebar => state.select_up(),
Focus::Input => {
if state.command_bar.input.is_empty() {
state.scroll_up();
} else {
state.command_bar.history_prev();
}
}
},
KeyCode::Down => match state.focus {
Focus::Sidebar => state.select_down(),
Focus::Input => {
if state.command_bar.input.is_empty() {
state.scroll_down();
} else {
state.command_bar.history_next();
}
}
},
KeyCode::Enter => {
if state.focus == Focus::Sidebar {
if let Some(name) = state.selected_target() {
let prefix = super::dashboard::session_prefix(&name);
state.command_bar.input = format!("@{prefix}: ");
state.focus = Focus::Input;
}
} else {
let typed = state.command_bar.take_for_execution();
if !typed.is_empty() {
coordinator_send(&mut state, client, &typed).await;
poll_daemon(&mut state, client).await;
last_poll = Instant::now();
}
}
}
KeyCode::Backspace if state.focus == Focus::Input => {
state.command_bar.backspace();
}
KeyCode::Char(c) if state.focus == Focus::Input => {
state.command_bar.push(c);
}
_ => {}
}
}
if last_poll.elapsed() >= Duration::from_millis(interval_ms) {
poll_daemon(&mut state, client).await;
last_poll = Instant::now();
}
if screen == Screen::Health && last_health_refresh.elapsed() >= Duration::from_secs(5) {
refresh_health_data(&mut health_screen).await;
last_health_refresh = Instant::now();
}
}
}
pub(super) async fn refresh_health_data(screen: &mut health::HealthScreen) {
for daemon in [health::Daemon::Search, health::Daemon::Memory] {
let url = match daemon {
health::Daemon::Search => screen.search_url.clone(),
health::Daemon::Memory => screen.memory_url.clone(),
};
let client = health::client_for(daemon, &url);
let rows = match daemon {
health::Daemon::Search => client.search_collections().await,
health::Daemon::Memory => client.memory_collections().await,
};
match daemon {
health::Daemon::Search => screen.search_collections = rows,
health::Daemon::Memory => screen.memory_collections = rows,
}
if let Ok((lines, total)) = client.logs_tail(health::LOG_BUFFER_CAP as u32).await {
let buf = match daemon {
health::Daemon::Search => &mut screen.search_logs,
health::Daemon::Memory => &mut screen.memory_logs,
};
let preserve_scroll = !buf.auto_scroll;
let prior_offset = buf.scroll_offset;
buf.replace(lines, Some(total));
if preserve_scroll {
buf.scroll_offset = prior_offset.min(buf.lines.len().saturating_sub(1));
}
}
}
screen.clamp_collection_selection();
}
pub(super) fn health_start(chat: &mut DashboardState, hp: &HealthScreen) {
let (label, args): (&str, &[&str]) = match hp.focus {
Daemon::Search => (
"trusty-search",
&["run", "-p", "trusty-search", "--", "start"],
),
Daemon::Memory => ("trusty-memory", &["run", "-p", "trusty-memory"]),
};
match std::process::Command::new("cargo").args(args).spawn() {
Ok(_) => {
tracing::info!("health screen: spawned {label}");
chat.last_action = Some(format!("starting {label}…"));
}
Err(e) => {
tracing::warn!("health screen: failed to start {label}: {e}");
chat.last_action = Some(format!("failed to start {label}: {e}"));
}
}
}
pub(super) async fn health_stop(chat: &mut DashboardState, hp: &HealthScreen) {
let label = match hp.focus {
Daemon::Search => "trusty-search",
Daemon::Memory => "trusty-memory",
};
let client = health::client_for(hp.focus, hp.focused_url());
match client.stop().await {
Ok(()) => {
tracing::info!("health screen: stop requested for {label}");
chat.last_action = Some(format!("stopping {label}…"));
}
Err(e) => {
tracing::warn!("health screen: failed to stop {label}: {e}");
chat.last_action = Some(format!("failed to stop {label}: {e}"));
}
}
}