From eb4399177e612fe83f92258dffc44985a3d8b7a4 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 26 Sep 2026 16:21:29 +0530 Subject: [PATCH] feat(tui): match Pi/OpenCode theme, compact routed models workings, and slash commands --- src/tui/app.rs | 31 ++- src/tui/mod.rs | 128 ++++++++++- src/tui/ui.rs | 603 ++++++++++++++++++++++++++++--------------------- 3 files changed, 500 insertions(+), 262 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 3fdc02a..cf5e3ec 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -22,6 +22,7 @@ pub enum MessageSender { Tool { command: String, exit_code: i32, + duration_ms: u64, }, System, } @@ -130,6 +131,10 @@ pub struct AppState { // Options pub allow_stealth: bool, pub escalate: bool, + + // UX & Theme + pub expand_tool_outputs: bool, + pub current_theme: String, } impl AppState { @@ -188,9 +193,20 @@ impl AppState { models_cached: 0, allow_stealth: false, escalate: false, + expand_tool_outputs: false, + current_theme: "default".to_string(), } } + pub fn cycle_theme(&mut self) { + self.current_theme = match self.current_theme.as_str() { + "default" => "warm".to_string(), + "warm" => "forest".to_string(), + "forest" => "mono".to_string(), + _ => "default".to_string(), + }; + } + pub fn current_persona_name(&self) -> &str { if self.selected_persona_idx == 0 { "auto" @@ -286,11 +302,21 @@ impl AppState { } } - pub fn add_tool_result_message(&mut self, command: String, exit_code: i32, output: String) { + pub fn add_tool_result_message( + &mut self, + command: String, + exit_code: i32, + output: String, + duration_ms: u64, + ) { self.active_session .add_message("tool", &format!("$ {}\n{}", command, output), None); self.messages.push(ChatMessage { - sender: MessageSender::Tool { command, exit_code }, + sender: MessageSender::Tool { + command, + exit_code, + duration_ms, + }, content: output, fell_back: false, }); @@ -353,6 +379,7 @@ impl AppState { "tool" => MessageSender::Tool { command: "exec".to_string(), exit_code: 0, + duration_ms: 0, }, other => MessageSender::Persona { name: other.to_string(), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 23d2c8f..613b913 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -55,6 +55,7 @@ pub enum WorkerEvent { command: String, exit_code: i32, output: String, + duration_ms: u64, }, StepStarted { step_index: usize, @@ -258,9 +259,13 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e command, exit_code, output, + duration_ms, } => { - app.add_tool_result_message(command.clone(), exit_code, output); - app.log_working_event(&format!("[tool:done] $ {command} -> exit {exit_code}")); + app.add_tool_result_message(command.clone(), exit_code, output, duration_ms); + let dur_s = (duration_ms as f64) / 1000.0; + app.log_working_event(&format!( + "[tool:done] $ {command} -> exit {exit_code} ({dur_s:.1}s)" + )); } WorkerEvent::StepStarted { @@ -469,6 +474,15 @@ fn handle_key_event( app.start_new_session(); return false; } + KeyCode::Char('o') => { + app.expand_tool_outputs = !app.expand_tool_outputs; + app.log_working_event(if app.expand_tool_outputs { + "[tools] output expanded" + } else { + "[tools] output collapsed" + }); + return false; + } KeyCode::Char('l') => { app.clear_chat(); return false; @@ -527,9 +541,15 @@ fn handle_key_event( KeyCode::Enter => { let prompt = app.input.trim().to_string(); if !prompt.is_empty() && !app.is_generating { - app.add_user_message(prompt.clone()); app.input.clear(); app.cursor_pos = 0; + + if prompt.starts_with('/') { + handle_slash_command(app, config, &prompt); + return false; + } + + app.add_user_message(prompt.clone()); app.is_generating = true; app.status_message = "Dispatching...".to_string(); @@ -560,6 +580,101 @@ fn handle_key_event( false } +fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) { + let parts: Vec<&str> = input.split_whitespace().collect(); + let cmd = parts.first().copied().unwrap_or(""); + + match cmd { + "/new" => { + app.start_new_session(); + } + "/session" | "/sessions" => { + app.session_summaries = Session::list(&app.config_dir).unwrap_or_default(); + app.selected_session_idx = 0; + app.mode = TuiMode::SessionSelect; + } + "/config" => { + let key_status = if config.api_key.is_some() { + "configured" + } else { + "not configured" + }; + app.add_system_message(format!( + "Config Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Base URL: {}\n Config Dir: {}", + config.router_model, + config.default_persona, + app.allow_stealth, + app.escalate, + key_status, + config.openrouter_base_url, + config.config_dir.display() + )); + } + "/usage" => { + app.add_system_message(format!( + "Rate Limit & Budget Usage:\n 1-Minute Rate: {}/{} requests\n 24-Hour Budget: {}/{} requests\n Cached Models: {} available", + app.minute_requests, + app.minute_limit, + app.daily_requests, + app.daily_limit, + app.models_cached + )); + } + "/theme" => { + app.cycle_theme(); + let theme_name = app.current_theme.clone(); + app.add_system_message(format!("Theme set to: {theme_name}")); + } + "/settings" => { + app.add_system_message(format!( + "Runtime Settings:\n Stealth Models: {} (toggle with /stealth)\n Complexity Escalate: {} (toggle with /escalate)\n Active Persona: {}\n Active Pipeline: {}", + if app.allow_stealth { "enabled" } else { "disabled" }, + if app.escalate { "enabled" } else { "disabled" }, + app.current_persona_name(), + app.active_pipeline_for_prompt.as_deref().unwrap_or("none") + )); + } + "/stealth" => { + app.allow_stealth = !app.allow_stealth; + app.add_system_message(format!( + "Stealth candidate models are now: {}", + if app.allow_stealth { + "ENABLED" + } else { + "DISABLED" + } + )); + } + "/escalate" => { + app.escalate = !app.escalate; + app.add_system_message(format!( + "Complexity escalation is now: {}", + if app.escalate { "ENABLED" } else { "DISABLED" } + )); + } + "/clear" => { + app.clear_chat(); + } + "/persona" | "/personas" => { + app.mode = TuiMode::PersonaSelect; + } + "/pipeline" | "/pipelines" => { + app.mode = TuiMode::PipelineSelect; + } + "/help" => { + app.add_system_message( + "Available Commands:\n /new - Start a fresh session\n /session - Open session switcher modal\n /config - View active system configuration\n /usage - View budget and rate-limit counters\n /theme - Cycle color theme (default, warm, forest, mono)\n /settings - View and adjust runtime settings\n /stealth - Toggle stealth model eligibility\n /escalate - Toggle complexity escalation\n /persona - Open persona selection modal\n /pipeline - Open pipeline selection modal\n /clear - Clear chat screen\n /help - Show this help summary" + .to_string(), + ); + } + other => { + app.add_system_message(format!( + "Unknown command '{other}'. Type /help to view available commands." + )); + } + } +} + fn spawn_dispatch_task( prompt: String, app: &AppState, @@ -811,12 +926,12 @@ fn spawn_dispatch_task( }) .await; - let (exit_code, formatted) = + let (exit_code, formatted, duration_ms) = match ToolEngine::execute_bash(&cmd, None, Duration::from_secs(30)) .await { - Ok(out) => (out.exit_code, out.format_for_model()), - Err(e) => (-1, format!("[Command: `{cmd}` failed: {e}]\n")), + Ok(out) => (out.exit_code, out.format_for_model(), out.duration_ms), + Err(e) => (-1, format!("[Command: `{cmd}` failed: {e}]\n"), 0), }; let _ = worker_tx @@ -824,6 +939,7 @@ fn spawn_dispatch_task( command: cmd, exit_code, output: formatted.clone(), + duration_ms, }) .await; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 325be1f..fb8b37c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -155,8 +155,8 @@ fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect) { let workspace_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(63), // Left: Pi Minimal Conversation & Input - Constraint::Percentage(37), // Right: Team & Routing Workings + Constraint::Percentage(78), // Left: Chat + Constraint::Percentage(22), // Right: Compact routed models ]) .split(area); @@ -169,142 +169,220 @@ fn render_conversation_pane(frame: &mut Frame, app: &mut AppState, area: Rect) { .direction(Direction::Vertical) .constraints([ Constraint::Min(4), // Messages stream - Constraint::Length(1), // Divider - Constraint::Length(1), // Minimal Input prompt line + Constraint::Length(1), // Top input border + Constraint::Length(1), // Input text line + Constraint::Length(1), // Bottom input border + Constraint::Length(1), // ~ indicator line ]) .split(area); render_minimal_messages(frame, app, chat_chunks[0]); - // Input divider - let div = Line::from(Span::styled( + // Top input border + let top_border = Line::from(Span::styled( "─".repeat(chat_chunks[1].width as usize), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Rgb(70, 90, 110)), )); - frame.render_widget(Paragraph::new(div), chat_chunks[1]); + frame.render_widget(Paragraph::new(top_border), chat_chunks[1]); + // Input text line render_minimal_input(frame, app, chat_chunks[2]); + + // Bottom input border + let bot_border = Line::from(Span::styled( + "─".repeat(chat_chunks[3].width as usize), + Style::default().fg(Color::Rgb(70, 90, 110)), + )); + frame.render_widget(Paragraph::new(bot_border), chat_chunks[3]); + + // ~ indicator line + let tilde = Line::from(vec![ + Span::raw(" "), + Span::styled("~", Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(tilde), chat_chunks[4]); } fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { let mut lines: Vec = Vec::new(); + let full_width = area.width.saturating_sub(1) as usize; for msg in &app.messages { match &msg.sender { MessageSender::User => { - lines.push(Line::from(vec![Span::styled( - "> you", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )])); + lines.push(Line::from("")); + let user_bg = Color::Rgb(46, 50, 66); for text_line in msg.content.lines() { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(text_line.to_string(), Style::default().fg(Color::White)), - ])); + let pad_len = full_width.saturating_sub(text_line.len() + 1); + lines.push( + Line::from(vec![ + Span::raw(" ").style(Style::default().bg(user_bg)), + Span::styled( + text_line.to_string(), + Style::default().fg(Color::White).bg(user_bg), + ), + Span::styled(" ".repeat(pad_len), Style::default().bg(user_bg)), + ]) + .style(Style::default().bg(user_bg)), + ); } lines.push(Line::from("")); } - MessageSender::Persona { name, model, tier } => { - let color = get_persona_color(name); - let tier_str = match tier { - Tier::Free => "free", - Tier::Paid => "paid", - }; - let mut header_spans = vec![ - Span::styled( - format!("* {name}"), - Style::default().fg(color).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - format!("({model} · {tier_str})"), - Style::default().fg(Color::DarkGray), - ), - ]; - if msg.fell_back { - header_spans.push(Span::raw(" ")); - header_spans.push(Span::styled( - "[fallback]", - Style::default().fg(Color::Yellow), - )); - } - lines.push(Line::from(header_spans)); - + MessageSender::Persona { .. } | MessageSender::PipelineStep { .. } => { render_clean_markdown_body(&msg.content, &mut lines); lines.push(Line::from("")); } - MessageSender::PipelineStep { - step_index, - total_steps, - persona, - model, - tier, + MessageSender::Tool { + command, + exit_code, + duration_ms, } => { - let color = get_persona_color(persona); - let tier_str = match tier { - Tier::Free => "free", - Tier::Paid => "paid", + let tool_bg = Color::Rgb(30, 38, 34); + + lines.push(Line::from("")); + + // Header line: $ (timeout 30s) + let header_prefix = format!(" $ {command} "); + let timeout_tag = "(timeout 30s)"; + let h_rem = full_width.saturating_sub(header_prefix.len() + timeout_tag.len()); + lines.push( + Line::from(vec![ + Span::styled(" $ ", Style::default().fg(Color::White).bold().bg(tool_bg)), + Span::styled( + command.clone(), + Style::default().fg(Color::White).bold().bg(tool_bg), + ), + Span::raw(" ").style(Style::default().bg(tool_bg)), + Span::styled( + timeout_tag, + Style::default().fg(Color::Rgb(130, 140, 135)).bg(tool_bg), + ), + Span::styled(" ".repeat(h_rem), Style::default().bg(tool_bg)), + ]) + .style(Style::default().bg(tool_bg)), + ); + + // Blank padding inside tool block + lines.push( + Line::from(vec![Span::styled( + " ".repeat(full_width), + Style::default().bg(tool_bg), + )]) + .style(Style::default().bg(tool_bg)), + ); + + let content_lines: Vec<&str> = msg.content.lines().collect(); + if !content_lines.is_empty() { + if content_lines.len() > 6 && !app.expand_tool_outputs { + let earlier_count = content_lines.len() - 5; + let earlier_msg = format!(" ... ({earlier_count} earlier lines, "); + let e_rem = full_width.saturating_sub(earlier_msg.len() + 20); + lines.push( + Line::from(vec![ + Span::styled( + earlier_msg, + Style::default() + .fg(Color::Rgb(130, 140, 135)) + .italic() + .bg(tool_bg), + ), + Span::styled( + "ctrl+o", + Style::default().fg(Color::LightGreen).bold().bg(tool_bg), + ), + Span::styled( + " to expand)", + Style::default() + .fg(Color::Rgb(130, 140, 135)) + .italic() + .bg(tool_bg), + ), + Span::styled(" ".repeat(e_rem), Style::default().bg(tool_bg)), + ]) + .style(Style::default().bg(tool_bg)), + ); + + for line in &content_lines[earlier_count..] { + let l_pad = full_width.saturating_sub(line.len() + 1); + lines.push( + Line::from(vec![ + Span::raw(" ").style(Style::default().bg(tool_bg)), + Span::styled( + *line, + Style::default().fg(Color::Rgb(210, 215, 210)).bg(tool_bg), + ), + Span::styled(" ".repeat(l_pad), Style::default().bg(tool_bg)), + ]) + .style(Style::default().bg(tool_bg)), + ); + } + } else { + for line in &content_lines { + let l_pad = full_width.saturating_sub(line.len() + 1); + lines.push( + Line::from(vec![ + Span::raw(" ").style(Style::default().bg(tool_bg)), + Span::styled( + *line, + Style::default().fg(Color::Rgb(210, 215, 210)).bg(tool_bg), + ), + Span::styled(" ".repeat(l_pad), Style::default().bg(tool_bg)), + ]) + .style(Style::default().bg(tool_bg)), + ); + } + } + + // Blank line inside tool box + lines.push( + Line::from(vec![Span::styled( + " ".repeat(full_width), + Style::default().bg(tool_bg), + )]) + .style(Style::default().bg(tool_bg)), + ); + } + + // Took Xs duration footer + let duration_s = (*duration_ms as f64) / 1000.0; + let took_str = format!(" Took {:.1}s", duration_s); + let exit_str = if *exit_code != 0 { + format!(" [exit {}]", exit_code) + } else { + String::new() }; - let mut header_spans = vec![ - Span::styled( - format!("* step {}/{}: {persona}", step_index + 1, total_steps), - Style::default().fg(color).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - format!("({model} · {tier_str})"), - Style::default().fg(Color::DarkGray), - ), - ]; - if msg.fell_back { - header_spans.push(Span::raw(" ")); - header_spans.push(Span::styled( - "[fallback]", - Style::default().fg(Color::Yellow), + let f_rem = full_width.saturating_sub(took_str.len() + exit_str.len()); + let mut footer_spans = vec![Span::styled( + took_str, + Style::default().fg(Color::Rgb(130, 140, 135)).bg(tool_bg), + )]; + if *exit_code != 0 { + footer_spans.push(Span::styled( + exit_str, + Style::default().fg(Color::LightRed).bg(tool_bg), )); } - lines.push(Line::from(header_spans)); + footer_spans.push(Span::styled( + " ".repeat(f_rem), + Style::default().bg(tool_bg), + )); - render_clean_markdown_body(&msg.content, &mut lines); - lines.push(Line::from("")); - } - - MessageSender::Tool { command, exit_code } => { - let code_color = if *exit_code == 0 { - Color::Green - } else { - Color::Red - }; - lines.push(Line::from(vec![ - Span::styled("$ ", Style::default().fg(Color::Yellow).bold()), - Span::styled(command.clone(), Style::default().fg(Color::LightYellow)), - Span::raw(" "), - Span::styled( - format!("[exit {}]", exit_code), - Style::default().fg(code_color), - ), - ])); - for text_line in msg.content.lines() { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(text_line.to_string(), Style::default().fg(Color::LightCyan)), - ])); - } + lines.push(Line::from(footer_spans).style(Style::default().bg(tool_bg))); lines.push(Line::from("")); } MessageSender::System => { - lines.push(Line::from(vec![ - Span::styled(". ", Style::default().fg(Color::DarkGray)), - Span::styled( - msg.content.clone(), - Style::default().fg(Color::DarkGray).italic(), - ), - ])); + for text_line in msg.content.lines() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + text_line.to_string(), + Style::default().fg(Color::Rgb(140, 140, 150)), + ), + ])); + } lines.push(Line::from("")); } } @@ -334,7 +412,7 @@ fn render_clean_markdown_body(content: &str, lines: &mut Vec) { if line.trim_start().starts_with("```") { in_code_block = !in_code_block; lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( line.to_string(), Style::default().fg(Color::DarkGray).bold(), @@ -342,29 +420,96 @@ fn render_clean_markdown_body(content: &str, lines: &mut Vec) { ])); } else if in_code_block { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled(line.to_string(), Style::default().fg(Color::LightCyan)), ])); } else { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(line.to_string(), Style::default().fg(Color::White)), - ])); + lines.push(Line::from(parse_markdown_line_spans(line))); } } } -fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { - let prompt_prefix = "> "; +fn parse_markdown_line_spans(line: &str) -> Vec> { + let mut spans = Vec::new(); + spans.push(Span::raw(" ")); + let trimmed = line.trim(); + if trimmed.is_empty() { + return spans; + } + + // Italic thought / preamble + if (trimmed.starts_with('*') && trimmed.ends_with('*') && !trimmed.starts_with("**")) + || (trimmed.starts_with('_') && trimmed.ends_with('_')) + || trimmed.starts_with("Report. Maybe") + || trimmed.starts_with("Honest answer.") + || trimmed.starts_with("Yes, briefly.") + { + spans.push(Span::styled( + line.to_string(), + Style::default() + .fg(Color::Rgb(140, 140, 150)) + .add_modifier(Modifier::ITALIC), + )); + return spans; + } + + // Bullet point + if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + spans.push(Span::styled("- ", Style::default().fg(Color::Cyan))); + parse_inline_spans(&trimmed[2..], &mut spans); + return spans; + } + + parse_inline_spans(line, &mut spans); + spans +} + +fn parse_inline_spans(text: &str, spans: &mut Vec>) { + let mut rest = text; + while let Some(start) = rest.find('`') { + if start > 0 { + spans.push(Span::styled( + rest[..start].to_string(), + Style::default().fg(Color::White), + )); + } + let after_start = &rest[start + 1..]; + if let Some(end) = after_start.find('`') { + let code_token = &after_start[..end]; + spans.push(Span::styled( + code_token.to_string(), + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + )); + rest = &after_start[end + 1..]; + } else { + spans.push(Span::styled( + rest[start..].to_string(), + Style::default().fg(Color::White), + )); + return; + } + } + + if !rest.is_empty() { + spans.push(Span::styled( + rest.to_string(), + Style::default().fg(Color::White), + )); + } +} + +fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { let para = Paragraph::new(Line::from(vec![ - Span::styled(prompt_prefix, Style::default().fg(Color::Cyan).bold()), + Span::raw(" "), Span::styled(&app.input, Style::default().fg(Color::White)), ])); frame.render_widget(para, area); // Terminal cursor positioning - let cursor_x = area.x + 2 + (app.cursor_pos as u16); + let cursor_x = area.x + 1 + (app.cursor_pos as u16); let cursor_y = area.y; if cursor_x < area.x + area.width { frame.set_cursor_position(Position { @@ -376,12 +521,11 @@ fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) + .borders(Borders::LEFT) .border_style(Style::default().fg(Color::DarkGray)) .title(Span::styled( - " [workings] ", - Style::default().fg(Color::Cyan).bold(), + " models routed ", + Style::default().fg(Color::DarkGray).bold(), )); let inner = block.inner(area); @@ -389,58 +533,22 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { let mut lines: Vec = Vec::new(); - // Section 1: Active Target & Route - lines.push(Line::from(vec![Span::styled( - "ROUTING", - Style::default().fg(Color::DarkGray).bold(), - )])); - - let target_persona = if app.is_auto_route() { - "auto (stage a/b)".to_string() - } else { - app.current_persona_name().to_string() - }; - lines.push(Line::from(vec![ - Span::raw(" target: "), - Span::styled(target_persona, Style::default().fg(Color::Green).bold()), - ])); - - if let Some(ref stage) = app.workings.route_stage { - lines.push(Line::from(vec![ - Span::raw(" stage: "), - Span::styled(stage.clone(), Style::default().fg(Color::Yellow)), - ])); - } - if let Some(ref detail) = app.workings.route_details { - lines.push(Line::from(vec![ - Span::raw(" detail: "), - Span::styled(detail.clone(), Style::default().fg(Color::Gray)), - ])); - } - lines.push(Line::from("")); - - // Section 2: Candidate Cascade - lines.push(Line::from(vec![Span::styled( - "CASCADE", - Style::default().fg(Color::DarkGray).bold(), - )])); - if app.workings.candidate_attempts.is_empty() { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( - "idle (evaluates on prompt)", + "waiting for prompt...", Style::default().fg(Color::DarkGray).italic(), ), ])); } else { - for (idx, att) in app.workings.candidate_attempts.iter().enumerate() { - let symbol = if att.is_success { - Span::styled("+ ", Style::default().fg(Color::Green).bold()) + for att in &app.workings.candidate_attempts { + let (icon, color) = if att.is_success { + ("•", Color::Green) } else if att.is_error { - Span::styled("- ", Style::default().fg(Color::Red).bold()) + ("x", Color::Red) } else { - Span::styled(". ", Style::default().fg(Color::Yellow)) + ("~", Color::Yellow) }; let tier_str = match att.tier { @@ -448,113 +556,100 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { Tier::Paid => "paid", }; + let model_display = truncate_str(&att.model, inner.width.saturating_sub(6) as usize); + lines.push(Line::from(vec![ - Span::raw(format!(" [{}] ", idx + 1)), - symbol, - Span::styled(&att.model, Style::default().fg(Color::White).bold()), Span::raw(" "), + Span::styled(format!("{icon} "), Style::default().fg(color).bold()), Span::styled( - format!("[{tier_str}]"), - Style::default().fg(Color::DarkGray), + model_display, + Style::default() + .fg(if att.is_success { + Color::White + } else { + Color::Gray + }) + .bold(), ), ])); - if !att.status.is_empty() { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - &att.status, - if att.is_error { - Style::default().fg(Color::LightRed).italic() - } else { - Style::default().fg(Color::LightGreen) - }, - ), - ])); - } + 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(Color::DarkGray) + }, + ), + ])); + lines.push(Line::from("")); } } - lines.push(Line::from("")); - - // Section 3: Team Pipeline - if let Some(ref p_name) = app.workings.active_pipeline { - lines.push(Line::from(vec![Span::styled( - "PIPELINE", - Style::default().fg(Color::DarkGray).bold(), - )])); - lines.push(Line::from(vec![ - Span::raw(" active: "), - Span::styled(p_name, Style::default().fg(Color::Yellow).bold()), - ])); - lines.push(Line::from("")); - } - - // Section 4: Budget & Rate Limits - lines.push(Line::from(vec![Span::styled( - "BUDGET", - Style::default().fg(Color::DarkGray).bold(), - )])); - lines.push(Line::from(vec![ - Span::raw(" 1m limit: "), - Span::styled( - format!("{}/{}", app.minute_requests, app.minute_limit), - Style::default().fg(Color::White).bold(), - ), - Span::raw(" reqs"), - ])); - lines.push(Line::from(vec![ - Span::raw(" 24h limit: "), - Span::styled( - format!("{}/{}", app.daily_requests, app.daily_limit), - Style::default().fg(Color::White).bold(), - ), - Span::raw(" reqs"), - ])); - lines.push(Line::from("")); - - // Section 5: Real-time Event Log - lines.push(Line::from(vec![Span::styled( - "EVENT LOG", - Style::default().fg(Color::DarkGray).bold(), - )])); - - let event_lines_available = inner.height.saturating_sub(lines.len() as u16) as usize; - let take_count = event_lines_available.clamp(2, 15); - let start_idx = app.workings.events.len().saturating_sub(take_count); - - for ev in &app.workings.events[start_idx..] { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(ev.clone(), Style::default().fg(Color::DarkGray)), - ])); - } let para = Paragraph::new(lines).wrap(Wrap { trim: false }); frame.render_widget(para, inner); } -fn render_footer(frame: &mut Frame, _app: &AppState, area: Rect) { - let help_spans = vec![ - Span::styled("[Enter]", Style::default().fg(Color::White).bold()), - Span::styled(" send ", Style::default().fg(Color::DarkGray)), - Span::styled("[Tab]", Style::default().fg(Color::White).bold()), - Span::styled(" persona ", Style::default().fg(Color::DarkGray)), - Span::styled("[Ctrl+P]", Style::default().fg(Color::White).bold()), - Span::styled(" pipeline ", Style::default().fg(Color::DarkGray)), - Span::styled("[Ctrl+S]", Style::default().fg(Color::White).bold()), - Span::styled(" sessions ", Style::default().fg(Color::DarkGray)), - Span::styled("[Ctrl+N]", Style::default().fg(Color::White).bold()), - Span::styled(" new ", Style::default().fg(Color::DarkGray)), - Span::styled("[Ctrl+L]", Style::default().fg(Color::White).bold()), - Span::styled(" clear ", Style::default().fg(Color::DarkGray)), - Span::styled("[PgUp/Dn]", Style::default().fg(Color::White).bold()), - Span::styled(" scroll ", Style::default().fg(Color::DarkGray)), - Span::styled("[Esc]", Style::default().fg(Color::White).bold()), - Span::styled(" quit", Style::default().fg(Color::DarkGray)), - ]; +fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) { + let active_str = if app.is_auto_route() { + "(auto)".to_string() + } else { + format!("({})", app.current_persona_name()) + }; - let para = Paragraph::new(Line::from(help_spans)).alignment(Alignment::Left); - frame.render_widget(para, area); + let active_model = if let Some(att) = app + .workings + .candidate_attempts + .iter() + .find(|a| a.is_success) + { + att.model.as_str() + } else { + app.current_persona_name() + }; + + let stealth_str = if app.allow_stealth { + "stealth • free" + } else { + "free" + }; + + 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 + ), + Style::default().fg(Color::DarkGray), + )]; + + let right_spans = vec![Span::styled( + format!("{active_model} • {stealth_str}"), + Style::default().fg(Color::DarkGray), + )]; + + let footer_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + frame.render_widget(Paragraph::new(Line::from(left_spans)), footer_layout[0]); + frame.render_widget( + Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), + footer_layout[1], + ); } fn render_persona_popup(frame: &mut Frame, app: &AppState) { @@ -577,7 +672,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(get_persona_color(&item.name)) }, ), Span::styled(