diff --git a/config/personas.example.toml b/config/personas.example.toml index 42460f2..d0d3342 100644 --- a/config/personas.example.toml +++ b/config/personas.example.toml @@ -77,10 +77,10 @@ is_stealth = false [[persona]] name = "general" system_prompt = "You are BrokeCrew's general assistant: insightful, direct, and concise." -trigger_words = ["help", "explain", "summarize", "what", "who", "why", "how", "hello"] +trigger_words = ["help", "explain", "summarize", "what", "who", "why", "how", "hello", "hi", "hey", "yo", "sup", "greetings"] [[persona.candidates]] -id = "qwen/qwen3.8-27b:free" +id = "liquid/lfm-2.5-2.6b:free" tier = "free" is_stealth = false @@ -90,7 +90,7 @@ tier = "free" is_stealth = false [[persona.candidates]] -id = "liquid/lfm-2.5-2.6b:free" +id = "qwen/qwen3.8-27b:free" tier = "free" is_stealth = false diff --git a/src/config.rs b/src/config.rs index b16fcdc..c9860fd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,7 +30,7 @@ fn default_persona_str() -> String { "general".to_string() } fn default_router_model() -> String { - "meta-llama/llama-3.2-3b-instruct:free".to_string() + "liquid/lfm-2.5-2.6b:free".to_string() } fn default_complexity_threshold() -> u8 { 7 diff --git a/src/main.rs b/src/main.rs index 0d053a0..afb7b27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,13 +18,39 @@ use std::time::Duration; use tokio::sync::mpsc; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; -fn init_logging(json_logs: bool, verbose: bool) { +fn init_logging( + json_logs: bool, + verbose: bool, + is_tui: bool, + log_path: Option, +) { let filter = if verbose { EnvFilter::new("brokecrew=debug,reqwest=debug") } else { EnvFilter::new("brokecrew=info") }; + if is_tui { + if let Some(path) = log_path { + if let Ok(file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().with_writer(file)) + .init(); + return; + } + } + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::sink)) + .init(); + return; + } + if json_logs { tracing_subscriber::registry() .with(filter) @@ -69,7 +95,6 @@ fn create_spinner(message: &'static str) -> ProgressBar { #[tokio::main] async fn main() -> Result<(), Box> { let cli = Cli::parse(); - init_logging(cli.json_logs, cli.verbose); let config = AppConfig::load_from_paths( cli.config_file.as_deref(), @@ -78,6 +103,19 @@ async fn main() -> Result<(), Box> { ) .map_err(|e| format!("Configuration error: {e}"))?; + let is_tui = match cli.command { + Some(Commands::Tui(_)) => true, + None => !cli.cli && io::stdout().is_terminal(), + _ => false, + }; + + init_logging( + cli.json_logs, + cli.verbose, + is_tui, + Some(config.config_dir.join("brokecrew.log")), + ); + let budget_path = config.config_dir.join("budget.json"); let mut budget_tracker = BudgetTracker::new(Some(budget_path), config.credit_tier); let model_registry = Arc::new(ModelRegistry::new()); @@ -603,6 +641,19 @@ async fn run_pipeline( let sp = create_spinner("Thinking..."); current_spinner = Some(sp); } + PipelineEvent::CandidateAttempt { + model, + attempt, + total_candidates, + .. + } => { + if let Some(ref sp) = current_spinner { + sp.set_message(format!("Trying {model} ({attempt}/{total_candidates})...")); + } + } + PipelineEvent::CandidateFailed { model, error, .. } => { + tracing::debug!(model = %model, error = %error, "Candidate attempt failed"); + } PipelineEvent::Chunk { delta, .. } => { if let Some(sp) = current_spinner.take() { sp.finish_and_clear(); diff --git a/src/pipeline.rs b/src/pipeline.rs index 44074c8..bdf646d 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -28,6 +28,20 @@ pub enum PipelineEvent { model: String, tier: Tier, }, + CandidateAttempt { + step_index: usize, + persona: String, + model: String, + attempt: usize, + total_candidates: usize, + tier: Tier, + }, + CandidateFailed { + step_index: usize, + persona: String, + model: String, + error: String, + }, Chunk { step_index: usize, delta: String, @@ -55,6 +69,33 @@ impl PipelineExecutor { allow_stealth: bool, require_paid: bool, chunk_sender: Option>, + ) -> Result { + Self::dispatch_persona_with_events( + persona_name, + persona_registry, + model_registry, + client, + prompt, + allow_stealth, + require_paid, + chunk_sender, + None, + ) + .await + } + + /// Executes a single persona query with candidate fallback walk and telemetry event streaming + #[allow(clippy::too_many_arguments)] + pub async fn dispatch_persona_with_events( + persona_name: &str, + persona_registry: &PersonaRegistry, + model_registry: &ModelRegistry, + client: &dyn ModelClient, + prompt: &str, + allow_stealth: bool, + require_paid: bool, + chunk_sender: Option>, + event_sender: Option>, ) -> Result { let persona = persona_registry.get(persona_name).ok_or_else(|| { ClientError::ModelUnavailable(format!("Persona '{persona_name}' not found")) @@ -112,6 +153,19 @@ impl PipelineExecutor { "Attempting candidate dispatch" ); + if let Some(ref tx) = event_sender { + let _ = tx + .send(PipelineEvent::CandidateAttempt { + step_index: 0, + persona: persona_name.to_string(), + model: model_id.clone(), + attempt: idx + 1, + total_candidates, + tier: candidate.tier, + }) + .await; + } + // Channel for streaming chunks if sender provided let (tx, mut rx) = mpsc::channel::(100); let forwarder = { @@ -165,6 +219,17 @@ impl PipelineExecutor { error: Some(err_msg.clone()), }); + if let Some(ref tx) = event_sender { + let _ = tx + .send(PipelineEvent::CandidateFailed { + step_index: 0, + persona: persona_name.to_string(), + model: model_id.clone(), + error: err_msg.clone(), + }) + .await; + } + // Fast-fail on auth or billing errors match e { ClientError::Unauthorized | ClientError::PaymentRequired => return Err(e), @@ -282,7 +347,7 @@ impl PipelineExecutor { .await; } - let dispatch_result = Self::dispatch_persona( + let dispatch_result = Self::dispatch_persona_with_events( &step.persona, persona_registry, model_registry, @@ -291,6 +356,7 @@ impl PipelineExecutor { allow_stealth, require_paid, Some(chunk_tx), + event_sender.clone(), ) .await?; diff --git a/src/tui/app.rs b/src/tui/app.rs index 8ab2fec..27a0536 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -39,6 +39,47 @@ pub struct PersonaItem { pub description: String, } +#[derive(Debug, Clone)] +pub struct CandidateAttemptInfo { + pub model: String, + pub tier: Tier, + pub status: String, + pub is_error: bool, + pub is_success: bool, +} + +#[derive(Debug, Clone)] +pub struct WorkingsState { + pub active_persona: String, + pub active_pipeline: Option, + pub route_stage: Option, + pub route_details: Option, + pub candidate_attempts: Vec, + pub events: Vec, +} + +impl Default for WorkingsState { + fn default() -> Self { + Self { + active_persona: "Auto-Route".to_string(), + active_pipeline: None, + route_stage: None, + route_details: None, + candidate_attempts: Vec::new(), + events: vec![ + format!( + "{} ⚡ BrokeCrew AI Team online", + chrono::Local::now().format("%H:%M:%S") + ), + format!( + "{} ℹ Catalog & budget initialized", + chrono::Local::now().format("%H:%M:%S") + ), + ], + } + } +} + pub struct AppState { pub messages: Vec, pub input: String, @@ -50,7 +91,7 @@ pub struct AppState { pub scroll_offset: u16, pub auto_scroll: bool, - // Personas & selection (0 = [Auto-Route], 1.. = specific personas) + // Personas & selection pub persona_items: Vec, pub selected_persona_idx: usize, @@ -59,6 +100,9 @@ pub struct AppState { pub selected_pipeline_idx: usize, pub active_pipeline_for_prompt: Option, + // Team & Routing Workings telemetry + pub workings: WorkingsState, + // Rate limits & budget pub minute_requests: usize, pub minute_limit: usize, @@ -94,7 +138,7 @@ impl AppState { 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(), + content: "⚡ BrokeCrew AI Team initialized. Press [Tab] to switch persona, [Ctrl+P] for pipelines, [Ctrl+L] to clear.".to_string(), fell_back: false, }], input: String::new(), @@ -110,6 +154,7 @@ impl AppState { pipelines, selected_pipeline_idx: 0, active_pipeline_for_prompt: None, + workings: WorkingsState::default(), minute_requests: 0, minute_limit, daily_requests: 0, @@ -134,6 +179,36 @@ impl AppState { self.selected_persona_idx == 0 } + pub fn log_working_event(&mut self, text: &str) { + let now = chrono::Local::now().format("%H:%M:%S").to_string(); + self.workings.events.push(format!("{now} {text}")); + if self.workings.events.len() > 60 { + self.workings.events.remove(0); + } + } + + pub fn reset_turn_telemetry(&mut self, persona: &str, pipeline: Option) { + self.workings.active_persona = persona.to_string(); + self.workings.active_pipeline = pipeline; + self.workings.route_stage = None; + self.workings.route_details = None; + self.workings.candidate_attempts.clear(); + } + + pub fn update_current_message_model(&mut self, model_id: &str) { + if let Some(msg) = self.messages.last_mut() { + match &mut msg.sender { + MessageSender::Persona { model, .. } => { + *model = model_id.to_string(); + } + MessageSender::PipelineStep { model, .. } => { + *model = model_id.to_string(); + } + _ => {} + } + } + } + pub fn add_user_message(&mut self, text: String) { self.messages.push(ChatMessage { sender: MessageSender::User, @@ -208,6 +283,7 @@ impl AppState { self.messages.clear(); self.add_system_message("Chat history cleared.".to_string()); self.scroll_offset = 0; + self.log_working_event("Chat history cleared"); } pub fn insert_char(&mut self, c: char) { @@ -217,7 +293,6 @@ impl AppState { 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; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e97522b..269da6f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,7 +1,7 @@ pub mod app; pub mod ui; -use app::{AppState, TuiMode}; +use app::{AppState, CandidateAttemptInfo, TuiMode}; use brokecrew::budget::BudgetTracker; use brokecrew::client::OpenRouterClient; use brokecrew::config::AppConfig; @@ -34,6 +34,17 @@ pub enum WorkerEvent { RouterResolved { persona: String, stage: RouteStage, + details: String, + }, + CandidateAttempt { + model: String, + attempt: usize, + total: usize, + tier: Tier, + }, + CandidateFailed { + model: String, + error: String, }, StepStarted { step_index: usize, @@ -42,7 +53,12 @@ pub enum WorkerEvent { model: String, tier: Tier, }, - Chunk(String), + Chunk { + persona: String, + model: String, + tier: Tier, + delta: String, + }, StepCompleted { fell_back: bool, }, @@ -176,12 +192,50 @@ pub async fn run_tui( 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", + WorkerEvent::RouterResolved { + persona, + stage, + details, + } => { + let stage_name = match stage { + RouteStage::StageAKeyword => "Stage A (Keyword Filter)", + RouteStage::StageBLlm => "Stage B (LLM Classifier)", }; - app.add_system_message(format!("⚡ Routed prompt to [{persona}] via {stage_info}")); + 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)); + } + + WorkerEvent::CandidateAttempt { + model, + attempt, + total, + tier, + } => { + app.workings.candidate_attempts.push(CandidateAttemptInfo { + model: model.clone(), + tier, + status: format!("Attempting ({attempt}/{total})..."), + is_error: false, + is_success: false, + }); + app.log_working_event(&format!( + "[candidate] trying {} ({}/{})", + model, attempt, total + )); + } + + WorkerEvent::CandidateFailed { model, error } => { + if let Some(att) = app + .workings + .candidate_attempts + .iter_mut() + .find(|a| a.model == model) + { + att.status = error.clone(); + att.is_error = true; + } + app.log_working_event(&format!("[fallback] failed {}: {}", model, error)); } WorkerEvent::StepStarted { @@ -193,13 +247,36 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e } => { if total_steps > 1 { app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier); + app.log_working_event(&format!( + "[pipeline] step {}/{}: {} ({})", + step_index + 1, + total_steps, + persona, + model + )); } else { - app.start_assistant_message(&persona, &model, tier); + app.log_working_event(&format!("[dispatch] {} ({})", persona, model)); } } - WorkerEvent::Chunk(chunk) => { - app.append_to_current_message(&chunk); + WorkerEvent::Chunk { + persona, + model, + tier, + delta, + } => { + // Lazily ensure assistant message exists so no empty boxes exist + let needs_start = match app.messages.last() { + Some(msg) => matches!( + msg.sender, + app::MessageSender::User | app::MessageSender::System + ), + None => true, + }; + if needs_start { + app.start_assistant_message(&persona, &model, tier); + } + app.append_to_current_message(&delta); } WorkerEvent::StepCompleted { fell_back } => { @@ -216,13 +293,30 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e paid_calls_made, } => { app.is_generating = false; - app.status_message = format!("Ready ({model_used})"); + app.status_message = "Ready".to_string(); + + if !model_used.is_empty() && model_used != "none" { + app.update_current_message_model(&model_used); + if let Some(att) = app + .workings + .candidate_attempts + .iter_mut() + .find(|a| a.model == model_used) + { + att.status = "Connected & Active".to_string(); + att.is_success = true; + } + } + if fell_back { app.mark_current_message_fallback(); } if let Some(err) = error { app.add_system_message(format!("❌ Error: {err}")); + app.log_working_event(&format!("[error] {err}")); + } else { + app.log_working_event(&format!("[complete] response finished ({model_used})")); } for _ in 0..calls_made { @@ -265,6 +359,10 @@ fn handle_key_event( KeyCode::Enter => { app.active_pipeline_for_prompt = None; app.mode = TuiMode::Normal; + app.log_working_event(&format!( + "[persona] switched to {}", + app.current_persona_name() + )); } _ => {} }, @@ -284,6 +382,7 @@ fn handle_key_event( KeyCode::Enter => { if let Some(pipe) = app.pipelines.get(app.selected_pipeline_idx) { app.active_pipeline_for_prompt = Some(pipe.name.clone()); + app.log_working_event(&format!("[pipeline] selected {}", pipe.name)); } app.mode = TuiMode::Normal; } @@ -346,6 +445,11 @@ fn handle_key_event( app.is_generating = true; app.status_message = "Dispatching...".to_string(); + let target_persona = app.current_persona_name().to_string(); + let pipe_name = app.active_pipeline_for_prompt.clone(); + app.reset_turn_telemetry(&target_persona, pipe_name); + app.log_working_event(&format!("[prompt] \"{prompt}\"")); + spawn_dispatch_task( prompt, app, @@ -416,8 +520,36 @@ fn spawn_dispatch_task( }) .await; } + PipelineEvent::CandidateAttempt { + model, + attempt, + total_candidates, + tier, + .. + } => { + let _ = w_tx + .send(WorkerEvent::CandidateAttempt { + model, + attempt, + total: total_candidates, + tier, + }) + .await; + } + PipelineEvent::CandidateFailed { model, error, .. } => { + let _ = w_tx + .send(WorkerEvent::CandidateFailed { model, error }) + .await; + } PipelineEvent::Chunk { delta, .. } => { - let _ = w_tx.send(WorkerEvent::Chunk(delta)).await; + let _ = w_tx + .send(WorkerEvent::Chunk { + persona: "pipeline".to_string(), + model: "pipeline".to_string(), + tier: Tier::Free, + delta, + }) + .await; } PipelineEvent::StepCompleted { fell_back, .. } => { let _ = w_tx.send(WorkerEvent::StepCompleted { fell_back }).await; @@ -449,7 +581,7 @@ fn spawn_dispatch_task( let _ = worker_tx .send(WorkerEvent::Finished { fell_back, - model_used: "pipeline".to_string(), + model_used: "pipeline completed".to_string(), error: None, calls_made: total_calls + results.len(), paid_calls_made: paid_calls, @@ -472,17 +604,33 @@ fn spawn_dispatch_task( } } - // Determine target persona - let (target_persona, route_stage) = if is_auto { + // Determine target persona via router + let (target_persona, route_stage, details) = 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), + Ok(decision) => { + let d = match decision.stage_used { + RouteStage::StageAKeyword => "Matched keyword in prompt".to_string(), + RouteStage::StageBLlm => { + format!("LLM classifier (model: {})", router.router_model) + } + }; + (decision.persona, Some(decision.stage_used), d) + } + Err(err) => ( + default_persona, + None, + format!("Router fallback to default: {err}"), + ), } } else { - (direct_persona, None) + ( + direct_persona, + None, + "Direct persona selected by user".to_string(), + ) }; if let Some(stage) = route_stage { @@ -490,32 +638,62 @@ fn spawn_dispatch_task( .send(WorkerEvent::RouterResolved { persona: target_persona.clone(), stage, + details, }) .await; } - // Discover or dispatch persona let (chunk_tx, mut chunk_rx) = mpsc::channel::(100); + let (event_tx, mut event_rx) = mpsc::channel::(50); + let w_tx = worker_tx.clone(); + let target_clone = target_persona.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; + // Forward candidate events from dispatch_persona_with_events + let event_forwarder = tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event { + PipelineEvent::CandidateAttempt { + model, + attempt, + total_candidates, + tier, + .. + } => { + let _ = w_tx + .send(WorkerEvent::CandidateAttempt { + model, + attempt, + total: total_candidates, + tier, + }) + .await; + } + PipelineEvent::CandidateFailed { model, error, .. } => { + let _ = w_tx + .send(WorkerEvent::CandidateFailed { model, error }) + .await; + } + _ => {} + } } }); - let res = PipelineExecutor::dispatch_persona( + let w_tx2 = worker_tx.clone(); + let chunk_forwarder = tokio::spawn(async move { + while let Some(chunk) = chunk_rx.recv().await { + let _ = w_tx2 + .send(WorkerEvent::Chunk { + persona: target_clone.clone(), + model: "streaming".to_string(), + tier: Tier::Free, + delta: chunk, + }) + .await; + } + }); + + let res = PipelineExecutor::dispatch_persona_with_events( &target_persona, &persona_registry, &model_registry, @@ -524,10 +702,12 @@ fn spawn_dispatch_task( allow_stealth, escalate, Some(chunk_tx), + Some(event_tx), ) .await; - let _ = forwarder.await; + let _ = chunk_forwarder.await; + let _ = event_forwarder.await; match res { Ok(disp) => { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 576f433..66f083b 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -11,20 +11,18 @@ use ratatui::{ pub fn render(frame: &mut Frame, app: &mut AppState) { let size = frame.area(); - let chunks = Layout::default() + let root_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 + Constraint::Length(2), // Minimal Header + divider + Constraint::Min(6), // Main workspace (Split: Chat left, Workings right) + Constraint::Length(1), // Minimal Bottom status / 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]); + render_header(frame, app, root_chunks[0]); + render_workspace(frame, app, root_chunks[1]); + render_footer(frame, app, root_chunks[2]); match app.mode { TuiMode::PersonaSelect => render_persona_popup(frame, app), @@ -34,153 +32,169 @@ pub fn render(frame: &mut Frame, app: &mut AppState) { } 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 active_str = if app.is_auto_route() { + "Auto-Route".to_string() + } else { + app.current_persona_name().to_string() + }; - let persona_tag = if app.is_auto_route() { + let pipe_tag = if let Some(ref p) = app.active_pipeline_for_prompt { Span::styled( - " [🤖 Auto-Route] ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + format!(" [pipeline: {p}]"), + Style::default().fg(Color::Yellow), ) - } 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, + let status_dot = if app.is_generating { + let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let sp = spinner_frames[app.spinner_tick % spinner_frames.len()]; 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), - ), - ]); + format!(" {sp} thinking..."), + Style::default().fg(Color::Yellow), + ) + } else { + Span::styled(" ● ready", Style::default().fg(Color::Green)) + }; - 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]); + let left_spans = vec![ + Span::styled( + "⚡ brokecrew", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("[{active_str}]"), + Style::default() + .fg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + ), + pipe_tag, + Span::raw(" "), + status_dot, + ]; + + let min_color = if app.minute_requests >= 18 { + Color::Red + } else if app.minute_requests >= 14 { + Color::Yellow + } else { + Color::Green + }; + + let day_color = if app.daily_requests >= 45 { + Color::Red + } else if app.daily_requests >= 35 { + Color::Yellow + } else { + Color::Green + }; + + let right_spans = vec![ + Span::styled( + format!("⏱ {}/{}m", app.minute_requests, app.minute_limit), + Style::default().fg(min_color), + ), + Span::raw(" "), + Span::styled( + format!("📅 {}/{}d", app.daily_requests, app.daily_limit), + Style::default().fg(day_color), + ), + Span::raw(" "), + Span::styled( + format!("📦 {} models", app.models_cached), + Style::default().fg(Color::DarkGray), + ), + ]; + + let header_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }); + + frame.render_widget(Paragraph::new(Line::from(left_spans)), header_layout[0]); + frame.render_widget( + Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), + header_layout[1], + ); + + // Subtle single-line horizontal divider + let divider = Line::from(Span::styled( + "─".repeat(area.width as usize), + Style::default().fg(Color::DarkGray), + )); + frame.render_widget( + Paragraph::new(divider), + Rect { + x: area.x, + y: area.y + 1, + width: area.width, + height: 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), - )); +fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect) { + let workspace_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(63), // Left: Pi Minimal Conversation & Input + Constraint::Percentage(37), // Right: Team & Routing Workings + ]) + .split(area); - let inner_area = block.inner(area); - let inner_height = inner_area.height as usize; + render_conversation_pane(frame, app, workspace_chunks[0]); + render_workings_pane(frame, app, workspace_chunks[1]); +} +fn render_conversation_pane(frame: &mut Frame, app: &mut AppState, area: Rect) { + let chat_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(4), // Messages stream + Constraint::Length(1), // Divider + Constraint::Length(1), // Minimal Input prompt line + ]) + .split(area); + + render_minimal_messages(frame, app, chat_chunks[0]); + + // Input divider + let div = Line::from(Span::styled( + "─".repeat(chat_chunks[1].width as usize), + Style::default().fg(Color::DarkGray), + )); + frame.render_widget(Paragraph::new(div), chat_chunks[1]); + + render_minimal_input(frame, app, chat_chunks[2]); +} + +fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { 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), - ), - ])); + lines.push(Line::from(vec![Span::styled( + "❯ you", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )])); for text_line in msg.content.lines() { lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::DarkGray)), + Span::raw(" "), 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("")); } @@ -190,36 +204,27 @@ fn render_messages(frame: &mut Frame, app: &mut AppState, area: Rect) { Tier::Free => "free", Tier::Paid => "paid", }; - let header = format!("╭─ {icon} {name} · {model} [{tier_str}] "); - lines.push(Line::from(vec![ + let mut header_spans = vec![ Span::styled( - header, + format!("{icon} {name}"), Style::default().fg(color).add_modifier(Modifier::BOLD), ), + Span::raw(" "), Span::styled( - "────────────────────────────────────────", + format!("({model} · {tier_str})"), 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), - ), - ])); + header_spans.push(Span::raw(" ")); + header_spans.push(Span::styled( + "[fallback]", + Style::default().fg(Color::Yellow), + )); } + lines.push(Line::from(header_spans)); - render_formatted_body(&msg.content, &mut lines); - - lines.push(Line::from(Span::styled( - "╰────────────────────────────────────────", - Style::default().fg(Color::DarkGray), - ))); + render_clean_markdown_body(&msg.content, &mut lines); lines.push(Line::from("")); } @@ -235,56 +240,36 @@ fn render_messages(frame: &mut Frame, app: &mut AppState, area: Rect) { 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![ + let mut header_spans = vec![ Span::styled( - header, + format!("{icon} step {}/{}: {persona}", step_index + 1, total_steps), Style::default().fg(color).add_modifier(Modifier::BOLD), ), + Span::raw(" "), Span::styled( - "────────────────────────────────────────", + format!("({model} · {tier_str})"), 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), - ), - ])); + header_spans.push(Span::raw(" ")); + header_spans.push(Span::styled( + "[fallback]", + Style::default().fg(Color::Yellow), + )); } + lines.push(Line::from(header_spans)); - render_formatted_body(&msg.content, &mut lines); - - lines.push(Line::from(Span::styled( - "╰────────────────────────────────────────", - Style::default().fg(Color::DarkGray), - ))); + render_clean_markdown_body(&msg.content, &mut lines); lines.push(Line::from("")); } MessageSender::System => { lines.push(Line::from(vec![ - Span::styled( - " ℹ ", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), + Span::styled("● ", Style::default().fg(Color::DarkGray)), Span::styled( msg.content.clone(), - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), + Style::default().fg(Color::DarkGray).italic(), ), ])); lines.push(Line::from("")); @@ -293,6 +278,7 @@ fn render_messages(frame: &mut Frame, app: &mut AppState, area: Rect) { } let total_lines = lines.len(); + let inner_height = area.height as usize; let scroll_y = if app.auto_scroll { total_lines.saturating_sub(inner_height) as u16 } else { @@ -302,90 +288,52 @@ fn render_messages(frame: &mut Frame, app: &mut AppState, area: Rect) { }; 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) { +fn render_clean_markdown_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::raw(" "), Span::styled( line.to_string(), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), + Style::default().fg(Color::DarkGray).bold(), ), ])); } else if in_code_block { lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::DarkGray)), - Span::styled(" ", Style::default()), + Span::raw(" "), Span::styled(line.to_string(), Style::default().fg(Color::LightCyan)), ])); } else { lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::DarkGray)), + Span::raw(" "), 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), - ) - }; +fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { + let prompt_prefix = "❯ "; - let border_color = if app.is_generating { - Color::Yellow - } else { - Color::Cyan - }; + let para = Paragraph::new(Line::from(vec![ + Span::styled(prompt_prefix, Style::default().fg(Color::Cyan).bold()), + Span::styled(&app.input, Style::default().fg(Color::White)), + ])); + frame.render_widget(para, area); - 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 { + // Terminal cursor positioning + let cursor_x = area.x + 2 + (app.cursor_pos as u16); + let cursor_y = area.y; + if cursor_x < area.x + area.width { frame.set_cursor_position(Position { x: cursor_x, y: cursor_y, @@ -393,47 +341,187 @@ fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { } } -fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) { - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let spinner_char = spinner_frames[app.spinner_tick % spinner_frames.len()]; +fn render_workings_pane(frame: &mut Frame, app: &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 & Routing Workings ", + Style::default().fg(Color::Cyan).bold(), + )); - let status_span = if app.is_generating { - Span::styled( - format!(" {spinner_char} Generating... "), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + + // Section 1: Active Target & Route + lines.push(Line::from(vec![Span::styled( + "ROUTING & PERSONA", + Style::default().fg(Color::DarkGray).bold(), + )])); + + let target_persona = if app.is_auto_route() { + "Auto-Route (Stage A/B)".to_string() } else { - Span::styled( - format!(" ✔ {} ", app.status_message), - Style::default().fg(Color::Green), - ) + app.current_persona_name().to_string() }; + lines.push(Line::from(vec![ + Span::raw(" Active: "), + Span::styled(target_persona, Style::default().fg(Color::Green).bold()), + ])); - let help_line = Line::from(vec![ - status_span, - Span::raw(" │ "), + if let Some(ref stage) = app.workings.route_stage { + lines.push(Line::from(vec![ + Span::raw(" Stage: "), + Span::styled(stage.clone(), Style::default().fg(Color::Yellow)), + ])); + } + if let Some(ref detail) = app.workings.route_details { + lines.push(Line::from(vec![ + Span::raw(" Detail: "), + Span::styled(detail.clone(), Style::default().fg(Color::Gray)), + ])); + } + lines.push(Line::from("")); + + // Section 2: Candidate Cascade + lines.push(Line::from(vec![Span::styled( + "CANDIDATE CASCADE", + Style::default().fg(Color::DarkGray).bold(), + )])); + + if app.workings.candidate_attempts.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "Idle (evaluates on prompt)", + Style::default().fg(Color::DarkGray).italic(), + ), + ])); + } else { + for (idx, att) in app.workings.candidate_attempts.iter().enumerate() { + let symbol = if att.is_success { + Span::styled("✔ ", Style::default().fg(Color::Green).bold()) + } else if att.is_error { + Span::styled("✖ ", Style::default().fg(Color::Red).bold()) + } else { + Span::styled("● ", Style::default().fg(Color::Yellow)) + }; + + let tier_str = match att.tier { + Tier::Free => "free", + Tier::Paid => "paid", + }; + + lines.push(Line::from(vec![ + Span::raw(format!(" [{}] ", idx + 1)), + symbol, + Span::styled(&att.model, Style::default().fg(Color::White).bold()), + Span::raw(" "), + Span::styled( + format!("[{tier_str}]"), + Style::default().fg(Color::DarkGray), + ), + ])); + + if !att.status.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + &att.status, + if att.is_error { + Style::default().fg(Color::LightRed).italic() + } else { + Style::default().fg(Color::LightGreen) + }, + ), + ])); + } + } + } + lines.push(Line::from("")); + + // Section 3: Team Pipeline + if let Some(ref p_name) = app.workings.active_pipeline { + lines.push(Line::from(vec![Span::styled( + "TEAM PIPELINE", + Style::default().fg(Color::DarkGray).bold(), + )])); + lines.push(Line::from(vec![ + Span::raw(" Running: "), + Span::styled(p_name, Style::default().fg(Color::Yellow).bold()), + ])); + lines.push(Line::from("")); + } + + // Section 4: Budget & Rate Limits + lines.push(Line::from(vec![Span::styled( + "BUDGET STATUS", + Style::default().fg(Color::DarkGray).bold(), + )])); + lines.push(Line::from(vec![ + Span::raw(" 1m limit: "), + Span::styled( + format!("{}/{}", app.minute_requests, app.minute_limit), + Style::default().fg(Color::White).bold(), + ), + Span::raw(" reqs"), + ])); + lines.push(Line::from(vec![ + Span::raw(" Daily limit:"), + Span::styled( + format!("{}/{}", app.daily_requests, app.daily_limit), + Style::default().fg(Color::White).bold(), + ), + Span::raw(" reqs"), + ])); + lines.push(Line::from("")); + + // Section 5: Real-time Event Log + lines.push(Line::from(vec![Span::styled( + "EVENT LOG", + Style::default().fg(Color::DarkGray).bold(), + )])); + + let event_lines_available = inner.height.saturating_sub(lines.len() as u16) as usize; + let take_count = event_lines_available.clamp(2, 15); + let start_idx = app.workings.events.len().saturating_sub(take_count); + + for ev in &app.workings.events[start_idx..] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ev.clone(), Style::default().fg(Color::DarkGray)), + ])); + } + + let para = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(para, inner); +} + +fn render_footer(frame: &mut Frame, _app: &AppState, area: Rect) { + let help_spans = vec![ Span::styled("[Enter]", Style::default().fg(Color::White).bold()), - Span::styled(" Send ", Style::default().fg(Color::DarkGray)), + 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(" 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(" 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(" 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(" 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); + let para = Paragraph::new(Line::from(help_spans)).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()); + let area = centered_rect(55, 55, frame.area()); frame.render_widget(Clear, area); let items: Vec = app @@ -473,26 +561,24 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { }) .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("❯ "); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .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), + )), + ); frame.render_widget(list, area); } fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { - let area = centered_rect(65, 50, frame.area()); + let area = centered_rect(60, 50, frame.area()); frame.render_widget(Clear, area); let items: Vec = if app.pipelines.is_empty() { @@ -537,7 +623,7 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { let list = List::new(items).block( Block::default() .borders(Borders::ALL) - .border_type(BorderType::Double) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Yellow)) .title(Span::styled( " Select Pipeline to Run [↑/↓ Navigate, Enter Select, Esc Cancel] ",