feat(tui): match Pi/OpenCode theme, compact routed models workings, and slash commands
This commit is contained in:
parent
9e96eec964
commit
eb4399177e
3 changed files with 500 additions and 262 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
128
src/tui/mod.rs
128
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;
|
||||
|
||||
|
|
|
|||
603
src/tui/ui.rs
603
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<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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue