use ratatui::prelude::*;
use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthChar;
use crate::tui::state::{AcKind, MentionAc, UiState};
use crate::tui::theme::Theme;
const MAX_VISIBLE: usize = 10;
pub const PROMPT_WIDTH: u16 = 2;
pub fn render(state: &UiState, area: Rect, buf: &mut Buffer) {
let theme = &state.theme;
ratatui::widgets::Block::default()
.style(Style::default().bg(theme.bg))
.render(area, buf);
if area.height > 0 {
let sep_area = Rect { height: 1, ..area };
let sep = "─".repeat(area.width as usize);
let sep_color = if state.agent_busy {
theme.border_busy
} else {
theme.border
};
Paragraph::new(Span::styled(sep, Style::default().fg(sep_color).dim()))
.render(sep_area, buf);
if area.y > 0 {
let approve_label = state.approve_mode.to_uppercase();
let badge_text = format!(" {} ", approve_label);
let badge_style = Style::default().fg(theme.text_muted).dim();
for (i, ch) in badge_text.chars().enumerate() {
let x = area.x + i as u16;
if x < area.x + area.width {
let cell = &mut buf[(x, area.y - 1)];
cell.set_char(ch);
cell.set_style(badge_style);
}
}
}
}
if area.height > 1 {
let input_y = area.y + 1;
let (prompt_span, prompt_color) = if state.agent_busy {
(
Span::styled("~ ", Style::default().fg(theme.status_busy_bg).bold()),
theme.status_busy_bg,
)
} else if state.shell_mode {
(
Span::styled("! ", Style::default().fg(theme.warning).bold()),
theme.warning,
)
} else {
(
Span::styled("> ", Style::default().fg(theme.accent).bold()),
theme.accent,
)
};
let _ = prompt_color;
let display_text =
if state.input.is_empty() && state.pending_images.is_empty() && !state.agent_busy {
if state.shell_mode {
"Shell command…".to_string()
} else {
"Message…".to_string()
}
} else {
state.input_display()
};
let text_style = if state.input.is_empty() && !state.agent_busy {
Style::default().fg(theme.text_muted)
} else {
Style::default().fg(theme.text)
};
let prompt_area = Rect::new(area.x, input_y, PROMPT_WIDTH, 1);
Paragraph::new(prompt_span).render(prompt_area, buf);
let text_x = area.x + PROMPT_WIDTH;
let text_w = area.width.saturating_sub(PROMPT_WIDTH);
let text_h = area.height.saturating_sub(1).max(1); if text_w > 0 {
let text_area = Rect::new(text_x, input_y, text_w, text_h);
let tw = text_w as usize;
let mut visual_lines: Vec<Vec<Span>> = vec![vec![]];
let mut col = 0usize;
for ch in display_text.chars() {
if ch == '\n' {
visual_lines.push(vec![]);
col = 0;
} else {
let w = ch.width().unwrap_or(1);
if tw > 0 && col + w > tw {
visual_lines.push(vec![]);
col = 0;
}
col += w;
let last = visual_lines
.last_mut()
.expect("initialized with one element");
if let Some(span) = last.last_mut() {
let mut s = span.content.to_string();
s.push(ch);
*span = Span::styled(s, text_style);
} else {
last.push(Span::styled(String::from(ch), text_style));
}
}
}
if let Some(ref hint) = state.input_hint {
let last = visual_lines
.last_mut()
.expect("initialized with one element");
last.push(Span::styled(
hint.clone(),
Style::default().fg(theme.text_muted).dim(),
));
}
let lines: Vec<Line> = visual_lines.into_iter().map(Line::from).collect();
Paragraph::new(lines).render(text_area, buf);
}
}
if let Some(ref ac) = state.mention_ac {
render_ac_popup(ac, area, buf, theme, theme.accent);
} else if let Some(ref ac) = state.command_ac {
render_ac_popup(ac, area, buf, theme, theme.success);
}
}
fn render_ac_popup(ac: &MentionAc, area: Rect, buf: &mut Buffer, theme: &Theme, accent: Color) {
let count = ac.candidates.len();
let visible = count.min(MAX_VISIBLE);
let height = visible as u16 + 2;
if area.y < height {
return;
}
let popup = Rect {
x: area.x,
y: area.y.saturating_sub(height),
width: area.width.min(64),
height,
};
let scroll_start = if ac.selected >= MAX_VISIBLE {
ac.selected - MAX_VISIBLE + 1
} else {
0
};
let end = (scroll_start + visible).min(count);
let selected_in_window = ac.selected.saturating_sub(scroll_start);
let title = if count > MAX_VISIBLE {
format!(
" {} ({}/{}) ↑↓ ",
if ac.prefix.is_empty() {
">"
} else {
&ac.prefix
},
ac.selected + 1,
count
)
} else {
format!(
" {} ",
if ac.prefix.is_empty() {
">"
} else {
&ac.prefix
}
)
};
Clear.render(popup, buf);
let items: Vec<ListItem> = ac.candidates[scroll_start..end]
.iter()
.enumerate()
.map(|(i, c)| {
let icon = match c.kind {
AcKind::Agent => " ",
AcKind::Dir => " ",
AcKind::File => " ",
AcKind::Command => " ",
AcKind::Skill => " ",
};
let text = format!("{icon}{}", c.label);
if i == selected_in_window {
ListItem::new(text).style(
Style::default()
.fg(theme.status_fg)
.bg(accent)
.add_modifier(Modifier::BOLD),
)
} else {
ListItem::new(text).style(Style::default().fg(theme.text))
}
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(selected_in_window));
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(title),
)
.style(Style::default().bg(theme.bg_surface));
ratatui::widgets::StatefulWidget::render(list, popup, buf, &mut list_state);
}