diff --git a/src/budget.rs b/src/budget.rs index 5addc52..b465cdf 100644 --- a/src/budget.rs +++ b/src/budget.rs @@ -7,6 +7,14 @@ use std::path::{Path, PathBuf}; 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)] pub enum CreditTier { LowBalance, // < $10 lifetime purchased: 50 requests/day diff --git a/src/config.rs b/src/config.rs index c9860fd..6095024 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,23 +9,6 @@ const KEYRING_SERVICE: &str = "brokecrew"; const KEYRING_USER: &str = "openrouter_api_key"; 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, -} - fn default_persona_str() -> String { "general".to_string() } @@ -41,6 +24,28 @@ fn default_credit_tier() -> CreditTier { fn default_base_url() -> 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, +} impl Default for AppConfigFile { fn default() -> Self { @@ -51,6 +56,7 @@ impl Default for AppConfigFile { allow_stealth: false, credit_tier: default_credit_tier(), openrouter_base_url: default_base_url(), + theme: default_theme_str(), api_key: None, } } @@ -71,6 +77,7 @@ pub struct AppConfig { pub allow_stealth: bool, pub credit_tier: CreditTier, pub openrouter_base_url: String, + pub theme: String, pub api_key: Option, pub persona_registry: PersonaRegistry, pub pipelines: Vec, @@ -131,6 +138,7 @@ impl AppConfig { allow_stealth: app_file.allow_stealth, credit_tier: app_file.credit_tier, openrouter_base_url: app_file.openrouter_base_url, + theme: app_file.theme, api_key, persona_registry: PersonaRegistry::new(personas_config.personas), 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::(&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: /// 1. CLI argument /// 2. `OPENROUTER_API_KEY` environment variable @@ -237,4 +267,14 @@ mod tests { assert!(parsed.personas.len() >= 3); 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"); + } } diff --git a/src/lib.rs b/src/lib.rs index 00b629a..5817367 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ pub mod router; pub mod session; 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 config::{AppConfig, AppConfigFile, PersonasConfigFile}; pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier}; diff --git a/src/tui/app.rs b/src/tui/app.rs index 6c0f40b..7425d65 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -112,6 +112,30 @@ pub const THEMES: &[(&str, &str)] = &[ "Cyberpunk - Midnight, neon pink & electric blue", ), ("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)] @@ -129,13 +153,25 @@ pub struct CandidateAttemptInfo { 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)] pub struct WorkingsState { pub active_persona: String, pub active_pipeline: Option, pub route_stage: Option, pub route_details: Option, + pub complexity_score: Option, pub candidate_attempts: Vec, + pub pipeline_steps: Vec, pub events: Vec, } @@ -146,7 +182,9 @@ impl Default for WorkingsState { active_pipeline: None, route_stage: None, route_details: None, + complexity_score: None, candidate_attempts: Vec::new(), + pipeline_steps: Vec::new(), events: vec![ format!( "{} brokecrew online", @@ -199,6 +237,8 @@ pub struct AppState { pub daily_requests: usize, pub daily_limit: usize, pub models_cached: usize, + pub tokens_in: usize, + pub tokens_out: usize, // Options pub allow_stealth: bool, @@ -218,6 +258,7 @@ impl AppState { minute_limit: usize, daily_limit: usize, config_dir: PathBuf, + initial_theme: String, ) -> Self { let mut persona_items = Vec::new(); // Item 0 is always the smart Auto-Router @@ -234,6 +275,10 @@ impl AppState { let session = Session::new("auto"); 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 { messages: Vec::new(), @@ -261,12 +306,22 @@ impl AppState { daily_requests: 0, daily_limit, models_cached: 0, + tokens_in: 0, + tokens_out: 0, allow_stealth: false, escalate: false, expand_tool_outputs: false, - current_theme: "opencode".to_string(), + current_theme: initial_theme, 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) { - self.current_theme = match self.current_theme.as_str() { - "opencode" => "warm".to_string(), - "warm" => "forest".to_string(), - "forest" => "cyberpunk".to_string(), - "cyberpunk" => "mono".to_string(), - _ => "opencode".to_string(), - }; + let current_pos = THEMES + .iter() + .position(|(name, _)| *name == self.current_theme) + .unwrap_or(0); + let next_pos = (current_pos + 1) % THEMES.len(); + self.current_theme = THEMES[next_pos].0.to_string(); + self.selected_theme_idx = next_pos; } pub fn current_persona_name(&self) -> &str { @@ -322,7 +377,9 @@ impl AppState { self.workings.active_pipeline = pipeline; self.workings.route_stage = None; self.workings.route_details = None; + self.workings.complexity_score = None; self.workings.candidate_attempts.clear(); + self.workings.pipeline_steps.clear(); } 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.scroll_offset = 0; self.log_working_event(&format!("[session] loaded {}", session_id)); @@ -492,6 +562,8 @@ impl AppState { let persona = self.current_persona_name().to_string(); self.active_session = Session::new(&persona); self.messages.clear(); + self.tokens_in = 0; + self.tokens_out = 0; self.add_system_message("Started fresh session.".to_string()); self.scroll_offset = 0; self.log_working_event("[session] started fresh session"); @@ -581,7 +653,14 @@ mod tests { #[test] 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()); app.input = "/se".to_string(); @@ -600,7 +679,14 @@ mod tests { #[test] 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"); app.cycle_theme(); assert_eq!(app.current_theme, "warm"); @@ -611,6 +697,8 @@ mod tests { app.cycle_theme(); assert_eq!(app.current_theme, "mono"); 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"); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index cf98c13..8493b75 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -33,10 +33,15 @@ impl Drop for TerminalGuard { } pub enum WorkerEvent { + PipelineStarted { + pipeline_name: String, + steps: Vec, + }, RouterResolved { persona: String, stage: RouteStage, details: String, + complexity_score: u8, }, CandidateAttempt { model: String, @@ -79,6 +84,8 @@ pub enum WorkerEvent { error: Option, calls_made: usize, paid_calls_made: usize, + tokens_in: usize, + tokens_out: usize, }, } @@ -124,6 +131,7 @@ pub async fn run_tui( minute_limit, daily_limit, config.config_dir.clone(), + config.theme.clone(), ); app.allow_stealth = allow_stealth || config.allow_stealth; 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) { 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 { persona, stage, details, + complexity_score, } => { let stage_name = match stage { 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_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 { @@ -275,6 +314,32 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e model, 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 { app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier); 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 } => { + 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 { app.mark_current_message_fallback(); } @@ -321,9 +391,20 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e error, calls_made, paid_calls_made, + tokens_in, + tokens_out, } => { app.is_generating = false; 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" { 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})")); } - 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); } for _ in 0..paid_calls_made { @@ -470,6 +552,7 @@ fn handle_key_event( KeyCode::Enter => { if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) { app.current_theme = name.to_string(); + let _ = AppConfig::save_theme(&app.config_dir, name); app.log_working_event(&format!("[theme] selected {}", name)); } app.mode = TuiMode::Normal; @@ -678,11 +761,13 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { } "/usage" => { 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_limit, app.daily_requests, app.daily_limit, + app::AppState::format_tokens(app.tokens_in), + app::AppState::format_tokens(app.tokens_out), app.models_cached )); } @@ -691,10 +776,12 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { if *arg == "next" { app.cycle_theme(); 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}")); return; } else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) { app.current_theme = name.to_string(); + let _ = AppConfig::save_theme(&app.config_dir, name); app.add_system_message(format!("Theme set to: {name}")); return; } @@ -782,6 +869,14 @@ fn spawn_dispatch_task( tokio::spawn(async move { if let Some(pipe_name) = pipeline_name { if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_name) { + let steps: Vec = 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::(50); let w_tx = worker_tx.clone(); @@ -863,6 +958,11 @@ fn spawn_dispatch_task( let paid_calls: usize = results.iter().filter(|r| r.tier_used == Tier::Paid).count(); 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 .send(WorkerEvent::Finished { fell_back, @@ -870,6 +970,8 @@ fn spawn_dispatch_task( error: None, calls_made: total_calls + results.len(), paid_calls_made: paid_calls, + tokens_in: tok_in, + tokens_out: tok_out, }) .await; } @@ -881,6 +983,8 @@ fn spawn_dispatch_task( error: Some(e.to_string()), calls_made: 1, paid_calls_made: 0, + tokens_in: brokecrew::estimate_tokens(&prompt), + tokens_out: 0, }) .await; } @@ -890,7 +994,7 @@ fn spawn_dispatch_task( } // 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 .route(&persona_registry, client.as_ref(), &prompt, escalate) .await @@ -902,12 +1006,18 @@ fn spawn_dispatch_task( 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) => ( default_persona, None, format!("Router fallback to default: {err}"), + 1, ), } } else { @@ -915,6 +1025,7 @@ fn spawn_dispatch_task( direct_persona, None, "Direct persona selected by user".to_string(), + 1, ) }; @@ -924,6 +1035,7 @@ fn spawn_dispatch_task( persona: target_persona.clone(), stage, details, + complexity_score, }) .await; } @@ -1066,23 +1178,32 @@ fn spawn_dispatch_task( match followup_res { 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 .send(WorkerEvent::Finished { fell_back: disp.fell_back || f_disp.fell_back, model_used: f_disp.model_used, error: None, calls_made: disp.attempts.len() + f_disp.attempts.len() + 2, - paid_calls_made: if disp.tier_used == Tier::Paid - || f_disp.tier_used == Tier::Paid - { - 1 - } else { - 0 - }, + paid_calls_made: paid, + tokens_in: tok_in, + tokens_out: tok_out, }) .await; } 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 .send(WorkerEvent::Finished { fell_back: disp.fell_back, @@ -1094,11 +1215,15 @@ fn spawn_dispatch_task( } else { 0 }, + tokens_in: tok_in, + tokens_out: tok_out, }) .await; } } } else { + let tok_in = brokecrew::estimate_tokens(&prompt); + let tok_out = brokecrew::estimate_tokens(&disp.reply); let _ = worker_tx .send(WorkerEvent::Finished { fell_back: disp.fell_back, @@ -1106,6 +1231,8 @@ fn spawn_dispatch_task( error: None, calls_made: disp.attempts.len() + 1, paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 }, + tokens_in: tok_in, + tokens_out: tok_out, }) .await; } @@ -1118,6 +1245,8 @@ fn spawn_dispatch_task( error: Some(e.to_string()), calls_made: 1, paid_calls_made: 0, + tokens_in: brokecrew::estimate_tokens(&prompt), + tokens_out: 0, }) .await; } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index a421796..a9540c8 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -52,6 +52,54 @@ pub fn get_theme_palette(theme: &str) -> ThemePalette { text_bright: Color::White, 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 { // opencode (default) 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) { + 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() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(78), // Left: Chat - Constraint::Percentage(22), // Right: Compact routed models + Constraint::Percentage(chat_pct), // Left: Chat + Constraint::Percentage(side_pct), // Right: Telemetry & Pipeline ]) .split(area); @@ -420,6 +476,54 @@ fn render_suggestions_list( frame.render_widget(list, inner); } +fn wrap_text(line: &str, max_width: usize) -> Vec { + 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 = 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 = 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) { let mut lines: Vec = Vec::new(); 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 { match &msg.sender { 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("")); // Top padding line + let top_pad = card_width.saturating_sub(2); lines.push( Line::from(vec![ Span::styled( "▌ ", Style::default().fg(palette.accent).bg(palette.user_bg), ), - Span::styled( - " ".repeat(full_width.saturating_sub(2)), - Style::default().bg(palette.user_bg), - ), + Span::styled(" ".repeat(top_pad), Style::default().bg(palette.user_bg)), ]) .style(Style::default().bg(palette.user_bg)), ); - // Content lines - for text_line in msg.content.lines() { - let pad_len = full_width.saturating_sub(text_line.len() + 3); - lines.push( - Line::from(vec![ - Span::styled( - "▌ ", - Style::default().fg(palette.accent).bg(palette.user_bg), - ), - Span::styled( - text_line.to_string(), - Style::default() - .fg(palette.text_bright) - .bold() - .bg(palette.user_bg), - ), - Span::styled(" ".repeat(pad_len), Style::default().bg(palette.user_bg)), - ]) - .style(Style::default().bg(palette.user_bg)), - ); + // Content lines with proper word-wrapping and exact right padding + for raw_line in msg.content.lines() { + let wrapped_lines = wrap_text(raw_line, inner_text_width); + for text_line in wrapped_lines { + let text_len = text_line.chars().count(); + let pad_len = card_width.saturating_sub(2 + text_len); + lines.push( + Line::from(vec![ + Span::styled( + "▌ ", + Style::default().fg(palette.accent).bg(palette.user_bg), + ), + Span::styled( + text_line, + Style::default() + .fg(palette.text_bright) + .bold() + .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 + let bot_pad = card_width.saturating_sub(2); lines.push( Line::from(vec![ Span::styled( "▌ ", Style::default().fg(palette.accent).bg(palette.user_bg), ), - Span::styled( - " ".repeat(full_width.saturating_sub(2)), - Style::default().bg(palette.user_bg), - ), + Span::styled(" ".repeat(bot_pad), 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("")); } - 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); 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) { + let is_pipeline = !app.workings.pipeline_steps.is_empty(); + let title_str = if is_pipeline { + " pipeline flow " + } else { + " routing & models " + }; + let block = Block::default() .borders(Borders::LEFT) .border_style(Style::default().fg(palette.card_border)) .title(Span::styled( - " models routed ", + title_str, 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); let mut lines: Vec = 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![ Span::raw(" "), Span::styled( - "waiting for prompt...", - Style::default().fg(palette.text_muted).italic(), + format!("⚡ pipeline: {pipe_name}"), + Style::default().fg(palette.accent).bold(), ), ])); - } else { - for att in &app.workings.candidate_attempts { - let (icon, color) = if att.is_success { - ("•", Color::Green) - } else if att.is_error { - ("x", Color::Red) + lines.push(Line::from("")); + + let total_steps = app.workings.pipeline_steps.len(); + for (idx, step) in app.workings.pipeline_steps.iter().enumerate() { + if idx > 0 { + 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 { - ("~", 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("[○] ", Style::default().fg(palette.text_muted)), + Span::styled( + format!("{}. {}", idx + 1, step.persona), + Style::default().fg(palette.text_muted), + ), + ])); + 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![ 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) - }, - ), + Span::styled("⚡ ", Style::default().fg(palette.accent).bold()), + Span::styled(stage.clone(), Style::default().fg(palette.accent).bold()), ])); + 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("")); } + + 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 }); @@ -889,8 +1289,13 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemeP let left_spans = vec![Span::styled( format!( - "↑0.0k ↓0.0k 1m: {}/{} 24h: {}/{} {active_str}", - app.minute_requests, app.minute_limit, app.daily_requests, app.daily_limit + "↑{} ↓{} 1m: {}/{} 24h: {}/{} {active_str}", + 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), )];