mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
// 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;
|
2026-04-05 02:29:44 -04:00
|
|
|
use std::sync::Arc;
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
use std::time::Duration;
|
2026-04-05 02:29:44 -04:00
|
|
|
use tokio::sync::Mutex;
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
|
2026-04-05 02:29:44 -04:00
|
|
|
use crate::agent::Agent;
|
|
|
|
|
use crate::agent::api::ApiClient;
|
|
|
|
|
use crate::config::SessionConfig;
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:29:44 -04:00
|
|
|
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(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
pub async fn run(
|
|
|
|
|
mut app: tui::App,
|
2026-04-05 02:29:44 -04:00
|
|
|
agent: Arc<Mutex<Agent>>,
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
mind_tx: tokio::sync::mpsc::UnboundedSender<MindMessage>,
|
2026-04-05 02:29:44 -04:00
|
|
|
ui_tx: ui_channel::UiSender,
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
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,
|
2026-04-05 02:29:44 -04:00
|
|
|
"/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 <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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
mind: split event loop — Mind and UI run independently
Mind::run() owns the cognitive event loop: user input, turn results,
DMN ticks, hotkey actions. The UI event loop (user/event_loop.rs) owns
the terminal: key events, render ticks, channel status display.
They communicate through channels: UI sends MindMessage (user input,
hotkey actions) to Mind. Mind sends UiMessage (status, info) to UI.
UI reads shared state (active tools, context) directly for rendering.
Removes direct coupling between Mind and App:
- cycle_reasoning no longer takes &mut App
- AdjustSampling updates agent only, UI reads from shared state
- /quit handled by UI directly, not routed through Mind
Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
2026-04-05 02:11:32 -04:00
|
|
|
_ => { let _ = mind_tx.send(MindMessage::UserInput(input)); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send hotkey actions to Mind
|
|
|
|
|
let actions: Vec<HotkeyAction> = 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(())
|
|
|
|
|
}
|