feat: add opencode/pi-style interactive TUI and dynamic candidate discovery for rotated free models

This commit is contained in:
katR 2026-09-26 14:48:53 +05:30
parent 172e9d3165
commit 65e265a535
10 changed files with 1596 additions and 54 deletions

View file

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

View file

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

View file

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

View file

@ -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<Commands>,
}
#[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.")]

View file

@ -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,12 +236,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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
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,
@ -250,19 +270,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?;
}
}
}
Ok(())
}
async fn run_chat(
args: ChatArgs,
config: &AppConfig,
budget_tracker: &mut BudgetTracker,
model_registry: Arc<ModelRegistry>,
) -> Result<(), Box<dyn std::error::Error>> {
let api_key = match config.api_key.clone() {
Some(k) if !k.trim().is_empty() => k,
_ => {
fn ensure_api_key(config: &AppConfig) -> Result<String, Box<dyn std::error::Error>> {
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(),
@ -271,7 +315,15 @@ async fn run_chat(
);
std::process::exit(1);
}
};
}
async fn run_chat(
args: ChatArgs,
config: &AppConfig,
budget_tracker: &mut BudgetTracker,
model_registry: Arc<ModelRegistry>,
) -> Result<(), Box<dyn std::error::Error>> {
let api_key = ensure_api_key(config)?;
let client = OpenRouterClient::with_base_url(api_key, config.openrouter_base_url.clone());

View file

@ -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
// 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() {

View file

@ -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<ModelCandidate> {
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)]

275
src/tui/app.rs Normal file
View file

@ -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<ChatMessage>,
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<PersonaItem>,
pub selected_persona_idx: usize,
// Pipelines & selection
pub pipelines: Vec<PipelineConfig>,
pub selected_pipeline_idx: usize,
pub active_pipeline_for_prompt: Option<String>,
// 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<PipelineConfig>,
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;
}
}
}

557
src/tui/mod.rs Normal file
View file

@ -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<String>,
calls_made: usize,
paid_calls_made: usize,
},
}
pub async fn run_tui(
config: AppConfig,
mut budget_tracker: BudgetTracker,
model_registry: Arc<ModelRegistry>,
allow_stealth: bool,
escalate: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// 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::<String>()
};
(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::<WorkerEvent>(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<ModelRegistry>,
client: &Arc<OpenRouterClient>,
router: &Arc<Router>,
worker_tx: mpsc::Sender<WorkerEvent>,
) -> 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<ModelRegistry>,
client: &Arc<OpenRouterClient>,
router: &Arc<Router>,
worker_tx: mpsc::Sender<WorkerEvent>,
) {
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::<PipelineEvent>(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::<String>(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;
}
}
});
}

582
src/tui/ui.rs Normal file
View file

@ -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<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),
),
]));
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<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::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<ListItem> = 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<ListItem> = 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<String> = 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]
}