From 7458fe655ff3b19e03945a2754af2fe1ffddba61 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 5 Apr 2026 16:56:23 -0400 Subject: [PATCH] event_loop: command table with inline closures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/user/event_loop.rs | 257 +++++++++++++++++++++-------------------- 1 file changed, 130 insertions(+), 127 deletions(-) diff --git a/src/user/event_loop.rs b/src/user/event_loop.rs index e1998f8..3986676 100644 --- a/src/user/event_loop.rs +++ b/src/user/event_loop.rs @@ -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, + &'a ui_channel::UiSender, + &'a str, +); + struct SlashCommand { name: &'static str, help: &'static str, + handler: CmdHandler, } -fn commands() -> Vec { - 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 )" }, - 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 { 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 )", + 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 { + 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>, mind_tx: &tokio::sync::mpsc::UnboundedSender, ui_tx: &ui_channel::UiSender, @@ -122,8 +188,8 @@ async fn cmd_retry( } } -fn cmd_cycle_reasoning(agent: &Arc>, 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>, ui_tx: &ui_channel::UiSender) } } -async fn cmd_kill_processes(agent: &Arc>, 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>, ui_tx: &ui_channel::UiSen } } -fn cmd_adjust_sampling(agent: &Arc>, 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,80 +483,15 @@ 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(", ")))); - } - } 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 ".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(); - } + 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 mut s = shared_mind.lock().unwrap(); + diff_mind_state(&s, &prev_mind, &ui_tx, &mut dirty); + s.input.push(input); + prev_mind = s.clone(); } } @@ -471,36 +499,11 @@ pub async fn run( let actions: Vec = 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), } }