feat(tui): match Pi/OpenCode theme, compact routed models workings, and slash commands

This commit is contained in:
katR 2026-09-26 16:21:29 +05:30
parent 9e96eec964
commit eb4399177e
3 changed files with 500 additions and 262 deletions

View file

@ -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(),

View file

@ -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;

View file

@ -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<Line> = 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: $ <command> (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<Line>) {
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<Line>) {
]));
} 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<Span<'static>> {
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<Span<'static>>) {
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<Line> = 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(