From 65e265a5354a9ad55508389e878e15f8d6dc689f Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 26 Sep 2026 14:48:53 +0530 Subject: [PATCH] feat: add opencode/pi-style interactive TUI and dynamic candidate discovery for rotated free models --- Cargo.toml | 2 + README.md | 28 +- config/personas.example.toml | 35 ++- src/cli.rs | 15 + src/main.rs | 94 ++++-- src/pipeline.rs | 22 +- src/registry.rs | 40 +++ src/tui/app.rs | 275 +++++++++++++++++ src/tui/mod.rs | 557 +++++++++++++++++++++++++++++++++ src/tui/ui.rs | 582 +++++++++++++++++++++++++++++++++++ 10 files changed, 1596 insertions(+), 54 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.toml b/Cargo.toml index a9c15d6..97c1818 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ regex = "1.10" chrono = { version = "0.4", features = ["serde"] } dirs = "5.0" tempfile = "3.12" +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } [dev-dependencies] wiremock = "0.6" diff --git a/README.md b/README.md index 652f958..4445414 100644 --- a/README.md +++ b/README.md @@ -23,24 +23,24 @@ BrokeCrew makes free-tier models first-class citizens while eliminating their in --- -## Terminal Experience +## OpenCode / Pi-Style Interactive TUI -BrokeCrew formats multi-agent discussions like a vibrant team group chat: +Simply run `brokecrew` to enter the rich, full-screen interactive TUI: -```text -$ brokecrew chat --pipeline build-and-review "Write a Rust LRU cache" - -[coder] (qwen/qwen-2.5-coder-32b-instruct:free) -Here is a thread-safe, high-performance LRU cache implementation using std::collections::HashMap -and a doubly-linked list with generational indexing... - -[reviewer] (meta-llama/llama-3.3-70b-instruct:free) -Code Review: -1. Concurrency: Eviction edge case when capacity is 0 is unhandled. -2. Memory Safety: Generational indices avoid raw pointers; good choice. -3. Suggestion: Implement std::borrow::Borrow to allow lookup by &str rather than String. +```bash +brokecrew ``` +- **Live Team View**: Color-coded persona avatars (`โšก coder`, `๐Ÿ” reviewer`, `๐Ÿ›๏ธ architect`), token streaming with markdown & syntax formatting. +- **Quota & Budget Monitor**: Real-time headers displaying rolling minute and 24h limits (`โฑ 1m: 2/20`, `๐Ÿ“… 24h: 14/50`). +- **Interactive Modals**: + - `[Tab]` โ€” Open Persona Selector (switch between intelligent Stage A/B `Auto-Route` and dedicated personas). + - `[Ctrl+P]` โ€” Open Pipeline Selector (execute multi-agent pipelines like `build-and-review`). + - `[PgUp/PgDn]` โ€” Scroll conversation history. + - `[Ctrl+L]` โ€” Clear screen. + - `[Esc]` โ€” Dismiss modal or exit. +- **Non-TUI / Scripting**: Use `brokecrew --cli` or `brokecrew chat "..."` for headless pipelines or script integration. + --- ## Quickstart diff --git a/config/personas.example.toml b/config/personas.example.toml index 01ce961..42460f2 100644 --- a/config/personas.example.toml +++ b/config/personas.example.toml @@ -1,7 +1,7 @@ # BrokeCrew Personas & Pipelines Configuration default_persona = "general" -router_model = "meta-llama/llama-3.2-3b-instruct:free" +router_model = "liquid/lfm-2.5-2.6b:free" complexity_threshold = 7 [[persona]] @@ -10,25 +10,20 @@ system_prompt = "You are a precise, terse coding assistant. Output working code trigger_words = ["build", "code", "script", "function", "website", "api", "rust", "python", "algorithm", "backend", "frontend"] [[persona.candidates]] -id = "qwen/qwen-2.5-coder-32b-instruct:free" +id = "cohere/north-mini-code:free" tier = "free" is_stealth = false [[persona.candidates]] -id = "deepseek/deepseek-coder:free" +id = "qwen/qwen3.8-27b:free" tier = "free" is_stealth = false [[persona.candidates]] -id = "meta-llama/llama-3.3-70b-instruct:free" +id = "google/gemma-4-31b-it:free" tier = "free" is_stealth = false -[[persona.candidates]] -id = "openrouter/auto:free" -tier = "free" -is_stealth = true - [[persona.candidates]] id = "google/gemini-2.0-flash-001" tier = "paid" @@ -40,12 +35,17 @@ system_prompt = "You are a meticulous, security-minded code reviewer. Flag bugs, trigger_words = ["review", "check", "audit", "critique", "inspect", "lint", "vulnerability", "security"] [[persona.candidates]] -id = "meta-llama/llama-3.3-70b-instruct:free" +id = "google/gemma-4-31b-it:free" tier = "free" is_stealth = false [[persona.candidates]] -id = "deepseek/deepseek-chat:free" +id = "nvidia/nemotron-3.5-lightning:free" +tier = "free" +is_stealth = false + +[[persona.candidates]] +id = "nvidia/nemotron-3-super-120b-a12b:free" tier = "free" is_stealth = false @@ -60,12 +60,12 @@ system_prompt = "You are a senior principal systems architect. Spec out distribu trigger_words = ["design", "architecture", "system", "structure", "schema", "scale", "spec", "blueprint"] [[persona.candidates]] -id = "meta-llama/llama-3.3-70b-instruct:free" +id = "nvidia/nemotron-3-ultra-550b-a55b:free" tier = "free" is_stealth = false [[persona.candidates]] -id = "deepseek/deepseek-r1:free" +id = "google/gemma-4-31b-it:free" tier = "free" is_stealth = false @@ -80,12 +80,17 @@ system_prompt = "You are BrokeCrew's general assistant: insightful, direct, and trigger_words = ["help", "explain", "summarize", "what", "who", "why", "how", "hello"] [[persona.candidates]] -id = "meta-llama/llama-3.3-70b-instruct:free" +id = "qwen/qwen3.8-27b:free" tier = "free" is_stealth = false [[persona.candidates]] -id = "qwen/qwen-2.5-72b-instruct:free" +id = "google/gemma-4-26b-a4b-it:free" +tier = "free" +is_stealth = false + +[[persona.candidates]] +id = "liquid/lfm-2.5-2.6b:free" tier = "free" is_stealth = false diff --git a/src/cli.rs b/src/cli.rs index a59393e..2c3cac3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,12 +33,18 @@ pub struct Cli { #[arg(long, short, global = true, help = "Enable verbose debug logs")] pub verbose: bool, + #[arg(long, global = true, help = "Force non-TUI line CLI mode")] + pub cli: bool, + #[command(subcommand)] pub command: Option, } #[derive(Subcommand, Debug)] pub enum Commands { + #[command(about = "Launch interactive OpenCode/Pi-style TUI")] + Tui(TuiArgs), + #[command(about = "Chat with a persona or multi-agent pipeline")] Chat(ChatArgs), @@ -52,6 +58,15 @@ pub enum Commands { Status, } +#[derive(Args, Debug, Default)] +pub struct TuiArgs { + #[arg(long, help = "Allow experimental stealth model candidates")] + pub allow_stealth: bool, + + #[arg(long, help = "Force escalation to paid model candidates")] + pub escalate: bool, +} + #[derive(Args, Debug, Default)] pub struct ChatArgs { #[arg(help = "The message or prompt to send. If omitted, starts interactive chat REPL.")] diff --git a/src/main.rs b/src/main.rs index ee0e9e4..0d053a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod cli; +mod tui; use brokecrew::budget::BudgetTracker; use brokecrew::client::OpenRouterClient; @@ -11,7 +12,7 @@ use clap::Parser; use cli::{ChatArgs, Cli, Commands, ConfigSubcommand, PersonasSubcommand}; use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; -use std::io::{self, BufRead, Write}; +use std::io::{self, BufRead, IsTerminal, Write}; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; @@ -235,43 +236,94 @@ async fn main() -> Result<(), Box> { return Ok(()); } + Some(Commands::Tui(tui_args)) => { + let api_key = ensure_api_key(&config)?; + let mut conf = config; + conf.api_key = Some(api_key); + tui::run_tui( + conf, + budget_tracker, + model_registry, + tui_args.allow_stealth, + tui_args.escalate, + ) + .await?; + } + Some(Commands::Chat(args)) => { run_chat(args, &config, &mut budget_tracker, model_registry).await?; } None => { - // Default to chat if no subcommand passed - run_chat( - ChatArgs::default(), - &config, - &mut budget_tracker, - model_registry, - ) - .await?; + if !cli.cli && io::stdout().is_terminal() { + let api_key = ensure_api_key(&config)?; + let mut conf = config; + conf.api_key = Some(api_key); + tui::run_tui(conf, budget_tracker, model_registry, false, false).await?; + } else { + run_chat( + ChatArgs::default(), + &config, + &mut budget_tracker, + model_registry, + ) + .await?; + } } } Ok(()) } +fn ensure_api_key(config: &AppConfig) -> Result> { + if let Some(ref k) = config.api_key { + if !k.trim().is_empty() { + return Ok(k.clone()); + } + } + + if io::stdin().is_terminal() { + eprintln!( + "{}", + "โšก Welcome to BrokeCrew! No OpenRouter API key found in keyring, env, or config." + .bright_cyan() + .bold() + ); + eprint!("Enter OpenRouter API Key: "); + io::stderr().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + eprintln!("{}", "Error: API key cannot be empty.".red().bold()); + std::process::exit(1); + } + let _ = AppConfig::save_api_key_to_keyring(&trimmed); + eprintln!( + "{}", + "โœ” OpenRouter API key saved securely in OS credential store." + .bright_green() + .bold() + ); + Ok(trimmed) + } else { + eprintln!( + "{}\nRun: {}\nOr set: {}", + "Error: OpenRouter API key not configured.".red().bold(), + "brokecrew config set-key".bright_green().bold(), + "export OPENROUTER_API_KEY=\"sk-or-v1-...\"".cyan() + ); + std::process::exit(1); + } +} + async fn run_chat( args: ChatArgs, config: &AppConfig, budget_tracker: &mut BudgetTracker, model_registry: Arc, ) -> Result<(), Box> { - let api_key = match config.api_key.clone() { - Some(k) if !k.trim().is_empty() => k, - _ => { - eprintln!( - "{}\nRun: {}\nOr set: {}", - "Error: OpenRouter API key not configured.".red().bold(), - "brokecrew config set-key".bright_green().bold(), - "export OPENROUTER_API_KEY=\"sk-or-v1-...\"".cyan() - ); - std::process::exit(1); - } - }; + let api_key = ensure_api_key(config)?; let client = OpenRouterClient::with_base_url(api_key, config.openrouter_base_url.clone()); diff --git a/src/pipeline.rs b/src/pipeline.rs index 67a153f..44074c8 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,5 +1,5 @@ use crate::client::{ClientError, ModelClient}; -use crate::persona::{DispatchResult, ModelAttempt, PersonaRegistry, Tier}; +use crate::persona::{DispatchResult, ModelAttempt, ModelCandidate, PersonaRegistry, Tier}; use crate::registry::ModelRegistry; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; @@ -65,7 +65,8 @@ impl PipelineExecutor { .filter_eligible(&persona.candidates, allow_stealth, require_paid) .await; - let candidates_to_try = if !eligible.is_empty() { + let dynamic_discovered; + let candidates_to_try: Vec<&ModelCandidate> = if !eligible.is_empty() { eligible } else if model_registry.model_count().await == 0 { // Uninitialized / offline degraded state: allow raw candidates @@ -75,8 +76,21 @@ impl PipelineExecutor { .filter(|c| allow_stealth || !c.is_stealth) .collect() } else { - // Live catalog was fetched and deliberately excluded all candidates as offline/unlisted - vec![] + // Live catalog was fetched, but declared candidates were rotated out. + // Dynamically discover active live free models for this persona: + dynamic_discovered = model_registry + .discover_free_candidates_for_role(persona_name) + .await; + if !dynamic_discovered.is_empty() { + tracing::info!( + persona = %persona_name, + count = dynamic_discovered.len(), + "Auto-discovered live free fallback candidates from OpenRouter catalog" + ); + dynamic_discovered.iter().collect() + } else { + vec![] + } }; if candidates_to_try.is_empty() { diff --git a/src/registry.rs b/src/registry.rs index d2ca24a..21ad9cc 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -160,6 +160,46 @@ impl ModelRegistry { }) .collect() } + + /// Discovers active `:free` models in the catalog that best match a persona role hint. + pub async fn discover_free_candidates_for_role(&self, role_hint: &str) -> Vec { + let read = self.models.read().await; + let mut matching = Vec::new(); + let mut generic_free = Vec::new(); + + let hint_lower = role_hint.to_lowercase(); + let keywords: &[&str] = match hint_lower.as_str() { + "coder" => &["code", "coder", "qwen", "gemma", "north"], + "reviewer" => &["review", "gemma", "nemotron", "reasoning", "qwen"], + "architect" => &["ultra", "super", "550b", "120b", "gemma", "nemotron"], + _ => &["gemma", "qwen", "nemotron", "liquid", "inkling", "ling"], + }; + + for id in read.keys() { + if id.ends_with(":free") { + let id_lower = id.to_lowercase(); + if keywords.iter().any(|&kw| id_lower.contains(kw)) { + matching.push(ModelCandidate { + id: id.clone(), + tier: Tier::Free, + is_stealth: false, + }); + } else { + generic_free.push(ModelCandidate { + id: id.clone(), + tier: Tier::Free, + is_stealth: false, + }); + } + } + } + + matching.sort_by(|a, b| a.id.cmp(&b.id)); + generic_free.sort_by(|a, b| a.id.cmp(&b.id)); + + matching.extend(generic_free); + matching + } } #[cfg(test)] diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..8ab2fec --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,275 @@ +use brokecrew::persona::Tier; +use brokecrew::pipeline::PipelineConfig; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MessageSender { + User, + Persona { + name: String, + model: String, + tier: Tier, + }, + PipelineStep { + step_index: usize, + total_steps: usize, + persona: String, + model: String, + tier: Tier, + }, + System, +} + +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub sender: MessageSender, + pub content: String, + pub fell_back: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TuiMode { + Normal, + PersonaSelect, + PipelineSelect, +} + +#[derive(Debug, Clone)] +pub struct PersonaItem { + pub name: String, + pub description: String, +} + +pub struct AppState { + pub messages: Vec, + pub input: String, + pub cursor_pos: usize, + pub mode: TuiMode, + pub is_generating: bool, + pub spinner_tick: usize, + pub status_message: String, + pub scroll_offset: u16, + pub auto_scroll: bool, + + // Personas & selection (0 = [Auto-Route], 1.. = specific personas) + pub persona_items: Vec, + pub selected_persona_idx: usize, + + // Pipelines & selection + pub pipelines: Vec, + pub selected_pipeline_idx: usize, + pub active_pipeline_for_prompt: Option, + + // Rate limits & budget + pub minute_requests: usize, + pub minute_limit: usize, + pub daily_requests: usize, + pub daily_limit: usize, + pub models_cached: usize, + + // Options + pub allow_stealth: bool, + pub escalate: bool, +} + +impl AppState { + pub fn new( + raw_personas: Vec<(String, String)>, + pipelines: Vec, + minute_limit: usize, + daily_limit: usize, + ) -> Self { + let mut persona_items = Vec::new(); + // Item 0 is always the smart Auto-Router + persona_items.push(PersonaItem { + name: "Auto-Route".to_string(), + description: "Stage A keyword + Stage B LLM classifier".to_string(), + }); + for (name, desc) in raw_personas { + persona_items.push(PersonaItem { + name, + description: desc, + }); + } + + Self { + messages: vec![ChatMessage { + sender: MessageSender::System, + content: "โšก BrokeCrew AI Team TUI initialized. Type your prompt below, or press [Tab] to pick a persona, [Ctrl+P] to run a pipeline.".to_string(), + fell_back: false, + }], + input: String::new(), + cursor_pos: 0, + mode: TuiMode::Normal, + is_generating: false, + spinner_tick: 0, + status_message: "Ready".to_string(), + scroll_offset: 0, + auto_scroll: true, + persona_items, + selected_persona_idx: 0, + pipelines, + selected_pipeline_idx: 0, + active_pipeline_for_prompt: None, + minute_requests: 0, + minute_limit, + daily_requests: 0, + daily_limit, + models_cached: 0, + allow_stealth: false, + escalate: false, + } + } + + pub fn current_persona_name(&self) -> &str { + if self.selected_persona_idx == 0 { + "Auto-Route" + } else if let Some(item) = self.persona_items.get(self.selected_persona_idx) { + &item.name + } else { + "Auto-Route" + } + } + + pub fn is_auto_route(&self) -> bool { + self.selected_persona_idx == 0 + } + + pub fn add_user_message(&mut self, text: String) { + self.messages.push(ChatMessage { + sender: MessageSender::User, + content: text, + fell_back: false, + }); + if self.auto_scroll { + self.scroll_offset = 0; + } + } + + pub fn start_assistant_message(&mut self, name: &str, model: &str, tier: Tier) { + self.messages.push(ChatMessage { + sender: MessageSender::Persona { + name: name.to_string(), + model: model.to_string(), + tier, + }, + content: String::new(), + fell_back: false, + }); + if self.auto_scroll { + self.scroll_offset = 0; + } + } + + pub fn start_pipeline_step_message( + &mut self, + step_index: usize, + total_steps: usize, + persona: &str, + model: &str, + tier: Tier, + ) { + self.messages.push(ChatMessage { + sender: MessageSender::PipelineStep { + step_index, + total_steps, + persona: persona.to_string(), + model: model.to_string(), + tier, + }, + content: String::new(), + fell_back: false, + }); + if self.auto_scroll { + self.scroll_offset = 0; + } + } + + pub fn append_to_current_message(&mut self, chunk: &str) { + if let Some(msg) = self.messages.last_mut() { + msg.content.push_str(chunk); + } + } + + pub fn mark_current_message_fallback(&mut self) { + if let Some(msg) = self.messages.last_mut() { + msg.fell_back = true; + } + } + + pub fn add_system_message(&mut self, text: String) { + self.messages.push(ChatMessage { + sender: MessageSender::System, + content: text, + fell_back: false, + }); + } + + pub fn clear_chat(&mut self) { + self.messages.clear(); + self.add_system_message("Chat history cleared.".to_string()); + self.scroll_offset = 0; + } + + pub fn insert_char(&mut self, c: char) { + self.input.insert(self.cursor_pos, c); + self.cursor_pos += c.len_utf8(); + } + + pub fn delete_char_before_cursor(&mut self) { + if self.cursor_pos > 0 { + // Find char boundary + let mut prev = self.cursor_pos - 1; + while !self.input.is_char_boundary(prev) && prev > 0 { + prev -= 1; + } + self.input.remove(prev); + self.cursor_pos = prev; + } + } + + pub fn delete_char_under_cursor(&mut self) { + if self.cursor_pos < self.input.len() { + self.input.remove(self.cursor_pos); + } + } + + pub fn move_cursor_left(&mut self) { + if self.cursor_pos > 0 { + let mut prev = self.cursor_pos - 1; + while !self.input.is_char_boundary(prev) && prev > 0 { + prev -= 1; + } + self.cursor_pos = prev; + } + } + + pub fn move_cursor_right(&mut self) { + if self.cursor_pos < self.input.len() { + let mut next = self.cursor_pos + 1; + while !self.input.is_char_boundary(next) && next < self.input.len() { + next += 1; + } + self.cursor_pos = next; + } + } + + pub fn move_cursor_start(&mut self) { + self.cursor_pos = 0; + } + + pub fn move_cursor_end(&mut self) { + self.cursor_pos = self.input.len(); + } + + pub fn scroll_up(&mut self, delta: u16) { + self.auto_scroll = false; + self.scroll_offset = self.scroll_offset.saturating_add(delta); + } + + pub fn scroll_down(&mut self, delta: u16) { + self.scroll_offset = self.scroll_offset.saturating_sub(delta); + if self.scroll_offset == 0 { + self.auto_scroll = true; + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..e97522b --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,557 @@ +pub mod app; +pub mod ui; + +use app::{AppState, TuiMode}; +use brokecrew::budget::BudgetTracker; +use brokecrew::client::OpenRouterClient; +use brokecrew::config::AppConfig; +use brokecrew::persona::Tier; +use brokecrew::pipeline::{PipelineEvent, PipelineExecutor}; +use brokecrew::registry::ModelRegistry; +use brokecrew::router::{RouteStage, Router}; +use crossterm::{ + event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures_util::StreamExt; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); + } +} + +pub enum WorkerEvent { + RouterResolved { + persona: String, + stage: RouteStage, + }, + StepStarted { + step_index: usize, + total_steps: usize, + persona: String, + model: String, + tier: Tier, + }, + Chunk(String), + StepCompleted { + fell_back: bool, + }, + Finished { + fell_back: bool, + model_used: String, + error: Option, + calls_made: usize, + paid_calls_made: usize, + }, +} + +pub async fn run_tui( + config: AppConfig, + mut budget_tracker: BudgetTracker, + model_registry: Arc, + allow_stealth: bool, + escalate: bool, +) -> Result<(), Box> { + // Enable raw mode and alternate screen + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, crossterm::cursor::Show)?; + let _guard = TerminalGuard; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Prepare personas list for AppState + let personas_summary: Vec<(String, String)> = config + .persona_registry + .list() + .iter() + .map(|p| { + let desc = if !p.trigger_words.is_empty() { + format!("triggers: {}", p.trigger_words.join(", ")) + } else { + p.system_prompt.chars().take(50).collect::() + }; + (p.name.clone(), desc) + }) + .collect(); + + let (minute_limit, daily_limit) = match budget_tracker.check_budget() { + Ok(s) => (s.minute_limit, s.daily_limit), + Err(_) => (20, 50), + }; + + let mut app = AppState::new( + personas_summary, + config.pipelines.clone(), + minute_limit, + daily_limit, + ); + app.allow_stealth = allow_stealth || config.allow_stealth; + app.escalate = escalate; + + // Refresh model catalog in background + let reg_clone = Arc::clone(&model_registry); + let url_clone = config.openrouter_base_url.clone(); + tokio::spawn(async move { + let _ = reg_clone.fetch_live(&url_clone).await; + }); + + let client = Arc::new(OpenRouterClient::with_base_url( + config.api_key.clone().unwrap_or_default(), + config.openrouter_base_url.clone(), + )); + let router = Arc::new(Router::new( + config.default_persona.clone(), + config.router_model.clone(), + config.complexity_threshold, + )); + + let (worker_tx, mut worker_rx) = mpsc::channel::(100); + + let mut event_stream = EventStream::new(); + let mut tick_interval = tokio::time::interval(Duration::from_millis(80)); + + loop { + // Update budget counters in AppState + if let Ok(st) = budget_tracker.check_budget() { + app.minute_requests = st.minute_requests; + app.daily_requests = st.daily_requests; + app.minute_limit = st.minute_limit; + app.daily_limit = st.daily_limit; + } + app.models_cached = model_registry.model_count().await; + + // Render UI + terminal.draw(|f| ui::render(f, &mut app))?; + + tokio::select! { + _ = tick_interval.tick() => { + if app.is_generating { + app.spinner_tick = app.spinner_tick.wrapping_add(1); + } + } + + Some(event) = worker_rx.recv() => { + handle_worker_event(&mut app, &mut budget_tracker, event); + } + + maybe_event = event_stream.next() => { + match maybe_event { + Some(Ok(Event::Key(key_event))) => { + let should_quit = handle_key_event( + &mut app, + key_event, + &config, + &model_registry, + &client, + &router, + worker_tx.clone(), + ); + if should_quit { + break; + } + } + Some(Ok(Event::Resize(_, _))) => { + terminal.autoresize()?; + } + _ => {} + } + } + } + } + + Ok(()) +} + +fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, event: WorkerEvent) { + match event { + WorkerEvent::RouterResolved { persona, stage } => { + let stage_info = match stage { + RouteStage::StageAKeyword => "Stage A keyword match", + RouteStage::StageBLlm => "Stage B LLM classification", + }; + app.add_system_message(format!("โšก Routed prompt to [{persona}] via {stage_info}")); + } + + WorkerEvent::StepStarted { + step_index, + total_steps, + persona, + model, + tier, + } => { + if total_steps > 1 { + app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier); + } else { + app.start_assistant_message(&persona, &model, tier); + } + } + + WorkerEvent::Chunk(chunk) => { + app.append_to_current_message(&chunk); + } + + WorkerEvent::StepCompleted { fell_back } => { + if fell_back { + app.mark_current_message_fallback(); + } + } + + WorkerEvent::Finished { + fell_back, + model_used, + error, + calls_made, + paid_calls_made, + } => { + app.is_generating = false; + app.status_message = format!("Ready ({model_used})"); + if fell_back { + app.mark_current_message_fallback(); + } + + if let Some(err) = error { + app.add_system_message(format!("โŒ Error: {err}")); + } + + for _ in 0..calls_made { + let _ = budget_tracker.record_call(false); + } + for _ in 0..paid_calls_made { + let _ = budget_tracker.record_call(true); + } + } + } +} + +fn handle_key_event( + app: &mut AppState, + key: KeyEvent, + config: &AppConfig, + model_registry: &Arc, + client: &Arc, + router: &Arc, + worker_tx: mpsc::Sender, +) -> bool { + // Global quit on Ctrl+C + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return true; + } + + match app.mode { + TuiMode::PersonaSelect => match key.code { + KeyCode::Esc => { + app.mode = TuiMode::Normal; + } + KeyCode::Up => { + app.selected_persona_idx = app.selected_persona_idx.saturating_sub(1); + } + KeyCode::Down => { + if app.selected_persona_idx + 1 < app.persona_items.len() { + app.selected_persona_idx += 1; + } + } + KeyCode::Enter => { + app.active_pipeline_for_prompt = None; + app.mode = TuiMode::Normal; + } + _ => {} + }, + + TuiMode::PipelineSelect => match key.code { + KeyCode::Esc => { + app.mode = TuiMode::Normal; + } + KeyCode::Up => { + app.selected_pipeline_idx = app.selected_pipeline_idx.saturating_sub(1); + } + KeyCode::Down => { + if app.selected_pipeline_idx + 1 < app.pipelines.len() { + app.selected_pipeline_idx += 1; + } + } + KeyCode::Enter => { + if let Some(pipe) = app.pipelines.get(app.selected_pipeline_idx) { + app.active_pipeline_for_prompt = Some(pipe.name.clone()); + } + app.mode = TuiMode::Normal; + } + _ => {} + }, + + TuiMode::Normal => { + // Mode switching and shortcuts + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('p') => { + app.mode = TuiMode::PipelineSelect; + return false; + } + KeyCode::Char('l') => { + app.clear_chat(); + return false; + } + _ => {} + } + } + + match key.code { + KeyCode::Esc => { + return true; + } + KeyCode::Tab => { + app.mode = TuiMode::PersonaSelect; + } + KeyCode::PageUp => { + app.scroll_up(5); + } + KeyCode::PageDown => { + app.scroll_down(5); + } + KeyCode::Left => { + app.move_cursor_left(); + } + KeyCode::Right => { + app.move_cursor_right(); + } + KeyCode::Home => { + app.move_cursor_start(); + } + KeyCode::End => { + app.move_cursor_end(); + } + KeyCode::Backspace => { + app.delete_char_before_cursor(); + } + KeyCode::Delete => { + app.delete_char_under_cursor(); + } + KeyCode::Enter => { + let prompt = app.input.trim().to_string(); + if !prompt.is_empty() && !app.is_generating { + app.add_user_message(prompt.clone()); + app.input.clear(); + app.cursor_pos = 0; + app.is_generating = true; + app.status_message = "Dispatching...".to_string(); + + spawn_dispatch_task( + prompt, + app, + config, + model_registry, + client, + router, + worker_tx, + ); + } + } + KeyCode::Char(c) => { + app.insert_char(c); + } + _ => {} + } + } + } + + false +} + +fn spawn_dispatch_task( + prompt: String, + app: &AppState, + config: &AppConfig, + model_registry: &Arc, + client: &Arc, + router: &Arc, + worker_tx: mpsc::Sender, +) { + let pipeline_name = app.active_pipeline_for_prompt.clone(); + let is_auto = app.is_auto_route(); + let direct_persona = app.current_persona_name().to_string(); + let allow_stealth = app.allow_stealth; + let escalate = app.escalate; + + let persona_registry = config.persona_registry.clone(); + let pipelines = config.pipelines.clone(); + let default_persona = config.default_persona.clone(); + let model_registry = Arc::clone(model_registry); + let client = Arc::clone(client); + let router = Arc::clone(router); + + tokio::spawn(async move { + if let Some(pipe_name) = pipeline_name { + if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_name) { + let (event_tx, mut event_rx) = mpsc::channel::(50); + let w_tx = worker_tx.clone(); + + let forwarder = tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event { + PipelineEvent::StepStarted { + step_index, + total_steps, + persona, + model, + tier, + } => { + let _ = w_tx + .send(WorkerEvent::StepStarted { + step_index, + total_steps, + persona, + model, + tier, + }) + .await; + } + PipelineEvent::Chunk { delta, .. } => { + let _ = w_tx.send(WorkerEvent::Chunk(delta)).await; + } + PipelineEvent::StepCompleted { fell_back, .. } => { + let _ = w_tx.send(WorkerEvent::StepCompleted { fell_back }).await; + } + } + } + }); + + let res = PipelineExecutor::execute( + pipe, + &persona_registry, + &model_registry, + client.as_ref(), + &prompt, + allow_stealth, + escalate, + Some(event_tx), + ) + .await; + + let _ = forwarder.await; + + match res { + Ok(results) => { + let total_calls: usize = results.iter().map(|r| r.attempts.len()).sum(); + 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 _ = worker_tx + .send(WorkerEvent::Finished { + fell_back, + model_used: "pipeline".to_string(), + error: None, + calls_made: total_calls + results.len(), + paid_calls_made: paid_calls, + }) + .await; + } + Err(e) => { + let _ = worker_tx + .send(WorkerEvent::Finished { + fell_back: false, + model_used: "none".to_string(), + error: Some(e.to_string()), + calls_made: 1, + paid_calls_made: 0, + }) + .await; + } + } + return; + } + } + + // Determine target persona + let (target_persona, route_stage) = if is_auto { + match router + .route(&persona_registry, client.as_ref(), &prompt, escalate) + .await + { + Ok(decision) => (decision.persona, Some(decision.stage_used)), + Err(_) => (default_persona, None), + } + } else { + (direct_persona, None) + }; + + if let Some(stage) = route_stage { + let _ = worker_tx + .send(WorkerEvent::RouterResolved { + persona: target_persona.clone(), + stage, + }) + .await; + } + + // Discover or dispatch persona + let (chunk_tx, mut chunk_rx) = mpsc::channel::(100); + let w_tx = worker_tx.clone(); + + // Notify UI of step started + let _ = worker_tx + .send(WorkerEvent::StepStarted { + step_index: 0, + total_steps: 1, + persona: target_persona.clone(), + model: "evaluating candidates...".to_string(), + tier: Tier::Free, + }) + .await; + + let forwarder = tokio::spawn(async move { + while let Some(chunk) = chunk_rx.recv().await { + let _ = w_tx.send(WorkerEvent::Chunk(chunk)).await; + } + }); + + let res = PipelineExecutor::dispatch_persona( + &target_persona, + &persona_registry, + &model_registry, + client.as_ref(), + &prompt, + allow_stealth, + escalate, + Some(chunk_tx), + ) + .await; + + let _ = forwarder.await; + + match res { + Ok(disp) => { + let _ = worker_tx + .send(WorkerEvent::Finished { + fell_back: disp.fell_back, + model_used: disp.model_used, + error: None, + calls_made: disp.attempts.len() + 1, + paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 }, + }) + .await; + } + Err(e) => { + let _ = worker_tx + .send(WorkerEvent::Finished { + fell_back: false, + model_used: "none".to_string(), + error: Some(e.to_string()), + calls_made: 1, + paid_calls_made: 0, + }) + .await; + } + } + }); +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..576f433 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,582 @@ +use super::app::{AppState, MessageSender, TuiMode}; +use brokecrew::persona::Tier; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +pub fn render(frame: &mut Frame, app: &mut AppState) { + let size = frame.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(5), // Chat Messages + Constraint::Length(3), // Input box + Constraint::Length(1), // Footer / Shortcuts + ]) + .split(size); + + render_header(frame, app, chunks[0]); + render_messages(frame, app, chunks[1]); + render_input(frame, app, chunks[2]); + render_footer(frame, app, chunks[3]); + + match app.mode { + TuiMode::PersonaSelect => render_persona_popup(frame, app), + TuiMode::PipelineSelect => render_pipeline_popup(frame, app), + TuiMode::Normal => {} + } +} + +fn render_header(frame: &mut Frame, app: &AppState, area: Rect) { + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(45), // Logo & Persona + Constraint::Percentage(55), // Rate limit badges & status + ]) + .split(area); + + let persona_tag = if app.is_auto_route() { + Span::styled( + " [๐Ÿค– Auto-Route] ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + let p_name = app.current_persona_name(); + Span::styled( + format!(" [โšก {p_name}] "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + }; + + let title_line = Line::from(vec![ + Span::styled( + " โšก BrokeCrew ", + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + persona_tag, + ]); + + let title_para = Paragraph::new(title_line).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)), + ); + frame.render_widget(title_para, header_chunks[0]); + + // Budget indicators + let min_ratio = if app.minute_limit > 0 { + app.minute_requests as f32 / app.minute_limit as f32 + } else { + 0.0 + }; + let min_color = if min_ratio >= 0.9 { + Color::Red + } else if min_ratio >= 0.7 { + Color::Yellow + } else { + Color::Green + }; + + let day_ratio = if app.daily_limit > 0 { + app.daily_requests as f32 / app.daily_limit as f32 + } else { + 0.0 + }; + let day_color = if day_ratio >= 0.9 { + Color::Red + } else if day_ratio >= 0.7 { + Color::Yellow + } else { + Color::Green + }; + + let stealth_badge = if app.allow_stealth { + Span::styled(" [STEALTH ON] ", Style::default().fg(Color::Magenta)) + } else { + Span::raw("") + }; + + let stats_line = Line::from(vec![ + stealth_badge, + Span::styled( + format!(" โฑ 1m: {}/{} ", app.minute_requests, app.minute_limit), + Style::default().fg(min_color).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!(" ๐Ÿ“… 24h: {}/{} ", app.daily_requests, app.daily_limit), + Style::default().fg(day_color).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!(" ๐Ÿ“ฆ Models: {} ", app.models_cached), + Style::default().fg(Color::Gray), + ), + ]); + + let stats_para = Paragraph::new(stats_line) + .alignment(Alignment::Right) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)), + ); + frame.render_widget(stats_para, header_chunks[1]); +} + +fn render_messages(frame: &mut Frame, app: &mut AppState, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + " Team Chat ", + Style::default().fg(Color::Gray), + )); + + let inner_area = block.inner(area); + let inner_height = inner_area.height as usize; + + let mut lines: Vec = Vec::new(); + + for msg in &app.messages { + match &msg.sender { + MessageSender::User => { + lines.push(Line::from(vec![ + Span::styled( + "โ•ญโ”€ ๐Ÿ‘ค You ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + Style::default().fg(Color::DarkGray), + ), + ])); + for text_line in msg.content.lines() { + lines.push(Line::from(vec![ + Span::styled("โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled(text_line.to_string(), Style::default().fg(Color::White)), + ])); + } + lines.push(Line::from(Span::styled( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + } + + MessageSender::Persona { name, model, tier } => { + let (color, icon) = get_persona_style(name); + let tier_str = match tier { + Tier::Free => "free", + Tier::Paid => "paid", + }; + let header = format!("โ•ญโ”€ {icon} {name} ยท {model} [{tier_str}] "); + lines.push(Line::from(vec![ + Span::styled( + header, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled( + "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + Style::default().fg(Color::DarkGray), + ), + ])); + + if msg.fell_back { + lines.push(Line::from(vec![ + Span::styled("โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + "โš  Fallback candidate model served this response", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + ), + ])); + } + + render_formatted_body(&msg.content, &mut lines); + + lines.push(Line::from(Span::styled( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + } + + MessageSender::PipelineStep { + step_index, + total_steps, + persona, + model, + tier, + } => { + let (color, icon) = get_persona_style(persona); + let tier_str = match tier { + Tier::Free => "free", + Tier::Paid => "paid", + }; + let header = format!( + "โ•ญโ”€ {icon} Step {}/{}: {persona} ยท {model} [{tier_str}] ", + step_index + 1, + total_steps + ); + lines.push(Line::from(vec![ + Span::styled( + header, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled( + "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + Style::default().fg(Color::DarkGray), + ), + ])); + + if msg.fell_back { + lines.push(Line::from(vec![ + Span::styled("โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + "โš  Fallback candidate model served this step", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + ), + ])); + } + + render_formatted_body(&msg.content, &mut lines); + + lines.push(Line::from(Span::styled( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + } + + MessageSender::System => { + lines.push(Line::from(vec![ + Span::styled( + " โ„น ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + msg.content.clone(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + ])); + lines.push(Line::from("")); + } + } + } + + let total_lines = lines.len(); + let scroll_y = if app.auto_scroll { + total_lines.saturating_sub(inner_height) as u16 + } else { + total_lines + .saturating_sub(inner_height) + .saturating_sub(app.scroll_offset as usize) as u16 + }; + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((scroll_y, 0)); + + frame.render_widget(paragraph, area); +} + +fn render_formatted_body(content: &str, lines: &mut Vec) { + let mut in_code_block = false; + + for line in content.lines() { + if line.trim_start().starts_with("```") { + in_code_block = !in_code_block; + lines.push(Line::from(vec![ + Span::styled("โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled( + line.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ])); + } else if in_code_block { + lines.push(Line::from(vec![ + Span::styled("โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled(" ", Style::default()), + Span::styled(line.to_string(), Style::default().fg(Color::LightCyan)), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled("โ”‚ ", Style::default().fg(Color::DarkGray)), + Span::styled(line.to_string(), Style::default().fg(Color::White)), + ])); + } + } +} + +fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { + let title = if let Some(ref pipeline_name) = app.active_pipeline_for_prompt { + Span::styled( + format!(" Prompt for pipeline [{pipeline_name}] "), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else if app.is_auto_route() { + Span::styled( + " Message [Auto-Route] ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + let p_name = app.current_persona_name(); + Span::styled( + format!(" Message [{p_name}] "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + }; + + let border_color = if app.is_generating { + Color::Yellow + } else { + Color::Cyan + }; + + let input_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(border_color)) + .title(title); + + let inner_area = input_block.inner(area); + + let display_text = format!("โฏ {}", app.input); + let input_para = Paragraph::new(display_text).block(input_block); + frame.render_widget(input_para, area); + + // Set cursor position inside the input box + let cursor_x = inner_area.x + 2 + (app.cursor_pos as u16); + let cursor_y = inner_area.y; + if cursor_x < inner_area.x + inner_area.width { + frame.set_cursor_position(Position { + x: cursor_x, + y: cursor_y, + }); + } +} + +fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) { + let spinner_frames = ["โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "]; + let spinner_char = spinner_frames[app.spinner_tick % spinner_frames.len()]; + + let status_span = if app.is_generating { + Span::styled( + format!(" {spinner_char} Generating... "), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::styled( + format!(" โœ” {} ", app.status_message), + Style::default().fg(Color::Green), + ) + }; + + let help_line = Line::from(vec![ + status_span, + Span::raw(" โ”‚ "), + Span::styled("[Enter]", Style::default().fg(Color::White).bold()), + Span::styled(" Send ", Style::default().fg(Color::DarkGray)), + Span::styled("[Tab]", Style::default().fg(Color::White).bold()), + Span::styled(" Persona ", Style::default().fg(Color::DarkGray)), + Span::styled("[Ctrl+P]", Style::default().fg(Color::White).bold()), + Span::styled(" Pipeline ", Style::default().fg(Color::DarkGray)), + Span::styled("[Ctrl+L]", Style::default().fg(Color::White).bold()), + Span::styled(" Clear ", Style::default().fg(Color::DarkGray)), + Span::styled("[PgUp/Dn]", Style::default().fg(Color::White).bold()), + Span::styled(" Scroll ", Style::default().fg(Color::DarkGray)), + Span::styled("[Esc]", Style::default().fg(Color::White).bold()), + Span::styled(" Quit", Style::default().fg(Color::DarkGray)), + ]); + + let para = Paragraph::new(help_line).alignment(Alignment::Left); + frame.render_widget(para, area); +} + +fn render_persona_popup(frame: &mut Frame, app: &AppState) { + let area = centered_rect(60, 60, frame.area()); + frame.render_widget(Clear, area); + + let items: Vec = app + .persona_items + .iter() + .enumerate() + .map(|(idx, item)| { + let is_selected = idx == app.selected_persona_idx; + let (icon, prefix) = if idx == 0 { + ("๐Ÿค–", if is_selected { "โฏ " } else { " " }) + } else { + ("โšก", if is_selected { "โฏ " } else { " " }) + }; + + let line = Line::from(vec![ + Span::styled( + format!("{prefix}{icon} {:<12} ", item.name), + if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + item.description.clone(), + Style::default().fg(Color::DarkGray), + ), + ]); + + if is_selected { + ListItem::new(line).style(Style::default().bg(Color::Rgb(30, 30, 45))) + } else { + ListItem::new(line) + } + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style(Style::default().fg(Color::Cyan)) + .title(Span::styled( + " Select Persona [โ†‘/โ†“ Navigate, Enter Select, Esc Cancel] ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + ) + .highlight_symbol("โฏ "); + + frame.render_widget(list, area); +} + +fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { + let area = centered_rect(65, 50, frame.area()); + frame.render_widget(Clear, area); + + let items: Vec = if app.pipelines.is_empty() { + vec![ListItem::new("No pipelines configured in personas.toml")] + } else { + app.pipelines + .iter() + .enumerate() + .map(|(idx, pipe)| { + let is_selected = idx == app.selected_pipeline_idx; + let prefix = if is_selected { "โฏ " } else { " " }; + + let steps: Vec = pipe.steps.iter().map(|s| s.persona.clone()).collect(); + let steps_str = steps.join(" โž” "); + + let line = Line::from(vec![ + Span::styled( + format!("{prefix}โšก {:<18} ", pipe.name), + if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + format!("({steps_str})"), + Style::default().fg(Color::DarkGray), + ), + ]); + + if is_selected { + ListItem::new(line).style(Style::default().bg(Color::Rgb(45, 35, 20))) + } else { + ListItem::new(line) + } + }) + .collect() + }; + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style(Style::default().fg(Color::Yellow)) + .title(Span::styled( + " Select Pipeline to Run [โ†‘/โ†“ Navigate, Enter Select, Esc Cancel] ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + ); + + frame.render_widget(list, area); +} + +fn get_persona_style(name: &str) -> (Color, &'static str) { + match name.to_lowercase().as_str() { + "coder" => (Color::LightGreen, "โšก"), + "reviewer" => (Color::LightYellow, "๐Ÿ”"), + "architect" => (Color::LightBlue, "๐Ÿ›๏ธ"), + "researcher" => (Color::LightMagenta, "๐Ÿ”ฌ"), + "summarizer" => (Color::LightCyan, "๐Ÿ“"), + _ => (Color::White, "๐Ÿค–"), + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +}