feat: theme persistence, 6 new themes, accurate token tracking, rectangular prompt card fix, and animated pipeline flow

This commit is contained in:
katR 2026-09-28 21:11:30 +05:30
parent bf02bf3bc6
commit e8dd01509c
6 changed files with 799 additions and 129 deletions

View file

@ -7,6 +7,14 @@ use std::path::{Path, PathBuf};
const MINUTE_LIMIT: usize = 20; const MINUTE_LIMIT: usize = 20;
/// Estimates token count from text using standard heuristic (~4 characters per token).
pub fn estimate_tokens(text: &str) -> usize {
if text.is_empty() {
return 0;
}
text.len().div_ceil(4).max(1)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreditTier { pub enum CreditTier {
LowBalance, // < $10 lifetime purchased: 50 requests/day LowBalance, // < $10 lifetime purchased: 50 requests/day

View file

@ -9,23 +9,6 @@ const KEYRING_SERVICE: &str = "brokecrew";
const KEYRING_USER: &str = "openrouter_api_key"; const KEYRING_USER: &str = "openrouter_api_key";
const DEFAULT_PERSONAS_TOML: &str = include_str!("../config/personas.example.toml"); const DEFAULT_PERSONAS_TOML: &str = include_str!("../config/personas.example.toml");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfigFile {
#[serde(default = "default_persona_str")]
pub default_persona: String,
#[serde(default = "default_router_model")]
pub router_model: String,
#[serde(default = "default_complexity_threshold")]
pub complexity_threshold: u8,
#[serde(default)]
pub allow_stealth: bool,
#[serde(default = "default_credit_tier")]
pub credit_tier: CreditTier,
#[serde(default = "default_base_url")]
pub openrouter_base_url: String,
pub api_key: Option<String>,
}
fn default_persona_str() -> String { fn default_persona_str() -> String {
"general".to_string() "general".to_string()
} }
@ -41,6 +24,28 @@ fn default_credit_tier() -> CreditTier {
fn default_base_url() -> String { fn default_base_url() -> String {
"https://openrouter.ai/api/v1".to_string() "https://openrouter.ai/api/v1".to_string()
} }
fn default_theme_str() -> String {
"opencode".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfigFile {
#[serde(default = "default_persona_str")]
pub default_persona: String,
#[serde(default = "default_router_model")]
pub router_model: String,
#[serde(default = "default_complexity_threshold")]
pub complexity_threshold: u8,
#[serde(default)]
pub allow_stealth: bool,
#[serde(default = "default_credit_tier")]
pub credit_tier: CreditTier,
#[serde(default = "default_base_url")]
pub openrouter_base_url: String,
#[serde(default = "default_theme_str")]
pub theme: String,
pub api_key: Option<String>,
}
impl Default for AppConfigFile { impl Default for AppConfigFile {
fn default() -> Self { fn default() -> Self {
@ -51,6 +56,7 @@ impl Default for AppConfigFile {
allow_stealth: false, allow_stealth: false,
credit_tier: default_credit_tier(), credit_tier: default_credit_tier(),
openrouter_base_url: default_base_url(), openrouter_base_url: default_base_url(),
theme: default_theme_str(),
api_key: None, api_key: None,
} }
} }
@ -71,6 +77,7 @@ pub struct AppConfig {
pub allow_stealth: bool, pub allow_stealth: bool,
pub credit_tier: CreditTier, pub credit_tier: CreditTier,
pub openrouter_base_url: String, pub openrouter_base_url: String,
pub theme: String,
pub api_key: Option<String>, pub api_key: Option<String>,
pub persona_registry: PersonaRegistry, pub persona_registry: PersonaRegistry,
pub pipelines: Vec<PipelineConfig>, pub pipelines: Vec<PipelineConfig>,
@ -131,6 +138,7 @@ impl AppConfig {
allow_stealth: app_file.allow_stealth, allow_stealth: app_file.allow_stealth,
credit_tier: app_file.credit_tier, credit_tier: app_file.credit_tier,
openrouter_base_url: app_file.openrouter_base_url, openrouter_base_url: app_file.openrouter_base_url,
theme: app_file.theme,
api_key, api_key,
persona_registry: PersonaRegistry::new(personas_config.personas), persona_registry: PersonaRegistry::new(personas_config.personas),
pipelines: personas_config.pipelines, pipelines: personas_config.pipelines,
@ -138,6 +146,28 @@ impl AppConfig {
}) })
} }
/// Persists the selected theme into `config.toml`
pub fn save_theme(config_dir: &Path, theme: &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.theme = theme.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(())
}
/// 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
@ -237,4 +267,14 @@ mod tests {
assert!(parsed.personas.len() >= 3); assert!(parsed.personas.len() >= 3);
assert!(!parsed.pipelines.is_empty()); assert!(!parsed.pipelines.is_empty());
} }
#[test]
fn test_theme_persistence() {
let temp_dir = tempfile::tempdir().unwrap();
AppConfig::save_theme(temp_dir.path(), "dracula").expect("save theme");
let loaded =
AppConfig::load_from_paths(Some(&temp_dir.path().join("config.toml")), None, None)
.expect("load config");
assert_eq!(loaded.theme, "dracula");
}
} }

View file

@ -10,7 +10,7 @@ pub mod router;
pub mod session; pub mod session;
pub mod tool; pub mod tool;
pub use budget::{BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier}; pub use budget::{estimate_tokens, BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier};
pub use client::{ClientError, ModelClient, OpenRouterClient}; pub use client::{ClientError, ModelClient, OpenRouterClient};
pub use config::{AppConfig, AppConfigFile, PersonasConfigFile}; pub use config::{AppConfig, AppConfigFile, PersonasConfigFile};
pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier}; pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier};

View file

