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 = 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 = 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 { 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 = 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 = 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 = 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: $ (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) { 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> { let mut spans = Vec::new(); spans.push(Span::raw(" ")); let trimmed = line.trim(); if trimmed.is_empty() { return spans; } // Italic thought / preamble if (trimmed.starts_with('*') && trimmed.ends_with('*') && !trimmed.starts_with("**")) || (trimmed.starts_with('_') && trimmed.ends_with('_')) || trimmed.starts_with("Report. Maybe") || trimmed.starts_with("Honest answer.") || trimmed.starts_with("Yes, briefly.") { spans.push(Span::styled( line.to_string(), Style::default() .fg(Color::Rgb(140, 140, 150)) .add_modifier(Modifier::ITALIC), )); return spans; } // Bullet point if trimmed.starts_with("- ") || trimmed.starts_with("* ") { spans.push(Span::styled("- ", Style::default().fg(Color::Cyan))); parse_inline_spans(&trimmed[2..], &mut spans); return spans; } parse_inline_spans(line, &mut spans); spans } fn parse_inline_spans(text: &str, spans: &mut Vec>) { let mut rest = text; while let Some(start) = rest.find('`') { if start > 0 { spans.push(Span::styled( rest[..start].to_string(), Style::default().fg(Color::White), )); } let after_start = &rest[start + 1..]; if let Some(end) = after_start.find('`') { let code_token = &after_start[..end]; spans.push(Span::styled( code_token.to_string(), Style::default() .fg(Color::LightCyan) .add_modifier(Modifier::BOLD), )); rest = &after_start[end + 1..]; } else { spans.push(Span::styled( rest[start..].to_string(), Style::default().fg(Color::White), )); return; } } if !rest.is_empty() { spans.push(Span::styled( rest.to_string(), Style::default().fg(Color::White), )); } } fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect, 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 = 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 = 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 = 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 = 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 = 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 = 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] }