move UI commands from Mind to event_loop

/quit, /help, /save handled directly in the UI event loop.
/model and /model <name> moved to event_loop as cmd_switch_model().
Mind no longer needs tui::App for any command handling.

Mind's handle_command now only has commands that genuinely need
Mind state: /new, /retry, /score (turn_in_progress, DMN, scoring).

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 02:29:44 -04:00
parent 804d55a702
commit 178824fa01
3 changed files with 111 additions and 107 deletions

View file

@ -90,8 +90,8 @@ pub struct Agent {
pub context: ContextState, pub context: ContextState,
/// Shared live context summary — TUI reads this directly for debug screen. /// Shared live context summary — TUI reads this directly for debug screen.
pub shared_context: SharedContextState, pub shared_context: SharedContextState,
/// App config — used to reload identity on compaction. /// App config — used to reload identity on compaction and model switching.
app_config: crate::config::AppConfig, pub app_config: crate::config::AppConfig,
pub prompt_file: String, pub prompt_file: String,
/// Stable session ID for memory-search dedup across turns. /// Stable session ID for memory-search dedup across turns.
pub session_id: String, pub session_id: String,

View file

@ -183,7 +183,9 @@ impl Mind {
)); ));
} }
if let Some(model_name) = turn_result.model_switch { if let Some(model_name) = turn_result.model_switch {
self.switch_model(&model_name).await; crate::user::event_loop::cmd_switch_model(
&self.agent, &model_name, &self.ui_tx,
).await;
} }
} }
Err(e) => { Err(e) => {
@ -329,28 +331,7 @@ impl Mind {
/// Handle slash commands. Returns how the main loop should respond. /// Handle slash commands. Returns how the main loop should respond.
async fn handle_command(&mut self, input: &str) -> Command { async fn handle_command(&mut self, input: &str) -> Command {
const COMMANDS: &[(&str, &str)] = &[
("/quit", "Exit consciousness"),
("/new", "Start fresh session (saves current)"),
("/save", "Save session to disk"),
("/retry", "Re-run last turn"),
("/model", "Show/switch model (/model <name>)"),
("/score", "Score memory importance"),
("/dmn", "Show DMN state"),
("/sleep", "Put DMN to sleep"),
("/wake", "Wake DMN to foraging"),
("/pause", "Full stop — no autonomous ticks (Ctrl+P)"),
("/test", "Run tool smoke tests"),
("/help", "Show this help"),
];
match input { match input {
"/save" => {
let _ = self.ui_tx.send(UiMessage::Info(
"Conversation is saved automatically (append-only log).".into()
));
Command::Handled
}
"/new" | "/clear" => { "/new" | "/clear" => {
if self.turn_in_progress { if self.turn_in_progress {
let _ = self.ui_tx.send(UiMessage::Info("(turn in progress, please wait)".into())); let _ = self.ui_tx.send(UiMessage::Info("(turn in progress, please wait)".into()));
@ -378,18 +359,6 @@ impl Mind {
let _ = self.ui_tx.send(UiMessage::Info("New session started.".into())); let _ = self.ui_tx.send(UiMessage::Info("New session started.".into()));
Command::Handled Command::Handled
} }
"/model" => {
if let Ok(agent) = self.agent.try_lock() {
let _ = self.ui_tx.send(UiMessage::Info(format!("Current model: {}", agent.model())));
let names = self.config.app.model_names();
if !names.is_empty() {
let _ = self.ui_tx.send(UiMessage::Info(format!("Available: {}", names.join(", "))));
}
} else {
let _ = self.ui_tx.send(UiMessage::Info("(busy)".into()));
}
Command::Handled
}
"/score" => { "/score" => {
if self.scoring_in_flight { if self.scoring_in_flight {
let _ = self.ui_tx.send(UiMessage::Info("(scoring already in progress)".into())); let _ = self.ui_tx.send(UiMessage::Info("(scoring already in progress)".into()));
@ -486,31 +455,6 @@ impl Mind {
} }
Command::Handled Command::Handled
} }
"/help" => {
for (name, desc) in COMMANDS {
let _ = self.ui_tx.send(UiMessage::Info(format!(" {:12} {}", name, desc)));
}
let _ = self.ui_tx.send(UiMessage::Info(String::new()));
let _ = self.ui_tx.send(UiMessage::Info(
"Keys: Tab=pane ^Up/Down=scroll PgUp/PgDn=scroll Mouse=click/scroll".into(),
));
let _ = self.ui_tx.send(UiMessage::Info(
" Alt+Enter=newline Esc=interrupt ^P=pause ^R=reasoning ^K=kill F10=context F2=agents".into(),
));
let _ = self.ui_tx.send(UiMessage::Info(
" Shift+click for native text selection (copy/paste)".into(),
));
Command::Handled
}
cmd if cmd.starts_with("/model ") => {
let name = cmd[7..].trim();
if name.is_empty() {
let _ = self.ui_tx.send(UiMessage::Info("Usage: /model <name>".into()));
return Command::Handled;
}
self.switch_model(name).await;
Command::Handled
}
_ => Command::None, _ => Command::None,
} }
} }
@ -609,49 +553,6 @@ impl Mind {
} }
/// Switch to a named model from the config registry. /// Switch to a named model from the config registry.
async fn switch_model(&mut self, name: &str) {
if self.turn_in_progress {
let _ = self.ui_tx.send(UiMessage::Info("(turn in progress, please wait)".into()));
return;
}
let resolved = match self.config.app.resolve_model(name) {
Ok(r) => r,
Err(e) => {
let _ = self.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 = resolved.prompt_file != self.config.prompt_file;
let mut agent_guard = self.agent.lock().await;
agent_guard.swap_client(new_client);
self.config.model = resolved.model_id.clone();
self.config.api_base = resolved.api_base;
self.config.api_key = resolved.api_key;
if prompt_changed {
self.config.prompt_file = resolved.prompt_file.clone();
agent_guard.prompt_file = resolved.prompt_file.clone();
agent_guard.compact();
let _ = self.ui_tx.send(UiMessage::Info(format!(
"Switched to {} ({}) — prompt: {}, recompacted",
name, resolved.model_id, resolved.prompt_file,
)));
} else {
let _ = self.ui_tx.send(UiMessage::Info(format!(
"Switched to {} ({})", name, resolved.model_id,
)));
}
drop(agent_guard);
self.update_status();
self.send_context_info();
}
fn load_context_groups(&self) -> Vec<config::ContextGroup> { fn load_context_groups(&self) -> Vec<config::ContextGroup> {
config::get().context_groups.clone() config::get().context_groups.clone()
} }
@ -859,15 +760,14 @@ pub async fn run(cli: crate::user::CliArgs) -> Result<()> {
// App for TUI // App for TUI
let app = tui::App::new(mind.config.model.clone(), shared_context, shared_active_tools); let app = tui::App::new(mind.config.model.clone(), shared_context, shared_active_tools);
let ui_agent = mind.agent.clone();
// Spawn Mind event loop // Spawn Mind event loop
tokio::spawn(async move { tokio::spawn(async move {
mind.run(mind_rx, turn_rx).await; mind.run(mind_rx, turn_rx).await;
}); });
// Run UI event loop on main thread (needs terminal access)
crate::user::event_loop::run( crate::user::event_loop::run(
app, mind_tx, ui_rx, observe_input_rx, app, ui_agent, mind_tx, ui_tx, ui_rx, observe_input_rx,
channel_tx, channel_rx, notify_rx, idle_state, channel_tx, channel_rx, notify_rx, idle_state,
).await ).await
} }

View file

@ -7,8 +7,13 @@
use anyhow::Result; use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyEventKind}; use crossterm::event::{Event, EventStream, KeyEventKind};
use futures::StreamExt; use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex;
use crate::agent::Agent;
use crate::agent::api::ApiClient;
use crate::config::SessionConfig;
use crate::user::{self as tui, HotkeyAction}; use crate::user::{self as tui, HotkeyAction};
use crate::user::ui_channel::{self, UiMessage}; use crate::user::ui_channel::{self, UiMessage};
@ -18,9 +23,79 @@ pub enum MindMessage {
Hotkey(HotkeyAction), Hotkey(HotkeyAction),
} }
fn send_help(ui_tx: &ui_channel::UiSender) {
let commands = &[
("/quit", "Exit consciousness"),
("/new", "Start fresh session (saves current)"),
("/save", "Save session to disk"),
("/retry", "Re-run last turn"),
("/model", "Show/switch model (/model <name>)"),
("/score", "Score memory importance"),
("/dmn", "Show DMN state"),
("/sleep", "Put DMN to sleep"),
("/wake", "Wake DMN to foraging"),
("/pause", "Full stop — no autonomous ticks (Ctrl+P)"),
("/help", "Show this help"),
];
for (name, desc) in commands {
let _ = ui_tx.send(UiMessage::Info(format!(" {:12} {}", name, desc)));
}
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(),
));
}
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,
)));
}
}
pub async fn run( pub async fn run(
mut app: tui::App, mut app: tui::App,
agent: Arc<Mutex<Agent>>,
mind_tx: tokio::sync::mpsc::UnboundedSender<MindMessage>, mind_tx: tokio::sync::mpsc::UnboundedSender<MindMessage>,
ui_tx: ui_channel::UiSender,
mut ui_rx: ui_channel::UiReceiver, mut ui_rx: ui_channel::UiReceiver,
mut observe_input_rx: tokio::sync::mpsc::UnboundedReceiver<String>, mut observe_input_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
channel_tx: tokio::sync::mpsc::Sender<Vec<(String, bool, u32)>>, channel_tx: tokio::sync::mpsc::Sender<Vec<(String, bool, u32)>>,
@ -111,6 +186,35 @@ pub async fn run(
if input.is_empty() { continue; } if input.is_empty() { continue; }
match input.as_str() { match input.as_str() {
"/quit" | "/exit" => app.should_quit = true, "/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()));
}
}
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 _ = mind_tx.send(MindMessage::UserInput(input)); } _ => { let _ = mind_tx.send(MindMessage::UserInput(input)); }
} }
} }