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:
parent
3c4220c079
commit
7458fe655f
1 changed files with 130 additions and 127 deletions
|
|
@ -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,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 <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();
|
||||
}
|
||||
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<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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue