Skip to main content

wisp/components/app/
plan_review_mode.rs

1use crate::components::common::render_key_hints;
2use crate::components::plan_review::{OutlinePanel, OutlinePanelMessage, PlanDocument, PlanPanel};
3use crate::components::review_comments::{CommentAnchor, ReviewComment};
4use std::fmt::Write;
5use tui::{Component, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, ViewContext};
6
7pub enum PlanReviewAction {
8    Approve,
9    RequestChanges { feedback: String },
10    Cancel,
11}
12
13pub struct PlanReviewInput {
14    pub title: String,
15    pub document: PlanDocument,
16}
17
18pub struct PlanReviewMode {
19    title: String,
20    split: SplitPanel<OutlinePanel, PlanPanel>,
21}
22
23impl PlanReviewMode {
24    pub fn new(input: PlanReviewInput) -> Self {
25        let PlanReviewInput { title, document } = input;
26        let outline_panel = OutlinePanel::new(document.outline.clone());
27        let plan_panel = PlanPanel::new(document);
28        let mut split = SplitPanel::new(outline_panel, plan_panel, SplitLayout::fraction(1, 4, 20, 32))
29            .with_separator("│", Style::default())
30            .with_resize_keys();
31
32        split.focus_right();
33        Self { title, split }
34    }
35
36    pub fn current_source_line_no(&self) -> usize {
37        self.split.right().current_source_line_no()
38    }
39
40    pub fn comment_count(&self) -> usize {
41        self.split.right().comment_count()
42    }
43}
44
45impl Component for PlanReviewMode {
46    type Message = PlanReviewAction;
47
48    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
49        if let Event::Key(key) = event
50            && !self.split.right().is_in_comment_mode()
51        {
52            match key.code {
53                KeyCode::Esc => return Some(vec![PlanReviewAction::Cancel]),
54                KeyCode::Char('a') => return Some(vec![PlanReviewAction::Approve]),
55                KeyCode::Char('r') => {
56                    let plan_panel = self.split.right();
57                    let feedback = compile_feedback(plan_panel.document(), plan_panel.comments());
58                    return Some(vec![PlanReviewAction::RequestChanges { feedback }]);
59                }
60                KeyCode::Char('u') => {
61                    self.split.right_mut().undo_last_comment();
62                    return Some(vec![]);
63                }
64                KeyCode::Char('n') => {
65                    self.split.right_mut().jump_next_heading();
66                    return Some(vec![]);
67                }
68                KeyCode::Char('p') => {
69                    self.split.right_mut().jump_prev_heading();
70                    return Some(vec![]);
71                }
72                KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
73                    self.split.focus_left();
74                    return Some(vec![]);
75                }
76                KeyCode::Char('l') | KeyCode::Right if self.split.is_left_focused() => {
77                    self.split.focus_right();
78                    return Some(vec![]);
79                }
80                _ => {}
81            }
82        }
83
84        let split_messages = self.split.on_event(event).await?;
85        for message in split_messages {
86            match message {
87                Either::Left(OutlinePanelMessage::OpenSelectedAnchor(anchor_line_no)) => {
88                    self.split.right_mut().set_cursor_source_line_no(anchor_line_no);
89                    self.split.focus_right();
90                }
91                Either::Right(message) => match message {},
92            }
93        }
94
95        Some(vec![])
96    }
97
98    fn render(&mut self, ctx: &ViewContext) -> Frame {
99        if ctx.size.width < 20 {
100            return Frame::new(vec![Line::new("Plan review view is too narrow")]);
101        }
102
103        let plan_focused = !self.split.is_left_focused();
104        let title_fg = if plan_focused { ctx.theme.accent() } else { ctx.theme.text_primary() };
105        let mut header = Line::default();
106        header.push_with_style(self.title.as_str(), Style::fg(title_fg).bold());
107
108        let body_height = ctx.size.height.saturating_sub(2);
109        let body_context = ctx.with_size((ctx.size.width, body_height));
110
111        self.split.left_mut().set_focused(!plan_focused);
112        self.split.set_separator_style(Style::fg(ctx.theme.muted()));
113        let body = self.split.render(&body_context);
114
115        let help_keys: &[(&str, &str)] = if plan_focused { &PLAN_HELP_KEYS } else { &OUTLINE_HELP_KEYS };
116        Frame::vstack([Frame::new(vec![header]), body, Frame::new(vec![render_key_hints(&ctx.theme, help_keys)])])
117    }
118}
119
120const OUTLINE_HELP_KEYS: [(&str, &str); 6] =
121    [("j/k", "move"), ("g/G", "top/bottom"), ("enter", "jump"), ("h/l", "focus"), ("u", "undo"), ("Esc", "cancel")];
122
123const PLAN_HELP_KEYS: [(&str, &str); 8] = [
124    ("j/k", "move"),
125    ("n/p", "heading"),
126    ("h", "outline"),
127    ("c", "comment"),
128    ("u", "undo"),
129    ("a", "approve"),
130    ("r", "changes"),
131    ("Esc", "cancel"),
132];
133
134fn compile_feedback(document: &PlanDocument, comments: &[ReviewComment<usize>]) -> String {
135    if comments.is_empty() {
136        return "Plan needs changes, but no inline comments were provided.".to_string();
137    }
138
139    let mut output = String::from("# Plan review feedback\n\n");
140    let mut current_section: Option<usize> = None;
141
142    for comment in comments {
143        let CommentAnchor(line_no) = comment.anchor;
144        let Some(line) = document.line_by_no(line_no) else {
145            continue;
146        };
147
148        if line.section_index != current_section {
149            if let Some(section_title) = document.section_title_for(line) {
150                let _ = writeln!(output, "## {section_title}");
151                output.push('\n');
152            }
153            current_section = line.section_index;
154        }
155
156        let _ = writeln!(output, "### Line {}", line.line_no);
157        if !line.text.trim().is_empty() {
158            let _ = writeln!(output, "`{}`", sanitize_line_snippet(&line.text));
159        }
160
161        let mut wrote_point = false;
162        for feedback_line in comment.body.lines().map(str::trim).filter(|line| !line.is_empty()) {
163            let _ = writeln!(output, "- {feedback_line}");
164            wrote_point = true;
165        }
166
167        if !wrote_point {
168            output.push_str("- (no comment text provided)\n");
169        }
170
171        output.push('\n');
172    }
173
174    if output.trim() == "# Plan review feedback" {
175        "Plan needs changes, but no inline comments were provided.".to_string()
176    } else {
177        output.trim().to_string()
178    }
179}
180
181fn sanitize_line_snippet(line: &str) -> String {
182    let mut trimmed = line.trim().replace('`', "\\`");
183    if trimmed.chars().count() > 140 {
184        trimmed = trimmed.chars().take(137).collect::<String>() + "...";
185    }
186    trimmed
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn compile_feedback_falls_back_when_no_comments() {
195        let document = PlanDocument::parse("/tmp/plan.md", "# Plan");
196        let feedback = compile_feedback(&document, &[]);
197        assert!(feedback.contains("no inline comments"));
198    }
199
200    #[test]
201    fn compile_feedback_includes_line_numbers_and_comments() {
202        let document = PlanDocument::parse("/tmp/plan.md", "# Overview\nline");
203        let comments = vec![ReviewComment::new(CommentAnchor(2), "Please expand this")];
204
205        let feedback = compile_feedback(&document, &comments);
206        assert!(feedback.contains("Line 2"));
207        assert!(feedback.contains("Please expand this"));
208    }
209}