feat: adopt Pi-style minimalist unboxed theme, fix TUI stderr pollution, and add dedicated Team & Routing Workings telemetry panel

This commit is contained in:
katR 2026-09-26 15:56:21 +05:30
parent 397503c8aa
commit bfea14d01b
7 changed files with 786 additions and 328 deletions

View file

@ -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

View file

@ -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

View file

@ -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<std::path::PathBuf>,
) {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
)
.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();

View file

@ -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<mpsc::Sender<String>>,
) -> Result<DispatchResult, ClientError> {
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<mpsc::Sender<String>>,
event_sender: Option<mpsc::Sender<PipelineEvent>>,
) -> Result<DispatchResult, ClientError> {
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::<String>(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?;

View file

@ -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<String>,
pub route_stage: Option<String>,
pub route_details: Option<String>,
pub candidate_attempts: Vec<CandidateAttemptInfo>,
pub events: Vec<String>,
}
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<ChatMessage>,
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<PersonaItem>,
pub selected_persona_idx: usize,
@ -59,6 +100,9 @@ pub struct AppState {
pub selected_pipeline_idx: usize,
pub active_pipeline_for_prompt: Option<String>,
// 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<String>) {
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;

View file

@ -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::<String>(100);
let (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(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) => {

View file

@ -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<Line> = 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<Line>) {
fn render_clean_markdown_body(content: &str, lines: &mut Vec<Line>) {
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<Line> = 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<ListItem> = 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<ListItem> = 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] ",