brokecrew/src/tui/ui.rs

1588 lines
59 KiB
Rust

use super::app::{AppState, CommandSuggestion, MessageSender, TuiMode, THEMES};
use brokecrew::persona::Tier;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
#[derive(Debug, Clone)]
pub struct ThemePalette {
pub accent: Color,
pub user_bg: Color,
pub card_border: Color,
pub tool_bg: Color,
pub text_bright: Color,
pub text_muted: Color,
}
pub fn get_theme_palette(theme: &str) -> ThemePalette {
match theme {
"warm" => ThemePalette {
accent: Color::Rgb(240, 160, 60), // warm amber
user_bg: Color::Rgb(48, 40, 36), // warm charcoal
card_border: Color::Rgb(100, 80, 60),
tool_bg: Color::Rgb(36, 32, 28),
text_bright: Color::Rgb(250, 245, 235),
text_muted: Color::Rgb(160, 145, 130),
},
"forest" => ThemePalette {
accent: Color::Rgb(80, 200, 140), // emerald mint
user_bg: Color::Rgb(30, 46, 38), // deep pine
card_border: Color::Rgb(60, 95, 75),
tool_bg: Color::Rgb(24, 38, 30),
text_bright: Color::Rgb(235, 250, 240),
text_muted: Color::Rgb(130, 165, 145),
},
"cyberpunk" => ThemePalette {
accent: Color::Rgb(255, 60, 150), // neon pink
user_bg: Color::Rgb(42, 25, 52), // midnight magenta
card_border: Color::Rgb(110, 50, 130),
tool_bg: Color::Rgb(30, 20, 40),
text_bright: Color::Rgb(255, 230, 250),
text_muted: Color::Rgb(160, 120, 180),
},
"mono" => ThemePalette {
accent: Color::White,
user_bg: Color::Rgb(38, 38, 38),
card_border: Color::Rgb(85, 85, 85),
tool_bg: Color::Rgb(28, 28, 28),
text_bright: Color::White,
text_muted: Color::DarkGray,
},
"catppuccin" => ThemePalette {
accent: Color::Rgb(180, 190, 254), // lavender
user_bg: Color::Rgb(49, 50, 68), // surface0
card_border: Color::Rgb(88, 91, 112), // surface2
tool_bg: Color::Rgb(30, 30, 46), // base
text_bright: Color::Rgb(205, 214, 244), // text
text_muted: Color::Rgb(147, 153, 178), // overlay0
},
"tokyo_night" => ThemePalette {
accent: Color::Rgb(122, 162, 247), // tokyo blue
user_bg: Color::Rgb(36, 40, 59),
card_border: Color::Rgb(65, 72, 104),
tool_bg: Color::Rgb(26, 27, 38),
text_bright: Color::Rgb(192, 202, 245),
text_muted: Color::Rgb(120, 130, 160),
},
"nord" => ThemePalette {
accent: Color::Rgb(136, 192, 208), // frost cyan
user_bg: Color::Rgb(59, 66, 82), // nord1
card_border: Color::Rgb(76, 86, 106), // nord3
tool_bg: Color::Rgb(46, 52, 64), // nord0
text_bright: Color::Rgb(236, 239, 244), // nord6
text_muted: Color::Rgb(140, 155, 170),
},
"dracula" => ThemePalette {
accent: Color::Rgb(189, 147, 249), // purple
user_bg: Color::Rgb(68, 71, 90), // current line
card_border: Color::Rgb(98, 114, 164), // comment
tool_bg: Color::Rgb(40, 42, 54), // bg
text_bright: Color::Rgb(248, 248, 242), // fg
text_muted: Color::Rgb(139, 147, 185),
},
"solarized" => ThemePalette {
accent: Color::Rgb(42, 161, 152), // cyan
user_bg: Color::Rgb(7, 54, 66), // base02
card_border: Color::Rgb(88, 110, 117), // base01
tool_bg: Color::Rgb(0, 43, 54), // base03
text_bright: Color::Rgb(238, 232, 213), // base2
text_muted: Color::Rgb(147, 161, 161), // base1
},
"gruvbox" => ThemePalette {
accent: Color::Rgb(254, 128, 25), // orange
user_bg: Color::Rgb(60, 56, 54), // dark2
card_border: Color::Rgb(102, 92, 84), // dark4
tool_bg: Color::Rgb(40, 40, 40), // dark0
text_bright: Color::Rgb(235, 219, 178), // light1
text_muted: Color::Rgb(168, 153, 132), // light4
},
_ => ThemePalette {
// opencode (default)
accent: Color::Rgb(80, 210, 230), // cyan / sky
user_bg: Color::Rgb(46, 50, 66), // deep slate
card_border: Color::Rgb(70, 90, 115),
tool_bg: Color::Rgb(30, 38, 34),
text_bright: Color::White,
text_muted: Color::Rgb(140, 145, 160),
},
}
}
pub fn render(frame: &mut Frame, app: &mut AppState) {
let size = frame.area();
let palette = get_theme_palette(&app.current_theme);
if app.is_start_screen() {
render_start_screen(frame, app, size, &palette);
} else {
let root_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Sleek 1-line top session tab
Constraint::Min(6), // Main workspace (Split: Chat left, Workings right)
Constraint::Length(1), // Minimal Bottom status
])
.split(size);
render_header(frame, app, root_chunks[0], &palette);
render_workspace(frame, app, root_chunks[1], &palette);
render_footer(frame, app, root_chunks[2], &palette);
}
match app.mode {
TuiMode::PersonaSelect => render_persona_popup(frame, app, &palette),
TuiMode::PipelineSelect => render_pipeline_popup(frame, app, &palette),
TuiMode::SessionSelect => render_session_popup(frame, app, &palette),
TuiMode::ThemeSelect => render_theme_popup(frame, app, &palette),
TuiMode::Normal => {}
}
}
fn render_start_screen(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(16), // Top spacer
Constraint::Length(6), // ASCII Logo
Constraint::Length(2), // Subtitle
Constraint::Length(1), // Spacer
Constraint::Length(3), // Centered input card
Constraint::Min(6), // Suggestions or shortcuts
Constraint::Length(1), // Footer
])
.split(area);
// 1. ASCII Logo (Compact block)
let width = area.width;
let logo_lines = if width >= 80 {
vec![
"██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ ██████╗██████╗ ███████╗██╗ ██╗",
"██╔══██╗██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝██╔════╝██╔══██╗██╔════╝██║ ██║",
"██████╔╝██████╔╝██║ ██║█████╔╝ █████╗ ██║ ██████╔╝█████╗ ██║ █╗ ██║",
"██╔══██╗██╔══██╗██║ ██║██╔═██╗ ██╔══╝ ██║ ██╔══██╗██╔══╝ ██║███╗██║",
"██████╔╝██║ ██║╚██████╔╝██║ ██╗███████╗╚██████╗██║ ██║███████╗╚███╔███╔╝",
"╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝ ",
]
} else {
vec![
" ___ _ ___ ",
" / _ )_______ / /_____ / _ \\_______ _ __ ",
" / _ / __/ _ \\/ '_/ -_) // / __/ -_) |/|/ / ",
"/____/_/ \\___/_/\\_\\\\__/\\___/_/ \\__/|__,__/ ",
"",
"",
]
};
let logo_spans: Vec<Line> = logo_lines
.into_iter()
.map(|l| {
Line::from(Span::styled(l, Style::default().fg(palette.accent).bold()))
.alignment(Alignment::Center)
})
.collect();
frame.render_widget(Paragraph::new(logo_spans), v_chunks[1]);
// 2. Subtitle
let subtitle = Line::from(vec![Span::styled(
"/help to view commands • v0.1.1",
Style::default().fg(palette.text_muted),
)])
.alignment(Alignment::Center);
frame.render_widget(Paragraph::new(subtitle), v_chunks[2]);
// 3. Centered input card
let h_card_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.split(v_chunks[4]);
let input_area = h_card_chunks[1];
let input_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.card_border))
.style(Style::default().bg(palette.user_bg));
let input_inner = input_block.inner(input_area);
frame.render_widget(input_block, input_area);
let input_display = if app.input.is_empty() {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(
"Ask anything or type / for commands...",
Style::default().fg(palette.text_muted).italic(),
),
])
} else {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(&app.input, Style::default().fg(palette.text_bright).bold()),
])
};
frame.render_widget(Paragraph::new(input_display), input_inner);
// Terminal cursor positioning on start screen
let cursor_x = input_inner.x + 2 + (app.cursor_pos as u16);
let cursor_y = input_inner.y;
if cursor_x < input_inner.x + input_inner.width {
frame.set_cursor_position(Position {
x: cursor_x,
y: cursor_y,
});
}
// 4. Suggestions dropdown OR shortcut hint pills
let suggestions = app.get_matching_suggestions();
if !suggestions.is_empty() {
let sug_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.split(v_chunks[5]);
render_suggestions_list(frame, app, sug_chunks[1], palette, &suggestions);
} else {
let hints = Line::from(vec![
Span::styled("Tab ", Style::default().fg(palette.accent).bold()),
Span::styled("persona • ", Style::default().fg(palette.text_muted)),
Span::styled("Ctrl+P ", Style::default().fg(palette.accent).bold()),
Span::styled("pipeline • ", Style::default().fg(palette.text_muted)),
Span::styled("/theme ", Style::default().fg(palette.accent).bold()),
Span::styled("color • ", Style::default().fg(palette.text_muted)),
Span::styled("Esc ", Style::default().fg(palette.accent).bold()),
Span::styled("quit", Style::default().fg(palette.text_muted)),
])
.alignment(Alignment::Center);
let hint_area = Rect {
x: v_chunks[5].x,
y: v_chunks[5].y + 1,
width: v_chunks[5].width,
height: 1,
};
frame.render_widget(Paragraph::new(hints), hint_area);
}
// 5. Footer
render_footer(frame, app, v_chunks[6], palette);
}
fn render_header(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let session_title = truncate_str(&app.active_session.title, 32);
let active_persona = app.current_persona_name();
let pipe_tag = if let Some(ref p) = app.active_pipeline_for_prompt {
format!(" [{p}]")
} else {
String::new()
};
let left_spans = vec![
Span::styled("⠇ ", Style::default().fg(palette.accent).bold()),
Span::styled(
session_title,
Style::default().fg(palette.text_bright).bold(),
),
Span::styled(
format!(" ({active_persona}{pipe_tag})"),
Style::default().fg(palette.text_muted),
),
];
let right_spans = if app.is_generating {
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let sp = spinner_frames[app.spinner_tick % spinner_frames.len()];
vec![Span::styled(
format!("{sp} thinking..."),
Style::default().fg(Color::Yellow),
)]
} else if app.esc_pressed_at.is_some() {
vec![Span::styled(
"[esc again to quit]",
Style::default().fg(Color::LightRed).bold(),
)]
} else {
vec![]
};
let header_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
frame.render_widget(Paragraph::new(Line::from(left_spans)), header_layout[0]);
frame.render_widget(
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
header_layout[1],
);
}
fn render_workspace(frame: &mut Frame, app: &mut AppState, area: Rect, palette: &ThemePalette) {
let (chat_pct, side_pct) = if area.width >= 130 {
(72, 28)
} else if area.width >= 95 {
(68, 32)
} else {
(64, 36)
};
let workspace_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(chat_pct), // Left: Chat
Constraint::Percentage(side_pct), // Right: Telemetry & Pipeline
])
.split(area);
render_conversation_pane(frame, app, workspace_chunks[0], palette);
render_workings_pane(frame, app, workspace_chunks[1], palette);
}
fn render_conversation_pane(
frame: &mut Frame,
app: &mut AppState,
area: Rect,
palette: &ThemePalette,
) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(4), // Messages stream
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], palette);
// Top input border
let top_border = Line::from(Span::styled(
"─".repeat(chat_chunks[1].width as usize),
Style::default().fg(palette.card_border),
));
frame.render_widget(Paragraph::new(top_border), chat_chunks[1]);
// Input text line
render_minimal_input(frame, app, chat_chunks[2], palette);
// Bottom input border
let bot_border = Line::from(Span::styled(
"─".repeat(chat_chunks[3].width as usize),
Style::default().fg(palette.card_border),
));
frame.render_widget(Paragraph::new(bot_border), chat_chunks[3]);
// ~ indicator line
let tilde = Line::from(vec![
Span::raw(" "),
Span::styled("~", Style::default().fg(palette.text_muted)),
]);
frame.render_widget(Paragraph::new(tilde), chat_chunks[4]);
// Suggestions popup when typing /
let suggestions = app.get_matching_suggestions();
if !suggestions.is_empty() {
render_suggestions_overlay(frame, app, chat_chunks[1], palette, &suggestions);
}
}
fn render_suggestions_overlay(
frame: &mut Frame,
app: &AppState,
anchor: Rect,
palette: &ThemePalette,
suggestions: &[&'static CommandSuggestion],
) {
let count = suggestions.len().min(8);
let box_height = (count as u16) + 2;
let box_y = anchor.y.saturating_sub(box_height);
let sug_rect = Rect {
x: anchor.x + 1,
y: box_y,
width: anchor.width.saturating_sub(2).min(64),
height: box_height,
};
render_suggestions_list(frame, app, sug_rect, palette, suggestions);
}
fn render_suggestions_list(
frame: &mut Frame,
app: &AppState,
area: Rect,
palette: &ThemePalette,
suggestions: &[&'static CommandSuggestion],
) {
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.accent))
.style(Style::default().bg(Color::Rgb(26, 30, 42)));
let inner = block.inner(area);
frame.render_widget(block, area);
let count = suggestions.len().min(inner.height as usize);
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
.take(count)
.map(|(idx, sug)| {
let is_selected = idx == app.selected_suggestion_idx;
let (prefix, prefix_style) = if is_selected {
("→ ", Style::default().fg(palette.accent).bold())
} else {
(" ", Style::default().fg(Color::DarkGray))
};
let line = Line::from(vec![
Span::styled(prefix, prefix_style),
Span::styled(
format!("{:<13} ", sug.name),
if is_selected {
Style::default().fg(palette.accent).bold()
} else {
Style::default().fg(Color::White)
},
),
Span::styled(sug.description, Style::default().fg(palette.text_muted)),
]);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(38, 44, 62)))
} else {
ListItem::new(line)
}
})
.collect();
let list = List::new(items);
frame.render_widget(list, inner);
}
fn wrap_text(line: &str, max_width: usize) -> Vec<String> {
if line.is_empty() {
return vec![String::new()];
}
if max_width == 0 {
return vec![line.to_string()];
}
let mut result = Vec::new();
let mut current = String::new();
let mut current_len = 0;
for word in line.split(' ') {
let word_len = word.chars().count();
if current.is_empty() {
if word_len > max_width {
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(max_width) {
result.push(chunk.iter().collect());
}
} else {
current.push_str(word);
current_len = word_len;
}
} else if current_len + 1 + word_len <= max_width {
current.push(' ');
current.push_str(word);
current_len += 1 + word_len;
} else {
result.push(current);
if word_len > max_width {
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(max_width) {
result.push(chunk.iter().collect());
}
current = String::new();
current_len = 0;
} else {
current = word.to_string();
current_len = word_len;
}
}
}
if !current.is_empty() {
result.push(current);
}
result
}
fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let mut lines: Vec<Line> = Vec::new();
let full_width = area.width.saturating_sub(1) as usize;
for msg in &app.messages {
match &msg.sender {
MessageSender::User => {
let card_width = full_width.max(12);
let inner_text_width = card_width.saturating_sub(4); // 2 cols for "▌ ", 2 cols right safety margin
lines.push(Line::from(""));
// Top padding line
let top_pad = card_width.saturating_sub(2);
lines.push(
Line::from(vec![
Span::styled(
"▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg),
),
Span::styled(" ".repeat(top_pad), Style::default().bg(palette.user_bg)),
])
.style(Style::default().bg(palette.user_bg)),
);
// Content lines with proper word-wrapping and exact right padding
for raw_line in msg.content.lines() {
let wrapped_lines = wrap_text(raw_line, inner_text_width);
for text_line in wrapped_lines {
let text_len = text_line.chars().count();
let pad_len = card_width.saturating_sub(2 + text_len);
lines.push(
Line::from(vec![
Span::styled(
"▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg),
),
Span::styled(
text_line,
Style::default()
.fg(palette.text_bright)
.bold()
.bg(palette.user_bg),
),
Span::styled(
" ".repeat(pad_len),
Style::default().bg(palette.user_bg),
),
])
.style(Style::default().bg(palette.user_bg)),
);
}
}
// Bottom padding line
let bot_pad = card_width.saturating_sub(2);
lines.push(
Line::from(vec![
Span::styled(
"▌ ",
Style::default().fg(palette.accent).bg(palette.user_bg),
),
Span::styled(" ".repeat(bot_pad), Style::default().bg(palette.user_bg)),
])
.style(Style::default().bg(palette.user_bg)),
);
lines.push(Line::from(""));
}
MessageSender::Persona { name, model, tier } => {
let tier_str = match tier {
Tier::Free => "free",
Tier::Paid => "paid",
};
let persona_color = get_persona_color(name);
let mut header_spans = vec![
Span::raw(" "),
Span::styled(
format!("⚡ {name}"),
Style::default().fg(persona_color).bold(),
),
Span::styled(
format!(" ({model} • {tier_str})"),
Style::default().fg(palette.text_muted),
),
];
if msg.fell_back {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
"⚠ fallback",
Style::default().fg(Color::Yellow).bold(),
));
}
lines.push(Line::from(header_spans));
lines.push(Line::from(""));
render_clean_markdown_body(&msg.content, &mut lines);
lines.push(Line::from(""));
}
MessageSender::PipelineStep {
step_index,
total_steps,
persona,
model,
tier,
} => {
let tier_str = match tier {
Tier::Free => "free",
Tier::Paid => "paid",
};
let persona_color = get_persona_color(persona);
let mut header_spans = vec![
Span::raw(" "),
Span::styled(
format!("⚡ Step {}/{}: {persona}", step_index + 1, total_steps),
Style::default().fg(persona_color).bold(),
),
Span::styled(
format!(" ({model} • {tier_str})"),
Style::default().fg(palette.text_muted),
),
];
if msg.fell_back {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
"⚠ fallback",
Style::default().fg(Color::Yellow).bold(),
));
}
lines.push(Line::from(header_spans));
lines.push(Line::from(""));
render_clean_markdown_body(&msg.content, &mut lines);
lines.push(Line::from(""));
}
MessageSender::Tool {
command,
exit_code,
duration_ms,
} => {
let tool_bg = palette.tool_bg;
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(
header_prefix,
Style::default()
.fg(Color::Rgb(170, 220, 180))
.bold()
.bg(tool_bg),
),
Span::styled(" ".repeat(h_rem), Style::default().bg(tool_bg)),
Span::styled(
timeout_tag,
Style::default().fg(Color::Rgb(110, 130, 120)).bg(tool_bg),
),
])
.style(Style::default().bg(tool_bg)),
);
// Body content lines
let raw_content = msg.content.trim();
if !raw_content.is_empty() {
let content_lines: Vec<&str> = raw_content.lines().collect();
let total_c_lines = content_lines.len();
// 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)),
);
if !app.expand_tool_outputs && total_c_lines > 6 {
let earlier_count = total_c_lines.saturating_sub(5);
let collapsed_msg =
format!(" ... ({earlier_count} earlier lines, ctrl+o to expand)");
let e_rem = full_width.saturating_sub(collapsed_msg.len());
lines.push(
Line::from(vec![
Span::styled(
collapsed_msg,
Style::default()
.fg(Color::Rgb(140, 150, 145))
.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 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),
));
}
footer_spans.push(Span::styled(
" ".repeat(f_rem),
Style::default().bg(tool_bg),
));
lines.push(Line::from(footer_spans).style(Style::default().bg(tool_bg)));
lines.push(Line::from(""));
}
MessageSender::System => {
for text_line in msg.content.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
text_line.to_string(),
Style::default().fg(palette.text_muted),
),
]));
}
lines.push(Line::from(""));
}
}
}
let total_lines = lines.len();
let inner_height = area.height as usize;
let scroll_y = if app.auto_scroll {
total_lines.saturating_sub(inner_height) as u16
} else {
total_lines
.saturating_sub(inner_height)
.saturating_sub(app.scroll_offset as usize) as u16
};
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll_y, 0));
frame.render_widget(paragraph, area);
}
fn render_clean_markdown_body(content: &str, lines: &mut Vec<Line>) {
let mut in_code_block = false;
for line in content.lines() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
line.to_string(),
Style::default().fg(Color::DarkGray).bold(),
),
]));
} else if in_code_block {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(line.to_string(), Style::default().fg(Color::LightCyan)),
]));
} else {
lines.push(Line::from(parse_markdown_line_spans(line)));
}
}
}
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, palette: &ThemePalette) {
let input_line = if app.input.is_empty() {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(
"Ask anything or type / for commands...",
Style::default().fg(palette.text_muted).italic(),
),
])
} else {
Line::from(vec![
Span::styled("▌ ", Style::default().fg(palette.accent)),
Span::styled(&app.input, Style::default().fg(palette.text_bright)),
])
};
frame.render_widget(Paragraph::new(input_line), area);
// Terminal cursor positioning
let cursor_x = area.x + 2 + (app.cursor_pos as u16);
let cursor_y = area.y;
if cursor_x < area.x + area.width {
frame.set_cursor_position(Position {
x: cursor_x,
y: cursor_y,
});
}
}
fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let is_pipeline = !app.workings.pipeline_steps.is_empty();
let title_str = if is_pipeline {
" pipeline flow "
} else {
" routing & models "
};
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(palette.card_border))
.title(Span::styled(
title_str,
Style::default().fg(palette.text_muted).bold(),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let sp = spinner_frames[app.spinner_tick % spinner_frames.len()];
if is_pipeline {
// Render Animated Multi-Agent Pipeline Flow
let pipe_name = app
.workings
.active_pipeline
.as_deref()
.unwrap_or("build-and-review");
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("⚡ pipeline: {pipe_name}"),
Style::default().fg(palette.accent).bold(),
),
]));
lines.push(Line::from(""));
let total_steps = app.workings.pipeline_steps.len();
for (idx, step) in app.workings.pipeline_steps.iter().enumerate() {
if idx > 0 {
let prev_completed = app.workings.pipeline_steps[idx - 1].is_completed;
let (connector_str, connector_color) = if prev_completed && step.is_active {
let anim_frames = [" │ ", " ╽ ", " ┃ ", " ▼ "];
let a = anim_frames[(app.spinner_tick / 2) % anim_frames.len()];
(a, palette.accent)
} else if prev_completed && step.is_completed {
(" │ ", Color::LightGreen)
} else {
(" · ", palette.text_muted)
};
lines.push(Line::from(vec![Span::styled(
connector_str,
Style::default().fg(connector_color).bold(),
)]));
}
if step.is_active {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{sp}] "),
Style::default().fg(palette.accent).bold(),
),
Span::styled(
format!("{}. {}", idx + 1, step.persona),
Style::default().fg(palette.text_bright).bold(),
),
]));
let model_label = truncate_str(&step.model, inner.width.saturating_sub(7) as usize);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("↳ {model_label}"),
Style::default().fg(Color::LightCyan),
),
]));
} else if step.is_completed {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("[✔] ", Style::default().fg(Color::LightGreen).bold()),
Span::styled(
format!("{}. {}", idx + 1, step.persona),
Style::default().fg(Color::White).bold(),
),
]));
let model_label = truncate_str(&step.model, inner.width.saturating_sub(7) as usize);
let tier_str = if step.tier == Tier::Paid {
"paid"
} else {
"free"
};
let mut status_spans = vec![
Span::raw(" "),
Span::styled(
format!("served ({model_label} • {tier_str})"),
Style::default().fg(Color::Green),
),
];
if step.fell_back {
status_spans.push(Span::styled(
" [fallback]",
Style::default().fg(Color::Yellow),
));
}
lines.push(Line::from(status_spans));
} else {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("[○] ", Style::default().fg(palette.text_muted)),
Span::styled(
format!("{}. {}", idx + 1, step.persona),
Style::default().fg(palette.text_muted),
),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("queued ({}/{})", idx + 1, total_steps),
Style::default().fg(palette.text_muted).italic(),
),
]));
}
}
} else if app.workings.route_stage.is_some() || !app.workings.candidate_attempts.is_empty() {
// Render Single Persona Routing Telemetry & Cascade Attempts
if let Some(stage) = &app.workings.route_stage {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("⚡ ", Style::default().fg(palette.accent).bold()),
Span::styled(stage.clone(), Style::default().fg(palette.accent).bold()),
]));
if let Some(details) = &app.workings.route_details {
let d_trunc = truncate_str(details, inner.width.saturating_sub(4) as usize);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(d_trunc, Style::default().fg(palette.text_muted)),
]));
}
if let Some(score) = app.workings.complexity_score {
let filled = (score as usize).min(10);
let empty = 10 - filled;
let bar_str = format!("{}{}", "■".repeat(filled), "□".repeat(empty));
let color = if score >= 7 {
Color::Yellow
} else {
Color::Green
};
let esc_tag = if score >= 7 {
" [paid escalation]"
} else {
" [free tier]"
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{bar_str}] {score}/10{esc_tag}"),
Style::default().fg(color).bold(),
),
]));
}
lines.push(Line::from(""));
}
if !app.workings.candidate_attempts.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"candidates evaluated:",
Style::default().fg(palette.text_muted).bold(),
),
]));
for att in &app.workings.candidate_attempts {
let (icon, color) = if att.is_success {
("•", Color::LightGreen)
} else if att.is_error {
("✖", Color::LightRed)
} else {
(sp, Color::Yellow)
};
let tier_str = match att.tier {
Tier::Free => "free",
Tier::Paid => "paid",
};
let model_display =
truncate_str(&att.model, inner.width.saturating_sub(6) as usize);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{icon} "), Style::default().fg(color).bold()),
Span::styled(
model_display,
Style::default()
.fg(if att.is_success {
palette.text_bright
} else {
Color::Gray
})
.bold(),
),
]));
let status_text = if att.is_success {
format!(" served [{tier_str}]")
} else if att.is_error {
let err_short =
truncate_str(&att.status, inner.width.saturating_sub(6) as usize);
format!(" failed: {err_short}")
} else {
format!(" attempting [{tier_str}]...")
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
status_text,
if att.is_error {
Style::default().fg(Color::LightRed).italic()
} else if att.is_success {
Style::default().fg(Color::LightGreen)
} else {
Style::default().fg(palette.text_muted)
},
),
]));
if att.is_error {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
" ↳ cascading to fallback...",
Style::default().fg(Color::Yellow).italic(),
),
]));
}
lines.push(Line::from(""));
}
}
} else {
// Idle state: Helpful team & feature overview
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"team status: ready",
Style::default().fg(palette.accent).bold(),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"available personas:",
Style::default().fg(palette.text_muted).bold(),
),
]));
for p in app.persona_items.iter().take(5) {
let p_color = get_persona_color(&p.name);
lines.push(Line::from(vec![
Span::raw(" • "),
Span::styled(&p.name, Style::default().fg(p_color).bold()),
]));
}
if !app.pipelines.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"pipelines (ctrl+p):",
Style::default().fg(palette.text_muted).bold(),
),
]));
for pipe in &app.pipelines {
lines.push(Line::from(vec![
Span::raw(" ⚡ "),
Span::styled(&pipe.name, Style::default().fg(Color::Yellow)),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("shortcuts:", Style::default().fg(palette.text_muted).bold()),
]));
lines.push(Line::from(vec![
Span::styled(" Tab ", Style::default().fg(palette.accent)),
Span::styled("Select persona", Style::default().fg(palette.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" Ctrl+P", Style::default().fg(palette.accent)),
Span::styled("Run pipeline", Style::default().fg(palette.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" Ctrl+S", Style::default().fg(palette.accent)),
Span::styled("Saved sessions", Style::default().fg(palette.text_muted)),
]));
lines.push(Line::from(vec![
Span::styled(" /theme", Style::default().fg(palette.accent)),
Span::styled("Color palettes", Style::default().fg(palette.text_muted)),
]));
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(para, inner);
}
fn render_footer(frame: &mut Frame, app: &AppState, area: Rect, palette: &ThemePalette) {
let active_str = if app.is_auto_route() {
"(auto)".to_string()
} else {
format!("({})", app.current_persona_name())
};
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!(
"↑{} ↓{} 1m: {}/{} 24h: {}/{} {active_str}",
AppState::format_tokens(app.tokens_in),
AppState::format_tokens(app.tokens_out),
app.minute_requests,
app.minute_limit,
app.daily_requests,
app.daily_limit
),
Style::default().fg(palette.text_muted),
)];
let right_spans = vec![Span::styled(
format!("{active_model} • {stealth_str}"),
Style::default().fg(palette.text_muted),
)];
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, palette: &ThemePalette) {
let area = centered_rect(55, 55, frame.area());
frame.render_widget(Clear, area);
let items: Vec<ListItem> = app
.persona_items
.iter()
.enumerate()
.map(|(idx, item)| {
let is_selected = idx == app.selected_persona_idx;
let prefix = if is_selected { "> " } else { " " };
let line = Line::from(vec![
Span::styled(
format!("{prefix}{:<12} ", item.name),
if is_selected {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(get_persona_color(&item.name))
},
),
Span::styled(
item.description.clone(),
Style::default().fg(palette.text_muted),
),
]);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(30, 30, 45)))
} else {
ListItem::new(line)
}
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.accent))
.title(Span::styled(
" select persona [up/down, enter select, esc cancel] ",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)),
);
frame.render_widget(list, area);
}
fn render_pipeline_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(60, 50, frame.area());
frame.render_widget(Clear, area);
let items: Vec<ListItem> = if app.pipelines.is_empty() {
vec![ListItem::new("no pipelines configured in personas.toml")]
} else {
app.pipelines
.iter()
.enumerate()
.map(|(idx, pipe)| {
let is_selected = idx == app.selected_pipeline_idx;
let prefix = if is_selected { "> " } else { " " };
let steps: Vec<String> = pipe.steps.iter().map(|s| s.persona.clone()).collect();
let steps_str = steps.join(" -> ");
let line = Line::from(vec![
Span::styled(
format!("{prefix}{:<18} ", pipe.name),
if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
},
),
Span::styled(
format!("({steps_str})"),
Style::default().fg(palette.text_muted),
),
]);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(45, 35, 20)))
} else {
ListItem::new(line)
}
})
.collect()
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Yellow))
.title(Span::styled(
" select pipeline [up/down, enter run, esc cancel] ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
);
frame.render_widget(list, area);
}
fn render_session_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(65, 55, frame.area());
frame.render_widget(Clear, area);
let items: Vec<ListItem> = if app.session_summaries.is_empty() {
vec![ListItem::new("no saved sessions yet")]
} else {
app.session_summaries
.iter()
.enumerate()
.map(|(idx, s)| {
let is_selected = idx == app.selected_session_idx;
let prefix = if is_selected { "> " } else { " " };
let line = Line::from(vec![
Span::styled(
format!("{prefix}{:<24} ", truncate_str(&s.title, 24)),
if is_selected {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
},
),
Span::styled(
format!(
"[{}] ({} msgs) - {}",
s.persona,
s.message_count,
truncate_str(&s.updated_at, 19)
),
Style::default().fg(palette.text_muted),
),
]);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(30, 30, 45)))
} else {
ListItem::new(line)
}
})
.collect()
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.accent))
.title(Span::styled(
" saved sessions [up/down, enter resume, esc cancel] ",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)),
);
frame.render_widget(list, area);
}
fn render_theme_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
let area = centered_rect(60, 48, frame.area());
frame.render_widget(Clear, area);
let items: Vec<ListItem> = THEMES
.iter()
.enumerate()
.map(|(idx, (name, desc))| {
let is_selected = idx == app.selected_theme_idx;
let is_active = *name == app.current_theme;
let prefix = if is_selected { "→ " } else { " " };
let mut spans = vec![
Span::styled(
format!("{prefix}{:<12} ", name),
if is_selected {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.text_bright)
},
),
Span::styled(*desc, Style::default().fg(palette.text_muted)),
];
if is_active {
spans.push(Span::styled(
" [active]",
Style::default().fg(Color::LightGreen).bold(),
));
}
let line = Line::from(spans);
if is_selected {
ListItem::new(line).style(Style::default().bg(Color::Rgb(32, 38, 54)))
} else {
ListItem::new(line)
}
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(palette.accent))
.title(Span::styled(
" select theme [up/down, enter apply, esc cancel] ",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)),
);
frame.render_widget(list, area);
}
fn get_persona_color(name: &str) -> Color {
match name.to_lowercase().as_str() {
"coder" => Color::LightGreen,
"reviewer" => Color::LightYellow,
"architect" => Color::LightBlue,
"researcher" => Color::LightMagenta,
"summarizer" => Color::LightCyan,
_ => Color::White,
}
}
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max.saturating_sub(3)])
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}