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")] #[arg(help = "Optional key directly passed; if omitted, securely prompted")]
key: Option<String>, 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)] #[derive(Args, Debug)]

View file

@ -3,6 +3,7 @@ use futures_util::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::fmt; use std::fmt;
use std::sync::RwLock;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@ -38,16 +39,17 @@ pub trait ModelClient: Send + Sync {
} }
pub struct OpenRouterClient { pub struct OpenRouterClient {
api_key: String, api_key: RwLock<String>,
base_url: String, base_url: RwLock<String>,
http: reqwest::Client, http: reqwest::Client,
} }
impl fmt::Debug for OpenRouterClient { impl fmt::Debug for OpenRouterClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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") f.debug_struct("OpenRouterClient")
.field("api_key", &"[REDACTED]") .field("api_key", &"[REDACTED]")
.field("base_url", &self.base_url) .field("base_url", &base_url)
.finish() .finish()
} }
} }
@ -97,12 +99,32 @@ impl OpenRouterClient {
.unwrap_or_else(|_| reqwest::Client::new()); .unwrap_or_else(|_| reqwest::Client::new());
Self { Self {
api_key, api_key: RwLock::new(api_key),
base_url, base_url: RwLock::new(base_url),
http, 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 /// Parses an SSE formatted line into a token delta if available
pub fn parse_sse_line(line: &str) -> Option<String> { pub fn parse_sse_line(line: &str) -> Option<String> {
let trimmed = line.trim(); let trimmed = line.trim();
@ -175,7 +197,9 @@ impl OpenRouterClient {
max_tokens: Option<u32>, max_tokens: Option<u32>,
temperature: Option<f32>, temperature: Option<f32>,
) -> Result<String, ClientError> { ) -> 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 { let body = ChatCompletionRequest {
model, model,
@ -194,9 +218,8 @@ impl OpenRouterClient {
temperature, temperature,
}; };
let mut auth_val = let mut auth_val = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key)) .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""));
.unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""));
auth_val.set_sensitive(true); auth_val.set_sensitive(true);
let res = self let res = self
@ -245,7 +268,9 @@ impl ModelClient for OpenRouterClient {
user: &str, user: &str,
chunk_sender: mpsc::Sender<String>, chunk_sender: mpsc::Sender<String>,
) -> Result<String, ClientError> { ) -> 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 { let body = ChatCompletionRequest {
model, model,
@ -264,9 +289,8 @@ impl ModelClient for OpenRouterClient {
temperature: None, temperature: None,
}; };
let mut auth_val = let mut auth_val = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.api_key)) .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""));
.unwrap_or_else(|_| reqwest::header::HeaderValue::from_static(""));
auth_val.set_sensitive(true); auth_val.set_sensitive(true);
let res = self let res = self

View file

