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;
|
2026-04-05 06:22:31 -04:00
|
|
|
use ratatui::crossterm::event::{Event, EventStream, KeyEventKind};
|
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 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;
|
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};
|
|
|
|
|
|
2026-04-05 03:34:43 -04:00
|
|
|
pub use crate::mind::MindCommand;
|
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 16:53:05 -04:00
|
|
|
// ── Slash commands ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct SlashCommand {
|
|
|
|
|
name: &'static str,
|
|
|
|
|
help: &'static str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn commands() -> Vec<SlashCommand> {
|
|
|
|
|
vec![
|
|
|
|
|
SlashCommand { name: "/quit", help: "Exit consciousness" },
|
|
|
|
|
SlashCommand { name: "/new", help: "Start fresh session (saves current)" },
|
|
|
|
|
SlashCommand { name: "/save", help: "Save session to disk" },
|
|
|
|
|
SlashCommand { name: "/retry", help: "Re-run last turn" },
|
|
|
|
|
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)" },
|
|
|
|
|
SlashCommand { name: "/score", help: "Score memory importance" },
|
|
|
|
|
SlashCommand { name: "/dmn", help: "Show DMN state" },
|
|
|
|
|
SlashCommand { name: "/sleep", help: "Put DMN to sleep" },
|
|
|
|
|
SlashCommand { name: "/wake", help: "Wake DMN to foraging" },
|
|
|
|
|
SlashCommand { name: "/pause", help: "Full stop — no autonomous ticks (Ctrl+P)" },
|
|
|
|
|
SlashCommand { name: "/help", help: "Show this help" },
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 04:29:56 -04:00
|
|
|
/// 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();
|
|
|
|
|
|
2026-04-05 04:42:50 -04:00
|
|
|
let mind = crate::mind::Mind::new(config, ui_tx.clone(), turn_tx);
|
2026-04-05 04:29:56 -04:00
|
|
|
|
|
|
|
|
let shared_context = mind.agent.lock().await.shared_context.clone();
|
|
|
|
|
let shared_active_tools = mind.agent.lock().await.active_tools.clone();
|
|
|
|
|
|
2026-04-05 04:53:33 -04:00
|
|
|
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),
|
2026-04-05 16:53:05 -04:00
|
|
|
&mind, mind_tx, ui_tx, ui_rx,
|
2026-04-05 04:53:33 -04:00
|
|
|
).await;
|
|
|
|
|
});
|
2026-04-05 04:29:56 -04:00
|
|
|
});
|
2026-04-05 04:53:33 -04:00
|
|
|
result
|
2026-04-05 04:29:56 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:29:44 -04:00
|
|
|
fn send_help(ui_tx: &ui_channel::UiSender) {
|
2026-04-05 16:53:05 -04:00
|
|
|
for cmd in &commands() {
|
|
|
|
|
let _ = ui_tx.send(UiMessage::Info(format!(" {:12} {}", cmd.name, cmd.help)));
|
2026-04-05 02:29:44 -04:00
|
|
|
}
|
|
|
|
|
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(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:37:51 -04:00
|
|
|
async fn cmd_retry(
|
|
|
|
|
agent: &Arc<Mutex<Agent>>,
|
2026-04-05 05:01:45 -04:00
|
|
|
mind_tx: &tokio::sync::mpsc::UnboundedSender<MindCommand>,
|
2026-04-05 02:37:51 -04:00
|
|
|
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])));
|
2026-04-05 05:01:45 -04:00
|
|
|
// Send as a Turn command — Mind will process it
|
|
|
|
|
let _ = mind_tx.send(MindCommand::Turn(text, crate::user::ui_channel::StreamTarget::Conversation));
|
2026-04-05 02:37:51 -04:00
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let _ = ui_tx.send(UiMessage::Info("(nothing to retry)".into()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:44:58 -04:00
|
|
|
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,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 03:12:50 -04:00
|
|
|
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::<usize>() / 1024,
|
|
|
|
|
instruction_files.len(), memory_files.len(),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 02:29:44 -04:00
|
|
|
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,
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 05:18:35 -04:00
|
|
|
fn diff_mind_state(
|
|
|
|
|
cur: &crate::mind::MindState,
|
|
|
|
|
prev: &crate::mind::MindState,
|
|
|
|
|
ui_tx: &ui_channel::UiSender,
|
|
|
|
|
dirty: &mut bool,
|
|
|
|
|
) {
|
|
|
|
|
if cur.dmn.label() != prev.dmn.label() || cur.dmn_turns != prev.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;
|
|
|
|
|
}
|
2026-04-05 16:09:34 -04:00
|
|
|
// Input consumed — Mind started a turn with it
|
|
|
|
|
if !prev.input.is_empty() && cur.input.is_empty() {
|
2026-04-05 05:18:35 -04:00
|
|
|
let text = prev.input.join("\n");
|
|
|
|
|
let _ = ui_tx.send(UiMessage::UserInput(text));
|
|
|
|
|
*dirty = true;
|
|
|
|
|
}
|
|
|
|
|
if cur.turn_active != prev.turn_active {
|
|
|
|
|
*dirty = true;
|
|
|
|
|
}
|
|
|
|
|
if cur.scoring_in_flight != prev.scoring_in_flight {
|
|
|
|
|
if !cur.scoring_in_flight && prev.scoring_in_flight {
|
|
|
|
|
let _ = ui_tx.send(UiMessage::Info("[scoring complete]".into()));
|
|
|
|
|
}
|
|
|
|
|
*dirty = true;
|
|
|
|
|
}
|
|
|
|
|
if cur.compaction_in_flight != prev.compaction_in_flight {
|
|
|
|
|
if !cur.compaction_in_flight && prev.compaction_in_flight {
|
|
|
|
|
let _ = ui_tx.send(UiMessage::Info("[compacted]".into()));
|
|
|
|
|
}
|
|
|
|
|
*dirty = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 16:53:05 -04:00
|
|
|
mind: &crate::mind::Mind,
|
2026-04-05 03:34:43 -04:00
|
|
|
mind_tx: tokio::sync::mpsc::UnboundedSender<MindCommand>,
|
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,
|
|
|
|
|
) -> Result<()> {
|
2026-04-05 16:53:05 -04:00
|
|
|
let agent = &mind.agent;
|
|
|
|
|
let shared_mind = &mind.shared;
|
|
|
|
|
let turn_watch = mind.turn_watch();
|
2026-04-05 04:17:04 -04:00
|
|
|
// 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::<Vec<(String, bool, u32)>>(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();
|
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 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;
|
2026-04-05 03:24:08 -04:00
|
|
|
let mut prev_mind = shared_mind.lock().unwrap().clone();
|
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
|
|
|
|
|
|
|
|
terminal.hide_cursor()?;
|
|
|
|
|
|
2026-04-05 04:02:16 -04:00
|
|
|
let _ = ui_tx.send(UiMessage::Info("consciousness v0.3 (tui)".into()));
|
|
|
|
|
|
2026-04-05 16:18:10 -04:00
|
|
|
// Initial render — don't wait for Mind to init
|
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
|
|
|
app.drain_messages(&mut ui_rx);
|
|
|
|
|
terminal.draw(|f| app.draw(f))?;
|
|
|
|
|
|
2026-04-05 16:18:10 -04:00
|
|
|
// Replay conversation after Mind init completes (non-blocking check)
|
|
|
|
|
let mut startup_done = false;
|
|
|
|
|
|
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
|
|
|
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);
|
|
|
|
|
|
2026-04-05 16:18:10 -04:00
|
|
|
// One-time: replay conversation after Mind init
|
|
|
|
|
if !startup_done {
|
|
|
|
|
if let Ok(ag) = agent.try_lock() {
|
|
|
|
|
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()));
|
|
|
|
|
}
|
|
|
|
|
let _ = ui_tx.send(UiMessage::Info(format!(" model: {}", ag.model())));
|
|
|
|
|
startup_done = true;
|
|
|
|
|
dirty = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 03:24:08 -04:00
|
|
|
// Diff MindState — generate UI messages from changes
|
|
|
|
|
{
|
2026-04-05 05:18:35 -04:00
|
|
|
let cur = shared_mind.lock().unwrap();
|
|
|
|
|
diff_mind_state(&cur, &prev_mind, &ui_tx, &mut dirty);
|
|
|
|
|
prev_mind = cur.clone();
|
2026-04-05 03:24:08 -04:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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()));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 03:34:43 -04:00
|
|
|
"/new" | "/clear" => { let _ = mind_tx.send(MindCommand::NewSession); }
|
2026-04-05 03:08:36 -04:00
|
|
|
"/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" => {
|
2026-04-05 03:46:29 -04:00
|
|
|
let mut s = shared_mind.lock().unwrap();
|
|
|
|
|
s.dmn = crate::mind::dmn::State::Resting { since: std::time::Instant::now() };
|
|
|
|
|
s.dmn_turns = 0;
|
2026-04-05 03:08:36 -04:00
|
|
|
let _ = ui_tx.send(UiMessage::Info("DMN sleeping.".into()));
|
|
|
|
|
}
|
|
|
|
|
"/wake" => {
|
2026-04-05 03:46:29 -04:00
|
|
|
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;
|
2026-04-05 03:08:36 -04:00
|
|
|
let _ = ui_tx.send(UiMessage::Info("DMN foraging.".into()));
|
|
|
|
|
}
|
|
|
|
|
"/pause" => {
|
2026-04-05 03:46:29 -04:00
|
|
|
let mut s = shared_mind.lock().unwrap();
|
|
|
|
|
s.dmn = crate::mind::dmn::State::Paused;
|
|
|
|
|
s.dmn_turns = 0;
|
2026-04-05 03:08:36 -04:00
|
|
|
let _ = ui_tx.send(UiMessage::Info("DMN paused.".into()));
|
|
|
|
|
}
|
2026-04-05 03:34:43 -04:00
|
|
|
"/score" => { let _ = mind_tx.send(MindCommand::Score); }
|
2026-04-05 02:37:51 -04:00
|
|
|
"/retry" => {
|
|
|
|
|
let agent = agent.clone();
|
2026-04-05 05:01:45 -04:00
|
|
|
let mind_tx = mind_tx.clone();
|
2026-04-05 02:37:51 -04:00
|
|
|
let ui_tx = ui_tx.clone();
|
|
|
|
|
let mut tw = turn_watch.clone();
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let _ = tw.wait_for(|&active| !active).await;
|
2026-04-05 05:01:45 -04:00
|
|
|
cmd_retry(&agent, &mind_tx, &ui_tx).await;
|
2026-04-05 02:37:51 -04:00
|
|
|
});
|
|
|
|
|
}
|
2026-04-05 02:29:44 -04:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 02:52:56 -04:00
|
|
|
_ => {
|
2026-04-05 05:18:35 -04:00
|
|
|
let mut s = shared_mind.lock().unwrap();
|
2026-04-05 16:53:05 -04:00
|
|
|
diff_mind_state(&s, &prev_mind, &ui_tx, &mut dirty);
|
2026-04-05 05:18:35 -04:00
|
|
|
s.input.push(input);
|
2026-04-05 16:53:05 -04:00
|
|
|
prev_mind = s.clone();
|
2026-04-05 02:52:56 -04:00
|
|
|
}
|
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:44:58 -04:00
|
|
|
// Handle hotkey actions
|
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 actions: Vec<HotkeyAction> = app.hotkey_actions.drain(..).collect();
|
|
|
|
|
for action in actions {
|
2026-04-05 02:44:58 -04:00
|
|
|
match action {
|
|
|
|
|
HotkeyAction::CycleReasoning => cmd_cycle_reasoning(&agent, &ui_tx),
|
|
|
|
|
HotkeyAction::KillProcess => cmd_kill_processes(&agent, &ui_tx).await,
|
2026-04-05 03:41:47 -04:00
|
|
|
HotkeyAction::Interrupt => { let _ = mind_tx.send(MindCommand::Interrupt); }
|
|
|
|
|
HotkeyAction::CycleAutonomy => {
|
2026-04-05 03:46:29 -04:00
|
|
|
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);
|
2026-04-05 03:41:47 -04:00
|
|
|
let _ = ui_tx.send(UiMessage::Info(format!("DMN → {} (Ctrl+P to cycle)", label)));
|
|
|
|
|
}
|
2026-04-05 02:44:58 -04:00
|
|
|
HotkeyAction::AdjustSampling(param, delta) => cmd_adjust_sampling(&agent, param, delta),
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(())
|
|
|
|
|
}
|