Add MindState behind Arc<Mutex<>> for state shared between Mind and UI. Pending user input goes through shared state instead of MindMessage::UserInput — UI pushes, Mind consumes. Mind checks for pending input after every event (message received, turn completed, DMN tick). User input is prioritized over DMN ticks. This enables the UI to display/edit/cancel queued messages, and removes the last MindMessage variant that carried data. Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
353 lines
13 KiB
Rust
353 lines
13 KiB
Rust
// 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),
|
|
DmnSleep,
|
|
DmnWake,
|
|
DmnPause,
|
|
DmnQuery,
|
|
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 _ = mind_tx.send(MindMessage::DmnQuery); }
|
|
"/sleep" => { let _ = mind_tx.send(MindMessage::DmnSleep); }
|
|
"/wake" => { let _ = mind_tx.send(MindMessage::DmnWake); }
|
|
"/pause" => { let _ = mind_tx.send(MindMessage::DmnPause); }
|
|
"/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(())
|
|
}
|