diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 97e1aa8..ea3f614 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -90,8 +90,8 @@ pub struct Agent { pub context: ContextState, /// Shared live context summary — TUI reads this directly for debug screen. pub shared_context: SharedContextState, - /// App config — used to reload identity on compaction. - app_config: crate::config::AppConfig, + /// App config — used to reload identity on compaction and model switching. + pub app_config: crate::config::AppConfig, pub prompt_file: String, /// Stable session ID for memory-search dedup across turns. pub session_id: String, diff --git a/src/mind/mod.rs b/src/mind/mod.rs index a023d2a..d78e85b 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -183,7 +183,9 @@ impl Mind { )); } 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) => { @@ -329,28 +331,7 @@ impl Mind { /// Handle slash commands. Returns how the main loop should respond. 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 )"), - ("/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 { - "/save" => { - let _ = self.ui_tx.send(UiMessage::Info( - "Conversation is saved automatically (append-only log).".into() - )); - Command::Handled - } "/new" | "/clear" => { if self.turn_in_progress { 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())); 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" => { if self.scoring_in_flight { let _ = self.ui_tx.send(UiMessage::Info("(scoring already in progress)".into())); @@ -486,31 +455,6 @@ impl Mind { } 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 ".into())); - return Command::Handled; - } - self.switch_model(name).await; - Command::Handled - } _ => Command::None, } } @@ -609,49 +553,6 @@ impl Mind { } /// 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::get().context_groups.clone() } @@ -859,15 +760,14 @@ pub async fn run(cli: crate::user::CliArgs) -> Result<()> { // App for TUI let app = tui::App::new(mind.config.model.clone(), shared_context, shared_active_tools); + let ui_agent = mind.agent.clone(); // Spawn Mind event loop tokio::spawn(async move { mind.run(mind_rx, turn_rx).await; }); - - // Run UI event loop on main thread (needs terminal access) 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, ).await } diff --git a/src/user/event_loop.rs b/src/user/event_loop.rs index 7c465e5..eaf1374 100644 --- a/src/user/event_loop.rs +++ b/src/user/event_loop.rs @@ -7,8 +7,13 @@ use anyhow::Result; use crossterm::event::{Event, EventStream, KeyEventKind}; use futures::StreamExt; +use std::sync::Arc; 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::ui_channel::{self, UiMessage}; @@ -18,9 +23,79 @@ pub enum MindMessage { 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 )"), + ("/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>, + 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( mut app: tui::App, + agent: Arc>, mind_tx: tokio::sync::mpsc::UnboundedSender, + ui_tx: ui_channel::UiSender, mut ui_rx: ui_channel::UiReceiver, mut observe_input_rx: tokio::sync::mpsc::UnboundedReceiver, channel_tx: tokio::sync::mpsc::Sender>, @@ -111,6 +186,35 @@ pub async fn run( 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())); + } + } + 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 _ = mind_tx.send(MindMessage::UserInput(input)); } } }