@ -112,6 +112,30 @@ pub const THEMES: &[(&str, &str)] = &[
"Cyberpunk - Midnight, neon pink & electric blue", "Cyberpunk - Midnight, neon pink & electric blue",
), ),
("mono", "Monochrome - High contrast grayscale minimal"), ("mono", "Monochrome - High contrast grayscale minimal"),
(
"catppuccin",
"Catppuccin Mocha - Soothing lavender, peach & velvet navy",
),
(
"tokyo_night",
"Tokyo Night - Midnight indigo, vibrant neon blue & cyan",
),
(
"nord",
"Nord - Polar night arctic frost, glacial blue & snow",
),
(
"dracula",
"Dracula - Classic vampire dark slate, neon purple & green",
),
(
"solarized",
"Solarized Dark - Deep ocean teal, cyan & solar amber",
),
(
"gruvbox",
"Gruvbox Dark - Retro dark charcoal, warm bright orange",
),
]; ];
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -129,13 +153,25 @@ pub struct CandidateAttemptInfo {
pub is_success: bool, pub is_success: bool,
} }
#[derive(Debug, Clone)]
pub struct PipelineStepTelemetry {
pub persona: String,
pub model: String,
pub tier: Tier,
pub is_active: bool,
pub is_completed: bool,
pub fell_back: bool,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WorkingsState { pub struct WorkingsState {
pub active_persona: String, pub active_persona: String,
pub active_pipeline: Option<String>, pub active_pipeline: Option<String>,
pub route_stage: Option<String>, pub route_stage: Option<String>,
pub route_details: Option<String>, pub route_details: Option<String>,
pub complexity_score: Option<u8>,
pub candidate_attempts: Vec<CandidateAttemptInfo>, pub candidate_attempts: Vec<CandidateAttemptInfo>,
pub pipeline_steps: Vec<PipelineStepTelemetry>,
pub events: Vec<String>, pub events: Vec<String>,
} }
@ -146,7 +182,9 @@ impl Default for WorkingsState {
active_pipeline: None, active_pipeline: None,
route_stage: None, route_stage: None,
route_details: None, route_details: None,
complexity_score: None,
candidate_attempts: Vec::new(), candidate_attempts: Vec::new(),
pipeline_steps: Vec::new(),
events: vec![ events: vec![
format!( format!(
"{} brokecrew online", "{} brokecrew online",
@ -199,6 +237,8 @@ pub struct AppState {
pub daily_requests: usize, pub daily_requests: usize,
pub daily_limit: usize, pub daily_limit: usize,
pub models_cached: usize, pub models_cached: usize,
pub tokens_in: usize,
pub tokens_out: usize,
// Options // Options
pub allow_stealth: bool, pub allow_stealth: bool,
@ -218,6 +258,7 @@ impl AppState {
minute_limit: usize, minute_limit: usize,
daily_limit: usize, daily_limit: usize,
config_dir: PathBuf, config_dir: PathBuf,
initial_theme: String,
) -> Self { ) -> Self {
let mut persona_items = Vec::new(); let mut persona_items = Vec::new();
// Item 0 is always the smart Auto-Router // Item 0 is always the smart Auto-Router
@ -234,6 +275,10 @@ impl AppState {
let session = Session::new("auto"); let session = Session::new("auto");
let session_summaries = Session::list(&config_dir).unwrap_or_default(); let session_summaries = Session::list(&config_dir).unwrap_or_default();
let selected_theme_idx = THEMES
.iter()
.position(|(name, _)| *name == initial_theme)
.unwrap_or(0);
Self { Self {
messages: Vec::new(), messages: Vec::new(),
@ -261,12 +306,22 @@ impl AppState {
daily_requests: 0, daily_requests: 0,
daily_limit, daily_limit,
models_cached: 0, models_cached: 0,
tokens_in: 0,
tokens_out: 0,
allow_stealth: false, allow_stealth: false,
escalate: false, escalate: false,
expand_tool_outputs: false, expand_tool_outputs: false,
current_theme: "opencode".to_string(), current_theme: initial_theme,
selected_suggestion_idx: 0, selected_suggestion_idx: 0,
selected_theme_idx: 0, selected_theme_idx,
}
}
pub fn format_tokens(tokens: usize) -> String {
if tokens < 1000 {
format!("{tokens}")
} else {
format!("{:.1}k", tokens as f64 / 1000.0)
} }
} }
@ -286,13 +341,13 @@ impl AppState {
} }
pub fn cycle_theme(&mut self) { pub fn cycle_theme(&mut self) {
self.current_theme = match self.current_theme.as_str() { let current_pos = THEMES
"opencode" => "warm".to_string(), .iter()
"warm" => "forest".to_string(), .position(|(name, _)| *name == self.current_theme)
"forest" => "cyberpunk".to_string(), .unwrap_or(0);
"cyberpunk" => "mono".to_string(), let next_pos = (current_pos + 1) % THEMES.len();
_ => "opencode".to_string(), self.current_theme = THEMES[next_pos].0.to_string();
}; self.selected_theme_idx = next_pos;
} }
pub fn current_persona_name(&self) -> &str { pub fn current_persona_name(&self) -> &str {
@ -322,7 +377,9 @@ impl AppState {
self.workings.active_pipeline = pipeline; self.workings.active_pipeline = pipeline;
self.workings.route_stage = None; self.workings.route_stage = None;
self.workings.route_details = None; self.workings.route_details = None;
self.workings.complexity_score = None;
self.workings.candidate_attempts.clear(); self.workings.candidate_attempts.clear();
self.workings.pipeline_steps.clear();
} }
pub fn update_current_message_model(&mut self, model_id: &str) { pub fn update_current_message_model(&mut self, model_id: &str) {
@ -482,6 +539,19 @@ impl AppState {
}); });
} }
self.tokens_in = self
.messages
.iter()
.filter(|m| matches!(m.sender, MessageSender::User))
.map(|m| brokecrew::estimate_tokens(&m.content))
.sum();
self.tokens_out = self
.messages
.iter()
.filter(|m| !matches!(m.sender, MessageSender::User | MessageSender::System))
.map(|m| brokecrew::estimate_tokens(&m.content))
.sum();
self.active_session = loaded; self.active_session = loaded;
self.scroll_offset = 0; self.scroll_offset = 0;
self.log_working_event(&format!("[session] loaded {}", session_id)); self.log_working_event(&format!("[session] loaded {}", session_id));
@ -492,6 +562,8 @@ impl AppState {
let persona = self.current_persona_name().to_string(); let persona = self.current_persona_name().to_string();
self.active_session = Session::new(&persona); self.active_session = Session::new(&persona);
self.messages.clear(); self.messages.clear();
self.tokens_in = 0;
self.tokens_out = 0;
self.add_system_message("Started fresh session.".to_string()); self.add_system_message("Started fresh session.".to_string());
self.scroll_offset = 0; self.scroll_offset = 0;
self.log_working_event("[session] started fresh session"); self.log_working_event("[session] started fresh session");
@ -581,7 +653,14 @@ mod tests {
#[test] #[test]
fn test_app_start_screen_and_suggestions() { fn test_app_start_screen_and_suggestions() {
let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test")); let mut app = AppState::new(
vec![],
vec![],
20,
50,
PathBuf::from("/tmp/brokecrew_test"),
"opencode".to_string(),
);
assert!(app.is_start_screen()); assert!(app.is_start_screen());
app.input = "/se".to_string(); app.input = "/se".to_string();
@ -600,7 +679,14 @@ mod tests {
#[test] #[test]
fn test_app_theme_cycling() { fn test_app_theme_cycling() {
let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test")); let mut app = AppState::new(
vec![],
vec![],
20,
50,
PathBuf::from("/tmp/brokecrew_test"),
"opencode".to_string(),
);
assert_eq!(app.current_theme, "opencode"); assert_eq!(app.current_theme, "opencode");
app.cycle_theme(); app.cycle_theme();
assert_eq!(app.current_theme, "warm"); assert_eq!(app.current_theme, "warm");
@ -611,6 +697,8 @@ mod tests {
app.cycle_theme(); app.cycle_theme();
assert_eq!(app.current_theme, "mono"); assert_eq!(app.current_theme, "mono");
app.cycle_theme(); app.cycle_theme();
assert_eq!(app.current_theme, "opencode"); assert_eq!(app.current_theme, "catppuccin");
app.cycle_theme();
assert_eq!(app.current_theme, "tokyo_night");
} }
} }

View file

@ -33,10 +33,15 @@ impl Drop for TerminalGuard {
} }
pub enum WorkerEvent { pub enum WorkerEvent {
PipelineStarted {
pipeline_name: String,
steps: Vec<String>,
},
RouterResolved { RouterResolved {
persona: String, persona: String,
stage: RouteStage, stage: RouteStage,
details: String, details: String,
complexity_score: u8,
}, },
CandidateAttempt { CandidateAttempt {
model: String, model: String,
@ -79,6 +84,8 @@ pub enum WorkerEvent {
error: Option<String>, error: Option<String>,
calls_made: usize, calls_made: usize,
paid_calls_made: usize, paid_calls_made: usize,
tokens_in: usize,
tokens_out: usize,
}, },
} }
@ -124,6 +131,7 @@ pub async fn run_tui(
minute_limit, minute_limit,
daily_limit, daily_limit,
config.config_dir.clone(), config.config_dir.clone(),
config.theme.clone(),
); );
app.allow_stealth = allow_stealth || config.allow_stealth; app.allow_stealth = allow_stealth || config.allow_stealth;
app.escalate = escalate; app.escalate = escalate;
@ -204,10 +212,37 @@ pub async fn run_tui(
fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, event: WorkerEvent) { fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, event: WorkerEvent) {
match event { match event {
WorkerEvent::PipelineStarted {
pipeline_name,
steps,
} => {
app.workings.active_pipeline = Some(pipeline_name.clone());
app.workings.pipeline_steps = steps
.into_iter()
.map(|p| app::PipelineStepTelemetry {
persona: p,
model: "evaluating...".to_string(),
tier: Tier::Free,
is_active: false,
is_completed: false,
fell_back: false,
})
.collect();
if let Some(first) = app.workings.pipeline_steps.first_mut() {
first.is_active = true;
}
app.log_working_event(&format!(
"[pipeline:start] '{}' with {} steps",
pipeline_name,
app.workings.pipeline_steps.len()
));
}
WorkerEvent::RouterResolved { WorkerEvent::RouterResolved {
persona, persona,
stage, stage,
details, details,
complexity_score,
} => { } => {
let stage_name = match stage { let stage_name = match stage {
RouteStage::StageAKeyword => "Stage A (Keyword Filter)", RouteStage::StageAKeyword => "Stage A (Keyword Filter)",
@ -215,7 +250,11 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
}; };
app.workings.route_stage = Some(stage_name.to_string()); app.workings.route_stage = Some(stage_name.to_string());
app.workings.route_details = Some(details.clone()); app.workings.route_details = Some(details.clone());
app.log_working_event(&format!("[route] {} ➔ {}", stage_name, persona)); app.workings.complexity_score = Some(complexity_score);
app.log_working_event(&format!(
"[route] {} ➔ {} (complexity: {}/10)",
stage_name, persona, complexity_score
));
} }
WorkerEvent::CandidateAttempt { WorkerEvent::CandidateAttempt {
@ -275,6 +314,32 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
model, model,
tier, tier,
} => { } => {
if step_index < app.workings.pipeline_steps.len() {
for (i, st) in app.workings.pipeline_steps.iter_mut().enumerate() {
if i < step_index {
st.is_completed = true;
st.is_active = false;
} else if i == step_index {
st.is_active = true;
st.model = model.clone();
st.tier = tier;
} else {
st.is_active = false;
}
}
} else if app.workings.pipeline_steps.is_empty() {
app.workings
.pipeline_steps
.push(app::PipelineStepTelemetry {
persona: persona.clone(),
model: model.clone(),
tier,
is_active: true,
is_completed: false,
fell_back: false,
});
}
if total_steps > 1 { if total_steps > 1 {
app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier); app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier);
app.log_working_event(&format!( app.log_working_event(&format!(
@ -310,6 +375,11 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
} }
WorkerEvent::StepCompleted { fell_back } => { WorkerEvent::StepCompleted { fell_back } => {
if let Some(st) = app.workings.pipeline_steps.iter_mut().find(|s| s.is_active) {
st.is_completed = true;
st.is_active = false;
st.fell_back = fell_back;
}
if fell_back { if fell_back {
app.mark_current_message_fallback(); app.mark_current_message_fallback();
} }
@ -321,9 +391,20 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
error, error,
calls_made, calls_made,
paid_calls_made, paid_calls_made,
tokens_in,
tokens_out,
} => { } => {
app.is_generating = false; app.is_generating = false;
app.status_message = "Ready".to_string(); app.status_message = "Ready".to_string();
app.tokens_in += tokens_in;
app.tokens_out += tokens_out;
for st in app.workings.pipeline_steps.iter_mut() {
if !st.is_completed {
st.is_completed = true;
st.is_active = false;
}
}
if !model_used.is_empty() && model_used != "none" { if !model_used.is_empty() && model_used != "none" {
app.update_current_message_model(&model_used); app.update_current_message_model(&model_used);
@ -349,7 +430,8 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
app.log_working_event(&format!("[complete] response finished ({model_used})")); app.log_working_event(&format!("[complete] response finished ({model_used})"));
} }
for _ in 0..calls_made { let free_calls = calls_made.saturating_sub(paid_calls_made);
for _ in 0..free_calls {
let _ = budget_tracker.record_call(false); let _ = budget_tracker.record_call(false);
} }
for _ in 0..paid_calls_made { for _ in 0..paid_calls_made {
@ -470,6 +552,7 @@ fn handle_key_event(
KeyCode::Enter => { KeyCode::Enter => {
if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) { if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) {
app.current_theme = name.to_string(); app.current_theme = name.to_string();
let _ = AppConfig::save_theme(&app.config_dir, name);
app.log_working_event(&format!("[theme] selected {}", name)); app.log_working_event(&format!("[theme] selected {}", name));
} }
app.mode = TuiMode::Normal; app.mode = TuiMode::Normal;
@ -678,11 +761,13 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
} }
"/usage" => { "/usage" => {
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 Cached Models: {} available", "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",
app.minute_requests, app.minute_requests,
app.minute_limit, app.minute_limit,
app.daily_requests, app.daily_requests,
app.daily_limit, app.daily_limit,
app::AppState::format_tokens(app.tokens_in),
app::AppState::format_tokens(app.tokens_out),
app.models_cached app.models_cached
)); ));
} }
@ -691,10 +776,12 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
if *arg == "next" { if *arg == "next" {
app.cycle_theme(); app.cycle_theme();
let theme_name = app.current_theme.clone(); let theme_name = app.current_theme.clone();
let _ = AppConfig::save_theme(&app.config_dir, &theme_name);
app.add_system_message(format!("Theme set to: {theme_name}")); app.add_system_message(format!("Theme set to: {theme_name}"));
return; return;
} else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) { } else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) {
app.current_theme = name.to_string(); app.current_theme = name.to_string();
let _ = AppConfig::save_theme(&app.config_dir, name);
app.add_system_message(format!("Theme set to: {name}")); app.add_system_message(format!("Theme set to: {name}"));
return; return;
} }
@ -782,6 +869,14 @@ fn spawn_dispatch_task(
tokio::spawn(async move { tokio::spawn(async move {
if let Some(pipe_name) = pipeline_name { if let Some(pipe_name) = pipeline_name {
if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_name) { if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_name) {
let steps: Vec<String> = pipe.steps.iter().map(|s| s.persona.clone()).collect();
let _ = worker_tx
.send(WorkerEvent::PipelineStarted {
pipeline_name: pipe_name.clone(),
steps,
})
.await;
let (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(50); let (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(50);
let w_tx = worker_tx.clone(); let w_tx = worker_tx.clone();
@ -863,6 +958,11 @@ fn spawn_dispatch_task(
let paid_calls: usize = let paid_calls: usize =
results.iter().filter(|r| r.tier_used == Tier::Paid).count(); results.iter().filter(|r| r.tier_used == Tier::Paid).count();
let fell_back = results.iter().any(|r| r.fell_back); let fell_back = results.iter().any(|r| r.fell_back);
let tok_in = brokecrew::estimate_tokens(&prompt);
let tok_out: usize = results
.iter()
.map(|r| brokecrew::estimate_tokens(&r.reply))
.sum();
let _ = worker_tx let _ = worker_tx
.send(WorkerEvent::Finished { .send(WorkerEvent::Finished {
fell_back, fell_back,
@ -870,6 +970,8 @@ fn spawn_dispatch_task(
error: None, error: None,
calls_made: total_calls + results.len(), calls_made: total_calls + results.len(),
paid_calls_made: paid_calls, paid_calls_made: paid_calls,
tokens_in: tok_in,
tokens_out: tok_out,
}) })
.await; .await;
} }
@ -881,6 +983,8 @@ fn spawn_dispatch_task(
error: Some(e.to_string()), error: Some(e.to_string()),
calls_made: 1, calls_made: 1,
paid_calls_made: 0, paid_calls_made: 0,
tokens_in: brokecrew::estimate_tokens(&prompt),
tokens_out: 0,
}) })
.await; .await;
} }
@ -890,7 +994,7 @@ fn spawn_dispatch_task(
} }
// Determine target persona via router // Determine target persona via router
let (target_persona, route_stage, details) = if is_auto { let (target_persona, route_stage, details, complexity_score) = if is_auto {
match router match router
.route(&persona_registry, client.as_ref(), &prompt, escalate) .route(&persona_registry, client.as_ref(), &prompt, escalate)
.await .await
@ -902,12 +1006,18 @@ fn spawn_dispatch_task(
format!("LLM classifier (model: {})", router.router_model) format!("LLM classifier (model: {})", router.router_model)
} }
}; };
(decision.persona, Some(decision.stage_used), d) (
decision.persona,
Some(decision.stage_used),
d,
decision.complexity_score,
)
} }
Err(err) => ( Err(err) => (
default_persona, default_persona,
None, None,
format!("Router fallback to default: {err}"), format!("Router fallback to default: {err}"),
1,
), ),
} }
} else { } else {
@ -915,6 +1025,7 @@ fn spawn_dispatch_task(
direct_persona, direct_persona,
None, None,
"Direct persona selected by user".to_string(), "Direct persona selected by user".to_string(),
1,
) )
}; };
@ -924,6 +1035,7 @@ fn spawn_dispatch_task(
persona: target_persona.clone(), persona: target_persona.clone(),
stage, stage,
details, details,
complexity_score,
}) })
.await; .await;
} }
@ -1066,23 +1178,32 @@ fn spawn_dispatch_task(
match followup_res { match followup_res {
Ok(f_disp) => { Ok(f_disp) => {
let tok_in = brokecrew::estimate_tokens(&prompt)
+ brokecrew::estimate_tokens(&tool_results_summary);
let tok_out = brokecrew::estimate_tokens(&disp.reply)
+ brokecrew::estimate_tokens(&f_disp.reply);
let paid =
if disp.tier_used == Tier::Paid || f_disp.tier_used == Tier::Paid {
1
} else {
0
};
let _ = worker_tx let _ = worker_tx
.send(WorkerEvent::Finished { .send(WorkerEvent::Finished {
fell_back: disp.fell_back || f_disp.fell_back, fell_back: disp.fell_back || f_disp.fell_back,
model_used: f_disp.model_used, model_used: f_disp.model_used,
error: None, error: None,
calls_made: disp.attempts.len() + f_disp.attempts.len() + 2, calls_made: disp.attempts.len() + f_disp.attempts.len() + 2,
paid_calls_made: if disp.tier_used == Tier::Paid paid_calls_made: paid,
|| f_disp.tier_used == Tier::Paid tokens_in: tok_in,
{ tokens_out: tok_out,
1
} else {
0
},
}) })
.await; .await;
} }
Err(e) => { Err(e) => {
let tok_in = brokecrew::estimate_tokens(&prompt)
+ brokecrew::estimate_tokens(&tool_results_summary);
let tok_out = brokecrew::estimate_tokens(&disp.reply);
let _ = worker_tx let _ = worker_tx
.send(WorkerEvent::Finished { .send(WorkerEvent::Finished {
fell_back: disp.fell_back, fell_back: disp.fell_back,
@ -1094,11 +1215,15 @@ fn spawn_dispatch_task(
} else { } else {
0 0
}, },
tokens_in: tok_in,
tokens_out: tok_out,
}) })
.await; .await;
} }
} }
} else { } else {
let tok_in = brokecrew::estimate_tokens(&prompt);
let tok_out = brokecrew::estimate_tokens(&disp.reply);
let _ = worker_tx let _ = worker_tx
.send(WorkerEvent::Finished { .send(WorkerEvent::Finished {
fell_back: disp.fell_back, fell_back: disp.fell_back,
@ -1106,6 +1231,8 @@ fn spawn_dispatch_task(
error: None, error: None,
calls_made: disp.attempts.len() + 1, calls_made: disp.attempts.len() + 1,
paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 }, paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 },
tokens_in: tok_in,
tokens_out: tok_out,
}) })
.await; .await;
} }
@ -1118,6 +1245,8 @@ fn spawn_dispatch_task(
error: Some(e.to_string()), error: Some(e.to_string()),
calls_made: 1, calls_made: 1,
paid_calls_made: 0, paid_calls_made: 0,
tokens_in: brokecrew::estimate_tokens(&prompt),
tokens_out: 0,
}) })
.await; .await;
} }