@ -168,6 +168,63 @@ impl AppConfig {
Ok(()) 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: /// Resolves the API key using precedence:
/// 1. CLI argument /// 1. CLI argument
/// 2. `OPENROUTER_API_KEY` environment variable /// 2. `OPENROUTER_API_KEY` environment variable
@ -277,4 +334,29 @@ mod tests {
.expect("load config"); .expect("load config");
assert_eq!(loaded.theme, "dracula"); 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(()); 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 { Some(Commands::Personas(personas_args)) => match personas_args.action {

View file

@ -23,7 +23,7 @@ pub struct RouteDecision {
pub struct Router { pub struct Router {
pub default_persona: String, pub default_persona: String,
pub router_model: String, router_model: std::sync::RwLock<String>,
pub complexity_threshold: u8, pub complexity_threshold: u8,
} }
@ -31,11 +31,24 @@ impl Router {
pub fn new(default_persona: String, router_model: String, complexity_threshold: u8) -> Self { pub fn new(default_persona: String, router_model: String, complexity_threshold: u8) -> Self {
Self { Self {
default_persona, default_persona,
router_model, router_model: std::sync::RwLock::new(router_model),
complexity_threshold, 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( pub async fn route(
&self, &self,
registry: &PersonaRegistry, registry: &PersonaRegistry,
@ -58,14 +71,10 @@ impl Router {
// Stage B: LLM classification fallback // Stage B: LLM classification fallback
let known: Vec<String> = registry.list().iter().map(|p| p.name.clone()).collect(); let known: Vec<String> = registry.list().iter().map(|p| p.name.clone()).collect();
let selected = LlmRouter::classify( let router_model = self.get_router_model();
client, let selected =
&self.router_model, LlmRouter::classify(client, &router_model, &known, &self.default_persona, prompt)
&known, .await?;
&self.default_persona,
prompt,
)
.await?;
Ok(RouteDecision { Ok(RouteDecision {
persona: selected, persona: selected,

View file

@ -52,7 +52,23 @@ pub struct CommandSuggestion {
pub const ALL_COMMANDS: &[CommandSuggestion] = &[ pub const ALL_COMMANDS: &[CommandSuggestion] = &[
CommandSuggestion { CommandSuggestion {
name: "/settings", 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 { CommandSuggestion {
name: "/session", name: "/session",
@ -70,6 +86,14 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[
name: "/usage", name: "/usage",
description: "View rate limit and budget usage counters", 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 { CommandSuggestion {
name: "/config", name: "/config",
description: "View active system configuration", description: "View active system configuration",
@ -83,12 +107,12 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[
description: "Toggle complexity escalation", description: "Toggle complexity escalation",
}, },
CommandSuggestion { CommandSuggestion {
name: "/personas", name: "/persona",
description: "Open persona selector modal", description: "Select active persona or pass name",
}, },
CommandSuggestion { CommandSuggestion {
name: "/pipelines", name: "/pipeline",
description: "Open pipeline selector modal", description: "Select active pipeline or pass name",
}, },
CommandSuggestion { CommandSuggestion {
name: "/clear", name: "/clear",
@ -569,6 +593,94 @@ impl AppState {
self.log_working_event("[session] started fresh session"); 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) { pub fn clear_chat(&mut self) {
self.messages.clear(); self.messages.clear();
self.add_system_message("Chat history cleared.".to_string()); self.add_system_message("Chat history cleared.".to_string());
@ -701,4 +813,29 @@ mod tests {
app.cycle_theme(); app.cycle_theme();
assert_eq!(app.current_theme, "tokyo_night"); 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, &client,
&router, &router,
worker_tx.clone(), worker_tx.clone(),
); )
.await;
if should_quit { if should_quit {
break; 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, app: &mut AppState,
key: KeyEvent, key: KeyEvent,
config: &AppConfig, config: &AppConfig,
@ -645,7 +646,15 @@ fn handle_key_event(
app.input.clear(); app.input.clear();
app.cursor_pos = 0; app.cursor_pos = 0;
app.selected_suggestion_idx = 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; return false;
} }
} }
@ -693,7 +702,15 @@ fn handle_key_event(
app.selected_suggestion_idx = 0; app.selected_suggestion_idx = 0;
if prompt.starts_with('/') { if prompt.starts_with('/') {
handle_slash_command(app, config, &prompt); handle_slash_command(
app,
config,
client,
router,
model_registry,
&prompt,
)
.await;
return false; return false;
} }
@ -729,7 +746,14 @@ fn handle_key_event(
false 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 parts: Vec<&str> = input.split_whitespace().collect();
let cmd = parts.first().copied().unwrap_or(""); 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.selected_session_idx = 0;
app.mode = TuiMode::SessionSelect; app.mode = TuiMode::SessionSelect;
} }
"/config" => { "/provider" => {
let key_status = if config.api_key.is_some() { if parts.len() >= 3 && parts[1].eq_ignore_ascii_case("set") {
"configured" 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 { } 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 <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!( 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 Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Provider Base URL: {}\n Config Dir: {}",
config.router_model, router.get_router_model(),
config.default_persona, config.default_persona,
app.allow_stealth, app.allow_stealth,
app.escalate, app.escalate,
key_status, masked,
config.openrouter_base_url, client.get_base_url(),
config.config_dir.display() 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!( 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_requests,
app.minute_limit, app.minute_limit,
min_pct,
app.daily_requests, app.daily_requests,
app.daily_limit, app.daily_limit,
day_pct,
app::AppState::format_tokens(app.tokens_in), app::AppState::format_tokens(app.tokens_in),
app::AppState::format_tokens(app.tokens_out), 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" => { "/theme" => {
@ -825,14 +991,57 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
app.clear_chat(); app.clear_chat();
} }
"/persona" | "/personas" => { "/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<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" => { "/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<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" => { "/help" => {
app.add_system_message( 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(), .to_string(),
); );
} }
@ -1003,7 +1212,7 @@ fn spawn_dispatch_task(
let d = match decision.stage_used { let d = match decision.stage_used {
RouteStage::StageAKeyword => "Matched keyword in prompt".to_string(), RouteStage::StageAKeyword => "Matched keyword in prompt".to_string(),
RouteStage::StageBLlm => { RouteStage::StageBLlm => {
format!("LLM classifier (model: {})", router.router_model) format!("LLM classifier (model: {})", router.get_router_model())
} }
}; };
( (