feat: add provider, key, model, models, export, and stats commands with CLI config helpers
This commit is contained in:
parent
659de0dc04
commit
e41a641c94
7 changed files with 580 additions and 48 deletions
14
src/cli.rs
14
src/cli.rs
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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••••••••"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
src/main.rs
57
src/main.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
147
src/tui/app.rs
147
src/tui/app.rs
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
249
src/tui/mod.rs
249
src/tui/mod.rs
|
|
@ -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(¤t_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())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue