mod confirm;
mod info_select;
mod list;
use confirm::LspInstallCtx;
use ratatui::prelude::*;
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use crate::tui::state::{PopupKind, UiState};
use crate::tui::theme::Theme;
pub(super) struct PopupListCtx<'a> {
pub(super) popup_area: Rect,
pub(super) selected: usize,
pub(super) scroll: u16,
pub(super) search: &'a str,
pub(super) theme: &'a Theme,
}
fn render_popup_chrome(
popup_area: Rect,
hint_area: Rect,
bottom_hint: &str,
theme: &Theme,
buf: &mut Buffer,
) {
Paragraph::new(" esc ")
.style(Style::default().fg(theme.text_muted))
.render(hint_area, buf);
render_bottom_hint(popup_area, buf, bottom_hint, theme.text_muted);
}
pub fn render(state: &UiState, area: Rect, buf: &mut Buffer) {
let Some(popup) = &state.popup else { return };
let theme = &state.theme;
let popup_width = (area.width as f32 * 0.80).max(50.0) as u16;
let popup_height = (area.height as f32 * 0.75).max(12.0) as u16;
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
Clear.render(popup_area, buf);
let block = Block::default()
.title(Span::styled(
format!(" {} ", popup.title),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.bg_surface));
let esc_hint = " esc ";
let hint_x = popup_area.x + popup_area.width.saturating_sub(esc_hint.len() as u16 + 1);
let hint_area = Rect::new(hint_x, popup_area.y, esc_hint.len() as u16, 1);
match &popup.kind {
PopupKind::Info => {
info_select::render_info(popup, popup_area, block, theme, buf);
render_popup_chrome(popup_area, hint_area, "↑↓ scroll", theme, buf);
}
PopupKind::Select { items, selected } => {
info_select::render_select(popup, items, *selected, popup_area, block, theme, buf);
render_popup_chrome(
popup_area,
hint_area,
"type to search ↑↓ navigate Enter apply",
theme,
buf,
);
}
PopupKind::TableSelect { items, selected } => {
let ctx = PopupListCtx {
popup_area,
selected: *selected,
scroll: popup.scroll,
search: &popup.search,
theme,
};
list::render_table_select(&ctx, buf, block, items);
render_popup_chrome(
popup_area,
hint_area,
"↑↓ navigate Enter select",
theme,
buf,
);
}
PopupKind::Config { items, selected } => {
list::render_config(
popup_area,
buf,
block,
items,
*selected,
popup.scroll,
theme,
);
render_popup_chrome(
popup_area,
hint_area,
"↑↓ navigate Space change Enter save Esc cancel",
theme,
buf,
);
}
PopupKind::QueueConfirm { pending, selected } => {
confirm::render_queue_confirm(
popup_area,
buf,
block,
pending,
*selected,
popup.scroll,
theme,
);
render_bottom_hint(
popup_area,
buf,
"↑↓ Select Enter Confirm Esc Close",
theme.text_muted,
);
}
PopupKind::ContinuationConfirm { selected } => {
confirm::render_continuation_confirm(
popup_area,
buf,
block,
&popup.content,
*selected,
theme,
);
render_bottom_hint(
popup_area,
buf,
"↑↓ Select Enter Confirm Esc Stop",
theme.text_muted,
);
}
PopupKind::InitConfirm { selected } => {
confirm::render_init_confirm(popup_area, buf, block, *selected, theme);
render_bottom_hint(
popup_area,
buf,
"↑↓ Select Enter Confirm Esc Cancel",
theme.text_muted,
);
}
PopupKind::ToolApproval {
tool_name,
tool_args,
selected,
} => {
confirm::render_tool_approval(
popup_area, buf, block, tool_name, tool_args, *selected, theme,
);
render_bottom_hint(
popup_area,
buf,
"↑↓ Select Enter Confirm",
theme.text_muted,
);
}
PopupKind::ModeApproval {
mode,
description,
selected,
} => {
confirm::render_mode_approval(
popup_area,
buf,
block,
mode,
description,
*selected,
theme,
);
render_bottom_hint(
popup_area,
buf,
"↑↓ Select Enter Confirm Esc Single agent",
theme.text_muted,
);
}
PopupKind::SessionResume { items, selected } => {
let ctx = PopupListCtx {
popup_area,
selected: *selected,
scroll: popup.scroll,
search: &popup.search,
theme,
};
list::render_session_resume(&ctx, buf, block, items);
render_popup_chrome(
popup_area,
hint_area,
"type to search ↑↓ navigate Enter resume Esc cancel",
theme,
buf,
);
}
PopupKind::ThemeSelect {
selected,
dark_mode,
} => {
list::render_theme_select(
popup_area,
buf,
block,
*selected,
*dark_mode,
popup.scroll,
theme,
);
render_popup_chrome(
popup_area,
hint_area,
"↑↓ family ← dark light → Enter apply Esc cancel",
theme,
buf,
);
}
PopupKind::LspInstall {
language,
server,
install_cmd,
selected,
} => {
confirm::render_lsp_install(
popup_area,
buf,
block,
&LspInstallCtx {
language,
server,
install_cmd,
selected: *selected,
theme,
},
);
render_bottom_hint(
popup_area,
buf,
"↑↓ select Enter confirm Esc skip",
theme.text_muted,
);
}
PopupKind::PiiWarning { findings, selected } => {
confirm::render_pii_warning(popup_area, buf, block, findings, *selected, theme);
render_popup_chrome(
popup_area,
hint_area,
"↑↓ select Enter confirm",
theme,
buf,
);
}
PopupKind::McpToggle {
items, selected, ..
} => {
let ctx = PopupListCtx {
popup_area,
selected: *selected,
scroll: popup.scroll,
search: &popup.search,
theme,
};
list::render_mcp_toggle(&ctx, buf, block, items);
render_popup_chrome(
popup_area,
hint_area,
"type to search ↑↓ Space toggle Esc close",
theme,
buf,
);
}
PopupKind::OptimizeSuggestion {
model,
session_count,
items,
selected,
action,
} => {
confirm::render_optimize_suggestion(
popup_area,
buf,
block,
&OptimizeSuggestionCtx {
model,
session_count: *session_count,
items,
selected: *selected,
action: *action,
scroll: popup.scroll,
theme,
},
);
render_popup_chrome(
popup_area,
hint_area,
"↑↓ navigate Space toggle Tab action Enter apply",
theme,
buf,
);
}
}
}
pub(super) fn truncate_str(s: &str, max_cols: usize) -> String {
use unicode_width::UnicodeWidthChar;
let mut width = 0usize;
let mut end = s.len();
let mut truncated = false;
for (i, c) in s.char_indices() {
let cw = c.width().unwrap_or(1);
if width + cw > max_cols.saturating_sub(1) {
end = i;
truncated = true;
break;
}
width += cw;
}
if truncated {
format!("{}…", &s[..end])
} else {
s.to_string()
}
}
pub(super) fn render_bottom_hint(popup_area: Rect, buf: &mut Buffer, hint: &str, color: Color) {
let hint_x = popup_area.x + 2;
let hint_y = popup_area.y + popup_area.height - 1;
let max_w = popup_area.width.saturating_sub(4);
if max_w > 0 {
let hint_area = Rect::new(hint_x, hint_y, max_w.min(hint.len() as u16), 1);
Paragraph::new(hint)
.style(Style::default().fg(color))
.render(hint_area, buf);
}
}
pub(super) fn render_info_line<'a>(line: &'a str, theme: &crate::tui::theme::Theme) -> Line<'a> {
if line.starts_with("@@") {
return Line::from(Span::styled(
line.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::DIM),
));
}
if line.starts_with("diff ") || line.starts_with("index ") {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.text_muted),
));
}
if line.starts_with("+++ ") || line.starts_with("--- ") {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.text_muted),
));
}
if line.starts_with('+') {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.diff_add_fg).bold(),
))
.style(Style::default().bg(theme.diff_add_bg));
}
if line.starts_with('-') {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.diff_remove_fg),
))
.style(Style::default().bg(theme.diff_remove_bg));
}
if let Some(rest) = line.strip_prefix("## ") {
return Line::from(Span::styled(
rest.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
));
}
if let Some(rest) = line.strip_prefix("# ") {
return Line::from(Span::styled(
rest.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
}
if line.starts_with("**") {
return Line::from(Span::styled(
line.replace("**", ""),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
));
}
if line.starts_with("---") || line.starts_with("═══") {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.text_muted),
));
}
if line.trim_start().starts_with('/') {
let trimmed = line.trim_start();
if let Some(idx) = trimmed
.char_indices()
.zip(trimmed.char_indices().skip(1))
.find(|((_, a), (_, b))| *a == ' ' && *b == ' ')
.map(|((i, _), _)| i)
{
let cmd = &trimmed[..idx];
let desc = trimmed[idx..].trim_start();
return Line::from(vec![
Span::styled(
format!(" {cmd:<22}", cmd = cmd),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(desc.to_string(), Style::default().fg(theme.text_dim)),
]);
}
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.accent),
));
}
if line.trim_start().starts_with("• ") {
let content = line.trim_start().trim_start_matches("• ");
if let Some(idx) = content.find(" ") {
let key = &content[..idx];
let val = content[idx..].trim_start();
return Line::from(vec![
Span::styled(
format!(" • {key:<20}", key = key),
Style::default().fg(theme.accent),
),
Span::styled(val.to_string(), Style::default().fg(theme.text_dim)),
]);
}
return Line::from(vec![
Span::styled(" • ", Style::default().fg(theme.accent)),
Span::styled(content.to_string(), Style::default().fg(theme.text)),
]);
}
if line.trim_start().starts_with("- ") {
return Line::from(vec![
Span::styled(" • ", Style::default().fg(theme.accent)),
Span::styled(
line.trim_start().trim_start_matches("- ").to_string(),
Style::default().fg(theme.text),
),
]);
}
if line.starts_with('+') && !line.starts_with("+++") {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.success),
));
}
if line.starts_with('-') && !line.starts_with("---") {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.error),
));
}
if line.starts_with("@@") {
return Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.info),
));
}
if line.contains(" | ") {
let parts: Vec<&str> = line.splitn(2, " | ").collect();
if parts.len() == 2 {
let right = parts[1];
let stat_chars = right
.chars()
.rev()
.take_while(|c| *c == '+' || *c == '-')
.count();
if stat_chars > 0 {
let stat_start = right.len() - stat_chars;
let count_part = &right[..stat_start];
let symbols = &right[stat_start..];
let mut spans = vec![
Span::styled(parts[0].to_string(), Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.text_muted)),
Span::styled(count_part.to_string(), Style::default().fg(theme.text_dim)),
];
for ch in symbols.chars() {
let color = if ch == '+' {
theme.success
} else {
theme.error
};
spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
}
return Line::from(spans);
}
}
}
Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme.text),
))
}
pub(super) struct OptimizeSuggestionCtx<'a> {
pub(super) model: &'a str,
pub(super) session_count: usize,
pub(super) items: &'a [crate::tui::state::OptimizeSuggestionItem],
pub(super) selected: usize,
pub(super) action: usize,
pub(super) scroll: u16,
pub(super) theme: &'a crate::tui::theme::Theme,
}
pub(super) fn render_search_bar(
area: Rect,
buf: &mut Buffer,
query: &str,
matched: usize,
total: usize,
theme: &Theme,
) {
let row = Rect::new(area.x, area.y, area.width, 1);
Paragraph::new(Span::styled(
" ".repeat(area.width as usize),
Style::default().bg(theme.bg),
))
.render(row, buf);
let cursor = if query.is_empty() { "" } else { "▌" };
let search_text = format!(" / {}{}", query, cursor);
let count_text = if query.is_empty() {
format!(" {} items ", total)
} else {
format!(" {}/{} matched ", matched, total)
};
let count_color = if matched == 0 && !query.is_empty() {
theme.error
} else {
theme.text_muted
};
let count_len = count_text.len() as u16;
let search_w = area.width.saturating_sub(count_len);
Paragraph::new(Span::styled(
truncate_str(&search_text, search_w as usize),
Style::default().fg(theme.accent),
))
.render(Rect::new(area.x, area.y, search_w, 1), buf);
let count_x = area.x + search_w;
Paragraph::new(Span::styled(count_text, Style::default().fg(count_color)))
.render(Rect::new(count_x, area.y, count_len, 1), buf);
}