mind: move all slash commands to event_loop dispatch

All slash command routing now lives in user/event_loop.rs. Mind
receives typed messages (NewSession, Score, DmnSleep, etc.) and
handles them as named methods. No more handle_command() dispatch
table or Command enum.

Commands that only need Agent state (/model, /retry) run directly
in the UI task. Commands that need Mind state (/new, /score, /dmn,
/sleep, /wake, /pause) send a MindMessage.

Mind is now purely: turn lifecycle, DMN state machine, and the
named handlers for each message type.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 02:40:45 -04:00
parent b05c956ab8
commit 64add58caa
2 changed files with 98 additions and 108 deletions

View file

@ -35,11 +35,6 @@ fn compaction_threshold(app: &AppConfig) -> u32 {
(crate::agent::context::context_window() as u32) * app.compaction.hard_threshold_pct / 100 (crate::agent::context::context_window() as u32) * app.compaction.hard_threshold_pct / 100
} }
/// Result of slash command handling.
pub enum Command {
Handled,
None,
}
// --- Mind: all mutable state for a running agent session --- // --- Mind: all mutable state for a running agent session ---
@ -345,104 +340,86 @@ impl Mind {
self.spawn_turn(prompt, StreamTarget::Autonomous); self.spawn_turn(prompt, StreamTarget::Autonomous);
} }
/// Handle slash commands. Returns how the main loop should respond. async fn cmd_new(&mut self) {
async fn handle_command(&mut self, input: &str) -> Command { let new_log = log::ConversationLog::new(
match input { self.config.session_dir.join("conversation.jsonl"),
"/new" | "/clear" => { ).ok();
if self.turn_in_progress { let mut agent_guard = self.agent.lock().await;
let _ = self.ui_tx.send(UiMessage::Info("(turn in progress, please wait)".into())); let shared_ctx = agent_guard.shared_context.clone();
return Command::Handled; let shared_tools = agent_guard.active_tools.clone();
} *agent_guard = Agent::new(
{ ApiClient::new(&self.config.api_base, &self.config.api_key, &self.config.model),
let new_log = log::ConversationLog::new( self.config.system_prompt.clone(),
self.config.session_dir.join("conversation.jsonl"), self.config.context_parts.clone(),
).ok(); self.config.app.clone(),
let mut agent_guard = self.agent.lock().await; self.config.prompt_file.clone(),
let shared_ctx = agent_guard.shared_context.clone(); new_log,
let shared_tools = agent_guard.active_tools.clone(); shared_ctx,
*agent_guard = Agent::new( shared_tools,
ApiClient::new(&self.config.api_base, &self.config.api_key, &self.config.model), );
self.config.system_prompt.clone(), drop(agent_guard);
self.config.context_parts.clone(), self.dmn = dmn::State::Resting { since: Instant::now() };
self.config.app.clone(), let _ = self.ui_tx.send(UiMessage::Info("New session started.".into()));
self.config.prompt_file.clone(), }
new_log,
shared_ctx, fn cmd_score(&mut self) {
shared_tools, if self.scoring_in_flight {
); let _ = self.ui_tx.send(UiMessage::Info("(scoring already in progress)".into()));
} return;
self.dmn = dmn::State::Resting { since: Instant::now() };
let _ = self.ui_tx.send(UiMessage::Info("New session started.".into()));
Command::Handled
}
"/score" => {
if self.scoring_in_flight {
let _ = self.ui_tx.send(UiMessage::Info("(scoring already in progress)".into()));
return Command::Handled;
}
let (context, client) = {
let agent = self.agent.lock().await;
(agent.context.clone(), agent.client_clone())
};
self.scoring_in_flight = true;
let agent = self.agent.clone();
let ui_tx = self.ui_tx.clone();
tokio::spawn(async move {
let result = learn::score_memories(
&context, &client, &ui_tx,
).await;
let agent = agent.lock().await;
match result {
Ok(scores) => {
agent.publish_context_state_with_scores(Some(&scores));
}
Err(e) => {
let _ = ui_tx.send(UiMessage::Info(format!("[scoring failed: {:#}]", e)));
}
}
});
Command::Handled
}
"/dmn" => {
let _ = self.ui_tx.send(UiMessage::Info(format!("DMN state: {:?}", self.dmn)));
let _ = self.ui_tx.send(UiMessage::Info(format!("Next tick in: {:?}", self.dmn.interval())));
let _ = self.ui_tx.send(UiMessage::Info(format!(
"Consecutive DMN turns: {}/{}", self.dmn_turns, self.max_dmn_turns,
)));
Command::Handled
}
"/sleep" => {
self.dmn = dmn::State::Resting { since: Instant::now() };
self.dmn_turns = 0;
let _ = self.ui_tx.send(UiMessage::Info(
"DMN sleeping (heartbeat every 5 min). Type anything to wake.".into(),
));
Command::Handled
}
"/wake" => {
let was_paused = matches!(self.dmn, dmn::State::Paused | dmn::State::Off);
if matches!(self.dmn, dmn::State::Off) {
dmn::set_off(false);
}
self.dmn = dmn::State::Foraging;
self.dmn_turns = 0;
let msg = if was_paused { "DMN unpaused — entering foraging mode." }
else { "DMN waking — entering foraging mode." };
let _ = self.ui_tx.send(UiMessage::Info(msg.into()));
self.update_status();
Command::Handled
}
"/pause" => {
self.dmn = dmn::State::Paused;
self.dmn_turns = 0;
let _ = self.ui_tx.send(UiMessage::Info(
"DMN paused — no autonomous ticks. Ctrl+P or /wake to resume.".into(),
));
self.update_status();
Command::Handled
}
_ => Command::None,
} }
let agent = self.agent.clone();
let ui_tx = self.ui_tx.clone();
self.scoring_in_flight = true;
tokio::spawn(async move {
let (context, client) = {
let ag = agent.lock().await;
(ag.context.clone(), ag.client_clone())
};
let result = learn::score_memories(&context, &client, &ui_tx).await;
let ag = agent.lock().await;
match result {
Ok(scores) => ag.publish_context_state_with_scores(Some(&scores)),
Err(e) => { let _ = ui_tx.send(UiMessage::Info(format!("[scoring failed: {:#}]", e))); }
}
});
}
fn cmd_dmn_query(&self) {
let _ = self.ui_tx.send(UiMessage::Info(format!("DMN state: {:?}", self.dmn)));
let _ = self.ui_tx.send(UiMessage::Info(format!("Next tick in: {:?}", self.dmn.interval())));
let _ = self.ui_tx.send(UiMessage::Info(format!(
"Consecutive DMN turns: {}/{}", self.dmn_turns, self.max_dmn_turns,
)));
}
fn cmd_dmn_sleep(&mut self) {
self.dmn = dmn::State::Resting { since: Instant::now() };
self.dmn_turns = 0;
let _ = self.ui_tx.send(UiMessage::Info(
"DMN sleeping (heartbeat every 5 min). Type anything to wake.".into(),
));
}
fn cmd_dmn_wake(&mut self) {
let was_paused = matches!(self.dmn, dmn::State::Paused | dmn::State::Off);
if matches!(self.dmn, dmn::State::Off) {
dmn::set_off(false);
}
self.dmn = dmn::State::Foraging;
self.dmn_turns = 0;
let msg = if was_paused { "DMN unpaused — entering foraging mode." }
else { "DMN waking — entering foraging mode." };
let _ = self.ui_tx.send(UiMessage::Info(msg.into()));
self.update_status();
}
fn cmd_dmn_pause(&mut self) {
self.dmn = dmn::State::Paused;
self.dmn_turns = 0;
let _ = self.ui_tx.send(UiMessage::Info(
"DMN paused — no autonomous ticks. Ctrl+P or /wake to resume.".into(),
));
self.update_status();
} }
/// Interrupt: kill processes, abort current turn, clear pending queue. /// Interrupt: kill processes, abort current turn, clear pending queue.
@ -598,12 +575,7 @@ impl Mind {
Some(msg) = input_rx.recv() => { Some(msg) = input_rx.recv() => {
match msg { match msg {
MindMessage::UserInput(input) => { MindMessage::UserInput(input) => self.submit_input(input),
match self.handle_command(&input).await {
Command::Handled => {}
Command::None => self.submit_input(input),
}
}
MindMessage::Hotkey(action) => { MindMessage::Hotkey(action) => {
match action { match action {
HotkeyAction::CycleReasoning => self.cycle_reasoning(), HotkeyAction::CycleReasoning => self.cycle_reasoning(),
@ -622,6 +594,12 @@ impl Mind {
} }
} }
} }
MindMessage::NewSession => self.cmd_new().await,
MindMessage::Score => self.cmd_score(),
MindMessage::DmnQuery => self.cmd_dmn_query(),
MindMessage::DmnSleep => self.cmd_dmn_sleep(),
MindMessage::DmnWake => self.cmd_dmn_wake(),
MindMessage::DmnPause => self.cmd_dmn_pause(),
} }
} }

View file

@ -21,6 +21,12 @@ use crate::user::ui_channel::{self, UiMessage};
pub enum MindMessage { pub enum MindMessage {
UserInput(String), UserInput(String),
Hotkey(HotkeyAction), Hotkey(HotkeyAction),
DmnSleep,
DmnWake,
DmnPause,
DmnQuery,
NewSession,
Score,
} }
fn send_help(ui_tx: &ui_channel::UiSender) { fn send_help(ui_tx: &ui_channel::UiSender) {
@ -232,6 +238,12 @@ pub async fn run(
let _ = ui_tx.send(UiMessage::Info("(busy)".into())); 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" => { "/retry" => {
let agent = agent.clone(); let agent = agent.clone();
let ui_tx = ui_tx.clone(); let ui_tx = ui_tx.clone();