event_loop: command table with inline closures

SlashCommand registry with name, help, and handler as inline
closures in commands(). Single source of truth — send_help reads
it, dispatch_command looks up by name. No separate named functions.

run() takes &Mind instead of individual handles. Dispatch reduced
to: quit check, command lookup, or submit as input.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 16:56:23 -04:00
parent 3c4220c079
commit 7458fe655f

View file

@ -13,33 +13,94 @@ use tokio::sync::Mutex;
use crate::agent::Agent;
use crate::agent::api::ApiClient;
use crate::mind::MindCommand;
use crate::user::{self as tui, HotkeyAction};
use crate::user::ui_channel::{self, UiMessage};
pub use crate::mind::MindCommand;
// ── Slash commands ─────────────────────────────────────────────
type CmdHandler = for<'a> fn(
&'a crate::mind::Mind,
&'a tokio::sync::mpsc::UnboundedSender<MindCommand>,
&'a ui_channel::UiSender,
&'a str,
);
struct SlashCommand {
name: &'static str,
help: &'static str,
handler: CmdHandler,
}
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" },
]
fn commands() -> Vec<SlashCommand> { vec![
SlashCommand { name: "/quit", help: "Exit consciousness",
handler: |_, _, _, _| {} },
SlashCommand { name: "/new", help: "Start fresh session (saves current)",
handler: |_, tx, _, _| { let _ = tx.send(MindCommand::NewSession); } },
SlashCommand { name: "/save", help: "Save session to disk",
handler: |_, _, ui, _| { let _ = ui.send(UiMessage::Info("Conversation is saved automatically.".into())); } },
SlashCommand { name: "/retry", help: "Re-run last turn",
handler: |m, tx, ui, _| {
let agent = m.agent.clone();
let mind_tx = tx.clone();
let ui_tx = ui.clone();
let mut tw = m.turn_watch();
tokio::spawn(async move {
let _ = tw.wait_for(|&active| !active).await;
cmd_retry_inner(&agent, &mind_tx, &ui_tx).await;
});
} },
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)",
handler: |m, _, ui, arg| {
if arg.is_empty() {
if let Ok(ag) = m.agent.try_lock() {
let _ = ui.send(UiMessage::Info(format!("Current model: {}", ag.model())));
let names = ag.app_config.model_names();
if !names.is_empty() {
let _ = ui.send(UiMessage::Info(format!("Available: {}", names.join(", "))));
}
} else {
let _ = ui.send(UiMessage::Info("(busy)".into()));
}
} else {
let agent = m.agent.clone();
let ui_tx = ui.clone();
let name = arg.to_string();
tokio::spawn(async move { cmd_switch_model(&agent, &name, &ui_tx).await; });
}
} },
SlashCommand { name: "/score", help: "Score memory importance",
handler: |_, tx, _, _| { let _ = tx.send(MindCommand::Score); } },
SlashCommand { name: "/dmn", help: "Show DMN state",
handler: |m, _, ui, _| {
let s = m.shared.lock().unwrap();
let _ = ui.send(UiMessage::Info(format!("DMN: {:?} ({}/{})", s.dmn, s.dmn_turns, s.max_dmn_turns)));
} },
SlashCommand { name: "/sleep", help: "Put DMN to sleep",
handler: |m, _, ui, _| {
let mut s = m.shared.lock().unwrap();
s.dmn = crate::mind::dmn::State::Resting { since: std::time::Instant::now() };
s.dmn_turns = 0;
let _ = ui.send(UiMessage::Info("DMN sleeping.".into()));
} },
SlashCommand { name: "/wake", help: "Wake DMN to foraging",
handler: |m, _, ui, _| {
let mut s = m.shared.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.send(UiMessage::Info("DMN foraging.".into()));
} },
SlashCommand { name: "/pause", help: "Full stop — no autonomous ticks (Ctrl+P)",
handler: |m, _, ui, _| {
let mut s = m.shared.lock().unwrap();
s.dmn = crate::mind::dmn::State::Paused;
s.dmn_turns = 0;
let _ = ui.send(UiMessage::Info("DMN paused.".into()));
} },
SlashCommand { name: "/help", help: "Show this help",
handler: |_, _, ui, _| { send_help(ui); } },
]}
/// Top-level entry point — creates Mind and UI, wires them together.
pub async fn start(cli: crate::user::CliArgs) -> Result<()> {
@ -77,6 +138,11 @@ pub async fn start(cli: crate::user::CliArgs) -> Result<()> {
result
}
fn dispatch_command(input: &str) -> Option<SlashCommand> {
let cmd_name = input.split_whitespace().next()?;
commands().into_iter().find(|c| c.name == cmd_name)
}
fn send_help(ui_tx: &ui_channel::UiSender) {
for cmd in &commands() {
let _ = ui_tx.send(UiMessage::Info(format!(" {:12} {}", cmd.name, cmd.help)));
@ -93,7 +159,7 @@ fn send_help(ui_tx: &ui_channel::UiSender) {
));
}
async fn cmd_retry(
async fn cmd_retry_inner(
agent: &Arc<Mutex<Agent>>,
mind_tx: &tokio::sync::mpsc::UnboundedSender<MindCommand>,
ui_tx: &ui_channel::UiSender,
@ -122,8 +188,8 @@ async fn cmd_retry(
}
}
fn cmd_cycle_reasoning(agent: &Arc<Mutex<Agent>>, ui_tx: &ui_channel::UiSender) {
if let Ok(mut ag) = agent.try_lock() {
fn hotkey_cycle_reasoning(mind: &crate::mind::Mind, ui_tx: &ui_channel::UiSender) {
if let Ok(mut ag) = mind.agent.try_lock() {
let next = match ag.reasoning_effort.as_str() {
"none" => "low",
"low" => "high",
@ -144,8 +210,8 @@ fn cmd_cycle_reasoning(agent: &Arc<Mutex<Agent>>, ui_tx: &ui_channel::UiSender)
}
}
async fn cmd_kill_processes(agent: &Arc<Mutex<Agent>>, ui_tx: &ui_channel::UiSender) {
let active_tools = agent.lock().await.active_tools.clone();
async fn hotkey_kill_processes(mind: &crate::mind::Mind, ui_tx: &ui_channel::UiSender) {
let active_tools = mind.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()));
@ -160,8 +226,35 @@ async fn cmd_kill_processes(agent: &Arc<Mutex<Agent>>, ui_tx: &ui_channel::UiSen
}
}
fn cmd_adjust_sampling(agent: &Arc<Mutex<Agent>>, param: usize, delta: f32) {
if let Ok(mut ag) = agent.try_lock() {
fn hotkey_cycle_autonomy(mind: &crate::mind::Mind, ui_tx: &ui_channel::UiSender) {
let mut s = mind.shared.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)));
}
fn hotkey_adjust_sampling(mind: &crate::mind::Mind, param: usize, delta: f32) {
if let Ok(mut ag) = mind.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),
@ -390,117 +483,27 @@ pub async fn run(
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(", "))));
}
if input == "/quit" || input == "/exit" {
app.should_quit = true;
} else if let Some(cmd) = dispatch_command(&input) {
(cmd.handler)(mind, &mind_tx, &ui_tx, &input[cmd.name.len()..].trim_start());
} 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 <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;
});
}
}
_ => {
let mut s = shared_mind.lock().unwrap();
diff_mind_state(&s, &prev_mind, &ui_tx, &mut dirty);
s.input.push(input);
prev_mind = s.clone();
}
}
}
// 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::CycleReasoning => hotkey_cycle_reasoning(mind, &ui_tx),
HotkeyAction::KillProcess => hotkey_kill_processes(mind, &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),
HotkeyAction::CycleAutonomy => hotkey_cycle_autonomy(mind, &ui_tx),
HotkeyAction::AdjustSampling(param, delta) => hotkey_adjust_sampling(mind, param, delta),
}
}