// event_loop.rs — TUI event loop // // Drives the terminal, renders the UI, dispatches user input and // hotkey actions to the Mind via a channel. Reads shared state // (agent, active tools) directly for rendering. use anyhow::Result; use crossterm::event::{Event, EventStream, KeyEventKind}; use futures::StreamExt; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use crate::agent::Agent; use crate::agent::api::ApiClient; use crate::config::SessionConfig; use crate::user::{self as tui, HotkeyAction}; use crate::user::ui_channel::{self, UiMessage}; /// Messages from the UI to the Mind. pub enum MindMessage { UserInput(String), Hotkey(HotkeyAction), } fn send_help(ui_tx: &ui_channel::UiSender) { let commands = &[ ("/quit", "Exit consciousness"), ("/new", "Start fresh session (saves current)"), ("/save", "Save session to disk"), ("/retry", "Re-run last turn"), ("/model", "Show/switch model (/model )"), ("/score", "Score memory importance"), ("/dmn", "Show DMN state"), ("/sleep", "Put DMN to sleep"), ("/wake", "Wake DMN to foraging"), ("/pause", "Full stop — no autonomous ticks (Ctrl+P)"), ("/help", "Show this help"), ]; for (name, desc) in commands { let _ = ui_tx.send(UiMessage::Info(format!(" {:12} {}", name, desc))); } let _ = ui_tx.send(UiMessage::Info(String::new())); let _ = ui_tx.send(UiMessage::Info( "Keys: Tab=pane ^Up/Down=scroll PgUp/PgDn=scroll Mouse=click/scroll".into(), )); let _ = ui_tx.send(UiMessage::Info( " Alt+Enter=newline Esc=interrupt ^P=pause ^R=reasoning ^K=kill F10=context F2=agents".into(), )); let _ = ui_tx.send(UiMessage::Info( " Shift+click for native text selection (copy/paste)".into(), )); } pub async fn cmd_switch_model( agent: &Arc>, name: &str, ui_tx: &ui_channel::UiSender, ) { let resolved = { let ag = agent.lock().await; match ag.app_config.resolve_model(name) { Ok(r) => r, Err(e) => { let _ = ui_tx.send(UiMessage::Info(format!("{}", e))); return; } } }; let new_client = ApiClient::new(&resolved.api_base, &resolved.api_key, &resolved.model_id); let prompt_changed = { let ag = agent.lock().await; resolved.prompt_file != ag.prompt_file }; let mut ag = agent.lock().await; ag.swap_client(new_client); if prompt_changed { ag.prompt_file = resolved.prompt_file.clone(); ag.compact(); let _ = ui_tx.send(UiMessage::Info(format!( "Switched to {} ({}) — prompt: {}, recompacted", name, resolved.model_id, resolved.prompt_file, ))); } else { let _ = ui_tx.send(UiMessage::Info(format!( "Switched to {} ({})", name, resolved.model_id, ))); } } pub async fn run( mut app: tui::App, agent: Arc>, mind_tx: tokio::sync::mpsc::UnboundedSender, ui_tx: ui_channel::UiSender, mut ui_rx: ui_channel::UiReceiver, mut observe_input_rx: tokio::sync::mpsc::UnboundedReceiver, channel_tx: tokio::sync::mpsc::Sender>, mut channel_rx: tokio::sync::mpsc::Receiver>, notify_rx: std::sync::mpsc::Receiver, mut idle_state: crate::thalamus::idle::State, ) -> Result<()> { let mut terminal = tui::init_terminal()?; let mut reader = EventStream::new(); let mut render_interval = tokio::time::interval(Duration::from_millis(50)); render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut dirty = true; terminal.hide_cursor()?; // Initial render app.drain_messages(&mut ui_rx); terminal.draw(|f| app.draw(f))?; loop { tokio::select! { biased; maybe_event = reader.next() => { match maybe_event { Some(Ok(Event::Key(key))) => { if key.kind != KeyEventKind::Press { continue; } app.handle_key(key); idle_state.user_activity(); if app.screen == tui::Screen::Thalamus { let tx = channel_tx.clone(); tokio::spawn(async move { let result = crate::thalamus::channels::fetch_all_channels().await; let _ = tx.send(result).await; }); } dirty = true; } Some(Ok(Event::Mouse(mouse))) => { app.handle_mouse(mouse); dirty = true; } Some(Ok(Event::Resize(w, h))) => { app.handle_resize(w, h); terminal.clear()?; dirty = true; } Some(Err(_)) => break, None => break, _ => continue, } } Some(line) = observe_input_rx.recv() => { app.submitted.push(line); dirty = true; } _ = render_interval.tick() => { idle_state.decay_ewma(); app.update_idle(&idle_state); while let Ok(notif) = notify_rx.try_recv() { let tx = channel_tx.clone(); tokio::spawn(async move { let result = crate::thalamus::channels::fetch_all_channels().await; let _ = tx.send(result).await; }); } } Some(channels) = channel_rx.recv() => { app.set_channel_status(channels); dirty = true; } Some(msg) = ui_rx.recv() => { app.handle_ui_message(msg); dirty = true; } } // Process submitted input let submitted: Vec = app.submitted.drain(..).collect(); for input in submitted { let input = input.trim().to_string(); if input.is_empty() { continue; } match input.as_str() { "/quit" | "/exit" => app.should_quit = true, "/save" => { let _ = ui_tx.send(UiMessage::Info( "Conversation is saved automatically (append-only log).".into() )); } "/help" => send_help(&ui_tx), "/model" => { if let Ok(ag) = agent.try_lock() { let _ = ui_tx.send(UiMessage::Info(format!("Current model: {}", ag.model()))); let names = ag.app_config.model_names(); if !names.is_empty() { let _ = ui_tx.send(UiMessage::Info(format!("Available: {}", names.join(", ")))); } } else { let _ = ui_tx.send(UiMessage::Info("(busy)".into())); } } cmd if cmd.starts_with("/model ") => { let name = cmd[7..].trim().to_string(); if name.is_empty() { let _ = ui_tx.send(UiMessage::Info("Usage: /model ".into())); } else { let agent = agent.clone(); let ui_tx = ui_tx.clone(); tokio::spawn(async move { cmd_switch_model(&agent, &name, &ui_tx).await; }); } } _ => { let _ = mind_tx.send(MindMessage::UserInput(input)); } } } // Send hotkey actions to Mind let actions: Vec = app.hotkey_actions.drain(..).collect(); for action in actions { let _ = mind_tx.send(MindMessage::Hotkey(action)); } if app.drain_messages(&mut ui_rx) { dirty = true; } if dirty { terminal.draw(|f| app.draw(f))?; dirty = false; } if app.should_quit { break; } } tui::restore_terminal(&mut terminal)?; Ok(()) }