Skip to main content

wisp/components/
plan_view.rs

1use agent_client_protocol::{PlanEntry, PlanEntryStatus};
2
3use tui::{FitOptions, Frame, Line, Style, ViewContext};
4
5const CHECKBOX_EMPTY: &str = "\u{2610}"; // Ballot Box
6const CHECKBOX_FILLED: &str = "\u{2611}"; // Ballot Box with Check
7
8/// Renders the agent's task plan as a compact checklist.
9///
10/// ```text
11/// Plan
12///   ☑ ~~Research AI agent patterns~~
13///   ☑ Implement task tracking
14///   ☐ Write integration tests
15/// ```
16pub struct PlanView<'a> {
17    pub entries: &'a [PlanEntry],
18}
19
20impl PlanView<'_> {
21    pub fn render(&self, context: &ViewContext) -> Frame {
22        if self.entries.is_empty() {
23            return Frame::empty();
24        }
25
26        let mut lines = Vec::with_capacity(self.entries.len() + 2);
27        lines.push(Line::default());
28
29        let mut header = Line::default();
30        header.push_styled("Plan".to_string(), context.theme.muted());
31        lines.push(header);
32
33        for entry in self.entries {
34            let mut line = Line::default();
35            match entry.status {
36                PlanEntryStatus::Completed => {
37                    line.push_styled(format!("  {CHECKBOX_FILLED} "), context.theme.muted());
38                    let completed_style = Style::fg(context.theme.muted()).strikethrough();
39                    line.push_with_style(entry.content.clone(), completed_style);
40                }
41                PlanEntryStatus::InProgress => {
42                    line.push_styled(format!("  {CHECKBOX_FILLED} "), context.theme.primary());
43                    line.push_text(entry.content.clone());
44                }
45                _ => {
46                    line.push_styled(format!("  {CHECKBOX_EMPTY} "), context.theme.muted());
47                    line.push_styled(entry.content.clone(), context.theme.muted());
48                }
49            }
50            lines.push(line);
51        }
52
53        Frame::new(lines).fit(context.size.width, FitOptions::wrap())
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use agent_client_protocol::{PlanEntry, PlanEntryPriority, PlanEntryStatus};
61
62    fn ctx() -> ViewContext {
63        ViewContext::new((80, 24))
64    }
65
66    fn entry(content: &str, status: PlanEntryStatus) -> PlanEntry {
67        PlanEntry::new(content.to_string(), PlanEntryPriority::Medium, status)
68    }
69
70    #[test]
71    fn empty_entries_render_nothing() {
72        let view = PlanView { entries: &[] };
73        assert!(view.render(&ctx()).lines().is_empty());
74    }
75
76    #[test]
77    fn renders_header_plus_entries() {
78        let entries = vec![
79            entry("Research", PlanEntryStatus::Completed),
80            entry("Implement", PlanEntryStatus::InProgress),
81            entry("Test", PlanEntryStatus::Pending),
82        ];
83        let view = PlanView { entries: &entries };
84        let frame = view.render(&ctx());
85        let lines = frame.lines();
86        assert_eq!(lines.len(), 5);
87        assert_eq!(lines[0].plain_text(), "");
88        assert_eq!(lines[1].plain_text(), "Plan");
89    }
90
91    #[test]
92    fn completed_entry_has_filled_checkbox() {
93        let entries = vec![entry("Done task", PlanEntryStatus::Completed)];
94        let view = PlanView { entries: &entries };
95        let frame = view.render(&ctx());
96        let text = frame.lines()[2].plain_text();
97        assert!(text.contains(CHECKBOX_FILLED));
98        assert!(text.contains("Done task"));
99    }
100
101    #[test]
102    fn completed_entry_has_strikethrough() {
103        let entries = vec![entry("Done task", PlanEntryStatus::Completed)];
104        let view = PlanView { entries: &entries };
105        let frame = view.render(&ctx());
106        let spans = frame.lines()[2].spans();
107        let text_span = &spans[1];
108        assert!(text_span.style().strikethrough);
109    }
110
111    #[test]
112    fn in_progress_entry_has_filled_checkbox() {
113        let entries = vec![entry("Working", PlanEntryStatus::InProgress)];
114        let view = PlanView { entries: &entries };
115        let frame = view.render(&ctx());
116        let text = frame.lines()[2].plain_text();
117        assert!(text.contains(CHECKBOX_FILLED));
118        assert!(text.contains("Working"));
119    }
120
121    #[test]
122    fn pending_entry_has_empty_checkbox() {
123        let entries = vec![entry("Todo", PlanEntryStatus::Pending)];
124        let view = PlanView { entries: &entries };
125        let frame = view.render(&ctx());
126        let text = frame.lines()[2].plain_text();
127        assert!(text.contains(CHECKBOX_EMPTY));
128        assert!(text.contains("Todo"));
129    }
130}