feat: add provider, key, model, models, export, and stats commands with CLI config helpers

This commit is contained in:
katR 2026-09-29 22:18:41 +05:30
parent 659de0dc04
commit e41a641c94
7 changed files with 580 additions and 48 deletions

View file

@ -98,6 +98,20 @@ pub enum ConfigSubcommand {
#[arg(help = "Optional key directly passed; if omitted, securely prompted")]
key: Option<String>,
},
#[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)]

View file

@ -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<String>,
base_url: RwLock<String>,
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<String> {
let trimmed = line.trim();
@ -175,7 +197,9 @@ impl OpenRouterClient {
max_tokens: Option<u32>,
temperature: Option<f32>,
) -> Result<String, ClientError> {
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,8 +218,7 @@ impl OpenRouterClient {
temperature,
};
let mut auth_val =
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key))
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);
@ -245,7 +268,9 @@ impl ModelClient for OpenRouterClient {
user: &str,
chunk_sender: mpsc::Sender<String>,
) -> Result<String, ClientError> {
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,8 +289,7 @@ impl ModelClient for OpenRouterClient {
temperature: None,
};
let mut auth_val =
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key))
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);

View file

@ -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::<AppConfigFile>(&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::<AppConfigFile>(&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••••••••"
);
}
}

View file

@ -150,6 +150,63 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
);
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 {

View file

@ -23,7 +23,7 @@ pub struct RouteDecision {
pub struct Router {
pub default_persona: String,
pub router_model: String,
router_model: std::sync::RwLock<String>,
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,13 +71,9 @@ impl Router {
// Stage B: LLM classification fallback
let known: Vec<String> = registry.list().iter().map(|p| p.name.clone()).collect();
let selected = LlmRouter::classify(
client,
&self.router_model,
&known,
&self.default_persona,
prompt,
)
let router_model = self.get_router_model();
let selected =
LlmRouter::classify(client, &router_model, &known, &self.default_persona, prompt)
.await?;
Ok(RouteDecision {

View file

@ -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<PathBuf, std::io::Error> {
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<PathBuf, std::io::Error> {
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());
}
}

View file

@ -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<OpenRouterClient>,
router: &Arc<Router>,
model_registry: &Arc<ModelRegistry>,
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"
} else {
"not 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!(
"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,
"Provider base URL updated to: {resolved_url}\nSaved to config.toml. Syncing model catalog..."
));
} else {
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 <url|preset>\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(&current_key);
app.add_system_message(format!(
"OpenRouter API Key Status:\n Active Key: {masked}\n\nTo update:\n /key <new-api-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 <model-identifier>"
));
}
}
"/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<String> = 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 <id>",
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 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" => {
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<String> =
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" => {
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<String> =
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 <url|preset>] - 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())
}
};
(