feat: add opencode/pi-style interactive TUI and dynamic candidate discovery for rotated free models
This commit is contained in:
parent
172e9d3165
commit
65e265a535
10 changed files with 1596 additions and 54 deletions
|
|
@ -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"
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -23,24 +23,24 @@ BrokeCrew makes free-tier models first-class citizens while eliminating their in
|
|||
|
||||
---
|
||||
|
||||
## Terminal Experience
|
||||
## OpenCode / Pi-Style Interactive TUI
|
||||
|
||||
BrokeCrew formats multi-agent discussions like a vibrant team group chat:
|
||||
Simply run `brokecrew` to enter the rich, full-screen interactive TUI:
|
||||
|
||||
```text
|
||||
$ brokecrew chat --pipeline build-and-review "Write a Rust LRU cache"
|
||||
|
||||
[coder] (qwen/qwen-2.5-coder-32b-instruct:free)
|
||||
Here is a thread-safe, high-performance LRU cache implementation using std::collections::HashMap
|
||||
and a doubly-linked list with generational indexing...
|
||||
|
||||
[reviewer] (meta-llama/llama-3.3-70b-instruct:free)
|
||||
Code Review:
|
||||
1. Concurrency: Eviction edge case when capacity is 0 is unhandled.
|
||||
2. Memory Safety: Generational indices avoid raw pointers; good choice.
|
||||
3. Suggestion: Implement std::borrow::Borrow to allow lookup by &str rather than String.
|
||||
```bash
|
||||
brokecrew
|
||||
```
|
||||
|
||||
- **Live Team View**: Color-coded persona avatars (`⚡ coder`, `🔍 reviewer`, `🏛️ architect`), token streaming with markdown & syntax formatting.
|
||||
- **Quota & Budget Monitor**: Real-time headers displaying rolling minute and 24h limits (`⏱ 1m: 2/20`, `📅 24h: 14/50`).
|
||||
- **Interactive Modals**:
|
||||
- `[Tab]` — Open Persona Selector (switch between intelligent Stage A/B `Auto-Route` and dedicated personas).
|
||||
- `[Ctrl+P]` — Open Pipeline Selector (execute multi-agent pipelines like `build-and-review`).
|
||||
- `[PgUp/PgDn]` — Scroll conversation history.
|
||||
- `[Ctrl+L]` — Clear screen.
|
||||
- `[Esc]` — Dismiss modal or exit.
|
||||
- **Non-TUI / Scripting**: Use `brokecrew --cli` or `brokecrew chat "..."` for headless pipelines or script integration.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
15
src/cli.rs
15
src/cli.rs
|
|
@ -33,12 +33,18 @@ pub struct Cli {
|
|||
#[arg(long, short, global = true, help = "Enable verbose debug logs")]
|
||||
pub verbose: bool,
|
||||
|
||||
#[arg(long, global = true, help = "Force non-TUI line CLI mode")]
|
||||
pub cli: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<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.")]
|
||||
|
|
|
|||
94
src/main.rs
94
src/main.rs
|
|
@ -1,4 +1,5 @@
|
|||
mod cli;
|
||||
mod tui;
|
||||
|
||||
use brokecrew::budget::BudgetTracker;
|
||||
use brokecrew::client::OpenRouterClient;
|
||||
|
|
@ -11,7 +12,7 @@ use clap::Parser;
|
|||
use cli::{ChatArgs, Cli, Commands, ConfigSubcommand, PersonasSubcommand};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use owo_colors::OwoColorize;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::io::{self, BufRead, IsTerminal, Write};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
|
@ -235,43 +236,94 @@ async fn main() -> Result<(), Box<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
|
||||
run_chat(
|
||||
ChatArgs::default(),
|
||||
&config,
|
||||
&mut budget_tracker,
|
||||
model_registry,
|
||||
)
|
||||
.await?;
|
||||
if !cli.cli && io::stdout().is_terminal() {
|
||||
let api_key = ensure_api_key(&config)?;
|
||||
let mut conf = config;
|
||||
conf.api_key = Some(api_key);
|
||||
tui::run_tui(conf, budget_tracker, model_registry, false, false).await?;
|
||||
} else {
|
||||
run_chat(
|
||||
ChatArgs::default(),
|
||||
&config,
|
||||
&mut budget_tracker,
|
||||
model_registry,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_api_key(config: &AppConfig) -> Result<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(),
|
||||
"brokecrew config set-key".bright_green().bold(),
|
||||
"export OPENROUTER_API_KEY=\"sk-or-v1-...\"".cyan()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_chat(
|
||||
args: ChatArgs,
|
||||
config: &AppConfig,
|
||||
budget_tracker: &mut BudgetTracker,
|
||||
model_registry: Arc<ModelRegistry>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key = match config.api_key.clone() {
|
||||
Some(k) if !k.trim().is_empty() => k,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"{}\nRun: {}\nOr set: {}",
|
||||
"Error: OpenRouter API key not configured.".red().bold(),
|
||||
"brokecrew config set-key".bright_green().bold(),
|
||||
"export OPENROUTER_API_KEY=\"sk-or-v1-...\"".cyan()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let api_key = ensure_api_key(config)?;
|
||||
|
||||
let client = OpenRouterClient::with_base_url(api_key, config.openrouter_base_url.clone());
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::client::{ClientError, ModelClient};
|
||||
use crate::persona::{DispatchResult, ModelAttempt, PersonaRegistry, Tier};
|
||||
use crate::persona::{DispatchResult, ModelAttempt, ModelCandidate, PersonaRegistry, Tier};
|
||||
use crate::registry::ModelRegistry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
|
@ -65,7 +65,8 @@ impl PipelineExecutor {
|
|||
.filter_eligible(&persona.candidates, allow_stealth, require_paid)
|
||||
.await;
|
||||
|
||||
let candidates_to_try = if !eligible.is_empty() {
|
||||
let dynamic_discovered;
|
||||
let candidates_to_try: Vec<&ModelCandidate> = if !eligible.is_empty() {
|
||||
eligible
|
||||
} else if model_registry.model_count().await == 0 {
|
||||
// Uninitialized / offline degraded state: allow raw candidates
|
||||
|
|
@ -75,8 +76,21 @@ impl PipelineExecutor {
|
|||
.filter(|c| allow_stealth || !c.is_stealth)
|
||||
.collect()
|
||||
} else {
|
||||
// Live catalog was fetched and deliberately excluded all candidates as offline/unlisted
|
||||
vec![]
|
||||
// Live catalog was fetched, but declared candidates were rotated out.
|
||||
// Dynamically discover active live free models for this persona:
|
||||
dynamic_discovered = model_registry
|
||||
.discover_free_candidates_for_role(persona_name)
|
||||
.await;
|
||||
if !dynamic_discovered.is_empty() {
|
||||
tracing::info!(
|
||||
persona = %persona_name,
|
||||
count = dynamic_discovered.len(),
|
||||
"Auto-discovered live free fallback candidates from OpenRouter catalog"
|
||||
);
|
||||
dynamic_discovered.iter().collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
if candidates_to_try.is_empty() {
|
||||
|
|
|
|||
|
|
@ -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
275
src/tui/app.rs
Normal 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
557
src/tui/mod.rs
Normal 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
582
src/tui/ui.rs
Normal 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]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue