Skip to main content

wisp/components/
plan_view.rs

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