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;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum CreditTier {
|
pub enum CreditTier {
|
||||||
LowBalance, // < $10 lifetime purchased: 50 requests/day
|
LowBalance, // < $10 lifetime purchased: 50 requests/day
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,6 @@ const KEYRING_SERVICE: &str = "brokecrew";
|
||||||
const KEYRING_USER: &str = "openrouter_api_key";
|
const KEYRING_USER: &str = "openrouter_api_key";
|
||||||
const DEFAULT_PERSONAS_TOML: &str = include_str!("../config/personas.example.toml");
|
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 {
|
fn default_persona_str() -> String {
|
||||||
"general".to_string()
|
"general".to_string()
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +24,28 @@ fn default_credit_tier() -> CreditTier {
|
||||||
fn default_base_url() -> String {
|
fn default_base_url() -> String {
|
||||||
"https://openrouter.ai/api/v1".to_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 {
|
impl Default for AppConfigFile {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
|
@ -51,6 +56,7 @@ impl Default for AppConfigFile {
|
||||||
allow_stealth: false,
|
allow_stealth: false,
|
||||||
credit_tier: default_credit_tier(),
|
credit_tier: default_credit_tier(),
|
||||||
openrouter_base_url: default_base_url(),
|
openrouter_base_url: default_base_url(),
|
||||||
|
theme: default_theme_str(),
|
||||||
api_key: None,
|
api_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +77,7 @@ pub struct AppConfig {
|
||||||
pub allow_stealth: bool,
|
pub allow_stealth: bool,
|
||||||
pub credit_tier: CreditTier,
|
pub credit_tier: CreditTier,
|
||||||
pub openrouter_base_url: String,
|
pub openrouter_base_url: String,
|
||||||
|
pub theme: String,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub persona_registry: PersonaRegistry,
|
pub persona_registry: PersonaRegistry,
|
||||||
pub pipelines: Vec<PipelineConfig>,
|
pub pipelines: Vec<PipelineConfig>,
|
||||||
|
|
@ -131,6 +138,7 @@ impl AppConfig {
|
||||||
allow_stealth: app_file.allow_stealth,
|
allow_stealth: app_file.allow_stealth,
|
||||||
credit_tier: app_file.credit_tier,
|
credit_tier: app_file.credit_tier,
|
||||||
openrouter_base_url: app_file.openrouter_base_url,
|
openrouter_base_url: app_file.openrouter_base_url,
|
||||||
|
theme: app_file.theme,
|
||||||
api_key,
|
api_key,
|
||||||
persona_registry: PersonaRegistry::new(personas_config.personas),
|
persona_registry: PersonaRegistry::new(personas_config.personas),
|
||||||
pipelines: personas_config.pipelines,
|
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:
|
/// Resolves the API key using precedence:
|
||||||
/// 1. CLI argument
|
/// 1. CLI argument
|
||||||
/// 2. `OPENROUTER_API_KEY` environment variable
|
/// 2. `OPENROUTER_API_KEY` environment variable
|
||||||
|
|
@ -237,4 +267,14 @@ mod tests {
|
||||||
assert!(parsed.personas.len() >= 3);
|
assert!(parsed.personas.len() >= 3);
|
||||||
assert!(!parsed.pipelines.is_empty());
|
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 session;
|
||||||
pub mod tool;
|
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 client::{ClientError, ModelClient, OpenRouterClient};
|
||||||
pub use config::{AppConfig, AppConfigFile, PersonasConfigFile};
|
pub use config::{AppConfig, AppConfigFile, PersonasConfigFile};
|
||||||
pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, PersonaRegistry, Tier};
|
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",
|
"Cyberpunk - Midnight, neon pink & electric blue",
|
||||||
),
|
),
|
||||||
("mono", "Monochrome - High contrast grayscale minimal"),
|
("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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -129,13 +153,25 @@ pub struct CandidateAttemptInfo {
|
||||||
pub is_success: bool,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WorkingsState {
|
pub struct WorkingsState {
|
||||||
pub active_persona: String,
|
pub active_persona: String,
|
||||||
pub active_pipeline: Option<String>,
|
pub active_pipeline: Option<String>,
|
||||||
pub route_stage: Option<String>,
|
pub route_stage: Option<String>,
|
||||||
pub route_details: Option<String>,
|
pub route_details: Option<String>,
|
||||||
|
pub complexity_score: Option<u8>,
|
||||||
pub candidate_attempts: Vec<CandidateAttemptInfo>,
|
pub candidate_attempts: Vec<CandidateAttemptInfo>,
|
||||||
|
pub pipeline_steps: Vec<PipelineStepTelemetry>,
|
||||||
pub events: Vec<String>,
|
pub events: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,7 +182,9 @@ impl Default for WorkingsState {
|
||||||
active_pipeline: None,
|
active_pipeline: None,
|
||||||
route_stage: None,
|
route_stage: None,
|
||||||
route_details: None,
|
route_details: None,
|
||||||
|
complexity_score: None,
|
||||||
candidate_attempts: Vec::new(),
|
candidate_attempts: Vec::new(),
|
||||||
|
pipeline_steps: Vec::new(),
|
||||||
events: vec![
|
events: vec![
|
||||||
format!(
|
format!(
|
||||||
"{} brokecrew online",
|
"{} brokecrew online",
|
||||||
|
|
@ -199,6 +237,8 @@ pub struct AppState {
|
||||||
pub daily_requests: usize,
|
pub daily_requests: usize,
|
||||||
pub daily_limit: usize,
|
pub daily_limit: usize,
|
||||||
pub models_cached: usize,
|
pub models_cached: usize,
|
||||||
|
pub tokens_in: usize,
|
||||||
|
pub tokens_out: usize,
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
pub allow_stealth: bool,
|
pub allow_stealth: bool,
|
||||||
|
|
@ -218,6 +258,7 @@ impl AppState {
|
||||||
minute_limit: usize,
|
minute_limit: usize,
|
||||||
daily_limit: usize,
|
daily_limit: usize,
|
||||||
config_dir: PathBuf,
|
config_dir: PathBuf,
|
||||||
|
initial_theme: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut persona_items = Vec::new();
|
let mut persona_items = Vec::new();
|
||||||
// Item 0 is always the smart Auto-Router
|
// Item 0 is always the smart Auto-Router
|
||||||
|
|
@ -234,6 +275,10 @@ impl AppState {
|
||||||
|
|
||||||
let session = Session::new("auto");
|
let session = Session::new("auto");
|
||||||
let session_summaries = Session::list(&config_dir).unwrap_or_default();
|
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 {
|
Self {
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
|
|
@ -261,12 +306,22 @@ impl AppState {
|
||||||
daily_requests: 0,
|
daily_requests: 0,
|
||||||
daily_limit,
|
daily_limit,
|
||||||
models_cached: 0,
|
models_cached: 0,
|
||||||
|
tokens_in: 0,
|
||||||
|
tokens_out: 0,
|
||||||
allow_stealth: false,
|
allow_stealth: false,
|
||||||
escalate: false,
|
escalate: false,
|
||||||
expand_tool_outputs: false,
|
expand_tool_outputs: false,
|
||||||
current_theme: "opencode".to_string(),
|
current_theme: initial_theme,
|
||||||
selected_suggestion_idx: 0,
|
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) {
|
pub fn cycle_theme(&mut self) {
|
||||||
self.current_theme = match self.current_theme.as_str() {
|
let current_pos = THEMES
|
||||||
"opencode" => "warm".to_string(),
|
.iter()
|
||||||
"warm" => "forest".to_string(),
|
.position(|(name, _)| *name == self.current_theme)
|
||||||
"forest" => "cyberpunk".to_string(),
|
.unwrap_or(0);
|
||||||
"cyberpunk" => "mono".to_string(),
|
let next_pos = (current_pos + 1) % THEMES.len();
|
||||||
_ => "opencode".to_string(),
|
self.current_theme = THEMES[next_pos].0.to_string();
|
||||||
};
|
self.selected_theme_idx = next_pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_persona_name(&self) -> &str {
|
pub fn current_persona_name(&self) -> &str {
|
||||||
|
|
@ -322,7 +377,9 @@ impl AppState {
|
||||||
self.workings.active_pipeline = pipeline;
|
self.workings.active_pipeline = pipeline;
|
||||||
self.workings.route_stage = None;
|
self.workings.route_stage = None;
|
||||||
self.workings.route_details = None;
|
self.workings.route_details = None;
|
||||||
|
self.workings.complexity_score = None;
|
||||||
self.workings.candidate_attempts.clear();
|
self.workings.candidate_attempts.clear();
|
||||||
|
self.workings.pipeline_steps.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_current_message_model(&mut self, model_id: &str) {
|
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.active_session = loaded;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.log_working_event(&format!("[session] loaded {}", session_id));
|
self.log_working_event(&format!("[session] loaded {}", session_id));
|
||||||
|
|
@ -492,6 +562,8 @@ impl AppState {
|
||||||
let persona = self.current_persona_name().to_string();
|
let persona = self.current_persona_name().to_string();
|
||||||
self.active_session = Session::new(&persona);
|
self.active_session = Session::new(&persona);
|
||||||
self.messages.clear();
|
self.messages.clear();
|
||||||
|
self.tokens_in = 0;
|
||||||
|
self.tokens_out = 0;
|
||||||
self.add_system_message("Started fresh session.".to_string());
|
self.add_system_message("Started fresh session.".to_string());
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.log_working_event("[session] started fresh session");
|
self.log_working_event("[session] started fresh session");
|
||||||
|
|
@ -581,7 +653,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_app_start_screen_and_suggestions() {
|
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());
|
assert!(app.is_start_screen());
|
||||||
|
|
||||||
app.input = "/se".to_string();
|
app.input = "/se".to_string();
|
||||||
|
|
@ -600,7 +679,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_app_theme_cycling() {
|
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");
|
assert_eq!(app.current_theme, "opencode");
|
||||||
app.cycle_theme();
|
app.cycle_theme();
|
||||||
assert_eq!(app.current_theme, "warm");
|
assert_eq!(app.current_theme, "warm");
|
||||||
|
|
@ -611,6 +697,8 @@ mod tests {
|
||||||
app.cycle_theme();
|
app.cycle_theme();
|
||||||
assert_eq!(app.current_theme, "mono");
|
assert_eq!(app.current_theme, "mono");
|
||||||
app.cycle_theme();
|
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 {
|
pub enum WorkerEvent {
|
||||||
|
PipelineStarted {
|
||||||
|
pipeline_name: String,
|
||||||
|
steps: Vec<String>,
|
||||||
|
},
|
||||||
RouterResolved {
|
RouterResolved {
|
||||||
persona: String,
|
persona: String,
|
||||||
stage: RouteStage,
|
stage: RouteStage,
|
||||||
details: String,
|
details: String,
|
||||||
|
complexity_score: u8,
|
||||||
},
|
},
|
||||||
CandidateAttempt {
|
CandidateAttempt {
|
||||||
model: String,
|
model: String,
|
||||||
|
|
@ -79,6 +84,8 @@ pub enum WorkerEvent {
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
calls_made: usize,
|
calls_made: usize,
|
||||||
paid_calls_made: usize,
|
paid_calls_made: usize,
|
||||||
|
tokens_in: usize,
|
||||||
|
tokens_out: usize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +131,7 @@ pub async fn run_tui(
|
||||||
minute_limit,
|
minute_limit,
|
||||||
daily_limit,
|
daily_limit,
|
||||||
config.config_dir.clone(),
|
config.config_dir.clone(),
|
||||||
|
config.theme.clone(),
|
||||||
);
|
);
|
||||||
app.allow_stealth = allow_stealth || config.allow_stealth;
|
app.allow_stealth = allow_stealth || config.allow_stealth;
|
||||||
app.escalate = escalate;
|
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) {
|
fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, event: WorkerEvent) {
|
||||||
match event {
|
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 {
|
WorkerEvent::RouterResolved {
|
||||||
persona,
|
persona,
|
||||||
stage,
|
stage,
|
||||||
details,
|
details,
|
||||||
|
complexity_score,
|
||||||
} => {
|
} => {
|
||||||
let stage_name = match stage {
|
let stage_name = match stage {
|
||||||
RouteStage::StageAKeyword => "Stage A (Keyword Filter)",
|
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_stage = Some(stage_name.to_string());
|
||||||
app.workings.route_details = Some(details.clone());
|
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 {
|
WorkerEvent::CandidateAttempt {
|
||||||
|
|
@ -275,6 +314,32 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
||||||
model,
|
model,
|
||||||
tier,
|
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 {
|
if total_steps > 1 {
|
||||||
app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier);
|
app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier);
|
||||||
app.log_working_event(&format!(
|
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 } => {
|
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 {
|
if fell_back {
|
||||||
app.mark_current_message_fallback();
|
app.mark_current_message_fallback();
|
||||||
}
|
}
|
||||||
|
|
@ -321,9 +391,20 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e
|
||||||
error,
|
error,
|
||||||
calls_made,
|
calls_made,
|
||||||
paid_calls_made,
|
paid_calls_made,
|
||||||
|
tokens_in,
|
||||||
|
tokens_out,
|
||||||
} => {
|
} => {
|
||||||
app.is_generating = false;
|
app.is_generating = false;
|
||||||
app.status_message = "Ready".to_string();
|
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" {
|
if !model_used.is_empty() && model_used != "none" {
|
||||||
app.update_current_message_model(&model_used);
|
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})"));
|
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);
|
let _ = budget_tracker.record_call(false);
|
||||||
}
|
}
|
||||||
for _ in 0..paid_calls_made {
|
for _ in 0..paid_calls_made {
|
||||||
|
|
@ -470,6 +552,7 @@ fn handle_key_event(
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) {
|
if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) {
|
||||||
app.current_theme = name.to_string();
|
app.current_theme = name.to_string();
|
||||||
|
let _ = AppConfig::save_theme(&app.config_dir, name);
|
||||||
app.log_working_event(&format!("[theme] selected {}", name));
|
app.log_working_event(&format!("[theme] selected {}", name));
|
||||||
}
|
}
|
||||||
app.mode = TuiMode::Normal;
|
app.mode = TuiMode::Normal;
|
||||||
|
|
@ -678,11 +761,13 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
||||||
}
|
}
|
||||||
"/usage" => {
|
"/usage" => {
|
||||||
app.add_system_message(format!(
|
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_requests,
|
||||||
app.minute_limit,
|
app.minute_limit,
|
||||||
app.daily_requests,
|
app.daily_requests,
|
||||||
app.daily_limit,
|
app.daily_limit,
|
||||||
|
app::AppState::format_tokens(app.tokens_in),
|
||||||
|
app::AppState::format_tokens(app.tokens_out),
|
||||||
app.models_cached
|
app.models_cached
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -691,10 +776,12 @@ fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
|
||||||
if *arg == "next" {
|
if *arg == "next" {
|
||||||
app.cycle_theme();
|
app.cycle_theme();
|
||||||
let theme_name = app.current_theme.clone();
|
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}"));
|
app.add_system_message(format!("Theme set to: {theme_name}"));
|
||||||
return;
|
return;
|
||||||
} else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) {
|
} else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) {
|
||||||
app.current_theme = name.to_string();
|
app.current_theme = name.to_string();
|
||||||
|
let _ = AppConfig::save_theme(&app.config_dir, name);
|
||||||
app.add_system_message(format!("Theme set to: {name}"));
|
app.add_system_message(format!("Theme set to: {name}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -782,6 +869,14 @@ fn spawn_dispatch_task(
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Some(pipe_name) = pipeline_name {
|
if let Some(pipe_name) = pipeline_name {
|
||||||
if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_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 (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(50);
|
||||||
let w_tx = worker_tx.clone();
|
let w_tx = worker_tx.clone();
|
||||||
|
|
||||||
|
|
@ -863,6 +958,11 @@ fn spawn_dispatch_task(
|
||||||
let paid_calls: usize =
|
let paid_calls: usize =
|
||||||
results.iter().filter(|r| r.tier_used == Tier::Paid).count();
|
results.iter().filter(|r| r.tier_used == Tier::Paid).count();
|
||||||
let fell_back = results.iter().any(|r| r.fell_back);
|
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
|
let _ = worker_tx
|
||||||
.send(WorkerEvent::Finished {
|
.send(WorkerEvent::Finished {
|
||||||
fell_back,
|
fell_back,
|
||||||
|
|
@ -870,6 +970,8 @@ fn spawn_dispatch_task(
|
||||||
error: None,
|
error: None,
|
||||||
calls_made: total_calls + results.len(),
|
calls_made: total_calls + results.len(),
|
||||||
paid_calls_made: paid_calls,
|
paid_calls_made: paid_calls,
|
||||||
|
tokens_in: tok_in,
|
||||||
|
tokens_out: tok_out,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -881,6 +983,8 @@ fn spawn_dispatch_task(
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
calls_made: 1,
|
calls_made: 1,
|
||||||
paid_calls_made: 0,
|
paid_calls_made: 0,
|
||||||
|
tokens_in: brokecrew::estimate_tokens(&prompt),
|
||||||
|
tokens_out: 0,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -890,7 +994,7 @@ fn spawn_dispatch_task(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine target persona via router
|
// 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
|
match router
|
||||||
.route(&persona_registry, client.as_ref(), &prompt, escalate)
|
.route(&persona_registry, client.as_ref(), &prompt, escalate)
|
||||||
.await
|
.await
|
||||||
|
|
@ -902,12 +1006,18 @@ fn spawn_dispatch_task(
|
||||||
format!("LLM classifier (model: {})", router.router_model)
|
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) => (
|
Err(err) => (
|
||||||
default_persona,
|
default_persona,
|
||||||
None,
|
None,
|
||||||
format!("Router fallback to default: {err}"),
|
format!("Router fallback to default: {err}"),
|
||||||
|
1,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -915,6 +1025,7 @@ fn spawn_dispatch_task(
|
||||||
direct_persona,
|
direct_persona,
|
||||||
None,
|
None,
|
||||||
"Direct persona selected by user".to_string(),
|
"Direct persona selected by user".to_string(),
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -924,6 +1035,7 @@ fn spawn_dispatch_task(
|
||||||
persona: target_persona.clone(),
|
persona: target_persona.clone(),
|
||||||
stage,
|
stage,
|
||||||
details,
|
details,
|
||||||
|
complexity_score,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -1066,23 +1178,32 @@ fn spawn_dispatch_task(
|
||||||
|
|
||||||
match followup_res {
|
match followup_res {
|
||||||
Ok(f_disp) => {
|
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
|
let _ = worker_tx
|
||||||
.send(WorkerEvent::Finished {
|
.send(WorkerEvent::Finished {
|
||||||
fell_back: disp.fell_back || f_disp.fell_back,
|
fell_back: disp.fell_back || f_disp.fell_back,
|
||||||
model_used: f_disp.model_used,
|
model_used: f_disp.model_used,
|
||||||
error: None,
|
error: None,
|
||||||
calls_made: disp.attempts.len() + f_disp.attempts.len() + 2,
|
calls_made: disp.attempts.len() + f_disp.attempts.len() + 2,
|
||||||
paid_calls_made: if disp.tier_used == Tier::Paid
|
paid_calls_made: paid,
|
||||||
|| f_disp.tier_used == Tier::Paid
|
tokens_in: tok_in,
|
||||||
{
|
tokens_out: tok_out,
|
||||||
1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
let _ = worker_tx
|
||||||
.send(WorkerEvent::Finished {
|
.send(WorkerEvent::Finished {
|
||||||
fell_back: disp.fell_back,
|
fell_back: disp.fell_back,
|
||||||
|
|
@ -1094,11 +1215,15 @@ fn spawn_dispatch_task(
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
},
|
},
|
||||||
|
tokens_in: tok_in,
|
||||||
|
tokens_out: tok_out,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let tok_in = brokecrew::estimate_tokens(&prompt);
|
||||||
|
let tok_out = brokecrew::estimate_tokens(&disp.reply);
|
||||||
let _ = worker_tx
|
let _ = worker_tx
|
||||||
.send(WorkerEvent::Finished {
|
.send(WorkerEvent::Finished {
|
||||||
fell_back: disp.fell_back,
|
fell_back: disp.fell_back,
|
||||||
|
|
@ -1106,6 +1231,8 @@ fn spawn_dispatch_task(
|
||||||
error: None,
|
error: None,
|
||||||
calls_made: disp.attempts.len() + 1,
|
calls_made: disp.attempts.len() + 1,
|
||||||
paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 },
|
paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 },
|
||||||
|
tokens_in: tok_in,
|
||||||
|
tokens_out: tok_out,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -1118,6 +1245,8 @@ fn spawn_dispatch_task(
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
calls_made: 1,
|
calls_made: 1,
|
||||||
paid_calls_made: 0,
|
paid_calls_made: 0,
|
||||||
|
tokens_in: brokecrew::estimate_tokens(&prompt),
|
||||||
|
tokens_out: 0,
|
||||||
})
|
})
|
||||||
.await;
|
.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_bright: Color::White,
|
||||||
text_muted: Color::DarkGray,
|
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 {
|
_ => ThemePalette {
|
||||||
// opencode (default)
|
// opencode (default)
|
||||||
accent: Color::Rgb(80, 210, 230), // cyan / sky
|
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) {
|
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()
|
let workspace_chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Percentage(78), // Left: Chat
|
Constraint::Percentage(chat_pct), // Left: Chat
|
||||||
Constraint::Percentage(22), // Right: Compact routed models
|
Constraint::Percentage(side_pct), // Right: Telemetry & Pipeline
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
|
|
@ -420,6 +476,54 @@ fn render_suggestions_list(
|
||||||
frame.render_widget(list, inner);
|
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) {
|
fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
let full_width = area.width.saturating_sub(1) as usize;
|
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 {
|
for msg in &app.messages {
|
||||||
match &msg.sender {
|
match &msg.sender {
|
||||||
MessageSender::User => {
|
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(""));
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
// Top padding line
|
// Top padding line
|
||||||
|
let top_pad = card_width.saturating_sub(2);
|
||||||
lines.push(
|
lines.push(
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"▌ ",
|
"▌ ",
|
||||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||||
),
|
),
|
||||||
Span::styled(
|
Span::styled(" ".repeat(top_pad), Style::default().bg(palette.user_bg)),
|
||||||
" ".repeat(full_width.saturating_sub(2)),
|
|
||||||
Style::default().bg(palette.user_bg),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
.style(Style::default().bg(palette.user_bg)),
|
.style(Style::default().bg(palette.user_bg)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Content lines
|
// Content lines with proper word-wrapping and exact right padding
|
||||||
for text_line in msg.content.lines() {
|
for raw_line in msg.content.lines() {
|
||||||
let pad_len = full_width.saturating_sub(text_line.len() + 3);
|
let wrapped_lines = wrap_text(raw_line, inner_text_width);
|
||||||
lines.push(
|
for text_line in wrapped_lines {
|
||||||
Line::from(vec![
|
let text_len = text_line.chars().count();
|
||||||
Span::styled(
|
let pad_len = card_width.saturating_sub(2 + text_len);
|
||||||
"▌ ",
|
lines.push(
|
||||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
Line::from(vec![
|
||||||
),
|
Span::styled(
|
||||||
Span::styled(
|
"▌ ",
|
||||||
text_line.to_string(),
|
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||||
Style::default()
|
),
|
||||||
.fg(palette.text_bright)
|
Span::styled(
|
||||||
.bold()
|
text_line,
|
||||||
.bg(palette.user_bg),
|
Style::default()
|
||||||
),
|
.fg(palette.text_bright)
|
||||||
Span::styled(" ".repeat(pad_len), Style::default().bg(palette.user_bg)),
|
.bold()
|
||||||
])
|
.bg(palette.user_bg),
|
||||||
.style(Style::default().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
|
// Bottom padding line
|
||||||
|
let bot_pad = card_width.saturating_sub(2);
|
||||||
lines.push(
|
lines.push(
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"▌ ",
|
"▌ ",
|
||||||
Style::default().fg(palette.accent).bg(palette.user_bg),
|
Style::default().fg(palette.accent).bg(palette.user_bg),
|
||||||
),
|
),
|
||||||
Span::styled(
|
Span::styled(" ".repeat(bot_pad), Style::default().bg(palette.user_bg)),
|
||||||
" ".repeat(full_width.saturating_sub(2)),
|
|
||||||
Style::default().bg(palette.user_bg),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
.style(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(""));
|
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);
|
render_clean_markdown_body(&msg.content, &mut lines);
|
||||||
lines.push(Line::from(""));
|
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) {
|
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()
|
let block = Block::default()
|
||||||
.borders(Borders::LEFT)
|
.borders(Borders::LEFT)
|
||||||
.border_style(Style::default().fg(palette.card_border))
|
.border_style(Style::default().fg(palette.card_border))
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" models routed ",
|
title_str,
|
||||||
Style::default().fg(palette.text_muted).bold(),
|
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);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
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![
|
lines.push(Line::from(vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"waiting for prompt...",
|
format!("⚡ pipeline: {pipe_name}"),
|
||||||
Style::default().fg(palette.text_muted).italic(),
|
Style::default().fg(palette.accent).bold(),
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
} else {
|
lines.push(Line::from(""));
|
||||||
for att in &app.workings.candidate_attempts {
|
|
||||||
let (icon, color) = if att.is_success {
|
let total_steps = app.workings.pipeline_steps.len();
|
||||||
("•", Color::Green)
|
for (idx, step) in app.workings.pipeline_steps.iter().enumerate() {
|
||||||
} else if att.is_error {
|
if idx > 0 {
|
||||||
("x", Color::Red)
|
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 {
|
} else {
|
||||||
("~", Color::Yellow)
|
lines.push(Line::from(vec![
|
||||||
};
|
Span::raw(" "),
|
||||||
|
Span::styled("[○] ", Style::default().fg(palette.text_muted)),
|
||||||
let tier_str = match att.tier {
|
Span::styled(
|
||||||
Tier::Free => "free",
|
format!("{}. {}", idx + 1, step.persona),
|
||||||
Tier::Paid => "paid",
|
Style::default().fg(palette.text_muted),
|
||||||
};
|
),
|
||||||
|
]));
|
||||||
let model_display = truncate_str(&att.model, inner.width.saturating_sub(6) as usize);
|
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![
|
lines.push(Line::from(vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("{icon} "), Style::default().fg(color).bold()),
|
Span::styled("⚡ ", Style::default().fg(palette.accent).bold()),
|
||||||
Span::styled(
|
Span::styled(stage.clone(), Style::default().fg(palette.accent).bold()),
|
||||||
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 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(""));
|
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 });
|
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(
|
let left_spans = vec![Span::styled(
|
||||||
format!(
|
format!(
|
||||||
"↑0.0k ↓0.0k 1m: {}/{} 24h: {}/{} {active_str}",
|
"↑{} ↓{} 1m: {}/{} 24h: {}/{} {active_str}",
|
||||||
app.minute_requests, app.minute_limit, app.daily_requests, app.daily_limit
|
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),
|
Style::default().fg(palette.text_muted),
|
||||||
)];
|
)];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue