feat: theme persistence, 6 new themes, accurate token tracking, rectangular prompt card fix, and animated pipeline flow
This commit is contained in:
parent
bf02bf3bc6
commit
e8dd01509c
6 changed files with 799 additions and 129 deletions
|
|
@ -7,6 +7,14 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
const MINUTE_LIMIT: usize = 20;
|
||||
|
||||
/// Estimates token count from text using standard heuristic (~4 characters per token).
|
||||
pub fn estimate_tokens(text: &str) -> usize {
|
||||
if text.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
text.len().div_ceil(4).max(1)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CreditTier {
|
||||
LowBalance, // < $10 lifetime purchased: 50 requests/day
|
||||
|
|
|
|||
|
|
@ -9,23 +9,6 @@ const KEYRING_SERVICE: &str = "brokecrew";
|
|||
const KEYRING_USER: &str = "openrouter_api_key";
|
||||
const DEFAULT_PERSONAS_TOML: &str = include_str!("../config/personas.example.toml");
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfigFile {
|
||||
#[serde(default = "default_persona_str")]
|
||||
pub default_persona: String,
|
||||
#[serde(default = "default_router_model")]
|
||||
pub router_model: String,
|
||||
#[serde(default = "default_complexity_threshold")]
|
||||
pub complexity_threshold: u8,
|
||||
#[serde(default)]
|
||||
pub allow_stealth: bool,
|
||||
#[serde(default = "default_credit_tier")]
|
||||
pub credit_tier: CreditTier,
|
||||
#[serde(default = "default_base_url")]
|
||||
pub openrouter_base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
fn default_persona_str() -> String {
|
||||
"general".to_string()
|
||||
}
|
||||
|
|
@ -41,6 +24,28 @@ fn default_credit_tier() -> CreditTier {
|
|||
fn default_base_url() -> String {
|
||||
"https://openrouter.ai/api/v1".to_string()
|
||||
}
|
||||
fn default_theme_str() -> String {
|
||||
"opencode".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfigFile {
|
||||
#[serde(default = "default_persona_str")]
|
||||
pub default_persona: String,
|
||||
#[serde(default = "default_router_model")]
|
||||
pub router_model: String,
|
||||
#[serde(default = "default_complexity_threshold")]
|
||||
pub complexity_threshold: u8,
|
||||
#[serde(default)]
|
||||
pub allow_stealth: bool,
|
||||
#[serde(default = "default_credit_tier")]
|
||||
pub credit_tier: CreditTier,
|
||||
#[serde(default = "default_base_url")]
|
||||
pub openrouter_base_url: String,
|
||||
#[serde(default = "default_theme_str")]
|
||||
pub theme: String,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppConfigFile {
|
||||
fn default() -> Self {
|
||||
|
|
@ -51,6 +56,7 @@ impl Default for AppConfigFile {
|
|||
allow_stealth: false,
|
||||
credit_tier: default_credit_tier(),
|
||||
openrouter_base_url: default_base_url(),
|
||||
theme: default_theme_str(),
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +77,7 @@ pub struct AppConfig {
|
|||
pub allow_stealth: bool,
|
||||
pub credit_tier: CreditTier,
|
||||
pub openrouter_base_url: String,
|
||||
pub theme: String,
|
||||
pub api_key: Option<String>,
|
||||
pub persona_registry: PersonaRegistry,
|
||||
pub pipelines: Vec<PipelineConfig>,
|
||||
|
|
@ -131,6 +138,7 @@ impl AppConfig {
|
|||
allow_stealth: app_file.allow_stealth,
|
||||
credit_tier: app_file.credit_tier,
|
||||
openrouter_base_url: app_file.openrouter_base_url,
|
||||
theme: app_file.theme,
|
||||
api_key,
|
||||
persona_registry: PersonaRegistry::new(personas_config.personas),
|
||||
pipelines: personas_config.pipelines,
|
||||
|
|
@ -138,6 +146,28 @@ impl AppConfig {
|
|||
})
|
||||
}
|
||||
|
||||
/// Persists the selected theme into `config.toml`
|
||||
pub fn save_theme(config_dir: &Path, theme: &str) -> Result<(), String> {
|
||||
let config_file_path = config_dir.join("config.toml");
|
||||
let mut app_file = if config_file_path.exists() {
|
||||
let data = fs::read_to_string(&config_file_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", config_file_path.display()))?;
|
||||
toml::from_str::<AppConfigFile>(&data).unwrap_or_default()
|
||||
} else {
|
||||
AppConfigFile::default()
|
||||
};
|
||||
|
||||
app_file.theme = theme.to_string();
|
||||
let serialized = toml::to_string_pretty(&app_file)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
|
||||
fs::create_dir_all(config_dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
fs::write(&config_file_path, serialized)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", config_file_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the API key using precedence:
|
||||
/// 1. CLI argument
|
||||
/// 2. `OPENROUTER_API_KEY` environment variable
|
||||
|
|
@ -237,4 +267,14 @@ mod tests {
|
|||
assert!(parsed.personas.len() >= 3);
|
||||
assert!(!parsed.pipelines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_persistence() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
AppConfig::save_theme(temp_dir.path(), "dracula").expect("save theme");
|
||||
let loaded =
|
||||
AppConfig::load_from_paths(Some(&temp_dir.path().join("config.toml")), None, None)
|
||||
.expect("load config");
|
||||
assert_eq!(loaded.theme, "dracula");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ pub mod router;
|
|||
pub mod session;
|
||||
pub mod tool;
|
||||
|
||||
pub use budget::{BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier};
|
||||
pub use budget::{estimate_tokens, BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier};
|
||||
pub use client::{ClientError, ModelClient, OpenRouterClient};
|
||||
pub use config::{AppConfig, AppConfigFile, PersonasConfigFile};
|
||||
pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier};
|
||||
|
|
|
|||
112
src/tui/app.rs
112
src/tui/app.rs
|
|
@ -112,6 +112,30 @@ pub const THEMES: &[(&str, &str)] = &[
|
|||
"Cyberpunk - Midnight, neon pink & electric blue",
|
||||
),
|
||||
("mono", "Monochrome - High contrast grayscale minimal"),
|
||||
(
|
||||
"catppuccin",
|
||||
"Catppuccin Mocha - Soothing lavender, peach & velvet navy",
|
||||
),
|
||||
(
|
||||
"tokyo_night",
|
||||
"Tokyo Night - Midnight indigo, vibrant neon blue & cyan",
|
||||
),
|
||||
(
|
||||
"nord",
|
||||
"Nord - Polar night arctic frost, glacial blue & snow",
|
||||
),
|
||||
(
|
||||
"dracula",
|
||||
"Dracula - Classic vampire dark slate, neon purple & green",
|
||||
),
|
||||
(
|
||||
"solarized",
|
||||
"Solarized Dark - Deep ocean teal, cyan & solar amber",
|
||||
),
|
||||
(
|
||||
"gruvbox",
|
||||
"Gruvbox Dark - Retro dark charcoal, warm bright orange",
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -129,13 +153,25 @@ pub struct CandidateAttemptInfo {
|
|||
pub is_success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PipelineStepTelemetry {
|
||||
pub persona: String,
|
||||
pub model: String,
|
||||
pub tier: Tier,
|
||||
pub is_active: bool,
|
||||
pub is_completed: bool,
|
||||
pub fell_back: 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 complexity_score: Option<u8>,
|
||||
pub candidate_attempts: Vec<CandidateAttemptInfo>,
|
||||
pub pipeline_steps: Vec<PipelineStepTelemetry>,
|
||||
pub events: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +182,9 @@ impl Default for WorkingsState {
|
|||
active_pipeline: None,
|
||||
route_stage: None,
|
||||
route_details: None,
|
||||
complexity_score: None,
|
||||
candidate_attempts: Vec::new(),
|
||||
pipeline_steps: Vec::new(),
|
||||
events: vec![
|
||||
format!(
|
||||
"{} brokecrew online",
|
||||
|
|
@ -199,6 +237,8 @@ pub struct AppState {
|
|||
pub daily_requests: usize,
|
||||
pub daily_limit: usize,
|
||||
pub models_cached: usize,
|
||||
pub tokens_in: usize,
|
||||
pub tokens_out: usize,
|
||||
|
||||
// Options
|
||||
pub allow_stealth: bool,
|
||||
|
|
@ -218,6 +258,7 @@ impl AppState {
|
|||
minute_limit: usize,
|
||||
daily_limit: usize,
|
||||
config_dir: PathBuf,
|
||||
initial_theme: String,
|
||||
) -> Self {
|
||||
let mut persona_items = Vec::new();
|
||||
// Item 0 is always the smart Auto-Router
|
||||
|
|
@ -234,6 +275,10 @@ impl AppState {
|
|||
|
||||
let session = Session::new("auto");
|
||||
let session_summaries = Session::list(&config_dir).unwrap_or_default();
|
||||
let selected_theme_idx = THEMES
|
||||
.iter()
|
||||
.position(|(name, _)| *name == initial_theme)
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
|
|
@ -261,12 +306,22 @@ impl AppState {
|
|||
daily_requests: 0,
|
||||
daily_limit,
|
||||
models_cached: 0,
|
||||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
allow_stealth: false,
|
||||
escalate: false,
|
||||
expand_tool_outputs: false,
|
||||
current_theme: "opencode".to_string(),
|
||||
current_theme: initial_theme,
|
||||
selected_suggestion_idx: 0,
|
||||
selected_theme_idx: 0,
|
||||
selected_theme_idx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_tokens(tokens: usize) -> String {
|
||||
if tokens < 1000 {
|
||||
format!("{tokens}")
|
||||
} else {
|
||||
format!("{:.1}k", tokens as f64 / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,13 +341,13 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn cycle_theme(&mut self) {
|
||||
self.current_theme = match self.current_theme.as_str() {
|
||||
"opencode" => "warm".to_string(),
|
||||
"warm" => "forest".to_string(),
|
||||
"forest" => "cyberpunk".to_string(),
|
||||
"cyberpunk" => "mono".to_string(),
|
||||
_ => "opencode".to_string(),
|
||||
};
|
||||
let current_pos = THEMES
|
||||
.iter()
|
||||
.position(|(name, _)| *name == self.current_theme)
|
||||
.unwrap_or(0);
|
||||
let next_pos = (current_pos + 1) % THEMES.len();
|
||||
self.current_theme = THEMES[next_pos].0.to_string();
|
||||
self.selected_theme_idx = next_pos;
|
||||
}
|
||||
|
||||
pub fn current_persona_name(&self) -> &str {
|
||||
|
|
@ -322,7 +377,9 @@ impl AppState {
|
|||
self.workings.active_pipeline = pipeline;
|
||||
self.workings.route_stage = None;
|
||||
self.workings.route_details = None;
|
||||
self.workings.complexity_score = None;
|
||||
self.workings.candidate_attempts.clear();
|
||||
self.workings.pipeline_steps.clear();
|
||||
}
|
||||
|
||||
pub fn update_current_message_model(&mut self, model_id: &str) {
|
||||
|
|
@ -482,6 +539,19 @@ impl AppState {
|
|||
});
|
||||
}
|
||||
|
||||
self.tokens_in = self
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.sender, MessageSender::User))
|
||||
.map(|m| brokecrew::estimate_tokens(&m.content))
|
||||
.sum();
|
||||
self.tokens_out = self
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| !matches!(m.sender, MessageSender::User | MessageSender::System))
|
||||
.map(|m| brokecrew::estimate_tokens(&m.content))
|
||||
.sum();
|
||||
|
||||
self.active_session = loaded;
|
||||
self.scroll_offset = 0;
|
||||
self.log_working_event(&format!("[session] loaded {}", session_id));
|
||||
|
|
@ -492,6 +562,8 @@ impl AppState {
|
|||
let persona = self.current_persona_name().to_string();
|
||||
self.active_session = Session::new(&persona);
|
||||
self.messages.clear();
|
||||
self.tokens_in = 0;
|
||||
self.tokens_out = 0;
|
||||
self.add_system_message("Started fresh session.".to_string());
|
||||
self.scroll_offset = 0;
|
||||
self.log_working_event("[session] started fresh session");
|
||||
|
|
@ -581,7 +653,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_app_start_screen_and_suggestions() {
|
||||
let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test"));
|
||||
let mut app = AppState::new(
|
||||
vec![],
|
||||
vec![],
|
||||
20,
|
||||
50,
|
||||
PathBuf::from("/tmp/brokecrew_test"),
|
||||
"opencode".to_string(),
|
||||
);
|
||||
assert!(app.is_start_screen());
|
||||
|
||||
app.input = "/se".to_string();
|
||||
|
|
@ -600,7 +679,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_app_theme_cycling() {
|
||||
let mut app = AppState::new(vec![], vec![], 20, 50, PathBuf::from("/tmp/brokecrew_test"));
|
||||
let mut app = AppState::new(
|
||||
vec![],
|
||||
vec![],
|
||||
20,
|
||||
50,
|
||||
PathBuf::from("/tmp/brokecrew_test"),
|
||||
"opencode".to_string(),
|
||||
);
|
||||
assert_eq!(app.current_theme, "opencode");
|
||||
app.cycle_theme();
|
||||
assert_eq!(app.current_theme, "warm");
|
||||
|
|
@ -611,6 +697,8 @@ mod tests {
|
|||
app.cycle_theme();
|
||||
assert_eq!(app.current_theme, "mono");
|
||||
app.cycle_theme();
|
||||
assert_eq!(app.current_theme, "opencode");
|
||||
assert_eq!(app.current_theme, "catppuccin");
|
||||
app.cycle_theme();
|
||||
assert_eq!(app.current_theme, "tokyo_night");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
src/tui/mod.rs
153
src/tui/mod.rs
|
|
@ -33,10 +33,15 @@ impl Drop for TerminalGuard {
|
|||
}
|
||||
|
||||
pub enum WorkerEvent {
|
||||
PipelineStarted {
|
||||
pipeline_name: String,
|
||||
steps: Vec<String>,
|
||||
},
|
||||
RouterResolved {
|
||||
persona: String,
|
||||
stage: RouteStage,
|
||||
details: String,
|
||||
complexity_score: u8,
|
||||
},
|
||||
CandidateAttempt {
|
||||
model: String,
|
||||
|
|
@ -79,6 +84,8 @@ pub enum WorkerEvent {
|
|||
error: Option<String>,
|
||||
calls_made: usize,
|
||||
paid_calls_made: usize,
|
||||
tokens_in: usize,
|
||||
tokens_out: usize,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +131,7 @@ pub async fn run_tui(
|
|||
minute_limit,
|
||||
daily_limit,
|
||||
config.config_dir.clone(),
|
||||
config.theme.clone(),
|
||||
);
|
||||
app.allow_stealth = allow_stealth || config.allow_stealth;
|
||||
app.escalate = escalate;
|
||||
|
|
@ -204,10 +212,37 @@ pub async fn run_tui(
|
|||
|
||||
fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, event: WorkerEvent) {
|
||||
match event {
|
||||
WorkerEvent::PipelineStarted {
|
||||
pipeline_name,
|
||||
steps,
|
||||
} => {
|
||||
app.workings.active_pipeline = Some(pipeline_name.clone());
|
||||
app.workings.pipeline_steps = steps
|
||||
.into_iter()
|
||||
.map(|p| app::PipelineStepTelemetry {
|
||||
persona: p,
|
||||
model: "evaluating...".to_string(),
|
||||
tier: Tier::Free,
|
||||
is_active: false,
|
||||
is_completed: false,
|
||||
fell_back: false,
|
||||
})
|
||||
.collect();
|
||||
if let Some(first) = app.workings.pipeline_steps.first_mut() {
|
||||
first.is_active = true;
|
||||
}
|
||||
app.log_working_event(&format!(
|
||||
"[pipeline:start] '{}' with {} steps",
|
||||
pipeline_name,
|
||||
app.workings.pipeline_steps.len()
|
||||
));
|
||||
}
|
||||
|
||||
WorkerEvent::RouterResolved {
|
||||
persona,
|
||||
stage,
|
||||
details,
|
||||
complexity_score,
|
||||
} => {
|
||||
let stage_name = match stage {
|
||||
RouteStage::StageAKeyword => "Stage A (Keyword Filter)",
|
||||
|
|
@ -215,7 +250,11 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
|||
};
|
||||
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));
|
||||
app.workings.complexity_score = Some(complexity_score);
|
||||
app.log_working_event(&format!(
|
||||
"[route] {} ➔ {} (complexity: {}/10)",
|
||||
stage_name, persona, complexity_score
|
||||
));
|
||||
}
|
||||
|
||||
WorkerEvent::CandidateAttempt {
|
||||
|
|
@ -275,6 +314,32 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
|||
model,
|
||||
tier,
|
||||
} => {
|
||||
if step_index < app.workings.pipeline_steps.len() {
|
||||
for (i, st) in app.workings.pipeline_steps.iter_mut().enumerate() {
|
||||
if i < step_index {
|
||||
st.is_completed = true;
|
||||
st.is_active = false;
|
||||
} else if i == step_index {
|
||||
st.is_active = true;
|
||||
st.model = model.clone();
|
||||
st.tier = tier;
|
||||
} else {
|
||||
st.is_active = false;
|
||||
}
|
||||
}
|
||||
} else if app.workings.pipeline_steps.is_empty() {
|
||||
app.workings
|
||||
.pipeline_steps
|
||||
.push(app::PipelineStepTelemetry {
|
||||
persona: persona.clone(),
|
||||
model: model.clone(),
|
||||
tier,
|
||||
is_active: true,
|
||||
is_completed: false,
|
||||
fell_back: false,
|
||||
});
|
||||
}
|
||||
|
||||
if total_steps > 1 {
|
||||
app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier);
|
||||
app.log_working_event(&format!(
|
||||
|
|
@ -310,6 +375,11 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
|||
}
|
||||
|
||||
WorkerEvent::StepCompleted { fell_back } => {
|
||||
if let Some(st) = app.workings.pipeline_steps.iter_mut().find(|s| s.is_active) {
|
||||
st.is_completed = true;
|
||||
st.is_active = false;
|
||||
st.fell_back = fell_back;
|
||||
}
|
||||
if fell_back {
|
||||
app.mark_current_message_fallback();
|
||||
}
|
||||
|
|
@ -321,9 +391,20 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
|||
error,
|
||||
calls_made,
|
||||
paid_calls_made,
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
app.is_generating = false;
|
||||
app.status_message = "Ready".to_string();
|
||||
app.tokens_in += tokens_in;
|
||||
app.tokens_out += tokens_out;
|
||||
|
||||
for st in app.workings.pipeline_steps.iter_mut() {
|
||||
if !st.is_completed {
|
||||
st.is_completed = true;
|
||||
st.is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
if !model_used.is_empty() && model_used != "none" {
|
||||
app.update_current_message_model(&model_used);
|
||||
|
|
@ -349,7 +430,8 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
|||
app.log_working_event(&format!("[complete] response finished ({model_used})"));
|
||||
}
|
||||
|
||||
for _ in 0..calls_made {
|
||||
let free_calls = calls_made.saturating_sub(paid_calls_made);
|
||||
for _ in 0..free_calls {
|
||||
let _ = budget_tracker.record_call(false);
|
||||
}
|
||||
for _ in 0..paid_calls_made {
|
||||
|
|
@ -470,6 +552,7 @@ fn handle_key_event(
|
|||
KeyCode::Enter => {
|
||||
if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) {
|
||||
app.current_theme = name.to_string();
|
||||
let _ = AppConfig::save_theme(&app.config_dir, name);
|
||||
app.log_working_event(&format!("[theme] selected {}", name));
|
||||
}
|
||||
app.mode = TuiMode::Normal;
|
||||
|
|
@ -678,11 +761,13 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
|||
}
|
||||
"/usage" => {
|
||||
app.add_system_message(format!(
|
||||
"Rate Limit & Budget Usage:\n 1-Minute Rate: {}/{} requests\n 24-Hour Budget: {}/{} requests\n Cached Models: {} available",
|
||||
"Rate Limit & Budget Usage:\n 1-Minute Rate: {}/{} requests\n 24-Hour Budget: {}/{} requests\n Tokens In: {} (est)\n Tokens Out: {} (est)\n Cached Models: {} available",
|
||||
app.minute_requests,
|
||||
app.minute_limit,
|
||||
app.daily_requests,
|
||||
app.daily_limit,
|
||||
app::AppState::format_tokens(app.tokens_in),
|
||||
app::AppState::format_tokens(app.tokens_out),
|
||||
app.models_cached
|
||||
));
|
||||
}
|
||||
|
|
@ -691,10 +776,12 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
|||
if *arg == "next" {
|
||||
app.cycle_theme();
|
||||
let theme_name = app.current_theme.clone();
|
||||
let _ = AppConfig::save_theme(&app.config_dir, &theme_name);
|
||||
app.add_system_message(format!("Theme set to: {theme_name}"));
|
||||
return;
|
||||
} else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) {
|
||||
app.current_theme = name.to_string();
|
||||
let _ = AppConfig::save_theme(&app.config_dir, name);
|
||||
app.add_system_message(format!("Theme set to: {name}"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -782,6 +869,14 @@ fn spawn_dispatch_task(
|
|||
tokio::spawn(async move {
|
||||
if let Some(pipe_name) = pipeline_name {
|
||||
if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_name) {
|
||||
let steps: Vec<String> = pipe.steps.iter().map(|s| s.persona.clone()).collect();
|
||||
let _ = worker_tx
|
||||
.send(WorkerEvent::PipelineStarted {
|
||||
pipeline_name: pipe_name.clone(),
|
||||
steps,
|
||||
})
|
||||
.await;
|
||||
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(50);
|
||||
let w_tx = worker_tx.clone();
|
||||
|
||||
|
|
@ -863,6 +958,11 @@ fn spawn_dispatch_task(
|
|||
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 tok_in = brokecrew::estimate_tokens(&prompt);
|
||||
let tok_out: usize = results
|
||||
.iter()
|
||||
.map(|r| brokecrew::estimate_tokens(&r.reply))
|
||||
.sum();
|
||||
let _ = worker_tx
|
||||
.send(WorkerEvent::Finished {
|
||||
fell_back,
|
||||
|
|
@ -870,6 +970,8 @@ fn spawn_dispatch_task(
|
|||
error: None,
|
||||
calls_made: total_calls + results.len(),
|
||||
paid_calls_made: paid_calls,
|
||||
tokens_in: tok_in,
|
||||
tokens_out: tok_out,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
@ -881,6 +983,8 @@ fn spawn_dispatch_task(
|
|||
error: Some(e.to_string()),
|
||||
calls_made: 1,
|
||||
paid_calls_made: 0,
|
||||
tokens_in: brokecrew::estimate_tokens(&prompt),
|
||||
tokens_out: 0,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
@ -890,7 +994,7 @@ fn spawn_dispatch_task(
|
|||
}
|
||||
|
||||
// Determine target persona via router
|
||||
let (target_persona, route_stage, details) = if is_auto {
|
||||
let (target_persona, route_stage, details, complexity_score) = if is_auto {
|
||||
match router
|
||||
.route(&persona_registry, client.as_ref(), &prompt, escalate)
|
||||
.await
|
||||
|
|
@ -902,12 +1006,18 @@ fn spawn_dispatch_task(
|
|||
format!("LLM classifier (model: {})", router.router_model)
|
||||
}
|
||||
};
|
||||
(decision.persona, Some(decision.stage_used), d)
|
||||
(
|
||||
decision.persona,
|
||||
Some(decision.stage_used),
|
||||
d,
|
||||
decision.complexity_score,
|
||||
)
|
||||
}
|
||||
Err(err) => (
|
||||
default_persona,
|
||||
None,
|
||||
format!("Router fallback to default: {err}"),
|
||||
1,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
|
|
@ -915,6 +1025,7 @@ fn spawn_dispatch_task(
|
|||
direct_persona,
|
||||
None,
|
||||
"Direct persona selected by user".to_string(),
|
||||
1,
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -924,6 +1035,7 @@ fn spawn_dispatch_task(
|
|||
persona: target_persona.clone(),
|
||||
stage,
|
||||
details,
|
||||
complexity_score,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
@ -1066,23 +1178,32 @@ fn spawn_dispatch_task(
|
|||
|
||||
match followup_res {
|
||||
Ok(f_disp) => {
|
||||
let tok_in = brokecrew::estimate_tokens(&prompt)
|
||||
+ brokecrew::estimate_tokens(&tool_results_summary);
|
||||
let tok_out = brokecrew::estimate_tokens(&disp.reply)
|
||||
+ brokecrew::estimate_tokens(&f_disp.reply);
|
||||
let paid =
|
||||
if disp.tier_used == Tier::Paid || f_disp.tier_used == Tier::Paid {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let _ = worker_tx
|
||||
.send(WorkerEvent::Finished {
|
||||
fell_back: disp.fell_back || f_disp.fell_back,
|
||||
model_used: f_disp.model_used,
|
||||
error: None,
|
||||
calls_made: disp.attempts.len() + f_disp.attempts.len() + 2,
|
||||
paid_calls_made: if disp.tier_used == Tier::Paid
|
||||
|| f_disp.tier_used == Tier::Paid
|
||||
{
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
paid_calls_made: paid,
|
||||
tokens_in: tok_in,
|
||||
tokens_out: tok_out,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let tok_in = brokecrew::estimate_tokens(&prompt)
|
||||
+ brokecrew::estimate_tokens(&tool_results_summary);
|
||||
let tok_out = brokecrew::estimate_tokens(&disp.reply);
|
||||
let _ = worker_tx
|
||||
.send(WorkerEvent::Finished {
|
||||
fell_back: disp.fell_back,
|
||||
|
|
@ -1094,11 +1215,15 @@ fn spawn_dispatch_task(
|
|||
} else {
|
||||
0
|
||||
},
|
||||
tokens_in: tok_in,
|
||||
tokens_out: tok_out,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let tok_in = brokecrew::estimate_tokens(&prompt);
|
||||
let tok_out = brokecrew::estimate_tokens(&disp.reply);
|
||||
let _ = worker_tx
|
||||
.send(WorkerEvent::Finished {
|
||||
fell_back: disp.fell_back,
|
||||
|
|
@ -1106,6 +1231,8 @@ fn spawn_dispatch_task(
|
|||
error: None,
|
||||
calls_made: disp.attempts.len() + 1,
|
||||
paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 },
|
||||
tokens_in: tok_in,
|
||||
tokens_out: tok_out,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
@ -1118,6 +1245,8 @@ fn spawn_dispatch_task(
|
|||
error: Some(e.to_string()),
|
||||
calls_made: 1,
|
||||
paid_calls_made: 0,
|
||||
tokens_in: brokecrew::estimate_tokens(&prompt),
|
||||
tokens_out: 0,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
|
|||
579
src/tui/ui.rs
579
src/tui/ui.rs
|
|
@ -52,6 +52,54 @@ pub fn get_theme_palette(theme: &str) -> ThemePalette {
|
|||
text_bright: Color::White,
|
||||
text_muted: Color::DarkGray,
|
||||
},
|
||||
"catppuccin" => ThemePalette {
|
||||
accent: Color::Rgb(180, 190, 254), // lavender
|
||||
user_bg: Color::Rgb(49, 50, 68), // surface0
|
||||
card_border: Color::Rgb(88, 91, 112), // surface2
|
||||
tool_bg: Color::Rgb(30, 30, 46), // base
|
||||
text_bright: Color::Rgb(205, 214, 244), // text
|
||||
text_muted: Color::Rgb(147, 153, 178), // overlay0
|
||||
},
|
||||
"tokyo_night" => ThemePalette {
|
||||
accent: Color::Rgb(122, 162, 247), // tokyo blue
|
||||
user_bg: Color::Rgb(36, 40, 59),
|
||||
card_border: Color::Rgb(65, 72, 104),
|
||||
tool_bg: Color::Rgb(26, 27, 38),
|
||||
text_bright: Color::Rgb(192, 202, 245),
|
||||
text_muted: Color::Rgb(120, 130, 160),
|
||||
},
|
||||
"nord" => ThemePalette {
|
||||
accent: Color::Rgb(136, 192, 208), // frost cyan
|
||||
user_bg: Color::Rgb(59, 66, 82), // nord1
|
||||
card_border: Color::Rgb(76, 86, 106), // nord3
|
||||
tool_bg: Color::Rgb(46, 52, 64), // nord0
|
||||
text_bright: Color::Rgb(236, 239, 244), // nord6
|
||||
text_muted: Color::Rgb(140, 155, 170),
|
||||
},
|
||||
"dracula" => ThemePalette {
|
||||
accent: Color::Rgb(189, 147, 249), // purple
|
||||
user_bg: Color::Rgb(68, 71, 90), // current line
|
||||
card_border: Color::Rgb(98, 114, 164), // comment
|
||||
tool_bg: Color::Rgb(40, 42, 54), // bg
|
||||
text_bright: Color::Rgb(248, 248, 242), // fg
|
||||
text_muted: Color::Rgb(139, 147, 185),
|
||||
},
|
||||
"solarized" => ThemePalette {
|
||||
accent: Color::Rgb(42, 161, 152), // cyan
|
||||
user_bg: Color::Rgb(7, 54, 66), // base02
|
||||
card_border: Color::Rgb(88, 110, 117), // base01
|
||||
tool_bg: Color::Rgb(0, 43, 54), // base03
|
||||
text_bright: Color::Rgb(238, 232, 213), // base2
|
||||
text_muted: Color::Rgb(147, 161, 161), // base1
|
||||
},
|
||||
"gruvbox" => ThemePalette {
|
||||
accent: Color::Rgb(254, 128, 25), // orange
|
||||
user_bg: Color::Rgb(60, 56, 54), // dark2
|
||||
card_border: Color::Rgb(102, 92, 84), // dark4
|
||||
tool_bg: Color::Rgb(40, 40, 40), // dark0
|
||||
text_bright: Color::Rgb(235, 219, 178), // light1
|
||||
text_muted: Color::Rgb(168, 153, 132), // light4
|
||||
},
|
||||
_ => ThemePalette {
|
||||
// opencode (default)
|
||||
accent: Color::Rgb(80, 210, 230), // cyan / sky
|
||||
|
|
@ -283,11 +331,19 @@ fn render_header(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemeP
|
|||
}
|
||||
|
||||
fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect, palette: &ThemePalette) {
|
||||
let (chat_pct, side_pct) = if area.width >= 130 {
|
||||
(72, 28)
|
||||
} else if area.width >= 95 {
|
||||
(68, 32)
|
||||
} else {
|
||||
(64, 36)
|
||||
};
|
||||
|
||||
let workspace_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(78), // Left: Chat
|
||||
Constraint::Percentage(22), // Right: Compact routed models
|
||||
Constraint::Percentage(chat_pct), // Left: Chat
|
||||
Constraint::Percentage(side_pct), // Right: Telemetry & Pipeline
|
||||
])
|
||||
.split(area);
|
||||
|
||||
|
|
@ -420,6 +476,54 @@ fn render_suggestions_list(
|
|||
frame.render_widget(list, inner);
|
||||
}
|
||||
|
||||
fn wrap_text(line: &str, max_width: usize) -> Vec<String> {
|
||||
if line.is_empty() {
|
||||
return vec![String::new()];
|
||||
}
|
||||
if max_width == 0 {
|
||||
return vec![line.to_string()];
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut current_len = 0;
|
||||
|
||||
for word in line.split(' ') {
|
||||
let word_len = word.chars().count();
|
||||
if current.is_empty() {
|
||||
if word_len > max_width {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
for chunk in chars.chunks(max_width) {
|
||||
result.push(chunk.iter().collect());
|
||||
}
|
||||
} else {
|
||||
current.push_str(word);
|
||||
current_len = word_len;
|
||||
}
|
||||
} else if current_len + 1 + word_len <= max_width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
current_len += 1 + word_len;
|
||||
} else {
|
||||
result.push(current);
|
||||
if word_len > max_width {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
for chunk in chars.chunks(max_width) {
|
||||
result.push(chunk.iter().collect());
|
||||
}
|
||||
current = String::new();
|
||||
current_len = 0;
|
||||
} else {
|
||||
current = word.to_string();
|
||||
current_len = word_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let full_width = area.width.saturating_sub(1) as usize;
|
||||
|
|
@ -427,56 +531,62 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palett
|
|||
for msg in &app.messages {
|
||||
match &msg.sender {
|
||||
MessageSender::User => {
|
||||
let card_width = full_width.max(12);
|
||||
let inner_text_width = card_width.saturating_sub(4); // 2 cols for "▌ ", 2 cols right safety margin
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Top padding line
|
||||
let top_pad = card_width.saturating_sub(2);
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"▌ ",
|
||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(
|
||||
" ".repeat(full_width.saturating_sub(2)),
|
||||
Style::default().bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(" ".repeat(top_pad), Style::default().bg(palette.user_bg)),
|
||||
])
|
||||
.style(Style::default().bg(palette.user_bg)),
|
||||
);
|
||||
|
||||
// Content lines
|
||||
for text_line in msg.content.lines() {
|
||||
let pad_len = full_width.saturating_sub(text_line.len() + 3);
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"▌ ",
|
||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(
|
||||
text_line.to_string(),
|
||||
Style::default()
|
||||
.fg(palette.text_bright)
|
||||
.bold()
|
||||
.bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(" ".repeat(pad_len), Style::default().bg(palette.user_bg)),
|
||||
])
|
||||
.style(Style::default().bg(palette.user_bg)),
|
||||
);
|
||||
// Content lines with proper word-wrapping and exact right padding
|
||||
for raw_line in msg.content.lines() {
|
||||
let wrapped_lines = wrap_text(raw_line, inner_text_width);
|
||||
for text_line in wrapped_lines {
|
||||
let text_len = text_line.chars().count();
|
||||
let pad_len = card_width.saturating_sub(2 + text_len);
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"▌ ",
|
||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(
|
||||
text_line,
|
||||
Style::default()
|
||||
.fg(palette.text_bright)
|
||||
.bold()
|
||||
.bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(
|
||||
" ".repeat(pad_len),
|
||||
Style::default().bg(palette.user_bg),
|
||||
),
|
||||
])
|
||||
.style(Style::default().bg(palette.user_bg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding line
|
||||
let bot_pad = card_width.saturating_sub(2);
|
||||
lines.push(
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
"▌ ",
|
||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(
|
||||
" ".repeat(full_width.saturating_sub(2)),
|
||||
Style::default().bg(palette.user_bg),
|
||||
),
|
||||
Span::styled(" ".repeat(bot_pad), Style::default().bg(palette.user_bg)),
|
||||
])
|
||||
.style(Style::default().bg(palette.user_bg)),
|
||||
);
|
||||
|
|
@ -484,7 +594,70 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palett
|
|||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
MessageSender::Persona { .. } | MessageSender::PipelineStep { .. } => {
|
||||
MessageSender::Persona { name, model, tier } => {
|
||||
let tier_str = match tier {
|
||||
Tier::Free => "free",
|
||||
Tier::Paid => "paid",
|
||||
};
|
||||
let persona_color = get_persona_color(name);
|
||||
let mut header_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("⚡ {name}"),
|
||||
Style::default().fg(persona_color).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({model} • {tier_str})"),
|
||||
Style::default().fg(palette.text_muted),
|
||||
),
|
||||
];
|
||||
if msg.fell_back {
|
||||
header_spans.push(Span::raw(" "));
|
||||
header_spans.push(Span::styled(
|
||||
"⚠ fallback",
|
||||
Style::default().fg(Color::Yellow).bold(),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(header_spans));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
render_clean_markdown_body(&msg.content, &mut lines);
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
MessageSender::PipelineStep {
|
||||
step_index,
|
||||
total_steps,
|
||||
persona,
|
||||
model,
|
||||
tier,
|
||||
} => {
|
||||
let tier_str = match tier {
|
||||
Tier::Free => "free",
|
||||
Tier::Paid => "paid",
|
||||
};
|
||||
let persona_color = get_persona_color(persona);
|
||||
let mut header_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("⚡ Step {}/{}: {persona}", step_index + 1, total_steps),
|
||||
Style::default().fg(persona_color).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({model} • {tier_str})"),
|
||||
Style::default().fg(palette.text_muted),
|
||||
),
|
||||
];
|
||||
if msg.fell_back {
|
||||
header_spans.push(Span::raw(" "));
|
||||
header_spans.push(Span::styled(
|
||||
"⚠ fallback",
|
||||
Style::default().fg(Color::Yellow).bold(),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(header_spans));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
render_clean_markdown_body(&msg.content, &mut lines);
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
|
@ -780,11 +953,18 @@ fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect, palette:
|
|||
}
|
||||
|
||||
fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
|
||||
let is_pipeline = !app.workings.pipeline_steps.is_empty();
|
||||
let title_str = if is_pipeline {
|
||||
" pipeline flow "
|
||||
} else {
|
||||
" routing & models "
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_style(Style::default().fg(palette.card_border))
|
||||
.title(Span::styled(
|
||||
" models routed ",
|
||||
title_str,
|
||||
Style::default().fg(palette.text_muted).bold(),
|
||||
));
|
||||
|
||||
|
|
@ -792,71 +972,291 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette:
|
|||
frame.render_widget(block, area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let sp = spinner_frames[app.spinner_tick % spinner_frames.len()];
|
||||
|
||||
if is_pipeline {
|
||||
// Render Animated Multi-Agent Pipeline Flow
|
||||
let pipe_name = app
|
||||
.workings
|
||||
.active_pipeline
|
||||
.as_deref()
|
||||
.unwrap_or("build-and-review");
|
||||
|
||||
if app.workings.candidate_attempts.is_empty() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"waiting for prompt...",
|
||||
Style::default().fg(palette.text_muted).italic(),
|
||||
format!("⚡ pipeline: {pipe_name}"),
|
||||
Style::default().fg(palette.accent).bold(),
|
||||
),
|
||||
]));
|
||||
} else {
|
||||
for att in &app.workings.candidate_attempts {
|
||||
let (icon, color) = if att.is_success {
|
||||
("•", Color::Green)
|
||||
} else if att.is_error {
|
||||
("x", Color::Red)
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let total_steps = app.workings.pipeline_steps.len();
|
||||
for (idx, step) in app.workings.pipeline_steps.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
let prev_completed = app.workings.pipeline_steps[idx - 1].is_completed;
|
||||
let (connector_str, connector_color) = if prev_completed && step.is_active {
|
||||
let anim_frames = [" │ ", " ╽ ", " ┃ ", " ▼ "];
|
||||
let a = anim_frames[(app.spinner_tick / 2) % anim_frames.len()];
|
||||
(a, palette.accent)
|
||||
} else if prev_completed && step.is_completed {
|
||||
(" │ ", Color::LightGreen)
|
||||
} else {
|
||||
(" · ", palette.text_muted)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
connector_str,
|
||||
Style::default().fg(connector_color).bold(),
|
||||
)]));
|
||||
}
|
||||
|
||||
if step.is_active {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("[{sp}] "),
|
||||
Style::default().fg(palette.accent).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{}. {}", idx + 1, step.persona),
|
||||
Style::default().fg(palette.text_bright).bold(),
|
||||
),
|
||||
]));
|
||||
let model_label = truncate_str(&step.model, inner.width.saturating_sub(7) as usize);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("↳ {model_label}"),
|
||||
Style::default().fg(Color::LightCyan),
|
||||
),
|
||||
]));
|
||||
} else if step.is_completed {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[✔] ", Style::default().fg(Color::LightGreen).bold()),
|
||||
Span::styled(
|
||||
format!("{}. {}", idx + 1, step.persona),
|
||||
Style::default().fg(Color::White).bold(),
|
||||
),
|
||||
]));
|
||||
let model_label = truncate_str(&step.model, inner.width.saturating_sub(7) as usize);
|
||||
let tier_str = if step.tier == Tier::Paid {
|
||||
"paid"
|
||||
} else {
|
||||
"free"
|
||||
};
|
||||
let mut status_spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("served ({model_label} • {tier_str})"),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
];
|
||||
if step.fell_back {
|
||||
status_spans.push(Span::styled(
|
||||
" [fallback]",
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(status_spans));
|
||||
} else {
|
||||
("~", Color::Yellow)
|
||||
};
|
||||
|
||||
let tier_str = match att.tier {
|
||||
Tier::Free => "free",
|
||||
Tier::Paid => "paid",
|
||||
};
|
||||
|
||||
let model_display = truncate_str(&att.model, inner.width.saturating_sub(6) as usize);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[○] ", Style::default().fg(palette.text_muted)),
|
||||
Span::styled(
|
||||
format!("{}. {}", idx + 1, step.persona),
|
||||
Style::default().fg(palette.text_muted),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("queued ({}/{})", idx + 1, total_steps),
|
||||
Style::default().fg(palette.text_muted).italic(),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} else if app.workings.route_stage.is_some() || !app.workings.candidate_attempts.is_empty() {
|
||||
// Render Single Persona Routing Telemetry & Cascade Attempts
|
||||
if let Some(stage) = &app.workings.route_stage {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{icon} "), Style::default().fg(color).bold()),
|
||||
Span::styled(
|
||||
model_display,
|
||||
Style::default()
|
||||
.fg(if att.is_success {
|
||||
palette.text_bright
|
||||
} else {
|
||||
Color::Gray
|
||||
})
|
||||
.bold(),
|
||||
),
|
||||
]));
|
||||
|
||||
let status_text = if att.is_success {
|
||||
format!(" served [{tier_str}]")
|
||||
} else if att.is_error {
|
||||
let err_short = truncate_str(&att.status, inner.width.saturating_sub(6) as usize);
|
||||
format!(" failed: {err_short}")
|
||||
} else {
|
||||
format!(" attempting [{tier_str}]...")
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status_text,
|
||||
if att.is_error {
|
||||
Style::default().fg(Color::LightRed).italic()
|
||||
} else if att.is_success {
|
||||
Style::default().fg(Color::LightGreen)
|
||||
} else {
|
||||
Style::default().fg(palette.text_muted)
|
||||
},
|
||||
),
|
||||
Span::styled("⚡ ", Style::default().fg(palette.accent).bold()),
|
||||
Span::styled(stage.clone(), Style::default().fg(palette.accent).bold()),
|
||||
]));
|
||||
if let Some(details) = &app.workings.route_details {
|
||||
let d_trunc = truncate_str(details, inner.width.saturating_sub(4) as usize);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(d_trunc, Style::default().fg(palette.text_muted)),
|
||||
]));
|
||||
}
|
||||
if let Some(score) = app.workings.complexity_score {
|
||||
let filled = (score as usize).min(10);
|
||||
let empty = 10 - filled;
|
||||
let bar_str = format!("{}{}", "■".repeat(filled), "□".repeat(empty));
|
||||
let color = if score >= 7 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
};
|
||||
let esc_tag = if score >= 7 {
|
||||
" [paid escalation]"
|
||||
} else {
|
||||
" [free tier]"
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("[{bar_str}] {score}/10{esc_tag}"),
|
||||
Style::default().fg(color).bold(),
|
||||
),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
if !app.workings.candidate_attempts.is_empty() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"candidates evaluated:",
|
||||
Style::default().fg(palette.text_muted).bold(),
|
||||
),
|
||||
]));
|
||||
|
||||
for att in &app.workings.candidate_attempts {
|
||||
let (icon, color) = if att.is_success {
|
||||
("•", Color::LightGreen)
|
||||
} else if att.is_error {
|
||||
("✖", Color::LightRed)
|
||||
} else {
|
||||
(sp, Color::Yellow)
|
||||
};
|
||||
|
||||
let tier_str = match att.tier {
|
||||
Tier::Free => "free",
|
||||
Tier::Paid => "paid",
|
||||
};
|
||||
|
||||
let model_display =
|
||||
truncate_str(&att.model, inner.width.saturating_sub(6) as usize);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{icon} "), Style::default().fg(color).bold()),
|
||||
Span::styled(
|
||||
model_display,
|
||||
Style::default()
|
||||
.fg(if att.is_success {
|
||||
palette.text_bright
|
||||
} else {
|
||||
Color::Gray
|
||||
})
|
||||
.bold(),
|
||||
),
|
||||
]));
|
||||
|
||||
let status_text = if att.is_success {
|
||||
format!(" served [{tier_str}]")
|
||||
} else if att.is_error {
|
||||
let err_short =
|
||||
truncate_str(&att.status, inner.width.saturating_sub(6) as usize);
|
||||
format!(" failed: {err_short}")
|
||||
} else {
|
||||
format!(" attempting [{tier_str}]...")
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status_text,
|
||||
if att.is_error {
|
||||
Style::default().fg(Color::LightRed).italic()
|
||||
} else if att.is_success {
|
||||
Style::default().fg(Color::LightGreen)
|
||||
} else {
|
||||
Style::default().fg(palette.text_muted)
|
||||
},
|
||||
),
|
||||
]));
|
||||
|
||||
if att.is_error {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
" ↳ cascading to fallback...",
|
||||
Style::default().fg(Color::Yellow).italic(),
|
||||
),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Idle state: Helpful team & feature overview
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"team status: ready",
|
||||
Style::default().fg(palette.accent).bold(),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"available personas:",
|
||||
Style::default().fg(palette.text_muted).bold(),
|
||||
),
|
||||
]));
|
||||
for p in app.persona_items.iter().take(5) {
|
||||
let p_color = get_persona_color(&p.name);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" • "),
|
||||
Span::styled(&p.name, Style::default().fg(p_color).bold()),
|
||||
]));
|
||||
}
|
||||
if !app.pipelines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"pipelines (ctrl+p):",
|
||||
Style::default().fg(palette.text_muted).bold(),
|
||||
),
|
||||
]));
|
||||
for pipe in &app.pipelines {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" ⚡ "),
|
||||
Span::styled(&pipe.name, Style::default().fg(Color::Yellow)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("shortcuts:", Style::default().fg(palette.text_muted).bold()),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Tab ", Style::default().fg(palette.accent)),
|
||||
Span::styled("Select persona", Style::default().fg(palette.text_muted)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Ctrl+P", Style::default().fg(palette.accent)),
|
||||
Span::styled("Run pipeline", Style::default().fg(palette.text_muted)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Ctrl+S", Style::default().fg(palette.accent)),
|
||||
Span::styled("Saved sessions", Style::default().fg(palette.text_muted)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" /theme", Style::default().fg(palette.accent)),
|
||||
Span::styled("Color palettes", Style::default().fg(palette.text_muted)),
|
||||
]));
|
||||
}
|
||||
|
||||
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
|
|
@ -889,8 +1289,13 @@ fn render_footer(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemeP
|
|||
|
||||
let left_spans = vec![Span::styled(
|
||||
format!(
|
||||
"↑0.0k ↓0.0k 1m: {}/{} 24h: {}/{} {active_str}",
|
||||
app.minute_requests, app.minute_limit, app.daily_requests, app.daily_limit
|
||||
"↑{} ↓{} 1m: {}/{} 24h: {}/{} {active_str}",
|
||||
AppState::format_tokens(app.tokens_in),
|
||||
AppState::format_tokens(app.tokens_out),
|
||||
app.minute_requests,
|
||||
app.minute_limit,
|
||||
app.daily_requests,
|
||||
app.daily_limit
|
||||
),
|
||||
Style::default().fg(palette.text_muted),
|
||||
)];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue