// 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::user::{self as tui, HotkeyAction}; use crate::user::ui_channel::{self, UiMessage}; pub use crate::mind::MindCommand; /// Top-level entry point — creates Mind and UI, wires them together. pub async fn start(cli: crate::user::CliArgs) -> Result<()> { let (config, _figment) = crate::config::load_session(&cli)?; if config.app.debug { unsafe { std::env::set_var("POC_DEBUG", "1") }; } let (ui_tx, ui_rx) = ui_channel::channel(); let (turn_tx, turn_rx) = tokio::sync::mpsc::channel(1); let (mind_tx, mind_rx) = tokio::sync::mpsc::unbounded_channel(); let mind = crate::mind::Mind::new(config, ui_tx.clone(), turn_tx); let shared_context = mind.agent.lock().await.shared_context.clone(); let shared_active_tools = mind.agent.lock().await.active_tools.clone(); let turn_watch = mind.turn_watch(); let mut result = Ok(()); tokio_scoped::scope(|s| { // Mind event loop — init + run s.spawn(async { mind.init().await; mind.run(mind_rx, turn_rx).await; }); // UI event loop s.spawn(async { result = run( tui::App::new(String::new(), shared_context, shared_active_tools), &mind.agent, &mind.shared, turn_watch, mind_tx, ui_tx, ui_rx, ).await; }); }); result } 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>, mind_tx: &tokio::sync::mpsc::UnboundedSender, 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]))); // Send as a Turn command — Mind will process it let _ = mind_tx.send(MindCommand::Turn(text, crate::user::ui_channel::StreamTarget::Conversation)); } 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 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, ) -> Result<()> { // UI-owned state let mut idle_state = crate::thalamus::idle::State::new(); idle_state.load(); let (channel_tx, mut channel_rx) = tokio::sync::mpsc::channel::>(4); { let tx = channel_tx.clone(); tokio::spawn(async move { let result = crate::thalamus::channels::fetch_all_channels().await; let _ = tx.send(result).await; }); } let notify_rx = crate::thalamus::channels::subscribe_all(); 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; let mut prev_mind = shared_mind.lock().unwrap().clone(); terminal.hide_cursor()?; // Startup info let _ = ui_tx.send(UiMessage::Info("consciousness v0.3 (tui)".into())); { let ag = agent.lock().await; let _ = ui_tx.send(UiMessage::Info(format!(" model: {}", ag.model()))); // Replay restored conversation to UI if !ag.entries().is_empty() { ui_channel::replay_session_to_ui(ag.entries(), &ui_tx); let _ = ui_tx.send(UiMessage::Info("--- restored from conversation log ---".into())); } } // 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, } } _ = render_interval.tick() => { idle_state.decay_ewma(); app.update_idle(&idle_state); // Diff MindState — generate UI messages from changes { let cur = shared_mind.lock().unwrap().clone(); if cur.dmn.label() != prev_mind.dmn.label() || cur.dmn_turns != prev_mind.dmn_turns { let _ = ui_tx.send(UiMessage::StatusUpdate(ui_channel::StatusInfo { dmn_state: cur.dmn.label().to_string(), dmn_turns: cur.dmn_turns, dmn_max_turns: cur.max_dmn_turns, prompt_tokens: 0, completion_tokens: 0, model: String::new(), turn_tools: 0, context_budget: String::new(), })); dirty = true; } if cur.turn_active && !prev_mind.turn_active && !prev_mind.input.is_empty() { let text = prev_mind.input.join("\n"); let _ = ui_tx.send(UiMessage::UserInput(text)); dirty = true; } if cur.turn_active != prev_mind.turn_active { dirty = true; } if cur.scoring_in_flight != prev_mind.scoring_in_flight { if !cur.scoring_in_flight && prev_mind.scoring_in_flight { let _ = ui_tx.send(UiMessage::Info("[scoring complete]".into())); } dirty = true; } if cur.compaction_in_flight != prev_mind.compaction_in_flight { if !cur.compaction_in_flight && prev_mind.compaction_in_flight { let _ = ui_tx.send(UiMessage::Info("[compacted]".into())); } dirty = true; } prev_mind = cur; } 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(MindCommand::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" => { let mut s = shared_mind.lock().unwrap(); s.dmn = crate::mind::dmn::State::Resting { since: std::time::Instant::now() }; s.dmn_turns = 0; let _ = ui_tx.send(UiMessage::Info("DMN sleeping.".into())); } "/wake" => { let mut s = shared_mind.lock().unwrap(); if matches!(s.dmn, crate::mind::dmn::State::Off) { crate::mind::dmn::set_off(false); } s.dmn = crate::mind::dmn::State::Foraging; s.dmn_turns = 0; let _ = ui_tx.send(UiMessage::Info("DMN foraging.".into())); } "/pause" => { let mut s = shared_mind.lock().unwrap(); s.dmn = crate::mind::dmn::State::Paused; s.dmn_turns = 0; let _ = ui_tx.send(UiMessage::Info("DMN paused.".into())); } "/score" => { let _ = mind_tx.send(MindCommand::Score); } "/retry" => { let agent = agent.clone(); let mind_tx = mind_tx.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, &mind_tx, &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(MindCommand::Interrupt); } HotkeyAction::CycleAutonomy => { let mut s = shared_mind.lock().unwrap(); let label = match &s.dmn { crate::mind::dmn::State::Engaged | crate::mind::dmn::State::Working | crate::mind::dmn::State::Foraging => { s.dmn = crate::mind::dmn::State::Resting { since: std::time::Instant::now() }; "resting" } crate::mind::dmn::State::Resting { .. } => { s.dmn = crate::mind::dmn::State::Paused; "PAUSED" } crate::mind::dmn::State::Paused => { crate::mind::dmn::set_off(true); s.dmn = crate::mind::dmn::State::Off; "OFF (persists across restarts)" } crate::mind::dmn::State::Off => { crate::mind::dmn::set_off(false); s.dmn = crate::mind::dmn::State::Foraging; "foraging" } }; s.dmn_turns = 0; drop(s); let _ = ui_tx.send(UiMessage::Info(format!("DMN → {} (Ctrl+P to cycle)", label))); } 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(()) }