consciousness/src/user/event_loop.rs

362 lines
13 KiB
Rust
Raw Normal View History

// 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 <name>)"),
("/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<Mutex<Agent>>,
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<Mutex<Agent>>, 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<Mutex<Agent>>, 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<Mutex<Agent>>, 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 async fn cmd_switch_model(
agent: &Arc<Mutex<Agent>>,
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<Mutex<Agent>>,
shared_mind: crate::mind::SharedMindState,
turn_watch: tokio::sync::watch::Receiver<bool>,
mind_tx: tokio::sync::mpsc::UnboundedSender<MindMessage>,
ui_tx: ui_channel::UiSender,
mut ui_rx: ui_channel::UiReceiver,
mut observe_input_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
channel_tx: tokio::sync::mpsc::Sender<Vec<(String, bool, u32)>>,
mut channel_rx: tokio::sync::mpsc::Receiver<Vec<(String, bool, u32)>>,
notify_rx: std::sync::mpsc::Receiver<crate::thalamus::channels::ChannelNotification>,
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<String> = 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 <name>".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<HotkeyAction> = 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(())
}