brokecrew/src/tui/ui.rs
oiupoyt b1aac41a56
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
feat: interactive /settings modal and quick /p persona command with fuzzy prefix matching
2026-09-30 07:02:17 +05:30

1702 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]
}