diff --git a/src/cli.rs b/src/cli.rs index 2c3cac3..e65878f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -98,6 +98,20 @@ pub enum ConfigSubcommand { #[arg(help = "Optional key directly passed; if omitted, securely prompted")] key: Option, }, + #[command(about = "Set and persist provider base URL")] + SetUrl { + #[arg( + help = "Provider API base URL (e.g. https://openrouter.ai/api/v1 or http://localhost:11434/v1)" + )] + url: String, + }, + #[command(about = "Set and persist Stage B router model")] + SetRouterModel { + #[arg(help = "Model identifier to use for Stage B classification")] + model: String, + }, + #[command(about = "Display active configuration and credentials status")] + Show, } #[derive(Args, Debug)] diff --git a/src/client.rs b/src/client.rs index c6a87ea..2ce59f3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,6 +3,7 @@ use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fmt; +use std::sync::RwLock; use std::time::Duration; use tokio::sync::mpsc; @@ -38,16 +39,17 @@ pub trait ModelClient: Send + Sync { } pub struct OpenRouterClient { - api_key: String, - base_url: String, + api_key: RwLock, + base_url: RwLock, http: reqwest::Client, } impl fmt::Debug for OpenRouterClient { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let base_url = self.base_url.read().map(|b| b.clone()).unwrap_or_default(); f.debug_struct("OpenRouterClient") .field("api_key", &"[REDACTED]") - .field("base_url", &self.base_url) + .field("base_url", &base_url) .finish() } } @@ -97,12 +99,32 @@ impl OpenRouterClient { .unwrap_or_else(|_| reqwest::Client::new()); Self { - api_key, - base_url, + api_key: RwLock::new(api_key), + base_url: RwLock::new(base_url), http, } } + pub fn set_api_key(&self, key: String) { + if let Ok(mut write) = self.api_key.write() { + *write = key; + } + } + + pub fn set_base_url(&self, url: String) { + if let Ok(mut write) = self.base_url.write() { + *write = url; + } + } + + pub fn get_api_key(&self) -> String { + self.api_key.read().map(|k| k.clone()).unwrap_or_default() + } + + pub fn get_base_url(&self) -> String { + self.base_url.read().map(|b| b.clone()).unwrap_or_default() + } + /// Parses an SSE formatted line into a token delta if available pub fn parse_sse_line(line: &str) -> Option { let trimmed = line.trim(); @@ -175,7 +197,9 @@ impl OpenRouterClient { max_tokens: Option, temperature: Option, ) -> Result { - let endpoint = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + let base_url = self.get_base_url(); + let api_key = self.get_api_key(); + let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/')); let body = ChatCompletionRequest { model, @@ -194,9 +218,8 @@ impl OpenRouterClient { temperature, }; - let mut auth_val = - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key)) - .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")); + let mut auth_val = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key)) + .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")); auth_val.set_sensitive(true); let res = self @@ -245,7 +268,9 @@ impl ModelClient for OpenRouterClient { user: &str, chunk_sender: mpsc::Sender, ) -> Result { - let endpoint = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + let base_url = self.get_base_url(); + let api_key = self.get_api_key(); + let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/')); let body = ChatCompletionRequest { model, @@ -264,9 +289,8 @@ impl ModelClient for OpenRouterClient { temperature: None, }; - let mut auth_val = - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key)) - .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")); + let mut auth_val = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key)) + .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("")); auth_val.set_sensitive(true); let res = self diff --git a/src/config.rs b/src/config.rs index 6095024..cd1e9dd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -168,6 +168,63 @@ impl AppConfig { Ok(()) } + /// Persists the provider base URL into `config.toml` + pub fn save_openrouter_base_url(config_dir: &Path, base_url: &str) -> Result<(), String> { + let config_file_path = config_dir.join("config.toml"); + let mut app_file = if config_file_path.exists() { + let data = fs::read_to_string(&config_file_path) + .map_err(|e| format!("Failed to read {}: {e}", config_file_path.display()))?; + toml::from_str::(&data).unwrap_or_default() + } else { + AppConfigFile::default() + }; + + app_file.openrouter_base_url = base_url.to_string(); + let serialized = toml::to_string_pretty(&app_file) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + + fs::create_dir_all(config_dir).map_err(|e| format!("Failed to create config dir: {e}"))?; + fs::write(&config_file_path, serialized) + .map_err(|e| format!("Failed to write {}: {e}", config_file_path.display()))?; + + Ok(()) + } + + /// Persists the selected router model into `config.toml` + pub fn save_router_model(config_dir: &Path, router_model: &str) -> Result<(), String> { + let config_file_path = config_dir.join("config.toml"); + let mut app_file = if config_file_path.exists() { + let data = fs::read_to_string(&config_file_path) + .map_err(|e| format!("Failed to read {}: {e}", config_file_path.display()))?; + toml::from_str::(&data).unwrap_or_default() + } else { + AppConfigFile::default() + }; + + app_file.router_model = router_model.to_string(); + let serialized = toml::to_string_pretty(&app_file) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + + fs::create_dir_all(config_dir).map_err(|e| format!("Failed to create config dir: {e}"))?; + fs::write(&config_file_path, serialized) + .map_err(|e| format!("Failed to write {}: {e}", config_file_path.display()))?; + + Ok(()) + } + + /// Masks an API key for safe display (e.g. `sk-or-v••••••••`) + pub fn mask_api_key(key: &str) -> String { + let trimmed = key.trim(); + if trimmed.is_empty() { + return "not configured".to_string(); + } + if trimmed.len() <= 8 { + return "••••••••".to_string(); + } + let prefix = &trimmed[..trimmed.len().min(7)]; + format!("{prefix}••••••••") + } + /// Resolves the API key using precedence: /// 1. CLI argument /// 2. `OPENROUTER_API_KEY` environment variable @@ -277,4 +334,29 @@ mod tests { .expect("load config"); assert_eq!(loaded.theme, "dracula"); } + + #[test] + fn test_provider_url_and_router_model_persistence() { + let temp_dir = tempfile::tempdir().unwrap(); + AppConfig::save_openrouter_base_url(temp_dir.path(), "http://localhost:11434/v1") + .expect("save base url"); + AppConfig::save_router_model(temp_dir.path(), "qwen/qwen-2.5-7b:free") + .expect("save router model"); + + let loaded = + AppConfig::load_from_paths(Some(&temp_dir.path().join("config.toml")), None, None) + .expect("load config"); + assert_eq!(loaded.openrouter_base_url, "http://localhost:11434/v1"); + assert_eq!(loaded.router_model, "qwen/qwen-2.5-7b:free"); + } + + #[test] + fn test_mask_api_key() { + assert_eq!(AppConfig::mask_api_key(""), "not configured"); + assert_eq!(AppConfig::mask_api_key("short"), "••••••••"); + assert_eq!( + AppConfig::mask_api_key("sk-or-v1-abcdef123456"), + "sk-or-v••••••••" + ); + } } diff --git a/src/main.rs b/src/main.rs index 7e41225..635205c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -150,6 +150,63 @@ async fn main() -> Result<(), Box> { ); return Ok(()); } + + ConfigSubcommand::SetUrl { url } => { + AppConfig::save_openrouter_base_url(&config.config_dir, &url) + .map_err(|e| format!("Failed to save provider base URL: {e}"))?; + println!( + "{}", + format!( + "✓ Provider base URL updated to '{}' and saved to config.toml", + url + ) + .bright_green() + .bold() + ); + return Ok(()); + } + + ConfigSubcommand::SetRouterModel { model } => { + AppConfig::save_router_model(&config.config_dir, &model) + .map_err(|e| format!("Failed to save router model: {e}"))?; + println!( + "{}", + format!( + "✓ Stage B router model updated to '{}' and saved to config.toml", + model + ) + .bright_green() + .bold() + ); + return Ok(()); + } + + ConfigSubcommand::Show => { + let key_str = config.api_key.as_deref().unwrap_or(""); + let masked = AppConfig::mask_api_key(key_str); + println!("\n{}", "BrokeCrew Configuration:".bold().underline()); + println!( + " Provider Base URL: {}", + config.openrouter_base_url.bright_cyan() + ); + println!( + " Router Model: {}", + config.router_model.bright_white().bold() + ); + println!( + " Default Persona: {}", + config.default_persona.bright_yellow() + ); + println!(" API Key: {}", masked); + println!(" Stealth Allowed: {}", config.allow_stealth); + println!(" Complexity Threshold: {}", config.complexity_threshold); + println!(" Theme: {}", config.theme.bright_magenta()); + println!( + " Config Directory: {}\n", + config.config_dir.display().to_string().dimmed() + ); + return Ok(()); + } }, Some(Commands::Personas(personas_args)) => match personas_args.action { diff --git a/src/router/mod.rs b/src/router/mod.rs index 324d208..959c396 100644 --- a/src/router/mod.rs +++ b/src/router/mod.rs @@ -23,7 +23,7 @@ pub struct RouteDecision { pub struct Router { pub default_persona: String, - pub router_model: String, + router_model: std::sync::RwLock, pub complexity_threshold: u8, } @@ -31,11 +31,24 @@ impl Router { pub fn new(default_persona: String, router_model: String, complexity_threshold: u8) -> Self { Self { default_persona, - router_model, + router_model: std::sync::RwLock::new(router_model), complexity_threshold, } } + pub fn set_router_model(&self, model: String) { + if let Ok(mut write) = self.router_model.write() { + *write = model; + } + } + + pub fn get_router_model(&self) -> String { + self.router_model + .read() + .map(|m| m.clone()) + .unwrap_or_default() + } + pub async fn route( &self, registry: &PersonaRegistry, @@ -58,14 +71,10 @@ impl Router { // Stage B: LLM classification fallback let known: Vec = registry.list().iter().map(|p| p.name.clone()).collect(); - let selected = LlmRouter::classify( - client, - &self.router_model, - &known, - &self.default_persona, - prompt, - ) - .await?; + let router_model = self.get_router_model(); + let selected = + LlmRouter::classify(client, &router_model, &known, &self.default_persona, prompt) + .await?; Ok(RouteDecision { persona: selected, diff --git a/src/tui/app.rs b/src/tui/app.rs index 7425d65..5fa7954 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -52,7 +52,23 @@ pub struct CommandSuggestion { pub const ALL_COMMANDS: &[CommandSuggestion] = &[ CommandSuggestion { name: "/settings", - description: "Open settings menu", + description: "Open settings and feature menu", + }, + CommandSuggestion { + name: "/provider", + description: "View or change LLM provider base URL", + }, + CommandSuggestion { + name: "/key", + description: "View or update OpenRouter API key", + }, + CommandSuggestion { + name: "/model", + description: "View or set Stage B router model", + }, + CommandSuggestion { + name: "/models", + description: "Search live cached models catalog", }, CommandSuggestion { name: "/session", @@ -70,6 +86,14 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[ name: "/usage", description: "View rate limit and budget usage counters", }, + CommandSuggestion { + name: "/stats", + description: "Detailed usage and token analytics", + }, + CommandSuggestion { + name: "/export", + description: "Export current chat to Markdown or JSON", + }, CommandSuggestion { name: "/config", description: "View active system configuration", @@ -83,12 +107,12 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[ description: "Toggle complexity escalation", }, CommandSuggestion { - name: "/personas", - description: "Open persona selector modal", + name: "/persona", + description: "Select active persona or pass name", }, CommandSuggestion { - name: "/pipelines", - description: "Open pipeline selector modal", + name: "/pipeline", + description: "Select active pipeline or pass name", }, CommandSuggestion { name: "/clear", @@ -569,6 +593,94 @@ impl AppState { self.log_working_event("[session] started fresh session"); } + /// Exports active conversation to Markdown file in `~/.config/brokecrew/exports/` + pub fn export_active_session_md(&self) -> Result { + let export_dir = self.config_dir.join("exports"); + std::fs::create_dir_all(&export_dir)?; + + let filename = format!("{}.md", self.active_session.id); + let path = export_dir.join(filename); + + let mut content = format!("# BrokeCrew Session: {}\n", self.active_session.title); + content.push_str(&format!("- **ID:** `{}`\n", self.active_session.id)); + content.push_str(&format!( + "- **Created:** {}\n", + self.active_session.created_at + )); + content.push_str(&format!( + "- **Updated:** {}\n\n---\n\n", + self.active_session.updated_at + )); + + for msg in &self.messages { + match &msg.sender { + MessageSender::User => { + content.push_str(&format!("### User\n\n{}\n\n", msg.content.trim())); + } + MessageSender::Persona { name, model, tier } => { + let tier_s = match tier { + Tier::Free => "free", + Tier::Paid => "paid", + }; + content.push_str(&format!( + "### Assistant ({} • {} [{}])\n\n{}\n\n", + name, + model, + tier_s, + msg.content.trim() + )); + } + MessageSender::PipelineStep { + step_index, + total_steps, + persona, + model, + .. + } => { + content.push_str(&format!( + "### Pipeline Step {}/{}: {} ({})\n\n{}\n\n", + step_index + 1, + total_steps, + persona, + model, + msg.content.trim() + )); + } + MessageSender::Tool { + command, + exit_code, + duration_ms, + } => { + let dur_s = (*duration_ms as f64) / 1000.0; + content.push_str(&format!( + "```bash\n$ {command}\n# exit {exit_code} ({dur_s:.1}s)\n{}\n```\n\n", + msg.content.trim() + )); + } + MessageSender::System => { + content.push_str(&format!("> *System: {}*\n\n", msg.content.trim())); + } + } + } + + std::fs::write(&path, content)?; + Ok(path) + } + + /// Exports active conversation to JSON file in `~/.config/brokecrew/exports/` + pub fn export_active_session_json(&self) -> Result { + let export_dir = self.config_dir.join("exports"); + std::fs::create_dir_all(&export_dir)?; + + let filename = format!("{}.json", self.active_session.id); + let path = export_dir.join(filename); + + let json = serde_json::to_string_pretty(&self.active_session) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&path, json)?; + Ok(path) + } + pub fn clear_chat(&mut self) { self.messages.clear(); self.add_system_message("Chat history cleared.".to_string()); @@ -701,4 +813,29 @@ mod tests { app.cycle_theme(); assert_eq!(app.current_theme, "tokyo_night"); } + + #[test] + fn test_app_export() { + let temp_dir = tempfile::tempdir().unwrap(); + let mut app = AppState::new( + vec![], + vec![], + 20, + 50, + temp_dir.path().to_path_buf(), + "opencode".to_string(), + ); + app.add_user_message("Hello from user".to_string()); + app.start_assistant_message("coder", "qwen-coder", Tier::Free); + app.append_to_current_message("Hello from coder"); + + let md_path = app.export_active_session_md().expect("export md"); + assert!(md_path.exists()); + let md_content = std::fs::read_to_string(&md_path).unwrap(); + assert!(md_content.contains("Hello from user")); + assert!(md_content.contains("Hello from coder")); + + let json_path = app.export_active_session_json().expect("export json"); + assert!(json_path.exists()); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8493b75..a9ba236 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -193,7 +193,8 @@ pub async fn run_tui( &client, &router, worker_tx.clone(), - ); + ) + .await; if should_quit { break; } @@ -444,7 +445,7 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e } } -fn handle_key_event( +async fn handle_key_event( app: &mut AppState, key: KeyEvent, config: &AppConfig, @@ -645,7 +646,15 @@ fn handle_key_event( app.input.clear(); app.cursor_pos = 0; app.selected_suggestion_idx = 0; - handle_slash_command(app, config, &cmd); + handle_slash_command( + app, + config, + client, + router, + model_registry, + &cmd, + ) + .await; return false; } } @@ -693,7 +702,15 @@ fn handle_key_event( app.selected_suggestion_idx = 0; if prompt.starts_with('/') { - handle_slash_command(app, config, &prompt); + handle_slash_command( + app, + config, + client, + router, + model_registry, + &prompt, + ) + .await; return false; } @@ -729,7 +746,14 @@ fn handle_key_event( false } -fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { +async fn handle_slash_command( + app: &mut AppState, + config: &AppConfig, + client: &Arc, + router: &Arc, + model_registry: &Arc, + input: &str, +) { let parts: Vec<&str> = input.split_whitespace().collect(); let cmd = parts.first().copied().unwrap_or(""); @@ -742,33 +766,175 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { app.selected_session_idx = 0; app.mode = TuiMode::SessionSelect; } - "/config" => { - let key_status = if config.api_key.is_some() { - "configured" + "/provider" => { + if parts.len() >= 3 && parts[1].eq_ignore_ascii_case("set") { + let target = parts[2]; + let target_lower = target.to_lowercase(); + let resolved_url = match target_lower.as_str() { + "openrouter" | "default" => "https://openrouter.ai/api/v1", + "ollama" => "http://localhost:11434/v1", + "vllm" | "local" => "http://localhost:8000/v1", + _ => target, + }; + client.set_base_url(resolved_url.to_string()); + let _ = AppConfig::save_openrouter_base_url(&app.config_dir, resolved_url); + + let reg_clone = Arc::clone(model_registry); + let url_string = resolved_url.to_string(); + tokio::spawn(async move { + let _ = reg_clone.fetch_live(&url_string).await; + }); + + app.log_working_event(&format!("[provider] endpoint switched to {resolved_url}")); + app.add_system_message(format!( + "Provider base URL updated to: {resolved_url}\nSaved to config.toml. Syncing model catalog..." + )); } else { - "not configured" + let current_url = client.get_base_url(); + app.add_system_message(format!( + "Provider Endpoint Status:\n Active URL: {current_url}\n\nTo change provider:\n /provider set \n\nBuilt-in presets:\n /provider set openrouter -> https://openrouter.ai/api/v1\n /provider set ollama -> http://localhost:11434/v1\n /provider set local -> http://localhost:8000/v1" + )); + } + } + "/key" => { + if parts.len() >= 2 { + let new_key = parts[1].trim().to_string(); + if new_key.is_empty() { + app.add_system_message("Error: API key cannot be empty.".to_string()); + } else { + client.set_api_key(new_key.clone()); + let _ = AppConfig::save_api_key_to_keyring(&new_key); + let masked = AppConfig::mask_api_key(&new_key); + app.log_working_event("[key] API key updated"); + app.add_system_message(format!( + "API key updated to {masked} and saved securely in OS credential store." + )); + } + } else { + let current_key = client.get_api_key(); + let masked = AppConfig::mask_api_key(¤t_key); + app.add_system_message(format!( + "OpenRouter API Key Status:\n Active Key: {masked}\n\nTo update:\n /key " + )); + } + } + "/model" => { + if parts.len() >= 2 { + let new_model = parts[1].trim().to_string(); + router.set_router_model(new_model.clone()); + let _ = AppConfig::save_router_model(&app.config_dir, &new_model); + app.log_working_event(&format!("[router] model set to {new_model}")); + app.add_system_message(format!( + "Stage B router model updated to: {new_model}\nSaved to config.toml." + )); + } else { + let current_model = router.get_router_model(); + app.add_system_message(format!( + "Stage B Router Model:\n Active Model: {current_model}\n\nTo change:\n /model " + )); + } + } + "/models" => { + let query = if parts.len() >= 2 { + parts[1..].join(" ").to_lowercase() + } else { + String::new() }; + + let count = app.models_cached; + if count == 0 { + app.add_system_message( + "Model catalog empty or still syncing from provider... Try again in a moment." + .to_string(), + ); + } else { + let free_models = model_registry + .discover_free_candidates_for_role(&query) + .await; + if free_models.is_empty() { + app.add_system_message(format!( + "No cached models matching query '{query}'. (Total cached in catalog: {count})" + )); + } else { + let display_models: Vec = free_models + .iter() + .take(12) + .map(|m| format!(" • {}", m.id)) + .collect(); + let total_found = free_models.len(); + app.add_system_message(format!( + "Available Free Models for '{}' ({}/{}):\n{}\n\nUse any model ID via /model ", + if query.is_empty() { "all" } else { &query }, + display_models.len(), + total_found, + display_models.join("\n") + )); + } + } + } + "/export" => { + let format = parts.get(1).copied().unwrap_or("md").to_lowercase(); + match format.as_str() { + "json" => match app.export_active_session_json() { + Ok(path) => { + app.add_system_message(format!( + "✓ Session exported to JSON:\n {}", + path.display() + )); + app.log_working_event(&format!("[export] saved to {}", path.display())); + } + Err(e) => { + app.add_system_message(format!("! Failed to export session to JSON: {e}")); + } + }, + _ => match app.export_active_session_md() { + Ok(path) => { + app.add_system_message(format!( + "✓ Session exported to Markdown:\n {}", + path.display() + )); + app.log_working_event(&format!("[export] saved to {}", path.display())); + } + Err(e) => { + app.add_system_message(format!( + "! Failed to export session to Markdown: {e}" + )); + } + }, + } + } + "/config" => { + let key = client.get_api_key(); + let masked = AppConfig::mask_api_key(&key); app.add_system_message(format!( - "Config Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Base URL: {}\n Config Dir: {}", - config.router_model, + "Config Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Provider Base URL: {}\n Config Dir: {}", + router.get_router_model(), config.default_persona, app.allow_stealth, app.escalate, - key_status, - config.openrouter_base_url, + masked, + client.get_base_url(), config.config_dir.display() )); } - "/usage" => { + "/usage" | "/stats" => { + let min_pct = (app.minute_requests * 100) / app.minute_limit.max(1); + let day_pct = (app.daily_requests * 100) / app.daily_limit.max(1); + let total_tokens = app.tokens_in + app.tokens_out; app.add_system_message(format!( - "Rate Limit & Budget Usage:\n 1-Minute Rate: {}/{} requests\n 24-Hour Budget: {}/{} requests\n Tokens In: {} (est)\n Tokens Out: {} (est)\n Cached Models: {} available", + "Rate Limit & Token Telemetry:\n • Rate Limit (1m): {}/{} requests ({}%)\n • Daily Budget (24h): {}/{} requests ({}%)\n • Tokens In: {} (est)\n • Tokens Out: {} (est)\n • Total Tokens: {} (est)\n • Models Cached: {} online\n • Provider Base: {}\n • Active Theme: {}", app.minute_requests, app.minute_limit, + min_pct, app.daily_requests, app.daily_limit, + day_pct, app::AppState::format_tokens(app.tokens_in), app::AppState::format_tokens(app.tokens_out), - app.models_cached + app::AppState::format_tokens(total_tokens), + app.models_cached, + client.get_base_url(), + app.current_theme )); } "/theme" => { @@ -825,14 +991,57 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { app.clear_chat(); } "/persona" | "/personas" => { - app.mode = TuiMode::PersonaSelect; + if let Some(target) = parts.get(1) { + let target_lower = target.to_lowercase(); + if let Some(pos) = app + .persona_items + .iter() + .position(|p| p.name.to_lowercase() == target_lower) + { + app.selected_persona_idx = pos; + app.active_pipeline_for_prompt = None; + let persona_name = app.current_persona_name().to_string(); + app.add_system_message(format!("Switched active persona to: {persona_name}")); + app.log_working_event(&format!("[persona] switched to {persona_name}")); + } else { + let available: Vec = + app.persona_items.iter().map(|p| p.name.clone()).collect(); + app.add_system_message(format!( + "Persona '{target}' not found. Available personas:\n {}", + available.join(", ") + )); + } + } else { + app.mode = TuiMode::PersonaSelect; + } } "/pipeline" | "/pipelines" => { - app.mode = TuiMode::PipelineSelect; + if let Some(target) = parts.get(1) { + let target_lower = target.to_lowercase(); + if let Some(pipe) = app + .pipelines + .iter() + .find(|p| p.name.to_lowercase() == target_lower) + { + app.active_pipeline_for_prompt = Some(pipe.name.clone()); + let pipe_name = pipe.name.clone(); + app.add_system_message(format!("Selected multi-agent pipeline: {pipe_name}")); + app.log_working_event(&format!("[pipeline] selected {pipe_name}")); + } else { + let available: Vec = + app.pipelines.iter().map(|p| p.name.clone()).collect(); + app.add_system_message(format!( + "Pipeline '{target}' not found. Available pipelines:\n {}", + available.join(", ") + )); + } + } else { + app.mode = TuiMode::PipelineSelect; + } } "/help" => { app.add_system_message( - "Available Commands:\n /new - Start a fresh session\n /session - Open session switcher modal\n /config - View active system configuration\n /usage - View budget and rate-limit counters\n /theme - Cycle color theme (default, warm, forest, mono)\n /settings - View and adjust runtime settings\n /stealth - Toggle stealth model eligibility\n /escalate - Toggle complexity escalation\n /persona - Open persona selection modal\n /pipeline - Open pipeline selection modal\n /clear - Clear chat screen\n /help - Show this help summary" + "BrokeCrew Commands Reference:\n\n Session & Workflows:\n /new - Start a fresh chat session\n /session - Switch, resume, or delete saved sessions\n /export [md|json] - Export active conversation to disk\n /clear - Clear chat screen\n\n Providers & Models:\n /provider [set ] - View or change endpoint (openrouter, ollama, local)\n /key [new-key] - View key status or update API key securely\n /model [model-id] - View or change Stage B router model\n /models [query] - Search live cached catalog for free models\n\n Personas & Pipelines:\n /persona [name] - Select persona (or open interactive picker)\n /pipeline [name] - Select pipeline (or open interactive picker)\n /stealth - Toggle experimental stealth models\n /escalate - Toggle automatic complexity escalation\n\n Telemetry & Settings:\n /usage, /stats - Rate limits, token usage, and live budget\n /theme [next|name] - Interactive color theme selector\n /config - View active system configuration\n /settings - Display runtime feature toggles\n /help - Display this reference" .to_string(), ); } @@ -1003,7 +1212,7 @@ fn spawn_dispatch_task( let d = match decision.stage_used { RouteStage::StageAKeyword => "Matched keyword in prompt".to_string(), RouteStage::StageBLlm => { - format!("LLM classifier (model: {})", router.router_model) + format!("LLM classifier (model: {})", router.get_router_model()) } }; (