use crate::theme::Theme;
use agent_client_protocol::schema::v2::{PlanEntry, PlanEntryStatus};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
const CHECKBOX_EMPTY: &str = "\u{2610}";
const CHECKBOX_FILLED: &str = "\u{2611}";
const SQUARE_FILLED: &str = "\u{25A0}";
pub(crate) struct PlanView<'a> {
entries: &'a [PlanEntry],
theme: &'a Theme,
}
impl<'a> PlanView<'a> {
pub(crate) fn new(entries: &'a [PlanEntry], theme: &'a Theme) -> Self {
Self { entries, theme }
}
pub(crate) fn line_count(&self) -> usize {
if self.entries.is_empty() { 0 } else { self.entries.len() + 2 }
}
}
impl Widget for PlanView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.entries.is_empty() || area.height == 0 {
return;
}
let lines = build_plan_lines(self.entries, self.theme);
Paragraph::new(lines).render(area, buf);
}
}
fn build_plan_lines(entries: &[PlanEntry], theme: &Theme) -> Vec<Line<'static>> {
let header_style = Style::from(theme.muted);
let mut lines = Vec::with_capacity(entries.len() + 2);
lines.push(Line::default());
lines.push(Line::from(Span::styled("Plan", header_style)));
let crossed_out = Style::from((theme.muted, Modifier::CROSSED_OUT));
for entry in entries {
let (glyph, color, style) = match &entry.status {
PlanEntryStatus::Completed => (CHECKBOX_FILLED, theme.muted, crossed_out),
PlanEntryStatus::Cancelled => ("×", theme.muted, crossed_out),
PlanEntryStatus::InProgress => (SQUARE_FILLED, theme.info, Style::from(theme.text_primary)),
_ => (CHECKBOX_EMPTY, theme.muted, Style::from(theme.muted)),
};
lines.push(Line::from(vec![
Span::styled(format!(" {glyph} "), color),
Span::styled(entry.content.clone(), style),
]));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client_protocol::schema::v2::{PlanEntryPriority, PlanEntryStatus};
fn test_theme() -> Theme {
Theme::default()
}
fn entry(content: &str, status: PlanEntryStatus) -> PlanEntry {
PlanEntry::new(content.to_string(), PlanEntryPriority::Medium, status)
}
#[test]
fn native_cancelled_status_renders_as_terminal() {
let theme = Theme::default();
for (status, glyph) in [(PlanEntryStatus::Cancelled, "×"), (PlanEntryStatus::Other("future".into()), "☐")] {
let entries = vec![entry("Task", status)];
let area = Rect::new(0, 0, 30, 3);
let mut buffer = Buffer::empty(area);
PlanView::new(&entries, &theme).render(area, &mut buffer);
assert_eq!(buffer[(2, 2)].symbol(), glyph);
}
}
#[test]
fn empty_entries_render_nothing() {
let theme = test_theme();
assert_eq!(PlanView::new(&[], &theme).line_count(), 0);
}
#[test]
fn line_count_matches_entries_plus_header() {
let theme = test_theme();
let entries = vec![entry("Task", PlanEntryStatus::Pending)];
assert_eq!(PlanView::new(&entries, &theme).line_count(), 3);
assert_eq!(PlanView::new(&[], &theme).line_count(), 0);
}
#[test]
fn renders_header_and_entries() {
let theme = test_theme();
let entries = vec![
entry("Research", PlanEntryStatus::Completed),
entry("Implement", PlanEntryStatus::InProgress),
entry("Test", PlanEntryStatus::Pending),
];
let view = PlanView::new(&entries, &theme);
assert_eq!(view.line_count(), 5);
}
}