// 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 { Hotkey(HotkeyAction), NewSession, Score, } 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(), )); } async fn cmd_retry( agent: &Arc>, shared_mind: &crate::mind::SharedMindState, ui_tx: &ui_channel::UiSender, ) { let mut agent_guard = agent.lock().await; let entries = agent_guard.entries_mut(); let mut last_user_text = None; while let Some(entry) = entries.last() { if entry.message().role == crate::agent::api::types::Role::User { last_user_text = Some(entries.pop().unwrap().message().content_text().to_string()); break; } entries.pop(); } drop(agent_guard); match last_user_text { Some(text) => { let preview_len = text.len().min(60); let _ = ui_tx.send(UiMessage::Info(format!("(retrying: {}...)", &text[..preview_len]))); shared_mind.lock().unwrap().input.push(text); } None => { let _ = ui_tx.send(UiMessage::Info("(nothing to retry)".into())); } } } fn cmd_cycle_reasoning(agent: &Arc>, ui_tx: &ui_channel::UiSender) { if let Ok(mut ag) = agent.try_lock() { let next = match ag.reasoning_effort.as_str() { "none" => "low", "low" => "high", _ => "none", }; ag.reasoning_effort = next.to_string(); let label = match next { "none" => "off (monologue hidden)", "low" => "low (brief monologue)", "high" => "high (full monologue)", _ => next, }; let _ = ui_tx.send(UiMessage::Info(format!("Reasoning: {} — ^R to cycle", label))); } else { let _ = ui_tx.send(UiMessage::Info( "(agent busy — reasoning change takes effect next turn)".into(), )); } } async fn cmd_kill_processes(agent: &Arc>, ui_tx: &ui_channel::UiSender) { let active_tools = agent.lock().await.active_tools.clone(); let mut tools = active_tools.lock().unwrap(); if tools.is_empty() { let _ = ui_tx.send(UiMessage::Info("(no running tool calls)".into())); } else { for entry in tools.drain(..) { let elapsed = entry.started.elapsed(); let _ = ui_tx.send(UiMessage::Info(format!( " killing {} ({:.0}s): {}", entry.name, elapsed.as_secs_f64(), entry.detail, ))); entry.handle.abort(); } } } fn cmd_adjust_sampling(agent: &Arc>, param: usize, delta: f32) { if let Ok(mut ag) = agent.try_lock() { match param { 0 => ag.temperature = (ag.temperature + delta).clamp(0.0, 2.0), 1 => ag.top_p = (ag.top_p + delta).clamp(0.0, 1.0), 2 => ag.top_k = (ag.top_k as f32 + delta).max(0.0) as u32, _ => {} } } } pub fn update_status(shared: &crate::mind::SharedMindState, ui_tx: &ui_channel::UiSender) { let s = shared.lock().unwrap(); let _ = ui_tx.send(UiMessage::StatusUpdate(ui_channel::StatusInfo { dmn_state: s.dmn.label().to_string(), dmn_turns: s.dmn_turns, dmn_max_turns: s.max_dmn_turns, prompt_tokens: 0, completion_tokens: 0, model: String::new(), turn_tools: 0, context_budget: String::new(), })); } pub fn send_context_info(config: &crate::config::SessionConfig, ui_tx: &ui_channel::UiSender) { let context_groups = crate::config::get().context_groups.clone(); let (instruction_files, memory_files) = crate::mind::identity::context_file_info( &config.prompt_file, config.app.memory_project.as_deref(), &context_groups, ); let _ = ui_tx.send(UiMessage::Info(format!( " context: {}K chars ({} config, {} memory files)", config.context_parts.iter().map(|(_, c)| c.len()).sum::() / 1024, instruction_files.len(), memory_files.len(), ))); } 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>, shared_mind: crate::mind::SharedMindState, turn_watch: tokio::sync::watch::Receiver, 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())); } } "/new" | "/clear" => { let _ = mind_tx.send(MindMessage::NewSession); } "/dmn" => { let s = shared_mind.lock().unwrap(); let _ = ui_tx.send(UiMessage::Info(format!("DMN: {:?} ({}/{})", s.dmn, s.dmn_turns, s.max_dmn_turns))); } "/sleep" => { shared_mind.lock().unwrap().dmn_sleep(); let _ = ui_tx.send(UiMessage::Info("DMN sleeping.".into())); } "/wake" => { shared_mind.lock().unwrap().dmn_wake(); let _ = ui_tx.send(UiMessage::Info("DMN foraging.".into())); } "/pause" => { shared_mind.lock().unwrap().dmn_pause(); let _ = ui_tx.send(UiMessage::Info("DMN paused.".into())); } "/score" => { let _ = mind_tx.send(MindMessage::Score); } "/retry" => { let agent = agent.clone(); let sm = shared_mind.clone(); let ui_tx = ui_tx.clone(); let mut tw = turn_watch.clone(); tokio::spawn(async move { let _ = tw.wait_for(|&active| !active).await; cmd_retry(&agent, &sm, &ui_tx).await; }); } 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; }); } } _ => { shared_mind.lock().unwrap().input.push(input); } } } // Handle hotkey actions let actions: Vec = app.hotkey_actions.drain(..).collect(); for action in actions { match action { HotkeyAction::CycleReasoning => cmd_cycle_reasoning(&agent, &ui_tx), HotkeyAction::KillProcess => cmd_kill_processes(&agent, &ui_tx).await, HotkeyAction::Interrupt => { let _ = mind_tx.send(MindMessage::Hotkey(action)); } HotkeyAction::CycleAutonomy => { let _ = mind_tx.send(MindMessage::Hotkey(action)); } HotkeyAction::AdjustSampling(param, delta) => cmd_adjust_sampling(&agent, param, delta), } } 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(()) }