use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::App;
pub(crate) fn render_pr_create_overlay(frame: &mut Frame, app: &App) {
let area = centered_rect(60, 60, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Create Pull Request ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Length(1), Constraint::Min(5), Constraint::Length(3), ])
.split(inner);
let title_style = if !app.pr_create_state.editing_body {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
let title_block = Block::default()
.title(" Title ")
.borders(Borders::ALL)
.border_style(title_style);
let title_text = Paragraph::new(app.pr_create_state.title.as_str()).block(title_block);
frame.render_widget(title_text, chunks[0]);
let body_style = if app.pr_create_state.editing_body {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
let body_block = Block::default()
.title(" Body ")
.borders(Borders::ALL)
.border_style(body_style);
let body_text = Paragraph::new(app.pr_create_state.body.as_str())
.block(body_block)
.wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(body_text, chunks[2]);
let gh_status = if app.pr_create_state.gh_available {
Span::styled("gh: ✓", Style::default().fg(Color::Green))
} else {
Span::styled("gh: ✗", Style::default().fg(Color::Red))
};
let actions = Line::from(vec![
Span::raw(" Tab: switch field | Enter: create | Esc: cancel | "),
gh_status,
]);
let actions_widget = Paragraph::new(actions).alignment(Alignment::Center);
frame.render_widget(actions_widget, chunks[3]);
}
pub(crate) fn render_review_queue_overlay(frame: &mut Frame, app: &App) {
let area = centered_rect(70, 70, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Review Queue ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Min(5), Constraint::Length(3), ])
.split(inner);
let items: Vec<ListItem> = app
.review_queue_view
.cache
.as_ref()
.map(|queue| {
queue
.items
.iter()
.enumerate()
.map(|(i, item)| {
let status_icon = match item.status {
crate::review_queue::ReviewStatus::Pending => "○",
crate::review_queue::ReviewStatus::Approved => "✓",
crate::review_queue::ReviewStatus::Rejected => "✗",
};
let style = if i == app.review_queue_view.nav.selected_index {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::styled(
format!(" {} ", status_icon),
Style::default().fg(match item.status {
crate::review_queue::ReviewStatus::Pending => Color::Yellow,
crate::review_queue::ReviewStatus::Approved => Color::Green,
crate::review_queue::ReviewStatus::Rejected => Color::Red,
}),
),
Span::styled(
format!("[{}] ", &item.commit_hash[..7.min(item.commit_hash.len())]),
Style::default().fg(Color::Cyan),
),
Span::styled(
item.review_points.first().cloned().unwrap_or_default(),
style,
),
]))
})
.collect()
})
.unwrap_or_default();
if items.is_empty() {
let empty = Paragraph::new(" No review items").style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, chunks[0]);
} else {
let list = List::new(items);
frame.render_widget(list, chunks[0]);
}
let actions = Paragraph::new(" j/k: navigate | a: approve | x: reject | Esc: close")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(actions, chunks[1]);
}
pub(crate) fn render_review_pack_view_overlay(frame: &mut Frame, app: &App) {
let area = centered_rect(80, 80, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Review Pack ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(4), Constraint::Min(5), Constraint::Length(2), ])
.split(inner);
if let Some(ref pack) = app.review_pack_view.cache {
let verdict_str = app
.review_pack_view
.verdict
.as_ref()
.and_then(|v| v.get("verdict"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let verdict_color = match verdict_str {
"low_risk" => Color::Green,
"needs_review" => Color::Yellow,
"high_risk" => Color::Red,
_ => Color::White,
};
let risk_bar_len = (pack.risk_score * 20.0).round() as usize;
let risk_bar = format!(
"[{}{}]",
"#".repeat(risk_bar_len),
"-".repeat(20 - risk_bar_len)
);
let summary_lines = vec![
Line::from(vec![
Span::styled(" Repo: ", Style::default().fg(Color::DarkGray)),
Span::raw(&pack.repo),
Span::raw(" "),
Span::styled("Branch: ", Style::default().fg(Color::DarkGray)),
Span::raw(&pack.branch),
Span::raw(" "),
Span::styled("HEAD: ", Style::default().fg(Color::DarkGray)),
Span::styled(&pack.head, Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" Risk: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:.2}", pack.risk_score),
Style::default().fg(verdict_color),
),
Span::raw(" "),
Span::styled(risk_bar, Style::default().fg(verdict_color)),
Span::raw(" "),
Span::styled("Conf: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{:.2}", pack.confidence)),
Span::raw(" "),
Span::styled("Verdict: ", Style::default().fg(Color::DarkGray)),
Span::styled(
verdict_str,
Style::default()
.fg(verdict_color)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Summary: ", Style::default().fg(Color::DarkGray)),
Span::raw(&pack.summary),
]),
];
let summary = Paragraph::new(summary_lines);
frame.render_widget(summary, chunks[0]);
let mut items: Vec<ListItem> = Vec::new();
let mut current_idx = 0;
if !pack.top_risks.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
"── Top Risks ──",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
current_idx += 1;
for risk in &pack.top_risks {
let sev_color = match risk.severity.as_str() {
"high" => Color::Red,
"medium" => Color::Yellow,
_ => Color::Green,
};
let selected = current_idx == app.review_pack_view.nav.selected_index + 1;
let style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
items.push(ListItem::new(Line::from(vec![
Span::styled(
format!(" [{}] ", risk.severity.to_uppercase()),
Style::default().fg(sev_color).add_modifier(Modifier::BOLD),
),
Span::styled(&risk.title, style),
Span::styled(
format!(" - {}", risk.details),
Style::default().fg(Color::DarkGray),
),
])));
current_idx += 1;
}
}
if !pack.test_gaps.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
"── Test Gaps ──",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
))));
current_idx += 1;
for gap in &pack.test_gaps {
let selected = current_idx == app.review_pack_view.nav.selected_index + 1;
let style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Magenta)
};
items.push(ListItem::new(Line::from(Span::styled(
format!(" - {}", gap),
style,
))));
current_idx += 1;
}
}
if !pack.recommended_actions.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
"── Recommended Actions ──",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
))));
current_idx += 1;
for action in &pack.recommended_actions {
let pri_color = match action.priority.as_str() {
"high" => Color::Red,
"medium" => Color::Yellow,
_ => Color::Green,
};
let selected = current_idx == app.review_pack_view.nav.selected_index + 1;
let style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
items.push(ListItem::new(Line::from(vec![
Span::styled(
format!(" [{}] ", action.priority),
Style::default().fg(pri_color),
),
Span::styled(&action.title, style),
Span::styled(
format!(" - {}", action.reason),
Style::default().fg(Color::DarkGray),
),
])));
current_idx += 1;
}
}
if !pack.owner_candidates.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
"── Owner Candidates ──",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
))));
for candidate in &pack.owner_candidates {
items.push(ListItem::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(&candidate.author, Style::default().fg(Color::Cyan)),
Span::styled(
format!(" ({:.0}%) ", candidate.ownership_percent * 100.0),
Style::default().fg(Color::DarkGray),
),
Span::raw(&candidate.path),
])));
}
}
let list = List::new(items);
frame.render_widget(list, chunks[1]);
} else {
let empty =
Paragraph::new(" No review pack data").style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, chunks[1]);
}
let actions = Paragraph::new(" j/k: scroll | Esc/q: close")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(actions, chunks[2]);
}
pub(crate) fn render_next_actions_view_overlay(frame: &mut Frame, app: &App) {
let area = centered_rect(70, 70, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Next Actions ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Min(5), Constraint::Length(2), ])
.split(inner);
if let Some(ref actions) = app.next_actions_view.cache {
let items: Vec<ListItem> = actions
.iter()
.enumerate()
.map(|(i, action)| {
let pri_color = match action.priority.as_str() {
"high" => Color::Red,
"medium" => Color::Yellow,
_ => Color::Green,
};
let selected = i == app.next_actions_view.nav.selected_index;
let style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(
format!(" {:>6} ", action.priority),
Style::default().fg(pri_color).add_modifier(Modifier::BOLD),
),
Span::styled(&action.title, style),
Span::styled(
format!(" {}", action.reason),
Style::default().fg(Color::DarkGray),
),
];
if let Some(ref hint) = action.command_hint {
spans.push(Span::styled(
format!(" [{}]", hint),
Style::default().fg(Color::Cyan),
));
}
ListItem::new(Line::from(spans))
})
.collect();
if items.is_empty() {
let empty = Paragraph::new(" No recommended actions")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, chunks[0]);
} else {
let list = List::new(items);
frame.render_widget(list, chunks[0]);
}
} else {
let empty = Paragraph::new(" No action data").style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, chunks[0]);
}
let footer = Paragraph::new(" j/k: scroll | Esc/q: close")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(footer, chunks[1]);
}
pub(crate) fn render_handoff_view_overlay(frame: &mut Frame, app: &App) {
let area = centered_rect(75, 75, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" AI Handoff ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(2), Constraint::Min(5), Constraint::Length(2), ])
.split(inner);
if let Some(ref ctx) = app.handoff_view.cache {
let target_color = match ctx.target.as_str() {
"claude" => Color::Magenta,
"codex" => Color::Green,
"copilot" => Color::Blue,
_ => Color::White,
};
let header = Paragraph::new(Line::from(vec![
Span::styled(" Target: ", Style::default().fg(Color::DarkGray)),
Span::styled(
ctx.target.to_uppercase(),
Style::default()
.fg(target_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Generated: ", Style::default().fg(Color::DarkGray)),
Span::raw(&ctx.generated_at),
]));
frame.render_widget(header, chunks[0]);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
"── Prompt ──",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
for prompt_line in ctx.prompt.lines() {
lines.push(Line::from(Span::styled(
format!(" {}", prompt_line),
Style::default().fg(Color::White),
)));
}
if !ctx.next_actions.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"── Next Actions ──",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)));
for (i, action) in ctx.next_actions.iter().enumerate() {
let pri_color = match action.priority.as_str() {
"high" => Color::Red,
"medium" => Color::Yellow,
_ => Color::Green,
};
let selected = i + 1 == app.handoff_view.nav.selected_index;
let style = if selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(
format!(" [{}] ", action.priority),
Style::default().fg(pri_color),
),
Span::styled(&action.title, style),
Span::styled(
format!(" {}", action.reason),
Style::default().fg(Color::DarkGray),
),
]));
}
}
let content = Paragraph::new(lines)
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((app.handoff_view.nav.scroll_offset as u16, 0));
frame.render_widget(content, chunks[1]);
} else {
let empty = Paragraph::new(" No handoff data").style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, chunks[1]);
}
let footer = Paragraph::new(" j/k: scroll | y: copy to clipboard | Esc/q: close")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(footer, chunks[2]);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}