Some checks are pending
CI / Test (macos-latest) (push) Waiting to run
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run
1702 lines
62 KiB
Rust
1702 lines
62 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::Settings => render_settings_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 render_settings_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) {
|
||
let area = centered_rect(68, 62, frame.area());
|
||
frame.render_widget(Clear, area);
|
||
|
||
let setting_items = [
|
||
(
|
||
"● Active Theme",
|
||
format!("{:<14} (enter/left/right cycle)", app.current_theme),
|
||
),
|
||
(
|
||
"● Stealth Candidates",
|
||
if app.allow_stealth {
|
||
"[ENABLED] (experimental free models included)".to_string()
|
||
} else {
|
||
"[DISABLED] (only catalog models)".to_string()
|
||
},
|
||
),
|
||
(
|
||
"● Complexity Escalation",
|
||
if app.escalate {
|
||
"[ENABLED] (escalate to paid on heavy tasks)".to_string()
|
||
} else {
|
||
"[DISABLED] (always prefer free models)".to_string()
|
||
},
|
||
),
|
||
("● Complexity Threshold", {
|
||
let filled = (app.complexity_threshold as usize).min(10);
|
||
let empty = 10 - filled;
|
||
format!(
|
||
"[{}{}] {} / 10 (left/right adjust)",
|
||
"■".repeat(filled),
|
||
"□".repeat(empty),
|
||
app.complexity_threshold
|
||
)
|
||
}),
|
||
(
|
||
"● Local Tool Execution",
|
||
if app.tool_execution_enabled {
|
||
"[AUTO-EXECUTE] (run <run> commands locally)".to_string()
|
||
} else {
|
||
"[DISABLED] (commands rendered only)".to_string()
|
||
},
|
||
),
|
||
(
|
||
"● Tool Output View",
|
||
if app.expand_tool_outputs {
|
||
"[EXPANDED] (full stdout/stderr shown)".to_string()
|
||
} else {
|
||
"[COLLAPSED] (compact with ctrl+o expand)".to_string()
|
||
},
|
||
),
|
||
(
|
||
"● Active Persona",
|
||
format!("{:<14} (enter/space to cycle)", app.current_persona_name()),
|
||
),
|
||
];
|
||
|
||
let items: Vec<ListItem> = setting_items
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(idx, (name, val))| {
|
||
let is_selected = idx == app.selected_setting_idx;
|
||
let prefix = if is_selected { "→ " } else { " " };
|
||
|
||
let line = Line::from(vec![
|
||
Span::styled(
|
||
prefix,
|
||
if is_selected {
|
||
Style::default().fg(palette.accent).bold()
|
||
} else {
|
||
Style::default().fg(Color::DarkGray)
|
||
},
|
||
),
|
||
Span::styled(
|
||
format!("{:<26} ", name),
|
||
if is_selected {
|
||
Style::default().fg(palette.accent).bold()
|
||
} else {
|
||
Style::default().fg(palette.text_bright)
|
||
},
|
||
),
|
||
Span::styled(
|
||
val.clone(),
|
||
if is_selected {
|
||
Style::default().fg(palette.text_bright).bold()
|
||
} else {
|
||
Style::default().fg(palette.text_muted)
|
||
},
|
||
),
|
||
]);
|
||
|
||
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(
|
||
" settings [up/down navigate, enter/space toggle, left/right adjust, esc close] ",
|
||
Style::default().fg(palette.accent).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]
|
||
}
|