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