View file

@ -52,6 +52,54 @@ pub fn get_theme_palette(theme: &str) -> ThemePalette {
text_bright: Color::White, text_bright: Color::White,
text_muted: Color::DarkGray, text_muted: Color::DarkGray,
}, },
"catppuccin" => ThemePalette {
accent: Color::Rgb(180, 190, 254), // lavender
user_bg: Color::Rgb(49, 50, 68), // surface0
card_border: Color::Rgb(88, 91, 112), // surface2
tool_bg: Color::Rgb(30, 30, 46), // base
text_bright: Color::Rgb(205, 214, 244), // text
text_muted: Color::Rgb(147, 153, 178), // overlay0
},
"tokyo_night" => ThemePalette {
accent: Color::Rgb(122, 162, 247), // tokyo blue
user_bg: Color::Rgb(36, 40, 59),
card_border: Color::Rgb(65, 72, 104),
tool_bg: Color::Rgb(26, 27, 38),
text_bright: Color::Rgb(192, 202, 245),
text_muted: Color::Rgb(120, 130, 160),
},
"nord" => ThemePalette {
accent: Color::Rgb(136, 192, 208), // frost cyan
user_bg: Color::Rgb(59, 66, 82), // nord1
card_border: Color::Rgb(76, 86, 106), // nord3
tool_bg: Color::Rgb(46, 52, 64), // nord0
text_bright: Color::Rgb(236, 239, 244), // nord6
text_muted: Color::Rgb(140, 155, 170),
},
"dracula" => ThemePalette {
accent: Color::Rgb(189, 147, 249), // purple
user_bg: Color::Rgb(68, 71, 90), // current line
card_border: Color::Rgb(98, 114, 164), // comment
tool_bg: Color::Rgb(40, 42, 54), // bg
text_bright: Color::Rgb(248, 248, 242), // fg
text_muted: Color::Rgb(139, 147, 185),
},
"solarized" => ThemePalette {
accent: Color::Rgb(42, 161, 152), // cyan
user_bg: Color::Rgb(7, 54, 66), // base02
card_border: Color::Rgb(88, 110, 117), // base01
tool_bg: Color::Rgb(0, 43, 54), // base03
text_bright: Color::Rgb(238, 232, 213), // base2
text_muted: Color::Rgb(147, 161, 161), // base1
},
"gruvbox" => ThemePalette {
accent: Color::Rgb(254, 128, 25), // orange
user_bg: Color::Rgb(60, 56, 54), // dark2
card_border: Color::Rgb(102, 92, 84), // dark4
tool_bg: Color::Rgb(40, 40, 40), // dark0
text_bright: Color::Rgb(235, 219, 178), // light1
text_muted: Color::Rgb(168, 153, 132), // light4
},
_ => ThemePalette { _ => ThemePalette {
// opencode (default) // opencode (default)
accent: Color::Rgb(80, 210, 230), // cyan / sky accent: Color::Rgb(80, 210, 230), // cyan / sky
@ -283,11 +331,19 @@ fn render_header(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemeP
} }
fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect, palette: &ThemePalette) { fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect, palette: &ThemePalette) {
let (chat_pct, side_pct) = if area.width >= 130 {
(72, 28)
} else if area.width >= 95 {
(68, 32)
} else {
(64, 36)
};
let workspace_chunks = Layout::default() let workspace_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Percentage(78), // Left: Chat Constraint::Percentage(chat_pct), // Left: Chat
Constraint::Percentage(22), // Right: Compact routed models Constraint::Percentage(side_pct), // Right: Telemetry & Pipeline
]) ])
.split(area); .split(area);
@ -420,6 +476,54 @@ fn render_suggestions_list(
frame.render_widget(list, inner); frame.render_widget(list, inner);
} }
fn wrap_text(line: &str, max_width: usize) -> Vec<String> {
if line.is_empty() {
return vec![String::new()];
}
if max_width == 0 {
return vec![line.to_string()];
}
let mut result = Vec::new();
let mut current = String::new();
let mut current_len = 0;
for word in line.split(' ') {
let word_len = word.chars().count();
if current.is_empty() {
if word_len > max_width {
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(max_width) {
result.push(chunk.iter().collect());
}
} else {
current.push_str(word);
current_len = word_len;
}
} else if current_len + 1 + word_len <= max_width {
current.push(' ');
current.push_str(word);
current_len += 1 + word_len;
} else {
result.push(current);
if word_len > max_width {
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(max_width) {
result.push(chunk.iter().collect());
}
current = String::new();
current_len = 0;
} else {
current = word.to_string();
current_len = word_len;
}
}
}
if !current.is_empty() {
result.push(current);
}
result
}
fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let full_width = area.width.saturating_sub(1) as usize; let full_width = area.width.saturating_sub(1) as usize;
@ -427,56 +531,62 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palett
for msg in &app.messages { for msg in &app.messages {
match &msg.sender { match &msg.sender {
MessageSender::User => { MessageSender::User => {
let card_width = full_width.max(12);
let inner_text_width = card_width.saturating_sub(4); // 2 cols for "▌ ", 2 cols right safety margin
lines.push(Line::from("")); lines.push(Line::from(""));
// Top padding line // Top padding line
let top_pad = card_width.saturating_sub(2);
lines.push( lines.push(
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
"▌ ", "▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg), Style::default().fg(palette.accent).bg(palette.user_bg),
), ),
Span::styled( Span::styled(" ".repeat(top_pad), Style::default().bg(palette.user_bg)),
" ".repeat(full_width.saturating_sub(2)),
Style::default().bg(palette.user_bg),
),
]) ])
.style(Style::default().bg(palette.user_bg)), .style(Style::default().bg(palette.user_bg)),
); );
// Content lines // Content lines with proper word-wrapping and exact right padding
for text_line in msg.content.lines() { for raw_line in msg.content.lines() {
let pad_len = full_width.saturating_sub(text_line.len() + 3); let wrapped_lines = wrap_text(raw_line, inner_text_width);
lines.push( for text_line in wrapped_lines {
Line::from(vec![ let text_len = text_line.chars().count();
Span::styled( let pad_len = card_width.saturating_sub(2 + text_len);
"▌ ", lines.push(
Style::default().fg(palette.accent).bg(palette.user_bg), Line::from(vec![
), Span::styled(
Span::styled( "▌ ",
text_line.to_string(), Style::default().fg(palette.accent).bg(palette.user_bg),
Style::default() ),
.fg(palette.text_bright) Span::styled(
.bold() text_line,
.bg(palette.user_bg), Style::default()
), .fg(palette.text_bright)
Span::styled(" ".repeat(pad_len), Style::default().bg(palette.user_bg)), .bold()
]) .bg(palette.user_bg),
.style(Style::default().bg(palette.user_bg)), ),
); Span::styled(
" ".repeat(pad_len),
Style::default().bg(palette.user_bg),
),
])
.style(Style::default().bg(palette.user_bg)),
);
}
} }
// Bottom padding line // Bottom padding line
let bot_pad = card_width.saturating_sub(2);
lines.push( lines.push(
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
"▌ ", "▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg), Style::default().fg(palette.accent).bg(palette.user_bg),
), ),
Span::styled( Span::styled(" ".repeat(bot_pad), Style::default().bg(palette.user_bg)),
" ".repeat(full_width.saturating_sub(2)),
Style::default().bg(palette.user_bg),
),
]) ])
.style(Style::default().bg(palette.user_bg)), .style(Style::default().bg(palette.user_bg)),
); );
@ -484,7 +594,70 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palett
lines.push(Line::from("")); lines.push(Line::from(""));
} }
MessageSender::Persona { .. } | MessageSender::PipelineStep { .. } => { MessageSender::Persona { name, model, tier } => {
let tier_str = match tier {
Tier::Free => "free",
Tier::Paid => "paid",
};
let persona_color = get_persona_color(name);
let mut header_spans = vec![
Span::raw(" "),
Span::styled(
format!("⚡ {name}"),
Style::default().fg(persona_color).bold(),
),
Span::styled(
format!(" ({model} • {tier_str})"),
Style::default().fg(palette.text_muted),
),
];
if msg.fell_back {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
"⚠ fallback",
Style::default().fg(Color::Yellow).bold(),
));
}
lines.push(Line::from(header_spans));
lines.push(Line::from(""));
render_clean_markdown_body(&msg.content, &mut lines);
lines.push(Line::from(""));
}
MessageSender::PipelineStep {
step_index,
total_steps,
persona,
model,
tier,
} => {
let tier_str = match tier {
Tier::Free => "free",
Tier::Paid => "paid",
};
let persona_color = get_persona_color(persona);
let mut header_spans = vec![
Span::raw(" "),
Span::styled(
format!("⚡ Step {}/{}: {persona}", step_index + 1, total_steps),
Style::default().fg(persona_color).bold(),
),
Span::styled(
format!(" ({model} • {tier_str})"),
Style::default().fg(palette.text_muted),
),
];
if msg.fell_back {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
"⚠ fallback",
Style::default().fg(Color::Yellow).bold(),
));
}
lines.push(Line::from(header_spans));
lines.push(Line::from(""));
render_clean_markdown_body(&msg.content, &mut lines); render_clean_markdown_body(&msg.content, &mut lines);
lines.push(Line::from("")); lines.push(Line::from(""));
} }
@ -780,11 +953,18 @@ fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect, palette:
} }
fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) { fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let is_pipeline = !app.workings.pipeline_steps.is_empty();
let title_str = if is_pipeline {
" pipeline flow "
} else {
" routing & models "
};
let block = Block::default() let block = Block::default()
.borders(Borders::LEFT) .borders(Borders::LEFT)
.border_style(Style::default().fg(palette.card_border)) .border_style(Style::default().fg(palette.card_border))
.title(Span::styled( .title(Span::styled(
" models routed ", title_str,
Style::default().fg(palette.text_muted).bold(), Style::default().fg(palette.text_muted).bold(),
)); ));
@ -792,71 +972,291 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette:
frame.render_widget(block, area); frame.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let sp = spinner_frames[app.spinner_tick % spinner_frames.len()];
if is_pipeline {
// Render Animated Multi-Agent Pipeline Flow
let pipe_name = app
.workings
.active_pipeline
.as_deref()
.unwrap_or("build-and-review");
if app.workings.candidate_attempts.is_empty() {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
"waiting for prompt...", format!("⚡ pipeline: {pipe_name}"),
Style::default().fg(palette.text_muted).italic(), Style::default().fg(palette.accent).bold(),
), ),
])); ]));
} else { lines.push(Line::from(""));
for att in &app.workings.candidate_attempts {
let (icon, color) = if att.is_success { let total_steps = app.workings.pipeline_steps.len();
("•", Color::Green) for (idx, step) in app.workings.pipeline_steps.iter().enumerate() {
} else if att.is_error { if idx > 0 {
("x", Color::Red) let prev_completed = app.workings.pipeline_steps[idx - 1].is_completed;
let (connector_str, connector_color) = if prev_completed && step.is_active {
let anim_frames = [" │ ", " ╽ ", " ┃ ", " ▼ "];
let a = anim_frames[(app.spinner_tick / 2) % anim_frames.len()];
(a, palette.accent)
} else if prev_completed && step.is_completed {
(" │ ", Color::LightGreen)
} else {
(" · ", palette.text_muted)
};
lines.push(Line::from(vec![Span::styled(
connector_str,
Style::default().fg(connector_color).bold(),
)]));
}
if step.is_active {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{sp}] "),
Style::default().fg(palette.accent).bold(),
),
Span::styled(
format!("{}. {}", idx + 1, step.persona),
Style::default().fg(palette.text_bright).bold(),
),
]));
let model_label = truncate_str(&step.model, inner.width.saturating_sub(7) as usize);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("↳ {model_label}"),
Style::default().fg(Color::LightCyan),
),
]));
} else if step.is_completed {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("[✔] ", Style::default().fg(Color::LightGreen).bold()),
Span::styled(
format!("{}. {}", idx + 1, step.persona),
Style::default().fg(Color::White).bold(),
),
]));
let model_label = truncate_str(&step.model, inner.width.saturating_sub(7) as usize);
let tier_str = if step.tier == Tier::Paid {
"paid"
} else {
"free"
};
let mut status_spans = vec![
Span::raw(" "),
Span::styled(
format!("served ({model_label} • {tier_str})"),
Style::default().fg(Color::Green),
),
];
if step.fell_back {
status_spans.push(Span::styled(
" [fallback]",
Style::default().fg(Color::Yellow),
));
}
lines.push(Line::from(status_spans));
} else { } else {
("~", Color::Yellow) lines.push(Line::from(vec![
}; Span::raw(" "),
Span::styled("[○] ", Style::default().fg(palette.text_muted)),
let tier_str = match att.tier { Span::styled(
Tier::Free => "free", format!("{}. {}", idx + 1, step.persona),
Tier::Paid => "paid", Style::default().fg(palette.text_muted),
}; ),
]));
let model_display = truncate_str(&att.model, inner.width.saturating_sub(6) as usize); lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("queued ({}/{})", idx + 1, total_steps),
Style::default().fg(palette.text_muted).italic(),
),
]));
}
}
} else if app.workings.route_stage.is_some() || !app.workings.candidate_attempts.is_empty() {
// Render Single Persona Routing Telemetry & Cascade Attempts
if let Some(stage) = &app.workings.route_stage {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::raw(" "), Span::raw(" "),
Span::styled(format!("{icon} "), Style::default().fg(color).bold()), Span::styled("⚡ ", Style::default().fg(palette.accent).bold()),
Span::styled( Span::styled(stage.clone(), Style::default().fg(palette.accent).bold()),
model_display,
Style::default()
.fg(if att.is_success {
palette.text_bright
} else {
Color::Gray
})
.bold(),
),
]));
let status_text = if att.is_success {
format!(" served [{tier_str}]")
} else if att.is_error {
let err_short = truncate_str(&att.status, inner.width.saturating_sub(6) as usize);
format!(" failed: {err_short}")
} else {
format!(" attempting [{tier_str}]...")
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
status_text,
if att.is_error {
Style::default().fg(Color::LightRed).italic()
} else if att.is_success {
Style::default().fg(Color::LightGreen)
} else {
Style::default().fg(palette.text_muted)
},
),
])); ]));
if let Some(details) = &app.workings.route_details {
let d_trunc = truncate_str(details, inner.width.saturating_sub(4) as usize);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(d_trunc, Style::default().fg(palette.text_muted)),
]));
}
if let Some(score) = app.workings.complexity_score {
let filled = (score as usize).min(10);
let empty = 10 - filled;
let bar_str = format!("{}{}", "■".repeat(filled), "□".repeat(empty));
let color = if score >= 7 {
Color::Yellow
} else {
Color::Green
};
let esc_tag = if score >= 7 {
" [paid escalation]"
} else {
" [free tier]"
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{bar_str}] {score}/10{esc_tag}"),
Style::default().fg(color).bold(),
),
]));
}
lines.push(Line::from("")); lines.push(Line::from(""));
} }
if !app.workings.candidate_attempts.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"candidates evaluated:",
Style::default().fg(palette.text_muted).bold(),
),
]));
for att in &app.workings.candidate_attempts {
let (icon, color) = if att.is_success {
("•", Color::LightGreen)
} else if att.is_error {
("✖", Color::LightRed)
} else {
(sp, Color::Yellow)
};
let tier_str = match att.tier {
Tier::Free => "free",
Tier::Paid => "paid",
};
let model_display =
truncate_str(&att.model, inner.width.saturating_sub(6) as usize);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{icon} "), Style::default().fg(color).bold()),
Span::styled(
model_display,
Style::default()
.fg(if att.is_success {
palette.text_bright
} else {
Color::Gray
})
.bold(),
),
]));
let status_text = if att.is_success {
format!(" served [{tier_str}]")
} else if att.is_error {
let err_short =
truncate_str(&att.status, inner.width.saturating_sub(6) as usize);
format!(" failed: {err_short}")
} else {
format!(" attempting [{tier_str}]...")
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
status_text,
if att.is_error {
Style::default().fg(Color::LightRed).italic()
} else if att.is_success {
Style::default().fg(Color::LightGreen)
} else {
Style::default().fg(palette.text_muted)
},
),
]));
if att.is_error {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
" ↳ cascading to fallback...",
Style::default().fg(Color::Yellow).italic(),
),
]));
}
lines.push(Line::from(""));
}
}
} else {
// Idle state: Helpful team & feature overview
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"team status: ready",
Style::default().fg(palette.accent).bold(),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"available personas:",
Style::default().fg(palette.text_muted).bold(),
),
]));
for p in app.persona_items.iter().take(5) {
let p_color = get_persona_color(&p.name);
lines.push(Line::from(vec![
Span::raw(" • "),
Span::styled(&p.name, Style::default().fg(p_color).bold()),
]));
}
if !app.pipelines.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"pipelines (ctrl+p):",
Style::default().fg(palette.text_muted).bold(),
),
]));
for pipe in &app.pipelines {
lines.push(Line::from(vec![
Span::raw(" ⚡ "),
Span::styled(&pipe.name, Style::default().fg(Color::Yellow)),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("shortcuts:", Style::default().fg(palette.text_muted).bold()),
]));
lines.push(Line::from(vec![
Span::styled(" Tab ", Style::default().fg(palette.accent)),
Span::styled("Select persona", Style::default().fg(palette.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" Ctrl+P", Style::default().fg(palette.accent)),
Span::styled("Run pipeline", Style::default().fg(palette.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" Ctrl+S", Style::default().fg(palette.accent)),
Span::styled("Saved sessions", Style::default().fg(palette.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" /theme", Style::default().fg(palette.accent)),
Span::styled("Color palettes", Style::default().fg(palette.text_muted)),
]));
} }
let para = Paragraph::new(lines).wrap(Wrap { trim: false }); let para = Paragraph::new(lines).wrap(Wrap { trim: false });
@ -889,8 +1289,13 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemeP
let left_spans = vec![Span::styled( let left_spans = vec![Span::styled(
format!( format!(
"↑0.0k ↓0.0k 1m: {}/{} 24h: {}/{} {active_str}", "↑{} ↓{} 1m: {}/{} 24h: {}/{} {active_str}",
app.minute_requests, app.minute_limit, app.daily_requests, app.daily_limit AppState::format_tokens(app.tokens_in),
AppState::format_tokens(app.tokens_out),
app.minute_requests,
app.minute_limit,
app.daily_requests,
app.daily_limit
), ),
Style::default().fg(palette.text_muted), Style::default().fg(palette.text_muted),
)]; )];